import { readdir } from "node:fs/promises";
import { z } from "zod";
import {
  normalizeReviewDecision,
  reviewDecisionCodec,
  type ReviewDecision,
} from "../../../schema/src/decisions/decision.js";
import {
  defineRecordCodec,
  type RecordCodec,
} from "../../../schema/src/records/envelope.js";
import { sha256Canonical } from "../../../schema/src/canonical.js";
import type { CoverageOutcome } from "../../../schema/src/records/coverage.js";
import { matchScope, scopesOverlap } from "../match/scope.js";
import type { MatchSubject } from "../match/scope.js";
import { atomicWriteRecord, CorruptStateError, readRecord } from "../store/atomic.js";
import { storeLayout } from "../store/layout.js";

export const DECISION_INDEX_VERSION = "decision-index/v1" as const;

const decisionIndexSchema = z
  .object({
    schemaVersion: z.literal(DECISION_INDEX_VERSION),
    activeIds: z.array(z.string()),
  })
  .strict();

export type DecisionStoreIndex = z.infer<typeof decisionIndexSchema>;

export const decisionIndexCodec: RecordCodec<DecisionStoreIndex> = defineRecordCodec({
  schema: decisionIndexSchema,
  currentVersion: DECISION_INDEX_VERSION,
});

export type DecisionQuerySubject = MatchSubject & {
  findingId: string;
  evidenceFingerprint: string;
};

export const COVERAGE_DECISION_DETECTOR = {
  id: "coverage-gap",
  version: "1.0.0",
} as const;

export function coverageDecisionSubject(
  outcome: CoverageOutcome,
  now: string,
): DecisionQuerySubject | undefined {
  const profileId = outcome.capabilityProfileRef?.id;
  if (profileId === undefined) {
    return undefined;
  }

  const surfaceFingerprint = sha256Canonical({
    scopeId: outcome.scope.id,
    surfaceId: outcome.scope.surfaceId ?? null,
    reason: outcome.reason,
  });
  const detectorId = outcome.scope.detectorId ?? "";

  return {
    findingId: `coverage:${detectorId}:${outcome.reason}:${surfaceFingerprint}`,
    evidenceFingerprint: profileId,
    detector: COVERAGE_DECISION_DETECTOR,
    target: `coverage:${detectorId}:${outcome.reason}:${surfaceFingerprint}`,
    contextKind: outcome.context.kind,
    dimensions: {},
    contractVersion: null,
    now,
  };
}

export type FinalizeResult = {
  finalized: ReviewDecision[];
  deactivatedIds: string[];
};

export class DecisionOverlapError extends Error {
  readonly fingerprint: string;
  readonly conflictingDecisionIds: readonly string[];

  constructor(fingerprint: string, conflictingDecisionIds: readonly string[]) {
    super(
      `Overlapping active decision scopes for fingerprint ${fingerprint}: ${conflictingDecisionIds.join(", ")}`,
    );
    this.name = "DecisionOverlapError";
    this.fingerprint = fingerprint;
    this.conflictingDecisionIds = conflictingDecisionIds;
  }
}

export class DecisionFinalizationError extends Error {
  readonly reason: string;

  constructor(reason: string) {
    super(reason);
    this.name = "DecisionFinalizationError";
    this.reason = reason;
  }
}

function compareUnicodeScalars(a: string, b: string): number {
  let i = 0;
  let j = 0;

  while (i < a.length && j < b.length) {
    const codeA = a.codePointAt(i);
    const codeB = b.codePointAt(j);
    if (codeA === undefined || codeB === undefined) {
      break;
    }
    if (codeA !== codeB) {
      return codeA < codeB ? -1 : 1;
    }
    i += codeA > 0xffff ? 2 : 1;
    j += codeB > 0xffff ? 2 : 1;
  }

  return a.length - b.length;
}

function decisionFileName(decisionId: string): string {
  return `${decisionId}.json`;
}

function isDecisionRecordFile(fileName: string): boolean {
  return fileName.endsWith(".json") && fileName !== "store-index.json";
}

export function hasBlockingAuthority(decision: ReviewDecision): boolean {
  return (
    decision.verdict === "confirmed_defect" && decision.promotion !== undefined
  );
}

export class DecisionStore {
  constructor(
    private readonly index: DecisionStoreIndex,
    private readonly decisions: Map<string, ReviewDecision>,
  ) {}

  getDecision(id: string): ReviewDecision | undefined {
    return this.decisions.get(id);
  }

