import { promises as fs } from "node:fs";
import path from "node:path";
import AdmZip from "adm-zip";

import { API_ROOT, check, type Activation } from "./license";
import { readInstall, writeInstall, PRODUCT_SLUG, PRODUCT_VERSION } from "./install";

/**
 * Updating the script itself.
 *
 * A theme update drops files into one folder. A core update rewrites the
 * app, which is a different kind of risk: get it wrong and the site the
 * customer is running does not come back. So this is built around one
 * rule — never destroy anything that cannot be recreated, and never leave
 * the site half-updated.
 *
 * That means:
 *   - the customer's own things are on a keep-list and are never touched,
 *     no matter what the zip contains;
 *   - every file the update would overwrite is copied out first;
 *   - if any file fails to write, the backup goes straight back.
 *
 * As with themes, files on disk do nothing until the app is rebuilt and
 * restarted. This module says so rather than claiming the site is live on
 * the new version.
 */

const ROOT = process.cwd();

/** Where rollbacks are kept. One folder per update, newest last. */
export const BACKUP_DIR = path.join(ROOT, "backups");

/**
 * Never overwritten, never backed up, never deleted.
 *
 * These are the customer's: their database, their uploads, their
 * configuration, the themes they bought, and the machinery a running site
 * needs. An update that touched any of them would be a data loss bug, so
 * the keep-list wins over the zip every time.
 */
const KEEP = new Set([
  "data",
  "backups",
  "install.json",
  "node_modules",
  ".next",
  ".git",
  "public/media",
  "public/uploads",
  "themes",
  ".env",
  ".env.local",
  ".env.production",
  ".env.production.local",
]);

/** What a core package is allowed to contain. Anything else is dropped. */
const ALLOWED = new Set([
  ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
  ".css", ".scss", ".json", ".md", ".txt",
  ".svg", ".png", ".jpg", ".jpeg", ".webp", ".gif", ".ico",
  ".woff", ".woff2", ".ttf", ".otf",
  ".html", ".xml", ".yml", ".yaml",
]);

const MAX_FILES = 6000;
const MAX_TOTAL = 120 * 1024 * 1024; // 120 MB unpacked
const MAX_ONE = 12 * 1024 * 1024;

/** Is this path one of the customer's? */
function isProtected(rel: string): boolean {
  const norm = rel.split(path.sep).join("/");
  for (const keep of KEEP) {
    if (norm === keep || norm.startsWith(keep + "/")) return true;
  }
  return false;
}

/**
 * An update that has been written but not yet started.
 *
 * Cleared automatically once the running version matches, which is how the
 * app notices the restart happened without anyone telling it.
 */
export function pendingRestart(): { version: string; since: string } | null {
  const state = readInstall();
  const version = state?.pendingVersion;
  if (!version) return null;

  // The restart happened: the running code is the version that was waiting.
  if (version === PRODUCT_VERSION) {
    writeInstall({ ...state, pendingVersion: undefined, pendingSince: undefined });
    return null;
  }

  return { version, since: String(state?.pendingSince ?? "") };
}

/** Records that files for this version are on disk, waiting for a restart. */
export function markPendingRestart(version: string) {
  const state = readInstall();
  if (!state) return;
  writeInstall({ ...state, pendingVersion: version, pendingSince: new Date().toISOString() });
}

export type UpdateInfo = {
  /** The version running now. */
  current: string;
  /** The newest version the licence server knows about, if it said. */
  latest?: string;
  available: boolean;
  /** A version already installed on disk, waiting for a restart. */
  pending?: string;
  /** Set when the licence itself is the problem. */
  error?: string;
  note?: string;
  checkedAt: string;
};

/**
 * Compares two dotted version numbers.
 *
 * Field by field and numerically, so 1.0.10 is correctly newer than 1.0.9 —
 * a string comparison gets that backwards, and would stop offering updates
 * at exactly the point a project has shipped ten of them.
 */
export function compareVersions(a: string, b: string): number {
  const parse = (v: string) =>
    String(v).split(".").map((n) => Number.parseInt(n, 10) || 0);

  const left = parse(a);
  const right = parse(b);
  const len = Math.max(left.length, right.length);

  for (let i = 0; i < len; i += 1) {
    const diff = (left[i] ?? 0) - (right[i] ?? 0);
    if (diff !== 0) return diff > 0 ? 1 : -1;
  }
  return 0;
}

