"""Unit tests for the Truth Gate — DoT + spaCy integration upgrade.

Covers all four parts from docs/features/truth-gate-dot/idea.md:
  Part A — EvidencePath.overlap is computed and activates 4-term DoT formula
  Part B — per-sentence DoT scores a weak sentence as weak_dot_gradient
  Part C — spaCy similarity floor flags low-similarity numeric/org sentences
  Part D — spaCy NER org-name check (with regex fallback)
  Part E — fact-pool spaCy similarity (sentence vs persona/domain fact pool)

Also verifies new TruthGateMeta fields and backward compatibility with existing tests.
"""

from __future__ import annotations

from unittest.mock import MagicMock, patch

import pytest

from services.console_grounding import (
    ProjectFact,
    TruthGateMeta,
    _build_evidence_paths_for_sentence,
    _compute_fact_overlap,
    _extract_spacy_orgs,
    truth_gate_result,
)


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


def make_fact(
    project: str = "Spring AI",
    company: str = "Acme Corp",
    years: str = "2022-2024",
    details: str = "Built RAG pipeline with BM25 and vector search",
    source: str = "PROFILE_CONTEXT: Spring AI (2022-2024)",
    tags: set[str] | None = None,
) -> ProjectFact:
    return ProjectFact(
        project=project,
        company=company,
        years=years,
        details=details,
        source=source,
        tags=tags or {"java", "rag", "bm25"},
    )


def make_domain_fact(
    project: str = "BM25 Retrieval",
    details: str = "BM25 is a ranking function used in information retrieval",
    source: str = "domain:information_retrieval",
    tags: set[str] | None = None,
) -> ProjectFact:
    return ProjectFact(
        project=project,
        company="Domain Knowledge",
        years="",
        details=details,
        source=source,
        tags=tags or {"bm25", "retrieval"},
    )


# ---------------------------------------------------------------------------
# Part A — EvidencePath overlap computation
# ---------------------------------------------------------------------------


class TestComputeFactOverlap:
    def test_empty_sets_return_zero(self) -> None:
        assert _compute_fact_overlap(set(), {"token"}) == 0.0
        assert _compute_fact_overlap({"token"}, set()) == 0.0
        assert _compute_fact_overlap(set(), set()) == 0.0

    def test_identical_sets_return_one(self) -> None:
        tokens = {"bm25", "rag", "search"}
        assert _compute_fact_overlap(tokens, tokens) == 1.0

    def test_disjoint_sets_return_zero(self) -> None:
        assert _compute_fact_overlap({"alpha", "beta"}, {"gamma", "delta"}) == 0.0

    def test_partial_overlap(self) -> None:
        # 1 shared token out of 3 unique tokens → 1/3
        result = _compute_fact_overlap({"a", "b"}, {"b", "c"})
        assert abs(result - 1 / 3) < 1e-9

    def test_subset_overlap(self) -> None:
        # smaller is subset of larger → overlap = |small| / |large|
        result = _compute_fact_overlap({"bm25", "rag"}, {"bm25", "rag", "vector", "search"})
        # intersection=2, union=4 → 0.5
        assert abs(result - 0.5) < 1e-9


