/**
 * # cli/commands/lang — `parse`/`validate` (+ the arm tier) + `print` (LIGHT, node-runnable)
 *
 * Eager imports are only `../../lang/index.ts` (`parse`, `parseMarkdown`, `print`,
 * `KestrelParseError`) and `node:fs`. No `bun:*`, no `Bun.*`, no chdb — so once bundled to
 * node-target JS these commands run under **plain node** with no bun and no chdb installed. Parse
 * failures become a structured USAGE error (exit 2) built from `KestrelParseError` — never a stack
 * trace.
 *
 * ## The arm tier (kestrel-elu9, epic kestrel-bc38): validation matches execution
 * Parse-green is NOT arm-green: a document naming `VELOCITY` (a case-variant of the WINDOWED metric
 * `velocity`) parses clean, then REFUSES at arm time — so a bare `validate` answering `valid: true`
 * taught authors a truth the engine then contradicted. `--arm` (or `--bus`/`--instruments`, which
 * imply it) additionally drives {@link import("../../engine/validate.ts").validateArm} — the ONE
 * arm-level surface, which runs the engine's REAL `armDocument` — so the refusal this command prints
 * is byte-identical to the message a real `run` raises. The engine graph is Worker/node-portable
 * (no `bun:*` specifiers — the same graph the hosted /validate route runs), reached through a lazy
 * `import()` so the eager LIGHT graph of this module is unchanged and the arm tier never loads for a
 * pure-parse invocation (whose output and exit codes stay byte-stable).
 */

import { readFileSync } from "node:fs";

import { parse, parseMarkdown, print, KestrelParseError, type KestrelNode } from "../../lang/index.ts";
import { canonicalPlansText } from "../../canonical/plans.ts";
// The tape reader (node:fs + the typed event guards — no bun:*, no chdb): light, and statically
// imported for the same bundler reason as ./specs.ts below.
import { readBus } from "../../bus/read.ts";
import type { InstrumentSpec } from "../../engine/plans.ts";
// The driver's PURE header rules (META → instruments): three tiny functions with type-only imports —
// zero runtime dependencies, so they sit on the eager LIGHT graph. Statically imported, deliberately:
// a dynamic `import()` here makes the bundler mint them as their own split chunk while the heavy
// chunk ALSO re-exports them (via session/sim), and the emitted heavy chunk then carries duplicate
// export statements that node rejects at load ("Duplicate export of 'requireMeta'").
import { requireMeta, resolveSpecs, specFromMeta } from "../../session/specs.ts";
import type { OutputCtx } from "../context.ts";
import { parseArgs } from "../args.ts";
import { CliError, EXIT } from "../errors.ts";

const GREEN = "[32m";
const RESET = "[0m";

function usageErr(message: string, hint?: string): CliError {
  return new CliError({ code: "USAGE", exit: EXIT.USAGE, message, ...(hint !== undefined ? { hint } : {}) });
}

/** Wrap a `KestrelParseError` (or any parse throw) into a loud, stack-free USAGE error. */
function asParseError(e: unknown): CliError {
  if (e instanceof KestrelParseError) {
    return new CliError({
      code: "PARSE",
      exit: EXIT.USAGE,
      message: e.message, // already carries `(line L, col C)`
    });
  }
  return usageErr(e instanceof Error ? e.message : String(e));
}

/** A loaded plans doc: the parsed documents plus the raw text + markdown-ness the arm tier needs. */
interface LoadedDocuments {
  readonly path: string;
  readonly documents: readonly KestrelNode[];
  readonly text: string;
  readonly asMarkdown: boolean;
}

/** Read + parse a plans doc from already-parsed argv pieces: `.md` (or `--md`) → fenced ```kestrel
 * blocks; else a single doc. `--single` forces the single-document parser. */
