#!/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";

type Row = Record<string, unknown>;

interface ArchiveFile {
  path: string;
  kind: "table" | "metadata";
  dataset: string;
  page: number;
  rowCount: number;
  bytes: number;
  sha256: string;
}

interface TableManifest {
  dataset: string;
  schema: string;
  table: string;
  columns: string[];
  primaryKey: string[];
  pagination: "primary-key" | "physical-cursor";
  rowCountBefore: number;
  rowCountAfter: number;
  fingerprintBefore: [string, string];
  fingerprintAfter: [string, string];
  exportedRows: number;
  pages: number;
}

interface MetadataManifest {
  name: string;
  rowCount: number;
  path: string;
}

interface Manifest {
  format: string;
  formatVersion: number;
  source: { schemas: string[] };
  archive: {
    tableCount: number;
    nonEmptyTableCount: number;
    tableRowCount: number;
    metadataRowCount: number;
    fileCount: number;
    storageBinariesDownloaded: boolean;
    excludedSchemas: string[];
    excludedRelations: string[];
  };
  catalogSha256: string;
  metadata: MetadataManifest[];
  tables: TableManifest[];
  files: ArchiveFile[];
}

const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const backendDirectory = path.resolve(scriptDirectory, "..");
const defaultOutputRoot = path.join(backendDirectory, "data", "migration", "source-archive");
const requiredMetadata = [
  "tables",
  "columns",
  "constraints",
  "indexes",
  "triggers",
  "policies",
  "functions",
  "extensions",
] as const;

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

