# region TranslationPreviewTests [TYPE Module]
# @SEMANTICS: test, translate, preview, session
# @PURPOSE: Test TranslationPreview service: preview, approve/edit/reject state transitions, cost estimation, acceptance gate.
# @LAYER Test
# @RELATION  BINDS_TO -> [TranslationPreview]

from datetime import UTC, datetime, timedelta
import json
import pytest
from unittest.mock import MagicMock, patch

from src.models.translate import (
    TranslationJob,
    TranslationPreviewRecord,
    TranslationPreviewSession,
)
from src.plugins.translate.preview import TokenEstimator, TranslationPreview
from src.plugins.translate.preview_executor import PreviewExecutor


# region _make_mock_job [TYPE Function]
# @PURPOSE: Create a mock TranslationJob with test config.
def _make_mock_job(**overrides):
    job = MagicMock(spec=TranslationJob)
    job.id = overrides.get("id", "job-123")
    job.name = overrides.get("name", "Test Job")
    job.source_dialect = overrides.get("source_dialect", "postgresql")
    job.target_dialect = overrides.get("target_dialect", "clickhouse")
    job.source_datasource_id = overrides.get("source_datasource_id", "42")
    job.source_table = overrides.get("source_table", "source_table")
    job.translation_column = overrides.get("translation_column", "title")
    job.context_columns = overrides.get("context_columns", ["category", "description"])
    job.target_languages = overrides.get("target_languages", ["ru"])
    job.provider_id = overrides.get("provider_id", "provider-1")
    job.batch_size = overrides.get("batch_size", 50)
    job.upsert_strategy = overrides.get("upsert_strategy", "MERGE")
    job.status = overrides.get("status", "DRAFT")
    job.target_column = None
    return job
# endregion _make_mock_job


# region _make_mock_provider [TYPE Function]
# @PURPOSE: Create a mock LLMProvider.
def _make_mock_provider(**overrides):
    provider = MagicMock()
    provider.id = overrides.get("id", "provider-1")
    provider.provider_type = overrides.get("provider_type", "openai")
    provider.base_url = overrides.get("base_url", "https://api.openai.com/v1")
    provider.default_model = overrides.get("default_model", "gpt-4o-mini")
    provider.api_key = "encrypted-key-123"
    return provider
# endregion _make_mock_provider


