#!/usr/bin/env python3
"""Compte les occurrences réelles des entités d'une carte sémantique dans un contenu.

Les LLM comptent mal les occurrences. Ce script fait le travail arithmétique pour que
l'audit annonce des densités mesurées plutôt qu'estimées. Le travail sémantique — quelles
variantes de surface comptent pour une entité, quels faux positifs écarter — reste au
modèle, qui le fait bien.

Usage
-----
    python count_density.py --content page.md --map carte.json [--out mesures.json]

Format de carte.json
--------------------
    {
      "entities": {
        "passage-ranking": {
          "label": "passage ranking",
          "target_density": 0.8,
          "variants": ["passage ranking", "passage-ranking", "ranking de passage"],
          "exclude": ["passage ranking API"]
        }
      },
      "ngrams": {
        "passage-ancre": {
          "label": "passage ancré",
          "target_count": 3,
          "variants": ["passage ancré", "passages ancrés"]
        }
      }
    }

`exclude` masque les zones du texte avant comptage — c'est ainsi qu'on écarte un
homographe (« PR » = public relations là où on cherche passage ranking).
`target_density` est en pourcentage du nombre de mots. `target_count` est un nombre
d'occurrences absolu (pour les n-grams de la couche C3, dont la carte prescrit une
fréquence, pas une densité).

Sortie
------
JSON sur stdout (ou dans --out) : total de mots, une entrée par entité avec occurrences,
densité mesurée, statut, et les positions trouvées pour vérification. Plus les candidats
hors scope : n-grams fréquents absents de la carte.
"""

from __future__ import annotations

import argparse
import json
import re
import sys
import unicodedata
from collections import Counter
from pathlib import Path

# Seuils du spec — un statut doit être reproductible d'un run à l'autre.
CONFORME_MIN = 0.70   # densité ≥ 70 % de la cible
CONFORME_MAX = 2.00   # densité ≤ 200 % de la cible
OOS_MIN_COUNT = 5     # hors scope : au moins 5 occurrences…
OOS_MIN_DENSITY = 0.15  # … ou 0,15 % de densité
OOS_FLOOR = 3         # … mais jamais moins de 3 occurrences en absolu
# Le plancher absolu existe parce que la condition de densité seule est trop permissive sur
# un texte court : à 300 mots, une occurrence unique pèse 0,33 % et passerait le seuil. Un
# terme vu une ou deux fois n'est pas une pollution vectorielle, c'est du vocabulaire de
# liaison. Les deux conditions du spec convergent vers 3000 mots ; le plancher couvre le reste.

DENSITY_MIN_WORDS = 500
# Sous ce volume, les densités cibles de la carte (calibrées sur 2000 mots) ne sont pas
# interprétables : une occurrence unique suffit à dépasser n'importe quelle cible. Le script
# mesure quand même, mais signale que les statuts de densité sont à lire avec prudence.

# Mots-outils écartés de la détection hors scope. Un terme hors scope pertinent est un
# terme de domaine : les mots-outils fréquents ne polluent pas le vecteur sémantique.
STOPWORDS = set("""
a à ai aie ainsi ait alors après as au aucun aucune aujourd aujourdhui auquel aura aurai
auraient aurait aurez auront aussi autant autre autres aux avaient avais avait avant avec
avez aviez avoir avons ayant beaucoup bien c ca ça car ce ceci cela celle celles celui cependant
certain certaine certains ces cet cette ceux chacun chaque chez ci comme comment d dans de
dedans dehors déjà depuis des dès désormais deux doit doivent donc dont du duquel elle elles
en encore enfin entre er es est et étaient étais était étant été êtes étiez être eu eux
faire fais fait faites faut il ils j je jusqu jusque l la là laquelle le lequel les lesquelles
lesquels leur leurs lors lui m ma mais me même mêmes mes mien moi moins mon n ne ni non nos
notre nous nul on ont ou où par parce pas peu peut peuvent plus plusieurs pour pourquoi
pouvez pourtant près puis puisque qu quand que quel quelle quelles quels qui quoi s sa
sans se selon sera seraient serait ses si sien soit sommes son sont sous soyez su sur t ta
tandis te tel telle tes toi ton tous tout toute toutes très tu un une vos votre vous y
about after all also an and any are as at be because been before being between both but by
can could did do does doing done down during each even for from further had has have having
he her here hers him his how i if in into is it its just may me might more most must my no
nor not now of off on once only or other our out over own same she should so some such than
that the their them then there these they this those through to too under until up very was
we were what when where which while who whom why will with would you your
""".split())

