# #region Test.TranslationPreview [C:3] [TYPE Module] [SEMANTICS test,translate,preview]
# @BRIEF Tests for preview.py — TranslationPreview class.
# @RELATION BINDS_TO -> [TranslationPreview]
# @TEST_EDGE: job_not_found -> ValueError
# @TEST_EDGE: no_source_datasource -> ValueError
# @TEST_EDGE: no_translation_column -> ValueError
# @TEST_EDGE: no_target_languages -> ValueError
# @TEST_EDGE: no_provider -> ValueError
# @TEST_EDGE: no_rows_returned -> ValueError

import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))

import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from datetime import UTC, datetime

from src.models.translate import TranslationJob, TranslationPreviewRecord, TranslationPreviewSession
from src.plugins.translate.preview import TranslationPreview
from .conftest import JOB_ID


class TestPreviewRows:
    """TranslationPreview.preview_rows — main preview function."""

    def _make_job(self, db_session):
        """Ensure the job has all required fields."""
        job = db_session.query(TranslationJob).filter(TranslationJob.id == JOB_ID).first()
        job.source_datasource_id = "1"
        job.translation_column = "text"
        job.target_languages = ["fr"]
        job.provider_id = "prov-1"
        job.target_dialect = "en"
        job.context_columns = None
        job.disable_reasoning = False
        db_session.commit()
        return job

    @pytest.mark.asyncio
    async def test_job_not_found(self, db_session):
        """Non-existent job_id raises ValueError."""
        preview = TranslationPreview(db_session, MagicMock())
        with pytest.raises(ValueError, match="not found"):
            await preview.preview_rows("nonexistent")

    @pytest.mark.asyncio
    async def test_no_source_datasource(self, db_session):
        """Job without source_datasource_id raises ValueError."""
        preview = TranslationPreview(db_session, MagicMock())
        job = db_session.query(TranslationJob).filter(TranslationJob.id == JOB_ID).first()
        job.source_datasource_id = None
        job.translation_column = "text"
        job.target_languages = ["fr"]
        job.provider_id = "prov-1"
        db_session.commit()
        with pytest.raises(ValueError, match="source datasource"):
            await preview.preview_rows(JOB_ID)

    @pytest.mark.asyncio
    async def test_no_translation_column(self, db_session):
        """Job without translation_column raises ValueError."""
        preview = TranslationPreview(db_session, MagicMock())
        job = db_session.query(TranslationJob).filter(TranslationJob.id == JOB_ID).first()
        job.source_datasource_id = "1"
        job.translation_column = None
        job.target_languages = ["fr"]
        job.provider_id = "prov-1"
        db_session.commit()
        with pytest.raises(ValueError, match="translation column"):
            await preview.preview_rows(JOB_ID)

    @pytest.mark.asyncio
    async def test_no_target_languages(self, db_session):
        """Job without target_languages raises ValueError."""
        preview = TranslationPreview(db_session, MagicMock())
        job = db_session.query(TranslationJob).filter(TranslationJob.id == JOB_ID).first()
        job.source_datasource_id = "1"
        job.translation_column = "text"
        job.target_languages = None
        job.provider_id = "prov-1"
        db_session.commit()
        with pytest.raises(ValueError, match="target language"):
            await preview.preview_rows(JOB_ID)

    @pytest.mark.asyncio
    async def test_no_provider(self, db_session):
        """Job without provider_id raises ValueError."""
        preview = TranslationPreview(db_session, MagicMock())
        job = db_session.query(TranslationJob).filter(TranslationJob.id == JOB_ID).first()
        job.source_datasource_id = "1"
        job.translation_column = "text"
        job.target_languages = ["fr"]
        job.provider_id = None
        db_session.commit()
        with pytest.raises(ValueError, match="LLM provider"):
            await preview.preview_rows(JOB_ID)

    @pytest.mark.asyncio
    async def test_no_rows_returned(self, db_session):
        """When fetch_sample_rows returns empty list."""
        preview = TranslationPreview(db_session, MagicMock())
        self._make_job(db_session)

        with patch.object(preview._executor, "compute_config_hash", return_value="hash1"):
            with patch.object(preview._executor, "compute_dict_snapshot_hash", return_value="hash2"):
                with patch.object(preview._executor, "fetch_sample_rows", AsyncMock(return_value=[])):
                    with pytest.raises(ValueError, match="No rows returned"):
                        await preview.preview_rows(JOB_ID)

    @pytest.mark.asyncio
    async def test_target_languages_not_list(self, db_session):
        """Non-list target_languages is converted."""
        preview = TranslationPreview(db_session, MagicMock())
        job = db_session.query(TranslationJob).filter(TranslationJob.id == JOB_ID).first()
        job.source_datasource_id = "1"
        job.translation_column = "text"
        job.target_languages = "fr"  # not a list
        job.provider_id = "prov-1"
        db_session.commit()

        with patch.object(preview._executor, "compute_config_hash", return_value="hash1"):
            with patch.object(preview._executor, "compute_dict_snapshot_hash", return_value="hash2"):
                with patch.object(preview._executor, "fetch_sample_rows",
                                   AsyncMock(return_value=[{"text": "hello"}])):
                    with patch.object(preview._executor, "resolve_provider_model",
                                       return_value="gpt-4o"):
                        with patch.object(preview._prompt_builder,
                                           "estimate_token_budget_for_rows") as mock_budget:
                            mock_budget.return_value = {
                                "source_rows": [{"text": "hello"}],
                                "actual_row_count": 1,
                                "token_budget": {
                                    "warning": None,
                                    "max_output_needed": 4096,
                                    "batch_size_adjusted": 1,
                                    "estimated_input_tokens": 100,
                                    "estimated_output_tokens": 200,
                                },
                            }
                            with patch.object(preview._prompt_builder,
                                               "build_prompt_from_rows") as mock_prompt:
                                mock_prompt.return_value = {
                                    "prompt": "translate",
                                    "row_meta": [{"row_index": 0, "source_text": "hello",
                                                   "source_row": {"text": "hello"}}],
                                    "actual_row_count": 1,
                                    "num_languages": 1,
                                    "sample_prompt_tokens": 10,
                                    "sample_output_tokens": 20,
                                    "sample_total_tokens": 30,
                                    "sample_cost": 0.001,
                                    "total_est_rows": 100,
                                    "total_est_tokens": 3000,
                                    "total_est_cost": 0.01,
                                    "cost_warning": None,
                                }
                                with patch.object(preview._executor, "call_llm",
                                                   AsyncMock(return_value="{}")):
                                    with patch.object(preview._executor,
                                                       "parse_llm_response",
                                                       return_value={}):
                                        result = await preview.preview_rows(JOB_ID)
                                        assert result["status"] == "ACTIVE"
                                        assert result["job_id"] == JOB_ID

    @pytest.mark.asyncio
    async def test_token_budget_warning_logged(self, db_session):
        """Token budget warning is logged but does not fail."""
        preview = TranslationPreview(db_session, MagicMock())
        self._make_job(db_session)

        with patch.object(preview._executor, "compute_config_hash", return_value="hash1"):
            with patch.object(preview._executor, "compute_dict_snapshot_hash", return_value="hash2"):
                with patch.object(preview._executor, "fetch_sample_rows",
                                   AsyncMock(return_value=[{"text": "hello"}])):
                    with patch.object(preview._executor, "resolve_provider_model",
                                       return_value="gpt-4o"):
                        with patch.object(preview._prompt_builder,
                                               "estimate_token_budget_for_rows") as mock_budget:
                            mock_budget.return_value = {
                                "source_rows": [{"text": "hello"}],
                                "actual_row_count": 1,
                                "token_budget": {
                                    "warning": "budget exceeded",
                                    "max_output_needed": 4096,
                                    "batch_size_adjusted": 1,
                                    "estimated_input_tokens": 100,
                                    "estimated_output_tokens": 200,
                                },
                            }
                            with patch.object(preview._prompt_builder,
                                               "build_prompt_from_rows") as mock_prompt:
                                mock_prompt.return_value = {
                                    "prompt": "translate",
                                    "row_meta": [{"row_index": 0, "source_text": "hello",
                                                   "source_row": {"text": "hello"}}],
                                    "actual_row_count": 1,
                                    "num_languages": 1,
                                    "sample_prompt_tokens": 10,
                                    "sample_output_tokens": 20,
                                    "sample_total_tokens": 30,
                                    "sample_cost": 0.001,
                                    "total_est_rows": 100,
                                    "total_est_tokens": 3000,
                                    "total_est_cost": 0.01,
                                    "cost_warning": None,
                                }
                                with patch.object(preview._executor, "call_llm",
                                                   AsyncMock(return_value="{}")):
                                    with patch.object(preview._executor,
                                                       "parse_llm_response",
                                                       return_value={}):
                                        result = await preview.preview_rows(JOB_ID)
                                        assert result["status"] == "ACTIVE"


