#!/usr/bin/env python3
"""
Simule l'effet d'une règle d'administration sur un texte ou sur le corpus synthétique.

Usage :
    # Appliquer une règle à un texte libre
    python tools/simulate_admin_rule.py --rule-id rule_chuxx_exact_mask \\
        --text "Consulté au CHUXX le 12/06/2024."

    # Appliquer à un fichier texte
    python tools/simulate_admin_rule.py --rule-id rule_chuxx_exact_mask \\
        --file path/to/document.txt

    # Valider la règle sur ses required_case_ids (--corpus)
    python tools/simulate_admin_rule.py --rule-id rule_chuxx_exact_mask --corpus

    # Valider TOUTES les règles actives sur leurs corpus
    python tools/simulate_admin_rule.py --all --corpus
"""
from __future__ import annotations

import argparse
import re
import sys
from pathlib import Path
from typing import Any

ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
    sys.path.insert(0, str(ROOT))

from admin_rules import (  # noqa: E402
    compile_active_admin_rules,
    load_effective_admin_rules_dict,
)

CASES_DIR = ROOT / "tests" / "synthetic_review" / "cases"


# ---------------------------------------------------------------------------
# Application isolée d'une règle compilée sur un texte
# ---------------------------------------------------------------------------

def _apply_rule_to_text(rule: dict[str, Any], text: str) -> tuple[str, list[tuple[str, str]]]:
    """
    Applique une règle compilée (format retourné par compile_active_admin_rules)
    et retourne (texte_masqué, [(original, placeholder), ...]).
    """
    placeholder = rule.get("placeholder", "[MASK]")
    matches: list[tuple[str, str]] = []

    rule_type = rule.get("type")
    if rule_type == "exact_term":
        raw = rule.get("match", {}).get("exact_value", "")
        if not raw:
            return text, []
        norm = rule.get("normalization", {}) or {}
        flags = re.IGNORECASE if norm.get("case_insensitive") else 0
        wb = norm.get("whole_word", True)
        boundary = r"\b" if wb else ""
        pattern = re.compile(rf"{boundary}{re.escape(raw)}{boundary}", flags)

        def _repl(m: re.Match) -> str:
            matches.append((m.group(0), placeholder))
            return placeholder

        text = pattern.sub(_repl, text)

    elif rule_type == "preserve_phrase":
        pass  # pas de masquage

    elif rule_type in {"normalized_identifier", "contextual_identifier"}:
        for pat in rule.get("patterns", []):
            def _repl_pat(m: re.Match, _ph: str = placeholder) -> str:
                captured = m.group(1) if m.lastindex else m.group(0)
                full = m.group(0)
                matches.append((captured, _ph))
                return full.replace(captured, _ph, 1)

            text = pat.sub(_repl_pat, text)

    return text, matches


def _compile_single_rule(rule_raw: dict[str, Any]) -> dict[str, Any]:
    """Compile une seule règle brute dans un format applicable par _apply_rule_to_text."""
    rule_type = rule_raw.get("type")
    if rule_type in {"normalized_identifier", "contextual_identifier"}:
        from admin_rules import _compile_identifier_rule  # noqa: PLC0415
        compiled = _compile_identifier_rule(rule_raw)
        compiled["match"] = rule_raw.get("match", {})
        compiled["normalization"] = rule_raw.get("normalization", {})
        compiled["placeholder"] = rule_raw.get("placeholder", "[MASK]")
        return compiled
    return {
        "id": rule_raw.get("id"),
        "type": rule_type,
        "action": rule_raw.get("action"),
        "match": rule_raw.get("match", {}),
        "normalization": rule_raw.get("normalization", {}),
        "placeholder": rule_raw.get("placeholder", "[MASK]"),
        "patterns": [],
    }


# ---------------------------------------------------------------------------
# Chargement des règles
# ---------------------------------------------------------------------------

def _load_rule_by_id(rule_id: str) -> dict[str, Any] | None:
    data = load_effective_admin_rules_dict()
    for rule in data.get("rules", []):
        if rule.get("id") == rule_id:
            return rule
    return None


def _load_all_active_rules() -> list[dict[str, Any]]:
    data = load_effective_admin_rules_dict()
    return [r for r in data.get("rules", []) if r.get("status") == "active"]


def _get_required_case_ids(rule: dict[str, Any]) -> list[str]:
    return (rule.get("governance") or {}).get("tests", {}).get("required_case_ids") or []


# ---------------------------------------------------------------------------
# Mode simulation texte libre
# ---------------------------------------------------------------------------

def simulate_text(rule: dict[str, Any], text: str, verbose: bool = True) -> bool:
    """Simule la règle sur text et affiche le résultat. Retourne True si au moins 1 match."""
    compiled = _compile_single_rule(rule)
    masked, hits = _apply_rule_to_text(compiled, text)

    rule_id = rule.get("id", "?")
    status = rule.get("status", "?")
    print(f"\n{'='*60}")
    print(f"Règle : {rule_id}  [status={status}]")
    print(f"Type  : {rule.get('type')}  /  action={rule.get('action')}")
    print(f"{'='*60}")

    if hits:
        print(f"\n{len(hits)} occurrence(s) masquée(s) :")
        for original, ph in hits:
            print(f"  '{original}' → {ph}")
        if verbose:
            print("\nTexte masqué :")
            print("-" * 40)
            print(masked)
            print("-" * 40)
    else:
        print("\nAucune occurrence masquée.")

    return bool(hits)


