import { createHash } from "node:crypto";
import {
  access,
  cp,
  mkdir,
  mkdtemp,
  readFile,
  rm,
  writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, isAbsolute, join, normalize } from "node:path";
import { fileURLToPath } from "node:url";

import {
  redactArtifacts,
  validateRedactionRules,
} from "../../../playwright/src/redaction.js";
import { canonicalize, sha256Canonical } from "../../../schema/src/canonical.js";
import { coverageOutcomeSchema } from "../../../schema/src/records/coverage.js";
import type { ExecutionContext } from "../../../schema/src/records/context.js";
import { findingSchema } from "../../../schema/src/records/finding.js";
import { matrixCellSchema } from "../../../schema/src/records/context.js";
import { harnessOutcomeSchema } from "../../../schema/src/records/harness.js";
import { reviewDecisionSchema } from "../../../schema/src/decisions/decision.js";

import { toAcceptedFindingRecord, toReopenedFindingRecord } from "../review/accepted.js";
import {
  sortCoverageOutcomes,
  sortFindings,
  sortHarnessOutcomes,
} from "./sort.js";
import {
  REPORT_DATA_VERSION,
  REPORT_MANIFEST_VERSION,
  type BuildReportDirOptions,
  type ReportArtifactInput,
  type ReportData,
  type ReportManifest,
  type ReportRunRecords,
  type StaticReviewInput,
} from "./types.js";

const packageRoot = join(fileURLToPath(new URL(".", import.meta.url)), "../..");
const clientSourceDir = join(packageRoot, "dist/client");

export class ReportBuildError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "ReportBuildError";
  }
}

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

function assertRelativeArtifactPath(artifactPath: string): void {
  const trimmed = artifactPath.trim();
  if (trimmed.length === 0) {
    throw new ReportBuildError("artifact relativePath must be a non-empty string");
  }
  if (isAbsolute(trimmed)) {
    throw new ReportBuildError(`artifact relativePath must be relative: ${artifactPath}`);
  }
  const normalized = normalize(trimmed);
  if (normalized === ".." || normalized.startsWith("../")) {
    throw new ReportBuildError(`artifact relativePath must not traverse: ${artifactPath}`);
  }
}

function assertIndexedBrowserCell(
  recordKind: string,
  recordId: string,
  context: ExecutionContext,
  cellIds: Set<string>,
): void {
  if (context.kind === "browser" && !cellIds.has(context.cell.id)) {
    throw new ReportBuildError(
      `${recordKind} ${recordId} references unknown cell id: ${context.cell.id}`,
    );
  }
}

function validateRunRecords(runRecords: ReportRunRecords): void {
  for (const finding of runRecords.findings) {
    findingSchema.parse(finding);
  }
  for (const accepted of runRecords.acceptedFindings ?? []) {
    findingSchema.parse(accepted.finding);
    reviewDecisionSchema.parse(accepted.decision);
  }
  for (const decision of runRecords.priorDecisions ?? []) {
    reviewDecisionSchema.parse(decision);
  }
  for (const outcome of runRecords.coverageOutcomes) {
    coverageOutcomeSchema.parse(outcome);
  }
  for (const outcome of runRecords.harnessOutcomes) {
    harnessOutcomeSchema.parse(outcome);
  }
  const cellIds = new Set<string>();
  for (const cell of runRecords.cells ?? []) {
    matrixCellSchema.parse(cell);
    if (cellIds.has(cell.id)) throw new ReportBuildError(`duplicate report cell id: ${cell.id}`);
    cellIds.add(cell.id);
  }
  for (const finding of runRecords.findings) {
    assertIndexedBrowserCell("browser finding", finding.id, finding.context, cellIds);
  }
  for (const accepted of runRecords.acceptedFindings ?? []) {
    assertIndexedBrowserCell("accepted browser finding", accepted.finding.id, accepted.finding.context, cellIds);
  }
  for (const outcome of runRecords.coverageOutcomes) {
    assertIndexedBrowserCell("coverage outcome", outcome.id, outcome.context, cellIds);
  }
}

function validateArtifacts(artifacts: ReportArtifactInput[]): void {
  const seen = new Set<string>();
  for (const artifact of artifacts) {
    assertRelativeArtifactPath(artifact.relativePath);
    assertNonEmptyString(artifact.sourcePath, "artifact sourcePath");
    if (seen.has(artifact.relativePath)) {
      throw new ReportBuildError(`duplicate artifact relativePath: ${artifact.relativePath}`);
    }
    seen.add(artifact.relativePath);
  }
}

function sortArtifacts(artifacts: ReportArtifactInput[]): ReportArtifactInput[] {
  return [...artifacts].sort((left, right) =>
    left.relativePath.localeCompare(right.relativePath, "en", { sensitivity: "variant" }),
  );
}

