"""Tests for NuExtract 1.5 Smol benchmark and promotion logic.

Tests:
- Adapter interface (test mode extraction)
- Benchmark comparison logic
- Promotion gate pass/fail
- Per-document-type reporting

Requirement: 6.6
"""
from __future__ import annotations

import pytest

from services.intelligence_pipeline_v3.nuextract.adapter import (
    NUEXTRACT_MODEL_VERSION,
    NuExtractAdapter,
)
from services.intelligence_pipeline_v3.nuextract.benchmark import (
    GLiNERResult,
    GoldDocument,
    NuExtractBenchmark,
)
from services.intelligence_pipeline_v3.nuextract.models import (
    BenchmarkReport,
    IncrementalValueReport,
    NuExtractResult,
    PromotionGate,
)
from services.intelligence_pipeline_v3.nuextract.promotion import PromotionEvaluator

# --- Fixtures ---


@pytest.fixture
def adapter() -> NuExtractAdapter:
    """Create a test-mode NuExtract adapter."""
    return NuExtractAdapter(test_mode=True)


@pytest.fixture
def benchmark() -> NuExtractBenchmark:
    """Create a benchmark instance with test-mode adapter."""
    return NuExtractBenchmark(
        adapter=NuExtractAdapter(test_mode=True),
        gate=PromotionGate(
            min_f1_improvement=0.05,
            max_latency_ms=5000.0,
            max_memory_mb=2048.0,
            min_sample_count=3,
        ),
    )


@pytest.fixture
def strict_gate() -> PromotionGate:
    """Strict promotion gate that's hard to pass."""
    return PromotionGate(
        min_f1_improvement=0.20,
        max_latency_ms=100.0,
        max_memory_mb=512.0,
        min_sample_count=100,
    )


@pytest.fixture
def lenient_gate() -> PromotionGate:
    """Lenient promotion gate that's easy to pass."""
    return PromotionGate(
        min_f1_improvement=0.01,
        max_latency_ms=10000.0,
        max_memory_mb=4096.0,
        min_sample_count=1,
    )


def _make_filing_doc(revenue: str = "4.2 billion") -> GoldDocument:
    """Create a sample filing document."""
    return GoldDocument(
        text=(
            f"Revenue: {revenue}\n"
            "Net Income: 1.3 billion\n"
            "Earnings Per Share: 2.45\n"
            "The company reported strong growth driven by cloud services."
        ),
        document_type="filing",
        gold_fields={
            "revenue": revenue,
            "net_income": "1.3 billion",
            "earnings_per_share": "2.45",
        },
        schema={
            "properties": {
                "revenue": {"type": "string"},
                "net_income": {"type": "string"},
                "earnings_per_share": {"type": "string"},
            }
        },
    )


def _make_transcript_doc() -> GoldDocument:
    """Create a sample transcript document."""
    return GoldDocument(
        text=(
            "CEO: We expect guidance of 5.0 to 5.2 billion for next quarter.\n"
            "CFO: Operating Margin: improved to 28 percent year over year.\n"
            "Analyst: What about the competitive landscape?\n"
            "CEO: We see strong demand across all segments."
        ),
        document_type="transcript",
        gold_fields={
            "guidance": "5.0 to 5.2 billion",
            "operating_margin": "28 percent",
        },
        schema={
            "properties": {
                "guidance": {"type": "string"},
                "operating_margin": {"type": "string"},
            }
        },
    )


def _make_article_doc() -> GoldDocument:
    """Create a sample article document."""
    return GoldDocument(
        text=(
            "Apple announced a new product line today. "
            "The stock price: rose 3.5% in after-hours trading. "
            "Analysts expect Revenue: 95 billion for the quarter."
        ),
        document_type="article",
        gold_fields={
            "stock_price": "rose 3.5%",
            "revenue": "95 billion",
        },
        schema={
            "properties": {
                "stock_price": {"type": "string"},
                "revenue": {"type": "string"},
            }
        },
    )


# --- Test Adapter Interface ---


