#!/usr/bin/env node
/**
 * Primary CLI for `@exxatdesignux/ui`.
 *
 * Usage (after publish) — prefer **scoped** install so you always run this package’s binary
 * (plain `npx exxat-ui` would fetch a different npm package named `exxat-ui` if it exists):
 *   npx --package=@exxatdesignux/ui@latest exxat-ui init
 *   npx --package=@exxatdesignux/ui@latest exxat-ui update
 *
 * `npm create exxat-app` still maps to `bin/init.mjs` for empty-folder scaffolding.
 */

import { execFileSync, spawnSync } from "child_process"
import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "fs"
import { dirname, resolve } from "path"
import { fileURLToPath } from "url"

const __dirname = dirname(fileURLToPath(import.meta.url))

const cmd = process.argv[2] ?? "help"

function runInitScript(extraArgs = []) {
  try {
    execFileSync(process.execPath, [resolve(__dirname, "init.mjs"), ...extraArgs], {
      stdio: "inherit",
      cwd: process.cwd(),
    })
  } catch (err) {
    process.exit(err?.status ?? 1)
  }
}

function runSyncExtras(extraArgs = []) {
  try {
    execFileSync(process.execPath, [resolve(__dirname, "sync-extras.mjs"), ...extraArgs], {
      stdio: "inherit",
      cwd: process.cwd(),
    })
  } catch (err) {
    process.exit(err?.status ?? 1)
  }
}

function runUpgradeScript(extraArgs = []) {
  try {
    execFileSync(process.execPath, [resolve(__dirname, "upgrade.mjs"), ...extraArgs], {
      stdio: "inherit",
      cwd: process.cwd(),
    })
  } catch (err) {
    process.exit(err?.status ?? 1)
  }
}

function runDevGuard(extraArgs) {
  const script = resolve(__dirname, "../consumer-extras/scripts/dev-guard.mjs")
  if (!existsSync(script)) {
    // Old install — pass through silently so predev hooks never break a build.
    process.exit(0)
  }
  try {
    execFileSync(process.execPath, [script, ...extraArgs], {
      stdio: "inherit",
      cwd: process.cwd(),
    })
  } catch (err) {
    process.exit(err?.status ?? 1)
  }
}

/**
 * `exxat-ui audit <file>` — engineer-facing quality gate.
 *
 * The full audit logic lives in the `exxat-ux-audit` skill (consumed by
 * Cursor / Claude). This subcommand is a *stub* that prints the right
 * invocation for both clients and exits. It is *not* a standalone linter —
 * a full machine audit lives behind the agent so the rules stay in one
 * place (the skill) instead of being duplicated in JS.
 */
function runAudit(args) {
  const target = args[0]
  if (!target) {
    console.error("Usage: exxat-ui audit <file-or-route>\n")
    console.error("Audits a React component / route against the Exxat DS")
    console.error("rules + UX principles (P1–P20) + modern SaaS patterns (M1–M12).\n")
    console.error("This stub prints the agent prompt to run; the actual audit is")
    console.error("performed by the `exxat-ux-audit` skill in your agent client.")
    process.exitCode = 1
    return
  }
  console.log(`
Audit target: ${target}

Run inside Cursor or Claude Code:

  /skill exxat-ux-audit
  audit ${target}

The skill loads:
  • .cursor/rules/exxat-accessibility.mdc      (a11y floor)
  • .cursor/rules/exxat-ux-principles.mdc      (P1–P20)
  • .cursor/rules/exxat-token-discipline.mdc   (no hex)
  • .cursor/rules/exxat-no-toast.mdc           (no Sonner)
  • .cursor/rules/exxat-ds-agents.mdc          (top of stack)

…and produces a Blocker / Issue / Nit findings report with code citations
and a fix plan. Text-only Blocker fixes are auto-applied on confirmation.

For headless / CI use: hit the same skill via the SDK
(\`@cursor/sdk\` Agent.prompt) and grep the JSON output for "Blocker:".
`)
}

/** @returns {number} negative if a < b, 0 if equal, positive if a > b (x.y.z only) */
function compareSemver(a, b) {
  const pa = a.split(".").map(n => parseInt(n, 10) || 0)
  const pb = b.split(".").map(n => parseInt(n, 10) || 0)
  for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
    const da = pa[i] ?? 0
    const db = pb[i] ?? 0
    if (da !== db) return da - db
  }
  return 0
}

