"""
Tests for Track A4 — L2 Pro premium judge.

Covers:
  - L2DimensionVerdict: 8 dimensions + scoring
  - L2Result: aggregation + dimension pass rates
  - L2ProJudge: construction, missing api_key, cost estimation
  - make_l2_judge_from_env: env-var-driven factory
  - run_l2_check: defensive wrapper
"""
import os
import json
import pytest
from unittest.mock import MagicMock, AsyncMock, patch

from services.quality.l2_judge import (
    L2DimensionVerdict,
    L2Result,
    L2ProJudge,
    make_l2_judge_from_env,
    L2_JUDGE_SYSTEM_PROMPT,
)


# ============================================================================
# L2DimensionVerdict
# ============================================================================

class TestL2DimensionVerdict:
    def test_all_pass(self):
        v = L2DimensionVerdict(
            accurate=True, fluent=True, correct_lang=True, no_leaks=True,
            terminology=True, style=True, completeness=True, formatting=True,
        )
        assert v.passed_count == 8
        assert v.total == 8
        assert v.score == 1.0
        assert v.passed is True

    def test_one_fails(self):
        v = L2DimensionVerdict(
            accurate=True, fluent=True, correct_lang=True, no_leaks=True,
            terminology=False,  # <-- fails
            style=True, completeness=True, formatting=True,
        )
        assert v.passed_count == 7
        assert v.total == 8
        assert v.score == pytest.approx(0.875)
        # L2 is strict: one fail = chunk fails
        assert v.passed is False

    def test_all_fail(self):
        v = L2DimensionVerdict()  # all default False
        assert v.passed_count == 0
        assert v.score == 0.0
        assert v.passed is False

    def test_default_construction(self):
        v = L2DimensionVerdict()
        assert v.accurate is False
        assert v.reason == ""


# ============================================================================
# L2Result
# ============================================================================

class TestL2Result:
    def test_default_construction(self):
        r = L2Result(verdict="pass")
        assert r.verdict == "pass"
        assert r.chunks_evaluated == 0
        assert r.dimension_pass_rates == {}
        assert r.error == ""

    def test_to_log_dict(self):
        r = L2Result(
            verdict="pass",
            chunks_evaluated=10,
            chunks_passed=8,
            chunks_failed=2,
            failure_rate=0.2,
            average_score=0.85,
            model_used="gpt-4o",
            cost_estimate_usd=0.012,
        )
        d = r.to_log_dict()
        assert d["verdict"] == "pass"
        assert d["chunks_evaluated"] == 10
        assert d["model_used"] == "gpt-4o"
        assert d["cost_estimate_usd"] == 0.012


# ============================================================================
# L2ProJudge construction
# ============================================================================

class TestL2ProJudgeConstruction:
    def test_requires_api_key(self):
        with pytest.raises(ValueError, match="api_key is required"):
            L2ProJudge(api_key="")

    def test_basic_construction(self):
        judge = L2ProJudge(api_key="sk-test")
        assert judge._api_key == "sk-test"
        assert judge._model == "gpt-4o"
        assert judge._base_url == "https://api.openai.com/v1"

    def test_custom_model(self):
        judge = L2ProJudge(
            api_key="sk-test",
            model="gpt-4o-mini",
            base_url="https://api.openai.com/v1",
        )
        assert judge._model == "gpt-4o-mini"

    def test_strips_trailing_slash_from_base_url(self):
        judge = L2ProJudge(
            api_key="sk-test",
            base_url="https://api.example.com/v1/",
        )
        assert judge._base_url == "https://api.example.com/v1"


# ============================================================================
# Cost estimation
# ============================================================================

class TestL2CostEstimation:
    def test_gpt4o_cost(self):
        judge = L2ProJudge(api_key="sk", model="gpt-4o")
        cost = judge._estimate_cost(15)  # 15 samples default
        # Should be in the $0.01–$0.05 range for 15 chunks
        assert 0.001 < cost < 0.10

    def test_gpt4o_mini_cheaper(self):
        judge_mini = L2ProJudge(api_key="sk", model="gpt-4o-mini")
        judge_full = L2ProJudge(api_key="sk", model="gpt-4o")
        cost_mini = judge_mini._estimate_cost(15)
        cost_full = judge_full._estimate_cost(15)
        # gpt-4o-mini should be cheaper than gpt-4o
        assert cost_mini < cost_full

    def test_zero_pairs(self):
        judge = L2ProJudge(api_key="sk", model="gpt-4o")
        cost = judge._estimate_cost(0)
        # Even with 0 pairs, the system prompt has some cost
        assert cost >= 0