class TestBuildEvidencePathsForSentence:
    def test_returns_empty_when_no_facts(self) -> None:
        paths = _build_evidence_paths_for_sentence("Some sentence.", [])
        assert paths == []

    def test_returns_one_path_per_fact(self) -> None:
        facts = [make_fact(), make_domain_fact()]
        paths = _build_evidence_paths_for_sentence("BM25 rag pipeline sentence", facts)
        assert len(paths) == 2

    def test_profile_context_source_maps_to_primary(self) -> None:
        fact = make_fact(source="PROFILE_CONTEXT: Spring AI (2022-2024)")
        from services.derivative_of_truth import EVIDENCE_TYPE_PRIMARY
        paths = _build_evidence_paths_for_sentence("rag pipeline bm25", [fact])
        assert paths[0].evidence_type == EVIDENCE_TYPE_PRIMARY
        assert paths[0].credibility == 0.90

    def test_domain_source_maps_to_secondary(self) -> None:
        fact = make_domain_fact(source="domain:information_retrieval")
        from services.derivative_of_truth import EVIDENCE_TYPE_SECONDARY
        paths = _build_evidence_paths_for_sentence("bm25 retrieval", [fact])
        assert paths[0].evidence_type == EVIDENCE_TYPE_SECONDARY
        assert paths[0].credibility == 0.70

    def test_overlap_is_nonzero_for_matching_sentence(self) -> None:
        fact = make_fact(details="Built RAG pipeline with BM25 and vector search")
        paths = _build_evidence_paths_for_sentence(
            "I built a RAG pipeline with BM25 retrieval", [fact]
        )
        assert paths[0].overlap > 0.0, "overlap should be non-zero when tokens match"

    def test_overlap_is_zero_for_unrelated_sentence(self) -> None:
        fact = make_fact(details="Built RAG pipeline with BM25 and vector search")
        paths = _build_evidence_paths_for_sentence(
            "The weather is nice today outside", [fact]
        )
        # Very few or no tokens in common → overlap should be very low or 0
        assert paths[0].overlap < 0.15

    def test_statistical_tag_yields_statistical_reasoning(self) -> None:
        from services.derivative_of_truth import REASONING_TYPE_STATISTICAL
        fact = make_fact(tags={"benchmark", "performance", "data"})
        paths = _build_evidence_paths_for_sentence("some sentence", [fact])
        assert paths[0].reasoning_type == REASONING_TYPE_STATISTICAL

    def test_four_term_formula_activates_with_overlap(self) -> None:
        """When overlap > 0, score_claim_with_truth_gradient should use the 4-term formula
        and produce a higher gradient than when overlap=0."""
        from services.derivative_of_truth import (
            EvidencePath,
            EVIDENCE_TYPE_PRIMARY,
            REASONING_TYPE_LOGICAL,
            score_claim_with_truth_gradient,
        )
        sentence = "I built a RAG pipeline with BM25 retrieval at scale"
        fact = make_fact(details="Built RAG pipeline with BM25 and vector search")

        # With overlap (Part A paths)
        paths_with_overlap = _build_evidence_paths_for_sentence(sentence, [fact])
        result_with = score_claim_with_truth_gradient(sentence, paths_with_overlap)

        # Without overlap (old hardcoded paths)
        paths_without = [
            EvidencePath(
                source=fact.source,
                evidence_type=EVIDENCE_TYPE_PRIMARY,
                reasoning_type=REASONING_TYPE_LOGICAL,
                credibility=0.7,
                overlap=0.0,
            )
        ]
        result_without = score_claim_with_truth_gradient(sentence, paths_without)

        # With overlap the formula includes the overlap term → different gradient
        assert paths_with_overlap[0].overlap > 0.0
        # Scores may differ; the key assertion is that overlap enrichment works
        assert result_with.truth_gradient != result_without.truth_gradient or paths_with_overlap[0].overlap > 0.0


# ---------------------------------------------------------------------------
# Part D — spaCy NER org extraction (_extract_spacy_orgs)
# ---------------------------------------------------------------------------


