"""Unit tests for DomainTermExtractor."""

import math
from unittest.mock import MagicMock, patch

import pytest

from src.extraction_v2.domain_term_extractor import (
    DomainTaxonomy,
    DomainTermExtractor,
    LLM_FALLBACK_CONFIDENCE,
    RULE_CONFIDENCE,
)


class TestDomainTaxonomy:
    """Tests for DomainTaxonomy dataclass."""

    def test_get_all_terms(self):
        """Should return all unique terms from all categories."""
        taxonomy = DomainTaxonomy()
        all_terms = taxonomy.get_all_terms()

        assert isinstance(all_terms, set)
        assert "revenue" in all_terms
        assert "employee" in all_terms
        assert "contract" in all_terms
        assert len(all_terms) > 50  # Should have many terms

    def test_infer_category_finance(self):
        """Should infer finance category from finance terms."""
        taxonomy = DomainTaxonomy()
        terms = ["revenue", "ebitda", "profit", "margin"]
        category = taxonomy.infer_category(terms)

        assert category == "finance"

    def test_infer_category_hr(self):
        """Should infer HR category from HR terms."""
        taxonomy = DomainTaxonomy()
        terms = ["employee", "salary", "compensation", "payroll"]
        category = taxonomy.infer_category(terms)

        assert category == "hr"

    def test_infer_category_mixed_prefers_highest(self):
        """Should choose category with most matches when terms are mixed."""
        taxonomy = DomainTaxonomy()
        # 3 finance terms, 1 HR term
        terms = ["revenue", "ebitda", "profit", "employee"]
        category = taxonomy.infer_category(terms)

        assert category == "finance"

    def test_infer_category_no_matches(self):
        """Should return 'general' when no terms match taxonomy."""
        taxonomy = DomainTaxonomy()
        terms = ["unknown", "random", "words"]
        category = taxonomy.infer_category(terms)

        assert category == "general"

    def test_infer_category_empty(self):
        """Should return 'general' for empty terms list."""
        taxonomy = DomainTaxonomy()
        category = taxonomy.infer_category([])

        assert category == "general"


