// C9-H13 (D6-SA6.3-F1): `hatch3r explain --cost <command>` surfaces the
// triage-first cost model declared in canonical command frontmatter. The
// `estimateUsdCost` helper from `src/pipeline/costEstimator.ts` (the canonical
// cost module per Decision 24; F3.4-F2 merged the former observability copy
// into it) produces per-token USD figures; this command wires it to the
// `triage_tiers` array so users can answer "what will this command cost at each
// triage tier?" without running the command.
//
// Cost model (deterministic, character-heuristic based — no provider lookup):
//   tier 1 (trivial / single-agent):  1 sub-agent invocation
//   tier 2 (standard pipeline):       length(agentPipeline) sub-agent invocations
//   tier 3 (research-first):          length(agentPipeline) + 1 (research mode)
//
// D6-23 (Cycle 11 Wave 3): input-token basis is per-actor, not body×subAgents.
// The orchestrator reads the command body once; each sub-agent it spawns loads
// its OWN agent definition (`agents/hatch3r-<id>.md` from `agentPipeline`) plus a
// task-scoped prompt — it does NOT receive a copy of the whole orchestrator body.
// Billing the full body to every sub-agent (the pre-fix basis) over-counted input
// by `body_tokens × (subAgents − 1)` per tier. The corrected per-tier input is
//   orchestrator_body_tokens (once)
//     + Σ over each spawned sub-agent ( agent_def_tokens + TASK_CONTEXT_ALLOWANCE ).
// Agent-def sizes are read from the bundled `agents/` dir; an unresolved id (e.g.
// the tier-1 inline actor or the tier-3 researcher when its file is absent) falls
// back to UNKNOWN_AGENT_DEF_CHARS. Output tokens are estimated as one-quarter of
// input — a conservative ratio that matches typical plan/act split ratios
// documented in `agents/shared/efficiency-patterns.md` P5.
//
// SA12.3-F03 (Cycle 10 Wave 2): `hatch3r explain --customizations` surfaces
// the per-artifact customization-applied state (yaml + md overrides, skips,
// fail-closed drops) that previously stayed silent under the Silent Failure
// Contract. The mode shares no logic with `--cost`; it dry-calls
// `applyCustomization` across the bundled content root and renders the
// result via `buildCustomizationSummary` (`src/adapters/customizationSummary.ts`).

import { readFile, access } from "node:fs/promises";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import chalk from "chalk";
import { parse as parseYaml } from "yaml";
import {
  CHARS_PER_TOKEN,
  estimateTokens,
  type PipelineTokenSummary,
} from "../../pipeline/observability.js";
// F3.4-F2 (Cycle 10 Wave 2): cost computation comes from the canonical cost
// module (Decision 24), not the observability shadow copy. estimateUsdCost +
// the DEFAULT_* rate constants are the single source of truth for token→USD.
import {
  DEFAULT_INPUT_COST_PER_1M,
  DEFAULT_OUTPUT_COST_PER_1M,
  estimateUsdCost,
  resolveModelRate,
  MODEL_RATES,
} from "../../pipeline/costEstimator.js";
import { HatchError, HATCH3R_DIR } from "../../types.js";
import { HATCH3R_VERSION } from "../../version.js";
import { findPackageRoot } from "../shared/paths.js";
import { printBox, label, info, error as logError, isQuiet, verbose } from "../shared/ui.js";
import { emitJson, type CliOutputFormat } from "../shared/output.js";
import { beginCommand, finishCommand } from "../shared/commandOutput.js";
import { readManifest } from "../../manifest/hatchJson.js";
import {
  buildCustomizationSummary,
  selectionSetFromManifest,
  type CustomizationStatus,
} from "../../adapters/customizationSummary.js";

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

const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;

/**
 * Parsed view of a command file's efficiency-relevant frontmatter fields.
 * Only the four fields needed for cost computation are typed — everything
 * else (description, tags, etc.) is ignored here.
 */
interface CommandFrontmatter {
  id: string;
  orchestrator: boolean;
  agentPipeline: string[];
  triageTiers: number[];
}

/**
 * Per-tier cost row in the output. Each row reports the sub-agent count, the
 * estimated token spend (input + output), and the USD cost for a single
 * invocation at that tier.
 */
interface TierCostRow {
  tier: number;
  label: string;
  subAgents: number;
  inputTokens: number;
  outputTokens: number;
  totalTokens: number;
  usd: number;
}

/**
 * Resolve a user-supplied command id to an on-disk `.md` path. The lookup
 * order matches the canonical-vs-installed split that the rest of the CLI
 * uses: prefer `.agents/commands/` inside the user's repo (the installed
 * copy), fall back to the canonical `commands/` directory bundled with the
 * hatch3r package (dev-mode / framework-development).
 *
 * The id is normalized to allow either `hatch3r-quick-change` or
 * `quick-change`; both map to `commands/hatch3r-quick-change.md`.
 */
async function resolveCommandPath(rootDir: string, commandId: string): Promise<string> {
  const normalized = commandId.startsWith("hatch3r-")
    ? commandId
    : `hatch3r-${commandId}`;
  const filename = `${normalized}.md`;

  const candidates = [
    // Wave 7: legacy `.agents/commands/` probe for pre-1.9 installs that
    // still ship a project-side canonical tree. New installs resolve via
    // the bundled package root (second candidate).
    join(rootDir, ".agents", "commands", filename),
    join(findPackageRoot(__dirname), "commands", filename),
  ];

  for (const candidate of candidates) {
    try {
      await access(candidate);
      return candidate;
    } catch (err) {
      if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
    }
  }

  throw new HatchError(
    `Command not found: ${commandId}. Looked in ${candidates.join(" and ")}.`,
    undefined,
    "FS_ERROR",
    "Run `hatch3r explain --cost <id>` with a known command id (e.g. hatch3r-quick-change), or check the `commands/` directory for the exact filename.",
  );
}

/**
 * Parse the YAML frontmatter from a command file and extract the four fields
 * needed for cost computation. Throws when the file lacks a frontmatter
 * block or when `orchestrator: true` is declared without `triage_tiers`.
 */
function parseCommandFrontmatter(raw: string, filePath: string): CommandFrontmatter {
  const match = raw.match(FRONTMATTER_REGEX);
  if (!match) {
    throw new HatchError(
      `Missing frontmatter in ${filePath}. Cost estimation requires id + orchestrator + triage_tiers.`,
      undefined,
      "VALIDATION_ERROR",
      "Add a YAML frontmatter block with `id`, `orchestrator`, and `triage_tiers` to the command file, then re-run.",
    );
  }

  const [, frontmatterStr] = match;
  const parsed = parseYaml(frontmatterStr ?? "") as Record<string, unknown> | null;

  if (!parsed || typeof parsed !== "object") {
    throw new HatchError(
      `Invalid frontmatter in ${filePath}: expected YAML object.`,
      undefined,
      "VALIDATION_ERROR",
      "Fix the frontmatter so it parses to a YAML mapping (key: value pairs between the `---` fences), then re-run.",
    );
  }

  const id = typeof parsed.id === "string" ? parsed.id : "";
  const orchestrator = typeof parsed.orchestrator === "boolean" ? parsed.orchestrator : false;

  const agentPipelineRaw = parsed.agentPipeline;
  const agentPipeline = Array.isArray(agentPipelineRaw)
    ? agentPipelineRaw.filter((a: unknown): a is string => typeof a === "string")
    : [];

  const triageRaw = parsed.triage_tiers;
  const triageTiers = Array.isArray(triageRaw)
    ? triageRaw.filter((n: unknown): n is number => Number.isInteger(n) && (n === 1 || n === 2 || n === 3))
    : [];

  if (orchestrator && triageTiers.length === 0) {
    throw new HatchError(
      `${id || filePath} is declared orchestrator: true but has no triage_tiers — cost cannot be split per tier.`,
      undefined,
      "VALIDATION_ERROR",
      "Add a `triage_tiers` array (e.g. [1, 2, 3]) to the command frontmatter, or set `orchestrator: false` if it does not fan out.",
    );
  }

  return { id, orchestrator, agentPipeline, triageTiers };
}

