import { readFile } from "node:fs/promises";
import { join } from "node:path";

import { decisionScopeSchema } from "../../../schema/src/decisions/scope.js";
import type { ReportData } from "../build/types.js";
import {
  canUndoFinding,
  clearStagedDecision,
  loadReviewDraft,
  notesForFinding,
  ReviewDecisionError,
  stageDecision,
  stagedDecisionForFinding,
  undoStagedDecision,
  updateFindingNotes,
  updateFindingScope,
  type ReviewDraft,
  type ReviewVerdict,
} from "../review/index.js";

export type ReviewAction =
  | "decide"
  | "batch-decide"
  | "clear-decision"
  | "undo"
  | "update-notes"
  | "update-scope";

export type ReviewRequestBody = {
  action?: unknown;
  targetIds?: unknown;
  verdict?: unknown;
  reason?: unknown;
  notes?: unknown;
  scope?: unknown;
  expectedRevision?: unknown;
};

export class ReviewRevisionConflictError extends Error {
  readonly code = "revision-conflict" as const;
  constructor(readonly draft: ReviewDraft, readonly changedDecisions: ReviewDraft["decisions"]) {
    super("review decisions changed; merge displayed decisions and retry");
    this.name = "ReviewRevisionConflictError";
  }
}

function parseExpectedRevision(value: unknown): number | undefined {
  if (value === undefined) return undefined;
  if (!Number.isInteger(value) || (value as number) < 0) {
    throw new InvalidReviewRequestError("invalid-revision", "review requests require a non-negative expectedRevision");
  }
  return value as number;
}

export class NonAdjudicableTargetError extends Error {
  readonly code = "non-adjudicable-target" as const;
  readonly rejectedIds: string[];

  constructor(rejectedIds: string[]) {
    super(
      `review cannot adjudicate coverage or harness outcome IDs: ${rejectedIds.join(", ")}`,
    );
    this.name = "NonAdjudicableTargetError";
    this.rejectedIds = rejectedIds;
  }
}

export class InvalidReviewRequestError extends Error {
  readonly code: string;

  constructor(code: string, message: string) {
    super(message);
    this.name = "InvalidReviewRequestError";
    this.code = code;
  }
}

async function loadReportData(reportDir: string): Promise<ReportData> {
  const reportPath = join(reportDir, "data/report.json");
  let raw: string;
  try {
    raw = await readFile(reportPath, "utf8");
  } catch {
    throw new InvalidReviewRequestError(
      "missing-report-data",
      "report data is unavailable for review mutations",
    );
  }

  let parsed: unknown;
  try {
    parsed = JSON.parse(raw) as unknown;
  } catch {
    throw new InvalidReviewRequestError("invalid-report-data", "report data is not valid JSON");
  }

  if (
    typeof parsed !== "object" ||
    parsed === null ||
    !("findings" in parsed) ||
    !("coverageOutcomes" in parsed) ||
    !("harnessOutcomes" in parsed)
  ) {
    throw new InvalidReviewRequestError(
      "invalid-report-data",
      "report data is missing required outcome collections",
    );
  }

  return parsed as ReportData;
}

function parseTargetIds(body: ReviewRequestBody): string[] {
  if (!Array.isArray(body.targetIds) || body.targetIds.length === 0) {
    throw new InvalidReviewRequestError(
      "invalid-target-ids",
      "review requests require a non-empty targetIds array",
    );
  }

  const targetIds: string[] = [];
  for (const targetId of body.targetIds) {
    if (typeof targetId !== "string" || targetId.trim().length === 0) {
      throw new InvalidReviewRequestError(
        "invalid-target-ids",
        "every target ID must be a non-empty string",
      );
    }
    targetIds.push(targetId);
  }
  return targetIds;
}

function parseVerdict(value: unknown): ReviewVerdict {
  if (value !== "confirmed_defect" && value !== "not_defect") {
    throw new InvalidReviewRequestError(
      "invalid-verdict",
      "review verdict must be confirmed_defect or not_defect",
    );
  }
  return value;
}

function parseAction(body: ReviewRequestBody): ReviewAction {
  if (
    body.action !== "decide" &&
    body.action !== "batch-decide" &&
    body.action !== "clear-decision" &&
    body.action !== "undo" &&
    body.action !== "update-notes" &&
    body.action !== "update-scope"
  ) {
    throw new InvalidReviewRequestError(
      "invalid-action",
      "review action must be decide, batch-decide, clear-decision, undo, update-notes, or update-scope",
    );
  }
  return body.action;
}

function nonAdjudicableIds(reportData: ReportData, targetIds: string[]): string[] {
  const coverageIds = new Set(reportData.coverageOutcomes.map((outcome) => outcome.id));
  const harnessIds = new Set(reportData.harnessOutcomes.map((outcome) => outcome.id));
  return targetIds.filter((id) => coverageIds.has(id) || harnessIds.has(id));
}

function assertAdjudicableFindings(reportData: ReportData, targetIds: string[]): void {
  const rejected = nonAdjudicableIds(reportData, targetIds);
  if (rejected.length > 0) {
    throw new NonAdjudicableTargetError(rejected);
  }

  const findingIds = new Set(reportData.findings.map((finding) => finding.id));
  const unknownTargets = targetIds.filter((id) => !findingIds.has(id));
  if (unknownTargets.length > 0) {
    throw new InvalidReviewRequestError(
      "unknown-target",
      `review target IDs are not product findings: ${unknownTargets.join(", ")}`,
    );
  }
}