class TestNuExtractAdapter:
    """Test the NuExtract adapter interface."""

    @pytest.mark.asyncio
    async def test_extract_returns_result(self, adapter: NuExtractAdapter) -> None:
        """Adapter returns a valid NuExtractResult."""
        result = await adapter.extract(
            text="Revenue: 4.2 billion\nNet Income: 1.3 billion",
            schema={"properties": {"revenue": {"type": "string"}}},
        )
        assert isinstance(result, NuExtractResult)
        assert result.model_version == NUEXTRACT_MODEL_VERSION
        assert result.error is None

    @pytest.mark.asyncio
    async def test_extract_captures_latency(self, adapter: NuExtractAdapter) -> None:
        """Extraction records latency in milliseconds."""
        result = await adapter.extract(
            text="Revenue: 10 million",
            schema={"properties": {"revenue": {"type": "string"}}},
        )
        assert result.latency_ms >= 0.0

    @pytest.mark.asyncio
    async def test_extract_finds_matching_fields(self, adapter: NuExtractAdapter) -> None:
        """Adapter extracts fields that match schema keys in text."""
        result = await adapter.extract(
            text="Revenue: 4.2 billion\nEPS: 2.45",
            schema={
                "properties": {
                    "revenue": {"type": "string"},
                    "eps": {"type": "string"},
                }
            },
        )
        field_names = [f.name for f in result.fields]
        assert "revenue" in field_names

    @pytest.mark.asyncio
    async def test_extract_sets_document_type(self, adapter: NuExtractAdapter) -> None:
        """Document type is preserved in result."""
        result = await adapter.extract(
            text="Some filing content",
            schema={"properties": {"field": {"type": "string"}}},
            document_type="filing",
        )
        assert result.document_type == "filing"

    @pytest.mark.asyncio
    async def test_extract_stores_schema_used(self, adapter: NuExtractAdapter) -> None:
        """Schema is stored in result for lineage."""
        schema = {"properties": {"revenue": {"type": "string"}}}
        result = await adapter.extract(text="Revenue: 100", schema=schema)
        assert result.schema_used == schema

    @pytest.mark.asyncio
    async def test_extract_handles_empty_text(self, adapter: NuExtractAdapter) -> None:
        """Adapter handles empty text gracefully."""
        result = await adapter.extract(
            text="",
            schema={"properties": {"field": {"type": "string"}}},
        )
        assert isinstance(result, NuExtractResult)
        assert result.error is None

    @pytest.mark.asyncio
    async def test_extract_hierarchical_schema(self, adapter: NuExtractAdapter) -> None:
        """Adapter handles nested/hierarchical schemas."""
        result = await adapter.extract(
            text="Revenue: 4.2 billion\nSegment growth: 15%",
            schema={
                "properties": {
                    "financials": {
                        "properties": {
                            "revenue": {"type": "string"},
                            "segment_growth": {"type": "string"},
                        }
                    }
                }
            },
        )
        assert isinstance(result, NuExtractResult)

    def test_model_version_pinned(self, adapter: NuExtractAdapter) -> None:
        """Model version is pinned and accessible."""
        assert adapter.model_version == NUEXTRACT_MODEL_VERSION
        assert "NuExtract" in adapter.model_name

    def test_test_mode_does_not_load_model(self, adapter: NuExtractAdapter) -> None:
        """Test mode doesn't attempt to load the real model."""
        assert not adapter.is_loaded

    def test_unload_is_safe_in_test_mode(self, adapter: NuExtractAdapter) -> None:
        """Unload is a no-op in test mode."""
        adapter.unload()
        assert not adapter.is_loaded


# --- Test Benchmark Comparison Logic ---