/**
 * D6-23: the canonical id for the tier-3 research mode. The +1 over the tier-2
 * pipeline is a research-first pass; it loads this agent's definition like any
 * other spawned sub-agent. A `null` element in a tier's id list marks an actor
 * with no resolvable agent-def file (the tier-1 inline path, or a pipeline that
 * declares no agents) and bills at {@link UNKNOWN_AGENT_DEF_CHARS}.
 */
const RESEARCH_MODE_AGENT_ID = "hatch3r-researcher";

/**
 * D6-23: per-sub-agent task-scoped prompt allowance in characters. Each spawned
 * sub-agent receives a task-context block (the specific files/instructions the
 * orchestrator hands it) ON TOP OF its own agent definition — it is NOT a copy of
 * the orchestrator body. 6,000 chars (~1,500 tokens at CHARS_PER_TOKEN) is a
 * conservative per-task allowance: larger than a one-line directive, smaller than
 * a full re-statement of the orchestrator body. Kept a single named constant so a
 * future calibration against `agents/shared/efficiency-patterns.md` is one edit.
 */
const TASK_CONTEXT_ALLOWANCE_CHARS = 6_000;

/**
 * D6-23: fallback agent-def size (characters) for a spawned actor whose
 * `agents/<id>.md` cannot be read — the tier-1 inline path (no distinct
 * definition file), a pipeline that declares no agents, or the tier-3 researcher
 * when its file is absent. 12,000 chars (~3,000 tokens) approximates a mid-sized
 * canonical agent definition so an unresolved actor is neither free nor
 * over-weighted relative to a real agent def.
 */
const UNKNOWN_AGENT_DEF_CHARS = 12_000;

/**
 * Triage-tier sub-agent fan-out model. The numbers come from the tier
 * definitions in `agents/shared/efficiency-patterns.md` P3 (triage-first
 * orchestration) and the audit-execute tier classifier in
 * `governance/AUDIT-EXECUTE.md`:
 *
 *   Tier 1 — trivial / single-agent path (1 invocation)
 *   Tier 2 — standard pipeline (one per pipeline stage)
 *   Tier 3 — research-first (standard pipeline + 1 researcher mode)
 *
 * D6-23: returns the per-actor agent-id list (not just a count) so the cost model
 * can size each spawned sub-agent by its own `agents/<id>.md`. A `null` entry is
 * an actor with no resolvable agent-def file (tier-1 inline path; or a tier-2/3
 * command that declares no `agentPipeline`). The sub-agent count is the list
 * length, preserving the prior `Math.max(1, pipelineLength)[+1]` totals.
 */
function subAgentIdsForTier(tier: number, pipeline: string[]): (string | null)[] {
  const nonEmptyPipeline = pipeline.length > 0 ? pipeline : [null];
  switch (tier) {
    case 1:
      // Trivial path: a single inline actor with no distinct agent-def file.
      return [null];
    case 2:
      return [...nonEmptyPipeline];
    case 3:
      return [...nonEmptyPipeline, RESEARCH_MODE_AGENT_ID];
    default:
      return [...nonEmptyPipeline];
  }
}

function tierLabel(tier: number): string {
  switch (tier) {
    case 1:
      return "Tier 1 (trivial)";
    case 2:
      return "Tier 2 (standard)";
    case 3:
      return "Tier 3 (research-first)";
    default:
      return `Tier ${tier}`;
  }
}

/**
 * Compute per-tier cost rows for the command. Each row models one full
 * invocation of the command at that tier.
 *
 * D6-23: input tokens use a per-actor basis, not body×subAgents. The orchestrator
 * reads its own body once; each spawned sub-agent loads its own agent definition
 * (`agents/<id>.md`, sized via `agentDefCharCounts`) plus a task-context allowance
 * — it does not receive a copy of the orchestrator body. A `null` actor id (the
 * tier-1 inline path, or a tier-2/3 command with no `agentPipeline`) is sized at
 * {@link UNKNOWN_AGENT_DEF_CHARS}. Output tokens stay a 0.25 ratio of input.
 *
 * @param agentDefCharCounts resolved char count per spawnable agent id (read from
 *   the bundled `agents/` dir at the call site). Missing ids fall back to
 *   {@link UNKNOWN_AGENT_DEF_CHARS}, so the function stays pure and I/O-free.
 */
function computeTierRows(
  fm: CommandFrontmatter,
  bodyCharCount: number,
  agentDefCharCounts: ReadonlyMap<string, number>,
  options: { inputCostPer1M: number; outputCostPer1M: number; cacheHitRatio: number },
): TierCostRow[] {
  const orchestratorBodyTokens = estimateTokens(bodyCharCount, CHARS_PER_TOKEN);

  const rows: TierCostRow[] = [];
  for (const tier of fm.triageTiers) {
    const actorIds = subAgentIdsForTier(tier, fm.agentPipeline);
    const subAgents = actorIds.length;

    // Per-actor input: that sub-agent's own agent definition + a task-context
    // allowance. The orchestrator body is added ONCE for the run, below.
    let subAgentInputTokens = 0;
    for (const actorId of actorIds) {
      const defChars =
        actorId !== null && agentDefCharCounts.has(actorId)
          ? agentDefCharCounts.get(actorId)!
          : UNKNOWN_AGENT_DEF_CHARS;
      subAgentInputTokens += estimateTokens(
        defChars + TASK_CONTEXT_ALLOWANCE_CHARS,
        CHARS_PER_TOKEN,
      );
    }
    const inputTokens = orchestratorBodyTokens + subAgentInputTokens;
    const outputTokens = Math.ceil(inputTokens / 4);
    // Build a one-phase summary so we go through the canonical estimateCost
    // path (instead of duplicating its multiplication / threshold logic).
    // PhaseName is a closed enum in src/pipeline/phaseTimeout.ts; "generation"
    // is the closest semantic match for sub-agent fan-out work.
    const summary: PipelineTokenSummary = {
      phases: [
        {
          phase: "generation",
          inputTokens,
          outputTokens,
          totalTokens: inputTokens + outputTokens,
        },
      ],
      totalInputTokens: inputTokens,
      totalOutputTokens: outputTokens,
      grandTotal: inputTokens + outputTokens,
    };
    const cost = estimateUsdCost(summary, {
      inputCostPer1M: options.inputCostPer1M,
      outputCostPer1M: options.outputCostPer1M,
      cacheHitRatio: options.cacheHitRatio,
    });
    rows.push({
      tier,
      label: tierLabel(tier),
      subAgents,
      inputTokens,
      outputTokens,
      totalTokens: inputTokens + outputTokens,
      usd: cost.totalCost,
    });
  }
  return rows;
}

/**
 * D6-23: resolve the char count of each spawnable sub-agent's definition file.
 * Reads `agents/<id>.md` from the bundled package root for every distinct id that
 * appears across the command's declared tiers (the `agentPipeline` plus the
 * tier-3 {@link RESEARCH_MODE_AGENT_ID}). An id whose file is missing or
 * unreadable is omitted from the map so the pure {@link computeTierRows} falls
 * back to {@link UNKNOWN_AGENT_DEF_CHARS} — a missing agent definition must not
 * make `explain --cost` throw (the command body parsed fine; only sizing is
 * affected). Reads run in parallel since they touch disjoint files.
 */
