from __future__ import annotations

import math
import re
import time
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import replace
from pathlib import Path

from gloggur.byte_spans import to_repo_relative_path
from gloggur.search.hybrid_search import HybridSearch
from gloggur.search.router.backends import (
    run_exact_backend,
    run_semantic_backend,
    run_symbol_backend,
)
from gloggur.search.router.config import SearchRouterConfig
from gloggur.search.router.hints import extract_query_hints
from gloggur.search.router.query_compat import ParsedQueryCompat, parse_query_compat
from gloggur.search.router.telemetry import log_router_event
from gloggur.search.router.types import (
    BackendHit,
    BackendResult,
    ContextHit,
    ContextPack,
    ContextSpan,
    ExecutionHints,
    QueryHints,
    RouterOutcome,
    SearchConstraints,
    SearchIntent,
)
from gloggur.storage.metadata_store import MetadataStore
from gloggur.symbol_index.store import SymbolIndexStore

_TOKEN_RE = re.compile(r"\b[A-Za-z_][A-Za-z0-9_]*\b")
_BACKEND_TIE_PRIORITY = {
    "exact": 0,
    "symbol": 1,
    "semantic": 2,
}
_RANK_FUSION_K = 60
_AUXILIARY_INTENT_RE = re.compile(
    r"(?i)\b(doc|docs|readme|changelog|history|news|release(?:-notes)?|guide|tutorial|example|examples)\b"
)
_SOURCE_FIRST_RANKING_MODES = {"balanced", "source-first"}
_RESULT_PROFILES = {"default", "locator"}
_SEMANTIC_ASSIST_MODES = {"none", "bounded_about"}


def _normalize_string_tuple(values: object) -> tuple[str, ...]:
    if not isinstance(values, (list, tuple)):
        return ()
    seen: set[str] = set()
    ordered: list[str] = []
    for raw in values:
        candidate = str(raw).strip()
        if not candidate or candidate in seen:
            continue
        seen.add(candidate)
        ordered.append(candidate)
    return tuple(ordered)


def _normalize_optional_query(value: object) -> str | None:
    if not isinstance(value, str):
        return None
    normalized = value.strip()
    return normalized or None


def _normalize_search_mode(value: object) -> str:
    search_mode = (
        str(value).strip().lower() if isinstance(value, str) and str(value).strip() else "semantic"
    )
    if search_mode not in {"semantic", "by_fqname", "by_fqname_regex", "by_path"}:
        return "semantic"
    return search_mode


def _normalize_ranking_mode(value: object) -> str:
    ranking_mode = (
        str(value).strip().lower() if isinstance(value, str) and str(value).strip() else "balanced"
    )
    if ranking_mode not in _SOURCE_FIRST_RANKING_MODES:
        return "balanced"
    return ranking_mode


def _normalize_result_profile(value: object) -> str:
    profile = (
        str(value).strip().lower() if isinstance(value, str) and str(value).strip() else "default"
    )
    if profile not in _RESULT_PROFILES:
        return "default"
    return profile


def _normalize_semantic_assist_mode(value: object) -> str:
    assist_mode = (
        str(value).strip().lower() if isinstance(value, str) and str(value).strip() else "none"
    )
    if assist_mode not in _SEMANTIC_ASSIST_MODES:
        return "none"
    return assist_mode


def _normalize_search_intent(
    intent: SearchIntent | None,
    config: SearchRouterConfig,
) -> SearchIntent:
    """Resolve runtime intent values with config defaults."""
    base = intent or SearchIntent()
    max_files = (
        base.max_files
        if isinstance(base.max_files, int) and base.max_files > 0
        else config.max_files
    )
    max_snippets = (
        base.max_snippets
        if isinstance(base.max_snippets, int) and base.max_snippets > 0
        else config.max_snippets
    )
    time_budget_ms = (
        base.time_budget_ms
        if isinstance(base.time_budget_ms, int) and base.time_budget_ms > 0
        else config.default_time_budget_ms
    )
    language = (
        base.language.strip() if isinstance(base.language, str) and base.language.strip() else None
    )
    path_prefix = (
        base.path_prefix.strip()
        if isinstance(base.path_prefix, str) and base.path_prefix.strip()
        else None
    )
    semantic_query = _normalize_optional_query(base.semantic_query)
    path_filters = _normalize_string_tuple(base.path_filters)
    return SearchIntent(
        search_mode=_normalize_search_mode(base.search_mode),
        semantic_query=semantic_query,
        ranking_mode=_normalize_ranking_mode(base.ranking_mode),
        result_profile=_normalize_result_profile(base.result_profile),
        semantic_assist_mode=_normalize_semantic_assist_mode(base.semantic_assist_mode),
        language=language,
        path_prefix=path_prefix,
        path_filters=path_filters,
        max_files=max_files,
        max_snippets=max_snippets,
        time_budget_ms=time_budget_ms,
    )


def _normalize_execution_hints(hints: ExecutionHints | None) -> ExecutionHints:
    include_globs = _normalize_string_tuple(hints.include_globs if hints is not None else ())
    exclude_globs = _normalize_string_tuple(hints.exclude_globs if hints is not None else ())
    case_mode: str | None = None
    if hints is not None and isinstance(hints.case_mode, str):
        normalized_case = hints.case_mode.strip().lower()
        if normalized_case in {"ignore", "smart"}:
            case_mode = normalized_case
    return ExecutionHints(
        include_globs=include_globs,
        exclude_globs=exclude_globs,
        case_mode=case_mode,
        word_match=bool(hints.word_match) if hints is not None else False,
        fixed_string=bool(hints.fixed_string) if hints is not None else False,
    )


def _intent_and_hints_from_constraints(
    constraints: SearchConstraints | None,
    *,
    config: SearchRouterConfig,
) -> tuple[SearchIntent, ExecutionHints]:
    """Convert compatibility constraints into SearchIntent + internal execution hints."""
    if constraints is None:
        return _normalize_search_intent(None, config), _normalize_execution_hints(None)
    intent = SearchIntent(
        search_mode=constraints.search_mode,
        ranking_mode="balanced",
        result_profile="default",
        semantic_assist_mode="none",
        language=constraints.language,
        path_prefix=constraints.path_prefix,
        path_filters=constraints.path_filters,
        max_files=constraints.max_files,
        max_snippets=constraints.max_snippets,
        time_budget_ms=constraints.time_budget_ms,
    )
    hints = ExecutionHints(
        include_globs=constraints.include_globs,
        exclude_globs=constraints.exclude_globs,
        case_mode=constraints.case_mode,
        word_match=constraints.word_match,
        fixed_string=constraints.fixed_string,
    )
    return _normalize_search_intent(intent, config), _normalize_execution_hints(hints)


