#!/usr/bin/env node

import { spawnSync } from "node:child_process";
import {
  existsSync,
  lstatSync,
  mkdirSync,
  readFileSync,
  writeFileSync,
} from "node:fs";
import {
  dirname,
  isAbsolute,
  join,
  relative,
  resolve,
  sep,
} from "node:path";
import process from "node:process";

const TOOL_VERSION = "0.1.0";
const SCHEMA_VERSION = "pib-repository-snapshot-v0.1";
const EXTRACTOR_PROFILE = "pib-node-core-v0.1";
const LFS_POINTER_SPEC = "https://git-lfs.github.com/spec/v1";
const GENERATED_PATH_POLICY = "git-index-plus-untracked-v0.1";
const MAX_ITEMS = 20_000;
const MAX_CHANGED_PATHS = 5_000;
const MAX_PACKAGES = 250;
const MAX_LABEL_LENGTH = 220;
const MAX_PATH_LENGTH = 420;
const MAX_META_BYTES = 1_048_576;
const MAX_JSON_BYTES = 2_097_152;
const MAX_GIT_BUFFER = 67_108_864;
const MAX_LFS_POINTER_BYTES = 1_024;
const MAX_SAFE_METRIC = Number.MAX_SAFE_INTEGER;

const supportedPackageSources = new Set(["registry", "git", "local", "embedded", "builtin", "unknown"]);
const supportedSerializationModes = new Set(["ForceText", "Mixed", "ForceBinary"]);
const supportedPathCaseModes = new Set(["case-sensitive", "case-insensitive"]);
const packageNamePattern = /^[a-z0-9][a-z0-9._-]{1,127}$/;

function usage() {
  return `XeSoft Project Integrity Evidence Kit ${TOOL_VERSION}

Usage:
  node pib-snapshot.mjs --mode baseline|candidate --comparison-id LABEL
    --revision-label LABEL --serialization-mode ForceText|Mixed|ForceBinary
    --path-case-mode case-sensitive|case-insensitive --output FILE
    [--base-ref REF] [--project-root DIR] [--expected-change ID]
    [--overwrite]

Candidate mode requires --base-ref. Run each mode from its clean checkout.
The tool writes one local JSON file and never uploads repository data.`;
}

function fail(message) {
  throw new Error(message);
}

function hasControlCharacters(value) {
  return [...value].some((character) => {
    const codePoint = character.codePointAt(0);
    return codePoint <= 31 || codePoint === 127;
  });
}

function parseArguments(argv) {
  const options = { expectedChanges: [], overwrite: false, help: false };
  const valued = new Set([
    "--mode",
    "--comparison-id",
    "--revision-label",
    "--serialization-mode",
    "--path-case-mode",
    "--output",
    "--base-ref",
    "--project-root",
    "--expected-change",
  ]);
  const mapping = {
    "--mode": "mode",
    "--comparison-id": "comparisonId",
    "--revision-label": "revisionLabel",
    "--serialization-mode": "serializationMode",
    "--path-case-mode": "pathCaseMode",
    "--output": "output",
    "--base-ref": "baseRef",
    "--project-root": "projectRoot",
  };

  for (let index = 0; index < argv.length; index += 1) {
    const argument = argv[index];
    if (argument === "--help" || argument === "-h") {
      options.help = true;
      continue;
    }
    if (argument === "--overwrite") {
      options.overwrite = true;
      continue;
    }
    if (!valued.has(argument)) fail(`Unsupported argument ${argument}. Use --help for the exact contract.`);
    const value = argv[index + 1];
    if (value === undefined || value.startsWith("--")) fail(`${argument} requires one value.`);
    index += 1;
    if (argument === "--expected-change") options.expectedChanges.push(value);
    else options[mapping[argument]] = value;
  }
  return options;
}

function validLabel(value, name) {
  if (typeof value !== "string") fail(`${name} is required.`);
  const trimmed = value.trim();
  if (!trimmed || trimmed.length > MAX_LABEL_LENGTH || hasControlCharacters(trimmed)) {
    fail(`${name} must be a non-empty, non-secret label of ${MAX_LABEL_LENGTH} characters or fewer.`);
  }
  return trimmed;
}

