# #region Test.LLMTranslationService [C:3] [TYPE Module] [SEMANTICS test, llm, translation, retry, parse]
# @BRIEF Verify LLMTranslationService contracts — call_llm_for_batch, retry, split, recovery, failure handling.
# @RELATION BINDS_TO -> [LLMTranslationService]
# @TEST_EDGE: batch_empty -> Handled gracefully
# @TEST_EDGE: llm_all_retries_exhausted -> Returns failure counts
# @TEST_EDGE: parse_failure -> Returns skipped counts
# @TEST_EDGE: truncation_recovery -> Recovers partial rows
# @TEST_EDGE: no_provider -> Raises ValueError
import json
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from datetime import UTC, datetime

from src.models.translate import (
    Base, TranslationBatch, TranslationJob, TranslationLanguage,
    TranslationRecord,
)
from src.plugins.translate._llm_call import LLMTranslationService, MAX_RETRIES_PER_BATCH

from .conftest import JOB_ID, RUN_ID


def _make_job(session, provider_id="test-provider", target_langs=None):
    job = session.query(TranslationJob).filter(TranslationJob.id == JOB_ID).first()
    if job is None:
        job = TranslationJob(
            id=JOB_ID, name="LLM Test", source_dialect="en",
            target_dialect=target_langs[0] if target_langs else "fr",
            target_languages=target_langs or ["fr"],
            status="ACTIVE", provider_id=provider_id,
        )
        session.add(job)
    else:
        if provider_id:
            job.provider_id = provider_id
        if target_langs:
            job.target_languages = target_langs
            job.target_dialect = target_langs[0]
    session.commit()
    return job


def _batch_rows(count=2, detected_lang="en"):
    return [
        {
            "row_index": str(i),
            "source_text": f"Hello world {i}",
            "source_data": {"table": "test"},
            "_detected_lang": detected_lang,
        }
        for i in range(count)
    ]


class TestInit:
    """Verify basic initialization."""

    def test_init(self, db_session):
        svc = LLMTranslationService(db_session)
        assert svc.db is db_session


class TestBuildDictionarySection:
    """Verify _build_dictionary_section."""

    def test_no_dict_matches(self, db_session):
        svc = LLMTranslationService(db_session)
        result = svc._build_dictionary_section([], _batch_rows(1))
        assert result == ""

    def test_with_dict_matches(self, db_session):
        svc = LLMTranslationService(db_session)
        matches = [{
            "entry_id": "e1", "source_term": "hello", "source_term_normalized": "hello",
            "target_term": "hola", "dictionary_id": "d1", "dictionary_name": "Test",
            "context_notes": "", "context_data": None, "has_context": False,
            "is_regex": False, "context_source": None, "usage_notes": None,
            "source_language": "en", "target_language": "es", "priority_match": False,
            "text_index": 0, "source_text": "hello",
        }]
        result = svc._build_dictionary_section(matches, _batch_rows(1))
        assert "Terminology dictionary" in result
        assert "hello" in result


class TestResolveTargetLanguages:
    """Verify _resolve_target_languages."""

    def test_list_target_languages(self, db_session):
        svc = LLMTranslationService(db_session)
        job = _make_job(db_session, target_langs=["fr", "de"])
        result = LLMTranslationService._resolve_target_languages(job)
        assert result == ["fr", "de"]

    def test_single_target_language(self, db_session):
        svc = LLMTranslationService(db_session)
        job = _make_job(db_session, target_langs=["fr"])
        result = LLMTranslationService._resolve_target_languages(job)
        assert result == ["fr"]


class TestBuildPrompt:
    """Verify _build_prompt."""

    def test_prompt_contains_rows_and_languages(self, db_session):
        svc = LLMTranslationService(db_session)
        job = _make_job(db_session, target_langs=["fr"])
        rows = _batch_rows(2)
        prompt = LLMTranslationService._build_prompt(job, rows, "dict_section\n", ["fr"])
        assert "fr" in prompt
        assert "Hello world 0" in prompt
        assert "Hello world 1" in prompt
        assert "dict_section" in prompt