function readDocuments(positionals: readonly string[], bools: ReadonlySet<string>): LoadedDocuments {
  const path = positionals[0];
  if (path === undefined) throw usageErr("expected a <file> path");

  let text: string;
  try {
    text = readFileSync(path, "utf8");
  } catch (e) {
    throw new CliError({
      code: "NOT_FOUND",
      exit: EXIT.NOT_FOUND,
      message: `cannot read ${JSON.stringify(path)}: ${e instanceof Error ? e.message : String(e)}`,
    });
  }

  const asMarkdown = bools.has("md") || (!bools.has("single") && path.endsWith(".md"));
  try {
    const documents = asMarkdown ? parseMarkdown(text) : [parse(text)];
    return { path, documents, text, asMarkdown };
  } catch (e) {
    throw asParseError(e);
  }
}

/** Read + parse a plans doc from a raw argv. Mirrors the old `loadDocuments`. */
function loadDocuments(argv: readonly string[]): LoadedDocuments {
  const { positionals, bools } = parseArgs(argv, new Set(["md", "single"]));
  return readDocuments(positionals, bools);
}

/** Count the statements across a document set (a `module` node carries a `statements` array; a
 * bare statement counts as one). */
function countStatements(documents: readonly KestrelNode[]): number {
  let n = 0;
  for (const d of documents) {
    n += d.kind === "module" ? d.statements.length : 1;
  }
  return n;
}

/**
 * Derive the arm-context instruments the flags name — REUSING the run driver's own derivations,
 * never a re-implementation that could drift (AGENTS.md: validation must match execution):
 *
 * - `--bus <tape>`: `readBus` → `requireMeta` → `resolveSpecs` — the exact call chain
 *   `runSimSession` opens a session with (`src/session/sim.ts`), so this command validates against
 *   the very instruments a real `run --bus <tape>` would arm with.
 * - `--instruments <SYM[,SYM…]>`: each symbol through the driver's own META→spec rule
 *   (`specFromMeta`, `src/session/specs.ts`) as an `option-underlier` — the standard listed-option
 *   shape (multiplier 100) every session META fixture declares. A DOCUMENTED default, not a silent
 *   one; pass `--bus` to derive from a real tape instead.
 * - neither: the EMPTY context — handed to `validateArm`, whose context tier REFUSES it
 *   (fail-closed), surfaced as `ARM_CONTEXT`. Never a silent pass.
 */
/** The arm context the flags name: the session instruments AND — when `--bus` is given — the tape's
 * complete regime-scope vocabulary (kestrel-hk9u), so `validate --bus <tape>` gives the EXACT regime
 * verdict a `run --bus <tape>` raises (a scope the tape writes → no notice; one it never writes →
 * dead-on-arrival). Without `--bus` the vocabulary is left undefined (validateArm's closed-world `[]`
 * default), so a regime-gated plan warns that the named context carries no regime stream. */
interface ArmFlags {
  readonly instruments: readonly InstrumentSpec[];
  readonly tapeRegimeScopes?: readonly string[];
}

