#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""CLI de production — anonymise UN fichier (ou un dossier) sans GUI.

Contrat principal (demande Dom 2026-06-10) :

    Anonymisation-CLI.exe <fichier> <dossier_sortie>

- argument 1 : fichier unique existant (ou dossier parcouru récursivement) ;
- argument 2 : dossier de sortie (créé si absent) ; `--out` reste accepté en
  compatibilité ;
- chemins avec espaces et accents supportés ;
- pas de GUI ;
- log lisible à côté de l'EXE (frozen) ou dans le cwd (venv).

Codes retour :
- 0 : anonymisation terminée, sortie produite ;
- 2 : entrée manquante (fichier/dossier introuvable ou aucun document) ;
- 3 : modèle OBLIGATOIRE indisponible (échec fail-closed, pas de mode dégradé
      silencieux) ;
- 4 : sortie non produite (mise en quarantaine résiduelle ou fichier absent) ;
- 1 : erreur de traitement (exception).

Modèles (alignés sur la dernière version du moteur, comme la GUI v5) :
- OBLIGATOIRE : CamemBERT-bio ONNX (`models/camembert-bio-deid/onnx/model.onnx`).
  Si absent/illisible et NER demandé -> le CLI ÉCHOUE (code 3), il n'affiche
  jamais « OK » en mode dégradé.
- OPTIONNELS : EDS-Pseudo, GLiNER. Chargés best effort, tracés dans le log ;
  leur absence est explicitement signalée, jamais masquée.
- `--no-ner` : mode regex seul ASSUMÉ par l'opérateur -> aucun modèle obligatoire.

Compatible frozen (PyInstaller) ET venv : en frozen, se positionne dans
`sys._MEIPASS` (comme launcher.py) pour que le core retrouve config/ data/
models/ ; écrit le log à côté de l'EXE ; résout les chemins entrée/sortie
contre le cwd de lancement.

