"""Tests for company-specific sentiment analysis.

Validates:
- Evidence grouping by company (including relations)
- FinBERT adapter returns valid probability distributions
- Mixed sentiment detection from evidence-group disagreement
- Non-mixed when evidence agrees
- Probability distributions sum to ~1.0
- Calibration passthrough
- SentimentScorer integration
- TextSentiment per-text scoring
- Aggregation module
"""

from __future__ import annotations

import pytest

from services.intelligence_pipeline_v3.sentiment.aggregation import (
    MIXED_DISAGREEMENT_THRESHOLD,
    aggregate_evidence_sentiments,
)
from services.intelligence_pipeline_v3.sentiment.calibrator import SentimentCalibrator
from services.intelligence_pipeline_v3.sentiment.evidence_groups import build_evidence_groups
from services.intelligence_pipeline_v3.sentiment.finbert_adapter import FinBERTAdapter
from services.intelligence_pipeline_v3.sentiment.mixed_sentiment import (
    DISAGREEMENT_THRESHOLD,
    compute_mixed_sentiment,
)
from services.intelligence_pipeline_v3.sentiment.models import (
    CompanySentimentResult,
    EvidenceGroup,
    SentimentBatchResult,
    TextSentiment,
)
from services.intelligence_pipeline_v3.sentiment.sentiment_scorer import (
    SentimentScorer,
)


class TestEvidenceGroups:
    """Test evidence grouping by company."""

    def test_single_company_single_evidence(self):
        entities = [{"company_id": "AAPL", "evidence_id": "ev1"}]
        evidence_spans = {"ev1": "Apple reported strong earnings."}

        groups = build_evidence_groups(entities, evidence_spans)

        assert "AAPL" in groups
        assert groups["AAPL"].company_id == "AAPL"
        assert groups["AAPL"].evidence_ids == ["ev1"]
        assert groups["AAPL"].texts == ["Apple reported strong earnings."]

    def test_single_company_multiple_evidence(self):
        entities = [
            {"company_id": "AAPL", "evidence_id": "ev1"},
            {"company_id": "AAPL", "evidence_id": "ev2"},
        ]
        evidence_spans = {
            "ev1": "Apple beat expectations.",
            "ev2": "iPhone sales surged.",
        }

        groups = build_evidence_groups(entities, evidence_spans)

        assert "AAPL" in groups
        assert len(groups["AAPL"].evidence_ids) == 2
        assert "ev1" in groups["AAPL"].evidence_ids
        assert "ev2" in groups["AAPL"].evidence_ids

    def test_multiple_companies(self):
        entities = [
            {"company_id": "AAPL", "evidence_id": "ev1"},
            {"company_id": "GOOGL", "evidence_id": "ev2"},
        ]
        evidence_spans = {
            "ev1": "Apple gained market share.",
            "ev2": "Google's ad revenue declined.",
        }

        groups = build_evidence_groups(entities, evidence_spans)

        assert len(groups) == 2
        assert "AAPL" in groups
        assert "GOOGL" in groups

    def test_shared_evidence_across_companies(self):
        """A span mentioning multiple companies should appear in both groups."""
        entities = [
            {"company_id": "AAPL", "evidence_id": "ev1"},
            {"company_id": "GOOGL", "evidence_id": "ev1"},
        ]
        evidence_spans = {"ev1": "Apple and Google both reported growth."}

        groups = build_evidence_groups(entities, evidence_spans)

        assert "AAPL" in groups
        assert "GOOGL" in groups
        assert "ev1" in groups["AAPL"].evidence_ids
        assert "ev1" in groups["GOOGL"].evidence_ids

    def test_entities_without_company_id_skipped(self):
        entities = [
            {"company_id": None, "evidence_id": "ev1"},
            {"company_id": "AAPL", "evidence_id": "ev2"},
        ]
        evidence_spans = {
            "ev1": "Some generic text.",
            "ev2": "Apple expanded.",
        }

        groups = build_evidence_groups(entities, evidence_spans)

        assert len(groups) == 1
        assert "AAPL" in groups

    def test_missing_evidence_span_excluded(self):
        """Entity referencing non-existent evidence span is excluded."""
        entities = [{"company_id": "AAPL", "evidence_id": "ev_missing"}]
        evidence_spans = {"ev1": "Some text."}

        groups = build_evidence_groups(entities, evidence_spans)

        assert len(groups) == 0

    def test_empty_inputs(self):
        groups = build_evidence_groups([], {})
        assert len(groups) == 0

    def test_deduplicates_evidence_ids_per_company(self):
        """Same evidence_id referenced twice for same company shouldn't duplicate."""
        entities = [
            {"company_id": "AAPL", "evidence_id": "ev1"},
            {"company_id": "AAPL", "evidence_id": "ev1"},
        ]
        evidence_spans = {"ev1": "Apple news."}

        groups = build_evidence_groups(entities, evidence_spans)

        assert groups["AAPL"].evidence_ids == ["ev1"]
        assert len(groups["AAPL"].texts) == 1

    def test_relations_add_evidence_to_company(self):
        """Relations parameter links additional evidence to companies."""
        entities = [{"company_id": "AAPL", "evidence_id": "ev1"}]
        relations = [
            {"company_id": "AAPL", "evidence_id": "ev2", "relation_type": "directly_affects"},
        ]
        evidence_spans = {
            "ev1": "Apple reported earnings.",
            "ev2": "iPhone demand surged globally.",
        }

        groups = build_evidence_groups(entities, evidence_spans, relations=relations)

        assert "AAPL" in groups
        assert "ev1" in groups["AAPL"].evidence_ids
        assert "ev2" in groups["AAPL"].evidence_ids
        assert len(groups["AAPL"].evidence_ids) == 2

    def test_relations_create_new_company_group(self):
        """Relations can create groups for companies not in entities."""
        entities = [{"company_id": "AAPL", "evidence_id": "ev1"}]
        relations = [
            {"company_id": "GOOGL", "evidence_id": "ev2", "relation_type": "inferred_exposure"},
        ]
        evidence_spans = {
            "ev1": "Apple expanded.",
            "ev2": "Google was affected.",
        }

        groups = build_evidence_groups(entities, evidence_spans, relations=relations)

        assert "AAPL" in groups
        assert "GOOGL" in groups
        assert groups["GOOGL"].evidence_ids == ["ev2"]

    def test_relations_none_skipped(self):
        """None relations parameter is handled gracefully."""
        entities = [{"company_id": "AAPL", "evidence_id": "ev1"}]
        evidence_spans = {"ev1": "Apple news."}

        groups = build_evidence_groups(entities, evidence_spans, relations=None)

        assert "AAPL" in groups
        assert groups["AAPL"].evidence_ids == ["ev1"]


