// Shared render-verification comparator — single source of truth for BOTH the
// Vue gate (tests/visual-verify/manifest-verifier.spec.ts) and the React gate
// (tests/render-verification-react/lib/drift-compare.ts).
// The Vue gate is canonical (runs against 930 manifest entries); the React gate
// re-exports every name from here so `await import('./lib/drift-compare')` is
// unchanged.  (Q10 de-dup — refactor(test): extract shared render-verification
// comparator)
import type { Page } from '@playwright/test'

export type ExpectedFromFigma = {
  rootWidth: number | null
  rootWidthMode: 'FIXED' | 'HUG' | 'FILL' | null
  rootHeight: number | null
  rootHeightMode: 'FIXED' | 'HUG' | 'FILL' | null
  rootPadding: { top: number; right: number; bottom: number; left: number }
  rootRadius: number | null
  rootGap: number
  rootGapMeaningful: boolean
  rootOpacity: number
  rootFillHex: string | null
  rootFillCssVar: string | null
  rootBorderHex: string | null
  rootBorderCssVar: string | null
  rootBorderWidth: number
  textFillHex: string | null
  textFillCssVar: string | null
}

export type ExpectedNode = {
  figmaNodeId: string
  name: string
  type: string
  fillHex: string | null
  borderHex: string | null
  textColorHex: string | null
  radius: number | null
  opacity: number | null
}

export type ManifestEntry = {
  manifestId: string
  figmaNodeId: string
  figmaName: string
  figmaVariantName: string
  theme?: 'dark' | 'light'
  state?: string
  rootTargetSelector?: string | null
  textTargetSelector?: string | null
  textInset?: { left?: number | null; right?: number | null } | null
  gapTargetSelector?: string | null
  suppressRootSize?: boolean
  targetSelector?: string | null
  _warnings?: string[]
  codeComponent: string
  codeProps: Record<string, unknown>
  renderRoute: string
  expectedFromFigma: ExpectedFromFigma
  expectedNodes?: ExpectedNode[]
}

export type NodePropCheck = {
  prop: 'color' | 'backgroundColor' | 'borderColor'
  actual: string | null
  expected: string | null
  pass: boolean
}

export type NodeCheckResult = {
  figmaNodeId: string
  name: string
  type?: string
  status: 'pass' | 'mismatch' | 'coverage-gap'
  checks?: NodePropCheck[]
}

export type CheckResult = {
  field: string
  expected: string | number | null
  actual: string | number | null
  pass: boolean
  status: 'pass' | 'fail' | 'pass-by-mode-skip'
  reason?: string
  classification?: 'A_TRUE_DRIFT_CANDIDATE' | 'B_RESIDUAL_SCHEMA_GAP' | 'C_BOUNDARY_CASE'
}

export type EntryReport = {
  manifestId: string
  figmaNodeId: string
  figmaName: string
  figmaVariantName: string
  theme?: 'dark' | 'light'
  state?: string
  warnings?: string[]
  codeComponent: string
  codeProps: Record<string, unknown>
  status: 'PASS' | 'PASS_BY_MODE_SKIP' | 'FAIL'
  checks: CheckResult[]
  nodeChecks?: NodeCheckResult[]
}

export function numberFromPx(value: string) {
  if (value === 'normal') return 0
  return Number.parseFloat(value.replace('px', ''))
}

export function normalizeHex(value: string | null) {
  return value?.toLowerCase() ?? null
}

export function colorToHex(value: string) {
  if (value === 'transparent' || value === 'rgba(0, 0, 0, 0)') return null
  const rgba = value.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([0-9.]+))?\)$/)
  if (rgba) {
    const alpha = rgba[4] === undefined ? 1 : Number.parseFloat(rgba[4])
    if (alpha === 0) return null
    return `#${[rgba[1], rgba[2], rgba[3]]
      .map((part) => Number(part).toString(16).padStart(2, '0'))
      .join('')}`
  }
  // color(srgb r g b[ / a]) — Chromium's computed form for color-mix(in srgb, ...).
  // Channels are 0–1 floats; without this branch any color-mix() output is a false-positive drift.
  const srgb = value.match(
    /^color\(srgb\s+([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)(?:\s*\/\s*([0-9.]+))?\)$/,
  )
  if (srgb) {
    const alpha = srgb[4] === undefined ? 1 : Number.parseFloat(srgb[4])
    if (alpha === 0) return null
    return `#${[srgb[1], srgb[2], srgb[3]]
      .map((part) => Math.round(Number.parseFloat(part) * 255).toString(16).padStart(2, '0'))
      .join('')}`
  }
  return value.toLowerCase()
}