  isActive(id: string): boolean {
    return this.index.activeIds.includes(id);
  }

  activeIds(): readonly string[] {
    return this.index.activeIds;
  }

  activeDecisionFor(subject: DecisionQuerySubject): ReviewDecision | undefined {
    for (const id of this.index.activeIds) {
      const decision = this.decisions.get(id);
      if (decision === undefined) {
        continue;
      }
      if (decision.findingId !== subject.findingId) {
        continue;
      }
      if (decision.evidenceFingerprint !== subject.evidenceFingerprint) {
        continue;
      }
      const match = matchScope(decision.scope, subject);
      if (match.matched) {
        return decision;
      }
    }
    return undefined;
  }

  reopenOnFingerprintChange(finding: {
    id: string;
    evidenceFingerprint: string;
  }): boolean {
    const history = [...this.decisions.values()]
      .filter((decision) => decision.findingId === finding.id)
      .sort((left, right) =>
        compareUnicodeScalars(right.reviewedAt, left.reviewedAt),
      );

    const latest = history[0];
    if (latest === undefined) {
      return false;
    }

    return latest.evidenceFingerprint !== finding.evidenceFingerprint;
  }

  queryDecisions(options: { audit?: boolean } = {}): ReviewDecision[] {
    const all = [...this.decisions.values()].sort((left, right) =>
      compareUnicodeScalars(left.id, right.id),
    );

    if (options.audit === true) {
      return all;
    }

    return all.filter((decision) => !this.isHiddenFromDefault(decision));
  }

  isHiddenFromDefault(decision: ReviewDecision): boolean {
    return decision.verdict === "not_defect";
  }
}

export async function readDecisionIndex(root: string): Promise<DecisionStoreIndex> {
  const layout = storeLayout(root);
  const indexPath = layout.decision("store-index.json");

  try {
    return await readRecord(indexPath, decisionIndexCodec, {
      stateClass: "blocking",
    });
  } catch (error) {
    if (
      error instanceof CorruptStateError &&
      error.reason === "record file is missing"
    ) {
      return {
        schemaVersion: DECISION_INDEX_VERSION,
        activeIds: [],
      };
    }
    throw error;
  }
}

async function listDecisionIds(root: string): Promise<string[]> {
  const layout = storeLayout(root);
  let entries: string[];
  try {
    entries = await readdir(layout.decisionsDir());
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code === "ENOENT") {
      return [];
    }
    throw error;
  }

  return entries
    .filter(isDecisionRecordFile)
    .map((fileName) => fileName.slice(0, -".json".length))
    .sort(compareUnicodeScalars);
}

export async function readDecisionRecord(
  root: string,
  decisionId: string,
): Promise<ReviewDecision> {
  const layout = storeLayout(root);
  const decisionPath = layout.decision(decisionFileName(decisionId));
  return readRecord(decisionPath, reviewDecisionCodec, { stateClass: "blocking" });
}

export async function loadDecisionStore(root: string): Promise<DecisionStore> {
  const index = await readDecisionIndex(root);
  const decisionIds = await listDecisionIds(root);
  const decisions = new Map<string, ReviewDecision>();

  for (const decisionId of decisionIds) {
    const decision = await readDecisionRecord(root, decisionId);
    decisions.set(decisionId, decision);
  }

  return new DecisionStore(index, decisions);
}

export async function writeDecisionIndex(
  root: string,
  index: DecisionStoreIndex,
): Promise<void> {
  const layout = storeLayout(root);
  const indexPath = layout.decision("store-index.json");
  await atomicWriteRecord(indexPath, index, decisionIndexCodec);
}

export async function writeDecisionRecord(
  root: string,
  decision: ReviewDecision,
): Promise<void> {
  const layout = storeLayout(root);
  const decisionPath = layout.decision(decisionFileName(decision.id));
  await atomicWriteRecord(decisionPath, decision, reviewDecisionCodec);
}

export function normalizeDecisionIndex(index: DecisionStoreIndex): DecisionStoreIndex {
  return {
    schemaVersion: DECISION_INDEX_VERSION,
    activeIds: [...index.activeIds].sort(compareUnicodeScalars),
  };
}