export type ReviewMutationResult = {
  status: "accepted";
  acceptedCount: number;
  draft: ReviewDraft;
};

let pendingReviewMutation: Promise<void> = Promise.resolve();

export async function serializeReviewMutation<T>(mutation: () => Promise<T>): Promise<T> {
  const prior = pendingReviewMutation;
  let release: (() => void) | undefined;
  pendingReviewMutation = new Promise<void>((resolve) => {
    release = resolve;
  });
  await prior;
  try {
    return await mutation();
  } finally {
    release?.();
  }
}

export async function readReviewDraft(reportDir: string): Promise<ReviewDraft> {
  return loadReviewDraft(reportDir);
}

export async function handleReviewMutation(
  reportDir: string,
  body: ReviewRequestBody,
): Promise<ReviewMutationResult> {
  const action = parseAction(body);
  const expectedRevision = parseExpectedRevision(body.expectedRevision);
  const targetIds = parseTargetIds(body);
  const reportData = await loadReportData(reportDir);
  assertAdjudicableFindings(reportData, targetIds);

  const decidedAt = new Date().toISOString();
  let draft = await loadReviewDraft(reportDir);
  if (expectedRevision !== undefined && draft.revision !== expectedRevision) {
    throw new ReviewRevisionConflictError(draft, draft.decisions);
  }

  if (action === "decide" || action === "batch-decide") {
    const verdict = parseVerdict(body.verdict);
    let reason: string | undefined;
    if (body.reason !== undefined) {
      if (typeof body.reason !== "string") {
        throw new InvalidReviewRequestError("invalid-reason", "review reason must be a string");
      }
      reason = body.reason;
    }

    let scope;
    if (body.scope !== undefined) {
      const parsedScope = decisionScopeSchema.safeParse(body.scope);
      if (!parsedScope.success) {
        throw new InvalidReviewRequestError("invalid-scope", "review scope is invalid");
      }
      scope = parsedScope.data;
    }

    for (const findingId of targetIds) {
      draft = await stageDecision(reportDir, reportData, {
        findingId,
        verdict,
        ...(reason === undefined ? {} : { reason }),
        ...(scope === undefined ? {} : { scope }),
        decidedAt,
      });
    }
  } else if (action === "clear-decision") {
    for (const findingId of targetIds) {
      draft = await clearStagedDecision(reportDir, findingId);
    }
  } else if (action === "undo") {
    if (targetIds.length !== 1) {
      throw new InvalidReviewRequestError(
        "invalid-target-ids",
        "undo requires exactly one target finding ID",
      );
    }
    const findingId = targetIds[0];
    if (findingId === undefined) {
      throw new InvalidReviewRequestError("invalid-target-ids", "undo requires a finding ID");
    }
    if (!canUndoFinding(draft, findingId)) {
      throw new InvalidReviewRequestError("nothing-to-undo", "finding has no undo history");
    }
    draft = await undoStagedDecision(reportDir, findingId);
  } else if (action === "update-notes") {
    if (targetIds.length !== 1) {
      throw new InvalidReviewRequestError(
        "invalid-target-ids",
        "update-notes requires exactly one target finding ID",
      );
    }
    if (typeof body.notes !== "string") {
      throw new InvalidReviewRequestError("invalid-notes", "update-notes requires a string notes field");
    }
    const findingId = targetIds[0];
    if (findingId === undefined) {
      throw new InvalidReviewRequestError("invalid-target-ids", "update-notes requires a finding ID");
    }
    draft = await updateFindingNotes(reportDir, findingId, body.notes);
  } else {
    if (targetIds.length !== 1) {
      throw new InvalidReviewRequestError(
        "invalid-target-ids",
        "update-scope requires exactly one target finding ID",
      );
    }
    const parsedScope = decisionScopeSchema.safeParse(body.scope);
    if (!parsedScope.success) {
      throw new InvalidReviewRequestError("invalid-scope", "review scope is invalid");
    }
    const findingId = targetIds[0];
    if (findingId === undefined) {
      throw new InvalidReviewRequestError("invalid-target-ids", "update-scope requires a finding ID");
    }
    let reason: string | undefined;
    if (body.reason !== undefined) {
      if (typeof body.reason !== "string") {
        throw new InvalidReviewRequestError("invalid-reason", "review reason must be a string");
      }
      reason = body.reason;
    }
    draft = await updateFindingScope(reportDir, reportData, {
      findingId,
      scope: parsedScope.data,
      ...(reason === undefined ? {} : { reason }),
    });
  }

  return {
    status: "accepted",
    acceptedCount: targetIds.length,
    draft,
  };
}

export async function isNonAdjudicableTarget(
  reportDir: string,
  targetId: string,
): Promise<boolean> {
  const reportData = await loadReportData(reportDir);
  return nonAdjudicableIds(reportData, [targetId]).length > 0;
}

export {
  canUndoFinding,
  notesForFinding,
  stagedDecisionForFinding,
  ReviewDecisionError,
};