class TestBenchmarkComparison:
    """Test the benchmark comparison between NuExtract and GLiNER2."""

    @pytest.mark.asyncio
    async def test_benchmark_produces_report(self, benchmark: NuExtractBenchmark) -> None:
        """Benchmark returns a complete BenchmarkReport."""
        docs = [_make_filing_doc(), _make_filing_doc("5.1 billion")]
        gliner_results = [
            GLiNERResult(fields={"revenue": "4.2 billion", "net_income": "1.3 billion"}),
            GLiNERResult(fields={"revenue": "5.1 billion", "net_income": "1.3 billion"}),
        ]

        report = await benchmark.evaluate_against_gliner(docs, gliner_results)
        assert isinstance(report, BenchmarkReport)
        assert report.total_documents == 2
        assert len(report.reports) == 1  # One doc type: filing

    @pytest.mark.asyncio
    async def test_benchmark_groups_by_document_type(self, benchmark: NuExtractBenchmark) -> None:
        """Benchmark reports separately for each document type."""
        docs = [_make_filing_doc(), _make_transcript_doc(), _make_article_doc()]
        gliner_results = [
            GLiNERResult(fields={"revenue": "4.2 billion"}),
            GLiNERResult(fields={"guidance": "5.0 to 5.2 billion"}),
            GLiNERResult(fields={"stock_price": "rose 3.5%"}),
        ]

        report = await benchmark.evaluate_against_gliner(docs, gliner_results)
        doc_types = {r.document_type for r in report.reports}
        assert "filing" in doc_types
        assert "transcript" in doc_types
        assert "article" in doc_types

    @pytest.mark.asyncio
    async def test_benchmark_computes_f1_delta(self, benchmark: NuExtractBenchmark) -> None:
        """Delta is computed as nuextract_f1 - gliner_f1."""
        docs = [_make_filing_doc(), _make_filing_doc(), _make_filing_doc()]
        gliner_results = [
            GLiNERResult(fields={"revenue": "4.2 billion"}),
            GLiNERResult(fields={"revenue": "4.2 billion"}),
            GLiNERResult(fields={"revenue": "4.2 billion"}),
        ]

        report = await benchmark.evaluate_against_gliner(docs, gliner_results)
        for r in report.reports:
            assert r.delta == pytest.approx(r.nuextract_f1 - r.gliner_f1, abs=1e-6)

    @pytest.mark.asyncio
    async def test_benchmark_rejects_mismatched_lengths(self, benchmark: NuExtractBenchmark) -> None:
        """Benchmark raises when document and result counts differ."""
        docs = [_make_filing_doc(), _make_filing_doc()]
        gliner_results = [GLiNERResult(fields={"revenue": "4.2 billion"})]

        with pytest.raises(ValueError, match="must match"):
            await benchmark.evaluate_against_gliner(docs, gliner_results)

    @pytest.mark.asyncio
    async def test_benchmark_tracks_latency(self, benchmark: NuExtractBenchmark) -> None:
        """Benchmark records latency metrics per type."""
        docs = [_make_filing_doc(), _make_filing_doc(), _make_filing_doc()]
        gliner_results = [
            GLiNERResult(fields={"revenue": "4.2 billion"}, latency_ms=50.0),
            GLiNERResult(fields={"revenue": "4.2 billion"}, latency_ms=60.0),
            GLiNERResult(fields={"revenue": "4.2 billion"}, latency_ms=55.0),
        ]

        report = await benchmark.evaluate_against_gliner(docs, gliner_results)
        filing_report = report.reports[0]
        assert filing_report.gliner_latency_ms > 0.0
        assert filing_report.nuextract_latency_ms >= 0.0

    @pytest.mark.asyncio
    async def test_benchmark_overall_metrics(self, benchmark: NuExtractBenchmark) -> None:
        """Overall metrics are weighted averages across types."""
        docs = [_make_filing_doc(), _make_filing_doc(), _make_filing_doc()]
        gliner_results = [
            GLiNERResult(fields={"revenue": "4.2 billion", "net_income": "1.3 billion", "earnings_per_share": "2.45"}),
            GLiNERResult(fields={"revenue": "4.2 billion", "net_income": "1.3 billion", "earnings_per_share": "2.45"}),
            GLiNERResult(fields={"revenue": "4.2 billion", "net_income": "1.3 billion", "earnings_per_share": "2.45"}),
        ]

        report = await benchmark.evaluate_against_gliner(docs, gliner_results)
        assert report.overall_gliner_f1 >= 0.0
        assert report.overall_nuextract_f1 >= 0.0
        assert report.overall_delta == pytest.approx(
            report.overall_nuextract_f1 - report.overall_gliner_f1, abs=1e-6
        )


# --- Test Promotion Gate ---


