"""Shared utilities for retrieval evaluators.

URL normalization is intentionally absent in V1: ``webUrl`` is preserved in
normalized telemetry passthrough only and surfaced as a diagnostic-only
``matchedHitUrl`` on matched items. URL-based assertions are deferred to V2
(see ``contracts/retrieval-result-evaluator.md`` "URL Matching (Deferred to V2)").
Selector matching operates on ``queryString`` directly.
"""

from __future__ import annotations

from typing import Any, Dict, List, Optional

_SUCCESS_STATUSES = {"success", "succeeded", "completed"}

# Contract-fixed values shared by RetrievalQueryEvaluator and
# RetrievalResultEvaluator. Both Return Value Schemas pin threshold=1.0 and
# treat pass/fail as binary 1.0 / 0.0 scores.
THRESHOLD = 1.0
PASS_SCORE = 1.0
FAIL_SCORE = 0.0


def normalized_contains(text: str, substring: str) -> bool:
    """Deterministic case-insensitive containment.

    Returns ``False`` for any non-string input or when ``substring`` is empty.
    """
    if not isinstance(text, str) or not isinstance(substring, str) or not substring:
        return False
    return substring.casefold() in text.casefold()


def get_retrieval_executions(
    telemetry: Optional[Dict[str, Any]],
    capability: str,
) -> List[Dict[str, Any]]:
    """Return successful retrieval executions for the given capability.

    Failed executions (``status`` not in ``{success, succeeded, completed}``)
    are filtered out so callers see only the executions whose hits are eligible
    for evaluation. Mixed-success fan-out is tolerated.

    Returns an empty list when the telemetry is missing or no successful
    executions match the capability.
    """
    if not telemetry or not isinstance(telemetry, dict):
        return []
    executions = telemetry.get("retrieval_executions") or []
    if not isinstance(executions, list):
        return []
    matching: List[Dict[str, Any]] = []
    for execution in executions:
        if not isinstance(execution, dict):
            continue
        if execution.get("capability") != capability:
            continue
        status = (execution.get("status") or "").lower()
        if status and status not in _SUCCESS_STATUSES:
            continue
        matching.append(execution)
    return matching


def has_retrieval_failure(
    telemetry: Optional[Dict[str, Any]],
    executions: List[Dict[str, Any]],
    capability: Optional[str] = None,
) -> bool:
    """Return True only when telemetry is missing or every matching execution failed.

    Mixed-success fan-out is tolerated: when at least one matching execution
    succeeded, this returns False so the evaluator can run against the
    successful executions.

    The all-failed case applies only when the artifact contained one or more
    matching-capability executions but none succeeded. If the capability simply
    wasn't requested in this run, this returns False so callers can surface
    "no candidates" rather than treating it as a retrieval-path failure.

    Args:
        telemetry: Normalized retrieval telemetry dict, or None if the artifact
            was missing.
        executions: Successful executions matching the capability (typically
            the output of :func:`get_retrieval_executions`).
        capability: When provided, restricts the all-failed check to executions
            whose ``capability`` matches. When omitted, any executions in the
            artifact count.
    """
    if telemetry is None or not isinstance(telemetry, dict):
        return True
    raw_executions = telemetry.get("retrieval_executions")
    if not raw_executions:
        # No executions in artifact at all — treat as retrieval-path failure.
        return True
    if executions:
        # At least one successful execution remains for the capability.
        return False
    # No successful executions remain. Check whether the artifact contained
    # any matching-capability executions that all failed.
    matching_in_artifact = 0
    for execution in raw_executions:
        if not isinstance(execution, dict):
            continue
        if capability is not None and execution.get("capability") != capability:
            continue
        matching_in_artifact += 1
    return matching_in_artifact > 0


def flatten_hits_with_execution_context(
    executions: List[Dict[str, Any]],
    max_rank: int,
) -> List[Dict[str, Any]]:
    """Collect ``retrievalHits`` from the supplied executions filtered by rank.

    Each returned hit is the dict from an execution's ``retrievalHits[]`` array
    whose ``rank <= max_rank``. Candidates are sorted by ascending ``rank`` so
    the most relevant hits come first across all executions; ties keep
    execution-then-array order (stable sort). This ordering matters because
    first-hit matching downstream walks candidates in order and stops at the
    first hit that supports a claim, so the sort determines which hit is matched
    and attributed. Hits missing an integer ``rank`` field are skipped.
    """
    candidates: List[Dict[str, Any]] = []
    for execution in executions:
        hits = execution.get("retrievalHits") or []
        if not isinstance(hits, list):
            continue
        for hit in hits:
            if not isinstance(hit, dict):
                continue
            rank = hit.get("rank")
            if not isinstance(rank, int) or isinstance(rank, bool):
                continue
            if rank <= max_rank:
                candidates.append(hit)
    candidates.sort(key=lambda hit: hit["rank"])
    return candidates


def extract_texts(hit: Dict[str, Any]) -> List[str]:
    """Return the ``text`` strings from a hit's ``extracts`` array.

    Skips non-dict entries and missing/non-string ``text`` fields.
    """
    extracts = hit.get("extracts") or []
    if not isinstance(extracts, list):
        return []
    texts: List[str] = []
    for extract in extracts:
        if not isinstance(extract, dict):
            continue
        text = extract.get("text")
        if isinstance(text, str):
            texts.append(text)
    return texts