async function resolveAgentDefCharCounts(fm: CommandFrontmatter): Promise<Map<string, number>> {
  const ids = new Set<string>(fm.agentPipeline);
  // The tier-3 research mode is only spawned when tier 3 is declared.
  if (fm.triageTiers.includes(3)) ids.add(RESEARCH_MODE_AGENT_ID);

  const agentsDir = join(findPackageRoot(__dirname), "agents");
  const counts = new Map<string, number>();
  await Promise.all(
    [...ids].map(async (id) => {
      try {
        const raw = await readFile(join(agentsDir, `${id}.md`), "utf-8");
        counts.set(id, raw.length);
      } catch (err) {
        // Missing / unreadable agent def → leave unmapped; computeTierRows
        // sizes it at UNKNOWN_AGENT_DEF_CHARS. A sizing miss must not abort the
        // estimate (the command body parsed fine), so emit a verbose-only
        // diagnostic (Silent Failure Contract, CONSTITUTION §2 P5) and continue.
        verbose(
          `explain --cost: could not size agent def for ${id} (${err instanceof Error ? err.message : String(err)}); using ~${formatTokens(estimateTokens(UNKNOWN_AGENT_DEF_CHARS, CHARS_PER_TOKEN))}-token fallback.`,
        );
      }
    }),
  );
  return counts;
}

function formatTokens(n: number): string {
  return n.toLocaleString("en-US");
}

function formatUsd(usd: number): string {
  if (usd >= 1) return `$${usd.toFixed(2)}`;
  return `$${usd.toFixed(4)}`;
}

/**
 * D12-9: word-wrap `text` into segments no wider than `width`, preferring
 * whitespace boundaries. A single token longer than `width` (e.g. a long path or
 * id with no spaces) is hard-sliced so no segment ever exceeds the column. An
 * empty/whitespace-only input yields `[""]` so the caller always has a first
 * segment to render. Operates on plain (un-styled) text — callers wrap the
 * customization reason, which carries no ANSI codes.
 */
function wrapToWidth(text: string, width: number): string[] {
  const safeWidth = Math.max(1, width);
  const normalized = text.replace(/\s+/g, " ").trim();
  if (normalized.length === 0) return [""];

  const segments: string[] = [];
  let current = "";
  for (const word of normalized.split(" ")) {
    // A word longer than the column is hard-sliced into width-sized chunks.
    if (word.length > safeWidth) {
      if (current.length > 0) {
        segments.push(current);
        current = "";
      }
      for (let i = 0; i < word.length; i += safeWidth) {
        const chunk = word.slice(i, i + safeWidth);
        if (chunk.length === safeWidth) segments.push(chunk);
        else current = chunk; // trailing partial chunk starts the next line
      }
      continue;
    }
    const candidate = current.length === 0 ? word : `${current} ${word}`;
    if (candidate.length > safeWidth) {
      segments.push(current);
      current = word;
    } else {
      current = candidate;
    }
  }
  if (current.length > 0) segments.push(current);
  return segments.length > 0 ? segments : [""];
}

interface ExplainOptions {
  cost?: string;
  customizations?: boolean;
  /**
   * SA12.4-F1 (D12): output path (relative to the repo root, e.g.
   * `CLAUDE.md` or `.cursor/rules/hatch3r-bridge.mdc`) whose canonical
   * provenance should be printed. Reads `.hatch3r/provenance.json`
   * written by `hatch3r sync`. The empty string form (`--source` with no
   * value, or `--source all`) lists every recorded output.
   */
  source?: string;
  /**
   * D6-M2 (Cycle 9 / Wave 3): when set, render the per-artifact +
   * per-phase aggregate from `.hatch3r/efficiency-events.jsonl` produced
   * by `recordEfficiencyEvent` / `recordSubAgentSpawn` / `recordPhaseDuration`
   * (gated by `HATCH3R_EFFICIENCY_TELEMETRY=1`). Without this CLI surface
   * the JSONL stream was write-only at runtime; this closes the read loop.
   */
  efficiency?: boolean;
  verbose?: boolean;
  inputRate?: string;
  outputRate?: string;
  /**
   * D6-18: model selector for `--cost` — a tier alias (`opus`/`sonnet`/`haiku`)
   * or an exact model id from `MODEL_RATES`. Resolves the per-1M input/output
   * rates from the versioned rate map. `--input-rate` / `--output-rate` override
   * the resolved rate per-axis when both `--model` and the explicit flag are
   * passed. Defaults to the Sonnet-biased `DEFAULT_*_COST_PER_1M` when omitted.
   */
  model?: string;
  /**
   * D6-19: fraction (0–1) of input tokens served from the prompt cache. Cached
   * input bills at 0.1× the base input rate (`CACHE_READ_MULTIPLIER`). Defaults
   * to 0 (no caching) — the conservative upper-bound cost.
   */
  cacheHit?: string;
  /**
   * D12-11 (Cycle 11 Wave 3, D12, P1) introduced `--format json` for the
   * `--source` mode; W5 widened it to EVERY mode — `--cost`,
   * `--customizations`, and `--efficiency` each emit one machine-readable
   * document on stdout under `--format json`.
   */
  format?: string;
  /** W5: suppress stdout chrome (banner, boxes, hints); stderr still emits. */
  quiet?: boolean;
}

/**
 * `hatch3r explain` entry point. Dispatches to one of four modes:
 *   - `--cost <command-id>`     → per-tier sub-agent fan-out + USD cost table
 *   - `--customizations`        → per-artifact customize.{yaml,md} state table
 *   - `--source <output-path>`  → canonical source files behind a generated file
 *   - `--efficiency`            → per-artifact + per-phase aggregate from
 *                                  `.hatch3r/efficiency-events.jsonl` (D6-M2)
 *
 * The four modes are mutually exclusive; passing more than one or none is
 * a usage error (exit code 2). The mode selector is checked before any I/O
 * so a missing flag returns immediately with an actionable message.
 *
 * D12-11 (Cycle 11 Wave 3): `--format json` emits a machine-readable document
 * for the `--source` mode (reusing `emitJson`), matching the JSON surface the
 * sibling `provenance` / `verify` commands ship. JSON is source-only; pairing
 * it with `--cost` / `--customizations` / `--efficiency` is a usage error.
 */