# Mots pleins mais vides de domaine : fréquents dans toute page web, jamais informatifs
# comme candidats hors scope. Sans eux, la détection remonte « page », « exemple », « cas »
# avant le terme métier qui pollue réellement le vecteur, et le signal se noie.
GENERIC_WORDS = set("""
page pages site sites web article articles contenu contenus texte textes mot mots exemple
exemples cas fois temps moment moments jour jours année années point points partie parties
type types sorte façon facon manière maniere sens monde besoin besoins moyen moyens chose
choses sujet sujets question questions réponse reponse réponses reponses idée idee idées
idees raison raisons niveau niveaux nombre nombres part place lieu fait faits effet suite
début debut fin milieu haut bas côté cote ensemble groupe ligne lignes liste listes série
serie exemple travail résultat resultat résultats resultats problème probleme problèmes
problemes solution solutions objectif objectifs outil outils service services produit
produits client clients personne personnes gens équipe equipe entreprise entreprises
société societe monde marché marche secteur domaine domaines google bing chatgpt
approche approches méthode methode méthodes methodes étape etape étapes etapes
""".split())


def strip_accents(text: str) -> str:
    """Retire les diacritiques pour un matching tolérant aux accents manquants.

    Un contenu réel écrit « pondéree » ou « ponderée » ; refuser de compter ces formes
    produirait un faux « Absent » sur une entité en réalité présente.
    """
    decomposed = unicodedata.normalize("NFD", text)
    return "".join(c for c in decomposed if unicodedata.category(c) != "Mn")


def normalize(text: str) -> str:
    """Normalise pour le matching : accents retirés, casse pliée, tirets et espaces unifiés.

    Les offsets sont préservés caractère pour caractère : chaque substitution garde la même
    longueur, ce qui permet de reporter les positions trouvées sur le texte d'origine.
    """
    text = strip_accents(unicodedata.normalize("NFC", text)).casefold()
    text = re.sub(r"[‐-―−\-_/]", " ", text)      # tirets, slashs → espace
    text = re.sub(r"[‘’‛′]", "'", text)     # apostrophes typographiques
    text = re.sub(r"[   \t]", " ", text)         # espaces insécables
    return text


def word_count(text: str) -> int:
    """Nombre de mots du document, base du calcul de densité."""
    return len(re.findall(r"\w+", text, flags=re.UNICODE))


def variant_pattern(variant: str) -> re.Pattern:
    """Compile une variante en regex à frontières de mots.

    Les frontières évitent qu'un acronyme court se retrouve compté dans un mot plus long :
    sans elles, « PR » serait trouvé dans « prix » et « BM25 » dans « BM250 ».
    Les espaces internes tolèrent une ponctuation intercalaire (« passage, ranking »).
    """
    parts = [re.escape(p) for p in normalize(variant).split() if p]
    if not parts:
        raise ValueError(f"variante vide : {variant!r}")
    body = r"[\s,;:.]+".join(parts)
    return re.compile(rf"(?<!\w){body}(?!\w)", flags=re.UNICODE)


def mask_spans(text: str, spans: list[tuple[int, int]]) -> str:
    """Remplace des zones par des espaces, en préservant longueur et offsets."""
    if not spans:
        return text
    chars = list(text)
    for start, end in spans:
        for i in range(start, end):
            chars[i] = " "
    return "".join(chars)


def find_non_overlapping(haystack: str, variants: list[str]) -> list[tuple[int, int, str]]:
    """Trouve les occurrences des variantes sans jamais compter deux fois la même zone.

    Les variantes les plus longues passent d'abord : sur « ranking de passage », on veut
    une occurrence de la variante longue, pas deux occurrences de « ranking » et
    « passage » qui gonfleraient artificiellement la densité.
    """
    hits: list[tuple[int, int, str]] = []
    consumed: list[tuple[int, int]] = []
    working = haystack

    for variant in sorted(variants, key=len, reverse=True):
        for match in variant_pattern(variant).finditer(working):
            span = (match.start(), match.end())
            if any(span[0] < e and s < span[1] for s, e in consumed):
                continue
            hits.append((span[0], span[1], variant))
            consumed.append(span)
        working = mask_spans(working, consumed)

    return sorted(hits)