class TestFinBERTAdapter:
    """Test FinBERT adapter returns valid probability distributions."""

    def setup_method(self):
        self.adapter = FinBERTAdapter(test_mode=True)

    def test_model_version_exposed(self):
        assert self.adapter.model_version == "ProsusAI/finbert@v1.0"
        assert self.adapter.model_name == "ProsusAI/finbert"

    def test_empty_input(self):
        result = self.adapter.classify([])
        assert result == []

    def test_positive_text(self):
        result = self.adapter.classify(["Company reported strong profit growth."])
        assert len(result) == 1
        pos, neg, neu = result[0]
        assert pos > neg
        assert pos > neu
        assert abs(pos + neg + neu - 1.0) < 1e-6

    def test_negative_text(self):
        result = self.adapter.classify(["Revenue declined sharply amid weak demand."])
        assert len(result) == 1
        pos, neg, neu = result[0]
        assert neg > pos
        assert neg > neu

    def test_neutral_text(self):
        result = self.adapter.classify(["The company held its annual general meeting today."])
        assert len(result) == 1
        pos, neg, neu = result[0]
        assert neu > pos
        assert neu > neg

    def test_mixed_keywords_text(self):
        result = self.adapter.classify(["Revenue growth was strong but the decline in margins hurt"])
        assert len(result) == 1
        pos, neg, neu = result[0]
        assert pos >= 0.3
        assert neg >= 0.3

    def test_batch_classification(self):
        texts = [
            "Earnings beat expectations.",
            "Stock plunged on weak results.",
            "Board met to discuss routine matters.",
        ]
        results = self.adapter.classify(texts)
        assert len(results) == 3
        assert results[0][0] > results[0][1]
        assert results[1][1] > results[1][0]
        assert results[2][2] > results[2][0]
        assert results[2][2] > results[2][1]

    def test_probabilities_sum_to_one(self):
        texts = ["Strong growth.", "Major loss.", "Neutral report."]
        results = self.adapter.classify(texts)
        for pos, neg, neu in results:
            assert abs(pos + neg + neu - 1.0) < 1e-6
            assert pos >= 0.0
            assert neg >= 0.0
            assert neu >= 0.0


