import { createHash } from "node:crypto";
import {
  closeSync,
  existsSync,
  fsyncSync,
  openSync,
  readFileSync,
  renameSync,
  rmSync,
  writeFileSync,
} from "node:fs";
import { dirname } from "node:path";
import { EXPECTED_CRITERION_IDS, type AcceptanceCriterionId } from "./criterion-registry.js";
import type { JourneyManifest } from "./journey-manifest.js";
import { manifestFingerprint } from "./journey-manifest.js";

export interface ProofReceipt {
  readonly criterionId: AcceptanceCriterionId;
  readonly unitId: string;
  readonly proofId: string;
  readonly manifestGeneration: number;
  readonly manifestFingerprint: string;
  readonly completedAt: string;
  readonly outputSha256: string;
}

export interface ProofReceiptLedger {
  readonly schemaVersion: 1;
  readonly runId: string;
  readonly seedFingerprint: string;
  readonly receipts: Readonly<Partial<Record<AcceptanceCriterionId, ProofReceipt>>>;
}

const criterionPattern = /^- \[([ xX])\] (AC-\d+):/gmu;

export function receiptLedgerPath(manifestPath: string): string {
  return `${manifestPath}.proof-receipts.json`;
}

function atomicWrite(path: string, source: string): void {
  const temporary = `${path}.tmp-${process.pid}-${Date.now()}`;
  let created = false;
  try {
    const fd = openSync(temporary, "wx", 0o600);
    created = true;
    try {
      writeFileSync(fd, source);
      fsyncSync(fd);
    } finally {
      closeSync(fd);
    }
    renameSync(temporary, path);
    created = false;
    const directory = openSync(dirname(path), "r");
    try {
      fsyncSync(directory);
    } finally {
      closeSync(directory);
    }
  } finally {
    if (created) rmSync(temporary, { force: true });
  }
}

export function acceptanceState(source: string): {
  readonly checked: ReadonlySet<AcceptanceCriterionId>;
  readonly total: number;
} {
  const matches = [...source.matchAll(criterionPattern)];
  const ids = matches.map((match) => match[2] as AcceptanceCriterionId);
  if (ids.length !== EXPECTED_CRITERION_IDS.length) {
    throw new Error(
      `acceptance state must contain exactly ${EXPECTED_CRITERION_IDS.length} criteria`,
    );
  }
  if (ids.some((id, index) => id !== EXPECTED_CRITERION_IDS[index])) {
    throw new Error("acceptance state criterion order/identity mismatch");
  }
  return {
    checked: new Set(
      matches
        .filter((match) => match[1]?.toLowerCase() === "x")
        .map((match) => match[2] as AcceptanceCriterionId),
    ),
    total: ids.length,
  };
}

export function loadProofReceiptLedger(
  path: string,
  manifest: JourneyManifest,
): ProofReceiptLedger {
  if (!existsSync(path)) {
    return {
      schemaVersion: 1,
      runId: manifest.runId,
      seedFingerprint: manifest.seedFingerprint,
      receipts: {},
    };
  }
  const raw = JSON.parse(readFileSync(path, "utf8")) as Partial<ProofReceiptLedger>;
  if (
    raw.schemaVersion !== 1 ||
    raw.runId !== manifest.runId ||
    raw.seedFingerprint !== manifest.seedFingerprint ||
    !raw.receipts ||
    typeof raw.receipts !== "object" ||
    Array.isArray(raw.receipts)
  ) {
    throw new Error("proof receipt ledger does not match the exact journey run");
  }
  for (const [criterionId, receipt] of Object.entries(raw.receipts)) {
    if (!EXPECTED_CRITERION_IDS.includes(criterionId as AcceptanceCriterionId)) {
      throw new Error(`proof receipt ledger contains unknown criterion ${criterionId}`);
    }
    if (
      !receipt ||
      receipt.criterionId !== criterionId ||
      !receipt.completedAt ||
      !receipt.outputSha256
    ) {
      throw new Error(`proof receipt ledger contains invalid receipt ${criterionId}`);
    }
  }
  return raw as ProofReceiptLedger;
}

export function persistProofReceipts(
  path: string,
  manifest: JourneyManifest,
  input: {
    readonly unitId: string;
    readonly proofId: string;
    readonly criterionIds: readonly AcceptanceCriterionId[];
    readonly output: string;
    readonly completedAt?: string;
  },
): ProofReceiptLedger {
  const current = loadProofReceiptLedger(path, manifest);
  const completedAt = input.completedAt ?? new Date().toISOString();
  const outputSha256 = createHash("sha256").update(input.output).digest("hex");
  const fingerprint = manifestFingerprint(manifest);
  const receipts: Partial<Record<AcceptanceCriterionId, ProofReceipt>> = { ...current.receipts };
  for (const criterionId of input.criterionIds) {
    const previous = receipts[criterionId];
    if (previous) {
      if (previous.unitId !== input.unitId || previous.proofId !== input.proofId) {
        throw new Error(`${criterionId} is already bound to a different proof receipt`);
      }
      continue;
    }
    receipts[criterionId] = {
      criterionId,
      unitId: input.unitId,
      proofId: input.proofId,
      manifestGeneration: manifest.generation,
      manifestFingerprint: fingerprint,
      completedAt,
      outputSha256,
    };
  }
  const next: ProofReceiptLedger = {
    schemaVersion: 1,
    runId: manifest.runId,
    seedFingerprint: manifest.seedFingerprint,
    receipts,
  };
  atomicWrite(path, `${JSON.stringify(next, null, 2)}\n`);
  return next;
}

export function reconcileAcceptanceFile(acceptanceFile: string, ledger: ProofReceiptLedger): void {
  let source = readFileSync(acceptanceFile, "utf8");
  const state = acceptanceState(source);
  for (const checked of state.checked) {
    if (!ledger.receipts[checked]) {
      throw new Error(
        `acceptance criterion ${checked} is checked without this run's durable receipt`,
      );
    }
  }
  for (const criterionId of EXPECTED_CRITERION_IDS) {
    if (!ledger.receipts[criterionId] || state.checked.has(criterionId)) continue;
    const needle = `- [ ] ${criterionId}:`;
    const matches = source.split(/\r?\n/u).filter((line) => line.startsWith(needle));
    if (matches.length !== 1) {
      throw new Error(
        `acceptance criterion ${criterionId} must occur exactly once and be unchecked`,
      );
    }
    source = source.replace(needle, `- [x] ${criterionId}:`);
  }
  const updated = acceptanceState(source);
  const percentage = Math.round((updated.checked.size / updated.total) * 100);
  source = source.replace(
    /^Current: \*\*\d+ \/ \d+ \(\d+%\).*?\*\*\.$/gmu,
    `Current: **${updated.checked.size} / ${updated.total} (${percentage}%) re-proven after the I1 drift audit**.`,
  );
  atomicWrite(acceptanceFile, source);
}