/** The activation this site was installed with. */
function ownActivation(): Activation | null {
  const state = readInstall();
  if (!state?.license || !state.token) return null;

  return {
    license: state.license,
    token: state.token,
    product: PRODUCT_SLUG,
    version: PRODUCT_VERSION,
    domain: "",
    activatedAt: state.installedAt ?? "",
  };
}

/**
 * Asks the licence server whether there is a newer version.
 *
 * Fails soft: if the server can't be reached we report "no update", not
 * an error the owner has to act on. A missed update is a nuisance; a
 * scary red banner because a network blipped is worse.
 */
export async function checkForUpdate(): Promise<UpdateInfo> {
  const now = new Date().toISOString();

  // Already downloaded and waiting to be started? Then there is nothing to
  // offer, however out of date the running process looks. Offering it again
  // would just download the same files over the top.
  const waiting = pendingRestart();
  if (waiting) {
    return {
      current: PRODUCT_VERSION,
      latest: waiting.version,
      available: false,
      pending: waiting.version,
      checkedAt: now,
    };
  }

  const activation = ownActivation();

  if (!activation) {
    return {
      current: PRODUCT_VERSION,
      available: false,
      error: "This copy has no licence key on file, so it can't check for updates.",
      checkedAt: now,
    };
  }

  const result = await check(activation);

  if (result.valid === false) {
    return {
      current: PRODUCT_VERSION,
      available: false,
      error: result.note ?? "This licence is no longer valid.",
      checkedAt: now,
    };
  }

  const latest = result.latestVersion;

  // Compare versions ourselves whenever the server tells us one. Its own
  // update_available flag is computed without knowing what this copy is
  // running, so trusting it leaves the button lit after a successful
  // update — offering people the version they already have.
  const available = latest
    ? compareVersions(latest, PRODUCT_VERSION) > 0
    : Boolean(result.updateAvailable);

  return {
    current: PRODUCT_VERSION,
    latest,
    available,
    note: result.note,
    checkedAt: now,
  };
}

/**
 * Downloads the core package.
 *
 * Same endpoint as themes — the server matches the token to an activation
 * and decides. A copy without a key has nothing to fetch.
 */
export async function fetchCoreZip(
  onProgress?: (received: number, total: number) => void
): Promise<{ ok: true; zip: Buffer } | { ok: false; error: string }> {
  const activation = ownActivation();
  if (!activation) {
    return { ok: false, error: "This copy has no licence key on file." };
  }

  const url =
    // Routed path, not download.php. That file has no requires of its own
    // — it relies on the api router to load config and the Database class —
    // so hitting it directly throws and answers with a generic 500.
    `${API_ROOT}/product/download` +
    `?license=${encodeURIComponent(activation.license)}` +
    `&token=${encodeURIComponent(activation.token)}` +
    `&product=${encodeURIComponent(PRODUCT_SLUG)}`;

  try {
    const res = await fetch(url, { cache: "no-store" });

    // The endpoint answers with JSON when it refuses, and the file when
    // it agrees.
    const type = res.headers.get("content-type") ?? "";
    if (!res.ok || type.includes("json")) {
      const data = await res.json().catch(() => null);
      return {
        ok: false,
        error: String(data?.error ?? "That key isn't valid for this product."),
      };
    }

    const total = Number(res.headers.get("content-length") ?? 0);

    // Read in chunks so the admin page can show a real percentage rather
    // than a spinner that means nothing.
    if (res.body && onProgress) {
      const reader = res.body.getReader();
      const chunks: Uint8Array[] = [];
      let received = 0;

      for (;;) {
        const { done, value } = await reader.read();
        if (done) break;
        if (value) {
          chunks.push(value);
          received += value.length;
          onProgress(received, total);
        }
      }

      const buf = Buffer.concat(chunks.map((c) => Buffer.from(c)));
      return verifyZip(buf);
    }

    return verifyZip(Buffer.from(await res.arrayBuffer()));
  } catch {
    return {
      ok: false,
      error: "Couldn't reach the update server. Check the site can get out to the internet.",
    };
  }
}