export async function explainCommand(opts?: ExplainOptions): Promise<void> {
  // W5: beginCommand resolves format (exit-2 on a bad value per D10-22),
  // wires quiet/verbose, and prints the banner in human mode only — JSON mode
  // keeps stdout a single parseable document.
  const format = beginCommand(opts ?? {}, { banner: "compact" });
  const jsonMode = format === "json";

  const costRequested = typeof opts?.cost === "string" && opts.cost.trim().length > 0;
  const customizationsRequested = !!opts?.customizations;
  // `--source` is mode-active whenever the flag is present, including the
  // valueless / `all` form (commander stores `true` when the option takes
  // an optional value and none is supplied). Normalize to a string subject.
  const sourceRequested = opts?.source !== undefined;
  const efficiencyRequested = !!opts?.efficiency;
  const modesRequested =
    [costRequested, customizationsRequested, sourceRequested, efficiencyRequested].filter(Boolean).length;

  // W5: `--format json` is honored by EVERY mode (D12-11 shipped it for
  // --source; --cost / --customizations / --efficiency now emit one JSON
  // document each), so the former json-with-non-source usage error is gone.

  if (modesRequested > 1) {
    logError("Conflicting flags: --cost, --customizations, --source, and --efficiency are mutually exclusive.");
    if (!isQuiet()) {
      console.log(chalk.dim("  Pick one mode per invocation."));
      console.log();
    }
    throw new HatchError(
      "Conflicting flags: --cost, --customizations, --source, --efficiency",
      2,
      "VALIDATION_ERROR",
      "Pass exactly one mode flag per invocation: `--cost <id>`, `--customizations`, `--source <output-path>`, or `--efficiency`.",
    );
  }

  if (modesRequested === 0) {
    logError("Missing required mode flag: pass --cost <command-id>, --customizations, --source <output-path>, OR --efficiency.");
    if (!isQuiet()) {
      console.log(chalk.dim("  Example: hatch3r explain --cost hatch3r-quick-change"));
      console.log(chalk.dim("  Example: hatch3r explain --customizations"));
      console.log(chalk.dim("  Example: hatch3r explain --source CLAUDE.md"));
      console.log(chalk.dim("  Example: hatch3r explain --efficiency"));
      console.log();
    }
    throw new HatchError(
      "Missing required mode flag: --cost, --customizations, --source, or --efficiency",
      2,
      "VALIDATION_ERROR",
      "Pass one of: `--cost <id>`, `--customizations`, `--source <output-path>`, or `--efficiency` (run `hatch3r --help` for usage).",
    );
  }

  if (customizationsRequested) {
    await explainCustomizationsMode(format);
    return;
  }

  if (sourceRequested) {
    await explainSourceMode(opts!.source, !!opts?.verbose, format);
    return;
  }

  if (efficiencyRequested) {
    await explainEfficiencyMode(format);
    return;
  }

  const commandId = opts!.cost!.trim();

  const rootDir = process.cwd();
  const commandPath = await resolveCommandPath(rootDir, commandId);
  const raw = await readFile(commandPath, "utf-8");
  const fm = parseCommandFrontmatter(raw, commandPath);

  // The body (everything after the frontmatter) is the static input frame
  // that every sub-agent invocation will receive. Cost scales linearly with
  // sub-agent count under the static-first prompt model (CONSTITUTION P7).
  const bodyMatch = raw.match(FRONTMATTER_REGEX);
  const body = bodyMatch?.[2] ?? raw;
  const bodyCharCount = body.length;

  // D6-18: resolve the model selector first; it sets the base rate band. The
  // Sonnet-biased DEFAULT_*_COST_PER_1M apply only when no --model is passed.
  // --input-rate / --output-rate override the resolved rate per-axis.
  let modelLabel = "(default — Sonnet rates $3/$15; pass --model to cost another model)";
  let baseInputRate = DEFAULT_INPUT_COST_PER_1M;
  let baseOutputRate = DEFAULT_OUTPUT_COST_PER_1M;
  if (opts?.model) {
    const resolved = resolveModelRate(opts.model);
    if (!resolved) {
      const known = ["opus", "sonnet", "haiku", ...Object.keys(MODEL_RATES)].join(", ");
      throw new HatchError(
        `Unknown --model: ${opts.model}. Valid selectors: ${known}.`,
        2,
        "VALIDATION_ERROR",
        `Pass a tier alias (opus, sonnet, haiku) or an exact model id (e.g. claude-opus-4-8) to --model.`,
      );
    }
    baseInputRate = resolved.inputCostPer1M;
    baseOutputRate = resolved.outputCostPer1M;
    modelLabel = `${opts.model} ($${resolved.inputCostPer1M}/$${resolved.outputCostPer1M} per 1M in/out, rate accessed ${resolved.accessed})`;
  }

  const inputRate = opts?.inputRate ? Number(opts.inputRate) : baseInputRate;
  const outputRate = opts?.outputRate ? Number(opts.outputRate) : baseOutputRate;

  if (!Number.isFinite(inputRate) || inputRate < 0) {
    throw new HatchError(
      `Invalid --input-rate: ${opts?.inputRate} (expected non-negative number USD per 1M tokens)`,
      2,
      "VALIDATION_ERROR",
      "Pass --input-rate as a non-negative number, e.g. `--input-rate 3.0` for $3/1M input tokens.",
    );
  }
  if (!Number.isFinite(outputRate) || outputRate < 0) {
    throw new HatchError(
      `Invalid --output-rate: ${opts?.outputRate} (expected non-negative number USD per 1M tokens)`,
      2,
      "VALIDATION_ERROR",
      "Pass --output-rate as a non-negative number, e.g. `--output-rate 15.0` for $15/1M output tokens.",
    );
  }

  // D6-19: cache-hit ratio (0–1). Cached input tokens bill at 0.1× base input.
  const cacheHitRatio = opts?.cacheHit ? Number(opts.cacheHit) : 0;
  if (!Number.isFinite(cacheHitRatio) || cacheHitRatio < 0 || cacheHitRatio > 1) {
    throw new HatchError(
      `Invalid --cache-hit: ${opts?.cacheHit} (expected a number between 0 and 1)`,
      2,
      "VALIDATION_ERROR",
      "Pass --cache-hit as a fraction 0–1, e.g. `--cache-hit 0.9` when ~90% of input is served from the prompt cache.",
    );
  }

  // D6-23: size each spawnable sub-agent by its own agent definition so input
  // tokens reflect (orchestrator body once) + (per sub-agent: its agent def +
  // task context), not the body re-billed to every sub-agent.
  const agentDefCharCounts = await resolveAgentDefCharCounts(fm);

  const rows = computeTierRows(fm, bodyCharCount, agentDefCharCounts, {
    inputCostPer1M: inputRate,
    outputCostPer1M: outputRate,
    cacheHitRatio,
  });

  // Totals shared by the human table and the W5 json envelope.
  const totalTokens = rows.reduce((sum, r) => sum + r.totalTokens, 0);
  const totalUsd = rows.reduce((sum, r) => sum + r.usd, 0);

  // W5: `--cost --format json` emits one machine-readable document mirroring
  // the human header + per-tier table, then returns.
  if (jsonMode) {
    finishCommand(format, {
      command: "explain",
      title: "Per-tier cost estimate",
      lines: [],
      style: "info",
      json: {
        mode: "cost",
        commandId: fm.id || commandId,
        path: commandPath,
        orchestrator: fm.orchestrator,
        agentPipeline: fm.agentPipeline,
        tiers: fm.triageTiers,
        bodyChars: bodyCharCount,
        model: opts?.model ?? null,
        rates: { inputPer1M: inputRate, outputPer1M: outputRate, cacheHitRatio },
        tierRows: rows,
        totals: { tokens: totalTokens, usd: totalUsd },
      },
    });
    return;
  }

  const headerLines: string[] = [
    label("Command", fm.id || commandId),
    label("Path", commandPath),
    label("Orchestrator", fm.orchestrator ? "true" : "false"),
    label("Pipeline", fm.agentPipeline.length > 0 ? `${fm.agentPipeline.length} sub-agent(s)` : "(inline)"),
    label("Tiers", fm.triageTiers.length > 0 ? fm.triageTiers.join(", ") : "(none)"),
    label("Body size", `${formatTokens(bodyCharCount)} chars (~${formatTokens(estimateTokens(bodyCharCount, CHARS_PER_TOKEN))} tokens)`),
    // D6-18: surface which model the cost figures assume so an Opus run is not
    // silently priced at Sonnet rates.
    label("Model", modelLabel),
  ];

  printBox("Command", headerLines, "info");

  // Per-tier cost table. Each row reports a single full invocation at that
  // tier; the column widths are static so the box renders the same on every
  // terminal width.
  const tableLines: string[] = [];
  const COL_LABEL = 24;
  const COL_AGENTS = 12;
  const COL_TOKENS = 11;
  const COL_USD = 10;
  tableLines.push(
    `${"Tier".padEnd(COL_LABEL)}${"Sub-agents".padEnd(COL_AGENTS)}` +
      `${"Input".padEnd(COL_TOKENS)}${"Output".padEnd(COL_TOKENS)}${"Total".padEnd(COL_TOKENS)}${"USD".padEnd(COL_USD)}`,
  );
  const tableWidth = COL_LABEL + COL_AGENTS + COL_TOKENS * 3 + COL_USD;
  tableLines.push(chalk.dim("─".repeat(tableWidth)));
  for (const row of rows) {
    tableLines.push(
      `${row.label.padEnd(COL_LABEL)}` +
        `${String(row.subAgents).padEnd(COL_AGENTS)}` +
        `${formatTokens(row.inputTokens).padEnd(COL_TOKENS)}` +
        `${formatTokens(row.outputTokens).padEnd(COL_TOKENS)}` +
        `${formatTokens(row.totalTokens).padEnd(COL_TOKENS)}` +
        `${formatUsd(row.usd).padEnd(COL_USD)}`,
    );
  }
  tableLines.push(chalk.dim("─".repeat(tableWidth)));
  tableLines.push(
    `${"All tiers (sum)".padEnd(COL_LABEL)}` +
      `${"—".padEnd(COL_AGENTS)}` +
      `${"".padEnd(COL_TOKENS)}` +
      `${"".padEnd(COL_TOKENS)}` +
      `${formatTokens(totalTokens).padEnd(COL_TOKENS)}` +
      `${formatUsd(totalUsd).padEnd(COL_USD)}`,
  );

  printBox("Per-tier cost estimate", tableLines, "info");

  info(
    chalk.dim(
      `Rates: $${inputRate}/1M input, $${outputRate}/1M output` +
        `${cacheHitRatio > 0 ? `, ${Math.round(cacheHitRatio * 100)}% input cache-hit (cached input billed at 0.1x)` : ""}. ` +
        `Token counts use CHARS_PER_TOKEN=${CHARS_PER_TOKEN} (English prose heuristic).`,
    ),
  );
  // D6-23: state the per-actor input basis so the figures are not read as
  // "whole command body re-sent to every sub-agent" (the pre-fix over-count).
  info(
    chalk.dim(
      `Input basis: orchestrator body once (${formatTokens(estimateTokens(bodyCharCount, CHARS_PER_TOKEN))} tokens) ` +
        `+ per sub-agent its own agents/<id>.md + a ~${formatTokens(estimateTokens(TASK_CONTEXT_ALLOWANCE_CHARS, CHARS_PER_TOKEN))}-token task allowance. ` +
        `An unsized actor (tier-1 inline path / missing agent def) bills at ~${formatTokens(estimateTokens(UNKNOWN_AGENT_DEF_CHARS, CHARS_PER_TOKEN))} tokens. Upper-bound estimate.`,
    ),
  );
  console.log();
}