def _merge_execution_hints_with_query_compat(
    execution_hints: ExecutionHints,
    *,
    parsed: ParsedQueryCompat,
) -> tuple[ExecutionHints, tuple[str, ...]]:
    merged_path_filters: list[str] = []
    if parsed.source != "grep_compat" or parsed.fallback_used:
        return execution_hints, ()
    merged_path_filters = []
    for candidate in parsed.path_filters:
        if candidate not in merged_path_filters:
            merged_path_filters.append(candidate)
    include_globs = list(execution_hints.include_globs)
    for candidate in parsed.include_globs:
        if candidate not in include_globs:
            include_globs.append(candidate)
    exclude_globs = list(execution_hints.exclude_globs)
    for candidate in parsed.exclude_globs:
        if candidate not in exclude_globs:
            exclude_globs.append(candidate)
    return (
        ExecutionHints(
            include_globs=tuple(include_globs),
            exclude_globs=tuple(exclude_globs),
            case_mode=(
                parsed.case_mode if parsed.case_mode is not None else execution_hints.case_mode
            ),
            word_match=execution_hints.word_match or parsed.word_match,
            fixed_string=execution_hints.fixed_string or parsed.fixed_string,
        ),
        tuple(merged_path_filters),
    )


def _span_length(hit: BackendHit) -> int:
    return max(1, hit.end_line - hit.start_line + 1)


def _spans_overlap(left: BackendHit, right: BackendHit) -> bool:
    if left.path != right.path:
        return False
    return not (left.end_line < right.start_line or right.end_line < left.start_line)


def _token_overlap_score(*, text: str, hints: QueryHints) -> float:
    tokens = {token.lower() for token in _TOKEN_RE.findall(text)}
    if not tokens:
        return 0.0
    hint_tokens = set(hints.identifier_tokens)
    if not hint_tokens:
        return 0.0
    overlap = tokens & hint_tokens
    return min(len(overlap) / max(len(hint_tokens), 1), 1.0)


def _path_quality(path: str) -> float:
    lowered = path.replace("\\", "/").lower()
    if "/dist/" in lowered or "/vendor/" in lowered or "/node_modules/" in lowered:
        return 0.0
    if "/src/" in lowered:
        return 1.0
    return 0.7


def _quality_exact(result: BackendResult, hints: QueryHints, intent: SearchIntent) -> float:
    if not result.hits:
        return 0.0
    match_strength = max(hit.raw_score for hit in result.hits)
    hit_density = min(len(result.hits) / max(1, intent.max_snippets or 1), 1.0)
    path_quality = sum(_path_quality(hit.path) for hit in result.hits) / len(result.hits)
    hint_overlap = sum(
        _token_overlap_score(text=f"{hit.path}\n{hit.snippet}", hints=hints) for hit in result.hits
    ) / len(result.hits)
    return max(
        0.0,
        min(
            1.0,
            0.45 * match_strength + 0.25 * hit_density + 0.20 * path_quality + 0.10 * hint_overlap,
        ),
    )


def _quality_symbol(result: BackendResult, hints: QueryHints, intent: SearchIntent) -> float:
    if not result.hits:
        return 0.0
    name_match = max(hit.raw_score for hit in result.hits)
    ref_density = min(len(result.hits) / max(1, intent.max_snippets or 1), 1.0)
    path_quality = sum(_path_quality(hit.path) for hit in result.hits) / len(result.hits)
    hint_overlap = sum(
        _token_overlap_score(text=f"{hit.path}\n{hit.snippet}", hints=hints) for hit in result.hits
    ) / len(result.hits)
    return max(
        0.0,
        min(
            1.0, 0.40 * name_match + 0.25 * ref_density + 0.20 * path_quality + 0.15 * hint_overlap
        ),
    )


def _quality_semantic(result: BackendResult, hints: QueryHints) -> float:
    if not result.hits:
        return 0.0
    ordered = sorted((hit.raw_score for hit in result.hits), reverse=True)
    top1 = ordered[0]
    top2 = ordered[1] if len(ordered) > 1 else 0.0
    margin = max(0.0, min(1.0, top1 - top2))

    directory_counts: dict[str, int] = {}
    for hit in result.hits:
        parent = str(Path(hit.path).parent)
        directory_counts[parent] = directory_counts.get(parent, 0) + 1
    dir_coherence = max(directory_counts.values()) / len(result.hits)

    hint_overlap = sum(
        _token_overlap_score(text=f"{hit.path}\n{hit.snippet}", hints=hints) for hit in result.hits
    ) / len(result.hits)
    return max(
        0.0, min(1.0, 0.45 * top1 + 0.25 * margin + 0.20 * dir_coherence + 0.10 * hint_overlap)
    )


def _compute_quality(
    result: BackendResult,
    *,
    hints: QueryHints,
    intent: SearchIntent,
) -> float:
    if result.error:
        return 0.0
    if result.name == "exact":
        return _quality_exact(result, hints, intent)
    if result.name == "symbol":
        return _quality_symbol(result, hints, intent)
    return _quality_semantic(result, hints)


def _threshold_for(backend: str, config: SearchRouterConfig) -> float:
    if backend == "exact":
        return config.threshold_exact
    if backend == "symbol":
        return config.threshold_symbol
    return config.threshold_semantic


def _evidence_kind_for_backend(name: str) -> str:
    if name == "exact":
        return "lexical"
    if name == "symbol":
        return "symbol"
    return "semantic"


def _backend_threshold_status(
    results: list[BackendResult],
    config: SearchRouterConfig,
) -> dict[str, dict[str, object]]:
    """Return per-backend threshold/debug status for summary/debug payloads."""
    status: dict[str, dict[str, object]] = {}
    for result in results:
        top_hit_score = max((hit.raw_score for hit in result.hits), default=0.0)
        threshold = _threshold_for(result.name, config)
        status[result.name] = {
            "top_hit_score": top_hit_score,
            "quality_score": result.quality_score,
            "threshold": threshold,
            "threshold_met": bool(result.hits and result.quality_score >= threshold),
            "evidence_kind": _evidence_kind_for_backend(result.name),
        }
    return status


def _route_auto(results: list[BackendResult], config: SearchRouterConfig) -> RouterOutcome:
    qualified = [
        result
        for result in results
        if result.hits and result.quality_score >= _threshold_for(result.name, config)
    ]
    if qualified:
        chosen = sorted(
            qualified,
            key=lambda item: (
                -item.quality_score,
                _BACKEND_TIE_PRIORITY.get(item.name, 99),
                item.timing_ms,
            ),
        )[0]
        return RouterOutcome(
            strategy=chosen.name,
            reason="quality_threshold_met",
            winner=chosen.name,
            considered=tuple(result.name for result in results),
            backend_scores={result.name: result.quality_score for result in results},
        )

    has_non_semantic_evidence = any(
        result.name in {"exact", "symbol"} and result.hits for result in results
    )
    if not has_non_semantic_evidence:
        return RouterOutcome(
            strategy="suppressed",
            reason="semantic_only_below_threshold",
            winner=None,
            considered=tuple(result.name for result in results),
            backend_scores={result.name: result.quality_score for result in results},
            warning_codes=("ungrounded_results_suppressed",),
        )

    return RouterOutcome(
        strategy="hybrid",
        reason="no_backend_met_threshold",
        winner=None,
        considered=tuple(result.name for result in results),
        backend_scores={result.name: result.quality_score for result in results},
    )