// Exported so the `--instruments` option-underlier spec-shape fixture (kestrel-2zx0) can drive the
// EXACT derivation the CLI runs — the derived InstrumentSpec's multiplier is consumed only later (at
// fill/settle), so no `validate` verdict can observe it; the fixture asserts the shape on this real
// derivation function rather than a re-implementation that could drift.
export async function deriveArmInstruments(flags: Map<string, string>): Promise<ArmFlags> {
  const busPath = flags.get("bus");
  const instruments = flags.get("instruments");
  if (busPath !== undefined && instruments !== undefined) {
    throw usageErr(
      "pass either --bus or --instruments, not both",
      "--bus derives the session instruments from the tape's META; --instruments names them directly",
    );
  }
  if (busPath !== undefined) {
    try {
      const events = [...readBus(busPath)];
      // The tape's regime vocabulary, scanned exactly as `tapeRegimeScopesOf` does (src/session/sim.ts) —
      // inlined here (a two-line scan) so this LIGHT, node-portable command graph does not pull in the
      // heavy session module. A `run --bus <tape>` pre-scans the identical set, so the verdicts match.
      const tapeRegimeScopes = [...new Set(events.filter((e) => e.stream === "REGIME").map((e) => e.scope))];
      return { instruments: resolveSpecs(requireMeta(events)), tapeRegimeScopes };
    } catch (e) {
      // A malformed/absent tape or a META-less bus is the CALLER's input being unusable — loud, with
      // the session layer's own message verbatim (it already names the RUNTIME §8 rule that failed).
      throw new CliError({
        code: "BUS",
        exit: EXIT.USAGE,
        message: `--bus ${JSON.stringify(busPath)}: ${e instanceof Error ? e.message : String(e)}`,
      });
    }
  }
  if (instruments !== undefined) {
    const symbols = instruments
      .split(",")
      .map((s) => s.trim())
      .filter((s) => s.length > 0);
    if (symbols.length === 0) {
      throw usageErr("--instruments: expected a comma-separated symbol list (e.g. SPY or SPX,SPY)");
    }
    return { instruments: symbols.map((symbol) => specFromMeta({ symbol, assetClass: "option-underlier" })) };
  }
  return { instruments: [] };
}

/**
 * Run the arm tier over every loaded document (fail-closed, first refusal wins). A refusal throws a
 * `CliError` whose `message` is the {@link import("../../engine/validate.ts").ArmDiagnostic} message
 * VERBATIM — `validateArm` drives the engine's real `armDocument`, so what this command prints is
 * byte-identical to the refusal a real run raises (the load-bearing kestrel-bc38 contract; pinned by
 * `tests/cli/validate-arm.test.ts`). Exit code is `EXIT.USAGE` (2) for every tier — the same bucket
 * as a parse failure, because for an author the outcome class is identical ("the input is defective,
 * fix it before running") — with DISTINCT stable codes (`ARM` / `ARM_CONTEXT` / `PARSE`), since
 * agents match on `code`, never on prose or exit numbers (cli/errors doctrine).
 */
async function runArmTier(loaded: LoadedDocuments, flags: Map<string, string>): Promise<readonly string[]> {
  // Lazy, node-portable edge (NOT `loadHeavy`): the engine graph carries no `bun:*` specifier — it is
  // the Worker-portable graph the hosted /validate route runs — so it loads under plain node; the
  // lazy import just keeps it off the eager light graph and out of pure-parse invocations.
  const { validateArm } = await import("../../engine/validate.ts");
  const { instruments, tapeRegimeScopes } = await deriveArmInstruments(flags);

  if (loaded.documents.length === 0) {
    // A fenced-block-less markdown file has nothing to arm; `armed` on zero documents would be a
    // verdict about nothing (fail-closed — mirrors validateArm's own empty-context refusal).
    throw usageErr("--arm: no kestrel documents found — nothing to validate at the arm tier");
  }

  // The single-doc path arms the RAW text; markdown blocks are re-printed canonically (`print`),
  // which the round-trip invariant makes semantics-preserving (`parse ∘ print` is identity) — the
  // arm tier evaluates the object model, never the surface bytes.
  const texts = loaded.asMarkdown ? loaded.documents.map((d) => print(d)) : [loaded.text];
  const warnings: string[] = [];
  for (let i = 0; i < texts.length; i += 1) {
    const v = validateArm(texts[i]!, { instruments, ...(tapeRegimeScopes !== undefined ? { tapeRegimeScopes } : {}) });
    // Non-fatal notices (kestrel-hk9u): a regime gate that can never fire on this context. Collected
    // and surfaced with the verdict (exit 0) — the document IS valid, the author just needs to know it
    // will no-op on a regime-less tape. Never demoted to silence, never promoted to a refusal.
    for (const w of v.warnings) warnings.push(w.message);
    if (v.armed) continue;
    const d = v.diagnostics[0]!;
    if (d.tier === "context") {
      throw new CliError({
        code: "ARM_CONTEXT",
        exit: EXIT.USAGE,
        message: d.message,
        hint: "derive the session context: --bus <tape> (instruments from its META, exactly as `run` resolves them) or --instruments <SYM[,SYM…]>",
      });
    }
    if (d.tier === "parse") {
      // Unreachable in practice — `readDocuments` already parsed every document — kept fail-closed
      // so a future tier reordering can never demote a parse escape to a silent pass.
      throw new CliError({ code: "PARSE", exit: EXIT.USAGE, message: d.message });
    }
    throw new CliError({
      code: "ARM",
      exit: EXIT.USAGE,
      message: d.message,
      hint:
        texts.length > 1
          ? `document ${i + 1} of ${texts.length} parses but refuses to arm — this is the refusal a run would raise`
          : "the document parses but refuses to arm — this is the refusal a run would raise",
    });
  }
  return warnings;
}