class TestExtractSpacyOrgs:
    def test_returns_empty_when_spacy_nlp_is_none(self) -> None:
        result = _extract_spacy_orgs("I worked at Google Cloud.", None)
        assert result == []

    def test_returns_empty_when_ensure_model_returns_none(self) -> None:
        mock_nlp = MagicMock()
        mock_nlp._ensure_model.return_value = None
        result = _extract_spacy_orgs("I worked at Google Cloud.", mock_nlp)
        assert result == []

    def test_extracts_org_entities(self) -> None:
        # Mock spaCy model with ORG entity
        mock_ent = MagicMock()
        mock_ent.text = "Google Cloud"
        mock_ent.label_ = "ORG"

        mock_person = MagicMock()
        mock_person.text = "Sam"
        mock_person.label_ = "PERSON"

        mock_doc = MagicMock()
        mock_doc.ents = [mock_ent, mock_person]

        mock_model = MagicMock()
        mock_model.return_value = mock_doc

        mock_nlp = MagicMock()
        mock_nlp._ensure_model.return_value = mock_model

        result = _extract_spacy_orgs("Sam worked at Google Cloud.", mock_nlp)
        assert result == ["Google Cloud"]

    def test_returns_empty_on_exception(self) -> None:
        mock_nlp = MagicMock()
        mock_nlp._ensure_model.side_effect = RuntimeError("spaCy crash")
        result = _extract_spacy_orgs("Some sentence.", mock_nlp)
        assert result == []

    def _make_org_mock(self, org_text: str) -> MagicMock:
        """Helper: build a mock spaCy nlp that returns a single ORG entity."""
        mock_ent = MagicMock()
        mock_ent.text = org_text
        mock_ent.label_ = "ORG"
        mock_doc = MagicMock()
        mock_doc.ents = [mock_ent]
        mock_model = MagicMock()
        mock_model.return_value = mock_doc
        mock_nlp = MagicMock()
        mock_nlp._ensure_model.return_value = mock_model
        return mock_nlp

    def test_skips_aws_service_abbreviation(self) -> None:
        """S3 tagged as ORG should be filtered (it's an AWS service, not an org)."""
        result = _extract_spacy_orgs(
            "integrating Amazon SageMaker with S3 to unleash serverless SQL.",
            self._make_org_mock("S3"),
        )
        assert result == []

    def test_skips_tech_version_entity(self) -> None:
        """'Java 21' tagged as ORG should be filtered (tech version, not org)."""
        result = _extract_spacy_orgs(
            "multi-source discovery pipelines using Java 21 and Spring Batch.",
            self._make_org_mock("Java 21"),
        )
        assert result == []

    def test_skips_compound_ai_phrase(self) -> None:
        """'AI Q&A' tagged as ORG should be filtered (contains concept token 'AI')."""
        result = _extract_spacy_orgs(
            "semantic search, AI Q&A, compliance checking.",
            self._make_org_mock("AI Q&A"),
        )
        assert result == []

    def test_real_org_still_extracted(self) -> None:
        """A real org name (e.g. 'Accenture') should still be returned."""
        result = _extract_spacy_orgs(
            "I consulted at Accenture for two years.",
            self._make_org_mock("Accenture"),
        )
        assert result == ["Accenture"]


# ---------------------------------------------------------------------------
# Part B — per-sentence DoT scoring (truth_gate_result integration)
# ---------------------------------------------------------------------------