def _route_lexical_auto(results: list[BackendResult], config: SearchRouterConfig) -> RouterOutcome:
    qualified = [
        result
        for result in results
        if result.hits and result.quality_score >= _threshold_for(result.name, config)
    ]
    if qualified:
        chosen = sorted(
            qualified,
            key=lambda item: (
                -item.quality_score,
                _BACKEND_TIE_PRIORITY.get(item.name, 99),
                item.timing_ms,
            ),
        )[0]
        return RouterOutcome(
            strategy=chosen.name,
            reason="quality_threshold_met",
            winner=chosen.name,
            considered=tuple(result.name for result in results),
            backend_scores={result.name: result.quality_score for result in results},
        )
    if not any(result.hits for result in results):
        return RouterOutcome(
            strategy="suppressed",
            reason="no_lexical_hits",
            winner=None,
            considered=tuple(result.name for result in results),
            backend_scores={result.name: result.quality_score for result in results},
        )
    return RouterOutcome(
        strategy="hybrid",
        reason="no_backend_met_threshold",
        winner=None,
        considered=tuple(result.name for result in results),
        backend_scores={result.name: result.quality_score for result in results},
    )


def _lexical_support_for_backend(result: BackendResult, *, hints: QueryHints) -> float:
    if not result.hits:
        return 0.0
    overlap = sum(
        _token_overlap_score(text=f"{hit.path}\n{hit.snippet}", hints=hints) for hit in result.hits
    ) / len(result.hits)
    if result.name == "exact":
        tagged = sum(
            1 for hit in result.hits if "literal_match" in hit.tags or "symbol_match" in hit.tags
        )
        tag_signal = tagged / len(result.hits)
        return max(0.0, min(1.0, 0.50 + 0.30 * overlap + 0.20 * tag_signal))
    if result.name == "symbol":
        tagged = sum(
            1 for hit in result.hits if "symbol_def" in hit.tags or "symbol_ref" in hit.tags
        )
        tag_signal = tagged / len(result.hits)
        return max(0.0, min(1.0, 0.42 + 0.35 * overlap + 0.23 * tag_signal))
    tagged = sum(1 for hit in result.hits if "semantic_high_conf" in hit.tags)
    tag_signal = tagged / len(result.hits)
    return max(0.0, min(1.0, 0.25 + 0.55 * overlap + 0.20 * tag_signal))


def _adaptive_backend_weight(
    result: BackendResult,
    *,
    hints: QueryHints,
    intent: SearchIntent,
    threshold_met: bool,
    max_backend_ms: int,
) -> float:
    if not result.hits:
        return 0.0
    hit_density = min(len(result.hits) / max(1, intent.max_snippets or 1), 1.0)
    lexical_support = _lexical_support_for_backend(result, hints=hints)
    timing_penalty = min(max(result.timing_ms / max(1, max_backend_ms), 0.0), 1.0)
    threshold_factor = 1.0 if threshold_met else 0.65
    raw_weight = (
        0.55 * result.quality_score
        + 0.20 * hit_density
        + 0.15 * lexical_support
        + 0.10 * (1.0 - timing_penalty)
    )
    return max(0.0, min(1.0, raw_weight * threshold_factor))


def _adaptive_fusion_selection(
    *,
    results_by_name: dict[str, BackendResult],
    intent: SearchIntent,
    hints: QueryHints,
    config: SearchRouterConfig,
) -> tuple[list[BackendHit], dict[str, int], dict[str, float], dict[str, bool], dict[str, float]]:
    available = {name: result for name, result in results_by_name.items() if result.hits}
    if not available:
        return [], {}, {}, {}, {}

    ordered_backends = sorted(
        available.keys(),
        key=lambda name: (
            _BACKEND_TIE_PRIORITY.get(name, 99),
            available[name].timing_ms,
            name,
        ),
    )
    max_snippets = max(1, intent.max_snippets or config.max_snippets)
    eligibility = {
        name: bool(
            available[name].hits and available[name].quality_score >= _threshold_for(name, config)
        )
        for name in ordered_backends
    }
    max_backend_ms = max((available[name].timing_ms for name in ordered_backends), default=1)
    backend_weights = {
        name: _adaptive_backend_weight(
            available[name],
            hints=hints,
            intent=intent,
            threshold_met=eligibility[name],
            max_backend_ms=max_backend_ms,
        )
        for name in ordered_backends
    }

    allocation = {name: 0 for name in ordered_backends}
    for name in ordered_backends:
        if not eligibility.get(name):
            continue
        if allocation[name] >= len(available[name].hits):
            continue
        if sum(allocation.values()) >= max_snippets:
            break
        allocation[name] += 1

    remaining = max_snippets - sum(allocation.values())
    proportional_shares = {name: 0.0 for name in ordered_backends}
    if remaining > 0:
        contributors = [
            name
            for name in ordered_backends
            if len(available[name].hits) > allocation[name] and backend_weights.get(name, 0.0) > 0.0
        ]
        if not contributors:
            contributors = [
                name for name in ordered_backends if len(available[name].hits) > allocation[name]
            ]
        if contributors:
            total_weight = sum(backend_weights.get(name, 0.0) for name in contributors)
            if total_weight <= 0.0:
                total_weight = float(len(contributors))
            fractional: dict[str, float] = {}
            distributed = 0
            for name in contributors:
                share = (
                    backend_weights.get(name, 0.0) / total_weight
                    if total_weight > 0.0
                    else 1.0 / len(contributors)
                )
                proportional_shares[name] = share
                capacity = len(available[name].hits) - allocation[name]
                whole = min(capacity, int(math.floor(remaining * share)))
                allocation[name] += whole
                distributed += whole
                fractional[name] = (remaining * share) - whole

            leftover = remaining - distributed
            if leftover > 0:
                remainder_order = sorted(
                    contributors,
                    key=lambda name: (
                        -fractional.get(name, 0.0),
                        -backend_weights.get(name, 0.0),
                        _BACKEND_TIE_PRIORITY.get(name, 99),
                        available[name].timing_ms,
                        name,
                    ),
                )
                while leftover > 0:
                    progressed = False
                    for name in remainder_order:
                        if allocation[name] >= len(available[name].hits):
                            continue
                        allocation[name] += 1
                        leftover -= 1
                        progressed = True
                        if leftover <= 0:
                            break
                    if not progressed:
                        break

    ranked_candidates = {
        name: list(available[name].hits)[: allocation.get(name, 0)] for name in ordered_backends
    }
    vote_scores: dict[tuple[str, int, int], float] = {}
    first_seen: dict[tuple[str, int, int], int] = {}
    representatives: dict[tuple[str, int, int], BackendHit] = {}
    order_counter = 0
    for name in ordered_backends:
        candidates = ranked_candidates.get(name, [])
        for rank, hit in enumerate(candidates, start=1):
            key: tuple[str, int, int] | None = None
            for existing_key, existing_hit in representatives.items():
                if _spans_overlap(hit, existing_hit):
                    key = existing_key
                    break
            if key is None:
                key = (hit.path, hit.start_line, hit.end_line)
            vote_scores[key] = vote_scores.get(key, 0.0) + (1.0 / (_RANK_FUSION_K + rank))
            if key not in first_seen:
                first_seen[key] = order_counter
            order_counter += 1
            existing = representatives.get(key)
            if existing is None:
                representatives[key] = hit
                continue
            merged_tags = tuple(sorted(set(existing.tags) | set(hit.tags)))
            preferred = existing if existing.raw_score >= hit.raw_score else hit
            if preferred.tags != merged_tags:
                preferred = replace(preferred, tags=merged_tags)
            representatives[key] = preferred

    ordered_keys = sorted(
        vote_scores,
        key=lambda key: (
            -vote_scores[key],
            first_seen.get(key, 0),
            key[0],
            key[1],
            key[2],
        ),
    )
    selected_hits = [representatives[key] for key in ordered_keys]
    return (
        selected_hits[:max_snippets],
        allocation,
        backend_weights,
        eligibility,
        proportional_shares,
    )