function verifyZip(buf: Buffer): { ok: true; zip: Buffer } | { ok: false; error: string } {
  // A zip starts "PK". Anything else is an error page in disguise.
  if (buf.length < 4 || buf[0] !== 0x50 || buf[1] !== 0x4b) {
    return { ok: false, error: "The download wasn't an update package." };
  }
  return { ok: true, zip: buf };
}

export type StagedUpdate = {
  version: string;
  files: { rel: string; data: Buffer }[];
  totalBytes: number;
};

/**
 * Reads the package and works out exactly what would change.
 *
 * Nothing is written here. The point is to fail on a bad package before a
 * single file on the live site has been touched.
 */
export function stageUpdate(zip: Buffer): StagedUpdate {
  const archive = new AdmZip(zip);
  const entries = archive.getEntries().filter((e) => !e.isDirectory);

  if (!entries.length) throw new Error("That update package is empty.");
  if (entries.length > MAX_FILES) throw new Error("That update package has too many files.");

  // A zip made from a folder has everything under one directory. Strip it,
  // so both shapes install the same way.
  const first = entries[0].entryName.split("/")[0];
  const nested =
    first !== "" && entries.every((e) => e.entryName.startsWith(first + "/"));

  const files: { rel: string; data: Buffer }[] = [];
  let totalBytes = 0;

  for (const entry of entries) {
    // The raw name, before the zip library tidies it. "../x" arrives here
    // already rewritten to "x", so checking only the tidied name would
    // silently install a file the package meant to put outside the site.
    const raw = entry.rawEntryName?.toString("utf8") ?? entry.entryName;
    if (raw.includes("..") || raw.startsWith("/") || /^[a-zA-Z]:/.test(raw)) continue;

    const rel = nested ? entry.entryName.slice(first.length + 1) : entry.entryName;

    // Nothing absolute, nothing climbing out, nothing hidden.
    if (!rel || rel.startsWith("/") || rel.includes("..")) continue;
    if (/(^|\/)\.[^/]/.test(rel) && !rel.startsWith(".well-known/")) continue;
    if (!ALLOWED.has(path.extname(rel).toLowerCase())) continue;

    // The keep-list wins over the package, always.
    if (isProtected(rel)) continue;

    const data = entry.getData();
    if (data.length > MAX_ONE) throw new Error(`${rel} is too large for an update.`);

    totalBytes += data.length;
    if (totalBytes > MAX_TOTAL) throw new Error("That update package is too large.");

    files.push({ rel, data });
  }

  if (!files.length) throw new Error("That package had nothing to install.");

  // package.json tells us the version, and its absence tells us this
  // isn't a core package at all.
  const pkgEntry = files.find((f) => f.rel === "package.json");
  if (!pkgEntry) {
    throw new Error("That package has no package.json, so it isn't a script update.");
  }

  let version = "";
  try {
    version = String(JSON.parse(pkgEntry.data.toString("utf8")).version ?? "");
  } catch {
    throw new Error("That package's package.json couldn't be read.");
  }
  if (!version) throw new Error("That package doesn't say what version it is.");

  return { version, files, totalBytes };
}

/**
 * Copies out every file the update would overwrite.
 *
 * Only files that already exist are copied — a brand new file has nothing
 * to restore, and rollback removes it instead. The manifest records which
 * were new so the rollback knows the difference.
 */
export async function backup(
  staged: StagedUpdate,
  onProgress?: (done: number, total: number) => void
): Promise<{ dir: string; saved: number; added: string[] }> {
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
  const dir = path.join(BACKUP_DIR, `${PRODUCT_VERSION}-to-${staged.version}-${stamp}`);

  await fs.mkdir(dir, { recursive: true });

  const added: string[] = [];
  let saved = 0;
  let done = 0;

  for (const file of staged.files) {
    const live = path.join(ROOT, file.rel);

    try {
      const data = await fs.readFile(live);
      const dest = path.join(dir, "files", file.rel);
      await fs.mkdir(path.dirname(dest), { recursive: true });
      await fs.writeFile(dest, data);
      saved += 1;
    } catch {
      // Didn't exist. Nothing to restore; rollback deletes it.
      added.push(file.rel);
    }

    done += 1;
    onProgress?.(done, staged.files.length);
  }

  await fs.writeFile(
    path.join(dir, "manifest.json"),
    JSON.stringify(
      {
        from: PRODUCT_VERSION,
        to: staged.version,
        at: new Date().toISOString(),
        restored: staged.files.length - added.length,
        added,
      },
      null,
      2
    )
  );

  return { dir, saved, added };
}

