// sysutil/apply-system-update.ts
// Applies a Nodarama system-overlay update folder created by export-system-release.ts.
// It verifies release-manifest.json, blocks unsafe paths, preserves user data/settings/auth,
// backs up overwritten files, and copies only manifest-listed files.
//
// Usage:
//   npx tsx sysutil/apply-system-update.ts logs/system/updates/nv-system-v1.0.1
//   npx tsx sysutil/apply-system-update.ts logs/system/updates/nv-system-v1.0.1 --dry-run

import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { resetBUFreq } from "../restore";

const ROOT = path.resolve(__dirname, "..");
const args = process.argv.slice(2);
const updateArg = args.find(arg => !arg.startsWith("--")) || "";
const DRY_RUN = args.includes("--dry-run");
const FORCE = args.includes("--force");
const UPDATE_ROOT = updateArg ? path.resolve(ROOT, updateArg) : "";

type ManifestFile = {
  path: string;
  bytes: number;
  sha256: string;
};

type ReleaseManifest = {
  product?: string;
  packageType?: string;
  version?: string;
  exportedAt?: string;
  updateRules?: Record<string, unknown>;
  files?: ManifestFile[];
};

const PRESERVED_ROOT_FILES = new Set([
  "settings.json",
  "settings.local.json",
  "user.json",
  ".auth_access",
  ".auth_refresh",
  ".auth_token",
  ".env"
]);

const PRESERVED_ROOT_DIRS = new Set([
  ".eq",
  ".obs",
  ".temp",
  ".git",
  ".vscode",
  "backups",
  "logs",
  "node_modules",
  "restore-points",
  "scrapes",
  "settings"
]);

const DANGEROUS_SEGMENTS = new Set([
  "",
  ".",
  ".."
]);

function fail(message: string): never {
  console.error(`update failed: ${message}`);
  process.exit(1);
}

function log(message: string): void {
  console.log(message);
}

function readJson<T>(filePath: string): T {
  try {
    return JSON.parse(fs.readFileSync(filePath, "utf8")) as T;
  } catch (err: any) {
    fail(`cannot read JSON ${filePath}: ${err?.message || String(err)}`);
  }
}

function sha256(filePath: string): string {
  return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
}

function normalizeManifestPath(value: string): string {
  const clean = String(value || "").replace(/\\/g, "/").trim();
  if (!clean) fail("manifest contains an empty file path");
  if (path.isAbsolute(clean)) fail(`absolute paths are not allowed: ${clean}`);
  if (clean.includes("\0")) fail(`null byte path is not allowed: ${clean}`);

  const parts = clean.split("/");
  for (const part of parts) {
    if (DANGEROUS_SEGMENTS.has(part)) fail(`unsafe path segment in manifest: ${clean}`);
  }

  return clean;
}

function isPreservedPath(rel: string): boolean {
  const normalized = normalizeManifestPath(rel);
  const parts = normalized.split("/");
  const top = parts[0];

  if (parts.length === 1 && PRESERVED_ROOT_FILES.has(top)) return true;
  if (PRESERVED_ROOT_DIRS.has(top)) return true;

  if (top === "projects") {
    return normalized !== "projects/system.json";
  }

  return false;
}

function resolveInside(base: string, rel: string): string {
  const normalized = normalizeManifestPath(rel);
  const target = path.resolve(base, ...normalized.split("/"));
  const baseWithSep = base.endsWith(path.sep) ? base : `${base}${path.sep}`;
  if (target !== base && !target.startsWith(baseWithSep)) fail(`path escapes base folder: ${rel}`);
  return target;
}

function ensureUpdateRoot(): void {
  if (!UPDATE_ROOT) fail("missing update folder argument");
  if (!fs.existsSync(UPDATE_ROOT)) fail(`update folder not found: ${UPDATE_ROOT}`);
  if (!fs.statSync(UPDATE_ROOT).isDirectory()) fail(`update path is not a folder: ${UPDATE_ROOT}`);
  if (UPDATE_ROOT === ROOT) fail("refusing to apply root folder as an update");
}

