"""Thin AI client wrapper used by SME-Hub matchmaking + future modules.

Currently exposes ``recommend`` and ``summarise``. Both fall back to
deterministic / passthrough behaviour when OPENAI_API_KEY is missing or
the LangChain stack is unreachable, so the rest of the app stays
testable without external services.
"""
from __future__ import annotations

import json
import logging
import math
from collections.abc import Iterable, Sequence

from django.conf import settings

logger = logging.getLogger(__name__)


def _has_openai_key() -> bool:
    key = getattr(settings, "OPENAI_API_KEY", "") or ""
    return bool(key.strip())


def _chat_fast():
    from langchain_openai import ChatOpenAI

    return ChatOpenAI(
        model=getattr(settings, "LANGCHAIN_CHAT_MODEL_FAST", "gpt-4o-mini"),
        temperature=0,
        api_key=settings.OPENAI_API_KEY,
        timeout=20,
    )


def _cosine(a: Sequence[float], b: Sequence[float]) -> float:
    if not a or not b:
        return 0.0
    n = min(len(a), len(b))
    num = sum(a[i] * b[i] for i in range(n))
    da = math.sqrt(sum(x * x for x in a[:n])) or 1.0
    db = math.sqrt(sum(x * x for x in b[:n])) or 1.0
    return num / (da * db)


def _jaccard(a: Iterable[str], b: Iterable[str]) -> float:
    sa, sb = set(a), set(b)
    if not sa and not sb:
        return 0.0
    return len(sa & sb) / len(sa | sb) if (sa | sb) else 0.0


def _deterministic_score(profile: dict, candidate: dict) -> tuple[float, str]:
    """Hand-rolled ranker used when no AI backend is available.

    Combines tag overlap (sectors, geo) with a small bonus when the
    candidate has a matching org-type / stage hint. Returns a score in
    [0, 1] and a one-line reason string.
    """
    sector_overlap = _jaccard(profile.get("sectors", []), candidate.get("sectors", []))
    geo_overlap = _jaccard(profile.get("geo", []), candidate.get("geo", []))
    stage_match = 1.0 if profile.get("stage") and profile.get("stage") in candidate.get("stages", []) else 0.0
    score = (sector_overlap * 0.55) + (geo_overlap * 0.30) + (stage_match * 0.15)
    reasons = []
    if sector_overlap:
        reasons.append(f"sector overlap {int(sector_overlap * 100)}%")
    if geo_overlap:
        reasons.append(f"geographic fit {int(geo_overlap * 100)}%")
    if stage_match:
        reasons.append("matches business stage")
    if not reasons:
        reasons.append("baseline match")
    return round(score, 4), "; ".join(reasons)


def _llm_score(profile: dict, candidates: list[dict]) -> dict[str, dict]:
    """Ask the LLM to score each candidate. Returns ``{id: {score, reason}}``.

    Raises on any failure so the caller can fall back deterministically.
    """
    from langchain_core.messages import HumanMessage, SystemMessage

    payload = {
        "profile": {
            "sectors": list(profile.get("sectors", [])),
            "geo": list(profile.get("geo", [])),
            "stage": profile.get("stage", ""),
            "stages": list(profile.get("stages", [])),
        },
        "candidates": [
            {
                "id": str(c.get("id")),
                "label": c.get("label", ""),
                "kind": c.get("kind", ""),
                "sectors": list(c.get("sectors", [])),
                "geo": list(c.get("geo", [])),
                "stages": list(c.get("stages", [])),
                "org_type": c.get("org_type", ""),
            }
            for c in candidates
        ],
    }
    system = (
        "You score how well each candidate matches the entrepreneur profile for "
        "investment / partnership matchmaking on the RUFORUM IILMP platform. "
        "Consider sector overlap, geography, and stage fit. "
        'Respond with strict JSON: {"items": [{"id": "<id>", "score": <0-1 float>, '
        '"reason": "<short one-line justification, <=120 chars>"}]}. '
        "Include every candidate id exactly once. No prose outside the JSON."
    )
    user = "Score these candidates:\n" + json.dumps(payload, ensure_ascii=False)
    raw = _chat_fast().invoke([SystemMessage(system), HumanMessage(user)])
    text = (raw.content or "").strip()
    if text.startswith("```"):
        # tolerate accidental code-fence wrapping
        text = text.strip("`")
        if text.lower().startswith("json"):
            text = text[4:]
        text = text.strip()
    parsed = json.loads(text)
    out: dict[str, dict] = {}
    for item in parsed.get("items", []):
        cid = str(item.get("id"))
        try:
            score = max(0.0, min(1.0, float(item.get("score", 0.0))))
        except (TypeError, ValueError):
            score = 0.0
        out[cid] = {
            "score": round(score, 4),
            "reason": str(item.get("reason", "")).strip()[:200] or "matched by AI",
        }
    return out


def recommend(
    profile: dict,
    candidates: list[dict],
    *,
    top_k: int = 10,
) -> list[dict]:
    """Rank ``candidates`` against ``profile`` and return the top-k.

    Each candidate dict must include an ``id``, optional ``label`` and
    feature lists (``sectors``, ``geo``, ``stages``). Returns a new list
    of dicts with ``id``, ``score``, ``reason``, ``is_fallback``.

    The function never raises — it logs and falls back to the
    deterministic ranker so callers never need to wrap in try/except.
    """
    if not candidates:
        return []
    is_fallback = True
    llm_results: dict[str, dict] = {}
    if _has_openai_key():
        try:
            llm_results = _llm_score(profile, candidates)
            if llm_results:
                is_fallback = False
        except Exception as exc:  # noqa: BLE001 — never block on AI failure
            logger.warning("AI recommend backend unavailable, using deterministic ranker: %s", exc)
            is_fallback = True
            llm_results = {}
    ranked: list[dict] = []
    for candidate in candidates:
        cid = candidate.get("id")
        ai_hit = llm_results.get(str(cid)) if llm_results else None
        if ai_hit is not None:
            score = ai_hit["score"]
            reason = ai_hit["reason"]
        else:
            score, reason = _deterministic_score(profile, candidate)
        ranked.append(
            {
                "id": cid,
                "label": candidate.get("label", ""),
                "kind": candidate.get("kind", ""),
                "score": score,
                "reason": reason,
                "is_fallback": is_fallback,
            }
        )
    # sort by score desc; tie-break on label then id for determinism
    ranked.sort(key=lambda r: (-r["score"], str(r.get("label") or ""), str(r.get("id") or "")))
    return ranked[:top_k]


def summarise(text: str, *, max_chars: int = 280) -> str:
    """Return a short summary of ``text``. Falls back to a clipped excerpt
    when no AI backend is configured (sufficient for tests + local dev).
    """
    text = (text or "").strip()
    if not text:
        return ""
    if not _has_openai_key():
        return text[:max_chars].rstrip() + ("…" if len(text) > max_chars else "")
    try:
        from langchain_core.messages import HumanMessage, SystemMessage

        system = (
            "You write neutral, factual one-paragraph summaries. "
            f"Keep responses under {max_chars} characters. No markdown, no preamble."
        )
        result = _chat_fast().invoke(
            [SystemMessage(system), HumanMessage(text[:8000])]
        )
        out = (result.content or "").strip()
        if not out:
            raise ValueError("empty LLM response")
        if len(out) > max_chars:
            out = out[:max_chars].rstrip() + "…"
        return out
    except Exception as exc:  # noqa: BLE001 — never block on AI failure
        logger.warning("AI summarise backend unavailable, returning clipped text: %s", exc)
        return text[:max_chars].rstrip() + ("…" if len(text) > max_chars else "")
