import { describe, test, expect, mock } from 'bun:test'

import {
  detectTextDirection,
  resolveTextDirection,
  SceneGraph,
  SkiaRenderer as SkiaRendererClass
} from '@open-pencil/core'

import { initCanvasKit } from '#cli/headless'
import type { SkiaRenderer } from '#core/canvas/renderer'
import { renderText } from '#core/canvas/scene'
import { buildParagraph } from '#core/canvas/text'
import type { SceneNode } from '#core/scene-graph'
import { fontManager } from '#core/text/fonts'

import { expectDefined } from '#tests/helpers/assert'
import { repoPath } from '#tests/helpers/paths'

function createMockCanvas() {
  return {
    drawParagraph: mock(() => undefined),
    drawPicture: mock(() => undefined),
    drawText: mock(() => undefined),
    drawRect: mock(() => undefined),
    save: mock(() => undefined),
    saveLayer: mock(() => undefined),
    restore: mock(() => undefined),
    clipRect: mock(() => undefined)
  }
}

function createMockParagraph() {
  return { delete: mock(() => undefined) }
}

function createMockPicture() {
  return { delete: mock(() => undefined) }
}

function createMockRenderer(overrides: Partial<Record<string, unknown>> = {}) {
  const paragraph = createMockParagraph()
  return {
    fontsLoaded: true,
    fontProvider: {},
    textFont: {},
    fillPaint: { getColor: () => new Float32Array([0, 0, 0, 1]) },
    effectLayerPaint: {
      setBlendMode: mock(() => undefined),
      setColorFilter: mock(() => undefined),
      setImageFilter: mock(() => undefined)
    },
    ck: {
      MakePicture: mock(() => createMockPicture()),
      LTRBRect: mock((...args: number[]) => args),
      Color4f: mock((...args: number[]) => new Float32Array(args)),
      BlendMode: { SrcOver: 0, SrcIn: 1 },
      ClipOp: { Intersect: 0 }
    },
    DEFAULT_FONT_SIZE: 14,
    isNodeFontLoaded: mock(() => true),
    buildParagraph: mock(() => paragraph),
    _paragraph: paragraph,
    ...overrides
  } as SkiaRenderer & { _paragraph: ReturnType<typeof createMockParagraph> }
}

function textNode(overrides: Partial<SceneNode> = {}): SceneNode {
  return {
    text: 'Hello 你好',
    fontSize: 16,
    fontFamily: 'Arial',
    fontWeight: 400,
    italic: false,
    letterSpacing: 0,
    lineHeight: null,
    textAlignHorizontal: 'LEFT',
    textAlignVertical: 'TOP',
    textAutoResize: 'NONE',
    textDecoration: 'NONE',
    textDirection: 'AUTO',
    styleRuns: [],
    ...overrides
  } as SceneNode
}

async function createTextRenderer() {
  const ck = await initCanvasKit()
  const surface = expectDefined(ck.MakeSurface(400, 120), 'surface')
  const renderer = new SkiaRendererClass(ck, surface)
  return { renderer, surface }
}

describe('renderText', () => {
  test('uses buildParagraph when fonts are loaded and node font is available', () => {
    const r = createMockRenderer()
    const canvas = createMockCanvas()

    renderText(r, canvas as never, textNode())

    expect(r.buildParagraph).toHaveBeenCalledTimes(1)
    expect(canvas.drawParagraph).toHaveBeenCalledTimes(1)
    expect(canvas.drawText).not.toHaveBeenCalled()
    expect(r._paragraph.delete).toHaveBeenCalledTimes(1)
  })

  test('skips text while the node font is not available', () => {
    const r = createMockRenderer({ isNodeFontLoaded: mock(() => false) })
    const canvas = createMockCanvas()

    renderText(r, canvas as never, textNode())

    expect(r.buildParagraph).not.toHaveBeenCalled()
    expect(canvas.drawParagraph).not.toHaveBeenCalled()
    expect(canvas.drawText).not.toHaveBeenCalled()
  })

  test('renders gradient text through a paragraph mask', () => {
    const r = createMockRenderer()
    const canvas = createMockCanvas()

    renderText(r, canvas as never, textNode(), {
      type: 'GRADIENT_LINEAR',
      visible: true,
      opacity: 1,
      gradientStops: [],
      gradientTransform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
    })

    expect(r.buildParagraph).toHaveBeenCalledTimes(1)
    expect(canvas.saveLayer).toHaveBeenCalledTimes(2)
    expect(canvas.drawParagraph).toHaveBeenCalledTimes(1)
    expect(canvas.drawRect).toHaveBeenCalledTimes(1)
    expect(r.effectLayerPaint.setBlendMode).toHaveBeenCalledWith(r.ck.BlendMode.SrcIn)
    expect(r._paragraph.delete).toHaveBeenCalledTimes(1)
  })

  test('prefers textPicture over paragraph', () => {
    const r = createMockRenderer()
    const canvas = createMockCanvas()
    const node = textNode({ textPicture: new Uint8Array([1, 2, 3]) })

    renderText(r, canvas as never, node)

    expect(canvas.drawPicture).toHaveBeenCalledTimes(1)
    expect(r.buildParagraph).not.toHaveBeenCalled()
  })

  test('falls back to drawText only when fonts are NOT loaded', () => {
    const r = createMockRenderer({ fontsLoaded: false, fontProvider: null })
    const canvas = createMockCanvas()

    renderText(r, canvas as never, textNode())

    expect(canvas.drawText).toHaveBeenCalledTimes(1)
    expect(r.buildParagraph).not.toHaveBeenCalled()
  })

  test('does nothing for empty text', () => {
    const r = createMockRenderer()
    const canvas = createMockCanvas()

    renderText(r, canvas as never, textNode({ text: '' }))

    expect(r.buildParagraph).not.toHaveBeenCalled()
    expect(canvas.drawText).not.toHaveBeenCalled()
    expect(canvas.drawPicture).not.toHaveBeenCalled()
  })
})