# ---------------------------------------------------------------------------
# Mode validation corpus
# ---------------------------------------------------------------------------

def validate_corpus(rule: dict[str, Any]) -> dict[str, Any]:
    """
    Valide la règle sur ses required_case_ids.

    Pour chaque cas, vérifie que :
    - La règle produit au moins 1 hit (sauf pour preserve_phrase).
    - Les must_contain de expectations.json ne sont pas détruits par la règle.

    Note : on ne vérifie PAS les must_not_contain globaux — ceux-ci testent
    l'ensemble du pipeline, pas une règle isolée.

    Retourne un dict {case_id: {"ok": bool, "failures": [...]}}
    """
    import json  # noqa: PLC0415

    required_ids = _get_required_case_ids(rule)
    if not required_ids:
        return {}

    compiled = _compile_single_rule(rule)
    rule_type = rule.get("type")
    results: dict[str, Any] = {}

    for case_id in required_ids:
        case_dir = CASES_DIR / case_id
        if not case_dir.exists():
            results[case_id] = {"ok": False, "failures": [f"Répertoire introuvable : {case_dir}"]}
            continue

        test_file = case_dir / "test.txt"
        if not test_file.exists():
            results[case_id] = {"ok": False, "failures": ["test.txt absent"]}
            continue

        text = test_file.read_text(encoding="utf-8")
        masked, hits = _apply_rule_to_text(compiled, text)

        exp_file = case_dir / "expectations.json"
        expectations = json.loads(exp_file.read_text(encoding="utf-8")) if exp_file.exists() else {}
        must_contain = expectations.get("must_contain", [])

        failures = []

        # Pour exact_term / normalized_identifier / contextual_identifier :
        # la règle doit masquer au moins 1 occurrence dans le document de test.
        if rule_type != "preserve_phrase" and not hits:
            target = (rule.get("match") or {}).get("exact_value") or (rule.get("match") or {}).get("canonical_value") or "?"
            failures.append(f"0 occurrence de '{target}' masquée — la règle n'a aucun effet sur ce cas")

        # La règle ne doit pas détruire les termes médicaux préservés.
        for term in must_contain:
            if term not in masked:
                failures.append(f"sur-masquage : '{term}' disparu après simulation")

        results[case_id] = {"ok": not failures, "failures": failures, "hits": len(hits)}

    return results


# ---------------------------------------------------------------------------
# Rapport corpus
# ---------------------------------------------------------------------------

def print_corpus_report(rule: dict[str, Any], results: dict[str, Any]) -> bool:
    """Affiche le rapport et retourne True si tout est OK."""
    rule_id = rule.get("id", "?")
    print(f"\n{'='*60}")
    print(f"Validation corpus : {rule_id}")
    print(f"{'='*60}")

    if not results:
        print("Aucun required_case_id défini dans governance.tests.")
        return True

    all_ok = True
    for case_id, res in results.items():
        status = "OK" if res["ok"] else "FAIL"
        hits = res.get("hits", 0)
        print(f"  [{status}] {case_id}  ({hits} occurrence(s) masquée(s))")
        for failure in res.get("failures", []):
            print(f"         - {failure}")
            all_ok = False

    print()
    if all_ok:
        print("Validation OK — la règle couvre tous ses cas de test.")
    else:
        print("Validation ÉCHEC — corriger les écarts ci-dessus.")

    return all_ok


# ---------------------------------------------------------------------------
# Entrée CLI
# ---------------------------------------------------------------------------

def main() -> int:
    parser = argparse.ArgumentParser(
        description="Simuler / valider une règle d'administration",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=__doc__,
    )

    target = parser.add_mutually_exclusive_group(required=True)
    target.add_argument("--rule-id", metavar="ID", help="Identifiant de la règle à simuler")
    target.add_argument("--all", action="store_true", help="Traiter toutes les règles actives")

    source = parser.add_mutually_exclusive_group()
    source.add_argument("--text", metavar="TEXT", help="Texte libre à tester")
    source.add_argument("--file", metavar="FILE", type=Path, help="Fichier texte à tester")

    parser.add_argument(
        "--corpus",
        action="store_true",
        help="Valider sur les required_case_ids définis dans la règle",
    )
    parser.add_argument("--quiet", action="store_true", help="Sortie minimale (code retour uniquement)")

    args = parser.parse_args()

    if not args.corpus and args.text is None and args.file is None:
        parser.error("Fournir --text, --file ou --corpus.")

    rules: list[dict[str, Any]] = []
    if args.all:
        rules = _load_all_active_rules()
        if not rules:
            print("Aucune règle active trouvée.")
            return 0
    else:
        rule = _load_rule_by_id(args.rule_id)
        if rule is None:
            print(f"Règle '{args.rule_id}' introuvable dans la configuration.", file=sys.stderr)
            return 2
        rules = [rule]

    exit_code = 0

    for rule in rules:
        if args.corpus:
            results = validate_corpus(rule)
            ok = print_corpus_report(rule, results)
            if not ok:
                exit_code = 1
        else:
            if args.file:
                text = args.file.read_text(encoding="utf-8")
            else:
                text = args.text or ""
            found = simulate_text(rule, text, verbose=not args.quiet)
            if not found:
                exit_code = 1 if args.quiet else 0

    return exit_code


if __name__ == "__main__":
    raise SystemExit(main())
