/**
 * CLI entry point: prepare everything needed to enter the live variant poll loop.
 *
 * Does (all in one command):
 *   1. Check .impeccable/live/config.json (returns config_missing if first-ever run)
 *   2. Start the live server in the background (or reuse a running one)
 *   3. Inject the browser script tag into the project's entry file
 *   4. Read PRODUCT.md / DESIGN.md for project context
 *   5. Print a single JSON blob with everything the agent needs
 *
 * After this, the agent's only remaining steps are:
 *   - Open the project's live dev/preview URL in the browser (optional, if browser automation exists)—not `serverPort`; that port is the Impeccable helper for /live.js and /poll
 *   - Enter the harness-native poll loop: `node live-poll.mjs`
 *
 * Usage:
 *   node live.mjs                   # Prepare everything, print JSON, exit
 *   node live.mjs --help
 */

import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { resolveTargetSelection } from './context.mjs';
import { resolveFiles } from './live-inject.mjs';
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
import { resolveSurfaceBrief } from './lib/surface-briefs.mjs';
import { resolveLiveTarget } from './live-target.mjs';
import { bootInstructions } from './live/instructions.mjs';
import { resolveRoots, writeRootsManifest } from './live/roots.mjs';

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

async function liveCli() {
  const args = process.argv.slice(2);
  const liveTarget = resolveLiveTarget(process.cwd(), args);

  if (args.includes('--help') || args.includes('-h')) {
    console.log(`Usage: node live.mjs

Prepare everything for live variant mode in a single command:
  - Checks .impeccable/live/config.json (required, created once per project)
  - Starts (or reuses) the live server in the background
  - Injects the browser script tag
  - Reads PRODUCT.md / DESIGN.md for project context
  - Prepares the harness-native foreground/background poll loop
  - In monorepos, choose a child app first; --target <path> is the fallback/manual path

On success, prints a JSON blob with:
  { ok, serverPort, serverToken, pageFiles, projectRoot, repoRoot, targetPath, productPath, designPath }

On target_selection_required, prints:
  { ok: false, error: "target_selection_required", targetCandidates }

On config_missing, prints:
  { ok: false, error: "config_missing", configPath, hint }

The agent should then:
  1. If target_selection_required, ask which app to use and rerun from that child cwd
  2. If config_missing, create the config and re-run this script
  3. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
  4. Enter the poll loop: node live-poll.mjs`);
    process.exit(0);
  }

  // Legacy workspace-monorepo selection first: it carries richer candidate
  // metadata (context inheritance status) than the roots scan.
  const targetSelection = resolveTargetSelection(liveTarget.originalCwd, liveTarget.targetOptions);
  if (targetSelection) {
    console.log(JSON.stringify({
      ok: false,
      error: 'target_selection_required',
      ...targetSelection,
      hint: 'Ask the user which app Impeccable should use, then rerun live from that child app cwd. Use --target <path> only as a fallback or explicit path diagnostic.',
    }, null, 2));
    process.exit(0);
  }

  const rootsResult = resolveRoots({
    cwd: liveTarget.originalCwd,
    targetPath: liveTarget.absoluteTargetPath,
  });
  if (rootsResult.selection) {
    console.log(JSON.stringify({
      ok: false,
      error: 'target_selection_required',
      targetCandidates: rootsResult.selection.candidates,
      hint: 'Several apps with a dev-server config exist. Ask the user which one to use, then rerun with --target <path into that app>.',
    }, null, 2));
    process.exit(0);
  }
  const roots = rootsResult.manifest;
  const activeCwd = roots.appRoot;
  const outputTargetPath = liveTarget.targetPath || null;

  // Gate on readable CONTENT, not path existence, so an empty or unreadable
  // PRODUCT.md routes to init instead of passing the gate and then reporting
  // hasProduct: false in the same payload.
  const product = safeRead(roots.productPath);
  const design = safeRead(roots.designPath);
  const missingContext = [];
  if (!product) missingContext.push('PRODUCT.md');
  if (!design) missingContext.push('DESIGN.md');
  if (missingContext.length > 0) {
    console.log(JSON.stringify({
      ok: false,
      error: 'context_missing',
      missing: missingContext,
      nextCommand: missingContext.includes('PRODUCT.md') ? 'init' : 'document',
      targetPath: outputTargetPath,
      projectRoot: roots.appRoot,
      repoRoot: roots.repoRoot,
      productPath: relOrNull(liveTarget.originalCwd, roots.productPath),
      designPath: relOrNull(liveTarget.originalCwd, roots.designPath),
    }, null, 2));
    process.exit(0);
  }

  // Persist the decision before anything else spawns, so every helper the
  // agent runs later (from any cwd inside the repo) lands on the same roots.
  writeRootsManifest(roots);

  // 1. Check config (fail fast if missing — no point starting anything else)
  const checkOut = runScript('live-inject.mjs', ['--check'], { cwd: activeCwd });
  const checkResult = safeParse(checkOut);
  if (!checkResult || !checkResult.ok) {
    console.log(JSON.stringify({
      ...(checkResult || { ok: false, error: 'check_failed', raw: checkOut }),
      targetPath: outputTargetPath,
      projectRoot: roots.appRoot,
      repoRoot: roots.repoRoot,
    }));
    process.exit(0);
  }

  // 2. Start server (or reuse existing)
  const serverInfo = ensureServerRunning(activeCwd);
  if (!serverInfo) {
    console.log(JSON.stringify({ ok: false, error: 'server_start_failed' }));
    process.exit(1);
  }

  // 3. Inject the script tag at the current port
  const injectOut = runScript(
    'live-inject.mjs',
    ['--port', String(serverInfo.port), '--token', String(serverInfo.token)],
    { cwd: activeCwd },
  );
  const injectResult = safeParse(injectOut);
  if (!injectResult || !injectResult.ok) {
    console.log(JSON.stringify({
      ok: false,
      error: 'inject_failed',
      detail: injectResult || injectOut,
      serverPort: serverInfo.port,
    }));
    process.exit(1);
  }

  // 4. Compute drift-heal: compare resolved inject targets against the
  //    project's HTML files. Orphans are HTML files not covered by config.
  //    Warning only — the agent decides whether to act.
  const resolvedFiles = resolveFiles(activeCwd, checkResult.config);
  const drift = scanForDrift(activeCwd, resolvedFiles, checkResult.config);

  // 5. Emit everything the agent needs. The surface brief rides along so the
  //    agent does not spend three more tool calls (and a --help miss) on
  //    surface-brief.mjs before the first poll.
  let surfaceBrief = null;
  let surfaceBriefPath = null;
  try {
    // Briefs live under .impeccable/surfaces, which in a nested-app repo sits
    // at the CONTEXT or repo root, not the app root; context.mjs already finds
    // them there, and live must not report "no brief" for the same project.
    const briefRoots = [roots.appRoot, roots.contextRoot, roots.repoRoot]
      .filter(Boolean)
      .filter((dir, i, arr) => arr.findIndex((other) => path.resolve(other) === path.resolve(dir)) === i);
    for (const briefRoot of briefRoots) {
      const resolvedBrief = resolveSurfaceBrief(briefRoot, liveTarget.absoluteTargetPath || null);
      if (!resolvedBrief?.brief) continue;
      surfaceBrief = resolvedBrief.brief.text ?? safeRead(resolvedBrief.brief.path);
      surfaceBriefPath = resolvedBrief.brief.path
        ? path.relative(liveTarget.originalCwd, resolvedBrief.brief.path)
        : null;
      break;
    }
  } catch { /* briefs are optional context */ }
  console.log(JSON.stringify({
    ok: true,
    serverPort: serverInfo.port,
    serverToken: serverInfo.token,
    pageFiles: resolvedFiles,
    liveConfigPath: checkResult.path,
    configDrift: drift,
    targetPath: outputTargetPath,
    projectRoot: roots.appRoot,
    repoRoot: roots.repoRoot,
    roots,
    hasProduct: !!product,
    product,
    productPath: relOrNull(liveTarget.originalCwd, roots.productPath),
    hasDesign: !!design,
    design,
    designPath: relOrNull(liveTarget.originalCwd, roots.designPath),
    hasSurfaceBrief: !!surfaceBrief,
    surfaceBrief,
    surfaceBriefPath,
    _instructions: bootInstructions({ scriptsPath: __dirname }),
  }, null, 2));
}