# region TestTranslationPreview [TYPE Class]
# @PURPOSE: Test suite for TranslationPreview service.
class TestTranslationPreview:

    # region test_preview_valid_job [TYPE Function]
    # @PURPOSE: Verify preview creates session and records successfully.
    def test_preview_valid_job(self):
        """Preview with valid job should create session and return records."""
        db = MagicMock()
        config_manager = MagicMock()

        # Mock job query
        job = _make_mock_job()
        db.query.return_value.filter.return_value.first.return_value = job

        # Mock environments
        env = MagicMock()
        env.id = "postgresql"
        env.url = "https://superset.example.com"
        env.username = "admin"
        env.password = "admin"
        env.verify_ssl = True
        env.timeout = 30
        config_manager.get_environments.return_value = [env]

        # Mock SupersetClient
        with patch("src.plugins.translate.preview_executor.SupersetClient") as MockClient:
            mock_client = MagicMock()
            MockClient.return_value = mock_client

            # Mock dataset_detail
            mock_client.get_dataset_detail.return_value = {
                "table_name": "test_table",
                "columns": [
                    {"column_name": "title", "type": "VARCHAR"},
                    {"column_name": "category", "type": "VARCHAR"},
                    {"column_name": "description", "type": "TEXT"},
                ],
            }

            # Mock build_dataset_preview_query_context
            mock_client.build_dataset_preview_query_context.return_value = {
                "datasource": {"id": 42, "type": "table"},
                "queries": [{
                    "columns": [],
                    "metrics": ["count"],
                    "row_limit": 10,
                }],
                "form_data": {},
                "result_format": "json",
                "result_type": "query",
                "force": True,
            }

            # Mock network request to Superset
            mock_client.network.request.return_value = {
                "result": [{
                    "status": "success",
                    "data": [
                        {"title": "Hello World", "category": "greeting", "description": "A friendly message"},
                        {"title": "Goodbye", "category": "farewell", "description": "A parting message"},
                    ],
                }]
            }

            # Mock LLM provider
            with patch("src.services.llm_provider.LLMProviderService") as MockProviderService:
                mock_provider_svc = MagicMock()
                MockProviderService.return_value = mock_provider_svc
                mock_provider = _make_mock_provider()
                mock_provider_svc.get_provider.return_value = mock_provider
                mock_provider_svc.get_decrypted_api_key.return_value = "sk-test-key"

                # Mock PreviewExecutor.call_llm to return structured JSON
                with patch.object(PreviewExecutor, "call_llm") as mock_llm:
                    mock_llm.return_value = json.dumps({
                        "rows": [
                            {"row_id": "0", "translation": "Привет мир"},
                            {"row_id": "1", "translation": "Прощай"},
                        ]
                    })

                    # Mock DictionaryManager.filter_for_batch
                    with patch("src.plugins.translate.preview_prompt_builder.DictionaryManager.filter_for_batch") as mock_filter:
                        mock_filter.return_value = []

                        preview_service = TranslationPreview(db, config_manager, "test-user")
                        result = preview_service.preview_rows(job_id="job-123", sample_size=10)

                        # Verify result structure
                        assert result["job_id"] == "job-123"
                        assert result["status"] == "ACTIVE"
                        assert len(result["records"]) == 2
                        assert result["records"][0]["target_sql"] == "Привет мир"
                        assert result["records"][1]["target_sql"] == "Прощай"

                        # Verify cost estimate
                        assert result["cost_estimate"]["sample_size"] == 2
                        assert result["cost_estimate"]["sample_total_tokens"] > 0
                        assert result["cost_estimate"]["sample_cost"] >= 0

                        # Verify config and dict hashes
                        assert result["config_hash"] is not None
                        assert result["dict_snapshot_hash"] is not None

                        # Verify session was added to DB
                        assert db.add.called
                        assert db.commit.called
    # endregion test_preview_valid_job

    # region test_preview_with_dictionary [TYPE Function]
    # @PURPOSE: Verify glossary entries from dictionary are included in LLM prompt.
    def test_preview_with_dictionary(self):
        """Preview should include dictionary glossary in the prompt sent to LLM."""
        db = MagicMock()
        config_manager = MagicMock()

        job = _make_mock_job()
        db.query.return_value.filter.return_value.first.return_value = job

        env = MagicMock()
        env.id = "postgresql"
        config_manager.get_environments.return_value = [env]

        with patch("src.plugins.translate.preview_executor.SupersetClient") as MockClient:
            mock_client = MagicMock()
            MockClient.return_value = mock_client
            mock_client.get_dataset_detail.return_value = {"table_name": "test", "columns": []}
            mock_client.build_dataset_preview_query_context.return_value = {
                "datasource": {"id": 42, "type": "table"},
                "queries": [{"columns": [], "metrics": ["count"], "row_limit": 10}],
                "form_data": {},
                "result_format": "json",
                "result_type": "query",
                "force": True,
            }
            mock_client.network.request.return_value = {
                "result": [{"status": "success", "data": [{"title": "Hello World", "category": "greeting"}]}]
            }

            with patch("src.services.llm_provider.LLMProviderService") as MockProviderService:
                mock_provider_svc = MagicMock()
                MockProviderService.return_value = mock_provider_svc
                mock_provider_svc.get_provider.return_value = _make_mock_provider()
                mock_provider_svc.get_decrypted_api_key.return_value = "sk-test-key"

                with patch.object(PreviewExecutor, "call_llm") as mock_llm:
                    mock_llm.return_value = json.dumps({"rows": [{"row_id": "0", "translation": "Привет мир"}]})

                    # Mock dictionary filter to return glossary entries
                    with patch("src.plugins.translate.preview_prompt_builder.DictionaryManager.filter_for_batch") as mock_filter:
                        mock_filter.return_value = [
                            {
                                "source_term": "Hello",
                                "target_term": "Здравствуйте",
                                "dictionary_id": "dict-1",
                                "dictionary_name": "Test Dict",
                                "context_notes": "Formal greeting",
                            }
                        ]

                        preview_service = TranslationPreview(db, config_manager, "test-user")
                        result = preview_service.preview_rows(job_id="job-123", sample_size=5)

                        # Verify LLM was called
                        mock_llm.assert_called_once()
                        assert len(result["records"]) == 1
    # endregion test_preview_with_dictionary

    # region test_preview_row_approve [TYPE Function]
    # @PURPOSE: Verify approving a preview row changes its status.
    def test_preview_row_approve(self):
        """Approve action should set record status to APPROVED."""
        db = MagicMock()
        config_manager = MagicMock()

        # Mock active session
        session = MagicMock(spec=TranslationPreviewSession)
        session.id = "session-1"
        session.job_id = "job-123"
        session.status = "ACTIVE"
        session.created_at = datetime.now(UTC)

        # Mock record
        record = MagicMock(spec=TranslationPreviewRecord)
        record.id = "record-1"
        record.session_id = "session-1"
        record.status = "PENDING"
        record.target_sql = "test translation"
        record.feedback = None

        # Setup: db.query().filter().order_by().first() -> session (session lookup)
        db.query.return_value.filter.return_value.order_by.return_value.first.return_value = session
        # Setup: db.query().filter().first() -> record (record lookup)
        db.query.return_value.filter.return_value.first.return_value = record

        preview_service = TranslationPreview(db, config_manager, "test-user")
        result = preview_service.update_preview_row(
            job_id="job-123",
            row_id="record-1",
            action="approve",
        )

        assert record.status == "APPROVED"
        assert result["status"] == "APPROVED"
        assert db.commit.called
    # endregion test_preview_row_approve

    # region test_preview_row_reject [TYPE Function]
    # @PURPOSE: Verify rejecting a preview row changes its status.
    def test_preview_row_reject(self):
        """Reject action should set record status to REJECTED."""
        db = MagicMock()
        config_manager = MagicMock()

        session = MagicMock(spec=TranslationPreviewSession)
        session.id = "session-1"
        session.job_id = "job-123"
        session.status = "ACTIVE"

        record = MagicMock(spec=TranslationPreviewRecord)
        record.id = "record-1"
        record.session_id = "session-1"
        record.status = "PENDING"
        record.target_sql = "test"
        record.feedback = None

        db.query.return_value.filter.return_value.order_by.return_value.first.return_value = session
        db.query.return_value.filter.return_value.first.return_value = record

        preview_service = TranslationPreview(db, config_manager, "test-user")
        result = preview_service.update_preview_row(
            job_id="job-123",
            row_id="record-1",
            action="reject",
        )

        assert record.status == "REJECTED"
        assert result["status"] == "REJECTED"
    # endregion test_preview_row_reject

    # region test_preview_row_edit [TYPE Function]
    # @PURPOSE: Verify editing a preview row updates its translation and status.
    def test_preview_row_edit(self):
        """Edit action should update translation and set status to APPROVED."""
        db = MagicMock()
        config_manager = MagicMock()

        session = MagicMock(spec=TranslationPreviewSession)
        session.id = "session-1"
        session.job_id = "job-123"
        session.status = "ACTIVE"

        record = MagicMock(spec=TranslationPreviewRecord)
        record.id = "record-1"
        record.session_id = "session-1"
        record.status = "PENDING"
        record.target_sql = "old translation"
        record.feedback = None

        db.query.return_value.filter.return_value.order_by.return_value.first.return_value = session
        db.query.return_value.filter.return_value.first.return_value = record

        preview_service = TranslationPreview(db, config_manager, "test-user")
        result = preview_service.update_preview_row(
            job_id="job-123",
            row_id="record-1",
            action="edit",
            translation="edited translation",
        )

        assert record.target_sql == "edited translation"
        assert record.status == "APPROVED"
        assert result["status"] == "APPROVED"
        assert result["target_sql"] == "edited translation"
    # endregion test_preview_row_edit

    # region test_preview_row_invalid_action [TYPE Function]
    # @PURPOSE: Verify invalid action raises ValueError.
    def test_preview_row_invalid_action(self):
        """Invalid action should raise ValueError."""
        db = MagicMock()
        config_manager = MagicMock()

        session = MagicMock(spec=TranslationPreviewSession)
        session.id = "session-1"
        session.job_id = "job-123"
        session.status = "ACTIVE"

        record = MagicMock(spec=TranslationPreviewRecord)
        record.id = "record-1"
        record.session_id = "session-1"
        record.status = "PENDING"

        db.query.return_value.filter.return_value.order_by.return_value.first.return_value = session
        db.query.return_value.filter.return_value.first.return_value = record

        preview_service = TranslationPreview(db, config_manager, "test-user")
        with pytest.raises(ValueError, match="Invalid action"):
            preview_service.update_preview_row(
                job_id="job-123",
                row_id="record-1",
                action="invalid_action",
            )
    # endregion test_preview_row_invalid_action

    # region test_preview_accept_session [TYPE Function]
    # @PURPOSE: Verify accepting a preview session gates execution.
    def test_preview_accept_session(self):
        """Accept should set session status to APPLIED and return records."""
        db = MagicMock()
        config_manager = MagicMock()

        session = MagicMock(spec=TranslationPreviewSession)
        session.id = "session-1"
        session.job_id = "job-123"
        session.status = "ACTIVE"
        session.created_by = "test-user"
        session.created_at = datetime.now(UTC)
        session.expires_at = datetime.now(UTC) + timedelta(hours=24)

        record = MagicMock(spec=TranslationPreviewRecord)
        record.id = "record-1"
        record.session_id = "session-1"
        record.source_sql = "Hello"
        record.target_sql = "Привет"
        record.status = "APPROVED"
        record.feedback = None

        # Session lookup
        db.query.return_value.filter.return_value.order_by.return_value.first.return_value = session
        # Records lookup
        db.query.return_value.filter.return_value.all.return_value = [record]

        preview_service = TranslationPreview(db, config_manager, "test-user")
        result = preview_service.accept_preview_session(job_id="job-123")

        assert session.status == "APPLIED"
        assert result["status"] == "APPLIED"
        assert len(result["records"]) == 1
        assert result["records"][0]["status"] == "APPROVED"
        assert db.commit.called
    # endregion test_preview_accept_session

    # region test_preview_no_active_session [TYPE Function]
    # @PURPOSE: Verify accept raises error when no active session.
    def test_preview_no_active_session(self):
        """Accept without active session should raise ValueError."""
        db = MagicMock()
        config_manager = MagicMock()

        db.query.return_value.filter.return_value.order_by.return_value.first.return_value = None

        preview_service = TranslationPreview(db, config_manager, "test-user")
        with pytest.raises(ValueError, match="No active preview session"):
            preview_service.accept_preview_session(job_id="job-123")
    # endregion test_preview_no_active_session

    # region test_cost_estimation [TYPE Function]
    # @PURPOSE: Verify token and cost estimation methods.
    def test_cost_estimation(self):
        """Token and cost estimation should return reasonable values."""
        prompt = "Translate this text from English to Russian. " * 100
        tokens = TokenEstimator.estimate_prompt_tokens(prompt)
        assert tokens > 0
        assert tokens < len(prompt)  # tokens typically fewer than chars

        # Single language
        output_tokens = TokenEstimator.estimate_output_tokens(10, num_languages=1)
        assert output_tokens == 1440  # 10 * 1 * 120 * 1.2

        # Multi-language
        output_tokens_multi = TokenEstimator.estimate_output_tokens(10, num_languages=3)
        assert output_tokens_multi == 4320  # 10 * 3 * 120 * 1.2

        cost = TokenEstimator.estimate_cost(1000, 0.002)
        assert cost == 0.002

        cost_default = TokenEstimator.estimate_cost(1000)
        assert cost_default == 0.002
    # endregion test_cost_estimation

    # region test_preview_parse_llm_response [TYPE Function]
    # @PURPOSE: Verify LLM JSON response parsing includes detected_source_language.
    def test_preview_parse_llm_response(self):
        """Parse LLM response should extract translations with detected_source_language per row."""
        response = json.dumps({
            "rows": [
                {"row_id": "0", "translation": "Привет", "detected_source_language": "en"},
                {"row_id": "1", "translation": "Мир"},
            ]
        })
        result = TranslationPreview._parse_llm_response(response, 2)
        assert result == {
            "0": {"translation": "Привет", "detected_source_language": "en"},
            "1": {"translation": "Мир", "detected_source_language": "und"},
        }
    # endregion test_preview_parse_llm_response

    # region test_preview_parse_llm_response_multilang [TYPE Function]
    # @PURPOSE: Verify multi-language LLM response parsing.
    def test_preview_parse_llm_response_multilang(self):
        """Parse multi-language LLM response should extract per-language translations."""
        response = json.dumps({
            "rows": [
                {"row_id": "0", "detected_source_language": "fr", "ru": "текст", "en": "text", "de": "Text"},
                {"row_id": "1", "detected_source_language": "en", "ru": "привет", "en": "hello"},
            ]
        })
        result = TranslationPreview._parse_llm_response(response, 2, target_languages=["ru", "en", "de"])
        assert result == {
            "0": {"detected_source_language": "fr", "ru": "текст", "en": "text", "de": "Text"},
            "1": {"detected_source_language": "en", "ru": "привет", "en": "hello"},
        }
        # Verify per-language extraction
        assert result["0"]["ru"] == "текст"
        assert result["0"]["en"] == "text"
        assert result["1"]["ru"] == "привет"
    # endregion test_preview_parse_llm_response_multilang

    # region test_preview_parse_llm_response_with_code_block [TYPE Function]
    # @PURPOSE: Verify LLM response parsing handles markdown code blocks.
    def test_preview_parse_llm_response_with_code_block(self):
        """Parse LLM response should handle markdown code block wrapping."""
        response = "```json\n{\n    \"rows\": [\n        {\"row_id\": \"0\", \"translation\": \"Test\"}\n    ]\n}\n```"
        result = TranslationPreview._parse_llm_response(response, 1)
        assert result == {"0": {"translation": "Test", "detected_source_language": "und"}}
    # endregion test_preview_parse_llm_response_with_code_block

    # region test_cost_warning [TYPE Function]
    # @PURPOSE: Verify cost warning for large sample sizes.
    def test_cost_warning(self):
        """Cost warning should appear for sample_size > 30."""
        warning = TokenEstimator.check_cost_warning(31, 2, 5000, 0.01)
        assert warning is not None
        assert "Large preview" in warning
        assert "5000" in warning

        no_warning = TokenEstimator.check_cost_warning(10, 2, 1000, 0.002)
        assert no_warning is None
    # endregion test_cost_warning

    # region test_preview_compute_config_hash [TYPE Function]
    # @PURPOSE: Verify config hash computation is deterministic.
    def test_preview_compute_config_hash(self):
        """Config hash should be deterministic and non-empty."""
        job = _make_mock_job()
        hash1 = TranslationPreview._compute_config_hash(job)
        hash2 = TranslationPreview._compute_config_hash(job)
        assert hash1 == hash2
        assert len(hash1) == 16
    # endregion test_preview_compute_config_hash

    # region test_preview_missing_datasource [TYPE Function]
    # @PURPOSE: Verify error when job has no datasource configured.
    def test_preview_missing_datasource(self):
        """Preview should fail if job has no datasource."""
        db = MagicMock()
        config_manager = MagicMock()

        job = _make_mock_job(source_datasource_id=None)
        db.query.return_value.filter.return_value.first.return_value = job

        preview_service = TranslationPreview(db, config_manager, "test-user")
        with pytest.raises(ValueError, match="source datasource"):
            preview_service.preview_rows(job_id="job-123")
    # endregion test_preview_missing_datasource

    # region test_preview_missing_translation_column [TYPE Function]
    # @PURPOSE: Verify error when job has no translation column.
    def test_preview_missing_translation_column(self):
        """Preview should fail if job has no translation column."""
        db = MagicMock()
        config_manager = MagicMock()

        job = _make_mock_job(translation_column=None)
        db.query.return_value.filter.return_value.first.return_value = job

        preview_service = TranslationPreview(db, config_manager, "test-user")
        with pytest.raises(ValueError, match="translation column"):
            preview_service.preview_rows(job_id="job-123")
    # endregion test_preview_missing_translation_column