function printHelp() {
  console.log(`
@exxatdesignux/ui — CLI

Commands:
  init         Scaffold the Exxat DS starter app (must run in an empty directory).
  sync-extras  Copy packaged Cursor/Claude skills + hooks + DS docs into this repo.
  upgrade      Port safe shell files from template-vite into this consumer app and
               report app-owned files that still need a manual merge.
  update       How to upgrade an existing project that depends on this package.
  changelog    Print release notes from the installed package (release notes + AI upgrade hints).
  doctor       Compare CLI version vs npm "latest" AND verify the brief-gate hook,
               Cursor rules, and skills are installed correctly in this project.
  dev-guard    Detect concurrent Vite dev servers — the #1 memory killer.
               Wire as a "predev" hook to abort if a sibling dev server is already running.
  audit <f>    Print the prompt to audit a file against DS rules + UX principles (P1–P20).
               The audit runs inside your agent client (the exxat-ux-audit skill).

Do NOT use plain npx @exxatdesignux/ui — use the scoped form below.

Upgrade workflow (consumer apps):
  exxat-ui upgrade   → sync-extras + safe shell ports + manual-merge report
  Agent skill        → exxat-package-upgrade for assisted merges of app-owned files

Examples:
  npx --package=@exxatdesignux/ui@latest exxat-ui init
  npx --package=@exxatdesignux/ui@latest exxat-ui sync-extras
  npx --package=@exxatdesignux/ui@latest exxat-ui upgrade
  npx --package=@exxatdesignux/ui@latest exxat-ui doctor
  npx --package=@exxatdesignux/ui@latest exxat-ui update
  npx --package=@exxatdesignux/ui@latest exxat-ui changelog

Legacy (npm create):
  npm create exxat-app@latest
`)
}

function printUpdate() {
  console.log(`
Update @exxatdesignux/ui in your application:

  npm install @exxatdesignux/ui@latest

  # If the lockfile still pins an old release, force the resolver:
  npm install @exxatdesignux/ui@latest --save --prefer-online

  # or (pnpm)
  pnpm add @exxatdesignux/ui@latest

  # or (yarn)
  yarn add @exxatdesignux/ui@latest

That refreshes **components / hooks / tokens** under node_modules only — your app
routes and product copy are unchanged.

After upgrading — for humans and AI tools:

  1. Read release notes on GitHub:
       https://github.com/ExxatDesign/Exxat-DS-Workspace/blob/main/packages/ui/RELEASES.md
     (the release notes are not shipped inside the npm tarball — view the file on
     GitHub or open the matching ui-v<version> GitHub Release.)

  2. Apply the packaged consumer upgrade workflow:
       npx --package=@exxatdesignux/ui@latest exxat-ui upgrade

     This:
       - runs sync-extras
       - ports low-risk package-owned shell files from template-vite
       - reports app-owned shell files that still need a manual merge

  3. For assisted merges of routes / nav / sidebar:
       Load skill exxat-package-upgrade

  4. Compare your app with the reference template when something new is unclear:
       node_modules/@exxatdesignux/ui/template-vite/

**Still seeing old code?** Run **exxat-ui doctor** — the npm "latest" tag can lag
this Git repo until maintainers publish (git tag **ui-v<version>**).

CLI (always pulls this scope):
  npx --package=@exxatdesignux/ui@latest exxat-ui <init|sync-extras|upgrade|update|changelog|doctor|help>

Monorepo / design-system contributors (this workspace): pull latest Git and run
  pnpm install
from the repository root — apps use "workspace:*" for the local package.

Scaffold: exxat-ui init pins @exxatdesignux/ui in the new app to the same semver as
this CLI tarball so npm does not resolve a stale "latest" from cache.
`)
}

function printChangelog() {
  // Release notes stopped shipping inside the npm tarball in 0.5.22 so the
  // file doesn't add ~120 KB of noise to every customer's node_modules. The
  // canonical copy lives on GitHub at packages/ui/RELEASES.md and on every
  // ui-v<version> GitHub Release page. Older tarballs (<= 0.5.21) still
  // contain CHANGELOG.md, so fall back to that path when present.
  const candidates = ["../RELEASES.md", "../CHANGELOG.md"]
  for (const rel of candidates) {
    const p = resolve(__dirname, rel)
    if (existsSync(p)) {
      console.log(readFileSync(p, "utf8"))
      return
    }
  }
  console.log(`Release notes are not bundled with @exxatdesignux/ui (since 0.5.22 — keeps node_modules light).

Read them on GitHub:
  https://github.com/ExxatDesign/Exxat-DS-Workspace/blob/main/packages/ui/RELEASES.md

Each release also has its own GitHub Release page:
  https://github.com/ExxatDesign/Exxat-DS-Workspace/releases/tag/ui-v<version>
`)
}