function buildReportData(runRecords: ReportRunRecords): ReportData {
  return {
    schemaVersion: REPORT_DATA_VERSION,
    findings: sortFindings(runRecords.findings).map((finding) =>
      toReopenedFindingRecord(finding, runRecords.priorDecisions ?? []),
    ),
    acceptedFindings: (runRecords.acceptedFindings ?? [])
      .map(toAcceptedFindingRecord)
      .sort((left, right) => left.decision.id.localeCompare(right.decision.id)),
    coverageOutcomes: sortCoverageOutcomes(runRecords.coverageOutcomes),
    harnessOutcomes: sortHarnessOutcomes(runRecords.harnessOutcomes),
    cellIndex: (runRecords.cells ?? []).map((cell) => ({
      id: cell.id,
      route: cell.route ?? "unknown",
      url: cell.url,
      role: cell.role,
      locale: cell.locale,
      direction: cell.state.direction ?? cell.state.dir ?? "unknown",
      viewport: cell.viewport,
      engine: cell.state.engine ?? "unknown",
    })).sort((left, right) => left.id < right.id ? -1 : left.id > right.id ? 1 : 0),
  };
}

function validateStaticReview(input: StaticReviewInput): void {
  assertNonEmptyString(input.projectIdentity, "staticReview projectIdentity");
  assertNonEmptyString(input.configHash, "staticReview configHash");
  assertNonEmptyString(input.report.id, "staticReview report id");
  assertNonEmptyString(input.report.revision, "staticReview report revision");
}

function withStaticReview(reportData: ReportData, input: StaticReviewInput | undefined): ReportData {
  if (input === undefined) return reportData;
  validateStaticReview(input);
  return {
    ...reportData,
    staticReview: {
      ...input,
      report: {
        ...input.report,
        findingSetHash: sha256Canonical(
          reportData.findings.map((finding) => ({
            id: finding.id,
            evidenceFingerprint: finding.evidenceFingerprint,
          })),
        ),
      },
    },
  };
}

async function assertClientBundleExists(): Promise<void> {
  try {
    await access(join(clientSourceDir, "index.html"));
    await access(join(clientSourceDir, "app.js"));
    await access(join(clientSourceDir, "styles.css"));
  } catch {
    throw new ReportBuildError(
      "report client bundle is missing; run `pnpm --filter @invariantum/report build` first",
    );
  }
}

async function copyClientBundle(targetDir: string): Promise<void> {
  await cp(join(clientSourceDir, "index.html"), join(targetDir, "index.html"));
  await cp(join(clientSourceDir, "app.js"), join(targetDir, "app.js"));
  await cp(join(clientSourceDir, "styles.css"), join(targetDir, "styles.css"));
}

async function copyArtifacts(
  stagingDir: string,
  artifacts: ReportArtifactInput[],
): Promise<void> {
  for (const artifact of artifacts) {
    try {
      await access(artifact.sourcePath);
    } catch {
      throw new ReportBuildError(`artifact source does not exist: ${artifact.sourcePath}`);
    }

    const destinationPath = join(stagingDir, artifact.relativePath);
    await mkdir(dirname(destinationPath), { recursive: true });
    await cp(artifact.sourcePath, destinationPath);
  }
}

async function replaceDirectoryContents(sourceDir: string, targetDir: string): Promise<void> {
  await mkdir(targetDir, { recursive: true });
  await rm(targetDir, { recursive: true, force: true });
  await cp(sourceDir, targetDir, { recursive: true });
}

export async function buildReportDir(
  options: BuildReportDirOptions,
): Promise<ReportManifest> {
  assertNonEmptyString(options.outDir, "outDir");
  validateRedactionRules(options.redaction);
  validateRunRecords(options.runRecords);
  validateArtifacts(options.artifacts);
  await assertClientBundleExists();

  const reportData = withStaticReview(buildReportData(options.runRecords), options.staticReview);
  const sortedArtifacts = sortArtifacts(options.artifacts);
  const stagingDir = await mkdtemp(join(tmpdir(), "invariantum-report-staging-"));

  try {
    const reportJsonPath = join(stagingDir, "data/report.json");
    await mkdir(dirname(reportJsonPath), { recursive: true });
    await writeFile(reportJsonPath, canonicalize(reportData), "utf8");

    await copyArtifacts(stagingDir, sortedArtifacts);
    await copyClientBundle(stagingDir);

    const redactionReport = await redactArtifacts(stagingDir, options.redaction);
    await replaceDirectoryContents(stagingDir, options.outDir);

    const finalReportBytes = await readFile(join(options.outDir, "data/report.json"));
    const contentHash = createHash("sha256").update(finalReportBytes).digest("hex");

    return {
      schemaVersion: REPORT_MANIFEST_VERSION,
      outDir: options.outDir,
      dataPath: "data/report.json",
      contentHash,
      redaction: redactionReport,
    };
  } finally {
    await rm(stagingDir, { recursive: true, force: true });
  }
}