def _select_hits_for_outcome(
    *,
    results_by_name: dict[str, BackendResult],
    outcome: RouterOutcome,
    intent: SearchIntent,
    hints: QueryHints,
    config: SearchRouterConfig,
) -> tuple[list[BackendHit], dict[str, int], dict[str, float], dict[str, bool], dict[str, float]]:
    if outcome.strategy == "hybrid":
        return _adaptive_fusion_selection(
            results_by_name=results_by_name,
            intent=intent,
            hints=hints,
            config=config,
        )
    if outcome.strategy == "suppressed":
        return [], {}, {}, {}, {}
    winner_result = results_by_name.get(outcome.strategy)
    return list(winner_result.hits if winner_result is not None else ()), {}, {}, {}, {}


def _is_auxiliary_path(path: str) -> bool:
    lowered = path.replace("\\", "/").lower()
    basename = Path(lowered).name
    stem = Path(lowered).stem
    if "/docs/" in lowered or "/doc/" in lowered:
        return True
    if "/examples/" in lowered or "/example/" in lowered:
        return True
    if basename.endswith((".md", ".rst", ".txt")):
        return True
    return any(
        marker in stem
        for marker in ("readme", "changelog", "changes", "history", "news", "release-notes")
    )


def _auxiliary_intent_requested(*, query: str, intent: SearchIntent) -> bool:
    if _AUXILIARY_INTENT_RE.search(query):
        return True
    for raw_path in (intent.path_prefix, *intent.path_filters):
        if not isinstance(raw_path, str):
            continue
        lowered = raw_path.replace("\\", "/").lower()
        if "/docs" in lowered or "/examples" in lowered or "readme" in lowered:
            return True
    return False


def _lexical_hits_are_auxiliary_only(
    *,
    hits: tuple[ContextHit, ...],
    query: str,
    intent: SearchIntent,
) -> bool:
    if not hits or _auxiliary_intent_requested(query=query, intent=intent):
        return False
    return all(_is_auxiliary_path(hit.path) for hit in hits)


def _semantic_overlap_score(
    *,
    candidate: BackendHit,
    semantic_hits: tuple[BackendHit, ...],
) -> float:
    best = 0.0
    for semantic_hit in semantic_hits:
        if semantic_hit.path != candidate.path:
            continue
        score = semantic_hit.raw_score
        if _spans_overlap(candidate, semantic_hit):
            score += 0.05
        elif abs(candidate.start_line - semantic_hit.start_line) <= 4:
            score += 0.02
        if score > best:
            best = score
    return min(best, 1.0)


def _rerank_lexical_hits_with_semantic(
    *,
    lexical_hits: list[BackendHit],
    semantic_hits: tuple[BackendHit, ...],
) -> tuple[list[BackendHit], bool]:
    if len(lexical_hits) < 2 or not semantic_hits:
        return lexical_hits, False

    overlap_scores = {
        index: _semantic_overlap_score(candidate=hit, semantic_hits=semantic_hits)
        for index, hit in enumerate(lexical_hits)
    }
    if not any(score > 0.0 for score in overlap_scores.values()):
        return lexical_hits, False

    reranked = sorted(
        enumerate(lexical_hits),
        key=lambda item: (
            -overlap_scores[item[0]],
            -item[1].raw_score,
            item[0],
        ),
    )
    ordered_hits = [hit for _index, hit in reranked]
    return ordered_hits, ordered_hits != lexical_hits


def _semantic_rescue_threshold(config: SearchRouterConfig) -> float:
    return max(config.threshold_semantic + 0.08, 0.74)


def _select_semantic_rescue_hits(
    *,
    semantic_result: BackendResult,
    config: SearchRouterConfig,
) -> tuple[list[BackendHit], bool]:
    candidates = [hit for hit in semantic_result.hits if not _is_auxiliary_path(hit.path)]
    if not candidates:
        return [], False

    top_hit = candidates[0]
    threshold = _semantic_rescue_threshold(config)
    if top_hit.raw_score < threshold:
        return [], False
    if len(candidates) > 1 and (top_hit.raw_score - candidates[1].raw_score) < 0.05:
        return [], False
    return [top_hit], True


def _suggested_path_prefix(hits: tuple[ContextHit, ...]) -> str | None:
    top_hits = hits[:4]
    if len(top_hits) < 2:
        return None

    path_counts: dict[str, int] = {}
    for hit in top_hits:
        path_counts[hit.path] = path_counts.get(hit.path, 0) + 1
    path, path_count = sorted(
        path_counts.items(),
        key=lambda item: (-item[1], item[0]),
    )[0]
    if path_count >= 2:
        return path

    dir_counts: dict[str, int] = {}
    for hit in top_hits:
        parent = str(Path(hit.path).parent)
        if parent in {"", "."}:
            continue
        dir_counts[parent] = dir_counts.get(parent, 0) + 1
    if not dir_counts:
        return None
    parent, parent_count = sorted(
        dir_counts.items(),
        key=lambda item: (-item[1], item[0]),
    )[0]
    if parent_count >= 2:
        return parent
    return None


def _has_multiple_hit_paths(hits: tuple[ContextHit, ...]) -> bool:
    """Return True when the packed hit set spans more than one file path."""
    return len({hit.path for hit in hits if hit.path}) > 1


