/**
 * Constraint: popup correctly renders bridge connection state with
 * per-failure-mode CTAs (post-2026-05-13 native messaging migration).
 *
 * Classification: quality / what -- violations mean the user sees the wrong
 * CTA when the bridge is down, breaking the setup flow (one of `tap bridge
 * setup --extension-id <id>` vs `tap bridge start` vs "restart Chrome").
 *
 * Scope: structural invariants in popup.html + render() dispatch logic in
 * popup.js. Visual regression (CSS, icons, layout) is NOT covered.
 *
 * Approach: hand-rolled minimal DOM + chrome.runtime stub. Matches the
 * extension/test/ "zero deps, just node:assert + readFileSync" convention.
 *
 * Run: node extension/test/popup.test.mjs
 */

import { strict as assert } from 'node:assert'
import { readFileSync } from 'node:fs'

const HTML_SRC = readFileSync(new URL('../popup.html', import.meta.url), 'utf-8')
const JS_SRC = readFileSync(new URL('../popup.js', import.meta.url), 'utf-8')

let passed = 0
let failed = 0

function test(name, fn) {
  try {
    fn()
    passed++
    console.log(`  \x1b[32m✓\x1b[0m ${name}`)
  } catch (e) {
    failed++
    console.log(`  \x1b[31m✗\x1b[0m ${name}`)
    console.log(`    ${e.message}`)
  }
}

// ─── Static: popup.html structural invariants ────────────────────────────

test('popup.html declares #connected section', () => {
  assert.match(HTML_SRC, /id="connected"/, 'missing #connected section')
})

test('popup.html declares 5 disconnect-mode sections (post-2026-05-30 B1)', () => {
  // Native messaging migration replaced the single #disconnected section
  // with failure-mode-specific sections so the CTA matches the actual
  // root cause (manifest missing vs daemon down vs Chrome blocklist).
  // 2026-05-30 (ADR bind-host-lifetime-to-nm-port §5, B1) added
  // dc-owned-by-other: another Chrome profile holds the single bridge —
  // an honest info state, not a failure.
  for (const id of ['dc-not-installed', 'dc-host-exited', 'dc-forbidden', 'dc-owned-by-other', 'dc-unknown']) {
    assert.match(HTML_SRC, new RegExp(`id="${id}"`), `missing #${id} section`)
  }
})

test('popup.html dc-owned-by-other is an info state, not red "Bridge not running"', () => {
  // B1's whole point is to stop the popup crying wolf when the bridge is
  // up but held by another profile. The section must use the amber warn
  // dot (not dot-err) and must NOT claim the bridge is "not running".
  const sec = HTML_SRC.match(/<section id="dc-owned-by-other"[\s\S]*?<\/section>/)
  assert.ok(sec, 'dc-owned-by-other section must exist')
  assert.match(sec[0], /dot-warn/, 'dc-owned-by-other must use the amber dot-warn (info), not dot-err')
  assert.doesNotMatch(sec[0], /not running/i, 'dc-owned-by-other must NOT say "Bridge not running" — the bridge IS running, just held by another profile')
  assert.match(sec[0], /another.*profile/i, 'dc-owned-by-other must explain another profile owns the bridge')
})

test('popup.html all sections start hidden (background SW resolves state)', () => {
  // Every state section must have `hidden` attribute initially — popup.js
  // flips exactly one based on actual status. Without `hidden`, user sees
  // a flash of multiple states before the first sendMessage resolves.
  const sections = HTML_SRC.match(/<section[^>]*>/g) ?? []
  for (const s of sections) {
    if (/id="(connected|dc-[a-z-]+)"/.test(s)) {
      assert.match(s, /\bhidden\b/, `section missing hidden attribute: ${s}`)
    }
  }
})

test('popup.html dc-not-installed cites bare `tap bridge setup` command', () => {
  // Per ADR 2026-05-13-install-os-managed-daemon.md Slice 3: the extension
  // ID is pinned in manifest.json's `"key"` field. `tap bridge setup` takes
  // no flag, and the popup ships the canonical form as static markup
  // (no per-install ID injection by popup.js).
  assert.match(HTML_SRC, /<code>tap bridge setup<\/code>/,
    'dc-not-installed section must cite the bare `tap bridge setup` command (no --extension-id flag — pinned via manifest "key" field)')
  assert.doesNotMatch(HTML_SRC, /tap bridge setup --extension-id/,
    'must NOT carry the legacy --extension-id template — Slice 3 removed it')
})

