/**
 * # cli/backend/local — the DEFAULT backend: in-process engine + light render/lang.
 *
 * openSession/openDaySession/grade dynamic-import the heavy barrel through loadHeavy
 * (chdb/bun:sqlite stay lazy, exit-4 under bun-less node). getPercept/submitPlan use
 * ONLY the light frame/lang code — no heavy import on those paths. Behavior is
 * byte-identical to today's grade.ts/frame.ts.
 */

import { readFileSync, statSync } from "node:fs";

import { type KestrelNode } from "../../lang/index.ts";
import { canonicalPlansText } from "../../canonical/plans.ts";
import { renderBriefing, renderWakeDelta, type RenderOptions } from "../../frame/render.ts";
import { KernelHonestyError } from "../../frame/types.ts";
import type { BriefingInput, WakeDeltaInput } from "../../frame/index.ts";
import type { GradeResult, CertifiedGrade, Blotter, TrialCapability } from "../../protocol/index.ts";
import type { EpisodeReport } from "../heavy.ts";
import { CliError, EXIT } from "../errors.ts";
import { loadHeavy } from "../commands/_heavy.ts";
import type {
  ExecutionBackend,
  Gated,
  PerceptArgs,
  PerceptFrame,
  PerceptKind,
  PlanResult,
  SessionArgs,
  DayArgs,
  LocalRunResult,
  GradeArgs,
  SessionScope,
} from "./index.ts";

const ok = <T>(value: T): Gated<T> => ({ gated: false, value });

export class LocalBackend implements ExecutionBackend {
  readonly kind = "local" as const;

  async bootstrapCapability(): Promise<TrialCapability> {
    // Local execution is unmetered: a synthetic always-valid trial cap over the full
    // wallet-signable scope set. No network. Deterministic id so tests are stable.
    return {
      kind: "trial",
      capabilityId: "local-trial",
      scopes: ["data", "sim", "grade", "paper"],
      expiry: "9999-12-31T00:00:00Z",
      subjectCommitment: "local",
      rateLimit: { limit: Number.MAX_SAFE_INTEGER, windowSeconds: 1 },
    };
  }

  async getPercept(args: PerceptArgs): Promise<Gated<PerceptFrame>> {
    // Identical to today's frameCommand fixture logic, minus the mode-branch render
    // (render stays in the command handler).
    const raw = args.raw ?? readInput(args.input);
    let input: unknown;
    try {
      input = JSON.parse(raw);
    } catch (e) {
      throw usage(`frame input is not valid JSON: ${msg(e)}`);
    }
    if (typeof input !== "object" || input === null)
      throw usage("frame input must be a JSON object matching BriefingInput or WakeDeltaInput");
    const kind: PerceptKind = args.kind ?? ("wakeIndex" in input ? "wake" : "briefing");
    // The ONE View-addressing seam (kestrel-wa0j.73.2): thread the caller's {view, seat} into the
    // renderer's RenderOptions. Absent ⇒ {} ⇒ resolveView's phase default, byte-identical to before.
    const renderOpts: RenderOptions = {
      ...(args.view !== undefined ? { view: args.view } : {}),
      ...(args.seat !== undefined ? { seat: args.seat } : {}),
    };
    let ascii: string;
    try {
      ascii = kind === "wake" ? renderWakeDelta(input as WakeDeltaInput, renderOpts) : renderBriefing(input as BriefingInput, renderOpts);
    } catch (e) {
      // A View/seat was requested and the renderer REFUSED it (unknown pane, args on an arg-less
      // pane, over budget, reserved kernel id): surface it as the View refusal it is — never the
      // misleading "input shape" message, and never a silent fall-through to phase defaults.
      if ((args.view !== undefined || args.seat !== undefined) && e instanceof KernelHonestyError) {
        throw usage(`frame View refused: ${msg(e)}`);
      }
      throw usage(`frame render failed — input does not match ${kind} shape: ${msg(e)}`);
    }
    return ok({ kind, ascii });
  }

  async submitPlan(documents: readonly KestrelNode[]): Promise<Gated<PlanResult>> {
    // Local parse already succeeded (caller parsed). Canonicalize + return.
    const canonicalText = canonicalPlansText(documents);
    return ok({ ok: true, canonicalText, documents, diagnostics: [] });
  }