# region TestAutoDetection [TYPE Class]
# @PURPOSE: Tests for auto-detected source language in preview/execution (T090).
class TestAutoDetection:

    # region test_detect_known_language [TYPE Function]
    # @BRIEF LLM response with known source language is correctly parsed.
    def test_detect_known_language(self):
        """Known source languages (en, fr, de, es) should be detected correctly."""
        test_cases = [
            ("en", "Hello world"),
            ("fr", "Bonjour le monde"),
            ("de", "Hallo Welt"),
            ("es", "Hola mundo"),
        ]
        for lang, text in test_cases:
            response = json.dumps({
                "rows": [{"row_id": "0", "translation": text, "detected_source_language": lang}]
            })
            result = TranslationPreview._parse_llm_response(response, 1)
            assert result["0"]["detected_source_language"] == lang, f"Expected {lang} for '{text}'"
    # endregion test_detect_known_language

    # region test_detect_und_for_mixed_content [TYPE Function]
    # @BRIEF Mixed/ambiguous content returns "und".
    def test_detect_und_for_mixed_content(self):
        """When LLM cannot determine language, 'und' is returned."""
        response = json.dumps({
            "rows": [{"row_id": "0", "translation": "...", "detected_source_language": "und"}]
        })
        result = TranslationPreview._parse_llm_response(response, 1)
        assert result["0"]["detected_source_language"] == "und"
    # endregion test_detect_und_for_mixed_content

    # region test_detect_und_flag_for_review [TYPE Function]
    # @BRIEF "und" rows are flagged for manual review.
    def test_detect_und_flag_for_review(self):
        """Rows with und should be flagged for manual review."""
        response = json.dumps({
            "rows": [
                {"row_id": "0", "translation": "text", "detected_source_language": "en"},
                {"row_id": "1", "translation": "---", "detected_source_language": "und"},
            ]
        })
        result = TranslationPreview._parse_llm_response(response, 2)
        assert result["0"]["detected_source_language"] == "en"
        assert result["1"]["detected_source_language"] == "und"
    # endregion test_detect_und_flag_for_review

    # region test_detect_per_row_independence [TYPE Function]
    # @BRIEF Each row's detected language is independent of other rows.
    def test_detect_per_row_independence(self):
        """Rows in same batch with different languages carry independent detections."""
        response = json.dumps({
            "rows": [
                {"row_id": "0", "detected_source_language": "fr", "ru": "французский", "en": "french"},
                {"row_id": "1", "detected_source_language": "de", "ru": "немецкий", "en": "german"},
            ]
        })
        result = TranslationPreview._parse_llm_response(response, 2, target_languages=["ru", "en"])
        assert result["0"]["detected_source_language"] == "fr"
        assert result["1"]["detected_source_language"] == "de"
        assert result["0"]["ru"] == "французский"
        assert result["1"]["en"] == "german"
    # endregion test_detect_per_row_independence

# endregion TestAutoDetection

# endregion TestTranslationPreview

# endregion TranslationPreviewTests