class TestPromotionGate:
    """Test the promotion gate pass/fail logic."""

    def test_promotion_passes_when_all_gates_met(self, lenient_gate: PromotionGate) -> None:
        """Promotion passes when all thresholds are met."""
        evaluator = PromotionEvaluator(lenient_gate)
        report = IncrementalValueReport(
            document_type="filing",
            gliner_f1=0.80,
            nuextract_f1=0.85,
            delta=0.05,
            nuextract_latency_ms=200.0,
            nuextract_memory_mb=500.0,
            sample_count=100,
        )
        assert evaluator.evaluate(report) is True

    def test_promotion_fails_insufficient_f1(self, strict_gate: PromotionGate) -> None:
        """Promotion fails when F1 improvement is below threshold."""
        evaluator = PromotionEvaluator(strict_gate)
        report = IncrementalValueReport(
            document_type="filing",
            gliner_f1=0.80,
            nuextract_f1=0.82,
            delta=0.02,  # Below 0.20 threshold
            nuextract_latency_ms=50.0,
            nuextract_memory_mb=200.0,
            sample_count=100,
        )
        assert evaluator.evaluate(report) is False

    def test_promotion_fails_high_latency(self) -> None:
        """Promotion fails when latency exceeds the gate."""
        gate = PromotionGate(
            min_f1_improvement=0.01,
            max_latency_ms=100.0,
            max_memory_mb=4096.0,
            min_sample_count=1,
        )
        evaluator = PromotionEvaluator(gate)
        report = IncrementalValueReport(
            document_type="transcript",
            gliner_f1=0.70,
            nuextract_f1=0.80,
            delta=0.10,
            nuextract_latency_ms=500.0,  # Exceeds 100ms gate
            nuextract_memory_mb=200.0,
            sample_count=50,
        )
        assert evaluator.evaluate(report) is False

    def test_promotion_fails_high_memory(self) -> None:
        """Promotion fails when memory exceeds the gate."""
        gate = PromotionGate(
            min_f1_improvement=0.01,
            max_latency_ms=10000.0,
            max_memory_mb=512.0,
            min_sample_count=1,
        )
        evaluator = PromotionEvaluator(gate)
        report = IncrementalValueReport(
            document_type="article",
            gliner_f1=0.70,
            nuextract_f1=0.85,
            delta=0.15,
            nuextract_latency_ms=200.0,
            nuextract_memory_mb=1024.0,  # Exceeds 512MB gate
            sample_count=50,
        )
        assert evaluator.evaluate(report) is False

    def test_promotion_fails_insufficient_samples(self) -> None:
        """Promotion fails when sample count is below minimum."""
        gate = PromotionGate(
            min_f1_improvement=0.01,
            max_latency_ms=10000.0,
            max_memory_mb=4096.0,
            min_sample_count=100,
        )
        evaluator = PromotionEvaluator(gate)
        report = IncrementalValueReport(
            document_type="filing",
            gliner_f1=0.70,
            nuextract_f1=0.90,
            delta=0.20,
            nuextract_latency_ms=200.0,
            nuextract_memory_mb=500.0,
            sample_count=10,  # Below 100 minimum
        )
        assert evaluator.evaluate(report) is False

    def test_rejection_reasons_reported(self) -> None:
        """Evaluator provides specific rejection reasons."""
        gate = PromotionGate(
            min_f1_improvement=0.10,
            max_latency_ms=100.0,
            max_memory_mb=512.0,
            min_sample_count=50,
        )
        evaluator = PromotionEvaluator(gate)
        report = IncrementalValueReport(
            document_type="filing",
            gliner_f1=0.80,
            nuextract_f1=0.82,
            delta=0.02,  # Below threshold
            nuextract_latency_ms=500.0,  # Above threshold
            nuextract_memory_mb=1024.0,  # Above threshold
            sample_count=10,  # Below minimum
        )
        reasons = evaluator.get_rejection_reasons(report)
        assert len(reasons) == 4
        assert any("F1" in r for r in reasons)
        assert any("Latency" in r for r in reasons)
        assert any("Memory" in r for r in reasons)
        assert any("samples" in r.lower() for r in reasons)

    def test_no_rejection_reasons_when_passing(self, lenient_gate: PromotionGate) -> None:
        """No rejection reasons when all gates pass."""
        evaluator = PromotionEvaluator(lenient_gate)
        report = IncrementalValueReport(
            document_type="filing",
            gliner_f1=0.70,
            nuextract_f1=0.80,
            delta=0.10,
            nuextract_latency_ms=200.0,
            nuextract_memory_mb=500.0,
            sample_count=100,
        )
        reasons = evaluator.get_rejection_reasons(report)
        assert reasons == []