function runGit(root, args, { input, allowFailure = false, maxBuffer = MAX_GIT_BUFFER } = {}) {
  const result = spawnSync("git", args, {
    cwd: root,
    input,
    encoding: null,
    windowsHide: true,
    maxBuffer,
  });
  if (result.error) fail("Git could not be started from PATH.");
  if (result.status !== 0 && !allowFailure) {
    const detail = Buffer.from(result.stderr ?? []).toString("utf8").trim().split(/\r?\n/u)[0];
    fail(detail ? `Git rejected the requested evidence operation: ${detail}` : "Git rejected the requested evidence operation.");
  }
  return result;
}

function splitNul(buffer) {
  return Buffer.from(buffer ?? [])
    .toString("utf8")
    .split("\0")
    .filter(Boolean);
}

function normalizeRepoPath(value) {
  if (typeof value !== "string") fail("Git returned a non-text repository path.");
  const path = value.normalize("NFC");
  if (
    !path
    || path.length > MAX_PATH_LENGTH
    || path.includes("\\")
    || path.startsWith("/")
    || path.endsWith("/")
    || path.includes("//")
    || hasControlCharacters(path)
  ) fail("A repository path cannot be represented safely by the evaluator contract.");
  const segments = path.split("/");
  if (segments.some((segment) => !segment || segment === "." || segment === "..")) {
    fail("A repository path contains an unsafe segment.");
  }
  if (!new Set(["Assets", "Packages", "ProjectSettings"]).has(segments[0])) {
    fail("Git returned a path outside the declared Unity project surface.");
  }
  return path;
}

function resolveProjectPath(root, repoPath) {
  const target = resolve(root, ...repoPath.split("/"));
  const relativeTarget = relative(root, target);
  if (!relativeTarget || relativeTarget.startsWith(`..${sep}`) || relativeTarget === ".." || isAbsolute(relativeTarget)) {
    fail("A repository path escaped the verified project root.");
  }
  return target;
}

function assertRegularNoLink(path, label, maxBytes = null) {
  if (!existsSync(path)) fail(`${label} is missing from the clean checkout.`);
  const stats = lstatSync(path);
  if (stats.isSymbolicLink()) fail(`${label} is a symbolic link; version 0.1.0 refuses paths outside the verified checkout.`);
  if (!stats.isFile()) fail(`${label} must be a regular file.`);
  if (maxBytes !== null && stats.size > maxBytes) fail(`${label} exceeds the bounded read ceiling.`);
  return stats;
}

function readBoundedText(path, label, maxBytes) {
  assertRegularNoLink(path, label, maxBytes);
  return readFileSync(path, "utf8");
}

function identityFor(path, caseMode) {
  return caseMode === "case-insensitive" ? path.toLocaleLowerCase("en-US") : path;
}

function addPathRow(rows, identities, row, caseMode) {
  const identity = identityFor(row.path, caseMode);
  if (identities.has(identity)) return;
  identities.add(identity);
  rows.push(row);
  if (rows.length > MAX_ITEMS) fail(`Path inventory exceeds the ${MAX_ITEMS}-entry evaluator ceiling.`);
}

function readUnityVersion(root) {
  const path = join(root, "ProjectSettings", "ProjectVersion.txt");
  const text = readBoundedText(path, "ProjectSettings/ProjectVersion.txt", 16_384);
  const match = /^m_EditorVersion:\s*([^\r\n]+)$/mu.exec(text);
  return validLabel(match?.[1], "Unity version");
}