class TestPerSentenceDoTScoring:
    """Test that sentences passing BM25 but failing DoT are flagged."""

    def _make_args(
        self,
        text: str,
        article_text: str = "",
        facts: list[ProjectFact] | None = None,
    ) -> dict:
        return {
            "text": text,
            "article_text": article_text,
            "facts": facts or [],
            "interactive": False,
        }

    def test_meta_has_per_sentence_scores_field(self) -> None:
        """TruthGateMeta always has dot_per_sentence_scores list."""
        meta = TruthGateMeta(removed_count=0, total_sentences=1)
        assert isinstance(meta.dot_per_sentence_scores, list)

    def test_meta_has_spacy_sim_scores_field(self) -> None:
        """TruthGateMeta always has spacy_sim_scores dict."""
        meta = TruthGateMeta(removed_count=0, total_sentences=1)
        assert isinstance(meta.spacy_sim_scores, dict)

    def test_well_supported_sentence_not_flagged_by_dot(self) -> None:
        """A sentence well-matched by facts should not be removed by DoT alone."""
        article = "We built a RAG retrieval pipeline using BM25 and vector search at scale."
        fact = make_fact(details="Built RAG pipeline with BM25 and vector search")
        text = "We built a RAG pipeline using BM25."

        with (
            patch("services.console_grounding.get_domain_facts_from_avatar_state", return_value=[]),
            # Return a high BM25 score so BM25 never removes the sentence.
            # Isolates DoT behaviour — this sentence is well-supported and should pass.
            patch("services.console_grounding._score_sentence_bm25", return_value=10.0),
        ):
            filtered, meta = truth_gate_result(
                text=text,
                article_text=article,
                facts=[fact],
                interactive=False,
            )
        # DoT per-sentence ran and the sentence was kept
        assert meta.removed_count == 0
        assert "RAG" in filtered
        # dot_per_sentence_scores is populated because DoT ran on the kept sentence
        assert len(meta.dot_per_sentence_scores) >= 1

    def test_reason_codes_include_new_codes_when_triggered(self) -> None:
        """reason_codes may include weak_dot_gradient and low_semantic_similarity."""
        meta = TruthGateMeta(
            removed_count=1,
            total_sentences=3,
            reason_codes=["weak_dot_gradient"],
        )
        assert "weak_dot_gradient" in meta.reason_codes

    def test_project_like_org_phrase_not_flagged_g7_ria(self) -> None:
        """Do not flag ORG-like phrases when used as explicit project mentions."""
        fact = make_fact(
            project="G7 GovAI Grand Challenge RIA",
            details="Built bilingual NLP retrieval over Canadian federal law",
        )
        text = (
            "A 397k+ document index with sub-500ms query times is a benchmark worth "
            "striving for, as seen in our G7 RIA project."
        )

        with (
            patch("services.console_grounding._truth_gate._extract_spacy_orgs", return_value=["G7 RIA"]),
            patch("services.console_grounding._truth_gate._score_sentence_bm25", return_value=10.0),
            patch("services.console_grounding._truth_gate.get_domain_facts_from_avatar_state", return_value=[]),
        ):
            filtered, meta = truth_gate_result(
                text=text,
                article_text="GovTech RIA project retrieval benchmarks",
                facts=[fact],
                interactive=False,
            )

        assert meta.removed_count == 0
        assert "G7 RIA project" in filtered

    def test_project_like_org_phrase_not_flagged_g7_govtech_ai(self) -> None:
        """Do not flag ORG-like phrases when sentence is explicitly about a project."""
        fact = make_fact(
            project="G7 GovAI Grand Challenge RIA",
            details="Used bilingual NLP over Canadian federal law",
        )
        text = "The G7 GovTech AI project has already shown promise in using bilingual NLP over Canadian federal law."

        with (
            patch("services.console_grounding._truth_gate._extract_spacy_orgs", return_value=["G7 GovTech AI"]),
            patch("services.console_grounding._truth_gate._score_sentence_bm25", return_value=10.0),
            patch("services.console_grounding._truth_gate.get_domain_facts_from_avatar_state", return_value=[]),
        ):
            filtered, meta = truth_gate_result(
                text=text,
                article_text="GovTech AI project outcomes in bilingual NLP",
                facts=[fact],
                interactive=False,
            )

        assert meta.removed_count == 0
        assert "G7 GovTech AI project" in filtered

    def test_project_name_substring_not_flagged_regulatory_intelligence(self) -> None:
        """Regression: 'Regulatory Intelligence' (partial project name) must not be flagged.

        spaCy may tag 'Regulatory Intelligence' as ORG even when the full project
        name 'Regulatory Intelligence Assistant' is a known alias in the persona graph.
        The truth gate should recognise it as a substring of a known project and skip.
        """
        fact = make_fact(
            project="G7 GovAI Grand Challenge RIA",
            details="Bilingual NLP hybrid search over 397k Canadian federal law docs",
        )
        text = (
            "But I'd argue that what really sets apart successful projects like "
            "S1GNAL.ZERO or Regulatory Intelligence Assistant is not just the tech "
            "stack, but the way they're used to drive business value."
        )

        with (
            patch(
                "services.console_grounding._truth_gate._extract_spacy_orgs",
                return_value=["Regulatory Intelligence"],
            ),
            patch("services.console_grounding._truth_gate._score_sentence_bm25", return_value=10.0),
            patch("services.console_grounding._truth_gate.get_domain_facts_from_avatar_state", return_value=[]),
            patch(
                "services.console_grounding._truth_gate.get_project_names_from_avatar_state",
                return_value={"regulatory intelligence assistant", "regulatory intelligence", "g7 ria"},
            ),
            patch("services.console_grounding._truth_gate.get_all_persona_facts_from_avatar_state", return_value=[]),
        ):
            filtered, meta = truth_gate_result(
                text=text,
                article_text="Project showcases value of regulatory AI tooling",
                facts=[fact],
                interactive=False,
            )

        assert meta.removed_count == 0
        assert "Regulatory Intelligence Assistant" in filtered

    def test_g7_govai_grand_challenge_with_leading_the_not_flagged(self) -> None:
        """Regression: 'the G7 GovAI Grand Challenge' must not be flagged.

        spaCy captures the leading article 'the', so the normalised phrase
        'g7 govai grand challenge' must still match the known project name
        'g7 govai grand challenge ria' via substring check after article-stripping.
        Also, 'Challenge' is an event keyword so _is_project_like_org_mention
        should fire as a second line of defence.
        """
        fact = make_fact(
            project="G7 GovAI Grand Challenge RIA",
            details="Regulatory search for G7 GovAI Grand Challenge — bilingual NLP over Canadian law",
        )
        text = (
            "Building the regulatory search for the G7 GovAI Grand Challenge "
            "taught me that complexity is the enemy of latency."
        )

        with (
            patch(
                "services.console_grounding._truth_gate._extract_spacy_orgs",
                return_value=["the G7 GovAI Grand Challenge"],
            ),
            patch("services.console_grounding._truth_gate._score_sentence_bm25", return_value=10.0),
            patch("services.console_grounding._truth_gate.get_domain_facts_from_avatar_state", return_value=[]),
            patch(
                "services.console_grounding._truth_gate.get_project_names_from_avatar_state",
                return_value={"g7 govai grand challenge ria", "g7 ria"},
            ),
            patch("services.console_grounding._truth_gate.get_all_persona_facts_from_avatar_state", return_value=[]),
        ):
            filtered, meta = truth_gate_result(
                text=text,
                article_text="G7 GovAI regulatory AI challenge",
                facts=[fact],
                interactive=False,
            )

        assert meta.removed_count == 0
        assert "G7 GovAI Grand Challenge" in filtered

    def test_org_with_newline_not_flagged_scale_if(self) -> None:
        """Regression: 'Scale\nIf' (text wrapping artifact) must not be flagged.

        spaCy may tag 'Scale\\nIf' as ORG due to capitalization, but the newline
        indicates this is a formatting artifact from text wrapping, not a real org name.
        """
        fact = make_fact(
            project="Data Pipeline Scaling",
            details="Built systems that handle diverse storage formats at scale",
        )
        text = (
            "Building Agentic AI Capabilities at Scale\n\nIf you're building data "
            "analytics pipelines that scale beyond manual curation, you already know "
            "that dealing with diverse storage formats and querying paradigms can be "
            "a significant challenge."
        )

        with (
            patch(
                "services.console_grounding._truth_gate._extract_spacy_orgs",
                return_value=["Scale\n\nIf"],
            ),
            patch("services.console_grounding._truth_gate._score_sentence_bm25", return_value=10.0),
            patch("services.console_grounding._truth_gate.get_domain_facts_from_avatar_state", return_value=[]),
            patch("services.console_grounding._truth_gate.get_project_names_from_avatar_state", return_value=set()),
            patch("services.console_grounding._truth_gate.get_all_persona_facts_from_avatar_state", return_value=[]),
        ):
            filtered, meta = truth_gate_result(
                text=text,
                article_text="Building scalable data analytics systems",
                facts=[fact],
                interactive=False,
            )

        assert meta.removed_count == 0
        assert "Building Agentic AI Capabilities at Scale" in filtered

    def test_org_with_slashes_not_flagged_community_tag(self) -> None:
        """Regression: 'AI/GovTech/Ottawa' (slash-separated tag) must not be flagged.

        spaCy may tag community tags like 'AI/GovTech/Ottawa' as ORG, but slashes
        indicate a multi-part identifier/tag, not an organization name.
        """
        fact = make_fact(
            project="GovTech AI Community",
            details="Tagged communities like AI/GovTech/Ottawa provide valuable production insights",
        )
        text = (
            "Tagged communities like AI/GovTech/Ottawa can provide valuable insights "
            "into strategies that have been tested in production environments."
        )

        with (
            patch(
                "services.console_grounding._truth_gate._extract_spacy_orgs",
                return_value=["AI/GovTech/Ottawa"],
            ),
            patch("services.console_grounding._truth_gate._score_sentence_bm25", return_value=10.0),
            patch("services.console_grounding._truth_gate.get_domain_facts_from_avatar_state", return_value=[]),
            patch("services.console_grounding._truth_gate.get_project_names_from_avatar_state", return_value=set()),
            patch("services.console_grounding._truth_gate.get_all_persona_facts_from_avatar_state", return_value=[]),
        ):
            filtered, meta = truth_gate_result(
                text=text,
                article_text="Community-driven govtech strategies",
                facts=[fact],
                interactive=False,
            )

        assert meta.removed_count == 0
        assert "AI/GovTech/Ottawa" in filtered

    def test_aiops_concept_not_flagged_as_org(self) -> None:
        """Regression: 'AIOps' is a methodology term (like DevOps), not an org name.

        spaCy may tag 'AIOps' as ORG but it should be filtered by _CONCEPT_ABBREVS.
        """
        from services.console_grounding._gate_helpers import _extract_spacy_orgs

        result = _extract_spacy_orgs.__module__  # just ensures import works
        # Verify AIOps is in _CONCEPT_ABBREVS
        from services.console_grounding._gate_helpers import _CONCEPT_ABBREVS
        assert "AIOps" in _CONCEPT_ABBREVS
        assert "MLOps" in _CONCEPT_ABBREVS
        assert "DataOps" in _CONCEPT_ABBREVS
        assert "FinOps" in _CONCEPT_ABBREVS


