/**
 * Distribution / self-update helpers: fingerprint, version bump, release
 * metadata, fleet approval, patch ring-buffer, apply + revert.
 */
import { createHash } from 'node:crypto';
import { execFileSync, spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { VERSION } from '../version';

export const LARGE_THRESHOLD_KB = 8;
/** Part 7d — keep the last N install snapshots under `patches/` for Revert. */
export const PATCH_RING_DEPTH = 5;

const SKIP_DIRS = new Set([
  'node_modules', '.git', 'workspaces', 'data', 'dist', 'patches', 'releases',
  'web/dist',
]);

export interface ReleaseEntry {
  version: string;
  hash: string;
  sizeKB: number;
  publishedAt: string;
  artifact: string;
}

export interface ReleaseFile {
  history: ReleaseEntry[];
}

export interface ApprovalFile {
  approvedVersion: string | null;
  approvedAt: string | null;
}

export interface ParsedVersion {
  major: number;
  minor: number;
  letter: string | null; // a–z or null
  raw: string;
}

export function parseVersion(raw: string): ParsedVersion | null {
  const m = /^V(\d+)\.(\d+)([a-z])?$/i.exec(String(raw).trim());
  if (!m) return null;
  return {
    major: Number(m[1]),
    minor: Number(m[2]),
    letter: m[3] ? m[3].toLowerCase() : null,
    raw: `V${m[1]}.${m[2]}${m[3] ? m[3].toLowerCase() : ''}`,
  };
}

/** Negative if a < b, 0 if equal, positive if a > b. */
export function compareVersions(a: string, b: string): number {
  const pa = parseVersion(a);
  const pb = parseVersion(b);
  if (!pa || !pb) return String(a).localeCompare(String(b));
  if (pa.major !== pb.major) return pa.major - pb.major;
  if (pa.minor !== pb.minor) return pa.minor - pb.minor;
  const la = pa.letter ? pa.letter.charCodeAt(0) - 96 : 0;
  const lb = pb.letter ? pb.letter.charCodeAt(0) - 96 : 0;
  return la - lb;
}

export function bumpVersion(current: string, kind: 'letter' | 'hundredths'): string {
  const p = parseVersion(current);
  if (!p) throw new Error(`unparseable version: ${current}`);
  if (kind === 'hundredths') {
    return `V${p.major}.${p.minor + 1}`;
  }
  // letter bump
  if (!p.letter) return `V${p.major}.${p.minor}a`;
  if (p.letter === 'z') return `V${p.major}.${p.minor + 1}`;
  return `V${p.major}.${p.minor}${String.fromCharCode(p.letter.charCodeAt(0) + 1)}`;
}

function shouldSkip(rel: string): boolean {
  const parts = rel.split(/[/\\]/).filter(Boolean);
  if (parts.some(p => SKIP_DIRS.has(p) || p === '.DS_Store')) return true;
  if (parts[0] === 'web' && parts[1] === 'dist') return true;
  return false;
}

/** Walk source tree; return sorted relative paths (posix) of files to fingerprint / package. */
export function listSourceFiles(root: string): string[] {
  const out: string[] = [];
  const walk = (abs: string, rel: string) => {
    let entries: string[];
    try { entries = fs.readdirSync(abs); } catch { return; }
    for (const name of entries.sort()) {
      if (name === '.DS_Store' || name === '.env') continue;
      const childRel = rel ? `${rel}/${name}` : name;
      if (shouldSkip(childRel)) continue;
      const childAbs = path.join(abs, name);
      let st: fs.Stats;
      try { st = fs.statSync(childAbs); } catch { continue; }
      if (st.isDirectory()) walk(childAbs, childRel);
      else if (st.isFile()) out.push(childRel);
    }
  };
  walk(root, '');
  return out.sort();
}

export function fingerprintTree(root: string): { hash: string; sizeKB: number; bytes: number } {
  const files = listSourceFiles(root);
  const h = createHash('sha256');
  let bytes = 0;
  for (const rel of files) {
    const buf = fs.readFileSync(path.join(root, rel));
    bytes += buf.length;
    h.update(rel);
    h.update('\0');
    h.update(buf);
    h.update('\0');
  }
  return { hash: h.digest('hex'), sizeKB: Math.round(bytes / 1024), bytes };
}

export function readReleaseFile(root: string): ReleaseFile {
  const p = path.join(root, 'release.json');
  if (!fs.existsSync(p)) return { history: [] };
  try {
    const j = JSON.parse(fs.readFileSync(p, 'utf8')) as ReleaseFile;
    return { history: Array.isArray(j.history) ? j.history : [] };
  } catch {
    return { history: [] };
  }
}

export function writeReleaseFile(root: string, data: ReleaseFile): void {
  fs.writeFileSync(path.join(root, 'release.json'), JSON.stringify(data, null, 2) + '\n');
}

export function latestRelease(root: string): ReleaseEntry | null {
  const hist = readReleaseFile(root).history;
  if (!hist.length) return null;
  return [...hist].sort((a, b) => compareVersions(a.version, b.version)).at(-1) ?? null;
}

export function readApproval(dataDir: string): ApprovalFile {
  const p = path.join(dataDir, 'release-approval.json');
  if (!fs.existsSync(p)) return { approvedVersion: null, approvedAt: null };
  try {
    const j = JSON.parse(fs.readFileSync(p, 'utf8')) as ApprovalFile;
    return {
      approvedVersion: j.approvedVersion ?? null,
      approvedAt: j.approvedAt ?? null,
    };
  } catch {
    return { approvedVersion: null, approvedAt: null };
  }
}

export function writeApproval(dataDir: string, approvedVersion: string | null): void {
  fs.mkdirSync(dataDir, { recursive: true });
  const body: ApprovalFile = {
    approvedVersion,
    approvedAt: approvedVersion ? new Date().toISOString() : null,
  };
  fs.writeFileSync(path.join(dataDir, 'release-approval.json'), JSON.stringify(body, null, 2) + '\n');
}

export function isFleetApproved(root: string, dataDir: string): boolean {
  const latest = latestRelease(root);
  if (!latest) return false;
  const a = readApproval(dataDir);
  return !!a.approvedVersion && a.approvedVersion === latest.version;
}

export function versionPayload(root: string, dataDir: string): {
  version: string;
  hash: string;
  sizeKB: number;
  publishedAt: string;
  approved: boolean;
} {
  const latest = latestRelease(root);
  const approved = isFleetApproved(root, dataDir);
  if (latest) {
    return {
      version: latest.version,
      hash: latest.hash,
      sizeKB: latest.sizeKB,
      publishedAt: latest.publishedAt,
      approved,
    };
  }
  const fp = fingerprintTree(root);
  return {
    version: VERSION,
    hash: fp.hash,
    sizeKB: fp.sizeKB,
    publishedAt: new Date(0).toISOString(),
    approved: false,
  };
}

export function listPatchVersions(root: string): string[] {
  const dir = path.join(root, 'patches');
  if (!fs.existsSync(dir)) return [];
  return fs.readdirSync(dir)
    .filter(name => {
      const p = path.join(dir, name);
      return fs.statSync(p).isDirectory() && !!parseVersion(name);
    })
    .sort(compareVersions);
}

export function isLegacyInstall(root: string): boolean {
  const patches = listPatchVersions(root);
  if (!patches.length) return false;
  const newest = patches[patches.length - 1]!;
  return compareVersions(VERSION, newest) < 0;
}

/** Snapshot current source tree into patches/<version>/ (5-deep ring buffer). */
export function snapshotCurrent(root: string, version = VERSION): string {
  const dest = path.join(root, 'patches', version);
  fs.mkdirSync(dest, { recursive: true });
  // Clear prior contents of this version slot
  for (const name of fs.readdirSync(dest)) {
    fs.rmSync(path.join(dest, name), { recursive: true, force: true });
  }
  const files = listSourceFiles(root);
  for (const rel of files) {
    const from = path.join(root, rel);
    const to = path.join(dest, rel);
    fs.mkdirSync(path.dirname(to), { recursive: true });
    fs.copyFileSync(from, to);
  }
  evictOldPatches(root, PATCH_RING_DEPTH);
  return dest;
}

function evictOldPatches(root: string, keep: number): void {
  const vers = listPatchVersions(root);
  while (vers.length > keep) {
    const oldest = vers.shift()!;
    fs.rmSync(path.join(root, 'patches', oldest), { recursive: true, force: true });
  }
}

/** Restore a patch snapshot over the install (does not touch data/workspaces/…). */
export function restorePatch(root: string, version: string): void {
  const src = path.join(root, 'patches', version);
  if (!fs.existsSync(src)) throw new Error(`patch ${version} not found`);
  const files: string[] = [];
  const walk = (abs: string, rel: string) => {
    for (const name of fs.readdirSync(abs).sort()) {
      const childRel = rel ? `${rel}/${name}` : name;
      const childAbs = path.join(abs, name);
      const st = fs.statSync(childAbs);
      if (st.isDirectory()) walk(childAbs, childRel);
      else files.push(childRel);
    }
  };
  walk(src, '');
  for (const rel of files) {
    const from = path.join(src, rel);
    const to = path.join(root, rel);
    fs.mkdirSync(path.dirname(to), { recursive: true });
    fs.copyFileSync(from, to);
  }
}

export function sha256File(filePath: string): string {
  return createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
}

export function writeVersionFile(root: string, version: string): void {
  const p = path.join(root, 'src', 'version.ts');
  fs.writeFileSync(p, `/** Single source of truth for the app version string (e.g. V1.10b). */\nexport const VERSION = '${version}';\n`);
}

/** Build a release zip of the source tree into releases/<version>.zip; return absolute path. */
export function buildReleaseZip(root: string, version: string): string {
  const dir = path.join(root, 'releases');
  fs.mkdirSync(dir, { recursive: true });
  const out = path.join(dir, `${version}.zip`);
  if (fs.existsSync(out)) fs.unlinkSync(out);
  // zip from a temp staging dir so the archive has files at root (not nested).
  const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'rha-rel-'));
  try {
    for (const rel of listSourceFiles(root)) {
      const to = path.join(stage, rel);
      fs.mkdirSync(path.dirname(to), { recursive: true });
      fs.copyFileSync(path.join(root, rel), to);
    }
    execFileSync('zip', ['-qr', out, '.'], { cwd: stage, timeout: 120_000 });
  } finally {
    fs.rmSync(stage, { recursive: true, force: true });
  }
  return out;
}