test('popup.html dc-host-exited cites Chrome/SW activation, not deleted `tap bridge start` / OS service load', () => {
  // Per ADR 2026-05-14-host-as-daemon.md: host is launched by Chrome's
  // SW (no OS service, no `tap bridge start` user command). Recovery
  // when the bridge isn't running = wake the SW (open Chrome window,
  // retry, or address a host crash). NOT `tap bridge setup` (that's
  // only Setup-required state) and NOT `launchctl`/`systemctl` (no
  // service to load in the host-as-daemon model).
  assert.match(HTML_SRC, /host\.log|Retry|service worker is active|window is open/i,
    'dc-host-exited must point at Chrome/SW activation or host crash diagnostics')
  assert.doesNotMatch(HTML_SRC, /tap bridge start/,
    'must NOT cite `tap bridge start` — deleted in 94c6a43 (CDD I1/I2 guards)')
  // The dc-host-exited section is specifically about the bridge NOT
  // RUNNING — slicing the HTML in two to scope this assertion. The
  // dc-not-installed section still uses `tap bridge setup` and must
  // not be confused with this one.
  const hostExitedSec = HTML_SRC.match(/<section id="dc-host-exited"[\s\S]*?<\/section>/)
  assert.ok(hostExitedSec, 'dc-host-exited section must exist')
  assert.doesNotMatch(hostExitedSec[0], /tap bridge setup/,
    'dc-host-exited must NOT advise `tap bridge setup` — that is the dc-not-installed CTA, not this state')
  assert.doesNotMatch(hostExitedSec[0], /launchctl|systemctl/,
    'dc-host-exited must NOT cite launchctl/systemctl — host-as-daemon eliminates the OS service per ADR 2026-05-14')
})

test('popup.html install link carries UTM params (memory: always-utm-external-shares)', () => {
  assert.match(HTML_SRC, /utm_source=chrome-ext/, 'install link missing utm_source=chrome-ext')
  assert.match(HTML_SRC, /utm_campaign=popup/, 'install link missing utm_campaign=popup')
})

// ─── Static: popup.js wiring invariants ──────────────────────────────────

test('popup.js polls bridge status (live state flip while popup open)', () => {
  // User opens popup, sees disconnected with a CTA, runs the CTA in
  // terminal, popup must flip to connected without re-opening.
  assert.match(JS_SRC, /setInterval\(\s*refresh\s*,\s*2000\s*\)/, 'missing setInterval(refresh, 2000)')
})

test('popup.js cleans up interval on unload (no leak)', () => {
  assert.match(JS_SRC, /clearInterval\(tick\)/, 'missing clearInterval on unload')
})

test('popup.js handles chrome.runtime.lastError as disconnected', () => {
  // Background SW can be asleep; popup must NOT show stale connected
  // state. Treat any sendMessage error as disconnected.
  assert.match(JS_SRC, /chrome\.runtime\.lastError/, 'missing lastError check')
})