With no directory, verifies the newest complete source archive.`);
}

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

function nonNegativeInteger(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 nonEmptyString(value: unknown, label: string): string {
  if (typeof value !== "string" || !value) throw new Error(`${label} must be a non-empty string`);
  return value;
}

function stringArray(value: unknown, label: string): string[] {
  if (!Array.isArray(value) || !value.every((item) => typeof item === "string" && item.length > 0)) {
    throw new Error(`${label} must be an array of strings`);
  }
  return value as string[];
}

function fingerprint(value: unknown, label: string): [string, string] {
  if (!Array.isArray(value) || value.length !== 2 || !value.every((item) => typeof item === "string")) {
    throw new Error(`${label} must contain two fingerprints`);
  }
  return value as [string, string];
}

function parseManifest(payload: unknown): Manifest {
  if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
    throw new Error("manifest.json must contain an object");
  }
  const manifest = payload as Record<string, unknown>;
  if (manifest.format !== "tonline-supabase-source-archive" || manifest.formatVersion !== 1) {
    throw new Error("Unsupported source archive format");
  }
  if (!manifest.source || typeof manifest.source !== "object" || Array.isArray(manifest.source)) {
    throw new Error("Manifest has no source section");
  }
  const source = manifest.source as Record<string, unknown>;
  const schemas = stringArray(source.schemas, "source.schemas");
  if (JSON.stringify(schemas) !== JSON.stringify(["public", "auth", "storage"])) {
    throw new Error("Archive does not cover exactly public, auth, and storage");
  }

  if (!manifest.archive || typeof manifest.archive !== "object" || Array.isArray(manifest.archive)) {
    throw new Error("Manifest has no archive summary");
  }
  const archive = manifest.archive as Record<string, unknown>;
  for (const field of ["tableCount", "nonEmptyTableCount", "tableRowCount", "metadataRowCount", "fileCount"]) {
    nonNegativeInteger(archive[field], `archive.${field}`);
  }
  if (archive.storageBinariesDownloaded !== false) {
    throw new Error("This verifier only accepts metadata-only Storage archives");
  }
  const excludedSchemas = stringArray(archive.excludedSchemas, "archive.excludedSchemas");
  const excludedRelations = stringArray(archive.excludedRelations, "archive.excludedRelations");
  if (!excludedSchemas.includes("vault") || !excludedRelations.includes("vault.decrypted_secrets")) {
    throw new Error("Manifest does not record the required vault exclusions");
  }

  if (typeof manifest.catalogSha256 !== "string" || !/^[a-f0-9]{64}$/.test(manifest.catalogSha256)) {
    throw new Error("Invalid catalog SHA-256");
  }
  if (!Array.isArray(manifest.metadata) || !Array.isArray(manifest.tables) || !Array.isArray(manifest.files)) {
    throw new Error("Manifest lacks metadata, tables, or files");
  }

  for (const [index, raw] of manifest.metadata.entries()) {
    if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`Invalid metadata[${index}]`);
    const item = raw as Record<string, unknown>;
    nonEmptyString(item.name, `metadata[${index}].name`);
    nonEmptyString(item.path, `metadata[${index}].path`);
    nonNegativeInteger(item.rowCount, `metadata[${index}].rowCount`);
  }

  for (const [index, raw] of manifest.tables.entries()) {
    if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`Invalid tables[${index}]`);
    const table = raw as Record<string, unknown>;
    nonEmptyString(table.dataset, `tables[${index}].dataset`);
    nonEmptyString(table.schema, `tables[${index}].schema`);
    nonEmptyString(table.table, `tables[${index}].table`);
    stringArray(table.columns, `tables[${index}].columns`);
    stringArray(table.primaryKey, `tables[${index}].primaryKey`);
    if (table.pagination !== "primary-key" && table.pagination !== "physical-cursor") {
      throw new Error(`Invalid pagination at tables[${index}]`);
    }
    for (const field of ["rowCountBefore", "rowCountAfter", "exportedRows", "pages"]) {
      nonNegativeInteger(table[field], `tables[${index}].${field}`);
    }
    fingerprint(table.fingerprintBefore, `tables[${index}].fingerprintBefore`);
    fingerprint(table.fingerprintAfter, `tables[${index}].fingerprintAfter`);
  }

  for (const [index, raw] of manifest.files.entries()) {
    if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`Invalid files[${index}]`);
    const file = raw as Record<string, unknown>;
    nonEmptyString(file.path, `files[${index}].path`);
    nonEmptyString(file.dataset, `files[${index}].dataset`);
    if (file.kind !== "table" && file.kind !== "metadata") throw new Error(`Invalid files[${index}].kind`);
    for (const field of ["page", "rowCount", "bytes"]) {
      nonNegativeInteger(file[field], `files[${index}].${field}`);
    }
    if (typeof file.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(file.sha256)) {
      throw new Error(`Invalid SHA-256 at files[${index}]`);
    }
  }

  return manifest as unknown as Manifest;
}

function resolveArtifact(archiveDirectory: string, relativePath: string): string {
  if (path.isAbsolute(relativePath) || relativePath.includes("\0")) {
    throw new Error(`Unsafe archive path: ${relativePath}`);
  }
  const root = path.resolve(archiveDirectory);
  const resolved = path.resolve(root, ...relativePath.split("/"));
  if (!resolved.startsWith(`${root}${path.sep}`)) throw new Error(`Archive path escapes root: ${relativePath}`);
  return resolved;
}

async function newestArchive(): Promise<string> {
  let entries;
  try {
    entries = await readdir(defaultOutputRoot, { withFileTypes: true });
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code === "ENOENT") {
      throw new Error(`No source archives 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 details = await stat(path.join(directory, "manifest.json"));
      candidates.push({ directory, modified: details.mtimeMs });
    } catch {
      // A directory without a manifest is a deliberately refused/incomplete archive.
    }
  }
  candidates.sort((left, right) => right.modified - left.modified);
  const newest = candidates[0];
  if (!newest) throw new Error(`No complete source archives found in ${defaultOutputRoot}`);
  return newest.directory;
}

async function listFiles(directory: string, prefix = ""): Promise<string[]> {
  const files: 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()) files.push(...await listFiles(diskPath, relativePath));
    else if (entry.isFile()) files.push(relativePath);
  }
  return files.sort();
}

function sameStrings(left: string[], right: string[]): boolean {
  return left.length === right.length && left.every((value, index) => value === right[index]);
}