Sortie de production (identique à la GUI v5) : burn raster
(`make_vector_redaction=False`, `also_make_raster_burn=True`), plus le texte
pseudonymisé (`.pseudonymise.txt`) et l'audit (`.audit.jsonl`).
"""
from __future__ import annotations

import argparse
import logging
import os
import sys
import time
from pathlib import Path

# Console Windows (cp1252) : forcer UTF-8 pour éviter UnicodeEncodeError sur les
# accents (le FileHandler est déjà en utf-8).
for _stream in (sys.stdout, sys.stderr):
    try:
        _stream.reconfigure(encoding="utf-8")  # type: ignore[attr-defined]
    except Exception:
        pass

# cwd de lancement, capturé AVANT tout chdir (sert à résoudre entrée/sortie).
_LAUNCH_CWD = Path.cwd()

# Résolution des chemins en frozen, alignée sur launcher.py : les datas
# (config/, data/, models/) sont extraites dans _MEIPASS ; on s'y positionne
# pour que le core les trouve. Le log va à côté de l'EXE.
if getattr(sys, "frozen", False):
    _APP_DIR = Path(sys._MEIPASS)  # type: ignore[attr-defined]
    _EXE_DIR = Path(sys.executable).parent
    sys.path.insert(0, str(_APP_DIR))
    os.chdir(str(_APP_DIR))
    _LOG_PATH = _EXE_DIR / "anonymisation_cli.log"
else:
    _APP_DIR = Path(__file__).resolve().parent.parent
    if str(_APP_DIR) not in sys.path:
        sys.path.insert(0, str(_APP_DIR))
    _LOG_PATH = Path("anonymisation_cli.log")

# Extensions acceptées (le core convertit les formats non-PDF en amont via
# format_converter ; ici on collecte large et on laisse le moteur trancher).
_SUPPORTED_EXT = {
    ".pdf", ".docx", ".odt", ".rtf", ".txt", ".html", ".htm",
    ".jpg", ".jpeg", ".png", ".tif", ".tiff", ".bmp",
}

# Le CLI production n'utilise pas le manager ONNX legacy/Optimum
# (NerModelManager). Le désactiver évite un second chargement natif ONNX dans le
# même process Windows/PyInstaller avant CamemBERT-bio, qui est le modèle
# obligatoire du CLI.
os.environ.setdefault("ANON_SKIP_LEGACY_ONNX_MANAGER", "1")

_n_cpu_threads = str(os.cpu_count() or 4)
for _env in ("OMP_NUM_THREADS", "MKL_NUM_THREADS", "OPENBLAS_NUM_THREADS",
             "NUMEXPR_NUM_THREADS", "VECLIB_MAXIMUM_THREADS"):
    os.environ.setdefault(_env, _n_cpu_threads)


def _resolve(p: str) -> Path:
    """Résout un chemin relatif contre le cwd de lancement (pas _MEIPASS)."""
    path = Path(p)
    return path if path.is_absolute() else (_LAUNCH_CWD / path)


def _mandatory_model_path() -> Path:
    """Chemin attendu du modèle ONNX obligatoire (relatif à l'app/_MEIPASS)."""
    return _APP_DIR / "models" / "camembert-bio-deid" / "onnx" / "model.onnx"


def _print_engines() -> int:
    """Affiche les moteurs réellement disponibles dans cet exécutable.

    Diagnostic « honnête » : ne déclare jamais disponible un moteur dont les
    dépendances (ou le modèle, pour CamemBERT) ne chargent pas. Sortie 0.
    """
    from engine_capabilities import capabilities_map

    caps = capabilities_map()
    print("Moteurs d'anonymisation — disponibilité dans cet exécutable :")
    for cap in caps.values():
        mark = "OUI" if cap.available else "NON"
        flag = "requis" if cap.required else "optionnel"
        print(f"  [{mark}] {cap.label} ({flag}) — {cap.reason}")
    return 0


def main(argv: list[str] | None = None) -> int:
    ap = argparse.ArgumentParser(
        prog="Anonymisation-CLI",
        description="Anonymise un fichier (ou dossier) sans GUI.",
    )
    ap.add_argument("input", nargs="?", default=None, help="Fichier unique existant (ou dossier parcouru récursivement)")
    ap.add_argument(
        "output", nargs="?", default=None,
        help="Dossier de sortie (forme positionnelle). Créé si absent.",
    )
    ap.add_argument("--out", default=None, help="Dossier de sortie (compatibilité). Équivaut à l'argument positionnel.")
    ap.add_argument("--limit", type=int, default=0, help="Nombre max de documents (0 = tous ; utile pour un dossier)")
    ap.add_argument("--no-ner", action="store_true", help="Mode regex seul : désactive EDS-Pseudo + CamemBERT (aucun modèle obligatoire)")
    ap.add_argument("--gliner", action="store_true", help="Active aussi GLiNER (optionnel, vote croisé)")
    ap.add_argument("--engines", action="store_true", help="Liste les moteurs réellement disponibles dans cet exécutable et quitte (diagnostic honnête, code 0)")
    ap.add_argument("--config", default=None, help="Chemin config dictionnaires.yml (défaut: runtime)")
    args = ap.parse_args(argv)

    # --- Diagnostic moteurs : honnêteté sur ce que le build embarque réellement ---
    if args.engines:
        return _print_engines()

    if args.input is None:
        ap.error("argument 'input' requis (sauf avec --engines)")

    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s %(levelname)s %(message)s",
        handlers=[
            logging.StreamHandler(sys.stdout),
            logging.FileHandler(str(_LOG_PATH), encoding="utf-8"),
        ],
    )
    log = logging.getLogger("anon_cli")
    log.info("CLI: démarrage frozen=%s python=%s log=%s",
             bool(getattr(sys, "frozen", False)), sys.version.split()[0], _LOG_PATH)

    # Dossier de sortie : positionnel prioritaire, puis --out, sinon défaut explicite.
    out_arg = args.output or args.out or "anonymise_out"
    out_root = _resolve(out_arg)

    # --- Entrée : fichier unique (contrat principal) ou dossier ---
    inp = _resolve(args.input)
    if not inp.exists():
        log.error("CLI: entrée introuvable: %s", inp)
        return 2
    if inp.is_file():
        docs = [inp]
    else:
        docs = sorted(p for p in inp.rglob("*") if p.is_file() and p.suffix.lower() in _SUPPORTED_EXT)
    if args.limit > 0:
        docs = docs[: args.limit]
    if not docs:
        log.error("CLI: aucun document supporté trouvé sous %s", inp)
        return 2

    # --- Modèles ---
    # OBLIGATOIRE (sauf --no-ner) : CamemBERT-bio ONNX. Fail-closed.
    eds_mgr = camembert_mgr = gliner_mgr = None
    if not args.no_ner:
        model_path = _mandatory_model_path()
        if not model_path.exists():
            log.error("CLI: MODÈLE OBLIGATOIRE absent: %s. "
                      "Build incomplet ou ressource manquante. Abandon (pas de mode dégradé). "
                      "Utilisez --no-ner pour forcer le mode regex seul.", model_path)
            return 3
        try:
            from camembert_ner_manager import CamembertNerManager
            camembert_mgr = CamembertNerManager()
            camembert_mgr.load()
            log.info("CLI: CamemBERT-bio ONNX chargé (obligatoire) ✓")
        except Exception as e:  # noqa: BLE001
            log.error("CLI: échec chargement du modèle OBLIGATOIRE CamemBERT-bio ONNX: %s. "
                      "Abandon (pas de mode dégradé silencieux). "
                      "Utilisez --no-ner pour forcer le mode regex seul.", e)
            return 3

        # OPTIONNEL : EDS-Pseudo (best effort, tracé).
        try:
            from eds_pseudo_manager import EdsPseudoManager
            eds_mgr = EdsPseudoManager()
            eds_mgr.load()
            log.info("CLI: EDS-Pseudo chargé (optionnel) ✓")
        except Exception as e:  # noqa: BLE001
            eds_mgr = None
            log.warning("CLI: EDS-Pseudo (optionnel) INDISPONIBLE: %s — traitement poursuivi sans.", e)

        # OPTIONNEL : GLiNER (sur demande).
        if args.gliner:
            try:
                from gliner_manager import GlinerManager
                gliner_mgr = GlinerManager()
                gliner_mgr.load()
                log.info("CLI: GLiNER chargé (optionnel) ✓")
            except Exception as e:  # noqa: BLE001
                gliner_mgr = None
                log.warning("CLI: GLiNER (optionnel) INDISPONIBLE: %s — traitement poursuivi sans.", e)
    else:
        log.warning("CLI: --no-ner -> MODE REGEX SEUL assumé (aucun modèle NER). "
                    "Qualité réduite : à n'utiliser qu'en connaissance de cause.")

    import anonymizer_core_refactored_onnx as core

    # H1 : aligne les threads torch (idempotent).
    if hasattr(core, "_configure_torch_threads"):
        core._configure_torch_threads()

    use_ner = bool(eds_mgr or gliner_mgr or camembert_mgr)
    log.info("CLI: %d document(s), ner=%s (camembert=%s eds=%s gliner=%s) -> sortie=%s",
             len(docs), use_ner, bool(camembert_mgr), bool(eds_mgr), bool(gliner_mgr), out_root)

    config_path = _resolve(args.config) if args.config else None
    process_fn = getattr(core, "process_document", None) or core.process_pdf
    path_key = "doc_path" if process_fn.__name__ == "process_document" else "pdf_path"
    out_root.mkdir(parents=True, exist_ok=True)

    t0 = time.perf_counter()
    ok = ko = quarantined = no_output = 0
    for i, doc in enumerate(docs, 1):
        td = time.perf_counter()
        try:
            res = process_fn(
                **{path_key: doc},
                out_dir=out_root,
                make_vector_redaction=False,
                also_make_raster_burn=True,
                config_path=config_path,
                use_hf=use_ner,
                ner_manager=eds_mgr,
                gliner_manager=gliner_mgr,
                camembert_manager=camembert_mgr,
                ogc_label=None,
            )
            dt = time.perf_counter() - td
            if isinstance(res, dict) and res.get("status") == "quarantined":
                quarantined += 1
                log.warning("CLI: [%d/%d] %s QUARANTAINE reason=%s %.2fs — sortie NON produite.",
                            i, len(docs), doc.name, res.get("reason", "?"), dt)
                continue
            # Vérifie qu'une sortie anonymisée a bien été produite.
            produced = []
            if isinstance(res, dict):
                produced = [v for k, v in res.items() if k.startswith("pdf") and v and Path(v).exists()]
            if not produced:
                no_output += 1
                log.error("CLI: [%d/%d] %s AUCUNE sortie PDF produite (%.2fs).", i, len(docs), doc.name, dt)
                continue
            ok += 1
            log.info("CLI: [%d/%d] %s OK %.2fs -> %s", i, len(docs), doc.name, dt,
                     ", ".join(Path(p).name for p in produced))
        except Exception as e:  # noqa: BLE001
            ko += 1
            log.error("CLI: [%d/%d] %s ERREUR: %s", i, len(docs), doc.name, e)

    total = time.perf_counter() - t0
    log.info("CLI: DONE total=%.1fs ok=%d quarantined=%d no_output=%d ko=%d",
             total, ok, quarantined, no_output, ko)

    # Codes retour : priorité aux échecs durs.
    if ko:
        return 1
    if quarantined or no_output:
        return 4
    return 0 if ok else 4


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