class TestAggregation:
    """Test aggregate_evidence_sentiments from the aggregation module."""

    def test_single_positive_text(self):
        scores = [TextSentiment(evidence_id="ev1", positive_prob=0.8, negative_prob=0.1, neutral_prob=0.1)]
        result = aggregate_evidence_sentiments("AAPL", scores, "test_model")

        assert result.label == "positive"
        assert result.company_id == "AAPL"
        assert result.is_mixed is False
        assert len(result.per_text_scores) == 1
        assert result.per_text_scores[0].evidence_id == "ev1"

    def test_single_negative_text(self):
        scores = [TextSentiment(evidence_id="ev1", positive_prob=0.1, negative_prob=0.8, neutral_prob=0.1)]
        result = aggregate_evidence_sentiments("GOOGL", scores, "test_model")

        assert result.label == "negative"
        assert result.is_mixed is False

    def test_mixed_from_disagreeing_texts(self):
        """Two texts: one positive, one negative -> mixed."""
        scores = [
            TextSentiment(evidence_id="ev1", positive_prob=0.75, negative_prob=0.10, neutral_prob=0.15),
            TextSentiment(evidence_id="ev2", positive_prob=0.10, negative_prob=0.75, neutral_prob=0.15),
        ]
        result = aggregate_evidence_sentiments("TSLA", scores, "test_model")

        assert result.label == "mixed"
        assert result.is_mixed is True
        assert result.positive_prob > 0.3
        assert result.negative_prob > 0.3

    def test_not_mixed_when_agreement(self):
        """Two positive texts should not trigger mixed."""
        scores = [
            TextSentiment(evidence_id="ev1", positive_prob=0.70, negative_prob=0.15, neutral_prob=0.15),
            TextSentiment(evidence_id="ev2", positive_prob=0.65, negative_prob=0.20, neutral_prob=0.15),
        ]
        result = aggregate_evidence_sentiments("AAPL", scores, "test_model")

        assert result.label == "positive"
        assert result.is_mixed is False

    def test_empty_scores_neutral(self):
        result = aggregate_evidence_sentiments("X", [], "test_model")
        assert result.label == "neutral"
        assert result.neutral_prob == 1.0
        assert result.is_mixed is False

    def test_probabilities_sum_to_one(self):
        scores = [
            TextSentiment(evidence_id="ev1", positive_prob=0.60, negative_prob=0.25, neutral_prob=0.15),
            TextSentiment(evidence_id="ev2", positive_prob=0.30, negative_prob=0.50, neutral_prob=0.20),
            TextSentiment(evidence_id="ev3", positive_prob=0.10, negative_prob=0.10, neutral_prob=0.80),
        ]
        result = aggregate_evidence_sentiments("X", scores, "test_model")
        total = result.positive_prob + result.negative_prob + result.neutral_prob
        assert abs(total - 1.0) < 1e-4

    def test_evidence_ids_preserved(self):
        scores = [
            TextSentiment(evidence_id="ev1", positive_prob=0.5, negative_prob=0.3, neutral_prob=0.2),
            TextSentiment(evidence_id="ev2", positive_prob=0.4, negative_prob=0.4, neutral_prob=0.2),
        ]
        result = aggregate_evidence_sentiments("AAPL", scores, "model_v1")

        assert result.evidence_ids == ["ev1", "ev2"]
        assert result.model_version == "model_v1"
        assert result.calibration_version == "uncalibrated"

    def test_disagreement_threshold_boundary(self):
        """Both max pos and max neg must be >= threshold for mixed."""
        threshold = MIXED_DISAGREEMENT_THRESHOLD
        scores = [
            TextSentiment(evidence_id="ev1", positive_prob=threshold, negative_prob=0.05, neutral_prob=1.0 - threshold - 0.05),
            TextSentiment(evidence_id="ev2", positive_prob=0.05, negative_prob=threshold, neutral_prob=1.0 - threshold - 0.05),
        ]
        result = aggregate_evidence_sentiments("X", scores, "test")
        assert result.is_mixed is True
        assert result.label == "mixed"

    def test_below_disagreement_threshold_not_mixed(self):
        """Below threshold should not be mixed."""
        threshold = MIXED_DISAGREEMENT_THRESHOLD
        scores = [
            TextSentiment(evidence_id="ev1", positive_prob=threshold - 0.01, negative_prob=0.05, neutral_prob=1.0 - (threshold - 0.01) - 0.05),
            TextSentiment(evidence_id="ev2", positive_prob=0.05, negative_prob=threshold - 0.01, neutral_prob=1.0 - (threshold - 0.01) - 0.05),
        ]
        result = aggregate_evidence_sentiments("X", scores, "test")
        assert result.is_mixed is False


