"use server";

import { promises as fs } from "node:fs";
import path from "node:path";
import Anthropic from "@anthropic-ai/sdk";
import { revalidatePath } from "next/cache";
import { BRAND_NAME, BRAND_DOMAIN } from "@/lib/brand";
import {
  loadRealisations,
  saveRealisations,
} from "@/lib/realisations-server";
import type {
  Highlight,
  LighthouseScores,
  PageSpeedData,
  ProjectType,
  Realisation,
} from "@/lib/realisations";

// Les captures d'écran sont stockées hors de `public/` car Next.js prod
// fige le manifest statique au build (tout fichier ajouté après → 404).
// On les sert via le route handler `app/captures/[filename]/route.ts`.
const CAPTURES_DIR = path.join(process.cwd(), "data", "captures");
const PUBLIC_PREFIX = "/captures";

export type AnalyseResult =
  | {
      ok: true;
      data: Partial<Realisation> & { domain: string; url: string };
    }
  | { ok: false; error: string };

export async function analyseSite(rawUrl: string): Promise<AnalyseResult> {
  const url = normaliseUrl(rawUrl);
  if (!url) return { ok: false, error: "URL invalide" };

  const slug = slugifyHostname(url.hostname);
  const filenameBase = slug;

  const [meta, desktopOk, mobileOk] = await Promise.all([
    scrapeMeta(url.toString()),
    captureScreenshot(url.toString(), 1440, 900, path.join(CAPTURES_DIR, `${filenameBase}-desktop.jpg`)),
    captureScreenshot(url.toString(), 390, 844, path.join(CAPTURES_DIR, `${filenameBase}-mobile.jpg`)),
  ]);

  return {
    ok: true,
    data: {
      slug,
      domain: url.hostname,
      url: url.toString(),
      name: meta.title ?? url.hostname,
      summary: meta.description ?? "",
      year: new Date().getFullYear(),
      desktopScreenshot: desktopOk ? `/captures/${filenameBase}-desktop.jpg` : undefined,
      mobileScreenshot: mobileOk ? `/captures/${filenameBase}-mobile.jpg` : undefined,
    },
  };
}

export async function uploadScreenshot(
  formData: FormData,
): Promise<{ ok: true; path: string } | { ok: false; error: string }> {
  const slug = String(formData.get("slug") ?? "").trim();
  const variant = String(formData.get("variant") ?? "");
  const file = formData.get("file");

  if (!slug || !/^[a-z0-9-]+$/.test(slug)) {
    return { ok: false, error: "Slug requis avant l'upload" };
  }
  if (variant !== "desktop" && variant !== "mobile") {
    return { ok: false, error: "Variante invalide" };
  }
  if (!(file instanceof File)) {
    return { ok: false, error: "Fichier manquant" };
  }
  if (!file.type.startsWith("image/")) {
    return { ok: false, error: "Le fichier doit être une image" };
  }
  if (file.size > 10 * 1024 * 1024) {
    return { ok: false, error: "Image trop lourde (10 MB max)" };
  }

  const ext = pickExtension(file.type, file.name);
  if (!ext) return { ok: false, error: "Format non supporté (jpg, png, webp)" };

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

  // Remove any previous file with a different extension to keep things clean
  const others = ["jpg", "png", "webp"].filter((e) => e !== ext);
  await Promise.allSettled(
    others.map((e) => fs.unlink(path.join(CAPTURES_DIR, `${slug}-${variant}.${e}`))),
  );

  const filename = `${slug}-${variant}.${ext}`;
  const dest = path.join(CAPTURES_DIR, filename);
  const buf = Buffer.from(await file.arrayBuffer());
  await fs.writeFile(dest, buf);

  return { ok: true, path: `/captures/${filename}?v=${Date.now()}` };
}

export type PageSpeedResult =
  | { ok: true; data: PageSpeedData }
  | { ok: false; error: string };