def _decisive_non_semantic_result(
    *,
    hits: tuple[ContextHit, ...],
    outcome: RouterOutcome,
    backend_thresholds: dict[str, dict[str, object]],
    query_kind: str,
) -> bool:
    if outcome.strategy not in {"exact", "symbol"} or not hits:
        return False
    winner_status = backend_thresholds.get(outcome.strategy, {})
    if not bool(winner_status.get("threshold_met")):
        return False

    top_score = hits[0].score
    if len(hits) == 1:
        return top_score >= 0.80

    second_score = hits[1].score
    if hits[0].path == hits[1].path and abs(top_score - second_score) < 0.05:
        return False
    if query_kind in {"identifier", "declaration"}:
        return top_score >= 0.85 and (
            top_score - second_score >= 0.05 or hits[0].path != hits[1].path
        )
    return top_score >= 0.90 and top_score - second_score >= 0.10


def _resolve_next_action(
    *,
    hits: tuple[ContextHit, ...],
    outcome: RouterOutcome,
    backend_thresholds: dict[str, dict[str, object]],
    query_kind: str,
    semantic_assist: str,
) -> tuple[bool, str, str | None]:
    if semantic_assist == "semantic_rescue" and hits:
        return True, "open_hit_1", None

    decisive = _decisive_non_semantic_result(
        hits=hits,
        outcome=outcome,
        backend_thresholds=backend_thresholds,
        query_kind=query_kind,
    )
    if decisive:
        return True, "open_hit_1", None

    suggested_path_prefix = _suggested_path_prefix(hits)
    if suggested_path_prefix is not None:
        return False, "narrow_by_path", suggested_path_prefix
    return False, "refine_query", None


def _pack_hits(
    hits: list[BackendHit],
    *,
    repo_root: Path,
    intent: SearchIntent,
    config: SearchRouterConfig,
    preserve_ranked_order: bool = False,
) -> tuple[ContextHit, ...]:
    """Dedupe and cap context hits with deterministic ordering."""
    sorted_hits = (
        list(hits)
        if preserve_ranked_order
        else sorted(hits, key=lambda item: (-item.raw_score, item.path, item.start_line))
    )

    deduped: list[BackendHit] = []
    for candidate in sorted_hits:
        replaced = False
        for index, existing in enumerate(deduped):
            if not _spans_overlap(candidate, existing):
                continue
            if preserve_ranked_order:
                # Keep first-seen hit when order is already pre-ranked by fusion.
                pass
            else:
                # Prefer smaller spans, then higher score.
                if _span_length(candidate) < _span_length(existing) or (
                    _span_length(candidate) == _span_length(existing)
                    and candidate.raw_score > existing.raw_score
                ):
                    deduped[index] = candidate
            replaced = True
            break
        if not replaced:
            deduped.append(candidate)

    by_path: dict[str, list[BackendHit]] = {}
    for hit in deduped:
        bucket = by_path.setdefault(hit.path, [])
        bucket.append(hit)
    if not preserve_ranked_order:
        for bucket in by_path.values():
            bucket.sort(key=lambda item: (-item.raw_score, item.start_line, item.end_line))

    max_files = intent.max_files or config.max_files
    max_snippets = intent.max_snippets or config.max_snippets
    ordered: list[BackendHit]
    if preserve_ranked_order:
        ordered = []
        selected_files: list[str] = []
        selected_file_set: set[str] = set()
        for hit in deduped:
            path = hit.path
            if path not in selected_file_set:
                if len(selected_files) >= max_files:
                    continue
                selected_files.append(path)
                selected_file_set.add(path)
            ordered.append(hit)
            if len(ordered) >= max_snippets:
                break
    else:
        selected_files = sorted(by_path.keys(), key=lambda path: -by_path[path][0].raw_score)[
            :max_files
        ]
        ordered = []
        cursor = {path: 0 for path in selected_files}
        while len(ordered) < max_snippets:
            advanced = False
            for path in selected_files:
                index = cursor[path]
                bucket = by_path[path]
                if index >= len(bucket):
                    continue
                ordered.append(bucket[index])
                cursor[path] += 1
                advanced = True
                if len(ordered) >= max_snippets:
                    break
            if not advanced:
                break

    context_hits: list[ContextHit] = []
    total_chars = 0
    for hit in ordered:
        snippet = hit.snippet.strip()
        if len(snippet) > config.max_snippet_chars:
            snippet = snippet[: config.max_snippet_chars].rstrip() + " ..."

        relative_path = to_repo_relative_path(repo_root, hit.path)
        projected = total_chars + len(relative_path) + len(snippet)
        if context_hits and projected > config.max_chars:
            break
        total_chars = projected
        context_hits.append(
            ContextHit(
                path=relative_path,
                span=ContextSpan(start_line=hit.start_line, end_line=hit.end_line),
                snippet=snippet,
                score=max(0.0, min(1.0, float(hit.raw_score))),
                start_byte=hit.start_byte,
                end_byte=hit.end_byte,
                tags=tuple(sorted(set(hit.tags))),
            )
        )

    return tuple(context_hits)