export function closeEnough(actual: number, expected: number) {
  return Math.abs(actual - expected) <= 1
}

export function pushNumber(checks: CheckResult[], field: string, actual: number, expected: number | null) {
  if (expected === null) return
  const pass = closeEnough(actual, expected)
  checks.push({ field, actual, expected, pass, status: pass ? 'pass' : 'fail' })
}

export function pushSkipped(checks: CheckResult[], field: string, actual: number | string | null, expected: number | string | null, reason: string) {
  checks.push({ field, actual, expected, pass: true, status: 'pass-by-mode-skip', reason })
}

export function pushSizedNumber(
  checks: CheckResult[],
  field: string,
  actual: number,
  expected: number | null,
  mode: ExpectedFromFigma['rootWidthMode'],
) {
  if (expected === null) return
  if (mode === 'HUG') {
    pushSkipped(checks, field, actual, expected, 'Figma root sizing is HUG; content metrics are renderer-driven.')
    return
  }
  if (mode === 'FILL') {
    const pass = actual >= expected
    checks.push({
      field,
      actual,
      expected,
      pass,
      status: pass ? 'pass-by-mode-skip' : 'fail',
      reason: pass ? 'Figma root sizing is FILL; verifier checks actual >= expected.' : undefined,
    })
    return
  }
  pushNumber(checks, field, actual, expected)
}

export function pushExact(checks: CheckResult[], field: string, actual: string | number | null, expected: string | number | null) {
  const pass = actual === expected
  checks.push({ field, actual, expected, pass, status: pass ? 'pass' : 'fail' })
}