def status_for_density(density: float, target: float) -> str:
    """Applique les seuils du spec. Une cible nulle ou absente ne peut pas être jugée."""
    if density == 0:
        return "absent"
    if not target:
        return "non-cible"
    ratio = density / target
    if ratio < CONFORME_MIN:
        return "sous-densifie"
    if ratio > CONFORME_MAX:
        return "sur-densifie"
    return "conforme"


def status_for_count(count: int, target: int) -> str:
    """Variante par occurrences absolues, pour les n-grams de la couche C3."""
    if count == 0:
        return "absent"
    if not target:
        return "non-cible"
    ratio = count / target
    if ratio < CONFORME_MIN:
        return "sous-densifie"
    if ratio > CONFORME_MAX:
        return "sur-densifie"
    return "conforme"


def measure_layer(norm_text: str, total_words: int, items: dict, mode: str) -> tuple[dict, list]:
    """Mesure un groupe d'entités ou de n-grams. Renvoie les mesures et les zones couvertes."""
    results: dict[str, dict] = {}
    covered: list[tuple[int, int]] = []

    for key, spec in items.items():
        variants = spec.get("variants") or [spec.get("label", key)]
        excludes = spec.get("exclude") or []

        exclude_spans = [
            (m.start(), m.end())
            for ex in excludes
            for m in variant_pattern(ex).finditer(norm_text)
        ]
        searchable = mask_spans(norm_text, exclude_spans)

        hits = find_non_overlapping(searchable, variants)
        count = len(hits)
        density = round(count / total_words * 100, 4) if total_words else 0.0
        covered.extend((s, e) for s, e, _ in hits)

        entry = {
            "label": spec.get("label", key),
            "variants": variants,
            "occurrences": count,
            "density": density,
            "matched_variants": dict(Counter(v for _, _, v in hits)),
            "positions": [[s, e] for s, e, _ in hits][:50],
        }

        if mode == "density":
            target = spec.get("target_density") or 0
            entry["target_density"] = target
            entry["status"] = status_for_density(density, target)
        else:
            target = spec.get("target_count") or 0
            entry["target_count"] = target
            entry["status"] = status_for_count(count, target)

        if excludes:
            entry["excluded_zones"] = len(exclude_spans)

        results[key] = entry

    return results, covered


