"""Deterministic scope resolution from query + derived DOCS catalog (pre-LLM).

Matches the user query against catalog terms (exact / normalized). Optional embedding-based
retrieval can extend candidates later; final ``scope_type`` never relies on embeddings alone.
"""

from __future__ import annotations

import re
from dataclasses import dataclass, field

from app.core.agent.processes.v2.intent_router.modules.scope_catalog import DocsScopeCatalog
from app.core.agent.processes.v2.intent_router.modules.target_terms import TargetTermsAnalysis
from app.core.agent.utils.process_v2.models import ScopeCandidate, V2ScopeType


_SCORE_EXACT = 1.0
_SCORE_NORMALIZED = 0.88
_SCORE_SOFT = 0.72
_STRONG_THRESHOLD = 0.85

_ENUM_MARKERS_RU = (
    "какие ",
    "какие\n",
    "какой ",
    "какого ",
    "список",
    "перечисли",
    "перечислить",
    "все api",
    "все методы",
    "какие api",
    "какие методы",
    "каких ",
)
_SINGLE_SEGMENT_ENDPOINT_ALLOWLIST = frozenset(
    {
        "/health",
        "/send",
        "/healthz",
        "/ready",
        "/live",
        "/metrics",
    }
)

_PROJECT_WIDE_MARKERS = (
    "в проекте",
    "в системе",
    "в приложении",
    "по проекту",
    "во всем проекте",
    "overall",
    "in the project",
)


@dataclass(slots=True)
class ScopeResolution:
    scope_type: str = V2ScopeType.UNKNOWN
    candidate_domains: list[ScopeCandidate] = field(default_factory=list)
    candidate_subdomains: list[ScopeCandidate] = field(default_factory=list)
    candidate_entities: list[ScopeCandidate] = field(default_factory=list)
    candidate_apis: list[ScopeCandidate] = field(default_factory=list)
    strong_domain: str | None = None
    strong_subdomain: str | None = None
    strong_entity_names: list[str] = field(default_factory=list)
    strong_endpoint_paths: list[str] = field(default_factory=list)
    catalog_loaded: bool = False


def _catalog_has_index_terms(catalog: DocsScopeCatalog) -> bool:
    return bool(
        catalog.domain_values
        or catalog.subdomain_pairs
        or catalog.entity_records
        or catalog.api_records
    )


def plausible_doc_endpoint_paths(paths: list[str]) -> list[str]:
    """Drop spurious ``/token`` paths from api-like heuristics (e.g. ``/billing`` after ``api``)."""
    out: list[str] = []
    for raw in paths:
        p = str(raw or "").strip().lower()
        if not p.startswith("/"):
            continue
        segments = [s for s in p.split("/") if s]
        if len(segments) >= 2:
            out.append(p)
            continue
        if len(segments) == 1 and p in _SINGLE_SEGMENT_ENDPOINT_ALLOWLIST:
            out.append(p)
            continue
    return out