/**
 * SA12.3-F03 (Cycle 10 Wave 2): render the per-artifact customization-applied
 * state. Each row reports the canonical artifact id, type, outcome class
 * (active | skipped | failed), and a reason string. Failures and skips appear
 * first so the user sees rejections at a glance; `active` rows follow.
 */
async function explainCustomizationsMode(format: CliOutputFormat = "human"): Promise<void> {
  const rootDir = process.cwd();
  // D10-29: pass the manifest's content selection so an override on a deselected
  // artifact is reported `inert` ("will not be emitted") rather than `active`.
  // A missing/unreadable manifest collapses to the unfiltered prior behavior.
  let selection: ReadonlySet<string> | undefined;
  try {
    const manifest = await readManifest(rootDir);
    selection = selectionSetFromManifest(manifest?.content);
  } catch {
    selection = undefined;
  }
  const summary = await buildCustomizationSummary(rootDir, selection);

  // W5: json mode emits one envelope (counts + per-artifact entries, sorted
  // failed > skipped > inert > active for parity with the human table).
  if (format === "json") {
    const outcomeRankJson: Record<CustomizationStatus["outcome"], number> = {
      failed: 0,
      skipped: 1,
      inert: 2,
      active: 3,
      none: 4,
    };
    finishCommand(format, {
      command: "explain",
      title: "Customizations",
      lines: [],
      style: "info",
      json: {
        mode: "customizations",
        counts: summary.counts,
        entries: [...summary.entries].sort((a, b) => {
          if (outcomeRankJson[a.outcome] !== outcomeRankJson[b.outcome]) {
            return outcomeRankJson[a.outcome] - outcomeRankJson[b.outcome];
          }
          if (a.type !== b.type) return a.type.localeCompare(b.type);
          return a.id.localeCompare(b.id);
        }),
      },
    });
    return;
  }

  if (summary.entries.length === 0) {
    printBox(
      "Customizations",
      [chalk.dim("No .customize.yaml or .customize.md files found under .hatch3r/{agents,skills,commands,rules}/.")],
      "info",
    );
    return;
  }

  // Sort: failed > skipped > inert > active, then by type, then by id.
  // D10-29: inert (override on a deselected artifact) ranks above active so a
  // no-op override surfaces before the honored ones.
  const outcomeRank: Record<CustomizationStatus["outcome"], number> = {
    failed: 0,
    skipped: 1,
    inert: 2,
    active: 3,
    none: 4,
  };
  const sorted = [...summary.entries].sort((a, b) => {
    if (outcomeRank[a.outcome] !== outcomeRank[b.outcome]) {
      return outcomeRank[a.outcome] - outcomeRank[b.outcome];
    }
    if (a.type !== b.type) return a.type.localeCompare(b.type);
    return a.id.localeCompare(b.id);
  });

  // D12-9 (Cycle 11 Wave 3): responsive column widths. The pre-fix layout used
  // static widths summing to 120 cols; with boxen's round border (2) + horizontal
  // padding (2) + left margin (1) it needs ~125 cols, but boxen's non-TTY / CI
  // fallback width is 80 — so the table wrapped and garbled (SA12.3-F4). Now the
  // content width is derived from `process.stdout.columns` (80 when undetectable),
  // the Type/Outcome/Overrides columns hold fixed minimums, Id flexes within a
  // clamp, and Reason takes the remaining width with the over-long reason text
  // wrapped into indented continuation lines (not truncated) so failures/skips
  // stay legible at 80 cols.
  const COL_TYPE = 10;
  const COL_OUTCOME = 10;
  // boxen chrome eaten off `stdout.columns`: round border (1 each side) +
  // horizontal padding (1 each side) + left margin (1) + 1-col safety = 6.
  const BOXEN_CHROME = 6;
  const available = Math.max(74, (process.stdout.columns ?? 80) - BOXEN_CHROME);
  // Flex budget shared by Id + Overrides + Reason after the two fixed columns.
  // Priority order: Id (the lookup key) gets the larger share, then Overrides,
  // then Reason — which wraps to continuation lines, so it tolerates the
  // smallest column without losing content. Id flexes 18..34 (34 = longest
  // canonical id), Overrides 13..18 ("enabled=false" is 13 wide), Reason ≥18.
  const flexBudget = available - (COL_TYPE + COL_OUTCOME);
  const COL_ID = Math.max(18, Math.min(34, flexBudget - 13 - 18));
  const COL_OVERRIDES = Math.max(13, Math.min(18, flexBudget - COL_ID - 18));
  const COL_REASON = Math.max(18, flexBudget - COL_ID - COL_OVERRIDES);
  // Reason continuation lines indent under the Reason column start.
  const reasonIndent = COL_TYPE + COL_ID + COL_OUTCOME + COL_OVERRIDES;

  const tableLines: string[] = [];
  tableLines.push(
    `${"Type".padEnd(COL_TYPE)}${"Id".padEnd(COL_ID)}${"Outcome".padEnd(COL_OUTCOME)}${"Overrides".padEnd(COL_OVERRIDES)}${"Reason".padEnd(COL_REASON)}`,
  );
  tableLines.push(chalk.dim("─".repeat(COL_TYPE + COL_ID + COL_OUTCOME + COL_OVERRIDES + COL_REASON)));

  for (const entry of sorted) {
    const overridesParts: string[] = [];
    if (entry.appliedOverrides.description !== undefined) overridesParts.push("desc");
    if (entry.appliedOverrides.model !== undefined) overridesParts.push("model");
    if (entry.appliedOverrides.scope !== undefined) overridesParts.push("scope");
    if (entry.appliedOverrides.enabled !== undefined) overridesParts.push(`enabled=${entry.appliedOverrides.enabled}`);
    if (entry.hasMd && !overridesParts.includes("md")) overridesParts.push("md");
    const overridesCell = overridesParts.length > 0 ? overridesParts.join(",") : chalk.dim("—");

    const outcomeCell = (() => {
      switch (entry.outcome) {
        case "failed":
          return chalk.red("failed");
        case "skipped":
          return chalk.yellow("skipped");
        case "inert":
          return chalk.yellow("inert");
        case "active":
          return chalk.green("active");
        default:
          return chalk.dim("none");
      }
    })();

    // Id is the one cell that can still overflow its (clamped) column; truncate
    // it with an ellipsis so the row stays aligned. Reason is wrapped instead.
    const idCell = entry.id.length > COL_ID - 1 ? `${entry.id.slice(0, COL_ID - 2)}…` : entry.id;

    // D12-9: wrap the reason across continuation lines rather than truncating.
    const reasonSegments = wrapToWidth(entry.reason ?? "", COL_REASON);
    const firstReason = reasonSegments[0] ?? "";

    tableLines.push(
      `${entry.type.padEnd(COL_TYPE)}` +
        `${idCell.padEnd(COL_ID)}` +
        // chalk-wrapped strings have ANSI codes that inflate length; pad manually.
        `${outcomeCell}${" ".repeat(Math.max(0, COL_OUTCOME - entry.outcome.length))}` +
        `${overridesCell.padEnd(COL_OVERRIDES)}` +
        `${firstReason.padEnd(COL_REASON)}`,
    );
    // Indented continuation lines for the wrapped tail of a long reason.
    for (const segment of reasonSegments.slice(1)) {
      tableLines.push(`${" ".repeat(reasonIndent)}${segment.padEnd(COL_REASON)}`);
    }
  }

  printBox("Customizations", tableLines, summary.counts.failed > 0 ? "warning" : "info");

  const summaryLine =
    `${summary.counts.active} active, ${summary.counts.skipped} skipped, ${summary.counts.failed} failed` +
    // D10-29: only report inert when present so the established 3-part summary
    // string stays byte-identical for repos with no deselected overrides.
    (summary.counts.inert > 0 ? `, ${summary.counts.inert} inert` : "");
  info(
    `${chalk.bold("Customizations:")} ${summaryLine}. ` +
      chalk.dim(`Run \`hatch3r status\` for a one-line summary.`),
  );
  console.log();
}