# ============================================================================
# judge_batch — defensive
# ============================================================================

class TestL2JudgeBatch:
    @pytest.mark.asyncio
    async def test_empty_pairs_skips(self):
        judge = L2ProJudge(api_key="sk")
        result = await judge.judge_batch([], "fr", "French")
        assert result.verdict == "skip"
        assert result.error == "empty pairs"

    @pytest.mark.asyncio
    async def test_client_unavailable_skips(self):
        judge = L2ProJudge(api_key="sk")
        # Simulate a client init failure
        with patch.object(judge, "_get_client", return_value=None):
            result = await judge.judge_batch(
                [("Hello", "Bonjour")], "fr", "French"
            )
        assert result.verdict == "skip"
        assert "unavailable" in result.error or "client" in result.error.lower()

    @pytest.mark.asyncio
    async def test_successful_judgement(self):
        """A mock client that returns a well-formed JSON response should
        produce a passing L2Result."""
        judge = L2ProJudge(api_key="sk", model="gpt-4o")

        # Mock the client
        mock_response = MagicMock()
        mock_response.choices = [MagicMock()]
        mock_response.choices[0].message.content = json.dumps([
            {
                "accurate": "yes", "fluent": "yes", "correct_lang": "yes",
                "no_leaks": "yes", "terminology": "yes", "style": "yes",
                "completeness": "yes", "formatting": "yes",
                "reason": "Perfect translation",
            }
        ])

        mock_client = MagicMock()
        mock_client.chat.completions.create = AsyncMock(return_value=mock_response)

        with patch.object(judge, "_get_client", return_value=mock_client):
            result = await judge.judge_batch(
                [("Hello", "Bonjour")], "fr", "French"
            )

        assert result.verdict == "pass"
        assert result.chunks_evaluated == 1
        assert result.chunks_passed == 1
        assert result.chunks_failed == 0
        assert result.failure_rate == 0.0
        assert result.average_score == 1.0
        # All 8 dimensions should have pass rate 1.0
        for dim in ["accurate", "fluent", "terminology", "style"]:
            assert result.dimension_pass_rates[dim] == 1.0

    @pytest.mark.asyncio
    async def test_partial_failure(self):
        """If one of 8 dimensions fails, the chunk should fail."""
        judge = L2ProJudge(api_key="sk", model="gpt-4o")

        mock_response = MagicMock()
        mock_response.choices = [MagicMock()]
        # fluent = no, others yes
        mock_response.choices[0].message.content = json.dumps([
            {
                "accurate": "yes", "fluent": "no", "correct_lang": "yes",
                "no_leaks": "yes", "terminology": "yes", "style": "yes",
                "completeness": "yes", "formatting": "yes",
                "reason": "Awkward phrasing",
            }
        ])

        mock_client = MagicMock()
        mock_client.chat.completions.create = AsyncMock(return_value=mock_response)

        with patch.object(judge, "_get_client", return_value=mock_client):
            result = await judge.judge_batch(
                [("Hello", "Bonjour")], "fr", "French"
            )

        # L2 is strict: one fail = overall fail
        assert result.verdict == "fail"
        assert result.chunks_evaluated == 1
        assert result.chunks_passed == 0
        assert result.chunks_failed == 1
        assert result.dimension_pass_rates["fluent"] == 0.0
        assert result.dimension_pass_rates["accurate"] == 1.0

    @pytest.mark.asyncio
    async def test_handles_markdown_fences(self):
        """The judge should strip markdown code fences from responses."""
        judge = L2ProJudge(api_key="sk", model="gpt-4o")

        mock_response = MagicMock()
        mock_response.choices = [MagicMock()]
        mock_response.choices[0].message.content = (
            "```json\n"
            + json.dumps([{
                "accurate": "yes", "fluent": "yes", "correct_lang": "yes",
                "no_leaks": "yes", "terminology": "yes", "style": "yes",
                "completeness": "yes", "formatting": "yes",
                "reason": "ok",
            }])
            + "\n```"
        )

        mock_client = MagicMock()
        mock_client.chat.completions.create = AsyncMock(return_value=mock_response)

        with patch.object(judge, "_get_client", return_value=mock_client):
            result = await judge.judge_batch(
                [("Hello", "Bonjour")], "fr", "French"
            )

        assert result.verdict == "pass"
        assert result.chunks_evaluated == 1

    @pytest.mark.asyncio
    async def test_handles_dict_with_list(self):
        """Some LLMs return {"verdicts": [...]} instead of a raw list."""
        judge = L2ProJudge(api_key="sk", model="gpt-4o")

        mock_response = MagicMock()
        mock_response.choices = [MagicMock()]
        mock_response.choices[0].message.content = json.dumps({
            "verdicts": [
                {
                    "accurate": "yes", "fluent": "yes", "correct_lang": "yes",
                    "no_leaks": "yes", "terminology": "yes", "style": "yes",
                    "completeness": "yes", "formatting": "yes",
                    "reason": "good",
                }
            ]
        })

        mock_client = MagicMock()
        mock_client.chat.completions.create = AsyncMock(return_value=mock_response)

        with patch.object(judge, "_get_client", return_value=mock_client):
            result = await judge.judge_batch(
                [("Hello", "Bonjour")], "fr", "French"
            )

        assert result.verdict == "pass"

    @pytest.mark.asyncio
    async def test_timeout_returns_skip(self):
        import asyncio
        judge = L2ProJudge(api_key="sk", model="gpt-4o", timeout_seconds=0.1)

        # Mock client that raises TimeoutError
        mock_client = MagicMock()
        mock_client.chat.completions.create = AsyncMock(
            side_effect=asyncio.TimeoutError()
        )

        with patch.object(judge, "_get_client", return_value=mock_client):
            result = await judge.judge_batch(
                [("Hello", "Bonjour")], "fr", "French"
            )

        assert result.verdict == "skip"
        assert "timeout" in result.error.lower()

    @pytest.mark.asyncio
    async def test_never_raises(self):
        """Even on unexpected error, the judge should return a skip, not raise."""
        judge = L2ProJudge(api_key="sk", model="gpt-4o")

        # Mock client that raises a generic exception
        mock_client = MagicMock()
        mock_client.chat.completions.create = AsyncMock(
            side_effect=RuntimeError("something unexpected")
        )

        with patch.object(judge, "_get_client", return_value=mock_client):
            # Should NOT raise
            result = await judge.judge_batch(
                [("Hello", "Bonjour")], "fr", "French"
            )
        assert result.verdict == "skip"

    @pytest.mark.asyncio
    async def test_dimension_pass_rates_aggregated(self):
        """Per-dimension pass rates should aggregate across chunks."""
        judge = L2ProJudge(api_key="sk", model="gpt-4o")

        mock_response = MagicMock()
        mock_response.choices = [MagicMock()]
        # 2 chunks: chunk 1 all-pass, chunk 2 fluent-fail
        mock_response.choices[0].message.content = json.dumps([
            {
                "accurate": "yes", "fluent": "yes", "correct_lang": "yes",
                "no_leaks": "yes", "terminology": "yes", "style": "yes",
                "completeness": "yes", "formatting": "yes",
                "reason": "ok",
            },
            {
                "accurate": "yes", "fluent": "no", "correct_lang": "yes",
                "no_leaks": "yes", "terminology": "yes", "style": "yes",
                "completeness": "yes", "formatting": "yes",
                "reason": "awkward",
            },
        ])

        mock_client = MagicMock()
        mock_client.chat.completions.create = AsyncMock(return_value=mock_response)

        with patch.object(judge, "_get_client", return_value=mock_client):
            result = await judge.judge_batch(
                [("Hello", "Bonjour"), ("Goodbye", "Au revoir")],
                "fr", "French"
            )

        assert result.chunks_evaluated == 2
        # fluent: 1/2 = 0.5
        assert result.dimension_pass_rates["fluent"] == 0.5
        # accurate: 2/2 = 1.0
        assert result.dimension_pass_rates["accurate"] == 1.0