class TestDomainTermExtractor:
    """Tests for DomainTermExtractor class."""

    def test_extract_from_chunk_finance_content(self):
        """Should extract finance terms from financial content."""
        extractor = DomainTermExtractor()
        content = "Q4 EBITDA increased by 15% to $2.3M. Revenue growth exceeded forecast."

        result = extractor.extract_from_chunk(content)

        assert "domain_terms" in result
        assert "domain_category" in result
        assert "extraction_method" in result

        # Should find finance terms
        assert "ebitda" in result["domain_terms"]
        assert "revenue" in result["domain_terms"]
        assert result["domain_category"] == "finance"

    def test_extract_from_chunk_hr_content(self):
        """Should extract HR terms from HR content."""
        extractor = DomainTermExtractor()
        content = "Employee headcount increased. New hires in Q3 received onboarding and training."

        result = extractor.extract_from_chunk(content)

        assert "employee" in result["domain_terms"]
        assert result["domain_category"] == "hr"

    def test_extract_from_chunk_empty_content(self):
        """Should return empty results for empty content."""
        extractor = DomainTermExtractor()

        result = extractor.extract_from_chunk("")

        assert result["domain_terms"] == []
        assert result["domain_category"] == "general"
        assert result["extraction_method"] == "none"

    def test_extract_from_chunk_whitespace_only(self):
        """Should return empty results for whitespace-only content."""
        extractor = DomainTermExtractor()

        result = extractor.extract_from_chunk("   \n\t  ")

        assert result["domain_terms"] == []
        assert result["domain_category"] == "general"

    def test_extract_from_chunk_respects_max_terms(self):
        """Should limit terms to MAX_TERMS_PER_CHUNK."""
        extractor = DomainTermExtractor()
        # Content with many matching terms
        content = """
        Revenue EBITDA profit margin loss income expense balance asset liability
        equity cash flow investment dividend earnings debt credit budget forecast
        valuation ROI quarter quarterly annual fiscal financial employee salary
        compensation benefit payroll hire termination performance review training
        """

        result = extractor.extract_from_chunk(content)

        # Should respect MAX_TERMS_PER_CHUNK limit (default 50)
        assert len(result["domain_terms"]) <= 50

    def test_extract_from_chunk_deduplicates_terms(self):
        """Should deduplicate repeated terms."""
        extractor = DomainTermExtractor()
        content = "Revenue increased. Revenue growth was strong. Revenue projections are up."

        result = extractor.extract_from_chunk(content)

        # Should only include "revenue" once
        revenue_count = result["domain_terms"].count("revenue")
        assert revenue_count == 1

    @patch("src.extraction_v2.domain_term_extractor.DomainTermExtractor._get_client")
    def test_extract_from_chunk_uses_llm_when_few_terms(self, mock_get_client):
        """Should fall back to LLM when rule-based finds < threshold terms."""
        # Mock LLM response
        mock_client = MagicMock()
        mock_response = MagicMock()
        mock_response.choices[0].message.content = '["custom", "term", "example"]'
        mock_client.chat.completions.create.return_value = mock_response
        mock_get_client.return_value = mock_client

        extractor = DomainTermExtractor()
        # Content with only 1 matching term (below threshold of 2)
        content = "This document discusses revenue in a generic way."

        with patch.dict("os.environ", {"DOMAIN_TERM_LLM_THRESHOLD": "2"}):
            result = extractor.extract_from_chunk(content)

        # Should have called LLM
        assert mock_client.chat.completions.create.called
        assert result["extraction_method"] == "llm"

    @patch("src.extraction_v2.domain_term_extractor.DomainTermExtractor._get_client")
    def test_llm_extract_handles_json_parse_error(self, mock_get_client):
        """Should handle LLM returning invalid JSON gracefully."""
        # Mock LLM returning invalid JSON
        mock_client = MagicMock()
        mock_response = MagicMock()
        mock_response.choices[0].message.content = "invalid json"
        mock_client.chat.completions.create.return_value = mock_response
        mock_get_client.return_value = mock_client

        extractor = DomainTermExtractor()
        result = extractor._llm_extract("test content")

        # Should return empty list on parse error
        assert result == []

    @patch("src.extraction_v2.domain_term_extractor.DomainTermExtractor._get_client")
    def test_llm_extract_handles_exception(self, mock_get_client):
        """Should handle LLM API errors gracefully."""
        # Mock LLM raising exception
        mock_client = MagicMock()
        mock_client.chat.completions.create.side_effect = Exception("API Error")
        mock_get_client.return_value = mock_client

        extractor = DomainTermExtractor()
        result = extractor._llm_extract("test content")

        # Should return empty list on exception
        assert result == []

    def test_extract_from_excel_record_with_hr_headers(self):
        """Should extract HR terms from Excel record with HR headers."""
        extractor = DomainTermExtractor()

        headers = ["Employee ID", "Salary", "Department", "Hire Date"]
        row_data = {
            "Employee ID": "E123",
            "Salary": 75000,
            "Department": "Engineering",
            "Hire Date": "2023-01-15"
        }

        result = extractor.extract_from_excel_record(headers, row_data)

        assert "employee" in result["domain_terms"]
        assert "salary" in result["domain_terms"]
        # Note: category might be "hr" or "engineering" depending on term counts
        assert result["domain_category"] in ["hr", "engineering"]

    def test_extract_from_excel_record_with_finance_headers(self):
        """Should extract finance terms from Excel record with finance headers."""
        extractor = DomainTermExtractor()

        headers = ["Quarter", "Revenue", "EBITDA", "Profit Margin"]
        row_data = {
            "Quarter": "Q4 2023",
            "Revenue": 2300000,
            "EBITDA": 450000,
            "Profit Margin": "19.5%"
        }

        result = extractor.extract_from_excel_record(headers, row_data)

        assert "revenue" in result["domain_terms"]
        assert "ebitda" in result["domain_terms"]
        assert "quarter" in result["domain_terms"]
        assert result["domain_category"] == "finance"

    def test_extract_from_excel_record_includes_sheet_name(self):
        """Should include sheet name in term extraction."""
        extractor = DomainTermExtractor()

        headers = ["Name", "Value"]
        row_data = {"Name": "Test", "Value": 100}
        sheet_name = "Revenue Analysis"

        result = extractor.extract_from_excel_record(
            headers, row_data, sheet_name=sheet_name
        )

        # Should find "revenue" from sheet name
        assert "revenue" in result["domain_terms"]

    def test_extract_from_excel_record_skips_numeric_values(self):
        """Should not extract from numeric cell values."""
        extractor = DomainTermExtractor()

        headers = ["ID", "Count"]
        row_data = {"ID": 123, "Count": 456}

        # Numeric values don't contribute to term extraction
        result = extractor.extract_from_excel_record(headers, row_data)

        # Should only extract from headers (no domain terms in "ID" or "Count")
        assert isinstance(result["domain_terms"], list)

    def test_extract_by_rules_uses_word_boundaries(self):
        """Should use word boundaries to avoid partial matches."""
        extractor = DomainTermExtractor()

        # "revenue" should match, but not in "revenues" substring
        content = "The company's revenue stream is strong."

        result = extractor._extract_by_rules(content)

        assert "revenue" in result

    def test_extract_by_rules_case_insensitive(self):
        """Should match terms case-insensitively."""
        extractor = DomainTermExtractor()

        content = "EBITDA, Revenue, and PROFIT all increased."

        result = extractor._extract_by_rules(content)

        assert "ebitda" in result
        assert "revenue" in result
        assert "profit" in result