function checkInstallation() {
  // Verify the Exxat DS extras (brief-gate hook, rules, skills, pattern docs)
  // are actually present in the CONSUMER cwd. Cursor loads hooks at session
  // start, so even when files exist the gate may not be live until the
  // window is restarted — but this command can at least prove the FILES are
  // on disk.
  const cwd = process.cwd()
  const checks = []

  function exists(rel) { return existsSync(resolve(cwd, rel)) }
  function fileMentions(rel, needle) {
    try {
      return readFileSync(resolve(cwd, rel), "utf8").includes(needle)
    } catch { return false }
  }

  checks.push({ label: "Brief-gate hook script",          ok: exists(".cursor/hooks/exxat-brief-gate.mjs"), hint: ".cursor/hooks/exxat-brief-gate.mjs not found — run `exxat-ui sync-extras`" })
  checks.push({ label: "Session-status hook script",      ok: exists(".cursor/hooks/exxat-session-status.mjs"), hint: ".cursor/hooks/exxat-session-status.mjs not found — run `exxat-ui sync-extras` (0.6.2+)" })
  checks.push({ label: "hooks.json wired to brief-gate",  ok: exists(".cursor/hooks.json") && fileMentions(".cursor/hooks.json", "exxat-brief-gate"), hint: ".cursor/hooks.json missing or does not mention exxat-brief-gate" })
  checks.push({ label: "Claude brief-gate hook script",   ok: exists(".claude/hooks/exxat-brief-gate.mjs"), hint: ".claude/hooks/exxat-brief-gate.mjs not found — run `exxat-ui sync-extras`" })
  checks.push({ label: "Claude settings wired to brief-gate", ok: exists(".claude/settings.json") && fileMentions(".claude/settings.json", "exxat-brief-gate"), hint: ".claude/settings.json missing or does not mention exxat-brief-gate" })
  checks.push({ label: "exxat-ux-discovery-protocol rule",ok: exists(".cursor/rules/exxat-ux-discovery-protocol.mdc"), hint: "Cursor rule missing — run `exxat-ui sync-extras`" })
  checks.push({ label: "exxat-senior-ux skill (Cursor)",  ok: exists(".cursor/skills/exxat-senior-ux/SKILL.md"), hint: "Cursor skill missing — run `exxat-ui sync-extras`" })
  checks.push({ label: "exxat-senior-ux skill (Claude)",  ok: exists(".claude/skills/exxat-senior-ux/SKILL.md"), hint: "Claude skill missing — run `exxat-ui sync-extras`" })
  checks.push({ label: "registering-a-product pattern",   ok: exists("docs/exxat-ds/registering-a-product.md"), hint: "Pattern doc missing — run `exxat-ui sync-extras` (0.6.0+)" })
  // postinstall auto-wire (so the gate stays fresh on every install)
  const pkgJson = exists("package.json") ? readFileSync(resolve(cwd, "package.json"), "utf8") : ""
  const postinstallWired = pkgJson.includes("exxat-ui sync-extras")
  checks.push({ label: "package.json postinstall wired",  ok: postinstallWired, hint: "package.json `postinstall` does NOT call exxat-ui sync-extras — re-run sync-extras to auto-wire" })

  // ── BEHAVIOR check — actually exercise the brief-gate ─────────────────
  //
  // Existing checks only verify files are ON DISK. They don't catch the
  // 0.6.3 regression where the hook ran but returned `allow` because the
  // path-extraction logic missed Cursor's `target_file` parameter name.
  //
  // Current decision matrix:
  //   File exists | Pattern match | Decision
  //   ------------|---------------|---------
  //   yes         | yes           | ask  (legit edits — bug fix, copy tweak)
  //   no          | yes           | ask  (new design-critical surface — pure
  //               |               |       design decision, must brief first)
  //   any         | no            | allow
  //
  // This check pipes the FIVE known Cursor / Claude payload shapes through
  // the live hook on disk, with a NEW (non-existent) design-critical path,
  // and verifies every shape returns `permission: "ask"`. Any shape that
  // returns `allow` means the gate is bypassed and the user never sees the
  // checkpoint.
  const hookPath = resolve(cwd, ".cursor/hooks/exxat-brief-gate.mjs")
  if (existsSync(hookPath)) {
    // Design-critical NEW file (never exists; pattern-matches src/pages/).
    const newDesignCriticalPath = "src/pages/__brief_gate_test__.tsx"
    // Benign path: doesn't pattern-match, doesn't exist on disk → allow.
    const benignPath = "scripts/__brief_gate_benign_test__.ts"
    // Each entry is one parameter-name shape Cursor / Claude may send.
    const payloadShapes = [
      { name: "tool_input.path",                    payload: { tool: "Write",       tool_input: { path: newDesignCriticalPath } } },
      { name: "tool_input.target_file",             payload: { tool: "edit_file",   tool_input: { target_file: newDesignCriticalPath } } },
      { name: "tool_input.file_path",               payload: { tool: "write_file",  tool_input: { file_path: newDesignCriticalPath } } },
      { name: "tool_input.relative_workspace_path", payload: { tool: "write",       tool_input: { relative_workspace_path: newDesignCriticalPath } } },
      { name: "tool_input.filePath",                payload: { tool: "Write",       tool_input: { filePath: newDesignCriticalPath } } },
    ]

    function runHook(payload) {
      const res = spawnSync(process.execPath, [hookPath], {
        input: JSON.stringify(payload),
        encoding: "utf8",
        timeout: 5000,
      })
      if (res.status !== 0) return { ok: false, err: `exit=${res.status}` }
      try { return { ok: true, json: JSON.parse(res.stdout || "{}") } }
      catch { return { ok: false, err: `unparseable stdout: ${res.stdout.slice(0, 80)}` } }
    }

    // 1. New design-critical file → MUST ask under every shape.
    const newFileAskFailures = []
    for (const { name, payload } of payloadShapes) {
      const r = runHook(payload)
      if (!r.ok)                                  newFileAskFailures.push(`${name} (${r.err})`)
      else if (r.json.permission !== "ask")       newFileAskFailures.push(`${name} → permission="${r.json.permission}" (expected "ask")`)
    }
    checks.push({
      label: "Brief-gate ASKS for new design-critical files (all 5 Cursor/Claude shapes)",
      ok: newFileAskFailures.length === 0,
      hint: newFileAskFailures.length === 0
        ? ""
        : `Hook does not surface new-file creation for review on: ${newFileAskFailures.join("; ")} — upgrade @exxatdesignux/ui so the brief-gate asks instead of trapping the agent`,
    })

    // 2. Existing design-critical file → MUST ask (so legit edits aren't blocked).
    //
    // Use this CLI file as a stand-in for an existing file: it definitely
    // exists (we're reading from it right now) but doesn't match any design-
    // critical pattern, so we can't use it. Instead, write a temp file inside
    // the cwd's .cursor/ folder (always present at this point) that DOES
    // match a design-critical pattern, hook against it, then clean up.
    //
    // We use a path under `src/pages/` (matched by design-critical pattern)
    // and toggle existence by touch/unlink.
    const existingDcPath = resolve(cwd, "src/pages/__brief_gate_existing_test__.tsx")
    let existingAskOk = true
    let existingAskErr = ""
    try {
      // Ensure parent exists; create the file as a stand-in existing edit.
      const parent = dirname(existingDcPath)
      if (!existsSync(parent)) mkdirSync(parent, { recursive: true })
      const preExisted = existsSync(existingDcPath)
      if (!preExisted) writeFileSync(existingDcPath, "// brief-gate doctor sentinel — safe to delete\n")
      try {
        const r = runHook({ tool: "edit_file", tool_input: { target_file: "src/pages/__brief_gate_existing_test__.tsx" } })
        if (!r.ok)                                  { existingAskOk = false; existingAskErr = r.err }
        else if (r.json.permission !== "ask")       { existingAskOk = false; existingAskErr = `permission="${r.json.permission}" (expected "ask")` }
      } finally {
        if (!preExisted) {
          try { unlinkSync(existingDcPath) } catch {}
        }
      }
    } catch (e) {
      existingAskOk = false
      existingAskErr = `setup error: ${e.message}`
    }
    checks.push({
      label: "Brief-gate ASKS for edits to EXISTING design-critical files (not deny)",
      ok: existingAskOk,
      hint: existingAskOk ? "" : `Existing-file edit returned ${existingAskErr} — would block legitimate bug fixes`,
    })

    // 3. Benign paths → MUST allow.
    //
    // Multiple realistic paths to catch over-eager suffix patterns. The
    // 0.6.5 hook had `-client.(?:tsx?|jsx?)$` (.ts included) which made
    // `lib/api-client.ts`, `lib/http-client.ts`, `lib/auth-client.ts` all
    // deny — every backend client utility false-positive'd. Caught by
    // extending this check after the fresh-install test in test-7/my-app.
    const benignPaths = [
      benignPath,                          // sentinel
      "lib/api-client.ts",                 // 0.6.5 false-positive — -client suffix
      "lib/http-client.ts",
      "lib/auth-client.ts",
      "lib/format-date.ts",                // ordinary utility
      "components/ui/spinner.tsx",         // a leaf UI primitive that's not in templates/data-views/sidebar
    ]
    const benignFailures = []
    for (const p of benignPaths) {
      const r = runHook({ tool: "Write", tool_input: { path: p } })
      if (!r.ok)                              benignFailures.push(`${p} (${r.err})`)
      else if (r.json.permission !== "allow") benignFailures.push(`${p} → permission="${r.json.permission}" (expected "allow")`)
    }
    checks.push({
      label: "Brief-gate ALLOWS benign writes (no false positives)",
      ok: benignFailures.length === 0,
      hint: benignFailures.length === 0
        ? ""
        : `Hook over-eagerly denies/asks for: ${benignFailures.join("; ")} — pattern list needs tightening (upgrade to 0.6.6+)`,
    })
  } else {
    checks.push({
      label: "Brief-gate behavior check (skipped — hook missing)",
      ok: false,
      hint: "Hook file not found; behavior check skipped. Run `exxat-ui sync-extras`.",
    })
  }

  const failed = checks.filter(c => !c.ok)
  console.log("")
  console.log("Installation check (cwd: " + cwd + ")")
  console.log("─".repeat(78))
  for (const c of checks) {
    const mark = c.ok ? "  ✓" : "  ✗"
    console.log(`${mark} ${c.label}`)
  }

  if (failed.length === 0) {
    console.log("")
    console.log("All checks passed.")
    console.log("If the brief-gate still does not fire on edits, RESTART Cursor — hooks")
    console.log("are only loaded at session start.")
  } else {
    console.log("")
    console.log(failed.length + " check(s) FAILED:")
    for (const c of failed) console.log("  - " + c.label + " → " + c.hint)
    console.log("")
    console.log("Fix: npx --package=@exxatdesignux/ui@latest exxat-ui sync-extras")
    console.log("Then: restart Cursor (Cmd+Q, reopen) so hooks reload.")
  }
}