export async function fetchPageSpeed(rawUrl: string): Promise<PageSpeedResult> {
  const url = normaliseUrl(rawUrl);
  if (!url) return { ok: false, error: "URL invalide" };

  const [mobile, desktop] = await Promise.all([
    fetchLighthouse(url.toString(), "mobile"),
    fetchLighthouse(url.toString(), "desktop"),
  ]);

  const okScores = [mobile, desktop].filter(
    (r): r is LighthouseScores => r !== null && typeof r !== "string",
  );

  if (okScores.length === 0) {
    // On retourne le code d'erreur de la stratégie qui a échoué le plus
    // explicitement (429 prioritaire) pour donner un message actionnable.
    const errorCode =
      (typeof mobile === "string" ? mobile : null) ??
      (typeof desktop === "string" ? desktop : null);
    if (errorCode === "429") {
      return {
        ok: false,
        error: process.env.PAGESPEED_API_KEY
          ? "Quota Google atteint pour cette clé API. Attendez quelques minutes."
          : "Quota Google PageSpeed dépassé sans clé API. Ajoutez PAGESPEED_API_KEY dans .env.local (clé gratuite sur Google Cloud Console) ou attendez 1 à 2 minutes avant de réessayer.",
      };
    }
    return {
      ok: false,
      error:
        "Échec de la mesure PageSpeed (site inaccessible ou Google indisponible). Réessayez ou complétez à la main.",
    };
  }

  return {
    ok: true,
    data: {
      mobile: typeof mobile !== "string" && mobile ? mobile : undefined,
      desktop: typeof desktop !== "string" && desktop ? desktop : undefined,
      fetchedAt: new Date().toISOString(),
    },
  };
}

async function fetchLighthouse(
  url: string,
  strategy: "mobile" | "desktop",
): Promise<LighthouseScores | string | null> {
  // API Google PageSpeed Insights v5, gratuite sans clé (rate limit par IP).
  // Avec une clé (PAGESPEED_API_KEY env), on peut taper plus haut.
  const params = new URLSearchParams({
    url,
    strategy,
  });
  params.append("category", "performance");
  params.append("category", "accessibility");
  params.append("category", "best-practices");
  params.append("category", "seo");
  if (process.env.PAGESPEED_API_KEY) {
    params.set("key", process.env.PAGESPEED_API_KEY);
  }

  const apiUrl = `https://www.googleapis.com/pagespeedonline/v5/runPagespeed?${params}`;

  try {
    const r = await fetch(apiUrl, {
      cache: "no-store",
      // Lighthouse peut tourner 30 à 90s côté Google
      signal: AbortSignal.timeout(120_000),
    });
    if (!r.ok) return String(r.status);
    const json = (await r.json()) as {
      lighthouseResult?: {
        categories?: {
          performance?: { score?: number };
          accessibility?: { score?: number };
          "best-practices"?: { score?: number };
          seo?: { score?: number };
        };
      };
    };
    const c = json?.lighthouseResult?.categories;
    if (!c) return null;
    const toScore = (s: number | undefined): number => {
      if (typeof s !== "number") return 0;
      return Math.max(0, Math.min(100, Math.round(s * 100)));
    };
    return {
      performance: toScore(c.performance?.score),
      accessibility: toScore(c.accessibility?.score),
      bestPractices: toScore(c["best-practices"]?.score),
      seo: toScore(c.seo?.score),
    };
  } catch {
    return null;
  }
}

// ---------------- Génération éditoriale via Claude ----------------

export type GeneratePresentationInput = {
  url: string;
  existing?: {
    name?: string;
    domain?: string;
    sector?: string;
    summary?: string;
  };
};

export type GeneratedPresentation = {
  tagline?: string;
  brief?: string;
  delivered?: string[];
  highlights?: Highlight[];
  sector?: string;
  type?: ProjectType;
  context?: string;
};

export type GenerateResult =
  | { ok: true; data: GeneratedPresentation }
  | { ok: false; error: string };

const PROJECT_TYPE_VALUES: ProjectType[] = [
  "vitrine",
  "saas",
  "ecommerce",
  "outil",
  "plateforme",
];