export async function collectActual(page: Page, entry: ManifestEntry) {
  const rootSelector = `[data-manifest-id="${entry.manifestId}"]`
  const root = page.locator(rootSelector)
  const rootTargetSelector = entry.rootTargetSelector ?? entry.targetSelector
  // Third fallback (unscoped) handles teleported roots (e.g. PopupBox modal via
  // `<Teleport to="body">` — `.popup-box` is outside the [data-manifest-id]
  // wrapper). Safe because each test navigates to a fresh page; DOM is clean.
  const locator = rootTargetSelector
    ? page.locator(`${rootSelector}${rootTargetSelector}, ${rootSelector} ${rootTargetSelector}, ${rootTargetSelector}`).first()
    : root
  try {
    await locator.waitFor({ state: 'visible', timeout: 1_000 })
  } catch {
    return {
      missingTarget: true,
      targetSelector: rootTargetSelector ?? rootSelector,
    }
  }
  // P14 + P14c + INFRA-F40: disable transitions AND animations on the measured
  // element before reading computed style — for ALL states, not just
  // hover/focus/editable. A border/bg transition (mount-time or state-driven)
  // otherwise races: a slower CI runner catches it mid-blend (e.g. select-box
  // border #546358 = grey↔brand) while a fast local run settles first →
  // non-deterministic A_TRUE_DRIFT_CANDIDATE across machines (0 local / 2 on
  // GitHub runner). transition:none snaps to the resting value, making the
  // numeric gate truly env-independent. (see memory verifier-css-transition-race)
  await locator.evaluate((el) => {
    ;(el as HTMLElement).style.setProperty('transition', 'none', 'important')
    ;(el as HTMLElement).style.setProperty('animation', 'none', 'important')
  })
  // P17c: reset mouse position before non-hover entries so previous hover state
  // doesn't leak into later DropDownListSelect rootFillHex checks.
  if (entry.state !== 'hover') {
    await page.mouse.move(0, 0)
  }
  if (entry.state === 'hover') {
    await locator.hover({ force: true })
  } else if (entry.state === 'focus' || entry.state === 'editable') {
    await locator.evaluate((el) => (el as HTMLElement).focus())
  }
  const rootActual = await locator.evaluate((el) => {
    const rect = el.getBoundingClientRect()
    const style = window.getComputedStyle(el)
    return {
      width: rect.width,
      height: rect.height,
      paddingTop: style.paddingTop,
      paddingRight: style.paddingRight,
      paddingBottom: style.paddingBottom,
      paddingLeft: style.paddingLeft,
      borderRadius: style.borderRadius,
      gap: style.gap,
      opacity: style.opacity,
      color: style.color,
      backgroundColor: style.backgroundColor,
      borderColor: style.borderColor,
      borderWidth: style.borderWidth,
    }
  })
  // INFRA-F39: Figma models some components as a single auto-layout frame whose
  // `gap` lives on an inner code element (FormItem's root gap is on
  // `.form-item__main`; a Table cell's gap is on `.tbl-cell__content`). When the
  // root selector is not the flex container, gapTargetSelector reads `gap` from the
  // element that actually carries it — same visual spacing, different DOM node.
  if (entry.gapTargetSelector) {
    const gapLocator = page
      .locator(`${rootSelector}${entry.gapTargetSelector}, ${rootSelector} ${entry.gapTargetSelector}, ${entry.gapTargetSelector}`)
      .first()
    try {
      await gapLocator.waitFor({ state: 'visible', timeout: 500 })
      rootActual.gap = await gapLocator.evaluate((el) => window.getComputedStyle(el).gap)
    } catch {
      // Gap target absent for this variant — fall back to the root element's gap.
    }
  }
  const textTargetSelector = entry.textTargetSelector
  if (!textTargetSelector) return rootActual
  const modelValue = entry.codeProps?.modelValue
  const isShowingPlaceholder =
    entry.state !== 'disabled' &&
    (modelValue === '' ||
      modelValue === null ||
      modelValue === undefined ||
      (Array.isArray(modelValue) && modelValue.length === 0))
  // Third fallback (unscoped) — see collectActual() rationale for teleported roots.
  const textLocator = page.locator(`${rootSelector}${textTargetSelector}, ${rootSelector} ${textTargetSelector}, ${textTargetSelector}`).first()
  try {
    await textLocator.waitFor({ state: 'visible', timeout: 500 })
    // The text node is a SEPARATELY-located element and can carry its OWN color transition
    // (e.g. TabList's active `.tab-item` has `transition: color 0.15s`); the root-locator
    // disable above never touches it, so the gate would sample a mid-/pre-transition colour
    // and mis-read it as drift (TabList Type=Text/Line light: settled #141414 read as #f8f8f8).
    // Snap the text node to its resting value too. (verifier-css-transition-race, on the text node.)
    await textLocator.evaluate((el) => {
      ;(el as HTMLElement).style.setProperty('transition', 'none', 'important')
      ;(el as HTMLElement).style.setProperty('animation', 'none', 'important')
    })
    const textActual = await textLocator.evaluate((el, shouldReadPlaceholder) => {
      const style = window.getComputedStyle(el)
      const placeholderStyle = shouldReadPlaceholder
        ? window.getComputedStyle(el, '::placeholder')
        : null
      const placeholderColor = placeholderStyle?.color
      const textColor = placeholderColor && placeholderColor !== 'rgba(0, 0, 0, 0)'
        ? placeholderColor
        : style.color
      return {
        textColor,
        textPaddingLeft: style.paddingLeft,
        textPaddingRight: style.paddingRight,
      }
    }, isShowingPlaceholder)
    return {
      ...rootActual,
      ...textActual,
    }
  } catch {
    return rootActual
  }
}

// INFRA-F41 Task 3: parallel, isolated node-walk pass. Strictly additive — its
// results live in a SEPARATE `nodeChecks` field and NEVER touch the entry
// `status`, the `checks` array, or the A/B/C classification gate. A node whose
// `data-figma-node-id` element is absent (nothing is annotated until Task 4) is
// an INFORMATIONAL coverage-gap, NEVER a failure and NEVER counted toward A.