describe('paragraph font weights', () => {
  test('bold Inter paragraph is wider than regular Inter', async () => {
    const { renderer, surface } = await createTextRenderer()
    await renderer.loadFonts()
    const regular = await Bun.file(repoPath('public/Inter-Regular.ttf')).arrayBuffer()
    const bold = await Bun.file(repoPath('public/Inter-Bold.ttf')).arrayBuffer()
    fontManager.markLoaded('Inter', 'Regular', regular)
    fontManager.markLoaded('Inter', 'Bold', bold)

    const base = textNode({
      text: 'World largest design',
      fontFamily: 'Inter',
      fontSize: 64,
      width: 1000,
      height: 100,
      fontWeight: 400,
      italic: false
    })
    const regularParagraph = buildParagraph(renderer, base)
    const boldParagraph = buildParagraph(renderer, { ...base, fontWeight: 700 })

    expect(boldParagraph.getLongestLine()).toBeGreaterThan(regularParagraph.getLongestLine())
    regularParagraph.delete()
    boldParagraph.delete()
    surface.delete()
  })
})

describe('renderText headless visual', () => {
  test('detects base direction for Arabic and mixed text', () => {
    expect(detectTextDirection('مرحبا')).toBe('RTL')
    expect(resolveTextDirection('AUTO', 'مرحبا world')).toBe('RTL')
    expect(resolveTextDirection('AUTO', 'Hello مرحبا')).toBe('LTR')
    expect(resolveTextDirection('RTL', 'Hello')).toBe('RTL')
  })

  test('renders CJK text via fallback font through paragraph shaper', async () => {
    const ck = await initCanvasKit()
    const fontProvider = ck.TypefaceFontProvider.Make()
    fontManager.attachProvider(ck, fontProvider)

    const interData = await Bun.file('public/Inter-Regular.ttf').arrayBuffer()
    fontProvider.registerFont(interData, 'Inter')
    fontManager.markLoaded('Inter', 'Regular', interData)

    const notoPath = repoPath('tests/fixtures/fonts/NotoSansSC-Regular.ttf')
    const notoData = await Bun.file(notoPath).arrayBuffer()
    fontProvider.registerFont(notoData, 'Noto Sans SC')
    fontManager.setCJKFallbackFamily('Noto Sans SC')

    const graph = new SceneGraph()
    const page = graph.getPages()[0]
    const node = graph.createNode('TEXT', page.id, {
      text: '你好世界',
      fontFamily: 'Inter',
      fontSize: 32,
      fontWeight: 400,
      width: 200,
      height: 50,
      fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
    })

    const surface = expectDefined(ck.MakeSurface(200, 50), 'CanvasKit surface')
    const renderer = new SkiaRendererClass(ck, surface)
    renderer.viewportWidth = 200
    renderer.viewportHeight = 50
    renderer.dpr = 1
    renderer.fontsLoaded = true
    renderer.fontProvider = fontProvider

    const canvas = surface.getCanvas()
    canvas.clear(ck.WHITE)
    renderText(renderer, canvas, expectDefined(graph.getNode(node.id), 'text node'))
    surface.flush()

    const image = surface.makeImageSnapshot()
    const encoded = expectDefined(image.encodeToBytes(ck.ImageFormat.PNG, 100), 'encoded PNG')
    image.delete()
    surface.delete()

    expect(encoded.length).toBeGreaterThan(200)

    const decodedImage = expectDefined(ck.MakeImageFromEncoded(encoded), 'decoded PNG image')
    const pixels = decodedImage.readPixels(0, 0, {
      width: 200,
      height: 50,
      colorType: ck.ColorType.RGBA_8888,
      alphaType: ck.AlphaType.Unpremul,
      colorSpace: ck.ColorSpace.SRGB
    })
    decodedImage.delete()

    let darkPixels = 0
    for (let i = 0; i < pixels.length; i += 4) {
      if (pixels[i] < 128 && pixels[i + 1] < 128 && pixels[i + 2] < 128) {
        darkPixels++
      }
    }
    // CJK characters are dense — should have many dark pixels if rendering correctly
    // Tofu boxes would have far fewer (just outlines)
    expect(darkPixels).toBeGreaterThan(500)
  })

  test('renders linear gradient text through the canvas scene fill path', async () => {
    const ck = await initCanvasKit()
    const fontProvider = ck.TypefaceFontProvider.Make()
    fontManager.attachProvider(ck, fontProvider)

    const interData = await Bun.file('public/Inter-Regular.ttf').arrayBuffer()
    fontProvider.registerFont(interData, 'Inter')
    fontManager.markLoaded('Inter', 'Regular', interData)

    const graph = new SceneGraph()
    const page = graph.getPages()[0]
    const node = graph.createNode('TEXT', page.id, {
      text: 'OPEN',
      fontFamily: 'Inter',
      fontSize: 64,
      fontWeight: 400,
      width: 220,
      height: 80,
      fills: [
        {
          type: 'GRADIENT_LINEAR',
          opacity: 1,
          visible: true,
          gradientStops: [
            { position: 0, color: { r: 1, g: 0, b: 0, a: 1 } },
            { position: 1, color: { r: 0, g: 0, b: 1, a: 1 } }
          ],
          gradientTransform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
        }
      ]
    })

    const surface = expectDefined(ck.MakeSurface(220, 80), 'CanvasKit surface')
    const renderer = new SkiaRendererClass(ck, surface)
    renderer.viewportWidth = 220
    renderer.viewportHeight = 80
    renderer.dpr = 1
    renderer.fontsLoaded = true
    renderer.fontProvider = fontProvider

    const canvas = surface.getCanvas()
    canvas.clear(ck.WHITE)
    renderer.renderShape(canvas, expectDefined(graph.getNode(node.id), 'text node'), graph)
    surface.flush()

    const image = surface.makeImageSnapshot()
    const pixels = image.readPixels(0, 0, {
      width: 220,
      height: 80,
      colorType: ck.ColorType.RGBA_8888,
      alphaType: ck.AlphaType.Unpremul,
      colorSpace: ck.ColorSpace.SRGB
    })
    image.delete()
    surface.delete()

    let redTextPixels = 0
    let blueTextPixels = 0
    for (let y = 0; y < 80; y++) {
      for (let x = 0; x < 220; x++) {
        const i = (y * 220 + x) * 4
        const r = pixels[i]
        const g = pixels[i + 1]
        const b = pixels[i + 2]
        if (g > 220) continue
        if (x < 110 && b > r + 40) blueTextPixels++
        if (x >= 110 && r > b + 40) redTextPixels++
      }
    }

    expect(redTextPixels).toBeGreaterThan(40)
    expect(blueTextPixels).toBeGreaterThan(40)
  })

  test('renders Arabic text via fallback font through paragraph shaper', async () => {
    const ck = await initCanvasKit()
    const fontProvider = ck.TypefaceFontProvider.Make()
    fontManager.attachProvider(ck, fontProvider)

    const interData = await Bun.file('public/Inter-Regular.ttf').arrayBuffer()
    fontProvider.registerFont(interData, 'Inter')
    fontManager.markLoaded('Inter', 'Regular', interData)

    const arabicPath = repoPath('tests/fixtures/fonts/NotoNaskhArabic-Regular.ttf')
    const arabicData = await Bun.file(arabicPath).arrayBuffer()
    fontProvider.registerFont(arabicData, 'Noto Naskh Arabic')
    fontManager.setArabicFallbackFamily('Noto Naskh Arabic')

    const graph = new SceneGraph()
    const page = graph.getPages()[0]
    const node = graph.createNode('TEXT', page.id, {
      text: 'مرحبا بالعالم',
      textDirection: 'AUTO',
      fontFamily: 'Inter',
      fontSize: 32,
      fontWeight: 400,
      width: 220,
      height: 60,
      fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
    })

    const surface = expectDefined(ck.MakeSurface(220, 60), 'CanvasKit surface')
    const renderer = new SkiaRendererClass(ck, surface)
    renderer.viewportWidth = 220
    renderer.viewportHeight = 60
    renderer.dpr = 1
    renderer.fontsLoaded = true
    renderer.fontProvider = fontProvider

    const canvas = surface.getCanvas()
    canvas.clear(ck.WHITE)
    renderText(renderer, canvas, expectDefined(graph.getNode(node.id), 'text node'))
    surface.flush()

    const image = surface.makeImageSnapshot()
    const encoded = expectDefined(image.encodeToBytes(ck.ImageFormat.PNG, 100), 'encoded PNG')
    image.delete()
    surface.delete()

    expect(encoded.length).toBeGreaterThan(200)

    const decodedImage = expectDefined(ck.MakeImageFromEncoded(encoded), 'decoded PNG image')
    const pixels = decodedImage.readPixels(0, 0, {
      width: 220,
      height: 60,
      colorType: ck.ColorType.RGBA_8888,
      alphaType: ck.AlphaType.Unpremul,
      colorSpace: ck.ColorSpace.SRGB
    })
    decodedImage.delete()

    let darkPixels = 0
    for (let i = 0; i < pixels.length; i += 4) {
      if (pixels[i] < 128 && pixels[i + 1] < 128 && pixels[i + 2] < 128) {
        darkPixels++
      }
    }
    expect(darkPixels).toBeGreaterThan(450)
  })
})
