import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ExecutionContext } from "../../../../schema/src/records/context.js";
import {
  classify,
  type ClassifiedRun,
  type CoverageEvent,
  type DetectorOutcome,
  type KernelStores,
} from "../../../../core/src/classify/classifier.js";
import { loadContractStore } from "../../../../core/src/contracts/store.js";
import { loadDecisionStore } from "../../../../core/src/decisions/store.js";
import { detectLifecycleViolations } from "./lifecycle-checks.js";
import {
  FAULT_BOUNDARIES,
  FAULT_KINDS,
  type AtomicityOracle,
  type AtomicityOracleVerdict,
  type FaultBoundary,
  type FaultBoundaryCall,
  type FaultCoverageGap,
  type FaultInjectableSystem,
  type FaultInjectionResult,
  type FaultKind,
  type FaultRunContext,
  type FaultSpec,
  type LifecycleEvidence,
  type OwnedResourceRecord,
  type PostTerminalEvent,
} from "./types.js";

const FAULT_DETECTOR = { id: "fault-injection", version: "1.0.0" } as const;

export class FaultInjectionError extends Error {
  readonly fault: FaultSpec;
  readonly faultKind: FaultKind;

  constructor(fault: FaultSpec, faultKind: FaultKind, message?: string) {
    super(message ?? `fault injection ${faultKind} at ${fault.boundary}:${fault.at}`);
    this.name = "FaultInjectionError";
    this.fault = fault;
    this.faultKind = faultKind;
  }
}

function assertNonEmptyString(value: string, field: string): void {
  if (value.trim().length === 0) {
    throw new Error(`${field} must be a non-empty string`);
  }
}

function assertFaultBoundary(value: string): asserts value is FaultBoundary {
  if (!(FAULT_BOUNDARIES as readonly string[]).includes(value)) {
    throw new Error(`unknown fault boundary: ${value}`);
  }
}

function assertFaultKind(value: string): asserts value is FaultKind {
  if (!(FAULT_KINDS as readonly string[]).includes(value)) {
    throw new Error(`unknown fault kind: ${value}`);
  }
}

function validateFault(fault: FaultSpec): void {
  assertFaultBoundary(fault.boundary);
  assertFaultKind(fault.kind);
  assertNonEmptyString(fault.at, "at");
}

function validateSystem<TState>(system: FaultInjectableSystem<TState>): void {
  assertNonEmptyString(system.id, "system.id");
  if (typeof system.createState !== "function") {
    throw new Error("createState must be a function");
  }
  if (typeof system.run !== "function") {
    throw new Error("run must be a function");
  }
  if (typeof system.snapshotLifecycle !== "function") {
    throw new Error("snapshotLifecycle must be a function");
  }
}

function validateOracle(oracle: AtomicityOracle): void {
  assertNonEmptyString(oracle.id, "oracle.id");
  if (typeof oracle.assertAtomicity !== "function") {
    throw new Error("assertAtomicity must be a function");
  }
}

function faultContext(systemId: string, fault: FaultSpec): ExecutionContext {
  return {
    kind: "api",
    surfaceId: systemId,
    adapterId: "fault-injection",
    environment: {},
    seed: `${systemId}:${fault.boundary}:${fault.at}:${fault.kind}`,
  };
}

async function createDefaultStores(now = "2026-07-25T12:00:00.000Z"): Promise<KernelStores> {
  const root = await mkdtemp(join(tmpdir(), "invariantum-fault-injection-"));
  const [decisions, contracts] = await Promise.all([
    loadDecisionStore(root),
    loadContractStore(root),
  ]);
  return {
    decisions,
    contracts,
    authoritativeContracts: new Set<string>(),
    now,
  };
}

function matchesFaultTarget(fault: FaultSpec, call: FaultBoundaryCall): boolean {
  return fault.boundary === call.boundary && fault.at === call.at;
}

