import { beforeAll, describe, expect, test } from 'bun:test'
import { readFileSync } from 'node:fs'

import {
  computeAllLayouts,
  initCodec,
  parseFigFile,
  renderNodesToImage,
  SkiaRenderer,
  type SceneGraph,
  type SceneNode
} from '@open-pencil/core'

import { initCanvasKit } from '#cli/headless'

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

let graph: SceneGraph
let movingNodeId: string
let ck: Awaited<ReturnType<typeof initCanvasKit>>

beforeAll(async () => {
  ck = await initCanvasKit()
  await initCodec()
  const buf = readFileSync(repoPath('tests/fixtures/gold-preview.fig'))
  graph = await parseFigFile(buf.buffer as ArrayBuffer)
  computeAllLayouts(graph)
  const preview = [...graph.getAllNodes()].find((node) => node.name === 'Preview Thumbnail')
  const input = preview
    ? graph.getChildren(preview.id).find((node) => node.name === 'Input')
    : undefined
  if (!input) throw new Error('gold-preview Input fixture node not found')
  movingNodeId = input.id
})

function renderPreview(renderer: SkiaRenderer, sceneVersion: number): Uint8Array {
  renderer.render(graph, new Set(), {}, sceneVersion)
  const image = renderer.surface.makeImageSnapshot()
  const pixels = image.readPixels(0, 0, {
    width: 900,
    height: 700,
    colorType: ck.ColorType.RGBA_8888,
    alphaType: ck.AlphaType.Unpremul,
    colorSpace: ck.ColorSpace.SRGB
  })
  image.delete()
  return expectDefined(pixels, 'rendered pixels')
}

function childNamed(parent: SceneNode | undefined, name: string): SceneNode | undefined {
  return parent ? graph.getChildren(parent.id).find((node) => node.name === name) : undefined
}

function fixtureInputBadge(): SceneNode {
  const preview = [...graph.getAllNodes()].find((node) => node.name === 'Preview Thumbnail')
  const input = childNamed(preview, 'Input')
  const inputRoot = childNamed(input, '_input')
  const inputFrame = childNamed(inputRoot, 'Input')
  const content = childNamed(inputFrame, 'Content')
  const tags = childNamed(content, 'Tags')
  const badge = childNamed(tags, 'Badge')
  if (!badge) throw new Error('gold-preview badge fixture node not found')
  return badge
}

function countDarkPixels(pixels: Uint8Array): number {
  let dark = 0
  for (let i = 0; i < pixels.length; i += 4) {
    if (pixels[i + 3] > 200 && pixels[i] < 80 && pixels[i + 1] < 80 && pixels[i + 2] < 80) {
      dark++
    }
  }
  return dark
}

function pixelIndex(width: number, x: number, y: number): number {
  return (y * width + x) * 4
}

function maskYCenter(
  pixels: Uint8Array,
  width: number,
  height: number,
  matches: (index: number) => boolean,
  xRange = [0, width]
): number {
  let minY = Infinity
  let maxY = -Infinity
  for (let y = 0; y < height; y++) {
    for (let x = xRange[0]; x < xRange[1]; x++) {
      if (!matches(pixelIndex(width, x, y))) continue
      minY = Math.min(minY, y)
      maxY = Math.max(maxY, y)
    }
  }
  if (minY === Infinity) throw new Error('pixel mask had no matches')
  return (minY + maxY) / 2
}

describe('render cache regressions', () => {
  test('badge label is vertically centered in the pill', async () => {
    const surface = expectDefined(ck.MakeSurface(120, 60), 'badge surface')
    const renderer = new SkiaRenderer(ck, surface)
    await renderer.loadFonts()
    try {
      const badge = fixtureInputBadge()
      const png = renderNodesToImage(ck, renderer, graph, graph.getPages()[0].id, [badge.id], {
        scale: 1,
        format: 'PNG'
      })
      expect(png).toBeTruthy()
      const image = expectDefined(
        ck.MakeImageFromEncoded(expectDefined(png, 'badge png')),
        'badge image'
      )
      const width = image.width()
      const height = image.height()
      const pixels = image.readPixels(0, 0, {
        width,
        height,
        colorType: ck.ColorType.RGBA_8888,
        alphaType: ck.AlphaType.Unpremul,
        colorSpace: ck.ColorSpace.SRGB
      })
      image.delete()

      const renderedPixels = expectDefined(pixels, 'badge pixels')
      const contentCenter = maskYCenter(
        renderedPixels,
        width,
        height,
        (i) => renderedPixels[i + 3] > 10
      )
      const textCenter = maskYCenter(
        renderedPixels,
        width,
        height,
        (i) =>
          renderedPixels[i + 3] > 128 &&
          pixels[i] < 130 &&
          pixels[i + 1] < 140 &&
          pixels[i + 2] < 160,
        [0, width]
      )
      expect(Math.abs(textCenter - contentCenter)).toBeLessThanOrEqual(0.6)
    } finally {
      surface.delete()
    }
  })

  test('scene picture redraw keeps text after moving a node', async () => {
    const surface = expectDefined(ck.MakeSurface(900, 700), 'preview surface')
    const renderer = new SkiaRenderer(ck, surface)
    renderer.viewportWidth = 900
    renderer.viewportHeight = 700
    renderer.dpr = 1
    await renderer.loadFonts()
    renderer.panX = 0
    renderer.panY = 0
    renderer.zoom = 0.75
    renderer.pageId = graph.getPages()[0].id

    try {
      const beforeDark = countDarkPixels(renderPreview(renderer, 1))
      const movingNode = graph.getNode(movingNodeId)
      expect(movingNode).toBeDefined()
      const originalX = movingNode?.x ?? 0
      graph.updateNode(movingNodeId, { x: originalX + 20 })
      expect(graph.getNode(movingNodeId)?.x).toBeCloseTo(originalX + 20, 3)
      const afterDark = countDarkPixels(renderPreview(renderer, 2))

      expect(afterDark).toBeGreaterThan(beforeDark * 0.8)
    } finally {
      surface.delete()
    }
  })

  test('scene picture cache recovers after position preview commits', async () => {
    const surface = expectDefined(ck.MakeSurface(900, 700), 'preview surface')
    const renderer = new SkiaRenderer(ck, surface)
    renderer.viewportWidth = 900
    renderer.viewportHeight = 700
    renderer.dpr = 1
    renderer.panX = 0
    renderer.panY = 0
    renderer.zoom = 0.75
    renderer.pageId = graph.getPages()[0].id

    try {
      renderPreview(renderer, 10)
      expect(renderer.profiler.stats.scenePictureMode).toBe('record')

      const movingNode = expectDefined(graph.getNode(movingNodeId), 'moving node')
      const originalX = movingNode.x
      graph.updateNodePositionPreview(movingNodeId, originalX + 20, movingNode.y)
      renderPreview(renderer, 10)
      expect(renderer.profiler.stats.scenePictureMode).toBe('volatile')
      expect(renderer.profiler.stats.scenePictureMissReason).toBe('position-preview')

      graph.updateNode(movingNodeId, { x: originalX + 20 })
      renderPreview(renderer, 11)
      expect(renderer.profiler.stats.scenePictureMode).toBe('record')
      expect(renderer.profiler.stats.scenePictureMissReason).toBe('position-preview-version')

      renderPreview(renderer, 11)
      expect(renderer.profiler.stats.scenePictureMode).toBe('hit')
    } finally {
      surface.delete()
    }
  })
})