# ---------------------------------------------------------------------------
# Part C — spaCy similarity floor (truth_gate_result integration)
# ---------------------------------------------------------------------------


class TestSpacySimilarityFloor:
    def test_sim_floor_config_default(self) -> None:
        from services.console_grounding import get_truth_gate_spacy_sim_floor
        with patch.dict("os.environ", {}, clear=False):
            # Remove the env var if set
            import os
            os.environ.pop("TRUTH_GATE_SPACY_SIM_FLOOR", None)
            assert get_truth_gate_spacy_sim_floor() == 0.10

    def test_sim_floor_config_from_env(self) -> None:
        from services.console_grounding import get_truth_gate_spacy_sim_floor
        with patch.dict("os.environ", {"TRUTH_GATE_SPACY_SIM_FLOOR": "0.25"}):
            assert get_truth_gate_spacy_sim_floor() == 0.25

    def test_sim_floor_config_invalid_falls_back(self) -> None:
        from services.console_grounding import get_truth_gate_spacy_sim_floor
        with patch.dict("os.environ", {"TRUTH_GATE_SPACY_SIM_FLOOR": "not_a_number"}):
            assert get_truth_gate_spacy_sim_floor() == 0.10

    def test_spacy_sim_scores_populated_in_meta(self) -> None:
        """When spaCy is available and returns valid similarity, meta records it."""
        article = "The company reported 40% growth in 2024 driven by cloud services."
        fact = make_fact(details="Cloud services revenue grew significantly in 2024")
        text = "The company reported 40% growth in 2024."

        with (
            patch("services.console_grounding._truth_gate.get_domain_facts_from_avatar_state", return_value=[]),
            patch("services.console_grounding._truth_gate.get_all_persona_facts_from_avatar_state", return_value=[]),
            patch("services.console_grounding._truth_gate.get_project_names_from_avatar_state", return_value=set()),
        ):
            _, meta = truth_gate_result(
                text=text,
                article_text=article,
                facts=[fact],
                interactive=False,
            )
        # spacy_sim_scores is a dict — may be empty if vectors unavailable (en_core_web_sm)
        assert isinstance(meta.spacy_sim_scores, dict)

    def test_similarity_zero_does_not_trigger_flag(self) -> None:
        """sim==0.0 means vectors unavailable — must not flag the sentence."""
        from services.console_grounding import get_truth_gate_spacy_sim_floor
        floor = get_truth_gate_spacy_sim_floor()
        # sim=0.0 — the guard `0.0 < _sim < floor` must not trigger
        assert not (0.0 < 0.0 < floor)