class TestCallLlm:
    """Verify call_llm method (provider routing)."""

    @pytest.mark.asyncio
    async def test_no_provider_id(self, db_session):
        svc = LLMTranslationService(db_session)
        job = _make_job(db_session, provider_id="")
        with pytest.raises(ValueError, match="no LLM provider configured"):
            await svc.call_llm(job, "prompt")

    @pytest.mark.asyncio
    async def test_provider_not_found(self, db_session):
        svc = LLMTranslationService(db_session)
        job = _make_job(db_session, provider_id="nonexistent")
        with patch('src.plugins.translate._llm_call.LLMProviderService') as MockSvc:
            mock_instance = MockSvc.return_value
            mock_instance.get_provider.return_value = None
            with pytest.raises(ValueError, match="not found"):
                await svc.call_llm(job, "prompt")

    @pytest.mark.asyncio
    async def test_api_key_not_found(self, db_session):
        svc = LLMTranslationService(db_session)
        job = _make_job(db_session, provider_id="test-provider")
        with patch('src.plugins.translate._llm_call.LLMProviderService') as MockSvc:
            mock_instance = MockSvc.return_value
            mock_instance.get_provider.return_value = MagicMock(
                provider_type="openai", base_url="https://api.openai.com",
                default_model="gpt-4o-mini",
            )
            mock_instance.get_decrypted_api_key.return_value = None
            with pytest.raises(ValueError, match="Could not decrypt"):
                await svc.call_llm(job, "prompt")

    @pytest.mark.asyncio
    async def test_unsupported_provider_type(self, db_session):
        svc = LLMTranslationService(db_session)
        job = _make_job(db_session, provider_id="test-provider")
        with patch('src.plugins.translate._llm_call.LLMProviderService') as MockSvc:
            mock_instance = MockSvc.return_value
            mock_provider = MagicMock(
                provider_type="unknown_type", base_url="https://example.com",
                default_model="test-model",
            )
            mock_instance.get_provider.return_value = mock_provider
            mock_instance.get_decrypted_api_key.return_value = "sk-test"
            with pytest.raises(ValueError, match="Unsupported provider type"):
                await svc.call_llm(job, "prompt")

    @pytest.mark.asyncio
    async def test_successful_llm_call(self, db_session):
        svc = LLMTranslationService(db_session)
        job = _make_job(db_session, provider_id="test-provider")
        with patch('src.plugins.translate._llm_call.LLMProviderService') as MockSvc:
            mock_instance = MockSvc.return_value
            mock_instance.get_provider.return_value = MagicMock(
                provider_type="openai", base_url="https://api.openai.com",
                default_model="gpt-4o-mini",
            )
            mock_instance.get_decrypted_api_key.return_value = "sk-test"
            with patch('src.plugins.translate._llm_call.call_openai_compatible',
                       new=AsyncMock(return_value=('{"response": "ok"}', "stop"))):
                response, finish = await svc.call_llm(job, "test prompt")
                assert response == '{"response": "ok"}'
                assert finish == "stop"


