import { describe, it, expect } from 'vitest'
import { readdirSync, existsSync, readFileSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { orderedPages } from '../playground/docs/navigation'

/**
 * INFRA-F86 ② — "which docs pages have a visual baseline" must be a gated fact.
 *
 * `pnpm test:visual` is not on any *commit-time* gate (not in pre-commit, not in pr-checks,
 * not in prepublishOnly), which is why its baselines rotted for two months unnoticed. It
 * also can't answer this question cheaply: it needs chromium + a dev server.
 *
 * ⚠️ CORRECTED 2026-08-13 (INFRA-F119): this comment used to read "is on no gate", full
 * stop. That has been false since 2026-07-31 — `scripts/release.mjs` step 1c runs
 * `pnpm test:visual` as a BLOCKING gate with no skip flag, so a stale baseline now stops
 * a tag from being cut. The four places listed above are the only ones it is *not* on.
 * The wrong sentence propagated: backlog INFRA-F119 quoted it as measured fact and
 * concluded the suite "has no place that runs it", which sent a session off to re-mount
 * something already mounted. If you change where this suite runs, fix this line in the
 * same commit — it is the line people cite.
 *
 * This test asks the one question that needs no browser — does a baseline FILE exist for
 * every page, and does every baseline file still belong to a page — and it rides
 * `pnpm test`, which IS on pre-commit and in CI. So from now on:
 *   · adding a docs page without a baseline fails a gated run instead of silently
 *     shipping an uncovered page;
 *   · renaming/removing a page leaves no dead baselines behind (the `prompt-message`
 *     pair survived the v0.10.0 PromptMessage → Message rename this way).
 *
 * It deliberately does NOT judge whether a baseline is *correct* or fresh — that is the
 * owner's per-page visual review (F86 ①, needs a human) and cannot be mechanized here.
 */

const HERE = dirname(fileURLToPath(import.meta.url))
const SNAPSHOT_DIR = resolve(HERE, 'visual/__screenshots__')
const THEMES = ['dark', 'light'] as const

/**
 * Pages knowingly without a baseline. **Empty is the terminal state, not a TODO** — the
 * F86 ① reset (2026-07-31, 33 pages × 2 themes regenerated, reviewed page by page before
 * the commit that landed them) emptied the last three
 * entries (`form` / `message` / `usermenu`, which had zero coverage since they were
 * added). The list is shrink-only: a NEW page belongs in a baseline, not in here, and the
 * third test below fails the moment an entry stops being true, so a filled-in line can
 * never quietly turn from "debt" into "permanent blind spot".
 */
const KNOWN_MISSING_BASELINES: Record<string, string> = {}

/**
 * Baseline files come in two shapes since INFRA-F88 (2026-07-31):
 *   · viewport   `<pageId>-<theme>.png`            — top 1280×720, also the page shell
 *   · segment    `<pageId>-<slug>-<theme>.png`     — one per `h2.docs-section__title` section
 * Page ids contain hyphens (`input` vs `input-number`), so classification takes the
 * LONGEST matching page id — otherwise `input-number-basic-usage-dark.png` would be
 * read as page `input` with slug `number-basic-usage`.
 */
function classify(file: string, pageIds: string[]) {
  const m = file.match(/^(.+)-(dark|light)\.png$/)
  if (!m) return null
  const [, stem, theme] = m
  const owner = pageIds
    .filter((id) => stem === id || stem.startsWith(`${id}-`))
    .sort((a, b) => b.length - a.length)[0]
  if (!owner) return null
  return { owner, theme, slug: stem === owner ? null : stem.slice(owner.length + 1) }
}

describe('visual baseline coverage (INFRA-F86)', () => {
  const files = new Set(readdirSync(SNAPSHOT_DIR))
  const pageIds = orderedPages.map((p) => p.id)

  it('every docs page has a dark + light baseline (or an explicitly logged debt)', () => {
    const missing: string[] = []
    for (const page of orderedPages) {
      if (page.id in KNOWN_MISSING_BASELINES) continue
      for (const theme of THEMES) {
        const file = `${page.id}-${theme}.png`
        if (!files.has(file)) missing.push(file)
      }
    }
    expect(
      missing,
      `Missing visual baseline(s). Generate them with \`pnpm test:visual:update\` and have the ` +
        `owner review each one before committing — or, if the gap is knowingly deferred, add the ` +
        `page id to KNOWN_MISSING_BASELINES with a reason + date.`
    ).toEqual([])
  })

  it('every baseline file still belongs to a live docs page', () => {
    const orphans = [...files].filter((file) => file.endsWith('.png') && !classify(file, pageIds))
    expect(
      orphans,
      `Baseline file(s) with no matching docs page — delete them; a renamed page's old ` +
        `baselines are dead weight that hide the fact the new page is uncovered.`
    ).toEqual([])
  })

  it('every docs page has at least one per-segment baseline (INFRA-F88)', () => {
    // The viewport pair alone is what INFRA-F88 measured as 6.8% pixel coverage. A page
    // whose segments stop being captured — because its headings changed shape, or someone
    // narrowed the spec's selector — would still have its two viewport files and would
    // otherwise look fully covered here. This is the offline half of the fail-closed
    // denominator the spec asserts at runtime.
    const segmentsPerPage = new Map<string, number>(pageIds.map((id) => [id, 0]))
    for (const file of files) {
      const c = classify(file, pageIds)
      if (c?.slug) segmentsPerPage.set(c.owner, (segmentsPerPage.get(c.owner) ?? 0) + 1)
    }
    const uncovered = [...segmentsPerPage]
      .filter(([id, n]) => n === 0 && !(id in KNOWN_MISSING_BASELINES))
      .map(([id]) => id)
    expect(
      uncovered,
      `Page(s) with only a viewport baseline and no segment baselines. Regenerate with ` +
        `\`pnpm test:visual:update\` and have the owner review the new images; if a page ` +
        `genuinely has no segment, the spec's own fail-closed check should have gone red first.`
    ).toEqual([])
  })

  it('the debt list only contains pages that really are missing a baseline', () => {
    // Guards the allowlist itself: once a page gets its baseline, its entry here has to
    // go, otherwise the list quietly turns from "debt" into "permanent blind spot".
    const stale = Object.keys(KNOWN_MISSING_BASELINES).filter((id) =>
      THEMES.every((theme) => files.has(`${id}-${theme}.png`))
    )
    expect(
      stale,
      `These page(s) now have baselines — remove them from KNOWN_MISSING_BASELINES.`
    ).toEqual([])
  })

  it('the snapshot directory the gate reads is the one playwright writes', () => {
    // Without this, moving snapshotPathTemplate would leave the gate reading a stale or
    // empty directory while still reporting green — a check that proves nothing.
    const configPath = resolve(HERE, '../playwright.config.ts')
    expect(existsSync(configPath)).toBe(true)
    const config = readFileSync(configPath, 'utf8')
    expect(config).toContain("snapshotPathTemplate: '{testDir}/__screenshots__/{arg}{ext}'")
    expect(config).toContain("testDir: 'tests/visual'")
    // And the run must not be allowed to mint baselines (see the config's own comment).
    expect(config).toContain("updateSnapshots: 'none'")
  })

  // ---- the three mechanisms added by the F86 ① reset (2026-07-31) --------------
  // Each one closes a way this suite was previously able to look green while proving
  // nothing. They are pinned here because all three are single lines that a future edit
  // could drop without any test noticing — which is exactly how the baselines rotted for
  // two months in the first place.

  it('test:visual is a blocking gate at tag-cut time (release.mjs step 1c)', () => {
    // Before this, `pnpm test:visual` rode on NO gate at all: not pre-commit, not
    // pr-checks, not prepublishOnly. It could be — and was — 33/33 red for two months
    // without blocking anything. It cannot live in CI (unprivileged Gitea runner can't
    // install chromium, INFRA-F40) nor in pre-commit (needs a dev server; its 0-px noise
    // floor is measured on one machine only), so it gates where the render gate does.
    const release = readFileSync(resolve(HERE, '../scripts/release.mjs'), 'utf8')
    expect(release).toContain('pnpm run test:visual')
    // ...and blocking, not advisory: the failure path must call fail() (which exits 1).
    const visualBlock = release.slice(release.indexOf('pnpm run test:visual'))
    expect(visualBlock.slice(0, 400)).toContain('fail(')
    // No escape hatch, matching the render gate's deliberate no-skip-flag policy.
    expect(release).not.toMatch(/--skip-visual|SKIP_VISUAL/)
  })

  it('committing a baseline requires the visual-approval signal', () => {
    // The pre-commit visual gate keyed on source extensions only (.html/.css/.svg/.vue/
    // .jsx/.tsx), so the 66 PNGs that ARE the docs site's visual truth could be committed
    // with no approval at all — verified against the reset session's own 67 staged files,
    // where the old pattern matched 0. AI must not be able to self-sign visual truth.
    const hook = readFileSync(resolve(HERE, '../.husky/pre-commit'), 'utf8')
    expect(hook).toContain('VISUAL_COMMIT_APPROVED')
    expect(hook).toContain('tests/visual/__screenshots__')
  })

  it('the spec waits for the page to mount and can see sub-JND colour changes', () => {
    const spec = readFileSync(resolve(HERE, 'visual/docs-pages.spec.ts'), 'utf8')
    // networkidle is not a readiness signal: docs pages are defineAsyncComponent, and the
    // frozen loading skeleton reads as "stable" to toHaveScreenshot, so an update run could
    // mint the skeleton as the baseline.
    expect(spec).toContain(".waitForSelector('.docs-loading', { state: 'detached' })")
    // pixelmatch's default threshold 0.2 scored 0 diff pixels on a real defect (a control's
    // border going invisible in light theme); 0.1 scores 152 and fails. Keep it explicit.
    expect(spec).toMatch(/threshold:\s*0\.1\b/)
  })

  it('the spec captures per-segment, fails closed on 0 segments, and keeps exemptions shrink-only (INFRA-F88)', () => {
    // Measured 2026-07-31: viewport-only capture saw 23 760 of 350 311 px (6.8%). All four
    // mechanisms below are single lines a future edit could drop while the suite still went
    // green over the top 720px of each page — which is precisely the failure being fixed.
    const spec = readFileSync(resolve(HERE, 'visual/docs-pages.spec.ts'), 'utf8')
    // Segments are found via the heading, not a wrapper class: `steps` uses
    // `.example-section` / `.api-section` while the other 32 pages use `.docs-section`.
    expect(spec).toContain("querySelectorAll('h2.docs-section__title')")
    expect(spec).toContain('data-visual-segment')
    // Fail-closed denominator: a page yielding no segment must go red, not contribute zero.
    expect(spec).toMatch(/segments\.length[\s\S]{0,400}toBeGreaterThan\(0\)/)
    // Oversized segments are exempt only via a named table, and the table is shrink-only.
    expect(spec).toContain('OVERSIZED_ALLOWLIST')
    expect(spec).toMatch(/no longer applies/)
  })

  it('the dead top-level viewport override stays out of the playwright config (INFRA-F88)', () => {
    // `use: { viewport: { width: 1280, height: 900 } }` sat at the top level and was
    // overridden by the chromium project's `devices['Desktop Chrome']` (720 tall). It made
    // the suite read as covering 900px per page when every baseline is 720. Re-adding a
    // top-level viewport would either be dead again or silently invalidate all baselines.
    const config = readFileSync(resolve(HERE, '../playwright.config.ts'), 'utf8')
    const topLevelUse = config.slice(config.indexOf('use: {'), config.indexOf('projects:'))
    expect(topLevelUse).not.toMatch(/^\s*viewport:/m)
  })
})