/**
 * D6-M2 (Cycle 9 / Wave 3): render the per-artifact + per-phase aggregate from
 * `.hatch3r/efficiency-events.jsonl`. The JSONL stream is produced at runtime
 * by `recordEfficiencyEvent` / `recordSubAgentSpawn` / `recordPhaseDuration`
 * in `src/pipeline/observability.ts` when `HATCH3R_EFFICIENCY_TELEMETRY=1` is
 * set. Before this mode the JSONL was write-only — no CLI surface read it. This
 * closes the integration gap (Integration Gap covered by C9-H56). Each event
 * carries `artifactId` + `phase` (or `type` for spawn/duration variants), so
 * the aggregate groups by `artifactId` then by `phase` and reports token in/out
 * sums + invocation count.
 */
async function explainEfficiencyMode(format: CliOutputFormat = "human"): Promise<void> {
  const rootDir = process.cwd();
  const logPath = join(rootDir, HATCH3R_DIR, "efficiency-events.jsonl");

  let raw: string;
  try {
    raw = await readFile(logPath, "utf-8");
  } catch (err) {
    const code = (err as NodeJS.ErrnoException).code;
    if (code === "ENOENT") {
      logError(`No efficiency telemetry found at ${HATCH3R_DIR}/efficiency-events.jsonl.`);
      if (!isQuiet()) {
        console.log(
          chalk.dim(
            "  Telemetry is gated by HATCH3R_EFFICIENCY_TELEMETRY=1. Set the env var and re-run the orchestrator, then re-invoke `hatch3r explain --efficiency`.",
          ),
        );
        console.log();
      }
      throw new HatchError(
        `No ${HATCH3R_DIR}/efficiency-events.jsonl found`,
        undefined,
        "FS_ERROR",
        "Set HATCH3R_EFFICIENCY_TELEMETRY=1, re-run an orchestrator command to emit events, then re-run `hatch3r explain --efficiency`.",
      );
    }
    throw new HatchError(
      `Unreadable ${HATCH3R_DIR}/efficiency-events.jsonl: ${err instanceof Error ? err.message : String(err)}`,
      undefined,
      "FS_ERROR",
      "Delete the file manually if it remains corrupt, then re-run the orchestrator with telemetry enabled.",
    );
  }

  interface PhaseAgg {
    invocations: number;
    inputTokens: number;
    outputTokens: number;
    totalLatencyMs: number;
  }
  interface ArtifactAgg {
    spawnInvocations: number;
    spawnSubAgents: number;
    phases: Map<string, PhaseAgg>;
  }
  const perArtifact = new Map<string, ArtifactAgg>();

  // Parse JSONL line-by-line. Malformed lines are tolerated (silent-failure
  // contract — corrupt rows shouldn't block the read-side aggregate).
  let totalLines = 0;
  let parseFailures = 0;
  for (const line of raw.split("\n")) {
    if (line.trim().length === 0) continue;
    totalLines++;
    let evt: Record<string, unknown>;
    try {
      evt = JSON.parse(line) as Record<string, unknown>;
    } catch {
      parseFailures++;
      continue;
    }
    const artifactId = typeof evt.artifactId === "string" ? evt.artifactId : "(unknown)";
    let agg = perArtifact.get(artifactId);
    if (!agg) {
      agg = { spawnInvocations: 0, spawnSubAgents: 0, phases: new Map() };
      perArtifact.set(artifactId, agg);
    }
    if (evt.type === "subagent_spawn") {
      agg.spawnInvocations += 1;
      const count = typeof evt.count === "number" ? Math.max(0, Math.floor(evt.count)) : 0;
      agg.spawnSubAgents += count;
      continue;
    }
    // Both `EfficiencyEvent` (no `type`) and `PhaseDurationEvent` (`type: phase_duration`)
    // carry a `phase` string and contribute to the per-phase aggregate.
    const phase = typeof evt.phase === "string" ? evt.phase : "(unknown)";
    let pagg = agg.phases.get(phase);
    if (!pagg) {
      pagg = { invocations: 0, inputTokens: 0, outputTokens: 0, totalLatencyMs: 0 };
      agg.phases.set(phase, pagg);
    }
    pagg.invocations += 1;
    if (typeof evt.tokensIn === "number") pagg.inputTokens += Math.max(0, Math.floor(evt.tokensIn));
    if (typeof evt.tokensOut === "number") pagg.outputTokens += Math.max(0, Math.floor(evt.tokensOut));
    if (typeof evt.latencyMs === "number") pagg.totalLatencyMs += Math.max(0, Math.floor(evt.latencyMs));
    if (typeof evt.durationMs === "number") pagg.totalLatencyMs += Math.max(0, Math.floor(evt.durationMs));
  }

  // W5: json mode emits one envelope with the per-artifact aggregate.
  if (format === "json") {
    const artifactIds = [...perArtifact.keys()].sort((a, b) => a.localeCompare(b));
    finishCommand(format, {
      command: "explain",
      title: "Efficiency telemetry",
      lines: [],
      style: "info",
      json: {
        mode: "efficiency",
        logFile: `${HATCH3R_DIR}/efficiency-events.jsonl`,
        eventsParsed: totalLines - parseFailures,
        parseFailures,
        artifacts: artifactIds.map((artId) => {
          const agg = perArtifact.get(artId)!;
          return {
            artifactId: artId,
            spawnInvocations: agg.spawnInvocations,
            spawnSubAgents: agg.spawnSubAgents,
            phases: [...agg.phases.entries()]
              .sort((a, b) => a[0].localeCompare(b[0]))
              .map(([phase, p]) => ({ phase, ...p })),
          };
        }),
      },
    });
    return;
  }

  const headerLines = [
    label("Log file", `${HATCH3R_DIR}/efficiency-events.jsonl`),
    label("Events parsed", String(totalLines - parseFailures)),
    label("Parse failures", String(parseFailures)),
    label("Distinct artifacts", String(perArtifact.size)),
  ];
  printBox("Efficiency telemetry", headerLines, "info");

  if (perArtifact.size === 0) {
    info("No events recorded yet. Set HATCH3R_EFFICIENCY_TELEMETRY=1 and run an orchestrator.");
    console.log();
    return;
  }

  // Per-artifact table: one row per (artifact, phase) pair plus a spawn row if
  // spawn events were observed for that artifact.
  const sortedArtifacts = [...perArtifact.keys()].sort((a, b) => a.localeCompare(b));
  const COL_ART = 32;
  const COL_PHASE = 16;
  const COL_INVOK = 8;
  const COL_TOK = 11;
  const COL_LAT = 12;

  for (const artId of sortedArtifacts) {
    const agg = perArtifact.get(artId)!;
    const tableLines: string[] = [];
    tableLines.push(
      `${"Artifact".padEnd(COL_ART)}${"Phase".padEnd(COL_PHASE)}${"Inv".padEnd(COL_INVOK)}` +
        `${"In".padEnd(COL_TOK)}${"Out".padEnd(COL_TOK)}${"Latency(ms)".padEnd(COL_LAT)}`,
    );
    tableLines.push(chalk.dim("─".repeat(COL_ART + COL_PHASE + COL_INVOK + COL_TOK * 2 + COL_LAT)));
    if (agg.spawnInvocations > 0) {
      tableLines.push(
        `${artId.padEnd(COL_ART)}${"(spawns)".padEnd(COL_PHASE)}${String(agg.spawnInvocations).padEnd(COL_INVOK)}` +
          `${"—".padEnd(COL_TOK)}${"—".padEnd(COL_TOK)}` +
          chalk.dim(`${agg.spawnSubAgents} sub-agent(s) total`),
      );
    }
    const sortedPhases = [...agg.phases.keys()].sort((a, b) => a.localeCompare(b));
    for (const phase of sortedPhases) {
      const p = agg.phases.get(phase)!;
      tableLines.push(
        `${artId.padEnd(COL_ART)}${phase.padEnd(COL_PHASE)}${String(p.invocations).padEnd(COL_INVOK)}` +
          `${formatTokens(p.inputTokens).padEnd(COL_TOK)}${formatTokens(p.outputTokens).padEnd(COL_TOK)}${formatTokens(p.totalLatencyMs).padEnd(COL_LAT)}`,
      );
    }
    printBox(artId, tableLines, "info");
  }

  info(
    chalk.dim(
      "Aggregates are sums across all sessions in the log. Truncate `.hatch3r/efficiency-events.jsonl` to reset.",
    ),
  );
  console.log();
}

