"""The fact lane: extractive answers for single-fact questions.

A question that names one resolvable symbol - a constant's value, a
function's definition - does not need a ranked context pack. The graph
already holds the answer; this module extracts it as a small card the model
verifies instead of a large pack it reads. Precision over recall throughout:
a false positive replaces the pack a question needed, a false negative only
falls through to normal retrieval.
"""

from __future__ import annotations

import re
from pathlib import Path

from . import graph as graph_module
from . import store, tokens

FACT_BODY_CAP = 1200
FACT_CONST_CAP = 600
COMMENT_LOOKBACK = 6
AMBIGUITY_CAP = 5
MAX_FACTS = 2
MAX_QUERY_WORDS = 28
MAX_CANDIDATES = 3

QUESTION_WORDS = frozenset({"what", "which", "where", "when", "how", "why", "does", "is", "are"})
EDIT_WORDS = frozenset(
    "set change update rename fix refactor implement bump increase decrease remove delete rewrite add make".split()
)
USAGE_WORDS = frozenset({"callers", "usages", "callsites", "used", "calls", "invocations", "references"})

CONST_KINDS = frozenset({"const"})
CODE_KINDS = frozenset({"function", "method", "class", "interface", "type", "enum", "struct", "trait", "module"})
COMMENT_PREFIXES = ("#", "//", "/*", "*", "--")

_SCREAMING = re.compile(r"^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$")
_ALL_CAPS_WORD = re.compile(r"^[A-Z][A-Z0-9]{2,}$")
_MIXED_CASE = re.compile(r"^[A-Za-z_$][\w$]*$")

def _miss() -> dict:
    return {"facts": [], "confidence": "none", "candidates": []}


def _fact_index(graph: graph_module.Graph) -> dict[str, list[tuple[str, dict]]]:
    """Leaf name (lowered) -> [(rel, symbol record)], cached on the graph object."""
    cached = getattr(graph, "_fact_index", None)
    if cached is not None:
        return cached
    index: dict[str, list[tuple[str, dict]]] = {}
    for rel, record in graph.files.items():
        for symbol in record["symbols"]:
            name = symbol.get("name")
            if not isinstance(name, str):
                continue
            leaf = name.rsplit(".", 1)[-1]
            if len(leaf) >= 3:
                index.setdefault(leaf.lower(), []).append((rel, symbol))
    graph._fact_index = index  # noqa: SLF001 - cache lives with the cached graph
    return index


def _question_shaped(query: str) -> bool:
    words = query.split()
    if not words or len(words) > MAX_QUERY_WORDS:
        return False
    # Imperatives lead with the verb ("Set X to 5"); a question mentioning the
    # same verb mid-sentence ("what is X set to?") is still a value lookup.
    first = words[0].strip("?,.:;!\"'()").lower()
    if first in EDIT_WORDS:
        return False
    lowered = {word.strip("?,.:;!\"'()").lower() for word in words}
    if lowered & USAGE_WORDS:
        return False
    if "who calls" in query.lower():
        return False
    return bool(lowered & QUESTION_WORDS) or query.rstrip().endswith("?")


def _anchors(query: str) -> list[tuple[str, frozenset]]:
    """Identifier anchors in priority order, each with the kinds it may resolve to."""
    seen: set[str] = set()
    screaming: list[tuple[str, frozenset]] = []
    mixed: list[tuple[str, frozenset]] = []
    for identifier in tokens.identifiers(query):
        if identifier in seen:
            continue
        seen.add(identifier)
        if _SCREAMING.match(identifier):
            screaming.append((identifier, CONST_KINDS))
        elif _ALL_CAPS_WORD.match(identifier):
            # Single all-caps words are usually shouting; trust them only as
            # constants, where a non-existent name simply fails to resolve.
            screaming.append((identifier, CONST_KINDS))
        elif (
            _MIXED_CASE.match(identifier)
            and len(identifier) >= 3
            and identifier.lower() not in tokens.STOPWORDS
            and any(ch.isupper() for ch in identifier)
            and any(ch.islower() for ch in identifier)
        ):
            mixed.append((identifier, CODE_KINDS))
    return screaming + mixed


DESCRIPTIVE_COVERAGE = 0.75
DESCRIPTIVE_MIN_TOKENS = 2


def _terms_cover(query_terms: set[str], token: str) -> bool:
    """Plural- and prefix-tolerant match: "failures" covers "failure",
    "authentication" covers "auth"."""
    if token in query_terms:
        return True
    if len(token) >= 4:
        return any(term.startswith(token) or token.startswith(term) for term in query_terms if len(term) >= 4)
    return False