function printDoctor() {
  const pkgPath = resolve(__dirname, "../package.json")
  let cliVersion = "unknown"
  try {
    cliVersion = JSON.parse(readFileSync(pkgPath, "utf8")).version
  } catch {
    /* ignore */
  }
  console.log(`This exxat-ui CLI (package on disk / npx extract): ${cliVersion}`)
  try {
    const reg = execFileSync("npm", ["view", "@exxatdesignux/ui", "version"], {
      encoding: "utf8",
      stdio: ["ignore", "pipe", "pipe"],
    }).trim()
    console.log(`npm registry dist-tag "latest":              ${reg || "(no response)"}`)
    if (reg && reg !== cliVersion) {
      console.log("")
      if (compareSemver(reg, cliVersion) < 0) {
        console.log(
          "The registry is **behind** this CLI package: Git has a newer version that has not been published to npm yet.",
        )
        console.log(
          "Running `npm install @exxatdesignux/ui@latest` only installs **" + reg + "** until maintainers publish (push tag **ui-v" + cliVersion + "**).",
        )
      } else {
        console.log(
          "The registry reports a **newer** version than this CLI — refresh npx/npm cache or run `npx --package=@exxatdesignux/ui@" +
            reg +
            " exxat-ui …` so the CLI matches the registry.",
        )
      }
      console.log("")
      console.log("Options:")
      console.log("  • Install the latest **published** build: npm install @exxatdesignux/ui@" + reg)
      console.log("  • Or pack from a Git checkout: cd packages/ui && npm pack && npm i ../exxatdesignux-ui-*.tgz")
    } else if (reg === cliVersion) {
      console.log("")
      console.log("Registry matches this CLI — @latest should resolve to the same release.")
    }
  } catch (e) {
    console.log("")
    console.log("Could not run `npm view` (offline or npm not in PATH). Error:", (e && e.message) || e)
  }
  checkInstallation()
  console.log("")
}

switch (cmd) {
  case "init":
    runInitScript(process.argv.slice(3))
    break
  case "sync-extras":
    runSyncExtras(process.argv.slice(3))
    break
  case "upgrade":
    runUpgradeScript(process.argv.slice(3))
    break
  case "update":
    printUpdate()
    break
  case "changelog":
    printChangelog()
    break
  case "doctor":
    printDoctor()
    break
  case "dev-guard":
    runDevGuard(process.argv.slice(3))
    break
  case "audit":
    runAudit(process.argv.slice(3))
    break
  case "help":
  case "--help":
  case "-h":
    printHelp()
    break
  default:
    console.error(`Unknown command: ${cmd}\n`)
    printHelp()
    process.exitCode = 1
}