class TestMixedSentiment:
    """Test mixed sentiment detection from evidence-group disagreement (legacy API)."""

    def test_single_positive_group(self):
        result = compute_mixed_sentiment(
            company_id="AAPL",
            group_results=[(0.75, 0.10, 0.15)],
            evidence_ids=["ev1"],
            model_version="ProsusAI/finbert@v1.0",
        )

        assert result.label == "positive"
        assert result.company_id == "AAPL"
        assert result.positive_prob > result.negative_prob
        assert result.is_mixed is False

    def test_single_negative_group(self):
        result = compute_mixed_sentiment(
            company_id="GOOGL",
            group_results=[(0.10, 0.75, 0.15)],
            evidence_ids=["ev1"],
            model_version="ProsusAI/finbert@v1.0",
        )

        assert result.label == "negative"
        assert result.negative_prob > result.positive_prob

    def test_single_neutral_group(self):
        result = compute_mixed_sentiment(
            company_id="MSFT",
            group_results=[(0.15, 0.15, 0.70)],
            evidence_ids=["ev1"],
            model_version="ProsusAI/finbert@v1.0",
        )

        assert result.label == "neutral"
        assert result.neutral_prob > result.positive_prob
        assert result.neutral_prob > result.negative_prob

    def test_mixed_from_disagreeing_groups(self):
        """Two groups: one positive, one negative -> mixed."""
        group_results = [
            (0.75, 0.10, 0.15),
            (0.10, 0.75, 0.15),
        ]
        result = compute_mixed_sentiment(
            company_id="TSLA",
            group_results=group_results,
            evidence_ids=["ev1", "ev2"],
            model_version="ProsusAI/finbert@v1.0",
        )

        assert result.label == "mixed"
        assert result.is_mixed is True
        assert result.positive_prob > 0.3
        assert result.negative_prob > 0.3

    def test_no_mixed_when_agreement(self):
        """Two positive groups should not trigger mixed."""
        group_results = [
            (0.70, 0.15, 0.15),
            (0.65, 0.20, 0.15),
        ]
        result = compute_mixed_sentiment(
            company_id="AAPL",
            group_results=group_results,
            evidence_ids=["ev1", "ev2"],
            model_version="ProsusAI/finbert@v1.0",
        )

        assert result.label == "positive"
        assert result.is_mixed is False

    def test_disagreement_threshold_boundary(self):
        """Both max pos and max neg must be >= threshold for mixed."""
        group_results = [
            (DISAGREEMENT_THRESHOLD, 0.05, 0.65),
            (0.05, DISAGREEMENT_THRESHOLD, 0.65),
        ]
        result = compute_mixed_sentiment(
            company_id="X",
            group_results=group_results,
            evidence_ids=["ev1", "ev2"],
            model_version="test",
        )
        assert result.label == "mixed"
        assert result.is_mixed is True

    def test_below_disagreement_threshold(self):
        """Below threshold should not be mixed."""
        group_results = [
            (DISAGREEMENT_THRESHOLD - 0.01, 0.05, 0.66),
            (0.05, DISAGREEMENT_THRESHOLD - 0.01, 0.66),
        ]
        result = compute_mixed_sentiment(
            company_id="X",
            group_results=group_results,
            evidence_ids=["ev1", "ev2"],
            model_version="test",
        )
        assert result.label == "neutral"
        assert result.is_mixed is False

    def test_empty_group_results(self):
        result = compute_mixed_sentiment(
            company_id="AAPL",
            group_results=[],
            evidence_ids=[],
            model_version="test",
        )
        assert result.label == "neutral"
        assert result.neutral_prob == 1.0
        assert result.is_mixed is False

    def test_probabilities_sum_to_one(self):
        group_results = [
            (0.60, 0.25, 0.15),
            (0.30, 0.50, 0.20),
            (0.10, 0.10, 0.80),
        ]
        result = compute_mixed_sentiment(
            company_id="X",
            group_results=group_results,
            evidence_ids=["a", "b", "c"],
            model_version="test",
        )
        total = result.positive_prob + result.negative_prob + result.neutral_prob
        assert abs(total - 1.0) < 1e-4

    def test_per_text_scores_preserved(self):
        """Legacy API now populates per_text_scores for provenance."""
        group_results = [(0.75, 0.10, 0.15), (0.20, 0.60, 0.20)]
        result = compute_mixed_sentiment(
            company_id="X",
            group_results=group_results,
            evidence_ids=["ev1", "ev2"],
            model_version="test",
        )
        assert len(result.per_text_scores) == 2
        assert result.per_text_scores[0].evidence_id == "ev1"
        assert result.per_text_scores[1].evidence_id == "ev2"


