from __future__ import annotations

from tests.pipeline_setup_v3.core.models import V3Case


class CaseValidator:
    def validate(self, case: V3Case, actual: dict, details: dict | None = None) -> list[str]:
        mismatches: list[str] = []
        self._check(case.expectations.router.intent, actual.get("intent"), "intent", mismatches)
        self._check(case.expectations.router.sub_intent, actual.get("sub_intent"), "sub_intent", mismatches)
        self._check(case.expectations.router.graph_id, actual.get("graph_id"), "graph_id", mismatches)
        self._check(case.expectations.router.conversation_mode, actual.get("conversation_mode"), "conversation_mode", mismatches)
        self._check(case.expectations.pipeline.answer_mode, actual.get("answer_mode"), "answer_mode", mismatches)
        self._check_retrieval(case, actual, details or {}, mismatches)
        self._check_llm(case, actual, mismatches)
        return mismatches

    def _check(self, expected, actual, label: str, mismatches: list[str]) -> None:
        if label == "answer_mode" and expected == "degraded" and actual == "not_found":
            return
        if expected is not None and expected != actual:
            mismatches.append(f"{label}: expected {expected}, got {actual}")

    def _check_retrieval(self, case: V3Case, actual: dict, details: dict, mismatches: list[str]) -> None:
        rag_count = int(actual.get("rag_count") or 0)
        retrieval = case.expectations.retrieval
        if retrieval.non_empty is True and rag_count == 0:
            mismatches.append("retrieval: expected non-empty rag, got 0 rows")
        if retrieval.non_empty is False and rag_count != 0:
            mismatches.append(f"retrieval: expected empty rag, got {rag_count} rows")
        if retrieval.min_rows is not None and rag_count < retrieval.min_rows:
            mismatches.append(f"retrieval: expected at least {retrieval.min_rows} rows, got {rag_count}")
        if retrieval.direct_symbol_test_hits_max is not None:
            direct_hits = self._direct_symbol_test_hits(case, details)
            if direct_hits > retrieval.direct_symbol_test_hits_max:
                mismatches.append(
                    f"retrieval: expected direct symbol-linked test hits <= {retrieval.direct_symbol_test_hits_max}, got {direct_hits}"
                )
        for path in retrieval.path_scope_contains:
            if not any(path in item for item in actual.get("path_scope") or ()):
                mismatches.append(f"path_scope should contain '{path}', got {list(actual.get('path_scope') or ())}")
        for symbol in retrieval.symbol_candidates_contain:
            if symbol not in (actual.get("symbol_candidates") or ()):
                mismatches.append(f"symbol_candidates should contain '{symbol}', got {list(actual.get('symbol_candidates') or ())}")
        for layer in retrieval.layers_include:
            if layer not in (actual.get("layers") or ()):
                mismatches.append(f"layers should include '{layer}', got {list(actual.get('layers') or ())}")

    def _check_llm(self, case: V3Case, actual: dict, mismatches: list[str]) -> None:
        llm = case.expectations.llm
        answer = str(actual.get("llm_answer") or "").strip()
        if llm.non_empty and not answer:
            mismatches.append("llm: expected non-empty answer")
        if llm.non_empty is False and answer:
            mismatches.append("llm: expected empty answer")
        lowered = answer.lower()
        for value in llm.contains_all:
            if value.lower() not in lowered:
                mismatches.append(f"llm: expected answer to contain '{value}'")
        for value in llm.excludes:
            if value.lower() in lowered:
                mismatches.append(f"llm: expected answer to exclude '{value}'")

    def _direct_symbol_test_hits(self, case: V3Case, details: dict) -> int:
        symbols = tuple(
            symbol.strip().lower()
            for symbol in case.expectations.retrieval.symbol_candidates_contain
            if symbol.strip()
        )
        if not symbols:
            return 0
        hits = 0
        for row in details.get("rag_rows") or []:
            if not self._is_test_row(row):
                continue
            haystacks = self._row_search_texts(row)
            if any(symbol in text for symbol in symbols for text in haystacks):
                hits += 1
        return hits

    def _is_test_row(self, row: dict) -> bool:
        path = str(row.get("path") or "").lower()
        metadata = dict(row.get("metadata") or {})
        if bool(metadata.get("is_test")):
            return True
        return "/tests/" in f"/{path}" or path.startswith("tests/") or "/test_" in path or path.endswith("_test.py")

    def _row_search_texts(self, row: dict) -> tuple[str, ...]:
        metadata = dict(row.get("metadata") or {})
        values = [
            str(row.get("title") or ""),
            str(row.get("content") or ""),
            str(metadata.get("qname") or ""),
            str(metadata.get("symbol_name") or ""),
            str(metadata.get("src_qname") or ""),
            str(metadata.get("dst_ref") or ""),
        ]
        return tuple(value.lower() for value in values if value)