# ============================================================================
# Factory
# ============================================================================

class TestL2JudgeFactory:
    def test_no_api_key_returns_none(self, monkeypatch):
        monkeypatch.delenv("L2_JUDGE_API_KEY", raising=False)
        assert make_l2_judge_from_env() is None

    def test_api_key_creates_judge(self, monkeypatch):
        monkeypatch.setenv("L2_JUDGE_API_KEY", "sk-test")
        monkeypatch.setenv("L2_JUDGE_MODEL", "gpt-4o")
        monkeypatch.setenv("L2_JUDGE_BASE_URL", "https://api.openai.com/v1")
        judge = make_l2_judge_from_env()
        assert judge is not None
        assert judge._api_key == "sk-test"
        assert judge._model == "gpt-4o"

    def test_api_key_with_default_model(self, monkeypatch):
        monkeypatch.setenv("L2_JUDGE_API_KEY", "sk-test")
        # Clear other vars to test defaults
        monkeypatch.delenv("L2_JUDGE_MODEL", raising=False)
        monkeypatch.delenv("L2_JUDGE_BASE_URL", raising=False)
        judge = make_l2_judge_from_env()
        assert judge._model == "gpt-4o"
        assert judge._base_url == "https://api.openai.com/v1"


# ============================================================================
# Pipeline integration
# ============================================================================

