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

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

type JsonRecord = Record<string, unknown>;

interface ManifestBucket {
  id: string;
  name: string;
  public: boolean;
}

interface ManifestObject {
  id: string;
  bucketId: string;
  name: string;
  path: string;
  sourceBytes: number | null;
  bytes: number;
  sha256: string;
}

interface StorageManifest {
  status: "complete";
  source: { projectRef: string; bucketCount: number; objectCount: number };
  summary: {
    bucketCount: number;
    objectCount: number;
    completedObjectCount: number;
    totalBytes: number;
  };
  buckets: ManifestBucket[];
  objects: ManifestObject[];
}

const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const backendDirectory = path.resolve(scriptDirectory, "..");
const defaultStorageRoot = path.join(backendDirectory, "data", "storage");
const manifestFileName = "manifest.json";
const checksumFileName = "manifest.sha256";

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

With no directory, verifies backend/data/storage. The check covers manifest
integrity, object count, byte sizes, SHA-256 hashes, unexpected files and symlinks.`);
}

function asRecord(value: unknown, label: string): JsonRecord {
  if (!value || typeof value !== "object" || Array.isArray(value)) {
    throw new Error(`${label} must be an object`);
  }
  return value as JsonRecord;
}

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

function asInteger(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 sha256(contents: Uint8Array): string {
  return createHash("sha256").update(contents).digest("hex");
}

function resolveSafe(root: string, relativePath: string): string {
  if (path.isAbsolute(relativePath) || path.win32.isAbsolute(relativePath) || relativePath.includes("\0")) {
    throw new Error(`Unsafe manifest path: ${relativePath}`);
  }
  const segments = relativePath.split("/");
  if (segments.some((segment) => !segment || segment === "." || segment === ".." || segment.includes("\\"))) {
    throw new Error(`Unsafe manifest path segment: ${relativePath}`);
  }
  if (process.platform === "win32") {
    for (const segment of segments) {
      if (/[<>:"|?*\u0000-\u001f]/.test(segment) || /[. ]$/.test(segment) ||
        /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i.test(segment)) {
        throw new Error(`Manifest path cannot be represented safely on Windows: ${relativePath}`);
      }
    }
  }
  const resolvedRoot = path.resolve(root);
  const resolved = path.resolve(resolvedRoot, ...segments);
  if (!resolved.startsWith(`${resolvedRoot}${path.sep}`)) throw new Error(`Manifest path escapes storage root: ${relativePath}`);
  return resolved;
}

function parseManifest(value: unknown): StorageManifest {
  const manifest = asRecord(value, "manifest.json");
  if (manifest.format !== "tonline-supabase-storage-mirror" || manifest.formatVersion !== 1) {
    throw new Error("Unsupported storage mirror manifest format");
  }
  if (manifest.status !== "complete") throw new Error("Storage mirror is incomplete; resume mirror-storage.ts first");
  const source = asRecord(manifest.source, "manifest.source");
  const summary = asRecord(manifest.summary, "manifest.summary");
  if (!Array.isArray(manifest.buckets) || !Array.isArray(manifest.objects)) {
    throw new Error("Storage manifest has no bucket/object lists");
  }
  const buckets = manifest.buckets.map((rawBucket, index): ManifestBucket => {
    const bucket = asRecord(rawBucket, `buckets[${index}]`);
    if (typeof bucket.public !== "boolean") throw new Error(`buckets[${index}].public must be boolean`);
    return {
      id: asString(bucket.id, `buckets[${index}].id`),
      name: asString(bucket.name, `buckets[${index}].name`),
      public: bucket.public,
    };
  });
  const objects = manifest.objects.map((rawObject, index): ManifestObject => {
    const object = asRecord(rawObject, `objects[${index}]`);
    const digest = asString(object.sha256, `objects[${index}].sha256`);
    if (!/^[a-f0-9]{64}$/.test(digest)) throw new Error(`Invalid SHA-256 at objects[${index}]`);
    const sourceBytes = object.sourceBytes === null ? null : asInteger(object.sourceBytes, `objects[${index}].sourceBytes`);
    return {
      id: asString(object.id, `objects[${index}].id`),
      bucketId: asString(object.bucketId, `objects[${index}].bucketId`),
      name: asString(object.name, `objects[${index}].name`),
      path: asString(object.path, `objects[${index}].path`),
      sourceBytes,
      bytes: asInteger(object.bytes, `objects[${index}].bytes`),
      sha256: digest,
    };
  });
  return {
    status: "complete",
    source: {
      projectRef: asString(source.projectRef, "source.projectRef"),
      bucketCount: asInteger(source.bucketCount, "source.bucketCount"),
      objectCount: asInteger(source.objectCount, "source.objectCount"),
    },
    summary: {
      bucketCount: asInteger(summary.bucketCount, "summary.bucketCount"),
      objectCount: asInteger(summary.objectCount, "summary.objectCount"),
      completedObjectCount: asInteger(summary.completedObjectCount, "summary.completedObjectCount"),
      totalBytes: asInteger(summary.totalBytes, "summary.totalBytes"),
    },
    buckets,
    objects,
  };
}

async function hashFile(filePath: string): Promise<{ bytes: number; sha256: string }> {
  const details = await stat(filePath);
  if (!details.isFile()) throw new Error(`Not a regular file: ${filePath}`);
  const hash = createHash("sha256");
  let bytes = 0;
  for await (const chunk of createReadStream(filePath)) {
    const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
    hash.update(buffer);
    bytes += buffer.byteLength;
  }
  return { bytes, sha256: hash.digest("hex") };
}

async function assertNoSymlinks(root: string, filePath: string): Promise<void> {
  const rootPath = path.resolve(root);
  const relative = path.relative(rootPath, filePath);
  let current = rootPath;
  const rootStats = await lstat(rootPath);
  if (!rootStats.isDirectory() || rootStats.isSymbolicLink()) throw new Error("Storage root is not a real directory");
  for (const segment of relative.split(path.sep)) {
    current = path.join(current, segment);
    const details = await lstat(current);
    if (details.isSymbolicLink()) throw new Error(`Symbolic links are not allowed in the mirror: ${current}`);
  }
}

async function listFiles(root: string, directory = root, 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);
    const details = await lstat(diskPath);
    if (details.isSymbolicLink()) throw new Error(`Symbolic link found in storage mirror: ${relativePath}`);
    if (entry.isDirectory()) files.push(...await listFiles(root, diskPath, relativePath));
    else if (entry.isFile()) files.push(relativePath);
    else throw new Error(`Unsupported filesystem entry in storage mirror: ${relativePath}`);
  }
  return files.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 storage directory");
  const storageRoot = path.resolve(arguments_[0] ?? defaultStorageRoot);
  const [contents, checksum] = await Promise.all([
    readFile(path.join(storageRoot, manifestFileName)),
    readFile(path.join(storageRoot, checksumFileName), "ascii"),
  ]);
  const checksumMatch = /^([a-f0-9]{64})\s+manifest\.json\s*$/i.exec(checksum);
  if (!checksumMatch?.[1]) throw new Error("manifest.sha256 has an invalid format");
  const actualManifestHash = sha256(contents);
  if (actualManifestHash !== checksumMatch[1].toLowerCase()) throw new Error("manifest.json checksum mismatch");
  let parsed: unknown;
  try {
    parsed = JSON.parse(contents.toString("utf8"));
  } catch {
    throw new Error("manifest.json is not valid JSON");
  }
  const manifest = parseManifest(parsed);

  if (manifest.buckets.length !== manifest.source.bucketCount ||
    manifest.buckets.length !== manifest.summary.bucketCount) throw new Error("Bucket count mismatch in manifest");
  if (manifest.objects.length !== manifest.source.objectCount ||
    manifest.objects.length !== manifest.summary.objectCount ||
    manifest.objects.length !== manifest.summary.completedObjectCount) throw new Error("Object count mismatch in manifest");

  const bucketIds = new Set<string>();
  for (const bucket of manifest.buckets) {
    if (bucketIds.has(bucket.id)) throw new Error(`Duplicate bucket in manifest: ${bucket.id}`);
    bucketIds.add(bucket.id);
    const bucketPath = resolveSafe(storageRoot, bucket.id);
    const details = await lstat(bucketPath);
    if (!details.isDirectory() || details.isSymbolicLink()) throw new Error(`Invalid bucket directory: ${bucket.id}`);
  }

  const ids = new Set<string>();
  const expectedFiles = new Set<string>([manifestFileName, checksumFileName]);
  let totalBytes = 0;
  let verified = 0;
  for (const object of manifest.objects) {
    if (ids.has(object.id)) throw new Error(`Duplicate object id in manifest: ${object.id}`);
    ids.add(object.id);
    if (!bucketIds.has(object.bucketId)) throw new Error(`Object references unknown bucket: ${object.bucketId}`);
    const expectedPath = `${object.bucketId}/${object.name}`;
    if (object.path !== expectedPath) throw new Error(`Object path metadata mismatch: ${object.path}`);
    if (expectedFiles.has(object.path)) throw new Error(`Duplicate object path in manifest: ${object.path}`);
    expectedFiles.add(object.path);
    const diskPath = resolveSafe(storageRoot, object.path);
    await assertNoSymlinks(storageRoot, diskPath);
    const actual = await hashFile(diskPath);
    if (actual.bytes !== object.bytes) throw new Error(`Byte count mismatch: ${object.path}`);
    if (object.sourceBytes !== null && actual.bytes !== object.sourceBytes) {
      throw new Error(`Source byte count mismatch: ${object.path}`);
    }
    if (actual.sha256 !== object.sha256) throw new Error(`SHA-256 mismatch: ${object.path}`);
    totalBytes += actual.bytes;
    verified += 1;
  }
  if (totalBytes !== manifest.summary.totalBytes) throw new Error("Total byte count mismatch in manifest");

  const actualFiles = await listFiles(storageRoot);
  const missing = [...expectedFiles].filter((file) => !actualFiles.includes(file));
  const unexpected = actualFiles.filter((file) => !expectedFiles.has(file));
  if (missing[0]) throw new Error(`Missing storage file: ${missing[0]}`);
  if (unexpected[0]) throw new Error(`Unexpected storage file: ${unexpected[0]}`);

  console.log(`Storage mirror verified: ${storageRoot}`);
  console.log(`Buckets: ${manifest.buckets.length}; objects: ${verified}; bytes: ${totalBytes}.`);
  console.log(`Manifest SHA-256: ${actualManifestHash}`);
  console.log("Every object count, size and SHA-256 checksum is consistent.");
}

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