  async openSession(_scope: SessionScope, args: SessionArgs): Promise<Gated<LocalRunResult>> {
    assertBusReadable(args.busPath);
    const sim = await loadHeavy(() => import("../heavy.ts"));
    const report = sim.runSimSession({
      documents: args.documents,
      fillModel: args.fillModel,
      rUsd: args.rUsd,
      ...(args.busPath !== undefined ? { busPath: args.busPath } : {}),
      ...(args.fairTauYears !== undefined ? { fairTauYears: args.fairTauYears } : {}),
      ...(args.makerFairParams !== undefined ? { makerFairParams: args.makerFairParams } : {}),
      ...(args.out !== undefined ? { out: args.out } : {}),
    });
    return ok({ report, plansText: canonicalPlansText(args.documents), wakes: [] });
  }

  async openDaySession(_scope: SessionScope, args: DayArgs): Promise<Gated<LocalRunResult>> {
    assertBusReadable(args.busPath);
    const day = await loadHeavy(() => import("../heavy.ts"));
    // NOTE: runDaySession reads the day's plan document from the handshake dir
    // (`plans-0.kestrel`), NOT from `args.documents` — the stepped path authors in-band.
    const { report, plansText, wakes } = await day.runDaySession({
      dir: args.dir,
      fillModel: args.fillModel,
      rUsd: args.rUsd,
      wakes: args.wakes ?? [],
      structural: args.structural ?? false,
      ...(args.busPath !== undefined ? { busPath: args.busPath } : {}),
      ...(args.fairTauYears !== undefined ? { fairTauYears: args.fairTauYears } : {}),
      ...(args.makerFairParams !== undefined ? { makerFairParams: args.makerFairParams } : {}),
      ...(args.maxWaitSec !== undefined ? { maxWaitSec: args.maxWaitSec } : {}),
      ...(args.awaitAuthor === true ? { awaitAuthor: true } : {}),
      ...(args.noAuthor === true ? { noAuthor: true } : {}),
      ...(args.out !== undefined ? { out: args.out } : {}),
    });
    return ok({ report, plansText, wakes });
  }

  async grade(subject: Blotter | EpisodeReport, _opts: GradeArgs): Promise<Gated<GradeResult | CertifiedGrade>> {
    // v1: derive a portable GradeResult from a EpisodeReport's totals (self-hosted numbers,
    // per protocol GradeResult doc). No signed receipt locally.
    const r = subject as EpisodeReport;
    const result: GradeResult = {
      subjectSessionId: r.session.determinism_hash,
      metrics: {
        realized_floor_usd: r.totals.realized_floor_usd,
        expected_usd: r.totals.expected_usd,
        premium_spent: r.totals.premium_spent,
        bus_events: r.session.bus_events,
      },
    };
    return ok(result);
  }
}

function readInput(pathOrDash: string | undefined): string {
  const src = pathOrDash === undefined || pathOrDash === "-" ? 0 : pathOrDash;
  try {
    return readFileSync(src, "utf8");
  } catch (e) {
    throw new CliError({
      code: "NOT_FOUND",
      exit: EXIT.NOT_FOUND,
      message: `cannot read frame input ${JSON.stringify(pathOrDash ?? "-")}: ${msg(e)}`,
    });
  }
}

/**
 * Fail closed on an absent `--bus` file BEFORE the heavy runner reads it. Without this, the ENOENT
 * from `readBus`'s `readFileSync` bubbles up as a bare `Error` and lands in GENERIC/exit 1 — the
 * taxonomy's "unexpected/uncaught" bucket — when the CLI's own table reserves NOT_FOUND/exit 3 for
 * "an input file is absent" (kestrel-96rw). Content buses (RUNTIME's "path or string": a JSONL bus
 * begins with `{`) are not paths and are left for {@link readBus} to parse.
 */
function assertBusReadable(busPath: string | undefined): void {
  if (busPath === undefined || busPath.trimStart().startsWith("{")) return;
  try {
    statSync(busPath);
  } catch (e) {
    throw new CliError({
      code: "NOT_FOUND",
      exit: EXIT.NOT_FOUND,
      message: `cannot read bus ${JSON.stringify(busPath)}: ${msg(e)}`,
    });
  }
}

const usage = (m: string): CliError => new CliError({ code: "USAGE", exit: EXIT.USAGE, message: m });
const msg = (e: unknown): string => (e instanceof Error ? e.message : String(e));