class TestL2PipelineIntegration:
    @pytest.mark.asyncio
    async def test_run_l2_check_no_judge_skips(self):
        from services.quality.pipeline import run_l2_check

        result = await run_l2_check(
            source_chunks=["Hello"] * 25,
            translated_chunks=["Bonjour"] * 25,
            target_lang="fr",
            file_extension="docx",
            judge=None,  # Will try to load from env, but env has no key
        )
        # Should return skip, not raise
        assert result.verdict == "skip"

    @pytest.mark.asyncio
    async def test_run_l2_check_with_mock_judge(self):
        from services.quality.pipeline import run_l2_check

        # Build a judge that always returns pass
        judge = L2ProJudge(api_key="sk")

        mock_response = MagicMock()
        mock_response.choices = [MagicMock()]
        mock_response.choices[0].message.content = json.dumps([
            {
                "accurate": "yes", "fluent": "yes", "correct_lang": "yes",
                "no_leaks": "yes", "terminology": "yes", "style": "yes",
                "completeness": "yes", "formatting": "yes",
                "reason": "ok",
            }
        ] * 5)

        mock_client = MagicMock()
        mock_client.chat.completions.create = AsyncMock(return_value=mock_response)
        judge._client = mock_client

        # 25 chunks > default min_chunks of 20
        result = await run_l2_check(
            source_chunks=["Hello world"] * 25,
            translated_chunks=["Bonjour le monde"] * 25,
            target_lang="fr",
            file_extension="docx",
            judge=judge,
            max_samples=5,
        )

        assert result.verdict == "pass"
        assert result.chunks_evaluated >= 1
        assert result.chunks_passed >= 1

    @pytest.mark.asyncio
    async def test_run_l2_check_too_few_chunks_skips(self):
        from services.quality.pipeline import run_l2_check

        # 5 chunks < default min_chunks of 20
        result = await run_l2_check(
            source_chunks=["a"] * 5,
            translated_chunks=["b"] * 5,
            target_lang="fr",
            min_chunks=20,
        )
        # Should skip due to insufficient chunks
        assert result.verdict == "skip"


# ============================================================================
# 8-dimension coverage
# ============================================================================

class TestL28DimensionCoverage:
    """Sanity check: the L2 prompt template actually mentions all 8 dimensions."""

    def test_prompt_has_all_8_dimensions(self):
        for dim in [
            "ACCURATE", "FLUENT", "CORRECT_LANG", "NO_LEAKS",
            "TERMINOLOGY", "STYLE", "COMPLETENESS", "FORMATTING",
        ]:
            assert dim in L2_JUDGE_SYSTEM_PROMPT, (
                f"Dimension {dim!r} missing from L2 prompt template"
            )

    def test_prompt_has_format_hint(self):
        # Should tell the model to respond with a JSON array
        assert "JSON" in L2_JUDGE_SYSTEM_PROMPT
        assert "yes" in L2_JUDGE_SYSTEM_PROMPT
        assert "no" in L2_JUDGE_SYSTEM_PROMPT