// On factorise en constante module-level pour bénéficier du prompt caching :
// Claude met en cache ce bloc dès le 1er appel (TTL 5 min), les appels suivants
// ne paient plus que les input tokens variables (le HTML scrappé).
const SYSTEM_PROMPT = `Tu es l'assistant éditorial de ${BRAND_NAME}, une agence web française qui vend des sites sur-mesure haute performance en abonnement mensuel tout-inclus. Tu rédiges la présentation publique d'un projet livré (page Réalisations).

Ton et style à respecter strictement :
- Vouvoiement systématique côté client. Voix sobre, technique mais accessible, pas commerciale bavarde.
- AUCUN tiret cadratin (em-dash "—" ou "–"). Utilise virgule, point, deux-points, ou point médian "·" selon le cas. Cette règle est non négociable, c'est une signature de texte généré par IA qu'on évite à tout prix.
- AUCUN emoji.
- Pas de chiffres précis dans les promesses marketing (pas de SLA en heures, pas de pourcentages inventés). Les chiffres factuels constatés sur le site (nombre de produits, nombre de pages, fonctionnalités présentes) sont OK.
- Tech-agnostique : ne mentionne JAMAIS Next.js, React, Tailwind, Vue, ou tout nom de framework dans le texte public.
- Devises en euros avec espace insécable. Dates au format français.
- Lexique technique tolérable : API, SSL, CDN, CI/CD, ms, Go.

Tu produis exactement les champs demandés via l'outil "save_presentation". Pour chaque champ :

- "tagline" : une seule phrase courte, type accroche éditoriale, mise en italique sur la page. Maximum 130 caractères. C'est la promesse essentielle du projet, pas du marketing creux.
- "brief" : un paragraphe de 2 à 4 phrases qui décrit le contexte du projet et ce qu'il fallait résoudre. Pas la liste des features, ce sera dans "delivered". Penser "ce qu'il fallait résoudre".
- "delivered" : 4 à 5 puces décrivant ce que ${BRAND_NAME} a probablement livré, déduit de ce qui est visible sur le site. Phrases nominales courtes (12 à 18 mots), commencent par un nom commun. Exemples : "Site vitrine multi-pages avec parcours différencié", "Outil de génération de devis", "Module de paiement sécurisé". Reste prudent et factuel : tu déduis depuis le site visible, l'utilisateur corrigera ce qui est faux.
- "highlights" : exactement 3 paires {label, value} avec des faits constatés sur le site. Le label en 1 à 3 mots, la value en 1 à 4 mots ou un nombre. Exemples : {"label":"Modèles couverts","value":"750+"}, {"label":"Hébergement","value":"France"}, {"label":"Langues","value":"FR · EN"}. Aucune promesse, que du factuel.
- "sector" : 1 à 3 mots, le secteur métier du client. Exemples : "Cybersécurité réseau", "Mobilité électrique", "Outils web", "Juridique B2B". À renseigner même si déjà fourni si tu peux affiner.
- "type" : un des 5 mots clés exactement : "vitrine" (site présentation classique), "saas" (produit logiciel en ligne), "ecommerce" (boutique avec panier et paiement), "outil" (utilitaire web grand public ou pro), "plateforme" (place de marché ou réseau B2B avec plusieurs acteurs). Choisis le plus juste.
- "context" : 2 à 5 mots indiquant la portée. Exemples : "France · B2B", "International · grand public", "France · B2B technique".

Rappel critique : pas de cadratin "—" dans aucun champ. Vérifie ton output avant de répondre.`;

const GENERATION_TOOL: Anthropic.Messages.Tool = {
  name: "save_presentation",
  description:
    `Enregistre la présentation éditoriale du projet (tagline, brief, delivered, highlights, sector, type, context) dans le format ${BRAND_NAME}.`,
  input_schema: {
    type: "object",
    properties: {
      tagline: { type: "string", maxLength: 200 },
      brief: { type: "string" },
      delivered: {
        type: "array",
        items: { type: "string" },
        minItems: 3,
        maxItems: 6,
      },
      highlights: {
        type: "array",
        items: {
          type: "object",
          properties: {
            label: { type: "string" },
            value: { type: "string" },
          },
          required: ["label", "value"],
        },
        minItems: 3,
        maxItems: 3,
      },
      sector: { type: "string" },
      type: {
        type: "string",
        enum: PROJECT_TYPE_VALUES,
      },
      context: { type: "string" },
    },
    required: ["tagline", "brief", "delivered", "highlights", "sector", "type", "context"],
  },
};