def resolve_docs_scope(
    normalized_query: str,
    terms: TargetTermsAnalysis,
    catalog: DocsScopeCatalog | None,
) -> ScopeResolution:
    """Lexical scope resolution; embeddings never set final scope alone (not used here)."""
    resolution = ScopeResolution()
    if catalog is None:
        return resolution
    if not _catalog_has_index_terms(catalog):
        return resolution

    resolution.catalog_loaded = True
    query_l = _norm_query(normalized_query)
    if not query_l:
        resolution.scope_type = V2ScopeType.UNKNOWN
        return resolution

    _collect_domain_candidates(query_l, catalog, resolution)
    _collect_subdomain_candidates(query_l, catalog, resolution)
    _collect_entity_candidates(query_l, catalog, resolution)
    _collect_api_candidates(query_l, catalog, resolution)

    _dedupe_candidates(resolution)

    endpoint_paths = plausible_doc_endpoint_paths(list(terms.endpoint_paths))
    strong_api = _pick_strong(resolution.candidate_apis)
    strong_entity = _pick_strong(resolution.candidate_entities)
    strong_sub = _pick_strong(resolution.candidate_subdomains)
    strong_dom = _pick_strong(resolution.candidate_domains)

    resolution.strong_endpoint_paths = list(dict.fromkeys(endpoint_paths))

    if endpoint_paths:
        resolution.scope_type = V2ScopeType.ENTITY
        resolution.strong_entity_names = _merge_unique(resolution.strong_entity_names, _entities_for_endpoints(endpoint_paths, catalog))
        return resolution

    if strong_api and strong_api.score >= _STRONG_THRESHOLD:
        resolution.scope_type = V2ScopeType.ENTITY
        resolution.strong_endpoint_paths = _merge_unique(resolution.strong_endpoint_paths, [strong_api.value])
        return resolution

    strong_sub_pre = _pick_strong(resolution.candidate_subdomains)
    if (
        strong_sub_pre
        and strong_sub_pre.score >= _STRONG_THRESHOLD
        and _subdomain_aligned_with_query(query_l, strong_sub_pre.value)
    ):
        resolution.scope_type = V2ScopeType.SUBDOMAIN
        parts = _split_subdomain_value(strong_sub_pre.value)
        if parts:
            resolution.strong_domain = parts[0]
            resolution.strong_subdomain = parts[1]
        return resolution

    if strong_entity and strong_entity.score >= _STRONG_THRESHOLD:
        resolution.scope_type = V2ScopeType.ENTITY
        resolution.strong_entity_names = _merge_unique(
            resolution.strong_entity_names,
            [strong_entity.value],
        )
        return resolution

    if strong_sub and strong_sub.score >= _STRONG_THRESHOLD:
        resolution.scope_type = V2ScopeType.SUBDOMAIN
        parts = _split_subdomain_value(strong_sub.value)
        if parts:
            resolution.strong_domain = parts[0]
            resolution.strong_subdomain = parts[1]
        return resolution

    if strong_dom and strong_dom.score >= _STRONG_THRESHOLD:
        resolution.scope_type = V2ScopeType.DOMAIN
        resolution.strong_domain = strong_dom.value
        return resolution

    if _is_global_enumeration(query_l, has_strong_any=bool(_any_strong(resolution))):
        resolution.scope_type = V2ScopeType.GLOBAL
        return resolution

    resolution.scope_type = V2ScopeType.UNKNOWN
    return resolution


def promote_target_terms(
    raw_terms: list[str],
    terms: TargetTermsAnalysis,
    resolution: ScopeResolution,
) -> list[str]:
    """Keep only high-confidence terms in ``target_terms``; weak matches stay in candidate_* only."""
    if not resolution.catalog_loaded:
        return list(raw_terms)
    out: list[str] = []
    strong_values = {c.value for c in _all_candidates(resolution) if c.score >= _STRONG_THRESHOLD}
    strong_values |= {c.value for c in _all_candidates(resolution) if c.match_type == "exact"}
    strong_entity = set(resolution.strong_entity_names)
    endpoints = set(terms.endpoint_paths)
    aliases = set(terms.matched_aliases)

    for term in raw_terms:
        t = str(term or "").strip()
        if not t:
            continue
        tl = t.lower()
        if t in endpoints or tl in {e.lower() for e in endpoints}:
            _append_unique(out, tl if tl.startswith("/") else t)
            continue
        if t in aliases or tl in {a.lower() for a in aliases}:
            _append_unique(out, tl)
            continue
        if tl in strong_values or t in strong_entity:
            _append_unique(out, tl)
            continue
        if _is_explicit_identifier(t) and tl in strong_entity:
            _append_unique(out, tl)
            continue
        # Drop weak/ungrounded terms (remain only in candidates on anchors)
    return out


def _all_candidates(resolution: ScopeResolution) -> list[ScopeCandidate]:
    return [
        *resolution.candidate_domains,
        *resolution.candidate_subdomains,
        *resolution.candidate_entities,
        *resolution.candidate_apis,
    ]