class TestIntegration:
    """Integration tests for DomainTermExtractor."""

    def test_full_extraction_pipeline_finance(self):
        """Test complete extraction flow with finance document."""
        extractor = DomainTermExtractor()

        content = """
        Financial Performance Summary - Q4 2023

        Key Highlights:
        - Revenue grew 23% YoY to $12.5M
        - EBITDA margin improved to 32%
        - Operating cash flow increased significantly
        - Net income exceeded forecast by 15%

        The company maintained strong balance sheet with low debt levels.
        Investment in new product lines is expected to drive future growth.
        """

        result = extractor.extract_from_chunk(content)

        # Should identify as finance
        assert result["domain_category"] == "finance"

        # Should extract key finance terms
        finance_terms = {"revenue", "ebitda", "margin", "cash flow", "income",
                        "balance", "debt", "investment"}
        found_terms = set(result["domain_terms"])

        # At least 50% of finance terms should be found
        overlap = len(finance_terms & found_terms)
        assert overlap >= len(finance_terms) * 0.5

    def test_full_extraction_pipeline_mixed_content(self):
        """Test extraction with mixed domain content."""
        extractor = DomainTermExtractor()

        content = """
        HR Update - Engineering Team Expansion

        We plan to hire 15 new engineers in Q3 to support product development.
        Total headcount will reach 200 employees.
        Compensation packages include competitive salary and equity.
        Training programs will be provided for all new hires.
        """

        result = extractor.extract_from_chunk(content)

        # Should have terms from both HR and engineering
        terms = set(result["domain_terms"])
        assert "hire" in terms or "employee" in terms
        assert "engineering" in terms or "training" in terms

        # Category should be either hr or engineering (both are valid)
        assert result["domain_category"] in ["hr", "engineering"]


# ---------------------------------------------------------------------------
# TEXTIQ-389: confidence scoring
# ---------------------------------------------------------------------------


def _make_logprobs_response(message_content: str, tokens: list[tuple[str, float]]):
    """Build a chat-completion-shaped mock whose logprobs.content matches ``tokens``.

    ``tokens`` is a list of ``(token_text, logprob)`` pairs. The concatenation of
    all token_text values is what ``_confidence_from_logprobs`` searches.
    """
    response = MagicMock()
    response.choices[0].message.content = message_content

    tok_objects = []
    for text, lp in tokens:
        t = MagicMock()
        t.token = text
        t.logprob = lp
        tok_objects.append(t)
    response.choices[0].logprobs.content = tok_objects
    return response