/**
 * Download + verify + snapshot + unpack a fleet update.
 * Trust: HTTPS (or localhost HTTP), sha256 must match expectedHash, no exec of unverified bytes.
 */
export async function applyUpdateFromUrl(opts: {
  root: string;
  downloadUrl: string;
  expectedHash: string;
  newVersion: string;
}): Promise<void> {
  const { root, downloadUrl, expectedHash, newVersion } = opts;
  let u: URL;
  try { u = new URL(downloadUrl); } catch {
    throw new Error('invalid download URL');
  }
  const local = u.hostname === 'localhost' || u.hostname === '127.0.0.1';
  if (u.protocol !== 'https:' && !(u.protocol === 'http:' && local)) {
    throw new Error('refusing non-HTTPS update URL (localhost HTTP allowed for testing)');
  }
  const res = await fetch(downloadUrl);
  if (!res.ok) throw new Error(`download failed: HTTP ${res.status}`);
  const buf = Buffer.from(await res.arrayBuffer());
  const got = createHash('sha256').update(buf).digest('hex');
  if (got !== expectedHash) {
    throw new Error(`hash mismatch — expected ${expectedHash.slice(0, 12)}… got ${got.slice(0, 12)}…; refusing to apply`);
  }

  // Snapshot current before replacing.
  snapshotCurrent(root, VERSION);

  const tmpZip = path.join(os.tmpdir(), `rha-update-${Date.now()}.zip`);
  const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'rha-unpack-'));
  try {
    fs.writeFileSync(tmpZip, buf);
    execFileSync('unzip', ['-qo', tmpZip, '-d', stage], { timeout: 120_000 });
    // Copy staged files over root (only files present in the verified zip).
    const walk = (abs: string, rel: string) => {
      for (const name of fs.readdirSync(abs)) {
        if (name === '.DS_Store') continue;
        const childRel = rel ? `${rel}/${name}` : name;
        if (shouldSkip(childRel)) continue;
        const childAbs = path.join(abs, name);
        const st = fs.statSync(childAbs);
        if (st.isDirectory()) walk(childAbs, childRel);
        else {
          const dest = path.join(root, childRel);
          fs.mkdirSync(path.dirname(dest), { recursive: true });
          fs.copyFileSync(childAbs, dest);
        }
      }
    };
    walk(stage, '');
    // Ensure version file matches declared new version if zip didn't include a bump yet.
    if (parseVersion(newVersion)) writeVersionFile(root, newVersion);
  } finally {
    try { fs.unlinkSync(tmpZip); } catch { /* ignore */ }
    fs.rmSync(stage, { recursive: true, force: true });
  }
}

export function scheduleRestart(): void {
  setTimeout(() => {
    try {
      const child = spawn(process.argv[0]!, process.argv.slice(1), {
        detached: true,
        stdio: 'inherit',
        env: process.env,
        cwd: process.cwd(),
      });
      child.unref();
    } catch {
      /* fall through to exit — operator restarts manually */
    }
    process.exit(0);
  }, 600);
}