# --- Test Per-Document-Type Reporting ---


class TestPerDocumentTypeReporting:
    """Test that benchmark produces correct per-type reports."""

    @pytest.mark.asyncio
    async def test_promoted_types_listed(self) -> None:
        """Promoted types appear in the benchmark report."""
        gate = PromotionGate(
            min_f1_improvement=0.0,  # Accept any improvement
            max_latency_ms=10000.0,
            max_memory_mb=4096.0,
            min_sample_count=1,
        )
        benchmark = NuExtractBenchmark(
            adapter=NuExtractAdapter(test_mode=True),
            gate=gate,
        )

        # Filing doc where NuExtract should find matches (schema keys appear in text)
        docs = [_make_filing_doc()]
        # GLiNER returns empty to ensure NuExtract has higher F1
        gliner_results = [GLiNERResult(fields={})]

        report = await benchmark.evaluate_against_gliner(docs, gliner_results)
        # With empty GLiNER results, NuExtract should score higher
        for r in report.reports:
            if r.nuextract_f1 > r.gliner_f1:
                assert r.document_type in report.promoted_types

    @pytest.mark.asyncio
    async def test_non_promoted_types_excluded(self) -> None:
        """Types that don't pass gates are not in promoted list."""
        gate = PromotionGate(
            min_f1_improvement=0.99,  # Nearly impossible to pass
            max_latency_ms=10000.0,
            max_memory_mb=4096.0,
            min_sample_count=1,
        )
        benchmark = NuExtractBenchmark(
            adapter=NuExtractAdapter(test_mode=True),
            gate=gate,
        )

        docs = [_make_filing_doc()]
        gliner_results = [
            GLiNERResult(
                fields={"revenue": "4.2 billion", "net_income": "1.3 billion", "earnings_per_share": "2.45"}
            )
        ]

        report = await benchmark.evaluate_against_gliner(docs, gliner_results)
        assert report.promoted_types == []

    @pytest.mark.asyncio
    async def test_sample_count_per_type(self) -> None:
        """Sample count reflects the number of documents per type."""
        benchmark = NuExtractBenchmark(
            adapter=NuExtractAdapter(test_mode=True),
            gate=PromotionGate(min_sample_count=1),
        )

        docs = [
            _make_filing_doc(),
            _make_filing_doc("5.0 billion"),
            _make_transcript_doc(),
        ]
        gliner_results = [
            GLiNERResult(fields={"revenue": "4.2 billion"}),
            GLiNERResult(fields={"revenue": "5.0 billion"}),
            GLiNERResult(fields={"guidance": "5.0 to 5.2 billion"}),
        ]

        report = await benchmark.evaluate_against_gliner(docs, gliner_results)
        type_counts = {r.document_type: r.sample_count for r in report.reports}
        assert type_counts["filing"] == 2
        assert type_counts["transcript"] == 1

    @pytest.mark.asyncio
    async def test_f1_scores_bounded(self) -> None:
        """F1 scores are always between 0 and 1."""
        benchmark = NuExtractBenchmark(
            adapter=NuExtractAdapter(test_mode=True),
            gate=PromotionGate(min_sample_count=1),
        )

        docs = [_make_filing_doc(), _make_transcript_doc(), _make_article_doc()]
        gliner_results = [
            GLiNERResult(fields={"revenue": "wrong value"}),
            GLiNERResult(fields={"guidance": "wrong"}),
            GLiNERResult(fields={}),
        ]

        report = await benchmark.evaluate_against_gliner(docs, gliner_results)
        for r in report.reports:
            assert 0.0 <= r.gliner_f1 <= 1.0
            assert 0.0 <= r.nuextract_f1 <= 1.0