class TestSentimentScorer:
    """Test SentimentScorer integration (end-to-end scoring)."""

    @pytest.mark.asyncio
    async def test_score_positive_evidence(self):
        scorer = SentimentScorer()
        group = EvidenceGroup(
            company_id="AAPL",
            evidence_ids=["ev1"],
            texts=["Apple reported strong profit growth."],
        )
        result = await scorer.score(group)

        assert result.company_id == "AAPL"
        assert result.label == "positive"
        assert result.positive_prob > result.negative_prob
        assert len(result.per_text_scores) == 1
        assert result.per_text_scores[0].evidence_id == "ev1"
        assert result.model_version == "ProsusAI/finbert@v1.0"
        assert result.calibration_version == "uncalibrated"

    @pytest.mark.asyncio
    async def test_score_negative_evidence(self):
        scorer = SentimentScorer()
        group = EvidenceGroup(
            company_id="GOOGL",
            evidence_ids=["ev1"],
            texts=["Google experienced a sharp decline in revenue."],
        )
        result = await scorer.score(group)

        assert result.label == "negative"
        assert result.negative_prob > result.positive_prob

    @pytest.mark.asyncio
    async def test_score_mixed_evidence(self):
        """Multiple texts with opposing sentiment triggers mixed."""
        scorer = SentimentScorer()
        group = EvidenceGroup(
            company_id="TSLA",
            evidence_ids=["ev1", "ev2"],
            texts=[
                "Tesla revenue growth exceeded expectations.",
                "Tesla faces major decline in margins and weak demand.",
            ],
        )
        result = await scorer.score(group)

        assert result.label == "mixed"
        assert result.is_mixed is True
        assert len(result.per_text_scores) == 2

    @pytest.mark.asyncio
    async def test_score_batch(self):
        scorer = SentimentScorer()
        groups = {
            "AAPL": EvidenceGroup(
                company_id="AAPL",
                evidence_ids=["ev1"],
                texts=["Apple beat earnings estimates."],
            ),
            "GOOGL": EvidenceGroup(
                company_id="GOOGL",
                evidence_ids=["ev2"],
                texts=["Google saw weak ad revenue and decline in users"],
            ),
        }
        batch_result = await scorer.score_batch(groups)

        assert len(batch_result.results) == 2
        assert batch_result.model_version == "ProsusAI/finbert@v1.0"
        assert batch_result.processing_time_ms >= 0

        labels = {r.company_id: r.label for r in batch_result.results}
        assert labels["AAPL"] == "positive"
        assert labels["GOOGL"] == "negative"

    @pytest.mark.asyncio
    async def test_score_probability_distributions_sum_to_one(self):
        scorer = SentimentScorer()
        group = EvidenceGroup(
            company_id="X",
            evidence_ids=["ev1", "ev2", "ev3"],
            texts=["Profit rose.", "Demand weakened.", "Board meeting held."],
        )
        result = await scorer.score(group)

        # Overall probabilities sum to 1
        total = result.positive_prob + result.negative_prob + result.neutral_prob
        assert abs(total - 1.0) < 1e-4

        # Per-text probabilities also sum to 1
        for ts in result.per_text_scores:
            text_total = ts.positive_prob + ts.negative_prob + ts.neutral_prob
            assert abs(text_total - 1.0) < 1e-6

    @pytest.mark.asyncio
    async def test_custom_model_protocol(self):
        """SentimentScorer works with any model implementing SentimentModel."""

        class MockModel:
            @property
            def model_version(self) -> str:
                return "mock@v1"

            def classify(self, texts: list[str]) -> list[tuple[float, float, float]]:
                return [(0.5, 0.3, 0.2)] * len(texts)

        scorer = SentimentScorer(model=MockModel())
        group = EvidenceGroup(
            company_id="X",
            evidence_ids=["ev1"],
            texts=["Any text."],
        )
        result = await scorer.score(group)

        assert result.model_version == "mock@v1"
        assert result.positive_prob > 0.4