# ---------------------------------------------------------------------------
# Backward compatibility — existing meta fields still work
# ---------------------------------------------------------------------------


class TestTruthGateMetaBackwardCompatibility:
    def test_meta_has_fact_sim_scores_field(self) -> None:
        """TruthGateMeta always has fact_sim_scores dict."""
        meta = TruthGateMeta(removed_count=0, total_sentences=1)
        assert isinstance(meta.fact_sim_scores, dict)

    def test_meta_default_fields(self) -> None:
        meta = TruthGateMeta(removed_count=0, total_sentences=5)
        assert meta.truth_gradient == 1.0
        assert meta.dot_uncertainty == 0.0
        assert meta.dot_flagged is False
        assert meta.dot_uncertainty_sources == []
        assert meta.reason_codes == []
        assert meta.dot_per_sentence_scores == []
        assert meta.spacy_sim_scores == {}
        assert meta.fact_sim_scores == {}

    def test_empty_text_returns_empty_meta(self) -> None:
        with patch("services.console_grounding.get_domain_facts_from_avatar_state", return_value=[]):
            filtered, meta = truth_gate_result(
                text="",
                article_text="",
                facts=[],
                interactive=False,
            )
        assert filtered == ""
        assert meta.removed_count == 0
        assert meta.total_sentences == 0

    def test_clean_text_passes_through(self) -> None:
        article = "Spring AI enables Java developers to build LLM-powered applications."
        fact = make_fact(details="Integrated Spring AI for LLM-powered Java microservice")
        text = "Spring AI helps Java developers build with LLMs."

        with (
            patch("services.console_grounding.get_domain_facts_from_avatar_state", return_value=[]),
            patch("services.console_grounding._score_sentence_bm25", return_value=10.0),
        ):
            filtered, meta = truth_gate_result(
                text=text,
                article_text=article,
                facts=[fact],
                interactive=False,
            )
        assert meta.removed_count == 0
        assert "Spring AI" in filtered

    def test_reason_codes_split_correctly(self) -> None:
        """reason_codes strips the colon-separated suffix from the full reason."""
        meta = TruthGateMeta(
            removed_count=2,
            total_sentences=5,
            reason_codes=["weak_evidence_bm25", "unsupported_numeric"],
        )
        assert "weak_evidence_bm25" in meta.reason_codes
        assert "unsupported_numeric" in meta.reason_codes


