"""Deterministische semantische Recall-Schicht für den DSE-Check.

WARUM: Reines Keyword-Matching hat schlechten Recall (eine Pflicht lässt sich
auf viele Arten formulieren). Der frühere Regex-Boost war zu stumpf (über-passt
auf vollständigen Dokumenten). BGE-M3-Embeddings erkennen den SINN — und sind
dabei DETERMINISTISCH: ein Embedding-Modell ist eine feste Funktion, gleicher
Text → gleicher Vektor → gleiches Pass/Fail bei fester Schwelle. Reproduzierbar,
auditierbar, kein Keyword-Katalog, kein generatives LLM zur Checkzeit.

Design:
  - Doc wird EINMAL pro Dokument-Hash eingebettet (teuer: ~37s/64k-Doc), die
    Per-Control-Scores werden gecacht (/data) → Folge-Checks sind instant.
  - Reachability-Guard: ist der Embedding-Service nicht erreichbar, liefert die
    Schicht leer zurück (der deterministische Keyword-Layer trägt) — KEIN Hang.
  - Schwelle ist die einzige Stellschraube (DSE-Default 0.65, an BMW-GT kalibriert;
    braucht Mehr-Firmen-Kalibrierung gegen Overfitting — bewusst konservativ).
"""

from __future__ import annotations

import asyncio
import hashlib
import json
import logging
import os
import sqlite3
from typing import Iterable

logger = logging.getLogger(__name__)

# DSE-Schwelle: an BMW-Haiku-GT vermessen (PASS-Median 0.648 / FAIL-Median 0.612).
# 0.65 = präzisionsfreundlich (wenig Über-Pass). Per ENV überschreibbar für
# spätere Mehr-Firmen-Kalibrierung, ohne Code-Änderung.
DSE_EMBED_THRESHOLD = float(os.getenv("DSE_EMBED_THRESHOLD", "0.65"))
_CACHE_PATH = os.getenv("DSE_EMBED_CACHE", "/data/dse_embed_cache.json")
_SIDECAR_DB = os.getenv("MC_CLASS_DB", "/data/mc_classification.db")


def _doc_hash(text: str) -> str:
    return hashlib.sha256(text.encode("utf-8", "ignore")).hexdigest()[:20]


def _load_cache() -> dict:
    try:
        with open(_CACHE_PATH, encoding="utf-8") as f:
            return json.load(f)
    except Exception:
        return {}


def _save_cache(cache: dict) -> None:
    try:
        # LRU-Kappung: max 30 Dokumente im Cache (Scores sind klein)
        if len(cache) > 30:
            for k in list(cache.keys())[:-30]:
                cache.pop(k, None)
        tmp = _CACHE_PATH + ".tmp"
        with open(tmp, "w", encoding="utf-8") as f:
            json.dump(cache, f)
        os.replace(tmp, _CACHE_PATH)
    except Exception as e:
        logger.warning("dse embed-cache save failed: %s", e)


def _load_control_vecs(cids: Iterable[str]) -> dict[str, list[float]]:
    from compliance.services.mc_embedding_matcher import _blob_to_vec
    cid_list = [c for c in cids if c]
    if not cid_list:
        return {}
    try:
        with sqlite3.connect(_SIDECAR_DB) as c:
            ph = ",".join("?" * len(cid_list))
            rows = c.execute(
                f"SELECT control_id, embedding FROM mc_classification "
                f"WHERE control_id IN ({ph}) AND doc_type='dse' "
                f"AND check_type='text' AND embedding IS NOT NULL",
                cid_list,
            ).fetchall()
        return {cid: _blob_to_vec(b) for cid, b in rows}
    except Exception as e:
        logger.warning("dse control-vec load failed: %s", e)
        return {}


async def _embedding_reachable(timeout: float = 2.0) -> bool:
    """Schneller TCP-Connect zum Embedding-Service. Verhindert, dass ein toter
    Service den Check blockiert (macmini-Lehrer-Last hat das Embedding früher
    verstopft)."""
    url = os.getenv("EMBEDDING_URL", "http://embedding-service:8087")
    hostport = url.split("://", 1)[-1].split("/", 1)[0]
    host, _, port = hostport.partition(":")
    port = int(port or "8087")
    try:
        fut = asyncio.open_connection(host, port)
        reader, writer = await asyncio.wait_for(fut, timeout=timeout)
        writer.close()
        try:
            await writer.wait_closed()
        except Exception:
            pass
        return True
    except Exception as e:
        logger.warning("dse embedding-service nicht erreichbar (%s) — "
                       "deterministischer Layer trägt", e)
        return False


async def _compute_scores(text: str, all_cids: list[str]) -> dict[str, float]:
    """Bettet das Dokument EINMAL ein und liefert max-Cosinus je Control."""
    from compliance.services.mc_embedding_matcher import (
        _chunk_text, _cosine, _embed_texts, DIM,
    )
    mc_vecs = _load_control_vecs(all_cids)
    if not mc_vecs:
        return {}
    chunks = _chunk_text(text)
    if not chunks:
        return {}
    chunk_vecs = await _embed_texts(chunks)
    chunk_vecs = [v for v in chunk_vecs if v and len(v) == DIM]
    if not chunk_vecs:
        return {}
    return {
        cid: round(float(max((_cosine(mv, cv) for cv in chunk_vecs),
                             default=0.0)), 4)
        for cid, mv in mc_vecs.items()
    }


async def embedding_recall(
    text: str,
    candidate_cids: Iterable[str],
    threshold: float | None = None,
    embed_timeout: float = 90.0,
) -> set[str]:
    """Returns die candidate control_ids, die semantisch (>= Schwelle) im Doc
    vorkommen. Deterministisch + gecacht. Leeres Set, wenn Service down/Fehler.

    candidate_cids: die im Keyword-Layer DURCHGEFALLENEN Controls (Recall-Rescue).
    """
    cands = [c for c in candidate_cids if c]
    if not text or len(text) < 100 or not cands:
        return set()
    thr = DSE_EMBED_THRESHOLD if threshold is None else threshold

    h = _doc_hash(text)
    cache = _load_cache()
    scores = cache.get(h)

    if scores is None:
        if not await _embedding_reachable():
            return set()
        try:
            scores = await asyncio.wait_for(
                _compute_scores(text, cands), timeout=embed_timeout)
        except (Exception, asyncio.TimeoutError) as e:
            logger.warning("dse embedding_recall skipped: %s", e)
            return set()
        if not scores:
            return set()
        cache[h] = scores
        _save_cache(cache)
        logger.info("dse embedding_recall: doc %s eingebettet (%d Scores)",
                    h, len(scores))
    else:
        logger.info("dse embedding_recall: Cache-Treffer doc %s", h)

    cand_set = set(cands)
    return {cid for cid, s in scores.items()
            if cid in cand_set and s >= thr}