class TestMultiCompanyOpposingSentiments:
    """Test that opposing sentiments for different companies produce separate records."""

    def test_separate_records_for_opposing_companies(self):
        """Article with positive Apple news and negative Google news."""
        entities = [
            {"company_id": "AAPL", "evidence_id": "ev1"},
            {"company_id": "GOOGL", "evidence_id": "ev2"},
        ]
        evidence_spans = {
            "ev1": "Apple reported record profit growth.",
            "ev2": "Google faces a major decline in ad revenue.",
        }

        groups = build_evidence_groups(entities, evidence_spans)
        adapter = FinBERTAdapter(test_mode=True)

        results: list[CompanySentimentResult] = []
        for company_id, group in groups.items():
            probs = adapter.classify(group.texts)
            result = compute_mixed_sentiment(
                company_id=company_id,
                group_results=probs,
                evidence_ids=group.evidence_ids,
                model_version=adapter.model_version,
            )
            results.append(result)

        assert len(results) == 2
        company_labels = {r.company_id: r.label for r in results}

        assert company_labels["AAPL"] == "positive"
        assert company_labels["GOOGL"] == "negative"


class TestCalibrator:
    """Test sentiment probability calibration passthrough and fitting."""

    def test_uncalibrated_passthrough(self):
        """Unfitted calibrator should pass through raw probabilities."""
        cal = SentimentCalibrator(method="isotonic")
        assert not cal.is_fitted
        assert cal.calibration_version == "uncalibrated"

        raw = [0.6, 0.3, 0.1]
        result = cal.calibrate(raw)
        assert result == raw

    def test_isotonic_fit_and_calibrate(self):
        """Fitted isotonic calibrator should transform probabilities."""
        cal = SentimentCalibrator(method="isotonic")

        raw_probs = [
            [0.8, 0.1, 0.1],
            [0.7, 0.2, 0.1],
            [0.1, 0.8, 0.1],
            [0.2, 0.7, 0.1],
            [0.1, 0.1, 0.8],
            [0.1, 0.2, 0.7],
            [0.9, 0.05, 0.05],
            [0.05, 0.9, 0.05],
            [0.05, 0.05, 0.9],
            [0.6, 0.3, 0.1],
        ]
        true_labels = [0, 0, 1, 1, 2, 2, 0, 1, 2, 0]

        cal.fit(raw_probs, true_labels, version="gold_v1")

        assert cal.is_fitted
        assert cal.calibration_version == "gold_v1"

        result = cal.calibrate([0.7, 0.2, 0.1])
        assert len(result) == 3
        assert all(0.0 <= p <= 1.0 for p in result)
        assert abs(sum(result) - 1.0) < 1e-6

    def test_calibration_preserves_ordering(self):
        """Higher raw probabilities should map to higher calibrated values."""
        cal = SentimentCalibrator(method="isotonic")

        raw_probs = [
            [0.9, 0.05, 0.05],
            [0.8, 0.1, 0.1],
            [0.7, 0.15, 0.15],
            [0.6, 0.2, 0.2],
            [0.3, 0.6, 0.1],
            [0.2, 0.7, 0.1],
            [0.1, 0.8, 0.1],
            [0.1, 0.1, 0.8],
            [0.15, 0.15, 0.7],
            [0.2, 0.2, 0.6],
        ]
        true_labels = [0, 0, 0, 0, 1, 1, 1, 2, 2, 2]

        cal.fit(raw_probs, true_labels, version="test_v1")

        low_pos = cal.calibrate([0.3, 0.5, 0.2])
        high_pos = cal.calibrate([0.8, 0.1, 0.1])

        assert high_pos[0] >= low_pos[0]

    def test_platt_calibration(self):
        """Platt scaling should also produce valid probabilities."""
        cal = SentimentCalibrator(method="platt")

        raw_probs = [
            [0.8, 0.1, 0.1],
            [0.7, 0.2, 0.1],
            [0.1, 0.8, 0.1],
            [0.2, 0.7, 0.1],
            [0.1, 0.1, 0.8],
            [0.1, 0.2, 0.7],
            [0.9, 0.05, 0.05],
            [0.05, 0.9, 0.05],
            [0.05, 0.05, 0.9],
            [0.6, 0.3, 0.1],
        ]
        true_labels = [0, 0, 1, 1, 2, 2, 0, 1, 2, 0]

        cal.fit(raw_probs, true_labels, version="platt_v1")
        assert cal.is_fitted

        result = cal.calibrate([0.6, 0.3, 0.1])
        assert len(result) == 3
        assert all(0.0 <= p <= 1.0 for p in result)
        assert abs(sum(result) - 1.0) < 1e-6

    def test_batch_calibrate(self):
        """Batch calibration should produce consistent results."""
        cal = SentimentCalibrator(method="isotonic")

        raw_probs = [
            [0.9, 0.05, 0.05],
            [0.1, 0.8, 0.1],
            [0.1, 0.1, 0.8],
            [0.7, 0.2, 0.1],
            [0.2, 0.7, 0.1],
            [0.2, 0.1, 0.7],
        ]
        true_labels = [0, 1, 2, 0, 1, 2]

        cal.fit(raw_probs, true_labels, version="batch_v1")

        batch = [[0.7, 0.2, 0.1], [0.2, 0.7, 0.1]]
        results = cal.calibrate_batch(batch)

        assert len(results) == 2
        for r in results:
            assert abs(sum(r) - 1.0) < 1e-6

    def test_fit_validation_errors(self):
        cal = SentimentCalibrator()

        with pytest.raises(ValueError):
            cal.fit([], [])

        with pytest.raises(ValueError):
            cal.fit([[0.5, 0.3, 0.2]], [0, 1])  # Length mismatch