class TestCallLlmForBatch:
    """Verify call_llm_for_batch orchestration."""

    @pytest.mark.asyncio
    async def test_llm_failure_all_retries(self, db_with_run):
        """Negative: all retries fail."""
        session, run_id = db_with_run
        svc = LLMTranslationService(session)
        job = _make_job(session, target_langs=["fr"])
        rows = _batch_rows(2)

        with patch.object(svc, '_call_llm_with_retry',
                          new=AsyncMock(return_value=(None, None, 3, "API error"))):
            result = await svc.call_llm_for_batch(
                job=job, run_id=run_id, batch_rows=rows,
                dict_matches=[], batch_id="batch-1",
            )
            assert result["successful"] == 0
            assert result["failed"] == 2
            assert result["retries"] == 3

    @pytest.mark.asyncio
    async def test_parse_failure(self, db_with_run):
        """Negative: LLM response cannot be parsed."""
        session, run_id = db_with_run
        svc = LLMTranslationService(session)
        job = _make_job(session, target_langs=["fr"])
        rows = _batch_rows(1)

        with patch.object(svc, '_call_llm_with_retry',
                          new=AsyncMock(return_value=("bad response", "stop", 1, None))):
            with patch('src.plugins.translate._llm_call.parse_llm_response',
                       side_effect=ValueError("Invalid JSON")):
                result = await svc.call_llm_for_batch(
                    job=job, run_id=run_id, batch_rows=rows,
                    dict_matches=[], batch_id="batch-1",
                )
                assert result["successful"] == 0
                assert result["skipped"] == 1

    @pytest.mark.asyncio
    async def test_successful_translation(self, db_with_run):
        """Happy: successful LLM call with parsed translations."""
        session, run_id = db_with_run
        svc = LLMTranslationService(session)
        job = _make_job(session, target_langs=["fr"])
        rows = _batch_rows(1)

        with patch.object(svc, '_call_llm_with_retry',
                          new=AsyncMock(return_value=('{"ok": true}', "stop", 1, None))):
            with patch('src.plugins.translate._llm_call.parse_llm_response',
                       return_value={"0": {"fr": "Bonjour le monde 0", "translation": "Bonjour le monde 0"}}):
                result = await svc.call_llm_for_batch(
                    job=job, run_id=run_id, batch_rows=rows,
                    dict_matches=[], batch_id="batch-1",
                )
                assert result["successful"] == 1

    @pytest.mark.asyncio
    async def test_truncation_split_and_retry(self, db_with_run):
        """Edge: truncation triggers binary split."""
        session, run_id = db_with_run
        svc = LLMTranslationService(session)
        job = _make_job(session, target_langs=["fr"])
        rows = _batch_rows(4)

        with patch.object(svc, '_call_llm_with_retry',
                          new=AsyncMock(return_value=('partial', "length", 1, None))):
            with patch.object(svc, '_try_recover_partial', return_value=None):
                with patch.object(svc, '_split_and_retry',
                                  new=AsyncMock(return_value={"successful": 2, "failed": 0,
                                                                "skipped": 0, "retries": 1})):
                    result = await svc.call_llm_for_batch(
                        job=job, run_id=run_id, batch_rows=rows,
                        dict_matches=[], batch_id="batch-1",
                    )
                    assert result["successful"] == 2

    @pytest.mark.asyncio
    async def test_truncation_recovery_all_rows(self, db_with_run):
        """Edge: all rows recovered from truncated response."""
        session, run_id = db_with_run
        svc = LLMTranslationService(session)
        job = _make_job(session, target_langs=["fr"])
        rows = _batch_rows(2)

        with patch.object(svc, '_call_llm_with_retry',
                          new=AsyncMock(return_value=('partial', "length", 1, None))):
            with patch.object(svc, '_try_recover_partial',
                              return_value={"0", "1"}):
                result = await svc.call_llm_for_batch(
                    job=job, run_id=run_id, batch_rows=rows,
                    dict_matches=[], batch_id="batch-1",
                )
                assert result["successful"] == 2

    @pytest.mark.asyncio
    async def test_truncation_partial_recovery_then_retry(self, db_with_run):
        """Edge: partial recovery, then retry remaining."""
        session, run_id = db_with_run
        svc = LLMTranslationService(session)
        job = _make_job(session, target_langs=["fr"])
        rows = _batch_rows(3)

        with patch.object(svc, '_call_llm_with_retry',
                          new=AsyncMock(return_value=('partial', "length", 1, None))):
            with patch.object(svc, '_try_recover_partial',
                              return_value={"0"}):
                with patch.object(svc, '_retry_missing_rows',
                                  new=AsyncMock(return_value={"successful": 2, "failed": 0,
                                                                "skipped": 0, "retries": 1})):
                    result = await svc.call_llm_for_batch(
                        job=job, run_id=run_id, batch_rows=rows,
                        dict_matches=[], batch_id="batch-1",
                    )
                    assert result["successful"] == 2

    @pytest.mark.asyncio
    async def test_truncation_depth_exceeded(self, db_with_run):
        """Edge: recursion depth exceeded."""
        session, run_id = db_with_run
        svc = LLMTranslationService(session)
        job = _make_job(session, target_langs=["fr"])
        rows = _batch_rows(2)

        with patch.object(svc, '_call_llm_with_retry',
                          new=AsyncMock(return_value=('partial', "length", 1, None))):
            with patch.object(svc, '_try_recover_partial', return_value=None):
                result = await svc.call_llm_for_batch(
                    job=job, run_id=run_id, batch_rows=rows,
                    dict_matches=[], batch_id="batch-1",
                    _recursion_depth=MAX_RETRIES_PER_BATCH,
                )
                assert result["successful"] == 0


