import { promises as fs } from "node:fs";
import path from "node:path";

/* Reading a folder at runtime makes the build trace the whole project,
   which is slow and warns loudly. Only the shop's install path needs it,
   and it's a server route, so the warning is noise rather than a fault. */

/**
 * Installed themes.
 *
 * A theme is data, not code: tokens and an optional stylesheet. That's the
 * whole reason one-click install is possible — Next compiles, so anything
 * with real components in it would need a rebuild on the customer's
 * server, and on shared hosting that frequently fails.
 */

/** Where installed themes live. Outside the build, so nothing recompiles. */
export const THEMES_DIR = path.join(process.cwd(), "data", "themes");

export type ThemeManifest = {
  slug: string;
  name: string;
  version: string;
  author?: string;
  description?: string;
  /** Which versions of the script it was built against. */
  requires?: string;
  /** Its own licence key, so an update can be fetched later. */
  license?: string;
  installedAt?: string;
};

export type ThemeTokens = Record<string, string>;

export type InstalledTheme = ManifestWithExtras;

type ManifestWithExtras = ThemeManifest & {
  tokens: ThemeTokens;
  /** Anything tokens can't reach. Absent for most themes. */
  css?: string;
  /** A data URL or a path under /media. */
  preview?: string;
};

/** Every theme installed, newest first. */
export async function installedThemes(): Promise<InstalledTheme[]> {
  try {
    const dirs = await fs.readdir(THEMES_DIR, { withFileTypes: true });

    const themes = await Promise.all(
      dirs
        .filter((d) => d.isDirectory())
        .map(async (d) => {
          try {
            return await readTheme(d.name);
          } catch {
            // A half-written folder shouldn't stop the rest from loading.
            return null;
          }
        })
    );

    return themes
      .filter((t): t is InstalledTheme => Boolean(t))
      .sort((a, b) => (b.installedAt ?? "").localeCompare(a.installedAt ?? ""));
  } catch {
    // No themes folder yet: the built-in theme is the only one.
    return [];
  }
}

/** One theme, read from its folder. */
export async function readTheme(slug: string): Promise<InstalledTheme> {
  const dir = path.join(THEMES_DIR, safeSlug(slug));

  const manifest = JSON.parse(
    await fs.readFile(path.join(dir, "theme.json"), "utf8")
  ) as ThemeManifest;

  const tokens = JSON.parse(
    await fs.readFile(path.join(dir, "tokens.json"), "utf8")
  ) as ThemeTokens;

  // Both optional.
  const css = await fs
    .readFile(path.join(dir, "theme.css"), "utf8")
    .catch(() => undefined);

  const preview = await fs
    .readFile(path.join(dir, "preview.txt"), "utf8")
    .catch(() => undefined);

  return { ...manifest, slug: safeSlug(slug), tokens, css, preview };
}

/**
 * Writes a theme from a downloaded package.
 *
 * The package is JSON rather than a zip: unpacking a zip means trusting
 * paths inside it, and a theme has no reason to write anywhere but its
 * own folder.
 */
export async function installTheme(pkg: {
  manifest: ThemeManifest;
  tokens: ThemeTokens;
  css?: string;
  preview?: string;
  license?: string;
}): Promise<string> {
  const slug = safeSlug(pkg.manifest.slug);
  const dir = path.join(THEMES_DIR, slug);

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

  await fs.writeFile(
    path.join(dir, "theme.json"),
    JSON.stringify(
      {
        ...pkg.manifest,
        slug,
        license: pkg.license ?? pkg.manifest.license,
        installedAt: new Date().toISOString(),
      },
      null,
      2
    )
  );

  await fs.writeFile(
    path.join(dir, "tokens.json"),
    JSON.stringify(pkg.tokens, null, 2)
  );

  // Stripped of anything that could reach outside a stylesheet.
  if (pkg.css) {
    await fs.writeFile(path.join(dir, "theme.css"), sanitiseCss(pkg.css));
  }

  if (pkg.preview) {
    await fs.writeFile(path.join(dir, "preview.txt"), pkg.preview);
  }

  return slug;
}

/** Removes a theme and everything in its folder. */
export async function removeTheme(slug: string): Promise<void> {
  await fs.rm(path.join(THEMES_DIR, safeSlug(slug)), {
    recursive: true,
    force: true,
  });
}

/**
 * A slug that can only name a folder.
 *
 * Without this, a theme called "../../lib" would write over the site.
 */
function safeSlug(slug: string): string {
  const clean = String(slug)
    .toLowerCase()
    .replace(/[^a-z0-9-]/g, "")
    .slice(0, 60);

  if (!clean) throw new Error("A theme needs a name");
  return clean;
}

/**
 * Strips what a stylesheet has no business doing.
 *
 * A theme is bought from a shop and written by someone else. It can change
 * how the site looks; it shouldn't be able to load scripts, phone home, or
 * read what someone types.
 */
export function sanitiseCss(css: string): string {
  return (
    css
      // No fetching anything from anywhere: a background-image URL is a
      // record of who visited, sent to whoever wrote the theme.
      .replace(/@import[^;]*;/gi, "")
      .replace(/url\((?!['"]?(?:data:|\/))[^)]*\)/gi, "none")
      // Old IE could run scripts from CSS. Some browsers still parse it.
      .replace(/expression\s*\(/gi, "(")
      .replace(/javascript:/gi, "")
      // Nothing outside its own layer.
      .replace(/<\/?\w+/g, "")
      .slice(0, 400_000)
  );
}

/** The tokens as CSS, for the page to apply. */
export function tokensToCss(tokens: ThemeTokens): string {
  const safe = Object.entries(tokens)
    .filter(([k]) => /^--[a-z0-9-]+$/i.test(k))
    // A value with a brace or a semicolon could close the rule and open
    // something else.
    .filter(([, v]) => !/[{};<>]/.test(String(v)))
    .map(([k, v]) => `  ${k}: ${v};`)
    .join("\n");

  return `:root {\n${safe}\n}`;
}