def _any_strong(resolution: ScopeResolution) -> bool:
    return any(c.score >= _STRONG_THRESHOLD for c in _all_candidates(resolution))


def _pick_strong(candidates: list[ScopeCandidate]) -> ScopeCandidate | None:
    if not candidates:
        return None
    return max(candidates, key=lambda c: (c.score, len(c.value)))


def _norm_query(q: str) -> str:
    return re.sub(r"\s+", " ", str(q or "").strip().lower())


def _append_unique(items: list[str], value: str) -> None:
    if value and value not in items:
        items.append(value)


def _merge_unique(a: list[str], b: list[str]) -> list[str]:
    return list(dict.fromkeys([*a, *b]))


def _is_explicit_identifier(token: str) -> bool:
    return bool(re.fullmatch(r"[A-Za-z][A-Za-z0-9_]+", token))


def _split_subdomain_value(value: str) -> tuple[str, str] | None:
    parts = str(value or "").split("::", 1)
    if len(parts) == 2 and parts[0] and parts[1]:
        return parts[0].strip().lower(), parts[1].strip().lower()
    return None


def _subdomain_aligned_with_query(query_l: str, composite: str) -> bool:
    """True when both domain and subdomain tokens match the query (substring / token match)."""
    parts = str(composite or "").split("::", 1)
    if len(parts) != 2:
        return False
    dom, sub = parts[0].strip().lower(), parts[1].strip().lower()
    s_dom, _ = _match_score(query_l, dom)
    s_sub, _ = _match_score(query_l, sub)
    return s_dom > 0 and s_sub > 0


def _entities_for_endpoints(endpoint_paths: list[str], catalog: DocsScopeCatalog) -> list[str]:
    found: list[str] = []
    eps = {e.lower() for e in endpoint_paths if e}
    for rec in catalog.entity_records:
        blob = str(rec.get("blob") or "").lower()
        name = str(rec.get("name") or "").strip().lower()
        if not name:
            continue
        if any(ep and ep in blob for ep in eps):
            _append_unique(found, name)
    return found


def _collect_domain_candidates(query_l: str, catalog: DocsScopeCatalog, resolution: ScopeResolution) -> None:
    for dom in catalog.domain_values:
        if not dom:
            continue
        score, mtype = _match_score(query_l, dom)
        if score <= 0:
            continue
        resolution.candidate_domains.append(
            ScopeCandidate(
                value=dom,
                score=score,
                source_layer="D1_DOCUMENT_CATALOG",
                match_type=mtype,
            )
        )


def _collect_subdomain_candidates(query_l: str, catalog: DocsScopeCatalog, resolution: ScopeResolution) -> None:
    seen: set[str] = set()
    for dom, sub in catalog.subdomain_pairs:
        if not dom or not sub:
            continue
        composite = f"{dom}::{sub}"
        if composite in seen:
            continue
        seen.add(composite)
        score_dom, _ = _match_score(query_l, dom)
        score_sub, mt_sub = _match_score(query_l, sub)
        phrase = _phrase_score(query_l, dom, sub)
        if phrase > 0:
            score = phrase
            mt = "normalized"
        elif score_dom > 0 and score_sub > 0:
            score = min(score_dom, score_sub)
            mt = mt_sub
        else:
            # Avoid promoting a (domain, subdomain) pair when only the domain token matches.
            score = 0.0
            mt = mt_sub
        if score <= 0:
            continue
        resolution.candidate_subdomains.append(
            ScopeCandidate(
                value=composite,
                score=score,
                source_layer="D1_DOCUMENT_CATALOG",
                match_type=mt,
            )
        )


def _collect_entity_candidates(query_l: str, catalog: DocsScopeCatalog, resolution: ScopeResolution) -> None:
    for rec in catalog.entity_records:
        name = str(rec.get("name") or "").strip().lower()
        if not name or len(name) < 2:
            continue
        blob = str(rec.get("blob") or "").lower()
        layer = str(rec.get("source_layer") or "")
        score, mtype = _match_entity(query_l, name, blob)
        if score <= 0:
            continue
        resolution.candidate_entities.append(
            ScopeCandidate(value=name, score=score, source_layer=layer, match_type=mtype)
        )