class TestCallLlmWithRetry:
    """Verify _call_llm_with_retry loop."""

    @pytest.mark.asyncio
    async def test_first_attempt_succeeds(self, db_session):
        svc = LLMTranslationService(db_session)
        with patch.object(svc, 'call_llm',
                          new=AsyncMock(return_value=("response", "stop"))):
            response, finish, retries, last_error = await svc._call_llm_with_retry(
                MagicMock(), "prompt", "batch-1", 8192,
            )
            assert response == "response"
            assert finish == "stop"
            assert retries == 0

    @pytest.mark.asyncio
    async def test_retry_then_succeeds(self, db_session):
        svc = LLMTranslationService(db_session)
        call_mock = AsyncMock(side_effect=[
            Exception("First fail"),
            ("response", "stop"),
        ])
        with patch.object(svc, 'call_llm', new=call_mock):
            response, finish, retries, last_error = await svc._call_llm_with_retry(
                MagicMock(), "prompt", "batch-1", 8192,
            )
            assert response == "response"
            assert retries == 1

    @pytest.mark.asyncio
    async def test_all_retries_exhausted(self, db_session):
        svc = LLMTranslationService(db_session)
        call_mock = AsyncMock(side_effect=Exception("Always fail"))
        with patch.object(svc, 'call_llm', new=call_mock):
            response, finish, retries, last_error = await svc._call_llm_with_retry(
                MagicMock(), "prompt", "batch-1", 8192,
            )
            assert response is None
            assert retries == MAX_RETRIES_PER_BATCH


class TestHandleLlmFailure:
    """Verify _handle_llm_failure."""

    def test_all_rows_marked_failed(self, db_with_run):
        session, run_id = db_with_run
        svc = LLMTranslationService(session)
        rows = _batch_rows(3)
        result = svc._handle_llm_failure(rows, run_id, "batch-1", 3, "Timeout")
        assert result["failed"] == 3
        assert result["successful"] == 0
        records = session.query(TranslationRecord).all()
        assert len(records) == 3
        assert all(r.status == "FAILED" for r in records)
        assert all("Timeout" in r.error_message for r in records)


class TestHandleParseFailure:
    """Verify _handle_parse_failure."""

    def test_all_rows_marked_skipped(self, db_with_run):
        session, run_id = db_with_run
        svc = LLMTranslationService(session)
        rows = _batch_rows(2)
        result = svc._handle_parse_failure(rows, run_id, "batch-1", 2, ValueError("Bad parse"))
        assert result["skipped"] == 2
        records = session.query(TranslationRecord).all()
        assert len(records) == 2
        assert all(r.status == "SKIPPED" for r in records)


class TestCreateRecordsFromTranslations:
    """Verify _create_records_from_translations."""

    def test_successful_creates_records(self, db_with_run):
        session, run_id = db_with_run
        svc = LLMTranslationService(session)
        rows = _batch_rows(1)
        translations = {"0": {"fr": "Bonjour", "translation": "Bonjour"}}
        result = svc._create_records_from_translations(
            rows, run_id, "batch-1", ["fr"], translations, [], 0,
        )
        assert result["successful"] == 1
        records = session.query(TranslationRecord).all()
        assert len(records) == 1
        assert records[0].status == "SUCCESS"
        langs = session.query(TranslationLanguage).all()
        assert len(langs) == 1

    def test_null_translation_skipped(self, db_with_run):
        session, run_id = db_with_run
        svc = LLMTranslationService(session)
        rows = _batch_rows(1)
        translations = {}
        result = svc._create_records_from_translations(
            rows, run_id, "batch-1", ["fr"], translations, [], 0,
        )
        assert result["skipped"] == 1

    def test_empty_plv_skipped(self, db_with_run):
        session, run_id = db_with_run
        svc = LLMTranslationService(session)
        rows = _batch_rows(1)
        translations = {"0": {"de": "Hallo"}}
        result = svc._create_records_from_translations(
            rows, run_id, "batch-1", ["fr"], translations, [], 0,
        )
        assert result["skipped"] == 1

    def test_with_dict_enforcement(self, db_with_run):
        session, run_id = db_with_run
        svc = LLMTranslationService(session)
        rows = _batch_rows(1)
        translations = {"0": {"fr": "Bonjour", "translation": "Bonjour"}}
        dict_matches = [{"entry_id": "e1", "source_term": "hello", "target_term": "hola"}]
        result = svc._create_records_from_translations(
            rows, run_id, "batch-1", ["fr"], translations, dict_matches, 0,
        )
        assert result["successful"] == 1


class TestExtractPerLangValues:
    """Verify _extract_per_lang_values."""

    def test_extract_first_target(self):
        td = {"fr": "Bonjour", "translation": "Hello"}
        result = LLMTranslationService._extract_per_lang_values(td, ["fr"])
        assert result == {"fr": "Bonjour"}

    def test_fallback_to_translation(self):
        td = {"translation": "Hello"}
        result = LLMTranslationService._extract_per_lang_values(td, ["fr"])
        assert result == {"fr": "Hello"}

    def test_empty_values(self):
        td = {"fr": "", "translation": ""}
        result = LLMTranslationService._extract_per_lang_values(td, ["fr"])
        assert result == {}

    def test_multi_language(self):
        td = {"fr": "Bonjour", "de": "Hallo"}
        result = LLMTranslationService._extract_per_lang_values(td, ["fr", "de"])
        assert result == {"fr": "Bonjour", "de": "Hallo"}