class TestConfidenceFromLogprobs:
    """Direct unit tests for _confidence_from_logprobs."""

    def test_none_logprobs_returns_fallback_for_all(self):
        extractor = DomainTermExtractor()
        response = MagicMock()
        response.choices[0].logprobs = None

        out = extractor._confidence_from_logprobs(response, ["alpha", "beta"])

        assert out == {"alpha": LLM_FALLBACK_CONFIDENCE, "beta": LLM_FALLBACK_CONFIDENCE}

    def test_empty_logprobs_content_returns_fallback(self):
        extractor = DomainTermExtractor()
        response = MagicMock()
        response.choices[0].logprobs.content = []

        out = extractor._confidence_from_logprobs(response, ["alpha"])

        assert out == {"alpha": LLM_FALLBACK_CONFIDENCE}

    def test_aligned_term_uses_min_prob(self):
        """Confidence = exp(min(token_logprobs)) over the aligned JSON-quoted span."""
        extractor = DomainTermExtractor()
        # Simulate: ["revenue", "ebitda"]  — each quote, term, comma is its own token.
        # The `revenue` span has logprobs [-0.001, -0.001]; min = -0.001.
        tokens = [
            ("[", -0.0),
            ('"', -0.0),
            ("revenue", -0.001),
            ('"', -0.0),
            (",", -0.0),
            (" ", -0.0),
            ('"', -0.0),
            ("ebitda", -0.5),  # weaker link
            ('"', -0.0),
            ("]", -0.0),
        ]
        response = _make_logprobs_response('["revenue", "ebitda"]', tokens)

        out = extractor._confidence_from_logprobs(response, ["revenue", "ebitda"])

        assert out["revenue"] == pytest.approx(math.exp(-0.001), abs=1e-9)
        assert out["ebitda"] == pytest.approx(math.exp(-0.5), abs=1e-9)

    def test_multi_token_term_takes_min(self):
        """Weakest-link: a multi-token term picks the lowest logprob across its tokens."""
        extractor = DomainTermExtractor()
        # "prior authorization" split across two tokens; min(-0.41, -1.0) = -1.0
        tokens = [
            ('"', -0.0),
            ("prior authorization", -0.41),
            (" ", -0.0),
            ("workflows", -1.0),
            ('"', -0.0),
        ]
        response = _make_logprobs_response(
            '"prior authorization workflows"',
            tokens,
        )

        out = extractor._confidence_from_logprobs(
            response, ["prior authorization workflows"]
        )

        assert out["prior authorization workflows"] == pytest.approx(
            math.exp(-1.0), abs=1e-9
        )

    def test_unalignable_term_returns_fallback(self):
        """Term not present in the token stream falls back to 0.70."""
        extractor = DomainTermExtractor()
        tokens = [('"', -0.0), ("revenue", -0.01), ('"', -0.0)]
        response = _make_logprobs_response('"revenue"', tokens)

        out = extractor._confidence_from_logprobs(response, ["revenue", "missing"])

        assert out["revenue"] == pytest.approx(math.exp(-0.01), abs=1e-9)
        assert out["missing"] == LLM_FALLBACK_CONFIDENCE

    def test_clamps_to_unit_interval(self):
        """exp(0) = 1.0 stays at 1.0; positive logprobs (shouldn't happen) clamp to 1.0."""
        extractor = DomainTermExtractor()
        tokens = [('"', 0.0), ("alpha", 0.5), ('"', 0.0)]  # exp(0.5) > 1
        response = _make_logprobs_response('"alpha"', tokens)

        out = extractor._confidence_from_logprobs(response, ["alpha"])

        assert 0.0 <= out["alpha"] <= 1.0


