/**
 * SET 89 §11 — canonical append-only action ledger.
 *
 * Every meaningful mutation, token-spending call, and load-bearing host
 * callback is a structured action with exactly one terminal. Skipped/no-op
 * decisions that never start work use ActionDecision (no start fabricated).
 *
 * Human-readable log lines are RENDERED from these records — never maintained
 * as a parallel string ledger.
 */
export type ActionDisposition =
  | 'applied'
  | 'rejected'
  | 'noop'
  | 'failed'
  | 'cancelled';

export type ActionStart = {
  actionId: string;
  kind: string;
  target: string;
  phase: string;
  startEvidence: unknown;
  relatedCandidateIds: string[];
  relatedEditIds: string[];
  beforeSignature?: string;
  ts: string;
};

export type ActionTerminal = {
  actionId: string;
  disposition: ActionDisposition;
  failureMode?: 'fatal' | 'nonfatal';
  endEvidence: unknown;
  afterSignature?: string;
  error?: {
    name?: string;
    message: string;
    stack?: string;
  };
  ts: string;
};

export type ActionDecision = {
  decisionId: string;
  kind: string;
  target: string;
  phase: string;
  disposition: 'rejected' | 'noop';
  reason: string;
  relatedCandidateIds: string[];
  relatedEditIds: string[];
  ts: string;
};

export type ActionLedgerSnapshot = {
  starts: ActionStart[];
  terminals: ActionTerminal[];
  decisions: ActionDecision[];
  metrics: ActionLedgerMetrics;
};

export type ActionLedgerMetrics = {
  actionStarts: number;
  actionTerminals: number;
  actionDecisions: number;
  actionPending: number;
  actionFailed: number;
  actionApplied: number;
  actionRejected: number;
  actionNoop: number;
  actionCancelled: number;
  actionFailedNonfatal: number;
};

function pad(n: number, width: number): string {
  return String(n).padStart(width, '0');
}

function errShape(e: unknown): ActionTerminal['error'] {
  if (e instanceof Error) {
    return { name: e.name, message: e.message, stack: e.stack };
  }
  return { message: String(e) };
}

export class ActionLedger {
  private actionSeq = 0;
  private decisionSeq = 0;
  private readonly starts: ActionStart[] = [];
  private readonly terminals: ActionTerminal[] = [];
  private readonly decisions: ActionDecision[] = [];
  private readonly pending = new Set<string>();
  private readonly terminalized = new Set<string>();
  private readonly clock: () => string;
  /** When true, double-terminal throws (tests/dev). Production logic rejects. */
  private readonly strict: boolean;

  constructor(
    clock: () => string = () => new Date().toISOString(),
    opts?: { strict?: boolean },
  ) {
    this.clock = clock;
    this.strict = opts?.strict ?? true;
  }

  startAction(input: {
    kind: string;
    target: string;
    phase: string;
    startEvidence?: unknown;
    relatedCandidateIds?: string[];
    relatedEditIds?: string[];
    beforeSignature?: string;
    ts?: string;
  }): string {
    this.actionSeq += 1;
    const actionId = `A${pad(this.actionSeq, 6)}`;
    const start: ActionStart = {
      actionId,
      kind: input.kind,
      target: input.target,
      phase: input.phase,
      startEvidence: input.startEvidence ?? null,
      relatedCandidateIds: [...(input.relatedCandidateIds ?? [])],
      relatedEditIds: [...(input.relatedEditIds ?? [])],
      beforeSignature: input.beforeSignature,
      ts: input.ts ?? this.clock(),
    };
    this.starts.push(start);
    this.pending.add(actionId);
    return actionId;
  }

  finishAction(input: {
    actionId: string;
    disposition: ActionDisposition;
    failureMode?: 'fatal' | 'nonfatal';
    endEvidence?: unknown;
    afterSignature?: string;
    error?: unknown;
    ts?: string;
  }): ActionTerminal {
    if (this.terminalized.has(input.actionId)) {
      const msg = `actionLedger: double terminal for ${input.actionId}`;
      if (this.strict) throw new Error(msg);
      // Production: reject silently by returning the existing terminal.
      return this.terminals.find(t => t.actionId === input.actionId)!;
    }
    if (!this.pending.has(input.actionId) && !this.starts.some(s => s.actionId === input.actionId)) {
      throw new Error(`actionLedger: finish without start for ${input.actionId}`);
    }
    const terminal: ActionTerminal = {
      actionId: input.actionId,
      disposition: input.disposition,
      failureMode: input.failureMode,
      endEvidence: input.endEvidence ?? null,
      afterSignature: input.afterSignature,
      error: input.error !== undefined ? errShape(input.error) : undefined,
      ts: input.ts ?? this.clock(),
    };
    this.terminals.push(terminal);
    this.pending.delete(input.actionId);
    this.terminalized.add(input.actionId);
    return terminal;
  }

  recordDecision(input: {
    kind: string;
    target: string;
    phase: string;
    disposition: 'rejected' | 'noop';
    reason: string;
    relatedCandidateIds?: string[];
    relatedEditIds?: string[];
    ts?: string;
  }): ActionDecision {
    this.decisionSeq += 1;
    const decision: ActionDecision = {
      decisionId: `AD${pad(this.decisionSeq, 6)}`,
      kind: input.kind,
      target: input.target,
      phase: input.phase,
      disposition: input.disposition,
      reason: input.reason,
      relatedCandidateIds: [...(input.relatedCandidateIds ?? [])],
      relatedEditIds: [...(input.relatedEditIds ?? [])],
      ts: input.ts ?? this.clock(),
    };
    this.decisions.push(decision);
    return decision;
  }