export async function collectNodeChecks(page: Page, entry: ManifestEntry): Promise<NodeCheckResult[]> {
  const results: NodeCheckResult[] = []
  for (const node of entry.expectedNodes ?? []) {
    try {
      const loc = page.locator(`[data-figma-node-id="${node.figmaNodeId}"]`)
      // `.count()` resolves fast for a missing node — no long waitFor, so an
      // unannotated tree (the Task 3 state) walks quickly to coverage-gap.
      const count = await loc.count()
      if (count === 0) {
        results.push({ figmaNodeId: node.figmaNodeId, name: node.name, status: 'coverage-gap' })
        continue
      }
      const el = loc.first()
      // Same transition/animation-disable pattern as collectActual (~line 180):
      // snap to the resting computed colour, env-independent (verifier-css-transition-race).
      await el.evaluate((node) => {
        ;(node as HTMLElement).style.setProperty('transition', 'none', 'important')
        ;(node as HTMLElement).style.setProperty('animation', 'none', 'important')
      })
      const computed = await el.evaluate((node) => {
        const style = window.getComputedStyle(node)
        return {
          color: style.color,
          backgroundColor: style.backgroundColor,
          borderColor: style.borderColor,
        }
      })
      const checks: NodePropCheck[] = []
      const pushNodeCheck = (prop: NodePropCheck['prop'], actualRaw: string, expectedHex: string) => {
        const actual = colorToHex(actualRaw)
        const expected = normalizeHex(expectedHex)
        checks.push({ prop, actual, expected, pass: actual === expected })
      }
      if (node.textColorHex != null) {
        // TEXT node — assert rendered text colour.
        pushNodeCheck('color', computed.color, node.textColorHex)
      } else if (node.fillHex != null) {
        // Whether a fill renders as background vs foreground (currentColor) is a
        // CODE fact, NOT reliably encoded by the Figma node type (a checkbox box is
        // a RECTANGLE, a radio circle is a VECTOR — both render as background). So
        // derive it from the annotated element itself: a painted (non-transparent)
        // background-color means the fill is a background; otherwise it is an icon
        // glyph rendered via currentColor. colorToHex() returns null for
        // transparent / alpha-0. Annotate the LEAF that carries the color (not a
        // wrapper) for this to hold. [INFRA-F41]
        const bgPainted = colorToHex(computed.backgroundColor) != null
        if (bgPainted) {
          pushNodeCheck('backgroundColor', computed.backgroundColor, node.fillHex)
        } else {
          pushNodeCheck('color', computed.color, node.fillHex)
        }
      }
      if (node.borderHex != null) {
        pushNodeCheck('borderColor', computed.borderColor, node.borderHex)
      }
      const status = checks.every((check) => check.pass) ? 'pass' : 'mismatch'
      results.push({ figmaNodeId: node.figmaNodeId, name: node.name, type: node.type, status, checks })
    } catch {
      // Any locator/read error → coverage-gap. NEVER a failure.
      results.push({ figmaNodeId: node.figmaNodeId, name: node.name, status: 'coverage-gap' })
    }
  }
  return results
}

export function classifyFailedCheck(entry: ManifestEntry, check: CheckResult) {
  if (check.pass) return undefined
  const warningText = (entry._warnings ?? []).join(' ')
  if (check.field === 'renderTarget' || check.field === 'navigation') return 'B_RESIDUAL_SCHEMA_GAP' as const
  if (check.field === 'textFillHex' && entry.state === 'placeholder' && /placeholder text color/i.test(warningText)) {
    return 'B_RESIDUAL_SCHEMA_GAP' as const
  }
  // NOTE: warnings read "no <Component> runtime prop/type" (e.g. "no DropDownListSelect
  // runtime prop"), so match the bare phrases — these phrases appear ONLY in schema-gap
  // warnings (verified across the full report), never in real-drift contexts.
  if (/pseudo-state|runtime prop|runtime type|runtime UX|harness limitation|verifier resolution/i.test(warningText)) {
    return 'B_RESIDUAL_SCHEMA_GAP' as const
  }
  if (/rootWidth|rootHeight/.test(check.field) && typeof check.expected === 'number' && typeof check.actual === 'number') {
    const ratio = check.expected === 0 ? 0 : Math.abs(check.actual - check.expected) / check.expected
    if (ratio <= 0.05) return 'C_BOUNDARY_CASE' as const
  }
  return 'A_TRUE_DRIFT_CANDIDATE' as const
}

