#!/usr/bin/env node
/**
 * CLI for series-level edition memory: the distillate (N11 — serie-nivå-vern)
 * and the editions register (N12 — A1-11 / A1-12).
 *
 *   node --import tsx src/cli.ts distil-append     --distillate <path> --series <slug> --extract <extract.json>
 *   node --import tsx src/cli.ts distil-check      --distillate <path> --skeleton <skeleton.json>
 *                                                  [--threshold <0..1>] [--json]
 *   node --import tsx src/cli.ts register-upsert   --edition-state <path> [--next <text>] [--slot <text>]
 *                                                  [--path <series-root>] [--register <path>] [--at <ISO>]
 *   node --import tsx src/cli.ts register-list     [--all] [--json] [--register <path>] [--now <ISO>]
 *   node --import tsx src/cli.ts register-complete --series <slug> --edition <NN>
 *                                                  [--register <path>] [--at <ISO>]
 *
 * `distil-append` runs at Step 8 lock: the command layer writes the AI extract
 * (the anecdotes/arguments/hooks the edition actually spent) to a JSON file, and
 * this folds it in deterministically. `distil-check` runs at Step 2.5, before
 * prose, and reports re-use into the annotation gate.
 *
 * `register-upsert` runs at EVERY phase transition — it appends the `phaseLog`
 * entry to the edition-state AND mirrors the row into the register in one call,
 * so the telemetry cannot drift out of step with the state it describes.
 * `register-complete` runs at Step 10 when the edition is scheduled.
 *
 * Clock at the edge only: `--at` / `--now` override it so runs are reproducible.
 *
 * Exit code: 0 on success (INCLUDING a REUSE verdict — the check is advisory by
 * design, unlike the binding gate's BLOCK), 1 on a failed operation, 2 on usage
 * error.
 */

import { readFileSync } from "node:fs";

import {
  DEFAULT_THRESHOLD,
  appendEntry,
  checkSkeleton,
  loadDistillate,
  saveDistillate,
} from "./distillate.js";
import {
  appendPhase,
  editionFacts,
  readEditionState,
  saveEditionState,
  seriesRootFromStatePath,
} from "./editionState.js";
import {
  completeEdition,
  defaultRegisterPath,
  elapsedDays,
  listEditions,
  loadRegister,
  saveRegister,
  upsertEdition,
} from "./register.js";
import type { DistillateEntry, EditionRow, SkeletonCandidate } from "./types.js";

function parseFlags(args: string[]): Record<string, string> {
  const out: Record<string, string> = {};
  for (let i = 0; i < args.length; i++) {
    const a = args[i];
    if (a.startsWith("--")) {
      const key = a.slice(2);
      const next = args[i + 1];
      if (next === undefined || next.startsWith("--")) {
        out[key] = "true";
      } else {
        out[key] = next;
        i++;
      }
    }
  }
  return out;
}

function usage(msg: string): never {
  console.error(`error: ${msg}`);
  console.error(
    "usage:\n" +
      "  distil-append     --distillate <path> --series <slug> --extract <extract.json>\n" +
      "  distil-check      --distillate <path> --skeleton <skeleton.json> [--threshold <0..1>] [--json]\n" +
      "  register-upsert   --edition-state <path> [--next <text>] [--slot <text>] [--path <series-root>]\n" +
      "                    [--register <path>] [--at <ISO>]\n" +
      "  register-list     [--all] [--json] [--register <path>] [--now <ISO>]\n" +
      "  register-complete --series <slug> --edition <NN> [--register <path>] [--at <ISO>]",
  );
  process.exit(2);
}

/** A failed operation (as opposed to a malformed invocation). */
function fail(msg: string): never {
  console.error(`error: ${msg}`);
  process.exit(1);
}

/** A flag that was given a real value (not absent, not a bare boolean flag). */
function value(flags: Record<string, string>, key: string): string | undefined {
  const v = flags[key];
  return v === undefined || v === "true" ? undefined : v;
}