export async function generatePresentation(
  input: GeneratePresentationInput,
): Promise<GenerateResult> {
  if (!process.env.ANTHROPIC_API_KEY) {
    return {
      ok: false,
      error:
        "Clé API Anthropic manquante. Ajoutez ANTHROPIC_API_KEY dans .env.local (clé sur console.anthropic.com).",
    };
  }

  const url = normaliseUrl(input.url);
  if (!url) return { ok: false, error: "URL invalide" };

  // Re-scrape le HTML pour avoir plus de contexte que la simple meta description
  let bodyText = "";
  try {
    const r = await fetch(url.toString(), {
      headers: {
        "User-Agent":
          `Mozilla/5.0 (compatible; NoveliaBot/1.0; +https://${BRAND_DOMAIN})`,
        Accept: "text/html",
      },
      redirect: "follow",
      cache: "no-store",
      signal: AbortSignal.timeout(15_000),
    });
    if (r.ok) {
      const html = (await r.text()).slice(0, 500_000);
      bodyText = htmlToReadableText(html).slice(0, 12_000);
    }
  } catch {
    // On peut générer sans le scrape si le site est inaccessible
  }

  const userMessage = [
    `URL du projet : ${url.toString()}`,
    input.existing?.name ? `Nom déjà saisi : ${input.existing.name}` : null,
    input.existing?.domain ? `Domaine : ${input.existing.domain}` : null,
    input.existing?.sector ? `Secteur déjà saisi : ${input.existing.sector}` : null,
    input.existing?.summary ? `Résumé déjà saisi : ${input.existing.summary}` : null,
    "",
    "Contenu visible du site (texte extrait du HTML, peut être tronqué) :",
    bodyText || "[contenu indisponible, génère depuis l'URL et tes connaissances générales]",
    "",
    `Génère la présentation ${BRAND_NAME} en appelant l'outil save_presentation.`,
  ]
    .filter(Boolean)
    .join("\n");

  try {
    const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });

    const message = await client.messages.create({
      model: "claude-haiku-4-5-20251001",
      max_tokens: 2048,
      system: [
        {
          type: "text",
          text: SYSTEM_PROMPT,
          cache_control: { type: "ephemeral" },
        },
      ],
      tools: [GENERATION_TOOL],
      tool_choice: { type: "tool", name: "save_presentation" },
      messages: [{ role: "user", content: userMessage }],
    });

    const block = message.content.find((b) => b.type === "tool_use");
    if (!block || block.type !== "tool_use") {
      return { ok: false, error: "Claude n'a pas retourné de structure exploitable." };
    }
    const raw = block.input as Record<string, unknown>;

    const data = sanitiseGeneratedPresentation(raw);
    return { ok: true, data };
  } catch (e) {
    const msg = e instanceof Error ? e.message : "Erreur inconnue";
    return { ok: false, error: `Échec Claude : ${msg}` };
  }
}

function sanitiseGeneratedPresentation(raw: Record<string, unknown>): GeneratedPresentation {
  const stripDash = (s: string) => s.replace(/[—–]/g, ",").replace(/\s{2,}/g, " ").trim();

  const out: GeneratedPresentation = {};

  if (typeof raw.tagline === "string") out.tagline = stripDash(raw.tagline);
  if (typeof raw.brief === "string") out.brief = stripDash(raw.brief);
  if (typeof raw.sector === "string") out.sector = stripDash(raw.sector);
  if (typeof raw.context === "string") out.context = stripDash(raw.context);

  if (typeof raw.type === "string" && PROJECT_TYPE_VALUES.includes(raw.type as ProjectType)) {
    out.type = raw.type as ProjectType;
  }

  if (Array.isArray(raw.delivered)) {
    out.delivered = raw.delivered
      .filter((s): s is string => typeof s === "string")
      .map((s) => stripDash(s))
      .filter(Boolean)
      .slice(0, 6);
  }

  if (Array.isArray(raw.highlights)) {
    out.highlights = raw.highlights
      .filter((h): h is Record<string, unknown> => typeof h === "object" && h !== null)
      .map((h) => ({
        label: typeof h.label === "string" ? stripDash(h.label) : "",
        value: typeof h.value === "string" ? stripDash(h.value) : "",
      }))
      .filter((h) => h.label && h.value)
      .slice(0, 3);
  }

  return out;
}

