import fs from "node:fs/promises";
import path from "node:path";
import type { IncomingMessage, ServerResponse } from "node:http";
import type { Connect } from "vite";
import type { Plugin, ResolvedConfig } from "vite";

/** Mode effectif de la construction Vite (sert à choisir la politique CSP). */
export type CspMode = "development" | "production";

/** Options publiques du plugin. */
export type CspPluginOptions = Readonly<{
  /**
   * Origine Matomo à autoriser (sans slash final). `null` = aucune origine externe
   * (Matomo non configuré via `VITE_MATOMO_URL`).
   */
  readonly matomoOrigin: string | null;
}>;

/** Une règle CSP (directive + sources). */
export type CspDirective = Readonly<{
  readonly name: string;
  readonly sources: readonly string[];
}>;

/** Helpers exportés (testés unitairement) : construction de la chaîne CSP. */

/**
 * Source `'self'` et tokens spéciaux. Les sources `none`, `self`, `unsafe-inline`,
 * `unsafe-eval`, `strict-dynamic` etc. doivent être quotés **uniquement** quand
 * ils sont entre quotes, ce qui est notre cas.
 */
const sourceNeedsQuoting = true;

const quote = (s: string): string => (sourceNeedsQuoting ? `'${s}'` : s);

/** Joint deux sources CSP avec un espace. */
const joinSources = (sources: readonly string[]): string => sources.map(quote).join(" ");

/**
 * Sérialise une liste de directives CSP en une chaîne `Content-Security-Policy`.
 * Chaque directive devient `name source1 source2 …` séparée par `; `.
 */
export const serializeCsp = (directives: readonly CspDirective[]): string =>
  directives.map((d) => `${d.name} ${joinSources(d.sources)}`).join("; ");

/** Enlève un slash final d'une origine. */
const stripTrailingSlash = (s: string): string => s.replace(/\/+$/, "");

/** Normalise une URL en origine (`https://host[:port]`). Renvoie `null` si invalide. */
export const toOrigin = (raw: string | null | undefined): string | null => {
  if (typeof raw !== "string") return null;
  const trimmed = raw.trim();
  if (!trimmed) return null;
  try {
    const u = new URL(trimmed);
    if (u.protocol !== "http:" && u.protocol !== "https:") return null;
    return stripTrailingSlash(`${u.protocol}//${u.host}`);
  } catch {
    return null;
  }
};

/**
 * Construit la liste de directives CSP pour le mode donné.
 * Sources autorisées :
 * - `'self'` partout
 * - Origine Matomo (si fournie) sur `script-src`, `connect-src`, `img-src`
 * - Dev uniquement : `'unsafe-inline'`, `'unsafe-eval'` (script & style), `ws:`
 *   / `wss:` (connect-src pour HMR Vite)
 */
export const buildCspDirectives = (
  mode: CspMode,
  matomoOrigin: string | null
): readonly CspDirective[] => {
  const matomo = matomoOrigin ? [matomoOrigin] : [];

  const scriptSources: string[] = ["self"];
  const styleSources: string[] = ["self"];
  const connectSources: string[] = ["self"];

  if (mode === "development") {
    scriptSources.push("unsafe-inline", "unsafe-eval");
    connectSources.push("ws:", "wss:");
  }

  // `'unsafe-inline'` sur `style-src` est **toujours** requis : Solid pose des styles
  // inline (`style={{...}}`) sur des nœuds (cf. `apps/web/src/ui/SpriteTile.tsx`).
  // L'attribut `style="..."` est restreint par `style-src-attr` (fallback `style-src`).
  styleSources.push("unsafe-inline");

  if (matomo.length) {
    scriptSources.push(...matomo);
    connectSources.push(...matomo);
  }

  return [
    { name: "default-src", sources: ["self"] },
    { name: "script-src", sources: scriptSources },
    { name: "style-src", sources: styleSources },
    { name: "img-src", sources: ["self", ...matomo] },
    { name: "font-src", sources: ["self"] },
    { name: "connect-src", sources: connectSources },
    { name: "media-src", sources: ["self"] },
    { name: "object-src", sources: ["none"] },
    { name: "base-uri", sources: ["self"] },
    { name: "form-action", sources: ["self"] },
    { name: "frame-ancestors", sources: ["none"] },
    { name: "frame-src", sources: ["none"] }
  ];
};

/** Construit la chaîne `Content-Security-Policy` pour le mode donné. */
export const buildCspHeader = (mode: CspMode, matomoOrigin: string | null): string =>
  serializeCsp(buildCspDirectives(mode, matomoOrigin));

/** Format d'en-têtes pour `serve.json` (cf. https://github.com/vercel/serve#configuration). */
export type ServeJsonHeader = Readonly<{ key: string; value: string }>;
export type ServeJsonRule = Readonly<{ source: string; headers: readonly ServeJsonHeader[] }>;
export type ServeJson = Readonly<{ headers: readonly ServeJsonRule[] }>;

