"""Optional open-set NER tier via GLiNER (R-11), with silent degrade.

Installed through the `ner` extra (`pip install privsweep[ner]`), which pulls gliner and
torch only then. When present, GLiNER catches names in running prose that no frame
anchored; findings merge into scan and sanitize. When absent or failing to load, the
tier is skipped with a single informational note and the run still succeeds.

The model loader is isolated in `_load_model` so the merge logic is testable with a stub
when torch is not installed; the real integration uses the same `ner_matches` path.
"""
from __future__ import annotations

from typing import Optional

from .detectors.base import Match
from .models import Finding, Report

GLINER_LABELS = ["person", "organization", "location"]
_LABEL_TO_TYPE = {
    "person": "person_name",
    "organization": "entity_name",
    "location": "address",
}
_MODEL = None
_MODEL_NAME = "urchade/gliner_small-v2.1"


def _load_model():
    from gliner import GLiNER  # imported only when the extra is installed

    return GLiNER.from_pretrained(_MODEL_NAME)


def _get_model():
    global _MODEL
    if _MODEL is None:
        _MODEL = _load_model()
    return _MODEL


def ner_matches(text: str, model=None) -> list[Match]:
    model = model or _get_model()
    out: list[Match] = []
    for ent in model.predict_entities(text, GLINER_LABELS):
        ftype = _LABEL_TO_TYPE.get(ent.get("label"))
        if not ftype:
            continue
        start, end = ent["start"], ent["end"]
        word = ent.get("text", text[start:end])
        role = "unknown" if ftype in ("person_name", "entity_name") else None
        out.append(Match("ner.gliner", ftype, start, end, word, preview=word,
                         role=role, confidence=ent.get("score"),
                         detail={"ner_label": ent.get("label")}))
    return out


def ner_name_matches(text: str) -> list[Match]:
    """Person/entity matches for the sanitize merge; [] if the tier is unavailable."""
    try:
        model = _get_model()
    except Exception:  # noqa: BLE001 - any load failure means "unavailable"
        return []
    try:
        return [m for m in ner_matches(text, model)
                if m.type in ("person_name", "entity_name")]
    except Exception:  # noqa: BLE001
        return []


def augment_with_ner(paths, report: Report) -> None:
    try:
        model = _get_model()
    except ImportError:
        report.note("NER tier unavailable; install privsweep[ner] for open-set "
                    "name detection in running prose.")
        return
    except Exception as exc:  # noqa: BLE001
        report.note(f"NER tier failed to load ({type(exc).__name__}); skipped.")
        return

    from .engine import (_effective_kind, _normalize_paths, locator_for_offset,
                         walk_paths)
    from .extract import extract

    existing = [(f.file, f.span) for f in report.findings if f.span]
    for fp in walk_paths(_normalize_paths(paths)):
        if _effective_kind(fp, None) in (None, "image", "eml"):
            continue
        try:
            doc = extract(fp)
            for m in ner_matches(doc.full_text, model):
                # Residual only: skip names already anchored by a frame in this file.
                if any(ef == fp and es and not (m.end <= es[0] or m.start >= es[1])
                       for ef, es in existing):
                    continue
                report.add(Finding(
                    detector=m.detector, type=m.type, severity=m.severity, file=fp,
                    location=locator_for_offset(doc, m.start), span=(m.start, m.end),
                    preview=m.preview, role=m.role, confidence=m.confidence,
                    detail=m.detail))
        except Exception:  # noqa: BLE001 - one bad file must not abort augmentation
            continue