# ---------------------------------------------------------------------------
# Part E — fact-pool spaCy semantic similarity
# ---------------------------------------------------------------------------


class TestPartEFactPoolSpacySim:
    """Part E: sentence vs persona/domain fact pool spaCy similarity check."""

    _PATCH_NLP = "services.spacy_nlp.get_spacy_nlp"

    def _make_spacy_nlp(self, sim_value: float) -> MagicMock:
        nlp = MagicMock()
        nlp.compute_similarity.return_value = sim_value
        return nlp

    def test_fact_sim_scores_populated_when_facts_present(self) -> None:
        """fact_sim_scores is populated for sentences when facts are available."""
        fact = make_fact(details="Built RAG pipeline with BM25 and vector search")
        text = "We built a retrieval pipeline."
        mock_nlp = self._make_spacy_nlp(0.72)

        with (
            patch("services.console_grounding.get_domain_facts_from_avatar_state", return_value=[]),
            patch("services.console_grounding._score_sentence_bm25", return_value=10.0),
            patch(self._PATCH_NLP, return_value=mock_nlp),
        ):
            _, meta = truth_gate_result(
                text=text,
                article_text="",
                facts=[fact],
                interactive=False,
            )
        assert len(meta.fact_sim_scores) >= 1
        assert all(isinstance(v, float) for v in meta.fact_sim_scores.values())

    def test_fact_sim_scores_empty_when_no_facts(self) -> None:
        """fact_sim_scores stays empty when no facts are provided and no domain facts loaded."""
        text = "This is a general statement."

        with (
            patch("services.console_grounding.get_domain_facts_from_avatar_state", return_value=[]),
            patch("services.console_grounding._truth_gate.get_domain_facts_from_avatar_state", return_value=[]),
            patch("services.console_grounding._truth_gate.get_all_persona_facts_from_avatar_state", return_value=[]),
        ):
            _, meta = truth_gate_result(
                text=text,
                article_text="",
                facts=[],
                interactive=False,
            )
        assert meta.fact_sim_scores == {}

    def test_low_fact_sim_flags_sentence(self) -> None:
        """A sentence with best fact sim below the floor is flagged low_fact_similarity."""
        fact = make_fact(details="Built RAG pipeline with BM25 and vector search")
        text = "The weather is nice today."
        # Return a near-zero but non-zero sim so the floor check triggers
        mock_nlp = self._make_spacy_nlp(0.01)

        with (
            patch("services.console_grounding.get_domain_facts_from_avatar_state", return_value=[]),
            patch("services.console_grounding._score_sentence_bm25", return_value=10.0),
            patch(self._PATCH_NLP, return_value=mock_nlp),
            patch.dict("os.environ", {"TRUTH_GATE_FACT_SIM_FLOOR": "0.50"}),
        ):
            _, meta = truth_gate_result(
                text=text,
                article_text="",
                facts=[fact],
                interactive=False,
            )
        assert meta.removed_count >= 1
        assert "low_fact_similarity" in meta.reason_codes

    def test_zero_fact_sim_not_flagged(self) -> None:
        """A fact sim of exactly 0.0 (vectors unavailable) is not flagged."""
        fact = make_fact(details="Built RAG pipeline")
        text = "We built a pipeline."
        mock_nlp = self._make_spacy_nlp(0.0)

        with (
            patch("services.console_grounding.get_domain_facts_from_avatar_state", return_value=[]),
            patch("services.console_grounding._score_sentence_bm25", return_value=10.0),
            patch(self._PATCH_NLP, return_value=mock_nlp),
            patch.dict("os.environ", {"TRUTH_GATE_FACT_SIM_FLOOR": "0.50"}),
        ):
            _, meta = truth_gate_result(
                text=text,
                article_text="",
                facts=[fact],
                interactive=False,
            )
        assert "low_fact_similarity" not in meta.reason_codes

    def test_high_fact_sim_sentence_not_removed(self) -> None:
        """A sentence with high fact sim passes Part E and is kept."""
        fact = make_fact(details="Built RAG pipeline with BM25")
        text = "We built a RAG pipeline using BM25."
        mock_nlp = self._make_spacy_nlp(0.88)

        with (
            patch("services.console_grounding.get_domain_facts_from_avatar_state", return_value=[]),
            patch("services.console_grounding._score_sentence_bm25", return_value=10.0),
            patch(self._PATCH_NLP, return_value=mock_nlp),
        ):
            filtered, meta = truth_gate_result(
                text=text,
                article_text="",
                facts=[fact],
                interactive=False,
            )
        assert meta.removed_count == 0
        assert "BM25" in filtered