function sanitizeForEvidence(value: unknown): unknown {
  if (value === undefined) {
    return undefined;
  }
  if (typeof value === "number" && !Number.isFinite(value)) {
    return String(value);
  }
  if (Array.isArray(value)) {
    return value.map((entry) => sanitizeForEvidence(entry));
  }
  if (typeof value === "object" && value !== null) {
    return Object.fromEntries(
      Object.entries(value)
        .map(([key, entry]) => [key, sanitizeForEvidence(entry)] as const)
        .filter(([, entry]) => entry !== undefined),
    );
  }
  return value;
}

type FaultRuntime = {
  ownedResources: OwnedResourceRecord[];
  terminalReached: boolean;
  terminalEvent?: string;
  postTerminalEvents: PostTerminalEvent[];
  retryAttempts: number;
  consumptionPeaks: Record<string, number>;
  crashBetweenPhase: number;
  faultConsumed: boolean;
};

function createFaultRuntime(): FaultRuntime {
  return {
    ownedResources: [],
    terminalReached: false,
    postTerminalEvents: [],
    retryAttempts: 0,
    consumptionPeaks: {},
    crashBetweenPhase: 0,
    faultConsumed: false,
  };
}

async function applyFault(
  fault: FaultSpec,
  call: FaultBoundaryCall,
  runtime: FaultRuntime,
): Promise<unknown> {
  switch (fault.kind) {
    case "error":
      throw new FaultInjectionError(fault, fault.kind);
    case "timeout":
      throw new FaultInjectionError(fault, fault.kind, `timeout at ${fault.boundary}:${fault.at}`);
    case "partial":
      await call.proceed();
      throw new FaultInjectionError(
        fault,
        fault.kind,
        `partial completion at ${fault.boundary}:${fault.at}`,
      );
    case "crash-between": {
      runtime.crashBetweenPhase += 1;
      if (runtime.crashBetweenPhase === 1) {
        await call.proceed();
        throw new FaultInjectionError(
          fault,
          fault.kind,
          `crash-between after first phase at ${fault.boundary}:${fault.at}`,
        );
      }
      return call.proceed();
    }
    default: {
      const exhaustive: never = fault.kind;
      throw new Error(`unsupported fault kind: ${String(exhaustive)}`);
    }
  }
}

function mergeLifecycle(runtime: FaultRuntime, systemSnapshot: LifecycleEvidence): LifecycleEvidence {
  const resourceMap = new Map<string, OwnedResourceRecord>();
  for (const resource of [...runtime.ownedResources, ...systemSnapshot.ownedResources]) {
    const existing = resourceMap.get(resource.id);
    if (existing === undefined || !resource.released) {
      resourceMap.set(resource.id, { ...resource });
    }
  }

  return {
    ownedResources: [...resourceMap.values()],
    terminalReached: runtime.terminalReached || systemSnapshot.terminalReached,
    ...(runtime.terminalEvent !== undefined
      ? { terminalEvent: runtime.terminalEvent }
      : systemSnapshot.terminalEvent !== undefined
        ? { terminalEvent: systemSnapshot.terminalEvent }
        : {}),
    postTerminalEvents:
      runtime.postTerminalEvents.length > 0
        ? runtime.postTerminalEvents.map((event) => ({ ...event }))
        : systemSnapshot.postTerminalEvents.map((event) => ({ ...event })),
    retryAttempts: Math.max(runtime.retryAttempts, systemSnapshot.retryAttempts),
    consumptionPeaks: { ...systemSnapshot.consumptionPeaks, ...runtime.consumptionPeaks },
  };
}