def find_out_of_scope(norm_text: str, total_words: int, covered: list[tuple[int, int]],
                      max_results: int) -> list[dict]:
    """Repère les n-grams fréquents qui n'appartiennent à aucune entité de la carte.

    C'est la détection ⚠️ Hors scope : un terme de domaine massivement présent sans être
    prévu signale soit un pan de sujet non cartographié, soit une pollution du vecteur.
    Le script ne tranche pas entre les deux — il remonte les candidats, le modèle juge.
    """
    text = mask_spans(norm_text, covered)
    tokens: list[tuple[str, int]] = [
        (m.group(0), m.start()) for m in re.finditer(r"\w+", text, flags=re.UNICODE)
    ]

    counter: Counter[str] = Counter()
    for size in (1, 2, 3):
        for i in range(len(tokens) - size + 1):
            window = tokens[i:i + size]
            # Un n-gram ne doit pas enjamber une zone masquée : si les tokens ne sont pas
            # contigus dans le texte, ils ne forment pas une expression réelle.
            if size > 1:
                gap = any(
                    window[j + 1][1] - (window[j][1] + len(window[j][0])) > 3
                    for j in range(size - 1)
                )
                if gap:
                    continue
            words = [w for w, _ in window]
            if any(w in STOPWORDS or len(w) < 3 or w.isdigit() for w in words):
                continue
            # Un mot vide de domaine disqualifie un unigramme, mais pas une expression :
            # « page » seul n'apprend rien, « page orpheline » est un terme métier.
            if all(w in GENERIC_WORDS for w in words):
                continue
            counter[" ".join(words)] += 1

    # Premier passage : tout ce qui franchit les seuils absolus.
    pool = []
    for term, count in counter.most_common():
        density = round(count / total_words * 100, 4) if total_words else 0.0
        if count < OOS_FLOOR:
            continue
        if count < OOS_MIN_COUNT and density < OOS_MIN_DENSITY:
            continue
        pool.append({"term": term, "occurrences": count, "density": density})

    # Second passage : dédoublonnage et bornage. Le tri par fréquence décroissante suffit à
    # mettre le signal en tête — un terme répété est intentionnel, et c'est au modèle de
    # décider si « netlinking » vu 12 fois est un pan de sujet non cartographié ou une
    # pollution du vecteur. Aucun calcul ne tranche cette question, et un seuil plus agressif
    # ferait disparaître de vrais signaux : mieux vaut quelques candidats à écarter qu'un
    # terme polluant jamais remonté.
    candidates: list[dict] = []
    for cand in pool:
        # Un n-gram long est plus informatif que ses fragments : on écarte un unigramme
        # dont toutes les occurrences sont déjà expliquées par un n-gram retenu.
        if any(cand["term"] in kept["term"] and cand["term"] != kept["term"]
               and kept["occurrences"] >= cand["occurrences"] for kept in candidates):
            continue
        candidates.append(cand)
        if len(candidates) >= max_results:
            break

    return candidates


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Mesure la couverture réelle d'une carte sémantique dans un contenu.")
    parser.add_argument("--content", required=True, type=Path,
                        help="Fichier du contenu audité (texte, Markdown ou HTML brut).")
    parser.add_argument("--map", required=True, type=Path,
                        help="Carte JSON : entities (densité cible) et/ou ngrams (occurrences cibles).")
    parser.add_argument("--out", type=Path, help="Écrit le JSON ici au lieu de stdout.")
    parser.add_argument("--max-oos", type=int, default=25,
                        help="Nombre maximum de candidats hors scope remontés (défaut : 25).")
    parser.add_argument("--quiet", action="store_true",
                        help="N'affiche pas le résumé lisible sur stderr.")
    args = parser.parse_args()

    if not args.content.exists():
        print(f"Contenu introuvable : {args.content}", file=sys.stderr)
        return 1
    if not args.map.exists():
        print(f"Carte introuvable : {args.map}", file=sys.stderr)
        return 1

    raw = args.content.read_text(encoding="utf-8", errors="replace")
    carte = json.loads(args.map.read_text(encoding="utf-8"))

    norm_text = normalize(raw)
    total_words = word_count(raw)
    if not total_words:
        print("Contenu vide : aucune densité calculable.", file=sys.stderr)
        return 1

    entities, covered_e = measure_layer(
        norm_text, total_words, carte.get("entities", {}), mode="density")
    ngrams, covered_n = measure_layer(
        norm_text, total_words, carte.get("ngrams", {}), mode="count")

    out_of_scope = find_out_of_scope(
        norm_text, total_words, covered_e + covered_n, args.max_oos)

    def tally(items: dict) -> dict:
        counts = Counter(v["status"] for v in items.values())
        return {k: counts.get(k, 0) for k in
                ("conforme", "sous-densifie", "sur-densifie", "absent", "non-cible")}

    result = {
        "content_path": str(args.content),
        "word_count": total_words,
        "thresholds": {
            "conforme_min_ratio": CONFORME_MIN,
            "conforme_max_ratio": CONFORME_MAX,
            "oos_min_count": OOS_MIN_COUNT,
            "oos_min_density": OOS_MIN_DENSITY,
            "oos_floor": OOS_FLOOR,
        },
        "warnings": (
            [f"Contenu de {total_words} mots : sous {DENSITY_MIN_WORDS} mots, les statuts de "
             f"densité ne sont pas interprétables (les cibles de la carte sont calibrées sur "
             f"2000 mots). Lire les occurrences brutes plutôt que les statuts."]
            if total_words < DENSITY_MIN_WORDS else []
        ),
        "entities": entities,
        "ngrams": ngrams,
        "out_of_scope_candidates": out_of_scope,
        "summary": {
            "entities": tally(entities),
            "ngrams": tally(ngrams),
            "out_of_scope_found": len(out_of_scope),
        },
    }

    payload = json.dumps(result, ensure_ascii=False, indent=2)
    if args.out:
        args.out.write_text(payload + "\n", encoding="utf-8")
    else:
        print(payload)

    if not args.quiet:
        s = result["summary"]["entities"]
        print(f"{total_words} mots | entités : {s['conforme']} conformes, "
              f"{s['sous-densifie']} sous-densifiées, {s['sur-densifie']} sur-densifiées, "
              f"{s['absent']} absentes | {len(out_of_scope)} candidats hors scope",
              file=sys.stderr)

    return 0


if __name__ == "__main__":
    sys.exit(main())