function htmlToReadableText(html: string): string {
  return html
    .replace(/<script[\s\S]*?<\/script>/gi, " ")
    .replace(/<style[\s\S]*?<\/style>/gi, " ")
    .replace(/<noscript[\s\S]*?<\/noscript>/gi, " ")
    .replace(/<svg[\s\S]*?<\/svg>/gi, " ")
    .replace(/<!--([\s\S]*?)-->/g, " ")
    .replace(/<[^>]+>/g, " ")
    .replace(/&nbsp;/g, " ")
    .replace(/&amp;/g, "&")
    .replace(/&lt;/g, "<")
    .replace(/&gt;/g, ">")
    .replace(/&quot;/g, '"')
    .replace(/&#039;/g, "'")
    .replace(/&apos;/g, "'")
    .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
    .replace(/\s+/g, " ")
    .trim();
}

// ---------------- ----------------

export async function recaptureScreenshot(
  rawUrl: string,
  variant: "desktop" | "mobile",
  slug: string,
): Promise<{ ok: true; path: string } | { ok: false; error: string }> {
  const url = normaliseUrl(rawUrl);
  if (!url) return { ok: false, error: "URL invalide" };
  if (!slug) return { ok: false, error: "Slug requis" };

  const filename = `${slug}-${variant}.jpg`;
  const dest = path.join(CAPTURES_DIR, filename);
  const dims = variant === "desktop" ? { w: 1440, h: 900 } : { w: 390, h: 844 };
  const ok = await captureScreenshot(url.toString(), dims.w, dims.h, dest);
  if (!ok) return { ok: false, error: "Échec de la capture" };

  return { ok: true, path: `/captures/${filename}?v=${Date.now()}` };
}

export async function saveRealisation(
  payload: Realisation,
  originalSlug?: string,
): Promise<{ ok: true } | { ok: false; error: string }> {
  const error = validateRealisation(payload);
  if (error) return { ok: false, error };

  const all = loadRealisations();

  if (originalSlug) {
    const idx = all.findIndex((r) => r.slug === originalSlug);
    if (idx < 0) return { ok: false, error: "Projet introuvable" };
    if (payload.slug !== originalSlug && all.some((r) => r.slug === payload.slug)) {
      return { ok: false, error: "Slug déjà utilisé" };
    }
    all[idx] = payload;
  } else {
    if (all.some((r) => r.slug === payload.slug)) {
      return { ok: false, error: "Slug déjà utilisé" };
    }
    all.unshift(payload);
  }

  saveRealisations(all);
  revalidatePath("/atelier-novelia/realisations");
  revalidatePath("/realisations");
  revalidatePath(`/realisations/${payload.slug}`);
  if (originalSlug && originalSlug !== payload.slug) {
    revalidatePath(`/realisations/${originalSlug}`);
  }
  return { ok: true };
}

export async function deleteRealisation(
  slug: string,
): Promise<{ ok: true } | { ok: false; error: string }> {
  const all = loadRealisations();
  const target = all.find((r) => r.slug === slug);
  if (!target) return { ok: false, error: "Projet introuvable" };

  saveRealisations(all.filter((r) => r.slug !== slug));

  await Promise.allSettled([
    target.desktopScreenshot ? fs.unlink(captureToDisk(target.desktopScreenshot)) : Promise.resolve(),
    target.mobileScreenshot ? fs.unlink(captureToDisk(target.mobileScreenshot)) : Promise.resolve(),
  ]);

  revalidatePath("/atelier-novelia/realisations");
  revalidatePath("/realisations");
  revalidatePath(`/realisations/${slug}`);
  return { ok: true };
}

// ---------------- helpers ----------------

function normaliseUrl(input: string): URL | null {
  if (!input) return null;
  let s = input.trim();
  if (!/^https?:\/\//i.test(s)) s = `https://${s}`;
  try {
    const u = new URL(s);
    if (!u.hostname.includes(".")) return null;
    return u;
  } catch {
    return null;
  }
}

function slugifyHostname(hostname: string): string {
  return hostname
    .replace(/^www\./, "")
    .replace(/[^a-z0-9.-]/gi, "")
    .replace(/\./g, "-")
    .toLowerCase();
}

async function scrapeMeta(url: string): Promise<{ title?: string; description?: string }> {
  try {
    const r = await fetch(url, {
      headers: {
        "User-Agent":
          `Mozilla/5.0 (compatible; NoveliaBot/1.0; +https://${BRAND_DOMAIN})`,
        Accept: "text/html",
      },
      redirect: "follow",
      cache: "no-store",
      signal: AbortSignal.timeout(10_000),
    });
    if (!r.ok) return {};
    const html = await r.text();

    const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
    const title = titleMatch?.[1] ? decodeEntities(titleMatch[1].trim()) : undefined;

    const description = matchMeta(html, "description") ?? matchOg(html, "description");

    return { title, description };
  } catch {
    return {};
  }
}

function matchMeta(html: string, name: string): string | undefined {
  const patterns = [
    new RegExp(
      `<meta[^>]+name=["']${name}["'][^>]+content=["']([^"']+)["']`,
      "i",
    ),
    new RegExp(
      `<meta[^>]+content=["']([^"']+)["'][^>]+name=["']${name}["']`,
      "i",
    ),
  ];
  for (const re of patterns) {
    const m = html.match(re);
    if (m?.[1]) return decodeEntities(m[1].trim());
  }
  return undefined;
}

function matchOg(html: string, name: string): string | undefined {
  const re = new RegExp(
    `<meta[^>]+property=["']og:${name}["'][^>]+content=["']([^"']+)["']`,
    "i",
  );
  const m = html.match(re);
  return m?.[1] ? decodeEntities(m[1].trim()) : undefined;
}

function decodeEntities(s: string): string {
  return s
    .replace(/&amp;/g, "&")
    .replace(/&lt;/g, "<")
    .replace(/&gt;/g, ">")
    .replace(/&quot;/g, '"')
    .replace(/&#039;/g, "'")
    .replace(/&apos;/g, "'")
    .replace(/&nbsp;/g, " ")
    .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
    .replace(/\s+/g, " ");
}

async function captureScreenshot(
  url: string,
  width: number,
  height: number,
  dest: string,
): Promise<boolean> {
  await fs.mkdir(path.dirname(dest), { recursive: true });
  const shotUrl = `https://s.wordpress.com/mshots/v1/${encodeURIComponent(url)}?w=${width}&h=${height}`;

  // mShots returns a PNG "loading" placeholder (~8.7 KB) until the real screenshot
  // is generated. Real screenshots are JPEGs (header FF D8 FF). We detect by the
  // first bytes of the response and retry until we get a JPEG, or give up.
  const delays = [0, 3000, 4000, 5000, 6000, 8000];

  for (const wait of delays) {
    if (wait) await new Promise((r) => setTimeout(r, wait));
    try {
      const r = await fetch(shotUrl, {
        redirect: "follow",
        cache: "no-store",
        signal: AbortSignal.timeout(20_000),
      });
      if (!r.ok) continue;
      const buf = Buffer.from(await r.arrayBuffer());
      if (!isJpeg(buf)) continue;
      if (buf.byteLength < 4096) continue;
      await fs.writeFile(dest, buf);
      return true;
    } catch {
      continue;
    }
  }

  return false;
}

function isJpeg(buf: Buffer): boolean {
  return buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff;
}

function pickExtension(mime: string, filename: string): "jpg" | "png" | "webp" | null {
  const map: Record<string, "jpg" | "png" | "webp"> = {
    "image/jpeg": "jpg",
    "image/jpg": "jpg",
    "image/png": "png",
    "image/webp": "webp",
  };
  if (map[mime]) return map[mime];
  const ext = filename.toLowerCase().split(".").pop();
  if (ext === "jpg" || ext === "jpeg") return "jpg";
  if (ext === "png") return "png";
  if (ext === "webp") return "webp";
  return null;
}

function captureToDisk(publicPath: string): string {
  // publicPath = "/captures/foo.jpg?v=123" → "data/captures/foo.jpg"
  const clean = publicPath.split("?")[0] ?? "";
  const filename = path.basename(clean);
  return path.join(CAPTURES_DIR, filename);
}

function validateRealisation(r: Realisation): string | null {
  if (!r.slug || !/^[a-z0-9-]+$/.test(r.slug)) {
    return "Slug invalide (lettres, chiffres et tirets uniquement)";
  }
  if (!r.name.trim()) return "Le nom est requis";
  if (!r.domain.trim()) return "Le domaine est requis";
  if (!r.url.trim()) return "L'URL est requise";
  if (!r.tagline.trim()) return "La phrase d'accroche est requise";
  if (!r.summary.trim()) return "Le résumé est requis";
  if (r.pagespeed) {
    for (const strat of ["mobile", "desktop"] as const) {
      const s = r.pagespeed[strat];
      if (!s) continue;
      for (const k of ["performance", "accessibility", "bestPractices", "seo"] as const) {
        const v = s[k];
        if (typeof v !== "number" || v < 0 || v > 100 || !Number.isFinite(v)) {
          return `Score PageSpeed ${strat}/${k} invalide (0 à 100)`;
        }
      }
    }
  }
  return null;
}