function tableIdentity(schema: string, table: string): string {
  return JSON.stringify([schema, table]);
}

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 archive directory");
  const archiveDirectory = path.resolve(arguments_[0] ?? await newestArchive());

  const [manifestContents, checksumContents] = await Promise.all([
    readFile(path.join(archiveDirectory, "manifest.json")),
    readFile(path.join(archiveDirectory, "manifest.sha256"), "ascii"),
  ]);
  const checksumMatch = /^([a-f0-9]{64})\s+manifest\.json\s*$/i.exec(checksumContents);
  const expectedManifestHash = checksumMatch?.[1]?.toLowerCase();
  if (!expectedManifestHash) throw new Error("manifest.sha256 has an invalid format");
  const actualManifestHash = hash(manifestContents);
  if (actualManifestHash !== expectedManifestHash) throw new Error("Manifest SHA-256 mismatch");

  let rawManifest: unknown;
  try {
    rawManifest = JSON.parse(manifestContents.toString("utf8"));
  } catch {
    throw new Error("manifest.json is not valid JSON");
  }
  const manifest = parseManifest(rawManifest);
  const expectedPaths = new Set<string>(["manifest.json", "manifest.sha256"]);
  const tableByDataset = new Map<string, TableManifest>();
  const tableIdentities = new Set<string>();
  for (const table of manifest.tables) {
    if (tableByDataset.has(table.dataset)) throw new Error(`Duplicate table dataset: ${table.dataset}`);
    const identity = tableIdentity(table.schema, table.table);
    if (tableIdentities.has(identity)) throw new Error(`Duplicate table: ${table.schema}.${table.table}`);
    tableIdentities.add(identity);
    tableByDataset.set(table.dataset, table);
  }

  const metadataByName = new Map<string, MetadataManifest>();
  for (const item of manifest.metadata) {
    if (metadataByName.has(item.name)) throw new Error(`Duplicate metadata set: ${item.name}`);
    metadataByName.set(item.name, item);
  }
  for (const name of requiredMetadata) {
    if (!metadataByName.has(name)) throw new Error(`Required metadata set is missing: ${name}`);
  }

  const seenPaths = new Set<string>();
  const tableAggregates = new Map<string, { rows: number; pages: number[]; primaryKeys: Set<string> }>();
  const metadataRows = new Map<string, Row[]>();

  for (const file of manifest.files) {
    if (seenPaths.has(file.path)) throw new Error(`Duplicate file path: ${file.path}`);
    seenPaths.add(file.path);
    expectedPaths.add(file.path);
    const contents = await readFile(resolveArtifact(archiveDirectory, file.path));
    if (contents.byteLength !== file.bytes) throw new Error(`Byte count mismatch: ${file.path}`);
    if (hash(contents) !== file.sha256) throw new Error(`SHA-256 mismatch: ${file.path}`);

    let rawPayload: unknown;
    try {
      rawPayload = JSON.parse(contents.toString("utf8"));
    } catch {
      throw new Error(`Invalid JSON: ${file.path}`);
    }
    if (!rawPayload || typeof rawPayload !== "object" || Array.isArray(rawPayload)) {
      throw new Error(`Invalid artifact object: ${file.path}`);
    }
    const payload = rawPayload as Record<string, unknown>;
    if (payload.dataset !== file.dataset || payload.page !== file.page || payload.rowCount !== file.rowCount) {
      if (file.kind === "metadata" && payload.page === undefined && file.page === 1 &&
        payload.rowCount === file.rowCount) {
        // Metadata has a name rather than a dataset/page pair in its payload.
      } else {
        throw new Error(`Artifact metadata mismatch: ${file.path}`);
      }
    }
    if (!Array.isArray(payload.rows) || payload.rows.length !== file.rowCount) {
      throw new Error(`Artifact row count mismatch: ${file.path}`);
    }

    if (file.kind === "metadata") {
      if (payload.format !== "tonline-source-archive-metadata" || payload.formatVersion !== 1) {
        throw new Error(`Invalid metadata artifact format: ${file.path}`);
      }
      const name = nonEmptyString(payload.name, `metadata name in ${file.path}`);
      const declared = metadataByName.get(name);
      if (!declared || declared.path !== file.path || declared.rowCount !== file.rowCount) {
        throw new Error(`Metadata manifest mismatch: ${file.path}`);
      }
      if (metadataRows.has(name)) throw new Error(`Duplicate metadata artifact: ${name}`);
      metadataRows.set(name, payload.rows as Row[]);
      continue;
    }

    if (payload.format !== "tonline-source-archive-table-page" || payload.formatVersion !== 1) {
      throw new Error(`Invalid table artifact format: ${file.path}`);
    }
    const table = tableByDataset.get(file.dataset);
    if (!table || payload.schema !== table.schema || payload.table !== table.table) {
      throw new Error(`Table manifest mismatch: ${file.path}`);
    }
    const aggregate = tableAggregates.get(file.dataset) ?? { rows: 0, pages: [], primaryKeys: new Set<string>() };
    aggregate.rows += file.rowCount;
    aggregate.pages.push(file.page);
    for (const [rowIndex, rawRow] of (payload.rows as unknown[]).entries()) {
      if (!rawRow || typeof rawRow !== "object" || Array.isArray(rawRow)) {
        throw new Error(`Invalid table row at ${file.path}#${rowIndex}`);
      }
      const row = rawRow as Row;
      if (!sameStrings(Object.keys(row), table.columns)) {
        throw new Error(`Column coverage mismatch at ${file.path}#${rowIndex}`);
      }
      if (table.primaryKey.length > 0) {
        const key = JSON.stringify(table.primaryKey.map((column) => row[column]));
        if (aggregate.primaryKeys.has(key)) throw new Error(`Duplicate primary key in ${file.dataset}`);
        aggregate.primaryKeys.add(key);
      }
    }
    tableAggregates.set(file.dataset, aggregate);
  }

  if (manifest.files.length !== manifest.archive.fileCount) throw new Error("Archive file count mismatch");
  if (metadataRows.size !== manifest.metadata.length) throw new Error("Not every metadata set has one artifact");

  const reconstructedCatalog: Record<string, Row[]> = {};
  let metadataRowCount = 0;
  for (const item of manifest.metadata) {
    const rows = metadataRows.get(item.name);
    if (!rows || rows.length !== item.rowCount) throw new Error(`Metadata count mismatch: ${item.name}`);
    reconstructedCatalog[item.name] = rows;
    metadataRowCount += rows.length;
  }
  if (hash(Buffer.from(JSON.stringify(reconstructedCatalog), "utf8")) !== manifest.catalogSha256) {
    throw new Error("Reconstructed catalog SHA-256 mismatch");
  }
  if (metadataRowCount !== manifest.archive.metadataRowCount) throw new Error("Metadata total mismatch");

  let tableRowCount = 0;
  let nonEmptyTableCount = 0;
  for (const table of manifest.tables) {
    if (table.rowCountBefore !== table.rowCountAfter || table.rowCountBefore !== table.exportedRows) {
      throw new Error(`Unstable recorded counts: ${table.schema}.${table.table}`);
    }
    if (!sameStrings(table.fingerprintBefore, table.fingerprintAfter)) {
      throw new Error(`Unstable recorded fingerprint: ${table.schema}.${table.table}`);
    }
    const aggregate = tableAggregates.get(table.dataset) ?? { rows: 0, pages: [], primaryKeys: new Set<string>() };
    aggregate.pages.sort((left, right) => left - right);
    const expectedPages = Array.from({ length: table.pages }, (_value, index) => index + 1);
    if (JSON.stringify(aggregate.pages) !== JSON.stringify(expectedPages)) {
      throw new Error(`Non-contiguous pages: ${table.schema}.${table.table}`);
    }
    if (aggregate.rows !== table.exportedRows) throw new Error(`Exported row mismatch: ${table.schema}.${table.table}`);
    if (table.exportedRows === 0 && table.pages !== 0) throw new Error(`Empty table has pages: ${table.schema}.${table.table}`);
    if (table.exportedRows > 0) nonEmptyTableCount += 1;
    tableRowCount += table.exportedRows;
  }

  if (manifest.tables.length !== manifest.archive.tableCount) throw new Error("Table count mismatch");
  if (nonEmptyTableCount !== manifest.archive.nonEmptyTableCount) throw new Error("Non-empty table count mismatch");
  if (tableRowCount !== manifest.archive.tableRowCount) throw new Error("Table row total mismatch");

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

  console.log(`Source archive verified: ${archiveDirectory}`);
  console.log(`${manifest.tables.length} tables; ${tableRowCount} data rows; ${manifest.files.length} files`);
  console.log(`Manifest SHA-256: ${actualManifestHash}`);
  console.log("All file hashes, catalog metadata, table counts, fingerprints, pages, and column coverage are consistent.");
}

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