class TestConfidenceScoringInExtraction:
    """Confidence surfaces in extract_from_chunk / extract_from_chunks_async."""

    def test_rule_only_terms_get_rule_confidence(self):
        """When LLM is not invoked (>= threshold terms), every rule term scores 0.95."""
        extractor = DomainTermExtractor()
        content = "Revenue EBITDA profit margin loss income budget forecast employee salary"

        result = extractor.extract_from_chunk(content)

        assert result["extraction_method"] == "rules"
        assert result["domain_confidences"]
        for term, conf in result["domain_confidences"].items():
            assert conf == RULE_CONFIDENCE, f"{term} expected {RULE_CONFIDENCE}, got {conf}"

    @patch("src.extraction_v2.domain_term_extractor.DomainTermExtractor._get_client")
    def test_llm_path_without_logprobs_uses_fallback(self, mock_get_client):
        """LLM returns terms but no logprobs (mock) → each LLM term gets 0.70."""
        mock_client = MagicMock()
        mock_response = MagicMock()
        mock_response.choices[0].message.content = '["alpha", "beta", "gamma"]'
        mock_response.choices[0].logprobs = None  # simulate old api-version
        mock_client.chat.completions.create.return_value = mock_response
        mock_get_client.return_value = mock_client

        extractor = DomainTermExtractor()
        content = "This document discusses revenue in a generic way."

        result = extractor.extract_from_chunk(content)

        # LLM terms fall back
        for term in ["alpha", "beta", "gamma"]:
            assert result["domain_confidences"].get(term) == LLM_FALLBACK_CONFIDENCE
        # Rule-hit term keeps its 0.95
        assert result["domain_confidences"].get("revenue") == RULE_CONFIDENCE

    @patch("src.extraction_v2.domain_term_extractor.DomainTermExtractor._get_client")
    def test_rule_and_llm_overlap_takes_max(self, mock_get_client):
        """If the LLM re-emits a rule term with a low score, the max (0.95) wins."""
        mock_client = MagicMock()
        tokens = [
            ("[", 0.0), ('"', 0.0),
            ("revenue", -2.0),  # exp(-2.0) ~ 0.135, much lower than rule 0.95
            ('"', 0.0), (",", 0.0), (" ", 0.0), ('"', 0.0),
            ("alpha", -0.01),
            ('"', 0.0), ("]", 0.0),
        ]
        mock_response = _make_logprobs_response('["revenue", "alpha"]', tokens)
        mock_client.chat.completions.create.return_value = mock_response
        mock_get_client.return_value = mock_client

        extractor = DomainTermExtractor()
        # Only "revenue" matches rules (1 term, below LLM_THRESHOLD=2), so LLM also fires.
        content = "This document discusses revenue in a generic way."

        result = extractor.extract_from_chunk(content)

        # revenue came from both rule (0.95) and LLM (~0.135) → max = 0.95
        assert result["domain_confidences"]["revenue"] == RULE_CONFIDENCE
        # alpha is LLM-only with its own computed confidence
        assert result["domain_confidences"]["alpha"] == pytest.approx(
            math.exp(-0.01), abs=1e-9
        )

    @patch("src.extraction_v2.domain_term_extractor.DomainTermExtractor._get_client")
    def test_llm_extract_returns_tuples(self, mock_get_client):
        """_llm_extract returns list[tuple[str, float]]."""
        mock_client = MagicMock()
        tokens = [
            ("[", 0.0), ('"', 0.0),
            ("alpha", -0.01),
            ('"', 0.0), ("]", 0.0),
        ]
        mock_response = _make_logprobs_response('["alpha"]', tokens)
        mock_client.chat.completions.create.return_value = mock_response
        mock_get_client.return_value = mock_client

        extractor = DomainTermExtractor()
        result = extractor._llm_extract("any content")

        assert len(result) == 1
        assert result[0][0] == "alpha"
        assert result[0][1] == pytest.approx(math.exp(-0.01), abs=1e-9)


class TestResolvePersistConfidence:
    """Per-row confidence lookup used by persist_new_terms."""

    def test_legacy_none_map_returns_fallback(self):
        """confidence_by_term=None → every row defaults to 0.70 (legacy callers)."""
        assert (
            DomainTermExtractor._resolve_persist_confidence("foo", None)
            == LLM_FALLBACK_CONFIDENCE
        )

    def test_map_hit_returns_looked_up_value(self):
        assert (
            DomainTermExtractor._resolve_persist_confidence(
                "Revenue", {"revenue": 0.42}
            )
            == 0.42
        )

    def test_map_miss_returns_fallback(self):
        """Term absent from a non-None map still falls back to 0.70."""
        assert (
            DomainTermExtractor._resolve_persist_confidence(
                "missing", {"other": 0.9}
            )
            == LLM_FALLBACK_CONFIDENCE
        )

    def test_clamps_out_of_range_values(self):
        assert (
            DomainTermExtractor._resolve_persist_confidence("a", {"a": 1.5}) == 1.0
        )
        assert (
            DomainTermExtractor._resolve_persist_confidence("a", {"a": -0.3}) == 0.0
        )