function readJson(path: string, what: string): unknown {
  try {
    return JSON.parse(readFileSync(path, "utf8"));
  } catch (err) {
    usage(`could not read ${what} at ${path}: ${(err as Error).message}`);
  }
}

function stringList(raw: unknown): string[] {
  if (!Array.isArray(raw)) return [];
  return raw.filter((x): x is string => typeof x === "string" && x.trim().length > 0);
}

/** Validate the AI extract at the edge — a malformed extract must not become a silent empty entry. */
function toEntry(raw: unknown): DistillateEntry {
  const o = (raw ?? {}) as Record<string, unknown>;
  for (const field of ["editionId", "title", "lockedAt"]) {
    const v = o[field];
    if (typeof v !== "string" || v.trim().length === 0) {
      usage(`extract is missing required string field: ${field}`);
    }
  }
  return {
    editionId: o.editionId as string,
    title: o.title as string,
    lockedAt: o.lockedAt as string,
    anecdotes: stringList(o.anecdotes),
    arguments: stringList(o.arguments),
    hooks: stringList(o.hooks),
  };
}

/**
 * One edition as the operator reads it: what it is, where it stands, what the
 * one next move is, and how long it has been running. This block IS the WIP
 * answer — if it needs a follow-up file to be useful, the register has failed.
 */
function renderRow(row: EditionRow, now: string): string {
  const days = elapsedDays(row.startedAt, row.completedAt ?? now);
  const age =
    days === null
      ? "age unknown"
      : row.status === "complete"
        ? `lead time ${days} day(s)`
        : `${days} day(s) in flight`;

  return (
    `\n  · ${row.series}/${row.editionId}${row.title ? ` — "${row.title}"` : ""}\n` +
    `    phase: ${row.currentPhase} → next: ${row.nextAction ?? "—"}\n` +
    `    ${age} · slot: ${row.slot ?? "—"}\n` +
    `    ${row.path}`
  );
}