class SearchRouter:
    """Deterministic retrieval router used by `gloggur search`."""

    def __init__(
        self,
        *,
        repo_root: Path,
        searcher: HybridSearch | None,
        metadata_store: MetadataStore | None,
        config: SearchRouterConfig,
        symbol_store: SymbolIndexStore | None = None,
        searcher_factory: Callable[[], tuple[HybridSearch | None, str | None]] | None = None,
    ) -> None:
        self.repo_root = repo_root
        self.searcher = searcher
        self.metadata_store = metadata_store
        self.symbol_store = symbol_store
        self.config = config
        self._searcher_factory = searcher_factory
        self._searcher_initialized = searcher is not None
        self._semantic_init_error: str | None = None

    def _semantic_backend_configured(self) -> bool:
        return self.searcher is not None or self._searcher_factory is not None

    def _get_searcher(self) -> tuple[HybridSearch | None, str | None]:
        if self.searcher is not None:
            return self.searcher, self._semantic_init_error
        if self._searcher_initialized:
            return None, self._semantic_init_error

        self._searcher_initialized = True
        if self._searcher_factory is None:
            self._semantic_init_error = "semantic backend unavailable"
            return None, self._semantic_init_error

        try:
            searcher, init_error = self._searcher_factory()
        except Exception as exc:
            self._semantic_init_error = str(exc)
            return None, self._semantic_init_error

        self.searcher = searcher
        self._semantic_init_error = init_error
        if self.searcher is None and self._semantic_init_error is None:
            self._semantic_init_error = "semantic backend unavailable"
        return self.searcher, self._semantic_init_error

    def _resolve_backends(self, mode: str) -> tuple[str, ...]:
        enabled = tuple(
            name for name in self.config.enabled_backends if name in {"exact", "semantic", "symbol"}
        )
        if not self._semantic_backend_configured():
            enabled = tuple(name for name in enabled if name != "semantic")
        if self.symbol_store is None:
            enabled = tuple(name for name in enabled if name != "symbol")
        if mode == "exact":
            return tuple(name for name in enabled if name == "exact") or ("exact",)
        if mode == "semantic":
            return tuple(name for name in enabled if name == "semantic") or ("semantic",)
        if mode == "hybrid":
            pair = tuple(name for name in enabled if name in {"exact", "semantic"})
            return pair if pair else enabled
        return enabled or ("exact",)

    def search(
        self,
        *,
        query: str,
        intent: SearchIntent | None = None,
        constraints: SearchConstraints | None = None,
        mode: str = "auto",
        include_debug: bool = False,
    ) -> ContextPack:
        """Run routed retrieval and return ContextPack v2."""
        started = time.perf_counter()
        normalized_mode = mode.strip().lower() if mode else "auto"
        if normalized_mode not in {"auto", "exact", "semantic", "hybrid"}:
            normalized_mode = "auto"

        if intent is not None:
            resolved_intent = _normalize_search_intent(intent, self.config)
            resolved_execution_hints = _normalize_execution_hints(None)
        else:
            resolved_intent, resolved_execution_hints = _intent_and_hints_from_constraints(
                constraints,
                config=self.config,
            )
        parsed_query = parse_query_compat(query)
        effective_query = query
        if (
            parsed_query.source == "grep_compat"
            and not parsed_query.fallback_used
            and isinstance(parsed_query.pattern, str)
            and parsed_query.pattern.strip()
        ):
            effective_query = parsed_query.pattern.strip()
        (
            resolved_execution_hints,
            parsed_path_filters,
        ) = _merge_execution_hints_with_query_compat(
            resolved_execution_hints,
            parsed=parsed_query,
        )
        if parsed_path_filters:
            merged_path_filters = list(resolved_intent.path_filters)
            for candidate in parsed_path_filters:
                if candidate not in merged_path_filters:
                    merged_path_filters.append(candidate)
            resolved_intent = replace(
                resolved_intent,
                path_filters=tuple(merged_path_filters),
            )
        hints = extract_query_hints(effective_query)
        if (
            parsed_query.source == "grep_compat"
            and not parsed_query.fallback_used
            and parsed_query.pattern_quoted
            and isinstance(parsed_query.pattern, str)
            and parsed_query.pattern.strip()
        ):
            quoted_pattern = parsed_query.pattern.strip()
            symbols = list(hints.symbols)
            if quoted_pattern not in symbols:
                symbols.append(quoted_pattern)
            identifier_tokens = list(hints.identifier_tokens)
            lowered = quoted_pattern.lower()
            if lowered not in identifier_tokens:
                identifier_tokens.append(lowered)
            hints = replace(
                hints,
                symbols=tuple(symbols),
                identifier_tokens=tuple(identifier_tokens),
            )
        explicit_regex_requested = (
            parsed_query.source == "grep_compat"
            and not parsed_query.fallback_used
            and not parsed_query.fixed_string
        )
        backend_execution_hints = resolved_execution_hints
        if hints.query_kind in {"identifier", "declaration"} and not explicit_regex_requested:
            backend_execution_hints = replace(resolved_execution_hints, fixed_string=True)
        bounded_about = bool(
            resolved_intent.semantic_query
            and resolved_intent.semantic_assist_mode == "bounded_about"
            and normalized_mode in {"auto", "hybrid"}
        )
        force_semantic_fusion = bool(
            resolved_intent.semantic_query
            and normalized_mode in {"auto", "hybrid"}
            and not bounded_about
        )
        backend_names = (
            self._resolve_backends("auto")
            if force_semantic_fusion or bounded_about
            else self._resolve_backends(normalized_mode)
        )

        def _run_backend(name: str) -> BackendResult:
            if name == "exact":
                return run_exact_backend(
                    query=effective_query,
                    hints=hints,
                    repo_root=self.repo_root,
                    intent=resolved_intent,
                    execution_hints=backend_execution_hints,
                    config=self.config,
                )
            if name == "semantic":
                searcher, semantic_init_error = self._get_searcher()
                if searcher is None:
                    return BackendResult(
                        name="semantic",
                        hits=(),
                        timing_ms=0,
                        error=semantic_init_error or "semantic backend unavailable",
                    )
                return run_semantic_backend(
                    query=effective_query,
                    searcher=searcher,
                    repo_root=self.repo_root,
                    intent=resolved_intent,
                    execution_hints=backend_execution_hints,
                    config=self.config,
                )
            if self.symbol_store is None:
                return BackendResult(
                    name="symbol",
                    hits=(),
                    timing_ms=0,
                    error="symbol backend unavailable",
                )
            return run_symbol_backend(
                symbol_store=self.symbol_store,
                hints=hints,
                query=effective_query,
                repo_root=self.repo_root,
                intent=resolved_intent,
                execution_hints=backend_execution_hints,
                config=self.config,
            )

        fusion_allocation: dict[str, int] = {}
        fusion_proportional_shares: dict[str, float] = {}
        semantic_assist = "none"
        outcome: RouterOutcome
        selected_hits: list[BackendHit]

        if bounded_about:
            lexical_backend_names = tuple(
                name for name in ("exact", "symbol") if name in backend_names
            )
            lexical_results = [_run_backend(name) for name in lexical_backend_names]
            lexical_enriched = [
                replace(
                    result,
                    quality_score=_compute_quality(
                        result,
                        hints=hints,
                        intent=resolved_intent,
                    ),
                )
                for result in lexical_results
            ]
            lexical_results_by_name = {result.name: result for result in lexical_enriched}
            if normalized_mode == "hybrid":
                lexical_outcome = RouterOutcome(
                    strategy="hybrid",
                    reason="forced_mode",
                    winner=None,
                    considered=tuple(result.name for result in lexical_enriched),
                    backend_scores={
                        result.name: result.quality_score for result in lexical_enriched
                    },
                )
            else:
                lexical_outcome = _route_lexical_auto(lexical_enriched, self.config)
            (
                lexical_selected_hits,
                lexical_fusion_allocation,
                _lexical_backend_weights,
                _lexical_eligibility,
                lexical_fusion_proportional_shares,
            ) = _select_hits_for_outcome(
                results_by_name=lexical_results_by_name,
                outcome=lexical_outcome,
                intent=resolved_intent,
                hints=hints,
                config=self.config,
            )
            lexical_packed_hits = _pack_hits(
                lexical_selected_hits,
                repo_root=self.repo_root,
                intent=resolved_intent,
                config=self.config,
                preserve_ranked_order=lexical_outcome.strategy == "hybrid",
            )
            lexical_backend_thresholds = _backend_threshold_status(
                lexical_enriched,
                self.config,
            )
            lexical_decisive = _decisive_non_semantic_result(
                hits=lexical_packed_hits,
                outcome=lexical_outcome,
                backend_thresholds=lexical_backend_thresholds,
                query_kind=hints.query_kind,
            )
            lexical_auxiliary_only = _lexical_hits_are_auxiliary_only(
                hits=lexical_packed_hits,
                query=effective_query,
                intent=resolved_intent,
            )
            lexical_decisive_for_about = (
                lexical_decisive
                and not lexical_auxiliary_only
                and not _has_multiple_hit_paths(lexical_packed_hits)
            )
            semantic_needed = "semantic" in backend_names and (
                not lexical_decisive_for_about or lexical_auxiliary_only
            )
            enriched_results = list(lexical_enriched)
            if semantic_needed:
                semantic_result = _run_backend("semantic")
                semantic_enriched = replace(
                    semantic_result,
                    quality_score=_compute_quality(
                        semantic_result,
                        hints=hints,
                        intent=resolved_intent,
                    ),
                )
                enriched_results.append(semantic_enriched)
                if not lexical_packed_hits or lexical_auxiliary_only:
                    rescue_hits, rescue_used = _select_semantic_rescue_hits(
                        semantic_result=semantic_enriched,
                        config=self.config,
                    )
                    if rescue_used:
                        semantic_assist = "semantic_rescue"
                        selected_hits = rescue_hits
                        outcome = RouterOutcome(
                            strategy="semantic",
                            reason="semantic_rescue",
                            winner="semantic",
                            considered=tuple(result.name for result in enriched_results),
                            backend_scores={
                                result.name: result.quality_score for result in enriched_results
                            },
                        )
                    else:
                        if lexical_auxiliary_only:
                            selected_hits = []
                            outcome = RouterOutcome(
                                strategy="suppressed",
                                reason="semantic_rescue_not_confident",
                                winner=None,
                                considered=tuple(result.name for result in enriched_results),
                                backend_scores={
                                    result.name: result.quality_score for result in enriched_results
                                },
                                warning_codes=lexical_outcome.warning_codes,
                            )
                        else:
                            selected_hits = lexical_selected_hits
                            outcome = RouterOutcome(
                                strategy=lexical_outcome.strategy,
                                reason=lexical_outcome.reason,
                                winner=lexical_outcome.winner,
                                considered=tuple(result.name for result in enriched_results),
                                backend_scores={
                                    result.name: result.quality_score for result in enriched_results
                                },
                                warning_codes=lexical_outcome.warning_codes,
                            )
                            fusion_allocation = lexical_fusion_allocation
                            fusion_proportional_shares = lexical_fusion_proportional_shares
                else:
                    reranked_hits, reranked = _rerank_lexical_hits_with_semantic(
                        lexical_hits=lexical_selected_hits,
                        semantic_hits=semantic_enriched.hits,
                    )
                    semantic_assist = "rerank" if reranked else "none"
                    selected_hits = reranked_hits
                    outcome = RouterOutcome(
                        strategy=lexical_outcome.strategy,
                        reason="semantic_rerank" if reranked else lexical_outcome.reason,
                        winner=lexical_outcome.winner,
                        considered=tuple(result.name for result in enriched_results),
                        backend_scores={
                            result.name: result.quality_score for result in enriched_results
                        },
                        warning_codes=lexical_outcome.warning_codes,
                    )
                    fusion_allocation = lexical_fusion_allocation
                    fusion_proportional_shares = lexical_fusion_proportional_shares
            else:
                selected_hits = lexical_selected_hits
                outcome = RouterOutcome(
                    strategy=lexical_outcome.strategy,
                    reason=lexical_outcome.reason,
                    winner=lexical_outcome.winner,
                    considered=tuple(result.name for result in enriched_results),
                    backend_scores={
                        result.name: result.quality_score for result in enriched_results
                    },
                    warning_codes=lexical_outcome.warning_codes,
                )
                fusion_allocation = lexical_fusion_allocation
                fusion_proportional_shares = lexical_fusion_proportional_shares
        else:
            backend_results: list[BackendResult] = []
            if force_semantic_fusion:
                non_semantic_results: list[BackendResult] = []
                for backend_name in ("exact", "symbol"):
                    if backend_name in backend_names:
                        non_semantic_results.append(_run_backend(backend_name))
                backend_results.extend(non_semantic_results)
                if "semantic" in backend_names:
                    backend_results.append(_run_backend("semantic"))
            elif normalized_mode == "hybrid":
                if len(backend_names) == 1:
                    backend_results.append(_run_backend(backend_names[0]))
                else:
                    timeout_seconds = max((resolved_intent.time_budget_ms or 1) / 1000.0, 0.1)
                    with ThreadPoolExecutor(max_workers=len(backend_names)) as executor:
                        futures = {
                            executor.submit(_run_backend, name): name for name in backend_names
                        }
                        try:
                            for future in as_completed(futures, timeout=timeout_seconds):
                                backend_results.append(future.result())
                        except TimeoutError:
                            for future in futures:
                                future.cancel()
            elif normalized_mode in {"exact", "semantic"}:
                backend_results.append(_run_backend(normalized_mode))
            else:
                non_semantic_results = []
                for backend_name in ("exact", "symbol"):
                    if backend_name in backend_names:
                        non_semantic_results.append(_run_backend(backend_name))
                backend_results.extend(non_semantic_results)

                preliminary_results = [
                    replace(
                        result,
                        quality_score=_compute_quality(
                            result,
                            hints=hints,
                            intent=resolved_intent,
                        ),
                    )
                    for result in non_semantic_results
                ]
                preliminary_outcome = _route_auto(preliminary_results, self.config)
                has_non_semantic_hits = any(result.hits for result in non_semantic_results)
                needs_semantic_fallback = False
                if "semantic" in backend_names:
                    if hints.query_kind in {"identifier", "declaration"}:
                        needs_semantic_fallback = not has_non_semantic_hits
                    else:
                        needs_semantic_fallback = preliminary_outcome.strategy in {
                            "hybrid",
                            "suppressed",
                        }
                if needs_semantic_fallback:
                    backend_results.append(_run_backend("semantic"))

            enriched_results = []
            for result in backend_results:
                quality = _compute_quality(result, hints=hints, intent=resolved_intent)
                enriched_results.append(replace(result, quality_score=quality))

            results_by_name = {result.name: result for result in enriched_results}
            if force_semantic_fusion:
                outcome = RouterOutcome(
                    strategy="hybrid",
                    reason="semantic_query_requested",
                    winner=None,
                    considered=tuple(result.name for result in enriched_results),
                    backend_scores={
                        result.name: result.quality_score for result in enriched_results
                    },
                )
                (
                    selected_hits,
                    fusion_allocation,
                    _ignored_backend_weights,
                    _ignored_eligibility,
                    fusion_proportional_shares,
                ) = _adaptive_fusion_selection(
                    results_by_name=results_by_name,
                    intent=resolved_intent,
                    hints=hints,
                    config=self.config,
                )
            elif normalized_mode == "hybrid":
                outcome = RouterOutcome(
                    strategy="hybrid",
                    reason="forced_mode",
                    winner=None,
                    considered=tuple(result.name for result in enriched_results),
                    backend_scores={
                        result.name: result.quality_score for result in enriched_results
                    },
                )
                (
                    selected_hits,
                    fusion_allocation,
                    _ignored_backend_weights,
                    _ignored_eligibility,
                    fusion_proportional_shares,
                ) = _adaptive_fusion_selection(
                    results_by_name=results_by_name,
                    intent=resolved_intent,
                    hints=hints,
                    config=self.config,
                )
            elif normalized_mode in {"exact", "semantic"}:
                forced = results_by_name.get(normalized_mode)
                if forced is None:
                    forced = BackendResult(
                        name=normalized_mode, hits=(), timing_ms=0, error="backend_not_available"
                    )
                outcome = RouterOutcome(
                    strategy=normalized_mode,
                    reason="forced_mode",
                    winner=normalized_mode,
                    considered=tuple(result.name for result in enriched_results),
                    backend_scores={
                        result.name: result.quality_score for result in enriched_results
                    },
                )
                selected_hits = list(forced.hits)
            else:
                outcome = _route_auto(enriched_results, self.config)
                (
                    selected_hits,
                    fusion_allocation,
                    _ignored_backend_weights,
                    _ignored_eligibility,
                    fusion_proportional_shares,
                ) = _select_hits_for_outcome(
                    results_by_name=results_by_name,
                    outcome=outcome,
                    intent=resolved_intent,
                    hints=hints,
                    config=self.config,
                )

        results_by_name = {result.name: result for result in enriched_results}
        max_backend_ms = max((result.timing_ms for result in enriched_results), default=1)
        eligibility = {
            result.name: bool(
                result.hits and result.quality_score >= _threshold_for(result.name, self.config)
            )
            for result in enriched_results
        }
        backend_weights = {
            result.name: _adaptive_backend_weight(
                result,
                hints=hints,
                intent=resolved_intent,
                threshold_met=eligibility[result.name],
                max_backend_ms=max_backend_ms,
            )
            for result in enriched_results
        }

        packed_hits = _pack_hits(
            selected_hits,
            repo_root=self.repo_root,
            intent=resolved_intent,
            config=self.config,
            preserve_ranked_order=outcome.strategy == "hybrid" or semantic_assist == "rerank",
        )

        total_ms = int((time.perf_counter() - started) * 1000)
        backend_thresholds = _backend_threshold_status(enriched_results, self.config)
        warning_codes = list(outcome.warning_codes)
        requested_snippets = resolved_intent.max_snippets or self.config.max_snippets
        if (
            hints.query_kind in {"identifier", "declaration"}
            and requested_snippets > 8
            and "identifier_query_high_top_k" not in warning_codes
        ):
            warning_codes.append("identifier_query_high_top_k")
        decisive, next_action, suggested_path_prefix = _resolve_next_action(
            hits=packed_hits,
            outcome=outcome,
            backend_thresholds=backend_thresholds,
            query_kind=hints.query_kind,
            semantic_assist=semantic_assist,
        )
        summary: dict[str, object] = {
            "strategy": outcome.strategy,
            "reason": outcome.reason,
            "winner": outcome.winner,
            "hits": len(packed_hits),
            "warning_codes": warning_codes,
            "backend_thresholds": backend_thresholds,
            "query_kind": hints.query_kind,
            "decisive": decisive,
            "next_action": next_action,
        }
        if resolved_intent.result_profile == "locator":
            summary["assist"] = semantic_assist
        if suggested_path_prefix is not None:
            summary["suggested_path_prefix"] = suggested_path_prefix

        debug_payload: dict[str, object] = {
            "queries": {
                "lexical_query": query,
                "effective_lexical_query": effective_query,
                "semantic_query": resolved_intent.semantic_query,
            },
            "semantic_assist": semantic_assist,
            "timings": {
                "total_ms": total_ms,
                "backend_ms": {result.name: result.timing_ms for result in enriched_results},
            },
            "thresholds": {
                "exact": self.config.threshold_exact,
                "semantic": self.config.threshold_semantic,
                "symbol": self.config.threshold_symbol,
            },
            "backend_thresholds": backend_thresholds,
            "backend_scores": {result.name: result.quality_score for result in enriched_results},
            "backend_errors": {
                result.name: result.error for result in enriched_results if result.error is not None
            },
            "commands": {
                result.name: list(result.commands) for result in enriched_results if result.commands
            },
            "backend_weights": backend_weights,
            "fusion_budget": {
                "max_snippets": resolved_intent.max_snippets or self.config.max_snippets,
                "allocation": fusion_allocation,
                "proportional_shares": fusion_proportional_shares,
            },
            "eligibility": eligibility,
            "constraints": {
                "search_mode": resolved_intent.search_mode,
                "language": resolved_intent.language,
                "path_prefix": resolved_intent.path_prefix,
                "path_filters": list(resolved_intent.path_filters),
                "include_globs": list(resolved_execution_hints.include_globs),
                "exclude_globs": list(resolved_execution_hints.exclude_globs),
                "case_mode": backend_execution_hints.case_mode,
                "word_match": backend_execution_hints.word_match,
                "fixed_string": backend_execution_hints.fixed_string,
                "max_files": resolved_intent.max_files,
                "max_snippets": resolved_intent.max_snippets,
                "time_budget_ms": resolved_intent.time_budget_ms,
                "ranking_mode": resolved_intent.ranking_mode,
                "result_profile": resolved_intent.result_profile,
                "semantic_assist_mode": resolved_intent.semantic_assist_mode,
            },
            "query_kind": hints.query_kind,
            "declaration_terms": list(hints.declaration_terms),
            "parsed_query": parsed_query.to_debug_payload(),
        }

        log_router_event(
            config=self.config,
            repo_root=self.repo_root,
            query=query,
            payload={
                "query_kind": hints.query_kind,
                "strategy": outcome.strategy,
                "winner": outcome.winner,
                "reason": outcome.reason,
                "next_action": next_action,
                "decisive": decisive,
                "semantic_assist": semantic_assist,
                "queries": {
                    "lexical_query": query,
                    "effective_lexical_query": effective_query,
                    "semantic_query": resolved_intent.semantic_query,
                },
                "backend_scores": {
                    result.name: result.quality_score for result in enriched_results
                },
                "backend_weights": backend_weights,
                "backend_timings": {result.name: result.timing_ms for result in enriched_results},
                "fusion_budget": {
                    "allocation": fusion_allocation,
                    "proportional_shares": fusion_proportional_shares,
                    "eligibility": eligibility,
                },
                "pack": {
                    "hits": len(packed_hits),
                    "files": len({hit.path for hit in packed_hits}),
                    "chars": sum(len(hit.path) + len(hit.snippet) for hit in packed_hits),
                },
                "duration_ms": total_ms,
            },
        )

        return ContextPack(
            query=query,
            summary=summary,
            hits=packed_hits,
            debug=debug_payload,
        )