function buildViolationOutcome(
  systemId: string,
  fault: FaultSpec,
  oracleId: string | undefined,
  summary: string,
  violation: Record<string, unknown>,
  laneEligibility: "advisory" | "blocking-eligible",
): DetectorOutcome {
  const context = faultContext(systemId, fault);
  const rule = typeof violation.rule === "string" ? violation.rule : undefined;
  const truthSource =
    rule?.startsWith("UNI-")
      ? "universal"
      : laneEligibility === "blocking-eligible"
        ? "confirmed"
        : "inferred";

  return {
    detector: FAULT_DETECTOR,
    class: "fault-injection-violation",
    severity: laneEligibility === "blocking-eligible" ? "high" : "medium",
    target: { kind: "fault-scenario", canonical: `${systemId}:${fault.boundary}:${fault.at}` },
    context,
    summary,
    evidence: [
      {
        truthSource,
        payload: violation,
      },
    ],
    artifacts: [],
    laneEligibility,
    ...(laneEligibility === "blocking-eligible" ? { proofConditionMet: true } : {}),
    scope: {
      id: `fault:${systemId}:${fault.boundary}:${fault.at}:${oracleId ?? "none"}`,
      detectorId: FAULT_DETECTOR.id,
      surfaceId: systemId,
    },
    violation: sanitizeForEvidence(violation) ?? {},
    contractOrConfig: sanitizeForEvidence({ systemId, fault, oracleId }) ?? {},
    contextDimensions: { boundary: fault.boundary, kind: fault.kind },
  };
}

async function classifyFaultResult(input: {
  systemId: string;
  fault: FaultSpec;
  oracle?: AtomicityOracle;
  lifecycle: LifecycleEvidence;
  oracleVerdict?: AtomicityOracleVerdict;
  coverageGap?: FaultCoverageGap;
  runId: string;
}): Promise<ClassifiedRun> {
  const detectorOutcomes: DetectorOutcome[] = [];
  const coverageEvents: CoverageEvent[] = [];
  const lifecycleViolations = detectLifecycleViolations(input.lifecycle);
  const laneForViolation = input.oracle === undefined ? "advisory" : "blocking-eligible";

  for (const violation of lifecycleViolations) {
    detectorOutcomes.push(
      buildViolationOutcome(
        input.systemId,
        input.fault,
        input.oracle?.id,
        `Lifecycle violation ${violation.rule}: ${violation.fact}`,
        {
          rule: violation.rule,
          fact: violation.fact,
          details: violation.details,
          lifecycle: sanitizeForEvidence(input.lifecycle),
        },
        laneForViolation,
      ),
    );
  }

  if (input.oracleVerdict !== undefined && !input.oracleVerdict.holds) {
    detectorOutcomes.push(
      buildViolationOutcome(
        input.systemId,
        input.fault,
        input.oracle?.id,
        `Atomicity oracle ${input.oracle?.id ?? "unknown"} violation`,
        {
          oracleId: input.oracle?.id,
          violated: input.oracleVerdict.violated ?? [],
          evidence: sanitizeForEvidence(input.oracleVerdict.evidence),
        },
        laneForViolation,
      ),
    );
  }

  if (input.coverageGap !== undefined) {
    coverageEvents.push({
      scope: {
        id: `fault:${input.systemId}`,
        detectorId: FAULT_DETECTOR.id,
        surfaceId: input.systemId,
      },
      context: faultContext(input.systemId, input.fault),
      reason: "unproven-precondition",
      witnessRefs: [
        {
          id: `fault-missing-atomicity-oracle:${input.systemId}:${input.fault.boundary}:${input.fault.at}`,
        },
      ],
    });
  }

  const stores = await createDefaultStores();
  return classify({
    detectorOutcomes,
    harnessEvents: [],
    coverageEvents,
    stores,
    runId: input.runId,
  });
}

function resolveLaneEligibility(
  oracle: AtomicityOracle | undefined,
  holds: boolean,
): "advisory" | "blocking-eligible" | "blocking" {
  if (oracle === undefined || holds) {
    return "advisory";
  }
  return "blocking-eligible";
}