function main(): void {
  const [command, ...rest] = process.argv.slice(2);
  const flags = parseFlags(rest);
  const distillatePath = value(flags, "distillate");
  const asJson = flags.json === "true";

  if (command === "distil-append") {
    if (!distillatePath) usage("distil-append needs --distillate <path>");
    const series = value(flags, "series");
    if (!series) usage("distil-append needs --series <slug>");
    const extractPath = value(flags, "extract");
    if (!extractPath) usage("distil-append needs --extract <extract.json>");

    const entry = toEntry(readJson(extractPath, "extract"));
    const res = appendEntry(loadDistillate(distillatePath, series), entry);
    saveDistillate(distillatePath, res.distillate);

    const units = entry.anecdotes.length + entry.arguments.length + entry.hooks.length;
    console.log(
      `${res.replaced ? "Replaced" : "Appended"} edition ${entry.editionId} (${units} narrative unit(s)) ` +
        `in ${distillatePath} — ${res.distillate.editions.length} edition(s) in the series distillate`,
    );
    return;
  }

  if (command === "distil-check") {
    if (!distillatePath) usage("distil-check needs --distillate <path>");
    const skeletonPath = value(flags, "skeleton");
    if (!skeletonPath) usage("distil-check needs --skeleton <skeleton.json>");

    const rawThreshold = value(flags, "threshold");
    let threshold = DEFAULT_THRESHOLD;
    if (rawThreshold !== undefined) {
      threshold = Number(rawThreshold);
      if (!Number.isFinite(threshold) || threshold <= 0 || threshold > 1) {
        usage("--threshold must be a number in (0, 1]");
      }
    }

    const candidate = (readJson(skeletonPath, "skeleton") ?? {}) as SkeletonCandidate;
    const report = checkSkeleton(loadDistillate(distillatePath, value(flags, "series") ?? ""), candidate, {
      threshold,
    });

    if (asJson) {
      console.log(JSON.stringify(report, null, 2));
      return;
    }

    if (report.verdict === "CLEAR") {
      console.log(
        `CLEAR — nothing in this skeleton resembles the ${report.comparedAgainst} locked edition(s) ` +
          `(threshold ${report.threshold}).`,
      );
      return;
    }
    console.log(
      `REUSE — ${report.hits.length} unit(s) resemble earlier editions (threshold ${report.threshold}, ` +
        `${report.comparedAgainst} edition(s) checked):`,
    );
    for (const hit of report.hits) {
      console.log(`\n  · ${hit.kind} — ${(hit.score * 100).toFixed(0)}% like edition ${hit.editionId}`);
      console.log(`    new:  ${hit.candidate}`);
      console.log(`    then: ${hit.matched}`);
    }
    console.log("\nAdvisory: a deliberate callback is fine — an unnoticed retread is not.");
    return;
  }

  if (command === "register-upsert") {
    const statePath = value(flags, "edition-state");
    if (!statePath) usage("register-upsert needs --edition-state <path>");

    const at = value(flags, "at") ?? new Date().toISOString();
    const registerPath = value(flags, "register") ?? defaultRegisterPath();

    let state;
    try {
      state = readEditionState(statePath);
    } catch (err) {
      usage(`could not read edition-state at ${statePath}: ${(err as Error).message}`);
    }

    let facts;
    try {
      facts = editionFacts(state);
    } catch (err) {
      usage((err as Error).message);
    }

    // Log first, mirror second: the state file is the source of truth, and a
    // register row for a transition that was never logged would be a lie.
    const logged = appendPhase(state, facts.editionId, facts.currentPhase, at);
    if (logged.appended) saveEditionState(statePath, logged.state);

    const { register, row, created } = upsertEdition(
      loadRegister(registerPath),
      {
        series: facts.series,
        editionId: facts.editionId,
        title: facts.title,
        path: value(flags, "path") ?? seriesRootFromStatePath(statePath),
        currentPhase: facts.currentPhase,
        nextAction: value(flags, "next"),
        slot: value(flags, "slot"),
      },
      at,
    );
    saveRegister(registerPath, register);

    console.log(
      `${created ? "Registered" : "Updated"} ${row.series}/${row.editionId} at phase ${row.currentPhase} ` +
        `(${logged.appended ? "phase logged" : "phase already logged"}) — ${registerPath}`,
    );
    return;
  }

  if (command === "register-list") {
    const registerPath = value(flags, "register") ?? defaultRegisterPath();
    const includeComplete = flags.all === "true";
    const now = value(flags, "now") ?? new Date().toISOString();
    const rows = listEditions(loadRegister(registerPath), { includeComplete });

    if (asJson) {
      console.log(JSON.stringify(rows, null, 2));
      return;
    }

    if (rows.length === 0) {
      console.log(includeComplete ? "No editions in the register." : "No editions in flight.");
      return;
    }

    console.log(
      includeComplete
        ? `${rows.length} edition(s) in the register:`
        : `${rows.length} edition(s) in flight:`,
    );
    for (const row of rows) console.log(renderRow(row, now));
    return;
  }

  if (command === "register-complete") {
    const series = value(flags, "series");
    const editionId = value(flags, "edition");
    if (!series || !editionId) usage("register-complete needs --series <slug> and --edition <NN>");

    const at = value(flags, "at") ?? new Date().toISOString();
    const registerPath = value(flags, "register") ?? defaultRegisterPath();
    const { register, row } = completeEdition(loadRegister(registerPath), { series, editionId }, at);
    if (!row) fail(`no register row for ${series}/${editionId} — nothing to complete`);

    saveRegister(registerPath, register);
    const lead = elapsedDays(row.startedAt, row.completedAt ?? at);
    console.log(
      `Completed ${row.series}/${row.editionId}` +
        (lead === null ? "" : ` — lead time ${lead} day(s)`) +
        ` — ${registerPath}`,
    );
    return;
  }

  usage(command ? `unknown command: ${command}` : "no command given");
}

main();