/**
 * SA12.4-F1 (D12): one provenance record for a generated output file. Mirrors
 * the schema in `src/manifest/provenance.ts` ({@link ProvenanceEntry} there),
 * written by the shared `writeProvenance` helper that `sync`, `init`, and
 * `update` all call (D12-4).
 */
interface ProvenanceEntry {
  path: string;
  adapter: string;
  sourceFiles: string[];
}

interface ProvenanceManifest {
  schemaVersion?: number;
  hatch3rVersion?: string;
  generatedAt?: string;
  /**
   * SA12.1-F-D12-M4 (D12, P1): identifier for the CLI command that produced
   * this manifest — `"sync"`, `"init"`, or `"update"` since D12-4 routed all
   * three through the shared `writeProvenance` helper. Lets operators
   * distinguish which command last wrote the manifest.
   */
  lastCommand?: string;
  /**
   * SA12.1-F-D12-M4 (D12, P1): per-run correlation id of the producing
   * command. Operators can grep `.hatch3r/.failure-log.jsonl` by this id to find
   * every entry recorded during the same run.
   */
  lastRunId?: string;
  outputs?: ProvenanceEntry[];
}

/**
 * SA12.4-F1 (D12): render the canonical-source provenance for a generated
 * adapter output. Reads `.hatch3r/provenance.json` (written by `sync`, `init`,
 * or `update` via the shared `writeProvenance` helper, D12-4) and prints, for
 * the requested output path, the adapter that produced it and
 * the sorted canonical `sourceFiles[]` that shaped it. This closes the gap
 * where `sourceFiles` provenance was captured in memory by `BaseAdapter` then
 * discarded, leaving operators with no user-visible canonical-source trace.
 *
 * Subject forms:
 *   - a specific output path (`CLAUDE.md`, `.cursor/rules/hatch3r-bridge.mdc`)
 *     → one full block listing every canonical source for that path
 *   - `all` or the valueless flag → a `path → N source(s)` summary table
 *     (one line per output). D12-2 (Cycle 11 Wave 2): the prior `all` form
 *     printed one full per-source block per output, which for a standard
 *     init+sync was 224,672 lines / 19.3 MB — unreadable and a size
 *     symptom of the per-output over-attribution fixed by D12-1. The
 *     default is now a bounded one-line-per-output count; pass `--verbose`
 *     to expand it back to the full per-path source enumeration.
 *
 * @param detail when true (driven by `--verbose`), the `all` form prints the
 *   full per-path source list instead of the count summary. Ignored for the
 *   single-path form, which always prints the full list for the one output.
 * @param format D12-11: `human` (default boxen render) or `json`. JSON emits a
 *   single `emitJson` document — `{output, adapter, sourceFiles, hatch3rVersion,
 *   generatedAt}` for a single path, or an `{outputs: [...], ...}` envelope for
 *   the `all` form — so CI consumers parse one stable shape instead of scraping
 *   box chrome. The stored `sourceFiles` are already repo-root-relative (D12-3 /
 *   H3), so the JSON form surfaces those relative paths verbatim.
 */