export async function withFault<TState>(
  system: FaultInjectableSystem<TState>,
  fault: FaultSpec,
  expected?: AtomicityOracle,
  options: { runId?: string } = {},
): Promise<FaultInjectionResult> {
  validateSystem(system);
  validateFault(fault);
  if (expected !== undefined) {
    validateOracle(expected);
  }

  const runId = options.runId ?? `fault-run:${system.id}`;
  const runtime = createFaultRuntime();
  const state = await system.createState();

  const ctx: FaultRunContext<TState> = {
    state,
    callBoundary: async (call: FaultBoundaryCall) => {
      assertFaultBoundary(call.boundary);
      assertNonEmptyString(call.at, "call.at");
      if (runtime.terminalReached) {
        runtime.postTerminalEvents.push({
          kind: `boundary:${call.boundary}`,
          at: call.at,
        });
        throw new Error(`post-terminal boundary call rejected: ${call.boundary}:${call.at}`);
      }
      if (matchesFaultTarget(fault, call)) {
        if (fault.kind === "crash-between") {
          return applyFault(fault, call, runtime);
        }
        if (runtime.faultConsumed) {
          return call.proceed();
        }
        runtime.faultConsumed = true;
        return applyFault(fault, call, runtime);
      }
      return call.proceed();
    },
    signalTerminal: (event: string) => {
      runtime.terminalReached = true;
      runtime.terminalEvent = event;
    },
    acquireResource: (id: string, kind: string) => {
      const existing = runtime.ownedResources.find((resource) => resource.id === id);
      if (existing === undefined) {
        runtime.ownedResources.push({ id, kind, released: false });
      }
    },
    releaseResource: (id: string) => {
      const resource = runtime.ownedResources.find((entry) => entry.id === id);
      if (resource !== undefined) {
        resource.released = true;
      }
    },
    recordPostTerminalEvent: (kind: string) => {
      runtime.postTerminalEvents.push({ kind, at: new Date().toISOString() });
    },
    recordRetryAttempt: () => {
      runtime.retryAttempts += 1;
    },
    recordConsumptionPeak: (dimension: string, value: number) => {
      const current = runtime.consumptionPeaks[dimension] ?? 0;
      runtime.consumptionPeaks[dimension] = Math.max(current, value);
    },
    isTerminal: () => runtime.terminalReached,
  };

  let outcome: unknown;
  let runError: string | undefined;

  try {
    outcome = await system.run(ctx);
  } catch (error) {
    runError = error instanceof Error ? error.message : String(error);
  }

  const lifecycle = mergeLifecycle(runtime, system.snapshotLifecycle(state));

  let oracleVerdict: AtomicityOracleVerdict | undefined;
  if (expected !== undefined) {
    oracleVerdict = expected.assertAtomicity({
      fault,
      lifecycle,
      outcome,
      ...(runError !== undefined ? { error: runError } : {}),
    });
  }

  const lifecycleViolations = detectLifecycleViolations(lifecycle);
  const oracleFailed = oracleVerdict !== undefined && !oracleVerdict.holds;
  const holds =
    runError === undefined &&
    lifecycleViolations.length === 0 &&
    (oracleVerdict === undefined || oracleVerdict.holds);

  const coverageGap: FaultCoverageGap | undefined =
    expected === undefined && (!holds || lifecycleViolations.length > 0 || oracleFailed)
      ? { reason: "missing-atomicity-oracle" }
      : undefined;

  const classified = await classifyFaultResult({
    systemId: system.id,
    fault,
    lifecycle,
    runId,
    ...(expected !== undefined ? { oracle: expected } : {}),
    ...(oracleVerdict !== undefined ? { oracleVerdict } : {}),
    ...(coverageGap !== undefined ? { coverageGap } : {}),
  });

  const laneEligibility = resolveLaneEligibility(expected, holds);

  const error =
    runError ??
    (lifecycleViolations.length > 0
      ? lifecycleViolations.map((violation) => violation.fact).join("; ")
      : oracleFailed
        ? `atomicity oracle violation: ${(oracleVerdict?.violated ?? ["recovery-or-atomicity"]).join(", ")}`
        : undefined);

  return {
    id: system.id,
    fault,
    holds,
    lifecycle,
    classified,
    laneEligibility,
    ...(outcome !== undefined ? { outcome } : {}),
    ...(oracleVerdict !== undefined ? { oracleVerdict } : {}),
    ...(coverageGap !== undefined ? { coverageGap } : {}),
    ...(error !== undefined ? { error } : {}),
  };
}