export function subjectFromDecision(decision: ReviewDecision, now: string): DecisionQuerySubject {
  const dimensions: Record<string, string> = {};
  if (decision.scope.kind === "exact") {
    for (const dimension of decision.scope.context.dimensions) {
      dimensions[dimension.key] = dimension.value;
    }
  }

  const target =
    decision.scope.target.kind === "exact"
      ? decision.scope.target.canonicalTarget
      : decision.scope.target.canonicalGlob;

  return {
    findingId: decision.findingId,
    evidenceFingerprint: decision.evidenceFingerprint,
    detector: decision.scope.detector,
    target,
    contextKind:
      decision.scope.kind === "exact"
        ? decision.scope.context.kind
        : decision.scope.context.kinds[0] ?? "",
    dimensions,
    contractVersion: decision.scope.contractVersion,
    now,
  };
}

export function findOverlappingActiveDecisions(
  staged: ReviewDecision,
  activeDecisions: ReviewDecision[],
): ReviewDecision[] {
  return activeDecisions.filter(
    (active) =>
      active.evidenceFingerprint === staged.evidenceFingerprint &&
      scopesOverlap(active.scope, staged.scope),
  );
}

export function validateStagedDecisions(
  staged: ReviewDecision[],
  store: DecisionStore,
): { normalized: ReviewDecision[]; deactivatedIds: string[] } {
  if (staged.length === 0) {
    throw new DecisionFinalizationError("at least one staged decision is required");
  }

  const normalized = staged.map((decision) => normalizeReviewDecision(decision));
  const stagedIds = new Set(normalized.map((decision) => decision.id));
  if (stagedIds.size !== normalized.length) {
    throw new DecisionFinalizationError("staged decisions contain duplicate ids");
  }

  const activeDecisions = store
    .activeIds()
    .map((id) => store.getDecision(id))
    .filter((decision): decision is ReviewDecision => decision !== undefined);

  const nextActiveIds = new Set(store.activeIds());
  const deactivatedIds: string[] = [];

  for (const decision of normalized) {
    for (const supersededId of decision.supersedes) {
      if (!nextActiveIds.has(supersededId)) {
        throw new DecisionFinalizationError(
          `supersedes references inactive or missing decision: ${supersededId}`,
        );
      }
      const superseded = store.getDecision(supersededId);
      if (superseded === undefined) {
        throw new DecisionFinalizationError(
          `supersedes references missing decision record: ${supersededId}`,
        );
      }
      if (
        superseded.evidenceFingerprint !== decision.evidenceFingerprint ||
        !scopesOverlap(superseded.scope, decision.scope)
      ) {
        throw new DecisionFinalizationError(
          `supersedes decision ${supersededId} does not overlap staged decision ${decision.id}`,
        );
      }
      nextActiveIds.delete(supersededId);
      deactivatedIds.push(supersededId);
    }

    const overlapping = findOverlappingActiveDecisions(decision, activeDecisions).filter(
      (active) => nextActiveIds.has(active.id),
    );
    const unsuperseded = overlapping.filter(
      (active) => !decision.supersedes.includes(active.id),
    );

    if (unsuperseded.length > 0) {
      throw new DecisionOverlapError(
        decision.evidenceFingerprint,
        unsuperseded.map((active) => active.id),
      );
    }

    nextActiveIds.add(decision.id);
  }

  for (let leftIndex = 0; leftIndex < normalized.length; leftIndex += 1) {
    for (let rightIndex = leftIndex + 1; rightIndex < normalized.length; rightIndex += 1) {
      const left = normalized[leftIndex];
      const right = normalized[rightIndex];
      if (left === undefined || right === undefined) {
        continue;
      }
      if (
        left.evidenceFingerprint === right.evidenceFingerprint &&
        scopesOverlap(left.scope, right.scope)
      ) {
        throw new DecisionOverlapError(left.evidenceFingerprint, [left.id, right.id]);
      }
    }
  }

  return { normalized, deactivatedIds: [...new Set(deactivatedIds)].sort(compareUnicodeScalars) };
}

export async function persistFinalizedDecisions(
  root: string,
  finalized: ReviewDecision[],
  deactivatedIds: readonly string[],
  currentIndex: DecisionStoreIndex,
): Promise<void> {
  const nextActiveIds = new Set(currentIndex.activeIds);
  for (const id of deactivatedIds) {
    nextActiveIds.delete(id);
  }
  for (const decision of finalized) {
    nextActiveIds.add(decision.id);
  }

  const nextIndex = normalizeDecisionIndex({
    schemaVersion: DECISION_INDEX_VERSION,
    activeIds: [...nextActiveIds],
  });

  for (const decision of finalized) {
    await writeDecisionRecord(root, decision);
  }
  await writeDecisionIndex(root, nextIndex);
}