def _collect_api_candidates(query_l: str, catalog: DocsScopeCatalog, resolution: ScopeResolution) -> None:
    for rec in catalog.api_records:
        ep = str(rec.get("endpoint") or "").strip().lower()
        if not ep:
            continue
        layer = str(rec.get("source_layer") or "")
        score, mtype = _match_score(query_l, ep.replace(" ", ""))
        if score <= 0:
            continue
        resolution.candidate_apis.append(
            ScopeCandidate(value=ep, score=score, source_layer=layer, match_type=mtype)
        )


def _phrase_score(query_l: str, dom: str, sub: str) -> float:
    if _contains_token(query_l, dom) and _contains_token(query_l, sub):
        return max(_SCORE_NORMALIZED, 0.9)
    joined = re.sub(r"\s+", " ", f"{dom} {sub}".strip())
    if joined in query_l or query_l in joined:
        return _SCORE_NORMALIZED
    return 0.0


def _match_entity(query_l: str, name: str, blob: str) -> tuple[float, str]:
    score, mt = _match_score(query_l, name)
    if score > 0:
        return score, mt
    if name in blob and len(name) >= 4:
        # cross-language hints: name appears in catalog blob; small boost if query token overlaps blob
        q_tokens = set(query_l.split())
        b_tokens = set(blob.split())
        overlap = q_tokens & b_tokens
        if overlap and (q_tokens & {name} or name[:4] in query_l):
            return _SCORE_SOFT, "normalized"
    return 0.0, "normalized"


def _match_score(query_l: str, value: str) -> tuple[float, str]:
    v = str(value or "").strip().lower()
    if not v:
        return 0.0, "normalized"
    v_compact = v.replace(" ", "")
    q_compact = query_l.replace(" ", "")
    if v == query_l or v_compact == q_compact:
        return _SCORE_EXACT, "exact"
    if _contains_token(query_l, v) or _contains_token(query_l, v.replace("/", " ")):
        return _SCORE_EXACT, "exact"
    if v in q_compact or v_compact in q_compact:
        return _SCORE_NORMALIZED, "normalized"
    if v in query_l:
        return _SCORE_NORMALIZED, "normalized"
    # prefix / slug
    for token in query_l.split():
        if token.startswith(v[: min(4, len(v))]) and len(v) >= 4:
            return _SCORE_SOFT, "normalized"
    return 0.0, "normalized"


def _contains_token(hay: str, needle: str) -> bool:
    if not needle:
        return False
    return f" {needle} " in f" {hay} "


def _dedupe_candidates(resolution: ScopeResolution) -> None:
    resolution.candidate_domains = _dedupe_list(resolution.candidate_domains)
    resolution.candidate_subdomains = _dedupe_list(resolution.candidate_subdomains)
    resolution.candidate_entities = _dedupe_list(resolution.candidate_entities)
    resolution.candidate_apis = _dedupe_list(resolution.candidate_apis)


def _dedupe_list(items: list[ScopeCandidate]) -> list[ScopeCandidate]:
    best: dict[str, ScopeCandidate] = {}
    for c in items:
        key = f"{c.value}|{c.source_layer}"
        prev = best.get(key)
        if prev is None or c.score > prev.score:
            best[key] = c
    return sorted(best.values(), key=lambda c: (-c.score, c.value))


def _is_global_enumeration(query_l: str, *, has_strong_any: bool) -> bool:
    if has_strong_any:
        return False
    if any(m in query_l for m in _PROJECT_WIDE_MARKERS) and any(
        m in query_l for m in ("какие", "какой", "список", "перечисли", "метод", "api")
    ):
        return True
    if any(query_l.strip().startswith(m.strip()) for m in _ENUM_MARKERS_RU if len(m.strip()) > 2):
        if any(k in query_l for k in ("метод", "api", "ручк", "эндпоинт")):
            return True
    return False