export function buildChecks(entry: ManifestEntry, actual: Awaited<ReturnType<typeof collectActual>>, expected: ExpectedFromFigma) {
  const checks: CheckResult[] = []
  if ('missingTarget' in actual) {
    checks.push({
      field: 'renderTarget',
      actual: `missing: ${actual.targetSelector}`,
      expected: 'visible',
      pass: false,
      status: 'fail',
    })
    return checks.map((check) => ({ ...check, classification: classifyFailedCheck(entry, check) }))
  }
  if (entry.suppressRootSize) {
    // INFRA-F39: rootTargetSelector points to a sub-element of the rendered
    // component (e.g. a Table cell inside a full <table>), so the Figma
    // component-frame width/height (the cell artboard is 120×33) are not binding
    // constraints — a real cell's box is driven by the table layout algorithm.
    pushSkipped(checks, 'rootWidth', actual.width, expected.rootWidth, 'rootTargetSelector is a sub-element; Figma component-frame width is layout-driven, not binding.')
    pushSkipped(checks, 'rootHeight', actual.height, expected.rootHeight, 'rootTargetSelector is a sub-element; Figma component-frame height is renderer-driven, not binding.')
  } else {
    pushSizedNumber(checks, 'rootWidth', actual.width, expected.rootWidth, expected.rootWidthMode)
    pushSizedNumber(checks, 'rootHeight', actual.height, expected.rootHeight, expected.rootHeightMode)
  }
  pushNumber(checks, 'rootPadding.top', numberFromPx(actual.paddingTop), expected.rootPadding.top)
  if (entry.textInset) {
    pushSkipped(
      checks,
      'rootPadding.right',
      numberFromPx(actual.paddingRight),
      expected.rootPadding.right,
      'Input feature=yes reserves overlay text inset on the rendered input; verified separately as textInset.right.',
    )
  } else {
    pushNumber(checks, 'rootPadding.right', numberFromPx(actual.paddingRight), expected.rootPadding.right)
  }
  pushNumber(checks, 'rootPadding.bottom', numberFromPx(actual.paddingBottom), expected.rootPadding.bottom)
  if (entry.textInset) {
    pushSkipped(
      checks,
      'rootPadding.left',
      numberFromPx(actual.paddingLeft),
      expected.rootPadding.left,
      'Input feature=yes reserves overlay text inset on the rendered input; verified separately as textInset.left.',
    )
  } else {
    pushNumber(checks, 'rootPadding.left', numberFromPx(actual.paddingLeft), expected.rootPadding.left)
  }
  pushNumber(checks, 'rootRadius', numberFromPx(actual.borderRadius), expected.rootRadius)
  if (expected.rootGapMeaningful === false) {
    pushSkipped(checks, 'rootGap', numberFromPx(actual.gap), expected.rootGap, 'Figma root has fewer than 2 subnodes; auto-layout gap is not visually meaningful.')
  } else {
    pushNumber(checks, 'rootGap', numberFromPx(actual.gap), expected.rootGap)
  }
  pushNumber(checks, 'rootOpacity', Number.parseFloat(actual.opacity), expected.rootOpacity)
  pushNumber(checks, 'rootBorderWidth', numberFromPx(actual.borderWidth), expected.rootBorderWidth)
  pushExact(checks, 'rootFillHex', colorToHex(actual.backgroundColor), normalizeHex(expected.rootFillHex))
  if (numberFromPx(actual.borderWidth) === 0 && expected.rootBorderHex === null) {
    pushSkipped(
      checks,
      'rootBorderHex',
      colorToHex(actual.borderColor),
      normalizeHex(expected.rootBorderHex),
      'Border width is 0 and Figma confirms no stroke; CSS computed border-color = currentColor is not visually rendered.',
    )
  } else {
    pushExact(checks, 'rootBorderHex', colorToHex(actual.borderColor), normalizeHex(expected.rootBorderHex))
  }
  const expectedTextFillHex = normalizeHex(expected.textFillHex)
  if (expectedTextFillHex === null) {
    // INFRA-F39: when Figma exposes no visible text fill (e.g. the only text node
    // is hidden, or the variant has no text), there is nothing to assert about
    // text colour — skip rather than comparing the rendered colour against null
    // (pushExact has no null guard, so it would otherwise FAIL on any real colour).
    // Consistent with pushNumber/rootBorderHex skipping when Figma data is absent.
    pushSkipped(
      checks,
      'textFillHex',
      colorToHex(actual.textColor ?? actual.color),
      null,
      'Figma variant exposes no visible text fill; nothing to verify for text colour.',
    )
  } else {
    pushExact(checks, 'textFillHex', colorToHex(actual.textColor ?? actual.color), expectedTextFillHex)
  }
  if (entry.textInset) {
    pushNumber(checks, 'textInset.left', numberFromPx(actual.textPaddingLeft ?? actual.paddingLeft), entry.textInset.left ?? null)
    pushNumber(checks, 'textInset.right', numberFromPx(actual.textPaddingRight ?? actual.paddingRight), entry.textInset.right ?? null)
  }
  return checks.map((check) => ({ ...check, classification: classifyFailedCheck(entry, check) }))
}