function safeRead(p) {
  if (!p) return null;
  try { return fs.readFileSync(p, 'utf-8'); } catch { return null; }
}

function relOrNull(base, p) {
  return p ? path.relative(base, p) : null;
}

/**
 * Drift-heal scan. Walks the project for HTML files under common
 * page-source directories (public/, src/, app/, pages/) and reports any
 * that aren't covered by the resolved inject targets. This is purely
 * advisory — the agent can ignore it, or suggest the user add the
 * orphans to config.files.
 *
 * Skipped if config.files already contains at least one glob pattern
 * covering everything in practice (signaled by the orphan count being 0).
 */
function scanForDrift(rootDir, resolvedFiles, config) {
  const SCAN_ROOTS = ['public', 'src', 'app', 'pages'];
  const IGNORE_DIRS = new Set([
    'node_modules', '.git', '.next', '.nuxt', '.svelte-kit', '.astro',
    '.turbo', '.vercel', '.cache', 'coverage', 'dist', 'build',
  ]);

  const resolvedSet = new Set(resolvedFiles.map((f) => f.split(path.sep).join('/')));

  // Files matching the user's `exclude` globs are intentional omissions,
  // not drift. Compile them to regexes so the orphan list stays signal.
  const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : [])
    .map((p) => globToRegex(p));
  const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel));

  const orphans = [];

  const walk = (dir, relBase) => {
    let entries;
    try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
    catch { return; }
    for (const e of entries) {
      const rel = relBase ? `${relBase}/${e.name}` : e.name;
      if (e.isDirectory()) {
        if (IGNORE_DIRS.has(e.name) || e.name.startsWith('.')) continue;
        walk(path.join(dir, e.name), rel);
      } else if (e.isFile() && e.name.endsWith('.html')) {
        if (resolvedSet.has(rel)) continue;
        if (isUserExcluded(rel)) continue;
        orphans.push(rel);
      }
    }
  };

  for (const root of SCAN_ROOTS) {
    const abs = path.join(rootDir, root);
    if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
      walk(abs, root);
    }
  }

  if (orphans.length === 0) return null;
  const capped = orphans.slice(0, 20);
  return {
    orphans: capped,
    orphanCount: orphans.length,
    hint: `${orphans.length} HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like "public/**/*.html".`,
  };
}