def _descriptive_const(
    graph: graph_module.Graph, query_terms: set[str], head_term: str | None
) -> tuple[str, dict] | None:
    """Resolve a constant described in words rather than named.

    "the first-turn scan wait budget" names no identifier, but the tokens of
    FIRST_SCAN_WAIT_MS cover it. Requires most of a multi-token name to be
    covered and a strictly unique winner - generic one-token names never
    qualify. An exact tie falls to the question's head noun ("what SHARE of
    the context window" asks about the share, not the window); a tie that
    survives even that is ambiguity, not a guess.
    """
    scored: list[tuple[float, int, str, dict]] = []
    for holders in _fact_index(graph).values():
        for rel, symbol in holders:
            if symbol.get("kind") not in CONST_KINDS:
                continue
            name = symbol.get("name")
            if not isinstance(name, str):
                continue
            leaf = name.rsplit(".", 1)[-1]
            parts = [
                part
                for part in tokens.split_identifier(leaf)
                if len(part) > 1 and part not in tokens.STOPWORDS
            ]
            if len(parts) < DESCRIPTIVE_MIN_TOKENS:
                continue
            matched = sum(1 for part in parts if _terms_cover(query_terms, part))
            if matched < DESCRIPTIVE_MIN_TOKENS:
                continue
            coverage = matched / len(parts)
            if coverage >= DESCRIPTIVE_COVERAGE:
                scored.append((coverage, matched, rel, symbol))
    if not scored:
        return None
    top = max((coverage, matched) for coverage, matched, _rel, _symbol in scored)
    leaders = [(rel, symbol) for coverage, matched, rel, symbol in scored if (coverage, matched) == top]
    if len(leaders) > 1 and head_term:
        heads = [
            (rel, symbol)
            for rel, symbol in leaders
            if any(
                _terms_cover({head_term}, part)
                for part in tokens.split_identifier(symbol["name"].rsplit(".", 1)[-1])
            )
        ]
        if len(heads) == 1:
            return heads[0]
    if len(leaders) != 1:
        return None
    return leaders[0]


def _tie_break(
    query_terms: set[str], holders: list[tuple[str, dict]]
) -> tuple[str, dict] | None:
    """Unique argmax of query-term overlap with each holder's path, or None."""
    best: tuple[str, dict] | None = None
    best_score = 0
    tied = False
    for rel, symbol in holders:
        overlap = len(query_terms & set(tokens.path_tokens(rel)))
        if overlap > best_score:
            best, best_score, tied = (rel, symbol), overlap, False
        elif overlap == best_score and best is not None:
            tied = True
    if best is None or best_score < 1 or tied:
        return None
    return best


def _comment_lookback(lines: list[str], start_row: int) -> int:
    """First row of the contiguous comment block directly above ``start_row``."""
    row = start_row
    taken = 0
    while row > 0 and taken < COMMENT_LOOKBACK:
        candidate = lines[row - 1].strip()
        if not candidate or not candidate.startswith(COMMENT_PREFIXES):
            break
        row -= 1
        taken += 1
    return row


def _card_text(graph: graph_module.Graph, rel: str, symbol: dict, max_chars: int) -> tuple[str, int, int] | None:
    lines = graph.source(rel)
    if lines is None:
        return None
    try:
        start = int(symbol["line"])
        end = int(symbol["end_line"])
    except (KeyError, TypeError, ValueError):
        return None
    if start < 1 or end < start or start > len(lines):
        return None
    end = min(end, len(lines))
    kind = symbol.get("kind")
    cap = FACT_CONST_CAP if kind in CONST_KINDS else max_chars

    first = start
    if kind in CONST_KINDS:
        first = _comment_lookback(lines, start - 1) + 1

    body = "\n".join(lines[first - 1 : end])
    if kind not in CONST_KINDS:
        # A function body that leans on a sibling constant is only half an
        # answer; append the constant when it fits under the cap.
        record = graph.files.get(rel)
        appended: list[str] = []
        if record is not None:
            lowered = body.lower()
            for sibling in record["symbols"]:
                name = sibling.get("name")
                if (
                    sibling.get("kind") in CONST_KINDS
                    and isinstance(name, str)
                    and name != symbol.get("name")
                    and re.search(rf"\b{re.escape(name)}\b", body)
                    and name.lower() in lowered
                ):
                    extra = _card_text(graph, rel, sibling, FACT_CONST_CAP)
                    if extra and len(body) + len(extra[0]) + 1 <= cap:
                        appended.append(extra[0])
        if appended:
            body = body + "\n" + "\n".join(appended)

    truncated = body[:cap]
    return truncated, first, first + truncated.count("\n")