/** En-têtes statiques (indépendants de la CSP). */
const STATIC_SECURITY_HEADERS: ReadonlyArray<{ key: string; value: string }> = [
  { key: "X-Content-Type-Options", value: "nosniff" },
  { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
  { key: "X-Frame-Options", value: "DENY" },
  {
    key: "Permissions-Policy",
    // Aucune fonctionnalité avancée nécessaire sur la V1 (app statique, pas de WebRTC, pas de paiement).
    value: "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
  }
];

/**
 * Construit l'objet `serve.json` qui sera écrit dans `dist/` (lu par `serve` au runtime).
 * Les en-têtes sont scopés sur le glob HTML pour la CSP, et sur un glob catch-all pour le
 * reste (ne pas « fuir » la CSP sur des assets qui n'en ont pas besoin ; l'en-tête reste
 * valide sur tout contenu textuel, mais limiter la surface évite les bugs).
 */
export const buildServeJson = (matomoOrigin: string | null): ServeJson => ({
  headers: [
    {
      source: "**/*.html",
      headers: [
        { key: "Content-Security-Policy", value: buildCspHeader("production", matomoOrigin) },
        ...STATIC_SECURITY_HEADERS
      ]
    },
    {
      source: "**/*",
      headers: STATIC_SECURITY_HEADERS
    }
  ]
});

/** Injecte la balise `<meta http-equiv="Content-Security-Policy">` dans un HTML. */
export const injectCspMetaIntoHtml = (html: string, csp: string): string => {
  const metaTag = `<meta http-equiv="Content-Security-Policy" content="${csp}">`;
  // Stratégie : insérer juste après la première balise d'en-tête de charset.
  // Si absente, on insère juste après `<head>`.
  const charsetMatch = /<meta\s+charset="[^"]*"\s*\/?>/i.exec(html);
  if (charsetMatch) {
    const insertAt = charsetMatch.index + charsetMatch[0].length;
    return `${html.slice(0, insertAt)}\n    ${metaTag}${html.slice(insertAt)}`;
  }
  const headMatch = /<head[^>]*>/i.exec(html);
  if (headMatch) {
    const insertAt = headMatch.index + headMatch[0].length;
    return `${html.slice(0, insertAt)}\n    ${metaTag}${html.slice(insertAt)}`;
  }
  return `    ${metaTag}\n${html}`;
};

/** Vrai si la requête vise un document HTML. */
const isHtmlRequest = (req: IncomingMessage): boolean => {
  const accept = String(req.headers.accept ?? "");
  // `fetch` côté JS : pas un document ; `curl` sans Accept : on autorise pour le smoke test.
  return accept.includes("text/html") || accept.includes("*/*") || accept === "";
};

/** Vrai si la requête vise une page recette (HTML pré-rendu `/r/...`). */
const isRecipeSharePath = (url: string | undefined): boolean => {
  if (!url) return false;
  try {
    const pathname = new URL(url, "http://localhost").pathname.replace(/\/$/, "");
    return /^\/r\/[^/]+\/[^/]+$/.test(pathname);
  } catch {
    return false;
  }
};

/** Middleware Vite : pose les en-têtes de sécurité sur les réponses HTML du dev server. */
const makeSecurityHeadersMiddleware = (
  cspValue: string
): Connect.NextHandleFunction => {
  return function securityHeadersMiddleware(
    req: IncomingMessage,
    res: ServerResponse,
    next: Connect.NextFunction
  ): void {
    if (!isHtmlRequest(req) && !isRecipeSharePath(req.url)) {
      next();
      return;
    }
    res.setHeader("Content-Security-Policy", cspValue);
    for (const h of STATIC_SECURITY_HEADERS) {
      res.setHeader(h.key, h.value);
    }
    next();
  };
};

/**
 * Plugin Vite qui injecte la CSP (en meta) dans le HTML de l'app
 * et émet les en-têtes de sécurité en dev + un `dist/serve.json` en prod.
 */
export function cspPlugin(options: CspPluginOptions): Plugin {
  let config: ResolvedConfig;
  const matomoOrigin = options.matomoOrigin;

  return {
    name: "ben-to-csp",
    enforce: "pre",
    configResolved(resolved) {
      config = resolved;
    },
    transformIndexHtml(html) {
      const mode: CspMode = config.command === "build" ? "production" : "development";
      const csp = buildCspHeader(mode, matomoOrigin);
      return injectCspMetaIntoHtml(html, csp);
    },
    configureServer(server) {
      const csp = buildCspHeader("development", matomoOrigin);
      // Place ce middleware **avant** celui de `recipeSeoPlugin` (enregistré après) pour
      // que `res.setHeader` soit effectif avant que `recipeSeoPlugin` n'écrive la réponse.
      server.middlewares.use(makeSecurityHeadersMiddleware(csp));
    },
    async closeBundle() {
      if (config.command !== "build") return;
      const outDir = path.resolve(config.root, config.build.outDir);
      const serveJsonPath = path.join(outDir, "serve.json");
      const payload = buildServeJson(matomoOrigin);
      await fs.writeFile(serveJsonPath, JSON.stringify(payload, null, 2) + "\n", "utf8");
      config.logger.info(
        `csp: dist/serve.json écrit (${payload.headers.length} règle(s), matomo=${matomoOrigin ?? "désactivé"
        })`
      );
    }
  };
}
