#!/usr/bin/env -S npx tsx

import { createHash } from "node:crypto";
import { readdir, readFile, stat } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";

interface ManifestFile {
  path: string;
  dataset: string;
  page: number;
  rowCount: number;
  bytes: number;
  sha256: string;
}

interface DatasetManifest {
  sourceCountBefore: number;
  sourceCountAfter: number;
  rowCount: number;
  pages: number;
}

interface ExportManifest {
  format: string;
  formatVersion: number;
  export: {
    binaryObjectsDownloaded: boolean;
    rowCount: number;
    fileCount: number;
  };
  datasets: Record<string, DatasetManifest>;
  files: ManifestFile[];
}

const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const backendDirectory = path.resolve(scriptDirectory, "..");
const defaultOutputRoot = path.join(backendDirectory, "data", "migration");

function printHelp(): void {
  console.log(`Usage: npx tsx backend/scripts/verify-export.ts [export-directory]

With no directory, verifies the newest timestamped export in backend/data/migration.`);
}

function sha256(contents: Uint8Array): string {
  return createHash("sha256").update(contents).digest("hex");
}

function asNonNegativeInteger(value: unknown, label: string): number {
  if (!Number.isSafeInteger(value) || (value as number) < 0) {
    throw new Error(`${label} must be a non-negative integer`);
  }
  return value as number;
}

function asString(value: unknown, label: string): string {
  if (typeof value !== "string" || !value) throw new Error(`${label} must be a non-empty string`);
  return value;
}

function parseManifest(payload: unknown): ExportManifest {
  if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
    throw new Error("manifest.json must contain an object");
  }
  const value = payload as Record<string, unknown>;
  if (value.format !== "tonline-supabase-source-export" || value.formatVersion !== 1) {
    throw new Error("Unsupported export manifest format");
  }
  if (!value.export || typeof value.export !== "object" || Array.isArray(value.export)) {
    throw new Error("Manifest has no export summary");
  }
  if (!value.datasets || typeof value.datasets !== "object" || Array.isArray(value.datasets)) {
    throw new Error("Manifest has no datasets");
  }
  if (!Array.isArray(value.files)) throw new Error("Manifest has no file list");

  const exportSummary = value.export as Record<string, unknown>;
  asNonNegativeInteger(exportSummary.rowCount, "export.rowCount");
  asNonNegativeInteger(exportSummary.fileCount, "export.fileCount");
  if (typeof exportSummary.binaryObjectsDownloaded !== "boolean") {
    throw new Error("export.binaryObjectsDownloaded must be boolean");
  }

  for (const [name, rawDataset] of Object.entries(value.datasets)) {
    if (!rawDataset || typeof rawDataset !== "object" || Array.isArray(rawDataset)) {
      throw new Error(`Invalid dataset manifest: ${name}`);
    }
    const dataset = rawDataset as Record<string, unknown>;
    asNonNegativeInteger(dataset.sourceCountBefore, `${name}.sourceCountBefore`);
    asNonNegativeInteger(dataset.sourceCountAfter, `${name}.sourceCountAfter`);
    asNonNegativeInteger(dataset.rowCount, `${name}.rowCount`);
    asNonNegativeInteger(dataset.pages, `${name}.pages`);
  }

  for (const [index, rawFile] of value.files.entries()) {
    if (!rawFile || typeof rawFile !== "object" || Array.isArray(rawFile)) {
      throw new Error(`Invalid file entry at index ${index}`);
    }
    const file = rawFile as Record<string, unknown>;
    asString(file.path, `files[${index}].path`);
    asString(file.dataset, `files[${index}].dataset`);
    asNonNegativeInteger(file.page, `files[${index}].page`);
    asNonNegativeInteger(file.rowCount, `files[${index}].rowCount`);
    asNonNegativeInteger(file.bytes, `files[${index}].bytes`);
    if (typeof file.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(file.sha256)) {
      throw new Error(`Invalid SHA-256 at files[${index}]`);
    }
  }

  return value as unknown as ExportManifest;
}

function resolveArtifact(exportDirectory: string, relativePath: string): string {
  if (path.isAbsolute(relativePath) || relativePath.includes("\0")) {
    throw new Error(`Unsafe manifest path: ${relativePath}`);
  }
  const resolvedRoot = path.resolve(exportDirectory);
  const resolved = path.resolve(resolvedRoot, ...relativePath.split("/"));
  const rootPrefix = `${resolvedRoot}${path.sep}`;
  if (!resolved.startsWith(rootPrefix)) throw new Error(`Manifest path escapes export directory: ${relativePath}`);
  return resolved;
}

async function newestExportDirectory(): Promise<string> {
  let entries;
  try {
    entries = await readdir(defaultOutputRoot, { withFileTypes: true });
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code === "ENOENT") {
      throw new Error(`No migration exports found in ${defaultOutputRoot}`);
    }
    throw error;
  }

  const candidates: Array<{ directory: string; modified: number }> = [];
  for (const entry of entries) {
    if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
    const directory = path.join(defaultOutputRoot, entry.name);
    try {
      const manifestStats = await stat(path.join(directory, "manifest.json"));
      candidates.push({ directory, modified: manifestStats.mtimeMs });
    } catch {
      // Incomplete exports have no usable manifest and are intentionally skipped.
    }
  }

  candidates.sort((left, right) => right.modified - left.modified);
  const newest = candidates[0];
  if (!newest) throw new Error(`No complete migration exports found in ${defaultOutputRoot}`);
  return newest.directory;
}