  async runAction<T>(input: {
    kind: string;
    target: string;
    phase: string;
    startEvidence?: unknown;
    relatedCandidateIds?: string[];
    relatedEditIds?: string[];
    beforeSignature?: string;
    afterSignature?: () => string | undefined;
    fn: () => Promise<T> | T;
    /**
     * Map the result to a disposition. Default: applied.
     * Throw → failed (fatal).
     */
    dispositionOf?: (result: T) => ActionDisposition;
  }): Promise<{ actionId: string; result: T; terminal: ActionTerminal }> {
    const actionId = this.startAction(input);
    try {
      const result = await input.fn();
      const disposition = input.dispositionOf?.(result) ?? 'applied';
      const terminal = this.finishAction({
        actionId,
        disposition,
        endEvidence: { ok: true },
        afterSignature: input.afterSignature?.(),
      });
      return { actionId, result, terminal };
    } catch (error) {
      const terminal = this.finishAction({
        actionId,
        disposition: 'failed',
        failureMode: 'fatal',
        error,
        endEvidence: { ok: false },
        afterSignature: input.afterSignature?.(),
      });
      throw Object.assign(error instanceof Error ? error : new Error(String(error)), {
        actionId,
        actionTerminal: terminal,
      });
    }
  }

  /**
   * Run a host callback. Failures are recorded as failed-nonfatal and do NOT
   * rethrow (run continues). Never recursively invokes the same callback to
   * report its own failure.
   */
  async runNonfatalAction(input: {
    kind: string;
    target: string;
    phase: string;
    startEvidence?: unknown;
    fn: () => Promise<unknown> | unknown;
  }): Promise<{ actionId: string; ok: boolean; terminal: ActionTerminal }> {
    const actionId = this.startAction({
      kind: input.kind,
      target: input.target,
      phase: input.phase,
      startEvidence: input.startEvidence,
    });
    try {
      await input.fn();
      const terminal = this.finishAction({
        actionId,
        disposition: 'applied',
        endEvidence: { ok: true },
      });
      return { actionId, ok: true, terminal };
    } catch (error) {
      const terminal = this.finishAction({
        actionId,
        disposition: 'failed',
        failureMode: 'nonfatal',
        error,
        endEvidence: { ok: false },
      });
      return { actionId, ok: false, terminal };
    }
  }

  /** Cancel every still-pending action (run abort). */
  cancelPending(reason = 'run cancelled'): void {
    for (const actionId of [...this.pending]) {
      this.finishAction({
        actionId,
        disposition: 'cancelled',
        endEvidence: { reason },
      });
    }
  }

  metrics(): ActionLedgerMetrics {
    let failed = 0, applied = 0, rejected = 0, noop = 0, cancelled = 0, failedNonfatal = 0;
    for (const t of this.terminals) {
      if (t.disposition === 'failed') {
        failed++;
        if (t.failureMode === 'nonfatal') failedNonfatal++;
      } else if (t.disposition === 'applied') applied++;
      else if (t.disposition === 'rejected') rejected++;
      else if (t.disposition === 'noop') noop++;
      else if (t.disposition === 'cancelled') cancelled++;
    }
    return {
      actionStarts: this.starts.length,
      actionTerminals: this.terminals.length,
      actionDecisions: this.decisions.length,
      actionPending: this.pending.size,
      actionFailed: failed,
      actionApplied: applied,
      actionRejected: rejected,
      actionNoop: noop,
      actionCancelled: cancelled,
      actionFailedNonfatal: failedNonfatal,
    };
  }

  snapshot(): ActionLedgerSnapshot {
    return {
      starts: this.starts.map(s => ({ ...s, relatedCandidateIds: [...s.relatedCandidateIds], relatedEditIds: [...s.relatedEditIds] })),
      terminals: this.terminals.map(t => ({ ...t })),
      decisions: this.decisions.map(d => ({ ...d, relatedCandidateIds: [...d.relatedCandidateIds], relatedEditIds: [...d.relatedEditIds] })),
      metrics: this.metrics(),
    };
  }

  assertConserved(): void {
    const m = this.metrics();
    if (m.actionStarts !== m.actionTerminals) {
      throw new Error(`actionLedger: starts ${m.actionStarts} !== terminals ${m.actionTerminals}`);
    }
    if (m.actionPending !== 0) {
      throw new Error(`actionLedger: ${m.actionPending} pending action(s) at conservation check`);
    }
  }

  /** Render a human log line from a terminal (or decision). */
  renderTerminalLine(actionId: string): string {
    const start = this.starts.find(s => s.actionId === actionId);
    const term = this.terminals.find(t => t.actionId === actionId);
    if (!start || !term) return `[action:${actionId} incomplete]`;
    const failTag = term.failureMode === 'nonfatal' ? '-nonfatal' : '';
    const err = term.error ? ` — ${term.error.message}` : '';
    return `[action:${start.kind}:${start.target} ${actionId} → ${term.disposition}${failTag}]${err}`;
  }

  renderStartLine(actionId: string): string {
    const start = this.starts.find(s => s.actionId === actionId);
    if (!start) return `[action:${actionId} START?]`;
    return `[action:${start.kind}:${start.target}:${actionId}] START`;
  }

  toJSONL(): string {
    const lines: string[] = [];
    for (const s of this.starts) lines.push(JSON.stringify({ ledgerKind: 'start', ...s }));
    for (const t of this.terminals) lines.push(JSON.stringify({ ledgerKind: 'terminal', ...t }));
    for (const d of this.decisions) lines.push(JSON.stringify({ ledgerKind: 'decision', ...d }));
    return lines.join('\n') + (lines.length ? '\n' : '');
  }
}
