import { access } from "node:fs/promises";
import { join } from "node:path";
import { z } from "zod";
import { sha256Canonical } from "../../../schema/src/canonical.js";
import {
  computeBundleHash,
  parseDecisionBundle,
  type BundleDecision,
  type DecisionBundle,
} from "../../../schema/src/decisions/bundle.js";
import {
  normalizeReviewDecision,
  REVIEW_DECISION_VERSION,
  type ReviewDecision,
} from "../../../schema/src/decisions/decision.js";
import {
  defineRecordCodec,
  SchemaError,
  type RecordCodec,
} from "../../../schema/src/records/envelope.js";
import {
  loadDecisionStore,
  persistFinalizedDecisions,
  readDecisionIndex,
  validateStagedDecisions,
} from "../decisions/store.js";
import { atomicWriteRecord, CorruptStateError, readRecord } from "../store/atomic.js";
import {
  compareAndSwapGeneration,
  readStoreGeneration,
  withStoreLock,
} from "../store/concurrency.js";
import { storeLayout } from "../store/layout.js";
import type { ReportRef } from "./export.js";

export type { ReportRef };

export const PROJECT_CONFIG_VERSION = "project-config/v1" as const;
export const BUNDLE_RECEIPT_VERSION = "bundle-receipt/v1" as const;

const projectConfigSchema = z
  .object({
    schemaVersion: z.literal(PROJECT_CONFIG_VERSION),
    projectIdentity: z.string(),
    configHash: z.string(),
  })
  .strict();

export type ProjectConfig = z.infer<typeof projectConfigSchema>;

export const projectConfigCodec: RecordCodec<ProjectConfig> = defineRecordCodec({
  schema: projectConfigSchema,
  currentVersion: PROJECT_CONFIG_VERSION,
});

const bundleReceiptSchema = z
  .object({
    schemaVersion: z.literal(BUNDLE_RECEIPT_VERSION),
    projectIdentity: z.string(),
    nonce: z.string(),
    bundleHash: z.string(),
    reviewer: z.string(),
    consumedAt: z.string(),
  })
  .strict();

export type BundleReceipt = z.infer<typeof bundleReceiptSchema>;

export const bundleReceiptCodec: RecordCodec<BundleReceipt> = defineRecordCodec({
  schema: bundleReceiptSchema,
  currentVersion: BUNDLE_RECEIPT_VERSION,
});

export type BundleRejectionReason =
  | "bundle-hash-mismatch"
  | "project-identity-mismatch"
  | "config-hash-mismatch"
  | "report-mismatch"
  | "expired"
  | "nonce-replay"
  | "invalid-bundle";

export class BundleRejection extends Error {
  readonly reason: BundleRejectionReason;

  constructor(reason: BundleRejectionReason, message: string) {
    super(message);
    this.name = "BundleRejection";
    this.reason = reason;
  }
}

export type ImportTrust = {
  confirmedBundleHash: string;
  reviewer: string;
  retainedReport: ReportRef;
  now: string;
};

export type ImportResult = {
  bundleHash: string;
  reviewer: string;
  importedDecisionIds: string[];
  receiptPath: string;
};

function receiptFileName(
  projectIdentity: string,
  nonce: string,
  bundleHash: string,
): string {
  return `${sha256Canonical({ projectIdentity, nonce, bundleHash })}.json`;
}

function receiptPath(
  root: string,
  projectIdentity: string,
  nonce: string,
  bundleHash: string,
): string {
  const layout = storeLayout(root);
  return join(
    layout.config(),
    "bundle-receipts",
    receiptFileName(projectIdentity, nonce, bundleHash),
  );
}

async function readProjectConfig(root: string): Promise<ProjectConfig> {
  const layout = storeLayout(root);
  const configPath = layout.configFile("project.json");
  try {
    return await readRecord(configPath, projectConfigCodec, {
      stateClass: "blocking",
    });
  } catch (error) {
    if (error instanceof SchemaError) {
      throw new BundleRejection(
        "project-identity-mismatch",
        `project config at ${configPath} is invalid`,
      );
    }
    if (
      error instanceof CorruptStateError &&
      error.reason === "record file is missing"
    ) {
      throw new BundleRejection(
        "project-identity-mismatch",
        `project config at ${configPath} is missing or unreadable`,
      );
    }
    throw error;
  }
}

async function receiptExists(path: string): Promise<boolean> {
  try {
    await access(path);
    return true;
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code === "ENOENT") {
      return false;
    }
    throw error;
  }
}

function reportsMatch(bundleReport: ReportRef, retainedReport: ReportRef): boolean {
  return (
    bundleReport.id === retainedReport.id &&
    bundleReport.revision === retainedReport.revision &&
    bundleReport.findingSetHash === retainedReport.findingSetHash
  );
}

function parseBundleOrReject(value: unknown): DecisionBundle {
  try {
    return parseDecisionBundle(value);
  } catch (error) {
    if (error instanceof SchemaError) {
      if (
        error.path === "bundleHash" ||
        error.reason.includes("bundleHash") ||
        error.message.includes("bundleHash")
      ) {
        throw new BundleRejection(
          "bundle-hash-mismatch",
          "decision bundle hash does not match canonical digest",
        );
      }
      throw new BundleRejection(
        "invalid-bundle",
        error.code === "unsupported-version"
          ? `unsupported decision bundle schema: ${error.reason}`
          : "decision bundle failed schema validation",
      );
    }
    throw new BundleRejection("invalid-bundle", "decision bundle is not a valid object");
  }
}