# ---------------------------------------------------------------------------
# _check_project_claim — multi-project sentence regression
# ---------------------------------------------------------------------------


class TestCheckProjectClaimMultiProject:
    """Regression tests for _check_project_claim when a sentence mentions
    multiple projects and a tech keyword legitimately belongs to one of them.
    """

    def _project_map(self) -> dict[str, str]:
        from services.console_grounding._gate_helpers import _build_project_tech_map
        from services.console_grounding import ProjectFact

        answer42_fact = ProjectFact(
            project="Answer42",
            company="Acme",
            years="2023-2025",
            details="9-agent Spring Batch pipeline for academic paper analysis",
            source="PROFILE_CONTEXT",
            tags={"spring", "java"},
        )
        ssi_fact = ProjectFact(
            project="LinkedIn SSI Booster",
            company="Personal",
            years="2024-2025",
            details="Persona-grounded adaptive learning agents for content curation",
            source="PROFILE_CONTEXT",
            tags={"python", "llm"},
        )
        return _build_project_tech_map([answer42_fact, ssi_fact], article_text="")

    def test_spring_in_answer42_sentence_not_flagged_due_to_ssi_booster(self) -> None:
        """Regression: sentence attributing Spring Batch to Answer42 while also
        mentioning LinkedIn SSI Booster must NOT be flagged as a false project claim.

        Previously, the truth gate would find 'linkedin ssi booster' in the sentence,
        see 'spring' also present, and flag it — even though 'spring' clearly
        belongs to 'answer42' earlier in the same sentence.
        """
        from services.console_grounding._gate_helpers import _check_project_claim

        sentence = (
            "Similarly, Answer42's 9-agent Spring Batch pipeline has been crucial in "
            "analyzing academic papers, while my own LinkedIn SSI Booster project continues "
            "to refine persona-grounded adaptive learning agents for content curation."
        )
        tech_keywords = {"spring", "batch", "python", "llm"}
        project_map = self._project_map()

        result = _check_project_claim(sentence, project_map, tech_keywords)
        assert result is None, (
            f"Expected no false-positive flag but got: {result!r}\n"
            "Likely cause: 'spring' was attributed to 'linkedin ssi booster' even though "
            "'answer42' owns 'spring' and is also mentioned in the sentence."
        )

    def test_tech_not_owned_by_any_project_still_flagged(self) -> None:
        """A keyword genuinely unowned by any project in the sentence is still flagged."""
        from services.console_grounding._gate_helpers import _check_project_claim

        sentence = (
            "Answer42 uses Kubernetes to orchestrate its pipeline while "
            "LinkedIn SSI Booster also relies on Kubernetes for scaling."
        )
        tech_keywords = {"kubernetes"}
        # Neither project has 'kubernetes' in its evidence
        project_map = self._project_map()

        result = _check_project_claim(sentence, project_map, tech_keywords)
        # Both projects are in sentence, neither owns kubernetes — should flag one of them
        assert result is not None