test('popup.js wires retry button → tap-retry message', () => {
  assert.match(JS_SRC, /['"`]tap-retry['"`]/, 'retry handler does not send tap-retry message')
})

test('popup.js classifies disconnect reason into 5 buckets', () => {
  // The dispatch function maps Chrome's lastError.message (or the host's
  // B1 signal) → CTA bucket. Must distinguish at least: not-installed /
  // host-exited / forbidden / owned-by-other / unknown (any unrecognized
  // reason falls through to unknown).
  assert.match(JS_SRC, /classifyReason/, 'missing classifyReason function')
  assert.match(JS_SRC, /not-installed/, 'classifier missing not-installed bucket')
  assert.match(JS_SRC, /host-exited/, 'classifier missing host-exited bucket')
  assert.match(JS_SRC, /forbidden/, 'classifier missing forbidden bucket')
  assert.match(JS_SRC, /owned-by-other/, 'classifier missing owned-by-other bucket (B1)')
})

test('popup.js NO LONGER injects extension ID into setup CTA (P4 anti-reintroduce)', () => {
  // Per ADR 2026-05-13-install-os-managed-daemon.md Slice 3: the extension
  // ID is pinned via manifest.json `"key"` and surfaced as the constant
  // EXTENSION_ID_PINNED. The popup ships the canonical command as static
  // markup; popup.js no longer reads chrome.runtime.id, no longer mutates
  // #setup-cmd. A future commit that re-introduces ID injection would
  // fail this assertion — P4 anti-reintroduce guard.
  assert.doesNotMatch(JS_SRC, /setup-cmd/,
    'popup.js must NOT mutate #setup-cmd — the popup ships static markup post-Slice-3')
  assert.doesNotMatch(JS_SRC, /tap bridge setup --extension-id/,
    'popup.js must NOT construct a `--extension-id` command line — flag was removed')
})

// ─── Static dispatch logic check: regex-match the classifier branches ────
//
// Earlier versions of this test re-evaluated popup.js inside a `new Function`
// sandbox to test the actual render() flow against a stub DOM. That broke on
// arrow-function escaping inside the eval scope and the maintenance burden
// outweighed the value vs the structural checks below. The classifier is
// small enough that source-text checks pin every load-bearing branch.

test('classifier maps "not found" lastError → not-installed bucket', () => {
  // Pre-baked Chrome lastError.message for missing native-messaging
  // manifest. classifyReason() must include `/not found/` regex pattern
  // and produce the string 'not-installed' somewhere in close proximity
  // (same function body window).
  assert.match(JS_SRC, /\/not found\/[\s\S]{0,300}['"`]not-installed['"`]/,
    'classifyReason must map /not found/ → "not-installed" bucket')
})

test('classifier maps "has exited" lastError → host-exited bucket', () => {
  // Chrome's "Native host has exited" — daemon down or host crashed.
  assert.match(JS_SRC, /has exited[\s\S]{0,300}['"`]host-exited['"`]/,
    'classifyReason must map /has exited/ → "host-exited" bucket')
})

test('classifier maps "forbidden" lastError → forbidden bucket', () => {
  // Chrome anti-DoS blocklist OR allowed_origins mismatch.
  assert.match(JS_SRC, /\/forbidden\/[\s\S]{0,300}['"`]forbidden['"`]/,
    'classifyReason must map /forbidden/ → "forbidden" bucket')
})

test('classifier maps host_already_running reason → owned-by-other bucket (B1)', () => {
  // The host's B1 signal (ADR 2026-05-30 §5) sets a reason containing
  // "already_running". classifyReason must route it to a distinct
  // owned-by-other bucket — checked BEFORE the generic buckets so it
  // never falls through to host-exited / unknown.
  assert.match(JS_SRC, /already_running[\s\S]{0,200}['"`]owned-by-other['"`]/,
    'classifyReason must map /already_running/ → "owned-by-other" bucket')
  assert.match(JS_SRC, /owned-by-other/,
    'render() must handle the owned-by-other bucket')
})

test('classifier has unknown fallback (raw reason surfaced for debug)', () => {
  // Unrecognized reasons → dc-unknown section + raw text rendered.
  assert.match(JS_SRC, /['"`]unknown['"`]/,
    'classifyReason must have an "unknown" fallback bucket')
  assert.match(JS_SRC, /raw-reason/,
    'render() must populate #raw-reason for the unknown bucket')
})

test('render shows exactly one section per state (no flash-of-multiple)', () => {
  // The showOnly() helper or equivalent must hide all sections except
  // the target. Source check: a SECTION_IDS array (or similar) is
  // iterated, with the matched section set hidden:false and others hidden:true.
  assert.match(JS_SRC, /SECTION_IDS|connected.*dc-not-installed/,
    'popup.js must enumerate all section IDs to drive the hidden flips')
})

test('refresh() lastError-path renders disconnected (no stale "connected" UI)', () => {
  // When chrome.runtime.lastError fires (SW asleep), refresh() must call
  // render with a falsy connected flag — not bail silently leaving a
  // stale "Connected" state visible. Allow generous window because the
  // path may have explanatory comments before the render() call.
  assert.match(JS_SRC,
    /chrome\.runtime\.lastError[\s\S]{0,800}render\s*\(\s*\{[\s\S]{0,200}connected:\s*false/,
    'refresh() must call render({connected:false, ...}) when lastError fires')
})

// ─── Report ──────────────────────────────────────────────────────────────

console.log(`\n${passed} passed, ${failed} failed`)
process.exit(failed === 0 ? 0 : 1)
