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

import { API_ROOT } from "./license";

/**
 * Code themes.
 *
 * A token theme is colours and a stylesheet, and installs as data. A code
 * theme is components — a Shell, rails, whole pages — and has to land in
 * `themes/<slug>/` where Next can compile it. That means unpacking a zip,
 * which means trusting nothing inside it: every path is checked, every
 * name is checked, and the whole thing is written to a staging folder
 * first so a half-downloaded theme never replaces a working one.
 */

/** Where compiled-in themes live. */
export const CODE_THEMES_DIR = path.join(process.cwd(), "themes");

/** What a theme is allowed to contain. Anything else is dropped. */
const ALLOWED = new Set([
  ".ts", ".tsx", ".js", ".jsx", ".mjs",
  ".css", ".json", ".md", ".txt",
  ".svg", ".png", ".jpg", ".jpeg", ".webp", ".gif",
  ".woff", ".woff2", ".ttf",
]);

/** Limits. A theme that needs more than this is not a theme. */
const MAX_FILES = 400;
const MAX_TOTAL = 12 * 1024 * 1024; // 12 MB unpacked
const MAX_ONE = 4 * 1024 * 1024;

export type CodeThemePackage = {
  slug: string;
  name: string;
  version: string;
  files: number;
};

/** Only ever a folder name. */
function safeSlug(slug: string): string {
  const clean = slug.toLowerCase().replace(/[^a-z0-9-]/g, "");
  if (!clean) throw new Error("That theme has no usable name.");
  return clean;
}

/**
 * Fetches the theme package for a licence.
 *
 * One request, and the server decides: it matches the token against an
 * active activation, checks the licence is active and is for this
 * product, and only then streams the file. A copy without a key has
 * nothing to fetch.
 */
export async function fetchThemeZip(
  license: string,
  token: string
): Promise<{ ok: true; zip: Buffer } | { ok: false; error: string }> {
  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(license)}&token=${encodeURIComponent(token)}`;

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

    // The endpoint answers with JSON when it refuses, and with 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 theme"),
      };
    }

    const buf = Buffer.from(await res.arrayBuffer());

    // 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 a theme package." };
    }

    return { ok: true, zip: buf };
  } catch {
    return {
      ok: false,
      error: "Couldn't reach the shop. Check the site can get out to the internet.",
    };
  }
}

/**
 * Unpacks a theme into `themes/<slug>/`.
 *
 * Written to `<slug>.incoming` and renamed at the end, so an interrupted
 * download leaves the theme that's already installed alone.
 */
export async function installCodeTheme(
  zip: Buffer,
  expectedSlug?: string
): Promise<CodeThemePackage> {
  const archive = new AdmZip(zip);
  const entries = archive.getEntries().filter((e) => !e.isDirectory);

  if (!entries.length) throw new Error("That theme package is empty.");
  if (entries.length > MAX_FILES) throw new Error("That theme 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 =
    entries.every((e) => e.entryName.startsWith(first + "/")) && first !== "";

  type Ready = { rel: string; data: Buffer };
  const ready: Ready[] = [];
  let total = 0;

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

    // Nothing absolute, nothing climbing out, nothing hidden.
    if (!rel || rel.startsWith("/") || rel.includes("..") || /(^|\/)\./.test(rel)) {
      continue;
    }
    if (!ALLOWED.has(path.extname(rel).toLowerCase())) continue;

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

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

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

  const manifestEntry = ready.find((f) => f.rel === "theme.json");
  if (!manifestEntry) throw new Error("That package has no theme.json, so it isn't a theme.");

  const manifest = JSON.parse(manifestEntry.data.toString("utf8")) as {
    slug?: string;
    name?: string;
    version?: string;
  };

  const slug = safeSlug(manifest.slug ?? expectedSlug ?? "");
  if (expectedSlug && slug !== safeSlug(expectedSlug)) {
    throw new Error("That package is for a different theme.");
  }
  if (!ready.some((f) => f.rel === "index.ts" || f.rel === "index.tsx")) {
    throw new Error("That theme has no index file, so nothing would load.");
  }

  const dir = path.join(CODE_THEMES_DIR, slug);
  const staging = `${dir}.incoming`;

  await fs.rm(staging, { recursive: true, force: true });
  await fs.mkdir(staging, { recursive: true });

  for (const file of ready) {
    const dest = path.join(staging, file.rel);

    // Belt and braces: the resolved path must still be inside staging.
    if (!dest.startsWith(staging + path.sep)) continue;

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

  // Swap it in. The old copy is kept until the new one is in place.
  const retired = `${dir}.old-${Date.now()}`;
  const had = await fs
    .stat(dir)
    .then(() => true)
    .catch(() => false);

  if (had) await fs.rename(dir, retired);
  await fs.rename(staging, dir);
  if (had) await fs.rm(retired, { recursive: true, force: true });

  return {
    slug,
    name: String(manifest.name ?? slug),
    version: String(manifest.version ?? "1.0.0"),
    files: ready.length,
  };
}