/** `parse <file>` / `validate <file>` — parse + validate, report doc/statement counts. With `--arm`
 * (or `--bus`/`--instruments`, which imply it) also run the engine's arm tier; see the header. */
export async function validateCommand(argv: readonly string[], ctx: OutputCtx): Promise<number> {
  const { positionals, flags, bools } = parseArgs(
    argv,
    new Set(["md", "single", "arm"]),
    new Set(["bus", "instruments"]),
  );
  const loaded = readDocuments(positionals, bools);
  const nDocs = loaded.documents.length;
  const nStmts = countStatements(loaded.documents);

  // OPT-IN, deliberately: the arm tier needs a session context no bare file supplies, so always-on
  // would flip every existing pure-parse invocation into a context refusal (and break the stable
  // exit-code contract). Without these flags the output below stays BYTE-IDENTICAL to before.
  const armTier = bools.has("arm") || flags.has("bus") || flags.has("instruments");
  // Non-fatal arm warnings (kestrel-hk9u) — collected only when the arm tier ran, empty otherwise.
  const warnings = armTier ? await runArmTier(loaded, flags) : [];

  if (ctx.mode === "json") {
    process.stdout.write(
      JSON.stringify({
        schema: "kestrel.validate/v1",
        valid: true,
        documents: nDocs,
        statements: nStmts,
        // Additive and CONDITIONAL: present only when the arm tier actually ran, so a pure-parse
        // `valid: true` can never be mistaken for an arm-level verdict (and stays byte-stable).
        ...(armTier ? { armed: true } : {}),
        // Non-fatal arm notices — present ONLY when the arm tier ran AND found one, so a clean arm
        // (and every pure-parse invocation) stays byte-identical to before.
        ...(warnings.length > 0 ? { warnings } : {}),
      }) + "\n",
    );
  } else if (ctx.mode === "text") {
    process.stdout.write(`valid\tdocuments=${nDocs}\tstatements=${nStmts}${armTier ? "\tarmed=true" : ""}\n`);
    // One tab-framed line per warning (stable, greppable) — after the verdict line, so a consumer
    // reading only the first line is unaffected.
    for (const w of warnings) process.stdout.write(`warning\t${w}\n`);
  } else {
    const armSuffix = armTier ? (warnings.length > 0 ? " — arms clean, with warnings" : " — arms clean") : "";
    const msg = `✓ valid — ${nDocs} document(s), ${nStmts} statement(s)${armSuffix}`;
    process.stdout.write((ctx.color ? `${GREEN}${msg}${RESET}` : msg) + "\n");
    for (const w of warnings) process.stdout.write(`⚠ ${w}\n`);
  }
  return 0;
}

/** `print <file>` — the canonical re-print (the payload IS the text in every mode). */
export function printCommand(argv: readonly string[], ctx: OutputCtx): number {
  const { documents } = loadDocuments(argv);
  const text = canonicalPlansText(documents);

  if (ctx.mode === "json") {
    process.stdout.write(JSON.stringify({ schema: "kestrel.print/v1", text }) + "\n");
  } else {
    process.stdout.write(text + "\n");
  }
  return 0;
}