class TestAddSkipped:
    """Verify _add_skipped."""

    def test_add_skipped_record(self, db_with_run):
        session, run_id = db_with_run
        svc = LLMTranslationService(session)
        row = _batch_rows(1)[0]
        svc._add_skipped(row, run_id, "batch-1", "source text", "No translation found")
        records = session.query(TranslationRecord).all()
        assert len(records) == 1
        assert records[0].status == "SKIPPED"
        assert records[0].error_message == "No translation found"


class TestSplitAndRetry:
    """Verify _split_and_retry."""

    @pytest.mark.asyncio
    async def test_split_creates_child_batches(self, db_with_run):
        session, run_id = db_with_run
        svc = LLMTranslationService(session)
        job = _make_job(session, target_langs=["fr"])
        rows = _batch_rows(4)

        with patch.object(svc, 'call_llm_for_batch',
                          new=AsyncMock(return_value={"successful": 2, "failed": 0,
                                                       "skipped": 0, "retries": 0})):
            result = await svc._split_and_retry(
                job, run_id, rows, [], "orig-batch", 8192, 1, 0,
            )
            assert result["successful"] == 4
            # Fixture creates 1 batch, split creates 2 child batches = 3 total
            batches = session.query(TranslationBatch).all()
            assert len(batches) == 3


class TestTryRecoverPartial:
    """Verify _try_recover_partial."""

    def test_successful_recovery(self, db_with_run):
        session, run_id = db_with_run
        svc = LLMTranslationService(session)
        rows = _batch_rows(2)
        translations_response = json.dumps([
            {"row_id": "0", "fr": "Bonjour"},
            {"row_id": "1", "fr": "Monde"},
        ])

        with patch('src.plugins.translate._llm_call.parse_llm_response',
                   return_value={"0": {"fr": "Bonjour"}, "1": {"fr": "Monde"}}):
            recovered = svc._try_recover_partial(
                translations_response, rows, run_id, "batch-1", ["fr"],
            )
            assert recovered == {"0", "1"}
            records = session.query(TranslationRecord).all()
            assert len(records) == 2

    def test_no_rows_recovered(self, db_with_run):
        session, run_id = db_with_run
        svc = LLMTranslationService(session)
        rows = _batch_rows(2)

        with patch('src.plugins.translate._llm_call.parse_llm_response',
                   return_value={}):
            recovered = svc._try_recover_partial(
                "", rows, run_id, "batch-1", ["fr"],
            )
            assert recovered is None

    def test_parse_error_returns_none(self, db_with_run):
        session, run_id = db_with_run
        svc = LLMTranslationService(session)
        rows = _batch_rows(2)

        with patch('src.plugins.translate._llm_call.parse_llm_response',
                   side_effect=ValueError("Parse error")):
            recovered = svc._try_recover_partial(
                "invalid json", rows, run_id, "batch-1", ["fr"],
            )
            assert recovered is None


class TestRetryMissingRows:
    """Verify _retry_missing_rows."""

    @pytest.mark.asyncio
    async def test_empty_missing(self, db_session):
        svc = LLMTranslationService(db_session)
        result = await svc._retry_missing_rows(
            MagicMock(), "run-1", [], [], "batch-1", 8192, 1,
        )
        assert result["successful"] == 0

    @pytest.mark.asyncio
    async def test_retry_creates_sub_batch(self, db_with_run):
        session, run_id = db_with_run
        svc = LLMTranslationService(session)
        job = _make_job(session, target_langs=["fr"])
        rows = _batch_rows(1)

        with patch.object(svc, 'call_llm_for_batch',
                          new=AsyncMock(return_value={"successful": 0, "failed": 1,
                                                       "skipped": 0, "retries": 1})):
            result = await svc._retry_missing_rows(
                job, run_id, rows, [], "batch-1", 8192, 1,
            )
            assert result["failed"] == 1
            # Fixture creates 1 batch, retry creates 1 sub-batch = 2 total
            batches = session.query(TranslationBatch).all()
            assert len(batches) == 2
# #endregion Test.LLMTranslationService