function readManifest(): ReleaseManifest {
  const manifestPath = path.join(UPDATE_ROOT, "release-manifest.json");
  if (!fs.existsSync(manifestPath)) fail(`release-manifest.json not found in ${UPDATE_ROOT}`);

  const manifest = readJson<ReleaseManifest>(manifestPath);
  if (manifest.product !== "Nodarama Verbatim") fail(`unexpected product: ${manifest.product || "missing"}`);
  if (manifest.packageType !== "system-overlay") fail(`unexpected package type: ${manifest.packageType || "missing"}`);
  if (!Array.isArray(manifest.files)) fail("manifest files list is missing");
  if (!manifest.files.length) fail("manifest files list is empty");

  return manifest;
}

function verifyManifestFiles(files: ManifestFile[]): ManifestFile[] {
  const seen = new Set<string>();
  const safeFiles: ManifestFile[] = [];

  for (const file of files) {
    const rel = normalizeManifestPath(file.path);
    if (seen.has(rel)) fail(`duplicate manifest path: ${rel}`);
    seen.add(rel);

    if (isPreservedPath(rel)) fail(`manifest attempts to overwrite preserved path: ${rel}`);

    const source = resolveInside(UPDATE_ROOT, rel);
    if (!fs.existsSync(source)) fail(`manifest file missing from update folder: ${rel}`);
    if (!fs.statSync(source).isFile()) fail(`manifest path is not a file: ${rel}`);

    const stat = fs.statSync(source);
    if (Number(file.bytes) !== stat.size) fail(`size mismatch for ${rel}`);

    const digest = sha256(source);
    if (String(file.sha256 || "").toLowerCase() !== digest) fail(`sha256 mismatch for ${rel}`);

    safeFiles.push({ path: rel, bytes: stat.size, sha256: digest });
  }

  return safeFiles.sort((a, b) => a.path.localeCompare(b.path));
}

function createBackupRoot(version: string): string {
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
  return path.join(ROOT, "restore-points", `system-update-${version || "unknown"}-${stamp}`);
}

function backupExistingFile(rel: string, backupRoot: string): void {
  const target = resolveInside(ROOT, rel);
  if (!fs.existsSync(target) || !fs.statSync(target).isFile()) return;

  const backupPath = resolveInside(backupRoot, rel);
  if (DRY_RUN) return;

  fs.mkdirSync(path.dirname(backupPath), { recursive: true });
  fs.copyFileSync(target, backupPath);
}

function applyFile(rel: string): void {
  const source = resolveInside(UPDATE_ROOT, rel);
  const target = resolveInside(ROOT, rel);

  if (DRY_RUN) return;
  fs.mkdirSync(path.dirname(target), { recursive: true });
  fs.copyFileSync(source, target);
}

function writeUpdateReport(backupRoot: string, manifest: ReleaseManifest, files: ManifestFile[]): void {
  if (DRY_RUN) return;

  fs.mkdirSync(backupRoot, { recursive: true });
  const report = {
    product: manifest.product,
    packageType: manifest.packageType,
    version: manifest.version || "unknown",
    exportedAt: manifest.exportedAt || "",
    appliedAt: new Date().toISOString(),
    updateRoot: UPDATE_ROOT,
    backupRoot,
    filesApplied: files.map(file => file.path)
  };

  fs.writeFileSync(path.join(backupRoot, "update-report.json"), JSON.stringify(report, null, 2), "utf8");
}

async function main(): Promise<void> {
  ensureUpdateRoot();
  const manifest = readManifest();
  const files = verifyManifestFiles(manifest.files || []);
  const version = String(manifest.version || "unknown");
  const backupRoot = createBackupRoot(version);

  log(`Nodarama system update ${DRY_RUN ? "dry run" : "apply"}`);
  log(`update: ${UPDATE_ROOT}`);
  log(`version: ${version}`);
  log(`files: ${files.length}`);
  log(`backup: ${backupRoot}`);

  if (!FORCE && UPDATE_ROOT.startsWith(path.join(ROOT, "logs", "system", "updates") + path.sep)) {
    log("source is inside logs/system/updates; this is allowed for local testing/export application");
  }

  for (const file of files) backupExistingFile(file.path, backupRoot);
  for (const file of files) applyFile(file.path);
  writeUpdateReport(backupRoot, manifest, files);

  if (!DRY_RUN) await resetBUFreq({ baselineVersion: version, source: "system-update" });

  if (DRY_RUN) {
    log("dry run complete: no files were changed");
    return;
  }

  log("update applied successfully");
  log("next: restart Nodarama");
}

main().catch(err => {
  console.error(err?.stack || err?.message || err);
  process.exit(1);
});