function inventoryProject(root, caseMode) {
  const result = runGit(root, [
    "ls-files",
    "-z",
    "--cached",
    "--others",
    "--exclude-standard",
    "--",
    "Assets",
    "Packages",
    "ProjectSettings",
  ]);
  const listed = [...new Set(splitNul(result.stdout).map(normalizeRepoPath))].sort((left, right) => left.localeCompare(right));
  if (listed.length > MAX_ITEMS) fail(`Unity project file inventory exceeds the ${MAX_ITEMS}-entry evaluator ceiling.`);

  const paths = [];
  const pathIdentities = new Set();
  const metaFiles = [];
  const metaIdentities = new Set();

  for (const repoPath of listed) {
    const absolutePath = resolveProjectPath(root, repoPath);
    if (!existsSync(absolutePath)) fail("The clean checkout is incomplete or sparse; a listed path is absent from disk.");
    const stats = lstatSync(absolutePath);
    if (stats.isSymbolicLink()) fail("A relevant Unity project path is a symbolic link; version 0.1.0 refuses to follow it.");

    if (repoPath.startsWith("Assets/") && repoPath.endsWith(".meta")) {
      if (!stats.isFile() || stats.size > MAX_META_BYTES) fail("A Unity .meta file is not a bounded regular file.");
      const text = readFileSync(absolutePath, "utf8");
      const matches = [...text.matchAll(/^guid:\s*([0-9a-fA-F]{32})\s*$/gmu)];
      if (matches.length !== 1) fail("Every Unity .meta file must expose exactly one 32-character hexadecimal GUID.");
      const metaPath = repoPath;
      const assetPath = repoPath.slice(0, -5);
      const metaIdentity = identityFor(metaPath, caseMode);
      if (metaIdentities.has(metaIdentity)) fail("The metadata inventory contains a path collision under the declared case mode.");
      metaIdentities.add(metaIdentity);
      metaFiles.push({ metaPath, assetPath, guid: matches[0][1].toLowerCase() });
      if (metaFiles.length > MAX_ITEMS) fail(`GUID inventory exceeds the ${MAX_ITEMS}-entry evaluator ceiling.`);

      const absoluteAsset = resolveProjectPath(root, assetPath);
      if (existsSync(absoluteAsset)) {
        const assetStats = lstatSync(absoluteAsset);
        if (assetStats.isSymbolicLink()) fail("A Unity asset paired with metadata is a symbolic link; version 0.1.0 refuses to follow it.");
        if (assetStats.isDirectory()) addPathRow(paths, pathIdentities, { path: assetPath, kind: "folder", sizeBytes: 0 }, caseMode);
      }
      continue;
    }

    if (!stats.isFile() && !stats.isDirectory()) fail("A relevant Unity project path has an unsupported filesystem type.");
    const kind = repoPath.startsWith("Assets/")
      ? stats.isDirectory() ? "folder" : "asset"
      : repoPath.startsWith("Packages/")
        ? "package_file"
        : repoPath.startsWith("ProjectSettings/")
          ? "project_setting"
          : "other";
    const sizeBytes = stats.isDirectory() ? 0 : stats.size;
    if (!Number.isSafeInteger(sizeBytes) || sizeBytes < 0 || sizeBytes > MAX_SAFE_METRIC) fail("A project path size exceeds the safe numeric range.");
    addPathRow(paths, pathIdentities, { path: repoPath, kind, sizeBytes }, caseMode);
  }

  paths.sort((left, right) => left.path.localeCompare(right.path));
  metaFiles.sort((left, right) => left.metaPath.localeCompare(right.metaPath));
  return { paths, metaFiles };
}