/**
 * Writes the new files in.
 *
 * If anything goes wrong part-way, the caller rolls back — which is why
 * the backup is taken first and why this reports where it stopped.
 */
export async function applyUpdate(
  staged: StagedUpdate,
  onProgress?: (done: number, total: number) => void
): Promise<{ written: number }> {
  let written = 0;

  for (const file of staged.files) {
    const dest = path.join(ROOT, file.rel);

    // Belt and braces: the resolved path must still be inside the project,
    // and must still not be one of the customer's.
    if (!dest.startsWith(ROOT + path.sep)) continue;
    if (isProtected(path.relative(ROOT, dest))) continue;

    await fs.mkdir(path.dirname(dest), { recursive: true });
    await fs.writeFile(dest, file.data);

    written += 1;
    onProgress?.(written, staged.files.length);
  }

  return { written };
}

/**
 * Puts the site back exactly as it was.
 *
 * Files that existed are restored from the backup; files the update added
 * are removed. Errors are swallowed on purpose — a rollback that stops
 * half way is worse than one that does everything it can.
 */
export async function rollback(dir: string): Promise<{ restored: number; removed: number }> {
  let restored = 0;
  let removed = 0;

  const manifest = JSON.parse(
    await fs.readFile(path.join(dir, "manifest.json"), "utf8")
  ) as { added?: string[] };

  const filesDir = path.join(dir, "files");

  const walk = async (current: string) => {
    let entries;
    try {
      entries = await fs.readdir(current, { withFileTypes: true });
    } catch {
      return;
    }

    for (const entry of entries) {
      const full = path.join(current, entry.name);
      if (entry.isDirectory()) {
        await walk(full);
        continue;
      }

      const rel = path.relative(filesDir, full);
      if (isProtected(rel)) continue;

      try {
        const dest = path.join(ROOT, rel);
        await fs.mkdir(path.dirname(dest), { recursive: true });
        await fs.copyFile(full, dest);
        restored += 1;
      } catch {
        /* keep going */
      }
    }
  };

  await walk(filesDir);

  for (const rel of manifest.added ?? []) {
    if (isProtected(rel)) continue;
    try {
      await fs.rm(path.join(ROOT, rel), { force: true });
      removed += 1;
    } catch {
      /* keep going */
    }
  }

  return { restored, removed };
}

/**
 * Deletes one rollback point.
 *
 * Only a folder that is actually a backup: the id is matched against the
 * listing rather than trusted, so a crafted name can never reach outside
 * the backups folder however it is spelled.
 */
export async function deleteBackup(id: string): Promise<boolean> {
  const known = await listBackups();
  if (!known.some((b) => b.id === id)) return false;

  const dir = path.join(BACKUP_DIR, id);
  if (!dir.startsWith(BACKUP_DIR + path.sep)) return false;

  await fs.rm(dir, { recursive: true, force: true });
  return true;
}

/** The rollback points on disk, newest first. */
export async function listBackups(): Promise<
  { id: string; from: string; to: string; at: string; files: number }[]
> {
  let dirs: string[];
  try {
    dirs = await fs.readdir(BACKUP_DIR);
  } catch {
    return [];
  }

  const out: { id: string; from: string; to: string; at: string; files: number }[] = [];

  for (const id of dirs) {
    try {
      const manifest = JSON.parse(
        await fs.readFile(path.join(BACKUP_DIR, id, "manifest.json"), "utf8")
      ) as { from?: string; to?: string; at?: string; restored?: number };

      out.push({
        id,
        from: String(manifest.from ?? "?"),
        to: String(manifest.to ?? "?"),
        at: String(manifest.at ?? ""),
        files: Number(manifest.restored ?? 0),
      });
    } catch {
      /* not a backup folder */
    }
  }

  return out.sort((a, b) => b.at.localeCompare(a.at));
}