async function explainSourceMode(
  subject: string | undefined,
  detail = false,
  format: CliOutputFormat = "human",
): Promise<void> {
  const jsonMode = format === "json";
  const rootDir = process.cwd();
  const provenancePath = join(rootDir, HATCH3R_DIR, "provenance.json");

  let manifest: ProvenanceManifest;
  try {
    const raw = await readFile(provenancePath, "utf-8");
    manifest = JSON.parse(raw) as ProvenanceManifest;
  } catch (err) {
    const code = (err as NodeJS.ErrnoException).code;
    if (code === "ENOENT") {
      if (jsonMode) {
        emitJson({
          status: "absent",
          error: `No ${HATCH3R_DIR}/provenance.json found`,
          recoveryHint: "Run `hatch3r sync` to generate adapter outputs and record their canonical sources.",
          hatch3rVersion: HATCH3R_VERSION,
          timestamp: new Date().toISOString(),
        });
      } else {
        logError(`No provenance manifest found at ${HATCH3R_DIR}/provenance.json.`);
        console.log(chalk.dim("  Run `hatch3r sync` to generate adapter outputs and record their canonical sources."));
        console.log();
      }
      throw new HatchError(
        `No ${HATCH3R_DIR}/provenance.json found`,
        undefined,
        "FS_ERROR",
        "Run `hatch3r sync` to generate adapter outputs and record their canonical sources, then re-run `hatch3r explain --source`.",
      );
    }
    logError(`Could not read ${HATCH3R_DIR}/provenance.json: ${err instanceof Error ? err.message : String(err)}`);
    if (!jsonMode) console.log();
    throw new HatchError(
      `Unreadable ${HATCH3R_DIR}/provenance.json`,
      undefined,
      "FS_ERROR",
      "Re-run `hatch3r sync` to regenerate the provenance manifest; if it remains unreadable, delete it and sync again.",
    );
  }

  const outputs = Array.isArray(manifest.outputs) ? manifest.outputs : [];
  if (outputs.length === 0) {
    if (jsonMode) {
      emitJson({
        status: "empty",
        outputs: [],
        hatch3rVersion: manifest.hatch3rVersion ?? HATCH3R_VERSION,
        generatedAt: manifest.generatedAt ?? null,
      });
      return;
    }
    printBox(
      "Source provenance",
      [chalk.dim(`No outputs recorded in ${HATCH3R_DIR}/provenance.json. Run \`hatch3r sync\` first.`)],
      "info",
    );
    return;
  }

  const normalized = (subject ?? "").trim();
  const wantAll = normalized === "" || normalized.toLowerCase() === "all";

  // D12-11: stable order shared by both render paths — by adapter then path,
  // matching the writer's sort so the JSON document is deterministic for CI.
  const sortedAll = [...outputs].sort((a, b) => {
    const byAdapter = a.adapter.localeCompare(b.adapter);
    return byAdapter !== 0 ? byAdapter : a.path.localeCompare(b.path);
  });

  if (jsonMode) {
    if (wantAll) {
      // The `all` form is not capped in JSON: a CI consumer that asked for the
      // machine document wants every output's full (relative) source list. The
      // `--verbose` summary cap is a human-terminal affordance (D12-2) only.
      emitJson({
        status: "present",
        hatch3rVersion: manifest.hatch3rVersion ?? HATCH3R_VERSION,
        generatedAt: manifest.generatedAt ?? null,
        lastCommand: manifest.lastCommand ?? null,
        lastRunId: manifest.lastRunId ?? null,
        outputs: sortedAll.map((o) => ({
          output: o.path,
          adapter: o.adapter,
          sourceFiles: o.sourceFiles,
        })),
      });
      return;
    }
    const match = outputs.find((o) => o.path === normalized);
    if (!match) {
      emitJson({
        status: "not-found",
        output: normalized,
        error: `Output path not recorded: ${normalized}`,
        recoveryHint: "Pass an output path exactly as recorded (run `hatch3r explain --source all` to list them), or re-run `hatch3r sync`.",
        hatch3rVersion: manifest.hatch3rVersion ?? HATCH3R_VERSION,
      });
      throw new HatchError(
        `Output path not recorded: ${normalized}`,
        undefined,
        "CONFIG_ERROR",
        "Pass an output path exactly as recorded (run `hatch3r explain --source all` to list them), or re-run `hatch3r sync`.",
      );
    }
    emitJson({
      status: "present",
      output: match.path,
      adapter: match.adapter,
      sourceFiles: match.sourceFiles,
      hatch3rVersion: manifest.hatch3rVersion ?? HATCH3R_VERSION,
      generatedAt: manifest.generatedAt ?? null,
    });
    return;
  }

  if (wantAll) {
    const totalSources = outputs.reduce((sum, o) => sum + o.sourceFiles.length, 0);
    const headerLines = [
      label("Manifest", `${HATCH3R_DIR}/provenance.json`),
      label("hatch3r", manifest.hatch3rVersion ?? "(unknown)"),
      label("Generated", manifest.generatedAt ?? "(unknown)"),
      // SA12.1-F-D12-M4: surface the producing command + run id so operators
      // can correlate the manifest back to a specific `.failure-log.jsonl` entry.
      label("Command", manifest.lastCommand ?? "(unknown)"),
      label("Run id", manifest.lastRunId ?? "(unknown)"),
      label("Outputs", String(outputs.length)),
      label("Source links", String(totalSources)),
    ];
    printBox("Source provenance — all outputs", headerLines, "info");

    // Stable order by adapter then path (matches the writer's sort); shared
    // with the JSON-mode `all` form above via `sortedAll`.
    const sorted = sortedAll;

    // D12-2 (Cycle 11 Wave 2, D12, P4/P1): the `all` form defaults to a
    // bounded `path → N source(s)` summary — one line per output — instead of
    // the full per-source block per output. For a standard init+sync (703
    // outputs, a handful of which aggregate the whole canonical read set) the
    // old full enumeration was 224,672 lines / 19.3 MB, unreadable in a
    // terminal and a size symptom of the over-attribution D12-1 corrected.
    // `--verbose` expands back to the full per-path source list on request.
    if (!detail) {
      const COL_OUTPUT = 56;
      const COL_ADAPTER = 10;
      const tableLines: string[] = [];
      tableLines.push(
        `${"Output".padEnd(COL_OUTPUT)}${"Adapter".padEnd(COL_ADAPTER)}Sources`,
      );
      tableLines.push(chalk.dim("─".repeat(COL_OUTPUT + COL_ADAPTER + 8)));
      for (const entry of sorted) {
        // Truncate over-long paths so the count column stays aligned; the
        // single-path form (`--source <path>`) shows the full untruncated path.
        const pathCell =
          entry.path.length > COL_OUTPUT - 1
            ? `${entry.path.slice(0, COL_OUTPUT - 2)}…`
            : entry.path;
        const count = entry.sourceFiles.length;
        const countCell = count === 0 ? chalk.dim("0") : String(count);
        tableLines.push(
          `${pathCell.padEnd(COL_OUTPUT)}${entry.adapter.padEnd(COL_ADAPTER)}${countCell}`,
        );
      }
      printBox("Per-output source counts", tableLines, "info");
      info(
        chalk.dim(
          "Showing source counts. Run `hatch3r explain --source <output-path>` for one output's full list, " +
            "or `hatch3r explain --source all --verbose` for every list.",
        ),
      );
      console.log();
      return;
    }

    for (const entry of sorted) {
      printSourceBlock(entry);
    }
    return;
  }

  const match = outputs.find((o) => o.path === normalized);
  if (!match) {
    logError(`Output path not recorded in provenance: ${normalized}`);
    const known = outputs.map((o) => o.path).sort();
    const preview = known.slice(0, 8).join(", ") + (known.length > 8 ? `, … (${known.length} total)` : "");
    console.log(chalk.dim(`  Recorded outputs: ${preview}`));
    console.log(chalk.dim("  Tip: run `hatch3r explain --source all` to list every recorded output."));
    console.log();
    throw new HatchError(
      `Output path not recorded: ${normalized}`,
      undefined,
      "CONFIG_ERROR",
      "Pass an output path exactly as recorded (run `hatch3r explain --source all` to list them), or re-run `hatch3r sync`.",
    );
  }

  printSourceBlock(match, manifest);
}

/**
 * SA12.4-F1 (D12): print a single output's provenance block — the generated
 * path, the adapter that emitted it, and each canonical source file. Used by
 * both the single-path and `--source all` forms.
 */
function printSourceBlock(entry: ProvenanceEntry, manifest?: ProvenanceManifest): void {
  const lines: string[] = [
    label("Output", entry.path),
    label("Adapter", entry.adapter),
  ];
  if (manifest) {
    lines.push(label("hatch3r", manifest.hatch3rVersion ?? "(unknown)"));
    lines.push(label("Generated", manifest.generatedAt ?? "(unknown)"));
    // SA12.1-F-D12-M4: surface the producing command + run id when a
    // single-output block is printed.
    if (manifest.lastCommand) lines.push(label("Command", manifest.lastCommand));
    if (manifest.lastRunId) lines.push(label("Run id", manifest.lastRunId));
  }
  if (entry.sourceFiles.length === 0) {
    lines.push(label("Sources", chalk.dim("(none recorded)")));
  } else {
    lines.push(label("Sources", `${entry.sourceFiles.length} canonical file(s):`));
    for (const src of entry.sourceFiles) {
      lines.push(`    ${chalk.cyan("•")} ${src}`);
    }
  }
  printBox(`Source: ${entry.path}`, lines, "info");
}
