// src/lib/semantic/engine.ts — wrapper typé du wasm semantic-core.
// Serveur uniquement (wasm nodejs, serverExternalPackages).
//
// ⚠️ SYMÉTRIE DE CONFIG OBLIGATOIRE entre `extractSemanticPage` et
// `buildSerpProfile` : `PageSemantics.weightedLen` est figé par
// `extractSemanticPage` avec les poids de zone (`ZoneWeights`) de CET appel ;
// `buildSerpProfile` recalcule ensuite ses propres fréquences de terme (ftf)
// à partir de SES poids au moment de l'agrégation. Passer des `config`
// différentes aux deux fonctions pour une même SERP fausse le score BM25F
// SILENCIEUSEMENT (probe de revue : 0.9699 vs 0.9167 sur le même corpus selon
// la config utilisée). Toujours passer la MÊME `SemanticConfig` (ou le même
// objet partiel) aux deux appels d'une même analyse.
import * as wasm from "semantic-core";
import type {
  PageInput,
  PageSemantics,
  SemanticConfig,
  SemanticProfile,
} from "./types";

// --- Assainissement pré-parse (follow-up js_rendered_suspected, cf. rapport
// .superpowers/sdd/followup-semantic-report.md) --------------------------
//
// Root cause : `tl` (parseur HTML du crate `semantic-core`, `crates/
// semantic-core/src/zones.rs`) n'implémente PAS l'état « raw text » du
// tokenizer HTML5 pour `<script>`/`<style>` — un `<` littéral dans le JS/CSS
// (ex. `for (var i = 0; i < n; i++)`, quasi systématique en JS réel) fait
// sortir `tl` du contenu du tag et repartir en parsing de balises normal. Le
// `</script>` réel qui suit est alors mal apparié : tout le sous-arbre qui
// vient après (souvent `<main>`/`<article>`, donc TOUT le contenu éditorial)
// se retrouve structurellement DESCENDANT du `<script>` — et `SKIP_TAGS`
// (à raison) exclut tout le sous-arbre d'un `<script>`. Résultat : une page
// pleinement rendue et riche en contenu ressort avec `wordCount` proche de 0,
// et le runner la flague `js_rendered_suspected` alors qu'il n'y a jamais eu
// de problème de rendu JS — juste une perte de corpus côté extraction.
// Repro confirmée (probe manuel, page réelle en prod, script #26 sur 80,
// déclencheur minimal `i <` en fin de script) : `bisection` isole le trigger
// exact au seul caractère `<` d'un opérateur de comparaison JS, sans qu'aucun
// tag ne suive — `tl` retombe quand même en mode tag-parsing.
//
// Fix : neutraliser le contenu des `<script>`/`<style>` AVANT `tl::parse`,
// côté TS, en amont de la frontière wasm — ces zones sont de toute façon
// exclues de l'extraction (`SKIP_TAGS`), les vider ne change donc rien au
// résultat sémantique pour un HTML qui parse correctement, et neutralise le
// bug pour tous les autres. `application/ld+json`/`application/json` sont
// PRÉSERVÉS intacts (JSON pur, jamais de `<` de comparaison) — `zones.rs`
// s'appuie dessus pour `schema_types`/la détection d'archétype ; les vider
// casserait ce signal. Le TAG reste (`<script ...></script>`) — seul le
// contenu texte est retiré — pour ne pas perturber les compteurs de
// structure qui comptent des balises, pas leur contenu.
//
// Post-review (probe Fable, cf. .superpowers/sdd/followup-semantic-report.md,
// section « Fix post-review ») — deux régressions corrigées :
//
// 1. La sous-expression attributs `[^>]*` s'arrêtait au premier `>`, même
//    porté par une valeur d'attribut quotée (`data-x="a>b"`, JSON dans un
//    `data-*` WordPress/Shopify/GTM — réaliste). La regex coupait alors en
//    plein milieu de la quote, laissait la quote ouverte, et sur-matchait
//    bien au-delà du vrai `</script>` : tout le contenu éditorial suivant
//    était avalé (wordCount → 0). Fix : `((?:[^>"']|"[^"]*"|'[^']*')*)` —
//    autorise soit des caractères hors quote/`>`, soit une chaîne
//    entièrement quotée (simple ou double), donc un `>` dans une quote ne
//    ferme plus prématurément le match d'attributs.
//
// 2. Un commentaire HTML contenant un `<script>` littéral non fermé
//    (`<!-- <script> -->`), suivi plus loin d'un vrai `<script>…</script>`,
//    faisait apparier le `<script>` du commentaire avec le PREMIER
//    `</script>` rencontré (celui du vrai script) — tout l'éditorial entre
//    les deux disparaissait. Fix : retirer les commentaires HTML AVANT le
//    traitement script/style (un `<!--…-->` non-greedy, en premier). Les
//    commentaires ne portent aucun contenu éditorial exploitable, ce retrait
//    est donc sans perte sémantique — et il ne casse pas le JSON-LD (les
//    blocs `<script type="application/ld+json">…</script>` ne sont pas des
//    commentaires, ils ne matchent jamais `HTML_COMMENT_RE`).
//    Résidu documenté (strip choisi malgré ce coût, cf. review) : ce retrait
//    opère sur le document entier, sans respecter les frontières de balises
//    ni les valeurs d'attributs quotées ailleurs dans le document — un
//    littéral `<!-- … -->` glissé dans la valeur d'un attribut quoté hors
//    script/style (cas exotique, non rencontré en prod) serait, lui aussi,
//    retiré à tort. Accepté : bien plus rare que les deux cas ci-dessus, et
//    strictement moins dommageable qu'un vrai commentaire HTML mal fermé qui
//    engloutit du contenu éditorial.
//
// 3. `JSON_TYPE_RE` n'était pas ancré à l'attribut `type` réel : il matchait
//    aussi `data-type="application/ld+json"` (substring `type=…` présent
//    dans `data-type=…`), préservant à tort un `<script>` JS déguisé en
//    JSON-LD (bug tl latent — `zones.rs` ne serait normalement pas dupé, cf.
//    probe manuel, mais mieux vaut ne jamais transmettre de JS non assaini
//    en aval). Fix : lookbehind négatif `(?<![-\w])` avant `type` — ne
//    matche que l'attribut `type` lui-même, jamais un attribut qui se
//    termine par `-type`/`Xtype`.
const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
const RAW_TEXT_TAG_RE =
  /<(script|style)\b((?:[^>"']|"[^"]*"|'[^']*')*)>([\s\S]*?)<\/\1\s*>/gi;
const JSON_TYPE_RE =
  /(?<![-\w])type\s*=\s*["']?application\/(ld\+json|json)["']?/i;

/** Vide le contenu texte des `<script>`/`<style>` (sauf JSON-LD/JSON, voir
 *  commentaire ci-dessus) — seule mutation apportée au HTML avant
 *  `tl::parse`, après retrait préalable des commentaires HTML (idem, voir
 *  commentaire ci-dessus, point 2). */
function sanitizeRawTextElements(html: string): string {
  const withoutComments = html.replace(HTML_COMMENT_RE, "");
  return withoutComments.replace(
    RAW_TEXT_TAG_RE,
    (match, tag: string, attrs: string) => {
      if (tag.toLowerCase() === "script" && JSON_TYPE_RE.test(attrs)) {
        return match;
      }
      return `<${tag}${attrs}></${tag}>`;
    },
  );
}

/**
 * Analyse une page HTML → `PageSemantics` (zones, n-grammes, structure,
 * questions). `config` est fusionné avec les défauts CDC côté Rust (tolère
 * un objet partiel). `html` est assaini (`sanitizeRawTextElements`) avant de
 * franchir la frontière wasm — voir commentaire ci-dessus.
 */
export function extractSemanticPage(
  html: string,
  finalUrl: string,
  config: Partial<SemanticConfig> = {},
): PageSemantics {
  return wasm.extract_semantic_page(
    sanitizeRawTextElements(html),
    finalUrl,
    JSON.stringify(config),
  ) as PageSemantics;
}

/**
 * Agrège un corpus SERP (+ page cliente optionnelle) → `SemanticProfile`.
 * `client = null` désactive la gap analysis (`profile.gap === null`).
 */
export function buildSerpProfile(
  pages: PageInput[],
  client: PageSemantics | null,
  config: Partial<SemanticConfig> = {},
): SemanticProfile {
  return wasm.build_serp_profile(
    JSON.stringify(pages),
    client ? JSON.stringify(client) : "",
    JSON.stringify(config),
  ) as SemanticProfile;
}