async function listFiles(directory: string, prefix = ""): Promise<string[]> {
  const found: string[] = [];
  for (const entry of await readdir(directory, { withFileTypes: true })) {
    const relativePath = prefix ? path.posix.join(prefix, entry.name) : entry.name;
    const diskPath = path.join(directory, entry.name);
    if (entry.isDirectory()) {
      found.push(...await listFiles(diskPath, relativePath));
    } else if (entry.isFile()) {
      found.push(relativePath);
    }
  }
  return found.sort();
}

async function main(): Promise<void> {
  const arguments_ = process.argv.slice(2);
  if (arguments_.includes("--help") || arguments_.includes("-h")) {
    printHelp();
    return;
  }
  if (arguments_.length > 1) throw new Error("Expected at most one export directory");

  const exportDirectory = path.resolve(arguments_[0] ?? await newestExportDirectory());
  const manifestPath = path.join(exportDirectory, "manifest.json");
  const checksumPath = path.join(exportDirectory, "manifest.sha256");
  const [manifestContents, checksumContents] = await Promise.all([
    readFile(manifestPath),
    readFile(checksumPath, "ascii"),
  ]);

  const checksumMatch = /^([a-f0-9]{64})\s+manifest\.json\s*$/i.exec(checksumContents);
  if (!checksumMatch) throw new Error("manifest.sha256 has an invalid format");
  const expectedManifestHash = checksumMatch[1];
  if (!expectedManifestHash) throw new Error("manifest.sha256 has no checksum");
  const actualManifestHash = sha256(manifestContents);
  if (actualManifestHash !== expectedManifestHash.toLowerCase()) {
    throw new Error("manifest.json SHA-256 does not match manifest.sha256");
  }

  let parsed: unknown;
  try {
    parsed = JSON.parse(manifestContents.toString("utf8"));
  } catch {
    throw new Error("manifest.json is not valid JSON");
  }
  const manifest = parseManifest(parsed);

  const expectedPaths = new Set<string>(["manifest.json", "manifest.sha256"]);
  const seenPaths = new Set<string>();
  const aggregates = new Map<string, { rows: number; pages: number[] }>();

  for (const file of manifest.files) {
    if (seenPaths.has(file.path)) throw new Error(`Duplicate manifest path: ${file.path}`);
    seenPaths.add(file.path);
    expectedPaths.add(file.path);

    if (!manifest.datasets[file.dataset]) {
      throw new Error(`File references unknown dataset: ${file.dataset}`);
    }
    const contents = await readFile(resolveArtifact(exportDirectory, file.path));
    if (contents.byteLength !== file.bytes) throw new Error(`Byte count mismatch: ${file.path}`);
    if (sha256(contents) !== file.sha256) throw new Error(`SHA-256 mismatch: ${file.path}`);

    let pagePayload: unknown;
    try {
      pagePayload = JSON.parse(contents.toString("utf8"));
    } catch {
      throw new Error(`Invalid JSON: ${file.path}`);
    }
    if (!pagePayload || typeof pagePayload !== "object" || Array.isArray(pagePayload)) {
      throw new Error(`Invalid page object: ${file.path}`);
    }
    const page = pagePayload as Record<string, unknown>;
    if (page.dataset !== file.dataset || page.page !== file.page || page.rowCount !== file.rowCount) {
      throw new Error(`Page metadata mismatch: ${file.path}`);
    }
    if (!Array.isArray(page.rows) || page.rows.length !== file.rowCount) {
      throw new Error(`Row count mismatch inside: ${file.path}`);
    }

    const aggregate = aggregates.get(file.dataset) ?? { rows: 0, pages: [] };
    aggregate.rows += file.rowCount;
    aggregate.pages.push(file.page);
    aggregates.set(file.dataset, aggregate);
  }

  if (manifest.files.length !== manifest.export.fileCount) {
    throw new Error("Manifest file count does not match export summary");
  }

  let totalRows = 0;
  for (const [name, dataset] of Object.entries(manifest.datasets)) {
    const aggregate = aggregates.get(name) ?? { rows: 0, pages: [] };
    aggregate.pages.sort((left, right) => left - right);
    const expectedPages = Array.from({ length: dataset.pages }, (_value, index) => index + 1);
    if (JSON.stringify(aggregate.pages) !== JSON.stringify(expectedPages)) {
      throw new Error(`Non-contiguous page sequence for ${name}`);
    }
    if (aggregate.rows !== dataset.rowCount) throw new Error(`Dataset row count mismatch: ${name}`);
    if (dataset.sourceCountBefore !== dataset.sourceCountAfter || dataset.rowCount !== dataset.sourceCountAfter) {
      throw new Error(`Unstable source counts recorded for ${name}`);
    }
    totalRows += dataset.rowCount;
  }
  if (totalRows !== manifest.export.rowCount) {
    throw new Error("Dataset totals do not match export row count");
  }

  const actualPaths = await listFiles(exportDirectory);
  const unexpected = actualPaths.filter((file) => !expectedPaths.has(file));
  const missing = [...expectedPaths].filter((file) => !actualPaths.includes(file));
  if (missing.length > 0) throw new Error(`Missing export file: ${missing[0]}`);
  if (unexpected.length > 0) throw new Error(`Unexpected export file: ${unexpected[0]}`);

  console.log(`Export verified: ${exportDirectory}`);
  console.log(`Rows: ${totalRows}; data files: ${manifest.files.length}`);
  console.log(`Manifest SHA-256: ${actualManifestHash}`);
  console.log("All checksums, page sequences, and recorded source counts are consistent.");
}

main().catch((error: unknown) => {
  const message = error instanceof Error ? error.message : "Unknown verification error";
  console.error(`Verification failed: ${message}`);
  process.exitCode = 1;
});