class TestCreatePreviewRecords:
    """TranslationPreview._create_preview_records — internal method."""

    def _make_preview(self, db_session):
        return TranslationPreview(db_session, MagicMock())

    def test_basic_records(self, db_session):
        """Creates records with languages."""
        preview = self._make_preview(db_session)
        job = MagicMock(spec=TranslationJob)
        job.target_key_cols = None
        job.target_language_column = "lang"

        row_meta = [
            {"row_index": 0, "source_text": "hello", "source_row": {}, "_detected_lang": "en"},
            {"row_index": 1, "source_text": "world", "source_row": {}, "_detected_lang": "en"},
        ]
        translations = {
            "0": {"fr": "bonjour"},
            "1": {"fr": "monde"},
        }
        target_languages = ["fr"]
        session = TranslationPreviewSession(
            id="session-1", job_id=JOB_ID, status="ACTIVE",
        )
        db_session.add(session)
        db_session.flush()

        records = preview._create_preview_records(job, row_meta, translations, target_languages, session)
        assert len(records) == 2
        assert records[0]["source_sql"] == "hello"
        assert records[1]["source_sql"] == "world"
        assert len(records[0]["languages"]) == 1
        assert records[0]["languages"][0]["translated_value"] == "bonjour"

    def test_translation_data_as_string(self, db_session):
        """When translation_data is a string (not dict)."""
        preview = self._make_preview(db_session)
        job = MagicMock(spec=TranslationJob)
        job.target_key_cols = None
        job.target_language_column = "lang"

        row_meta = [
            {"row_index": 0, "source_text": "hello", "source_row": {}, "_detected_lang": "und"},
        ]
        translations = {
            "0": "just a string",  # not a dict
        }
        target_languages = ["fr"]
        session = TranslationPreviewSession(
            id="session-2", job_id=JOB_ID, status="ACTIVE",
        )
        db_session.add(session)
        db_session.flush()

        records = preview._create_preview_records(job, row_meta, translations, target_languages, session)
        assert len(records) == 1
        assert records[0]["languages"][0]["translated_value"] == "just a string"

    def test_detected_lang_fallback_from_translation(self, db_session):
        """Fallback to translation_data detected_source_language when _detected_lang is 'und'."""
        preview = self._make_preview(db_session)
        job = MagicMock(spec=TranslationJob)
        job.target_key_cols = None
        job.target_language_column = "lang"

        row_meta = [
            {"row_index": 0, "source_text": "hello", "source_row": {}, "_detected_lang": None},
        ]
        translations = {
            "0": {"detected_source_language": "en", "fr": "bonjour"},
        }
        target_languages = ["fr"]
        session = TranslationPreviewSession(
            id="session-3", job_id=JOB_ID, status="ACTIVE",
        )
        db_session.add(session)
        db_session.flush()

        records = preview._create_preview_records(job, row_meta, translations, target_languages, session)
        assert records[0]["source_language_detected"] == "en"

    def test_source_data_with_key_cols(self, db_session):
        """When job has target_key_cols, source_data is filtered."""
        preview = self._make_preview(db_session)
        job = MagicMock(spec=TranslationJob)
        job.target_key_cols = ["id"]

        row_meta = [
            {"row_index": 0, "source_text": "hello",
             "source_row": {"id": 1, "name": "test", "extra": "stuff"},
             "_detected_lang": "en"},
        ]
        translations = {"0": {"fr": "bonjour"}}
        target_languages = ["fr"]
        session = TranslationPreviewSession(
            id="session-4", job_id=JOB_ID, status="ACTIVE",
        )
        db_session.add(session)
        db_session.flush()

        records = preview._create_preview_records(job, row_meta, translations, target_languages, session)
        # source_data stored in DB record, not in returned dict — query DB
        record = db_session.query(TranslationPreviewRecord).filter_by(id=records[0]["id"]).first()
        assert record is not None
        assert record.source_data == {"id": 1}

    def test_detected_lang_matches_source(self, db_session):
        """When detected_lang matches lang_code, use source_text as translation."""
        preview = self._make_preview(db_session)
        job = MagicMock(spec=TranslationJob)
        job.target_key_cols = None
        job.target_language_column = "lang"

        row_meta = [
            {"row_index": 0, "source_text": "hello", "source_row": {}, "_detected_lang": "fr"},
        ]
        translations = {
            "0": {"fr": "should be overwritten"},
        }
        target_languages = ["fr"]
        session = TranslationPreviewSession(
            id="session-5", job_id=JOB_ID, status="ACTIVE",
        )
        db_session.add(session)
        db_session.flush()

        records = preview._create_preview_records(job, row_meta, translations, target_languages, session)
        assert records[0]["languages"][0]["translated_value"] == "hello"

    def test_no_lang_translation_fallback_empty(self, db_session):
        """When translation is None, use empty string."""
        preview = self._make_preview(db_session)
        job = MagicMock(spec=TranslationJob)
        job.target_key_cols = None
        job.target_language_column = "lang"

        row_meta = [
            {"row_index": 0, "source_text": "hello", "source_row": {}, "_detected_lang": "en"},
        ]
        translations = {
            "0": {"fr": None},
        }
        target_languages = ["fr"]
        session = TranslationPreviewSession(
            id="session-6", job_id=JOB_ID, status="ACTIVE",
        )
        db_session.add(session)
        db_session.flush()

        records = preview._create_preview_records(job, row_meta, translations, target_languages, session)
        assert records[0]["languages"][0]["translated_value"] == ""

    def test_needs_review_when_undetected(self, db_session):
        """needs_review is True when detected_lang is 'und'."""
        preview = self._make_preview(db_session)
        job = MagicMock(spec=TranslationJob)
        job.target_key_cols = None
        job.target_language_column = "lang"

        row_meta = [
            {"row_index": 0, "source_text": "hello", "source_row": {}, "_detected_lang": "und"},
        ]
        translations = {"0": {"fr": "bonjour"}}
        target_languages = ["fr"]
        session = TranslationPreviewSession(
            id="session-7", job_id=JOB_ID, status="ACTIVE",
        )
        db_session.add(session)
        db_session.flush()

        records = preview._create_preview_records(job, row_meta, translations, target_languages, session)
        assert records[0]["needs_review"] is True


class TestStaticMethods:
    """Backward-compatible static methods."""

    def test_parse_llm_response(self):
        """Delegates to preview_response_parser."""
        with patch("src.plugins.translate.preview._parse_llm_response_module") as mock:
            mock.return_value = {"0": {"fr": "bonjour"}}
            result = TranslationPreview._parse_llm_response("text", 1, ["fr"])
            assert result == {"0": {"fr": "bonjour"}}

    def test_compute_config_hash(self):
        """Delegates to preview_response_parser."""
        with patch("src.plugins.translate.preview._compute_config_hash_module") as mock:
            mock.return_value = "hash123"
            job = MagicMock()
            result = TranslationPreview._compute_config_hash(job)
            assert result == "hash123"


class TestGetPreviewSession:
    """Delegated get_preview_session."""

    def test_get_preview_session(self, db_session):
        """Delegates to PreviewSessionManager."""
        preview = TranslationPreview(db_session, MagicMock())
        with patch.object(preview._session_mgr, "get_preview_session") as mock:
            mock.return_value = {"id": "sess-1"}
            result = preview.get_preview_session(JOB_ID)
            assert result == {"id": "sess-1"}
# #endregion Test.TranslationPreview
