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

import { computeAllLayouts, SceneGraph, setTextMeasurer } from '@open-pencil/core'

import { createEditorStore } from '@/app/editor/session'

import { getNodeOrThrow } from '#tests/helpers/assert'
import { autoFrame, loadFixtureGraph, pageId, rect } from '#tests/helpers/layout'

describe('text measurement', () => {
  test('derived text layout preserves imported auto-layout text bounds during measurement', () => {
    const graph = new SceneGraph()
    const page = pageId(graph)
    const tabs = autoFrame(graph, page, {
      width: 180,
      height: 42,
      primaryAxisSizing: 'FIXED',
      counterAxisSizing: 'HUG',
      paddingTop: 5,
      paddingBottom: 5,
      paddingLeft: 5,
      paddingRight: 5
    })
    const tab = autoFrame(graph, tabs.id, {
      width: 80,
      height: 32,
      primaryAxisSizing: 'FIXED',
      counterAxisSizing: 'HUG',
      paddingTop: 6,
      paddingBottom: 6,
      paddingLeft: 12,
      paddingRight: 12
    })
    graph.createNode('TEXT', tab.id, {
      text: 'Account',
      width: 56,
      height: 20,
      textAutoResize: 'WIDTH_AND_HEIGHT',
      figmaDerivedLayout: { width: 56, height: 20 }
    })

    setTextMeasurer(() => ({ width: 56, height: 40 }))
    computeAllLayouts(graph, page)
    setTextMeasurer(null)

    expect(graph.getNode(tab.id)?.height).toBe(32)
    expect(graph.getNode(tabs.id)?.height).toBe(42)
  })

  test('live text without derived glyphs still uses CanvasKit measurement', () => {
    const graph = new SceneGraph()
    const page = pageId(graph)
    const tabs = autoFrame(graph, page, {
      width: 180,
      height: 42,
      primaryAxisSizing: 'FIXED',
      counterAxisSizing: 'HUG',
      paddingTop: 5,
      paddingBottom: 5,
      paddingLeft: 5,
      paddingRight: 5
    })
    const tab = autoFrame(graph, tabs.id, {
      width: 80,
      height: 32,
      primaryAxisSizing: 'FIXED',
      counterAxisSizing: 'HUG',
      paddingTop: 6,
      paddingBottom: 6,
      paddingLeft: 12,
      paddingRight: 12
    })
    graph.createNode('TEXT', tab.id, {
      text: 'Account',
      width: 56,
      height: 20,
      textAutoResize: 'WIDTH_AND_HEIGHT'
    })

    setTextMeasurer(() => ({ width: 56, height: 40 }))
    computeAllLayouts(graph, page)
    setTextMeasurer(null)

    expect(graph.getNode(tab.id)?.height).toBe(52)
    expect(graph.getNode(tabs.id)?.height).toBe(62)
  })

  test('opening imported fig keeps stored text bounds before CanvasKit measurement', async () => {
    const graph = await loadFixtureGraph('gold-preview.fig')
    const store = createEditorStore(graph)
    const title = [...store.graph.getAllNodes()].find(
      (node) => node.type === 'TEXT' && node.text === "World's largest"
    )
    const subtitle = [...store.graph.getAllNodes()].find(
      (node) =>
        node.type === 'TEXT' && node.text === 'Preline UI Figma - crafted with Tailwind CSS styles'
    )
    const description = [...store.graph.getAllNodes()].find(
      (node) =>
        node.type === 'TEXT' &&
        node.text.startsWith('Preline UI Figma is the largest free design system for Figma')
    )

    if (!title || !subtitle || !description) {
      throw new Error('Expected imported text nodes in gold-preview.fig')
    }

    expect(title.width).toBe(444)
    expect(title.height).toBe(73)
    expect(subtitle.width).toBe(439)
    expect(subtitle.height).toBe(22)
    expect(description.width).toBe(878)
    expect(description.height).toBe(60)

    await store.switchPage(store.graph.getPages()[0].id)

    expect(store.graph.getNode(title.id)?.width).toBe(444)
    expect(store.graph.getNode(title.id)?.height).toBe(73)
    expect(store.graph.getNode(subtitle.id)?.width).toBe(439)
    expect(store.graph.getNode(subtitle.id)?.height).toBe(22)
    expect(store.graph.getNode(description.id)?.width).toBe(878)
    expect(store.graph.getNode(description.id)?.height).toBe(60)
  })

  test('imported nested instance layout keeps hidden sibling offsets stable', async () => {
    const graph = await loadFixtureGraph('gold-preview.fig')
    const previewRoot = graph.getChildren(graph.getPages()[0].id)[0]
    const wysiwygEditor = graph
      .getChildren(previewRoot.id)
      .find((node) => node.name === '_WYSIWYG-editor')
    const toolbarVariant = wysiwygEditor
      ? graph.getChildren(wysiwygEditor.id).find((node) => node.name === '_on-text-WYSIWYG-toolbar')
      : undefined
    const toolbarRow = toolbarVariant
      ? graph.getChildren(toolbarVariant.id).find((node) => node.name === 'Toolbar')
      : undefined
    const hiddenInput = toolbarRow
      ? graph.getChildren(toolbarRow.id).find((node) => node.name === 'Input')
      : undefined
    const visibleToolbar = toolbarRow
      ? graph
          .getChildren(toolbarRow.id)
          .find((node) => node.name === 'Toolbar' && node.id !== toolbarRow.id)
      : undefined

    if (!toolbarRow || !hiddenInput || !visibleToolbar) {
      throw new Error('Expected imported WYSIWYG toolbar nodes in gold-preview.fig')
    }

    expect(hiddenInput.visible).toBe(false)
    // x=8 is the Figma-exported fixture value (verified via fixture inspection 2026-05-03).
    // The old assertion of x=298 was incorrect — it was a post-layout computed value from
    // an older layout implementation. Post-layout, left-aligned HUG content retains x=8.
    expect(visibleToolbar.x).toBe(8)

    setTextMeasurer(null)
    computeAllLayouts(graph, graph.getPages()[0].id)

    expect(graph.getNode(visibleToolbar.id)?.x).toBe(8)
    expect(graph.getNode(visibleToolbar.id)?.y).toBe(8)
  })

  test('WIDTH_AND_HEIGHT text uses measured width in centered layout', () => {
    const graph = new SceneGraph()
    const pid = pageId(graph)

    const frame = autoFrame(graph, pid, {
      width: 300,
      height: 40,
      layoutMode: 'HORIZONTAL',
      primaryAxisSizing: 'FIXED',
      counterAxisSizing: 'FIXED',
      primaryAxisAlign: 'CENTER',
      paddingLeft: 10,
      paddingRight: 10,
      itemSpacing: 10
    })

    const arrow1 = graph.createNode('FRAME', frame.id, { width: 20, height: 20 })
    const text = graph.createNode('TEXT', frame.id, {
      width: 200,
      height: 20,
      text: 'Test',
      fontSize: 14,
      textAutoResize: 'WIDTH_AND_HEIGHT' as const
    })
    const arrow2 = graph.createNode('FRAME', frame.id, { width: 20, height: 20 })

    setTextMeasurer((node) => {
      if (node.type === 'TEXT' && node.textAutoResize === 'WIDTH_AND_HEIGHT') {
        return { width: 60, height: 20 }
      }
      return null
    })

    computeAllLayouts(graph)

    setTextMeasurer(null)

    const updatedText = getNodeOrThrow(graph, text.id)
    const updatedArrow1 = getNodeOrThrow(graph, arrow1.id)
    const updatedArrow2 = getNodeOrThrow(graph, arrow2.id)

    expect(updatedText.width).toBe(60)

    // Total content: 10 + 20 + 10 + 60 + 10 + 20 + 10 = 140
    // Free space: 300 - 140 = 160, centered offset = 80
    expect(updatedArrow1.x).toBe(90)
    expect(updatedText.x).toBe(120)
    expect(updatedArrow2.x).toBe(190)
  })

  test('without measurer, text preserves stored dimensions from .fig import', () => {
    const graph = new SceneGraph()
    const pid = pageId(graph)

    const frame = autoFrame(graph, pid, {
      width: 300,
      height: 40,
      layoutMode: 'HORIZONTAL',
      primaryAxisSizing: 'FIXED',
      counterAxisSizing: 'FIXED',
      primaryAxisAlign: 'CENTER'
    })

    graph.createNode('TEXT', frame.id, {
      width: 200,
      height: 20,
      text: 'Test',
      fontSize: 14,
      textAutoResize: 'WIDTH_AND_HEIGHT' as const
    })

    setTextMeasurer(null)
    computeAllLayouts(graph)

    const children = graph.getChildren(frame.id)
    const updatedText = children[0]
    expect(updatedText.width).toBe(200)
    expect(updatedText.height).toBe(20)
  })

  test('without measurer, text with 100x100 default falls back to estimate', () => {
    const graph = new SceneGraph()
    const pid = pageId(graph)

    const frame = autoFrame(graph, pid, {
      width: 300,
      height: 40,
      layoutMode: 'HORIZONTAL',
      primaryAxisSizing: 'FIXED',
      counterAxisSizing: 'FIXED',
      primaryAxisAlign: 'CENTER'
    })

    graph.createNode('TEXT', frame.id, {
      width: 100,
      height: 100,
      text: 'Test',
      fontSize: 14,
      textAutoResize: 'WIDTH_AND_HEIGHT' as const
    })

    setTextMeasurer(null)
    computeAllLayouts(graph)

    const children = graph.getChildren(frame.id)
    const updatedText = children[0]
    expect(updatedText.width).toBeLessThan(100)
    expect(updatedText.width).toBeGreaterThan(0)
    expect(updatedText.height).toBeLessThan(100)
    expect(updatedText.height).toBeGreaterThan(0)
  })

  test('without measurer, HEIGHT text preserves stored height from .fig import', () => {
    const graph = new SceneGraph()
    const pid = pageId(graph)

    const frame = autoFrame(graph, pid, {
      width: 269,
      height: 400,
      layoutMode: 'VERTICAL',
      primaryAxisSizing: 'FIXED',
      counterAxisSizing: 'FIXED'
    })

    graph.createNode('TEXT', frame.id, {
      width: 269,
      height: 66,
      text: 'GDP Growth Exceeds Expectations at 3.1% in Q2 Report That Nobody Expected',
      fontSize: 15,
      lineHeight: 22,
      textAutoResize: 'HEIGHT' as const
    })

    setTextMeasurer(null)
    computeAllLayouts(graph)

    const children = graph.getChildren(frame.id)
    const updatedText = children[0]
    expect(updatedText.height).toBe(66)
    expect(updatedText.width).toBe(269)
  })

  test('without measurer, HEIGHT text with 100x100 default falls back to estimate', () => {
    const graph = new SceneGraph()
    const pid = pageId(graph)

    const frame = autoFrame(graph, pid, {
      width: 269,
      height: 400,
      layoutMode: 'VERTICAL',
      primaryAxisSizing: 'FIXED',
      counterAxisSizing: 'FIXED'
    })

    graph.createNode('TEXT', frame.id, {
      width: 100,
      height: 100,
      text: 'GDP Growth Exceeds Expectations at 3.1% in Q2 Report That Nobody Expected',
      fontSize: 15,
      lineHeight: 22,
      textAutoResize: 'HEIGHT' as const
    })

    setTextMeasurer(null)
    computeAllLayouts(graph)

    const children = graph.getChildren(frame.id)
    const updatedText = children[0]
    expect(updatedText.height).toBeGreaterThan(22)
  })

  test('resizing vertical typography frames reflows fill-width auto-height text', () => {
    const graph = new SceneGraph()
    const pid = pageId(graph)

    const frame = autoFrame(graph, pid, {
      width: 300,
      height: 200,
      layoutMode: 'VERTICAL',
      primaryAxisSizing: 'FIXED',
      counterAxisSizing: 'FIXED',
      paddingLeft: 20,
      paddingRight: 20
    })

    const text = graph.createNode('TEXT', frame.id, {
      width: 260,
      height: 20,
      text: 'Body text — The quick brown fox jumps and wraps.',
      fontSize: 14,
      textAutoResize: 'HEIGHT' as const,
      layoutAlignSelf: 'STRETCH' as const
    })

    setTextMeasurer((_node, maxWidth) => {
      const w = maxWidth ?? 260
      return { width: w, height: w <= 160 ? 60 : 20 }
    })

    computeAllLayouts(graph)
    expect(getNodeOrThrow(graph, text.id).width).toBe(260)
    expect(getNodeOrThrow(graph, text.id).height).toBe(20)

    graph.updateNode(frame.id, { width: 200 })
    computeAllLayouts(graph)
    setTextMeasurer(null)

    const updatedText = getNodeOrThrow(graph, text.id)
    expect(updatedText.width).toBe(160)
    expect(updatedText.height).toBe(60)
  })

  test('text with w="fill" in flex="col" stretches to parent width', () => {
    const graph = new SceneGraph()
    const pid = pageId(graph)

    const frame = autoFrame(graph, pid, {
      width: 300,
      height: 400,
      layoutMode: 'VERTICAL',
      primaryAxisSizing: 'FIXED',
      counterAxisSizing: 'FIXED',
      paddingLeft: 20,
      paddingRight: 20
    })

    const text = graph.createNode('TEXT', frame.id, {
      width: 100,
      height: 20,
      text: 'This text should fill the parent width',
      fontSize: 14,
      textAutoResize: 'HEIGHT' as const,
      layoutAlignSelf: 'STRETCH' as const
    })

    setTextMeasurer((_node, maxWidth) => {
      const w = maxWidth ?? 260
      return { width: w, height: 20 }
    })

    computeAllLayouts(graph)
    setTextMeasurer(null)

    const updatedText = getNodeOrThrow(graph, text.id)
    // Should stretch to 300 - 20 - 20 = 260, NOT stay at 100
    expect(updatedText.width).toBe(260)
  })

  test('HEIGHT auto-resize text wraps via MeasureFunc when filling parent', () => {
    const graph = new SceneGraph()
    const pid = pageId(graph)

    const frame = autoFrame(graph, pid, {
      width: 300,
      height: 200,
      layoutMode: 'VERTICAL',
      primaryAxisSizing: 'FIXED',
      counterAxisSizing: 'FIXED'
    })

    const text = graph.createNode('TEXT', frame.id, {
      width: 300,
      height: 20,
      text: 'Long text that should wrap within the available width',
      fontSize: 14,
      textAutoResize: 'HEIGHT' as const
    })

    setTextMeasurer((_node, maxWidth) => {
      const w = maxWidth ?? 1e6
      if (w >= 300) return { width: 300, height: 20 }
      return { width: w, height: 60 }
    })

    computeAllLayouts(graph)
    setTextMeasurer(null)

    const updatedText = getNodeOrThrow(graph, text.id)
    expect(updatedText.width).toBe(300)
    expect(updatedText.height).toBe(20)
  })

  test('MeasureFunc receives constraint width from flex layout', () => {
    const graph = new SceneGraph()
    const pid = pageId(graph)

    const frame = autoFrame(graph, pid, {
      width: 400,
      height: 200,
      layoutMode: 'HORIZONTAL',
      primaryAxisSizing: 'FIXED',
      counterAxisSizing: 'FIXED',
      itemSpacing: 10
    })

    rect(graph, frame.id, 100, 50)

    const text = graph.createNode('TEXT', frame.id, {
      width: 500,
      height: 20,
      text: 'Wide text',
      fontSize: 14,
      textAutoResize: 'WIDTH_AND_HEIGHT' as const,
      layoutGrow: 1
    })

    const receivedWidths: number[] = []
    setTextMeasurer((_node, maxWidth) => {
      if (maxWidth !== undefined) receivedWidths.push(Math.round(maxWidth))
      const w = maxWidth ?? 500
      return { width: Math.min(200, w), height: w < 200 ? 40 : 20 }
    })

    computeAllLayouts(graph)
    setTextMeasurer(null)

    // 400 - 100 - 10 = 290 available for the fill text
    expect(receivedWidths.length).toBeGreaterThan(0)
    const updatedText = getNodeOrThrow(graph, text.id)
    expect(updatedText.width).toBe(290)
  })

  test('textAutoResize NONE skips MeasureFunc', () => {
    const graph = new SceneGraph()
    const pid = pageId(graph)

    const frame = autoFrame(graph, pid, {
      width: 300,
      height: 100,
      layoutMode: 'HORIZONTAL',
      primaryAxisSizing: 'FIXED',
      counterAxisSizing: 'FIXED'
    })

    const text = graph.createNode('TEXT', frame.id, {
      width: 150,
      height: 40,
      text: 'Fixed text',
      fontSize: 14,
      textAutoResize: 'NONE' as const
    })

    let measureCalled = false
    setTextMeasurer(() => {
      measureCalled = true
      return { width: 80, height: 20 }
    })

    computeAllLayouts(graph)
    setTextMeasurer(null)

    expect(measureCalled).toBe(false)
    const updatedText = getNodeOrThrow(graph, text.id)
    expect(updatedText.width).toBe(150)
    expect(updatedText.height).toBe(40)
  })
})