def fact(root: Path, query: str, max_chars: int = FACT_BODY_CAP) -> dict:
    """Extract a direct answer for a single-fact question, or miss cleanly."""
    text = query if isinstance(query, str) else ""
    if not _question_shaped(text):
        return _miss()
    graph = graph_module.load(root)
    if graph is None or not graph.files:
        return _miss()

    query_terms = set(tokens.tokenize(text))
    facts: list[dict] = []
    candidates: list[dict] = []
    confidence = "exact"

    # An explicit path::symbol reference outranks every heuristic anchor.
    if "::" in text:
        for match in re.finditer(r"(\S+::[\w.$]+)", text):
            rel, symbol_name = store.parse_ref(root, match.group(1).rstrip("?,.;:!\"'"))
            if not rel or not symbol_name:
                continue
            entry = graph.symbol(rel, symbol_name)
            if entry is None:
                continue
            card = _card_text(graph, rel, entry, max(0, int(max_chars)) or FACT_BODY_CAP)
            if card is None:
                continue
            body, first, last = card
            facts.append(
                {
                    "claim": body.split("\n")[0].strip()[:160],
                    "path": rel,
                    "symbol": entry.get("name"),
                    "kind": entry.get("kind"),
                    "lines": [first, last],
                    "text": body,
                }
            )
            if len(facts) >= MAX_FACTS:
                break
    if facts:
        return {"facts": facts, "confidence": "exact", "candidates": []}

    # No named anchor is not yet a miss: the descriptive-const path below can
    # still resolve a constant the question described in words.
    anchors = _anchors(text)

    for identifier, kinds in anchors:
        if len(facts) >= MAX_FACTS:
            break
        holders = [
            (rel, symbol)
            for rel, symbol in _fact_index(graph).get(identifier.lower(), [])
            if symbol.get("kind") in kinds
        ]
        if not holders or len(holders) > AMBIGUITY_CAP:
            continue
        if len(holders) == 1:
            chosen = holders[0]
        else:
            chosen = _tie_break(query_terms, holders)
            if chosen is None:
                for rel, symbol in holders[:MAX_CANDIDATES]:
                    candidates.append(
                        {"path": rel, "symbol": symbol.get("name"), "lines": [symbol.get("line"), symbol.get("end_line")]}
                    )
                continue
            confidence = "strong"
        rel, symbol = chosen
        card = _card_text(graph, rel, symbol, max(0, int(max_chars)) or FACT_BODY_CAP)
        if card is None:
            continue
        body, first, last = card
        name = symbol.get("name")
        leaf = name.rsplit(".", 1)[-1] if isinstance(name, str) else identifier
        if leaf != identifier:
            confidence = "strong"
        claim_row = symbol.get("line")
        claim = ""
        lines = body.split("\n")
        offset = int(claim_row) - first if isinstance(claim_row, int) else 0
        if 0 <= offset < len(lines):
            claim = lines[offset].strip()[:160]
        facts.append(
            {
                "claim": claim,
                "path": rel,
                "symbol": name,
                "kind": symbol.get("kind"),
                "lines": [first, last],
                "text": body,
            }
        )

    if not facts and not candidates:
        ordered_terms = tokens.tokenize(text)
        described = _descriptive_const(graph, query_terms, ordered_terms[0] if ordered_terms else None)
        if described is not None:
            rel, symbol = described
            card = _card_text(graph, rel, symbol, max(0, int(max_chars)) or FACT_BODY_CAP)
            if card is not None:
                body, first, last = card
                claim_row = symbol.get("line")
                lines = body.split("\n")
                offset = int(claim_row) - first if isinstance(claim_row, int) else 0
                claim = lines[offset].strip()[:160] if 0 <= offset < len(lines) else ""
                facts.append(
                    {
                        "claim": claim,
                        "path": rel,
                        "symbol": symbol.get("name"),
                        "kind": symbol.get("kind"),
                        "lines": [first, last],
                        "text": body,
                    }
                )
                confidence = "strong"

    if not facts:
        result = _miss()
        result["candidates"] = candidates[:MAX_CANDIDATES]
        return result
    return {"facts": facts, "confidence": confidence, "candidates": []}