/**
 * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here
 * to avoid a circular import (live-inject.mjs already imports nothing
 * from live.mjs). The two must stay in sync.
 */
function globToRegex(pattern) {
  let re = '';
  let i = 0;
  while (i < pattern.length) {
    const c = pattern[i];
    if (c === '*') {
      if (pattern[i + 1] === '*') {
        if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; }
        else { re += '.*'; i += 2; }
      } else {
        re += '[^/]*';
        i += 1;
      }
    } else if (c === '?') {
      re += '[^/]';
      i += 1;
    } else if (/[.+^${}()|[\]\\]/.test(c)) {
      re += '\\' + c;
      i += 1;
    } else {
      re += c;
      i += 1;
    }
  }
  return new RegExp('^' + re + '$');
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function runScript(name, args, options = {}) {
  const scriptPath = path.join(__dirname, name);
  try {
    // argv form, never a shell: string interpolation into double quotes would
    // let a `"` or `$(...)` in any future caller's arg escape into the shell
    // (issue #476).
    return execFileSync(process.execPath, [scriptPath, ...args], {
      encoding: 'utf-8',
      cwd: options.cwd || process.cwd(),
      timeout: 15_000,
    });
  } catch (err) {
    // execFileSync throws on non-zero exit; return stdout if any
    return err.stdout || err.message || '';
  }
}

function safeParse(out) {
  try { return JSON.parse(String(out).trim()); } catch { return null; }
}

/**
 * Return { pid, port, token } for the running live server, starting one if needed.
 */
function ensureServerRunning(cwd = process.cwd()) {
  // Try to reuse an existing server
  try {
    const existing = readLiveServerInfo(cwd)?.info;
    if (existing && existing.pid) {
      try {
        process.kill(existing.pid, 0); // throws if dead
        return existing;
      } catch { /* stale PID file — the server script will clean it up */ }
    }
  } catch { /* no PID file */ }

  // Start a new server
  const out = runScript('live-server.mjs', ['--background'], { cwd });
  return safeParse(out);
}

// ---------------------------------------------------------------------------
// Auto-execute
// ---------------------------------------------------------------------------

const _running = process.argv[1];
if (_running?.endsWith('live.mjs') || _running?.endsWith('live.mjs/')) {
  liveCli();
}