class TestModels:
    """Test data model validation."""

    def test_evidence_group_requires_non_empty_ids(self):
        with pytest.raises(ValueError):
            EvidenceGroup(company_id="AAPL", evidence_ids=[], texts=["test"])

    def test_evidence_group_requires_non_empty_texts(self):
        with pytest.raises(ValueError):
            EvidenceGroup(company_id="AAPL", evidence_ids=["ev1"], texts=[])

    def test_company_sentiment_result_valid_labels(self):
        for label in ("positive", "negative", "neutral", "mixed"):
            result = CompanySentimentResult(
                company_id="X",
                label=label,
                positive_prob=0.33,
                negative_prob=0.33,
                neutral_prob=0.34,
                evidence_ids=["ev1"],
                model_version="test",
            )
            assert result.label == label

    def test_company_sentiment_result_invalid_label(self):
        with pytest.raises(ValueError):
            CompanySentimentResult(
                company_id="X",
                label="very_positive",
                positive_prob=0.8,
                negative_prob=0.1,
                neutral_prob=0.1,
                evidence_ids=["ev1"],
                model_version="test",
            )

    def test_sentiment_batch_result(self):
        result = SentimentBatchResult(
            results=[
                CompanySentimentResult(
                    company_id="AAPL",
                    label="positive",
                    positive_prob=0.8,
                    negative_prob=0.1,
                    neutral_prob=0.1,
                    evidence_ids=["ev1"],
                    model_version="test",
                )
            ],
            model_version="test",
            processing_time_ms=150,
        )
        assert len(result.results) == 1
        assert result.processing_time_ms == 150

    def test_text_sentiment_model(self):
        ts = TextSentiment(
            evidence_id="ev1",
            positive_prob=0.7,
            negative_prob=0.2,
            neutral_prob=0.1,
        )
        assert ts.evidence_id == "ev1"
        assert ts.dominant_label == "positive"
        assert abs(ts.positive_prob + ts.negative_prob + ts.neutral_prob - 1.0) < 1e-6

    def test_text_sentiment_dominant_negative(self):
        ts = TextSentiment(evidence_id="ev1", positive_prob=0.1, negative_prob=0.7, neutral_prob=0.2)
        assert ts.dominant_label == "negative"

    def test_text_sentiment_dominant_neutral(self):
        ts = TextSentiment(evidence_id="ev1", positive_prob=0.1, negative_prob=0.2, neutral_prob=0.7)
        assert ts.dominant_label == "neutral"

    def test_company_sentiment_result_is_mixed_field(self):
        result = CompanySentimentResult(
            company_id="X",
            label="mixed",
            positive_prob=0.4,
            negative_prob=0.4,
            neutral_prob=0.2,
            evidence_ids=["ev1", "ev2"],
            is_mixed=True,
            model_version="test",
        )
        assert result.is_mixed is True