function verifyBundleHash(
  bundle: DecisionBundle,
  trust: ImportTrust,
): void {
  const computedHash = computeBundleHash(bundle);
  if (bundle.bundleHash !== computedHash) {
    throw new BundleRejection(
      "bundle-hash-mismatch",
      "bundle bundleHash does not match canonical digest",
    );
  }
  if (trust.confirmedBundleHash !== bundle.bundleHash) {
    throw new BundleRejection(
      "bundle-hash-mismatch",
      "confirmed bundle hash does not match bundle digest",
    );
  }
}

function verifyProjectIdentity(
  bundle: DecisionBundle,
  localConfig: ProjectConfig,
): void {
  if (bundle.projectIdentity !== localConfig.projectIdentity) {
    throw new BundleRejection(
      "project-identity-mismatch",
      "bundle project identity does not match local project identity",
    );
  }
}

function verifyConfigHash(bundle: DecisionBundle, localConfig: ProjectConfig): void {
  if (bundle.configHash !== localConfig.configHash) {
    throw new BundleRejection(
      "config-hash-mismatch",
      "bundle config hash does not match local resolved config hash",
    );
  }
}

function verifyReport(bundle: DecisionBundle, trust: ImportTrust): void {
  if (!reportsMatch(bundle.report, trust.retainedReport)) {
    throw new BundleRejection(
      "report-mismatch",
      "bundle report id, revision, or finding-set hash does not match retained report",
    );
  }
}

function verifyExpiry(bundle: DecisionBundle, now: string): void {
  if (now >= bundle.expiresAt) {
    throw new BundleRejection("expired", "decision bundle has expired");
  }
}

async function verifyUnusedNonce(
  root: string,
  bundle: DecisionBundle,
): Promise<void> {
  const path = receiptPath(
    root,
    bundle.projectIdentity,
    bundle.nonce,
    bundle.bundleHash,
  );
  if (await receiptExists(path)) {
    throw new BundleRejection(
      "nonce-replay",
      "bundle nonce was already consumed for this project identity and bundle hash",
    );
  }
}

function importedDecisionId(
  bundleHash: string,
  decision: BundleDecision,
  index: number,
): string {
  return `import-${sha256Canonical({
    bundleHash,
    index,
    findingId: decision.findingId,
    evidenceFingerprint: decision.evidenceFingerprint,
  }).slice(0, 16)}`;
}

function bundleDecisionToReviewDecision(
  bundle: DecisionBundle,
  decision: BundleDecision,
  index: number,
  trust: ImportTrust,
): ReviewDecision {
  return normalizeReviewDecision({
    schemaVersion: REVIEW_DECISION_VERSION,
    id: importedDecisionId(bundle.bundleHash, decision, index),
    findingId: decision.findingId,
    evidenceFingerprint: decision.evidenceFingerprint,
    verdict: decision.verdict,
    scope: decision.scope,
    reviewer: trust.reviewer,
    reviewedAt: trust.now,
    reportId: bundle.report.id,
    supersedes: [],
    ...(decision.reason === undefined ? {} : { reason: decision.reason }),
  });
}

async function recordBundleReceipt(
  root: string,
  bundle: DecisionBundle,
  trust: ImportTrust,
): Promise<string> {
  const path = receiptPath(
    root,
    bundle.projectIdentity,
    bundle.nonce,
    bundle.bundleHash,
  );

  if (await receiptExists(path)) {
    throw new BundleRejection(
      "nonce-replay",
      "bundle nonce was already consumed for this project identity and bundle hash",
    );
  }

  const receipt: BundleReceipt = {
    schemaVersion: BUNDLE_RECEIPT_VERSION,
    projectIdentity: bundle.projectIdentity,
    nonce: bundle.nonce,
    bundleHash: bundle.bundleHash,
    reviewer: trust.reviewer,
    consumedAt: trust.now,
  };

  await atomicWriteRecord(path, receipt, bundleReceiptCodec);
  return path;
}

async function persistImportedDecisions(
  root: string,
  staged: ReviewDecision[],
): Promise<ReviewDecision[]> {
  const currentGeneration = await readStoreGeneration(root);
  const store = await loadDecisionStore(root);
  const { normalized, deactivatedIds } = validateStagedDecisions(staged, store);
  const currentIndex = await readDecisionIndex(root);

  await compareAndSwapGeneration(
    root,
    currentGeneration,
    currentGeneration + 1,
    staged,
  );

  await persistFinalizedDecisions(root, normalized, deactivatedIds, currentIndex);
  return normalized;
}

export async function importBundle(
  root: string,
  bundleInput: unknown,
  trust: ImportTrust,
): Promise<ImportResult> {
  const bundle = parseBundleOrReject(bundleInput);
  verifyBundleHash(bundle, trust);

  const localConfig = await readProjectConfig(root);
  verifyProjectIdentity(bundle, localConfig);
  verifyConfigHash(bundle, localConfig);
  verifyReport(bundle, trust);
  verifyExpiry(bundle, trust.now);
  await verifyUnusedNonce(root, bundle);

  const staged = bundle.decisions.map((decision, index) =>
    bundleDecisionToReviewDecision(bundle, decision, index, trust),
  );

  return withStoreLock(root, async () => {
    await verifyUnusedNonce(root, bundle);
    const receiptPathWritten = await recordBundleReceipt(root, bundle, trust);
    const finalized = await persistImportedDecisions(root, staged);

    return {
      bundleHash: bundle.bundleHash,
      reviewer: trust.reviewer,
      importedDecisionIds: finalized.map((decision) => decision.id),
      receiptPath: receiptPathWritten,
    };
  });
}