function sanitizePackageValue(value, source = "unknown") {
  if (typeof value !== "string" || !value.trim()) fail("Every package requirement and lock version must be a non-empty string.");
  const trimmed = value.trim();
  const lower = trimmed.toLowerCase();
  if (source === "git" || lower.startsWith("git+") || lower.startsWith("git:") || lower.startsWith("ssh:") || /https?:\/\//u.test(lower)) return "git-source-redacted";
  if (source === "local" || source === "embedded" || lower.startsWith("file:") || lower.startsWith("../") || lower.startsWith("./")) return `${source === "embedded" ? "embedded" : "local"}-source-redacted`;
  if (source === "builtin") return "builtin";
  if (/^[0-9a-zA-Z*^~<>=|._+ -]{1,120}$/u.test(trimmed)) return trimmed;
  return "source-redacted";
}

function readJsonObject(path, label, required) {
  if (!existsSync(path)) {
    if (required) fail(`${label} is missing.`);
    return null;
  }
  const text = readBoundedText(path, label, MAX_JSON_BYTES);
  let value;
  try {
    value = JSON.parse(text);
  } catch {
    fail(`${label} is not valid JSON.`);
  }
  if (!value || typeof value !== "object" || Array.isArray(value)) fail(`${label} must contain one JSON object.`);
  return value;
}

function readPackages(root) {
  const manifest = readJsonObject(join(root, "Packages", "manifest.json"), "Packages/manifest.json", true);
  const manifestDependencies = manifest.dependencies;
  if (!manifestDependencies || typeof manifestDependencies !== "object" || Array.isArray(manifestDependencies)) {
    fail("Packages/manifest.json must contain a dependencies object.");
  }
  const manifestEntries = Object.entries(manifestDependencies);
  if (manifestEntries.length > MAX_PACKAGES) fail(`Direct package inventory exceeds the ${MAX_PACKAGES}-entry evaluator ceiling.`);
  const sanitizedManifest = Object.create(null);
  for (const [name, value] of manifestEntries.sort(([left], [right]) => left.localeCompare(right))) {
    if (!packageNamePattern.test(name)) fail("A direct package name does not match the evaluator contract.");
    sanitizedManifest[name] = sanitizePackageValue(value);
  }

  const lockDocument = readJsonObject(join(root, "Packages", "packages-lock.json"), "Packages/packages-lock.json", false);
  if (lockDocument === null) return { lockEnabled: false, manifest: sanitizedManifest, lock: {} };
  const lockDependencies = lockDocument.dependencies;
  if (!lockDependencies || typeof lockDependencies !== "object" || Array.isArray(lockDependencies)) {
    fail("Packages/packages-lock.json must contain a dependencies object.");
  }
  const lockEntries = Object.entries(lockDependencies);
  if (lockEntries.length > MAX_PACKAGES) fail(`Resolved package inventory exceeds the ${MAX_PACKAGES}-entry evaluator ceiling.`);
  const sanitizedLock = Object.create(null);
  for (const [name, entry] of lockEntries.sort(([left], [right]) => left.localeCompare(right))) {
    if (!packageNamePattern.test(name) || !entry || typeof entry !== "object" || Array.isArray(entry)) {
      fail("A resolved package entry does not match the evaluator contract.");
    }
    const source = supportedPackageSources.has(entry.source) ? entry.source : "unknown";
    if (!Number.isInteger(entry.depth) || entry.depth < 0 || entry.depth > 100) fail("Every resolved package must expose a depth from 0 through 100.");
    sanitizedLock[name] = {
      version: sanitizePackageValue(entry.version, source),
      depth: entry.depth,
      source,
    };
  }
  return { lockEnabled: true, manifest: sanitizedManifest, lock: sanitizedLock };
}

function changedPaths(root, options) {
  if (options.mode === "baseline") return [];
  const baseRef = validLabel(options.baseRef, "--base-ref");
  if (baseRef.startsWith("-")) fail("--base-ref cannot begin with a dash.");
  runGit(root, ["rev-parse", "--verify", `${baseRef}^{commit}`]);
  const result = runGit(root, [
    "diff",
    "--name-only",
    "-z",
    "--diff-filter=ACDMRTUXB",
    `${baseRef}...HEAD`,
    "--",
    "Assets",
    "Packages",
    "ProjectSettings",
  ]);
  const paths = [...new Set(splitNul(result.stdout).map(normalizeRepoPath))].sort((left, right) => left.localeCompare(right));
  if (paths.length > MAX_CHANGED_PATHS) fail(`Changed-path inventory exceeds the ${MAX_CHANGED_PATHS}-entry candidate ceiling.`);
  return paths;
}

function readFilterAttributes(root, paths) {
  if (!paths.length) return new Map();
  const input = Buffer.from(`${paths.join("\0")}\0`, "utf8");
  const result = runGit(root, ["check-attr", "-z", "--stdin", "filter"], { input });
  const fields = splitNul(result.stdout);
  if (fields.length !== paths.length * 3) fail("Git returned an incomplete filter-attribute inventory.");
  const values = new Map();
  for (let index = 0; index < fields.length; index += 3) {
    const path = normalizeRepoPath(fields[index]);
    if (fields[index + 1] !== "filter") fail("Git returned an unexpected attribute name.");
    values.set(path, fields[index + 2]);
  }
  return values;
}

function parseLfsPointer(buffer) {
  const text = Buffer.from(buffer).toString("utf8").replace(/\r\n/gu, "\n");
  const lines = text.split("\n").filter((line) => line.length > 0);
  const versionLine = lines.find((line) => line.startsWith("version "));
  const oidLine = lines.find((line) => line.startsWith("oid "));
  const sizeLine = lines.find((line) => line.startsWith("size "));
  const pointerCandidate = Boolean(versionLine?.includes("git-lfs.github.com/spec/"));
  if (!pointerCandidate) return { pointerCandidate: false };
  const version = versionLine.slice("version ".length).trim();
  const oidMatch = /^oid\s+([a-z0-9]+):([0-9a-f]+)$/u.exec(oidLine ?? "");
  const sizeMatch = /^size\s+(\d+)$/u.exec(sizeLine ?? "");
  const extrasValid = lines.every((line) => line === versionLine || line === oidLine || line === sizeLine || line.startsWith("ext-"));
  const declaredSize = sizeMatch ? Number(sizeMatch[1]) : null;
  const validSize = Number.isSafeInteger(declaredSize) && declaredSize >= 0;
  const valid = version === LFS_POINTER_SPEC && oidMatch?.[1] === "sha256" && oidMatch[2].length === 64 && validSize && extrasValid;
  return {
    pointerCandidate: true,
    valid,
    version: version || "unknown",
    oidAlgorithm: oidMatch?.[1] ?? "unknown",
    declaredSize: validSize ? declaredSize : null,
  };
}

function readLfsEvidence(root, changed) {
  const current = changed.filter((path) => existsSync(resolveProjectPath(root, path)) && !path.endsWith(".meta"));
  const attributes = readFilterAttributes(root, current);
  const evidence = [];
  for (const path of current) {
    const objectSpec = `HEAD:${path}`;
    const sizeResult = runGit(root, ["cat-file", "-s", objectSpec]);
    const objectSize = Number(Buffer.from(sizeResult.stdout).toString("utf8").trim());
    if (!Number.isSafeInteger(objectSize) || objectSize < 0) fail("Git returned an unsafe object size for a changed path.");
    const filter = attributes.get(path) ?? "unspecified";
    const shouldUseLfs = filter === "lfs";
    let pointer = { pointerCandidate: false };
    if (objectSize <= MAX_LFS_POINTER_BYTES) {
      const content = runGit(root, ["cat-file", "blob", objectSpec], { maxBuffer: MAX_LFS_POINTER_BYTES * 4 }).stdout;
      pointer = parseLfsPointer(content);
    }
    if (!shouldUseLfs && !pointer.pointerCandidate) continue;
    if (pointer.pointerCandidate) {
      evidence.push({
        path,
        shouldUseLfs,
        storageForm: "pointer",
        pointerValid: Boolean(pointer.valid),
        pointerVersion: validLabel(pointer.version, "LFS pointer version"),
        oidAlgorithm: validLabel(pointer.oidAlgorithm, "LFS pointer OID algorithm"),
        blobSizeBytes: pointer.declaredSize ?? objectSize,
        policyEvidence: `git check-attr filter=${filter}`,
      });
    } else {
      evidence.push({
        path,
        shouldUseLfs,
        storageForm: "full_blob",
        pointerValid: null,
        pointerVersion: null,
        oidAlgorithm: null,
        blobSizeBytes: objectSize,
        policyEvidence: `git check-attr filter=${filter}`,
      });
    }
  }
  if (evidence.length > MAX_CHANGED_PATHS) fail("Git LFS evidence exceeds the changed-path ceiling.");
  return evidence.sort((left, right) => left.path.localeCompare(right.path));
}

function resolveOutput(root, value) {
  const supplied = validLabel(value, "--output");
  const output = isAbsolute(supplied) ? resolve(supplied) : resolve(root, supplied);
  if (!output.toLowerCase().endsWith(".json")) fail("--output must name a JSON file.");
  const within = relative(root, output).split(sep).join("/");
  if (within === "Assets" || within.startsWith("Assets/") || within === "Packages" || within.startsWith("Packages/") || within === "ProjectSettings" || within.startsWith("ProjectSettings/")) {
    fail("The evidence file cannot be written inside Assets, Packages, or ProjectSettings.");
  }
  return output;
}

function main() {
  const options = parseArguments(process.argv.slice(2));
  if (options.help) {
    process.stdout.write(`${usage()}\n`);
    return;
  }
  if (!new Set(["baseline", "candidate"]).has(options.mode)) fail("--mode must be baseline or candidate.");
  if (!supportedSerializationModes.has(options.serializationMode)) fail("--serialization-mode must be ForceText, Mixed, or ForceBinary.");
  if (!supportedPathCaseModes.has(options.pathCaseMode)) fail("--path-case-mode must be case-sensitive or case-insensitive.");
  const comparisonId = validLabel(options.comparisonId, "--comparison-id");
  const revisionLabel = validLabel(options.revisionLabel, "--revision-label");
  const expectedChanges = options.expectedChanges.map((value) => validLabel(value, "--expected-change"));
  if (expectedChanges.length > MAX_PACKAGES || new Set(expectedChanges).size !== expectedChanges.length) fail("Expected-change identifiers must be unique and within the 250-entry ceiling.");

  const start = resolve(options.projectRoot ?? process.cwd());
  const rootResult = runGit(start, ["rev-parse", "--show-toplevel"]);
  const root = resolve(Buffer.from(rootResult.stdout).toString("utf8").trim());
  const sparse = runGit(root, ["config", "--bool", "core.sparseCheckout"], { allowFailure: true });
  if (sparse.status === 0 && Buffer.from(sparse.stdout).toString("utf8").trim() === "true") fail("Sparse checkouts are not supported because the path/GUID inventory would be incomplete.");
  const dirty = runGit(root, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]);
  if (Buffer.from(dirty.stdout).length > 0) fail("The Unity Git checkout must be clean before evidence is generated.");
  runGit(root, ["rev-parse", "--verify", "HEAD^{commit}"]);

  const output = resolveOutput(root, options.output);
  if (existsSync(output) && !options.overwrite) fail("The output file already exists; choose another path or pass --overwrite explicitly.");

  const unityVersion = readUnityVersion(root);
  const inventory = inventoryProject(root, options.pathCaseMode);
  const packages = readPackages(root);
  const changed = changedPaths(root, options);
  const lfsFiles = readLfsEvidence(root, changed);

  const snapshot = {
    schemaVersion: SCHEMA_VERSION,
    comparisonId,
    revisionLabel,
    contract: {
      vcs: "git",
      unityVersion,
      serializationMode: options.serializationMode,
      assetRoot: "Assets",
      pathCaseMode: options.pathCaseMode,
      pathNormalization: "forward-slash-nfc",
      extractorProfile: EXTRACTOR_PROFILE,
      packageManagerProfile: "upm-lock-v1",
      lfsPointerSpec: LFS_POINTER_SPEC,
      generatedPathPolicy: GENERATED_PATH_POLICY,
    },
    coverage: {
      pathInventory: "complete",
      guidIndex: "complete",
      serializedReferences: "bounded",
      packages: "complete",
      lfs: "complete",
    },
    changedPaths: changed,
    paths: inventory.paths,
    metaFiles: inventory.metaFiles,
    serializedReferences: [],
    lfsFiles,
    packages,
    expectedChanges,
  };

  mkdirSync(dirname(output), { recursive: true });
  writeFileSync(output, `${JSON.stringify(snapshot, null, 2)}\n`, { encoding: "utf8", flag: options.overwrite ? "w" : "wx" });
  process.stdout.write(`[XeSoft Project Integrity Evidence] Wrote local snapshot: ${output}\n`);
  process.stdout.write(`[XeSoft Project Integrity Evidence] Coverage: 4/5 complete; serialized references remain bounded.\n`);
}

try {
  main();
} catch (error) {
  const message = error instanceof Error ? error.message : "Unknown evidence-generator failure.";
  process.stderr.write(`[XeSoft Project Integrity Evidence] Snapshot skipped: ${message}\n`);
  process.exitCode = 1;
}
