# #region Test.BatchInsert [C:3] [TYPE Module] [SEMANTICS test,translate,batch,insert]
# @BRIEF Tests for _batch_insert.py — batch insert logic.
# @RELATION BINDS_TO -> [BatchInsertService]
# @TEST_EDGE: empty_batch -> Returns early
# @TEST_EDGE: no_columns_fallback -> Uses effective_target as single column
# @TEST_EDGE: backend_resolve_failure -> Returns early
# @TEST_EDGE: sql_generation_failure -> Returns early

import json
import sys
from datetime import UTC, datetime
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 src.models.translate import (
    TranslationBatch,
    TranslationJob,
    TranslationRecord,
    TranslationLanguage,
    TranslationRun,
)
from src.plugins.translate._batch_insert import (
    _build_target_columns,
    _build_context_keys,
    _build_insert_rows,
    _generate_insert_sql,
    _execute_insert_sql,
    _resolve_insert_backend,
    _fetch_batch_records,
    insert_batch_to_target,
)
from .conftest import JOB_ID


class TestBuildTargetColumns:
    """_build_target_columns — build INSERT column list."""

    def test_basic_columns(self):
        """Basic columns with key_cols and effective_target."""
        job = MagicMock(spec=TranslationJob)
        job.target_key_cols = ["id"]
        job.target_language_column = "lang"
        job.target_source_column = "source"
        job.target_source_language_column = "src_lang"
        cols = _build_target_columns(job, "translated_text")
        assert "id" in cols
        assert "translated_text" in cols
        assert "lang" in cols
        assert "source" in cols
        assert "src_lang" in cols
        assert "context" in cols
        assert "is_original" in cols

    def test_deduplication(self):
        """Duplicate columns are removed."""
        job = MagicMock(spec=TranslationJob)
        job.target_key_cols = ["id", "id"]
        job.target_language_column = None
        job.target_source_column = None
        job.target_source_language_column = None
        cols = _build_target_columns(job, "id")
        # "id" appears only once despite being in key_cols and effective_target
        assert cols.count("id") == 1
        assert "context" in cols
        assert "is_original" in cols

    def test_empty_key_cols(self):
        """No key cols, no extra columns."""
        job = MagicMock(spec=TranslationJob)
        job.target_key_cols = None
        job.target_language_column = None
        job.target_source_column = None
        job.target_source_language_column = None
        cols = _build_target_columns(job, "text")
        assert cols == ["text", "context", "is_original"]

    def test_none_filtered(self):
        """None values in columns are filtered out."""
        job = MagicMock(spec=TranslationJob)
        job.target_key_cols = []
        job.target_language_column = None
        job.target_source_column = None
        job.target_source_language_column = None
        cols = _build_target_columns(job, None)
        assert "context" in cols
        assert "is_original" in cols
        assert None not in cols


class TestBuildContextKeys:
    """_build_context_keys — build context keys list."""

    def test_basic_context_keys(self):
        """Context from job.context_columns."""
        job = MagicMock(spec=TranslationJob)
        job.context_columns = ["col1", "col2"]
        job.translation_column = "translated_text"
        keys = _build_context_keys(job, "translated_text")
        assert keys == ["col1", "col2"]

    def test_translation_column_appended(self):
        """translation_column added when different from effective_target."""
        job = MagicMock(spec=TranslationJob)
        job.context_columns = ["col1"]
        job.translation_column = "orig_text"
        keys = _build_context_keys(job, "translated_text")
        assert "col1" in keys
        assert "orig_text" in keys

    def test_no_context_columns(self):
        """None context_columns returns empty list."""
        job = MagicMock(spec=TranslationJob)
        job.context_columns = None
        job.translation_column = "text"
        keys = _build_context_keys(job, "text")
        assert keys == []

    def test_no_duplicate_context(self):
        """translation_column not added if already in context_columns."""
        job = MagicMock(spec=TranslationJob)
        job.context_columns = ["text"]
        job.translation_column = "text"
        keys = _build_context_keys(job, "translated")
        # "text" already in context_columns, effective_target is different
        assert keys == ["text"]


class TestBuildInsertRows:
    """_build_insert_rows — build INSERT row dicts."""

    def _make_record(self, source_sql="hello", target_sql="bonjour",
                     source_data=None, languages=None, status="SUCCESS"):
        rec = MagicMock(spec=TranslationRecord)
        rec.source_sql = source_sql
        rec.target_sql = target_sql
        rec.source_data = source_data or {}
        rec.languages = languages or []
        rec.status = status
        return rec

    def test_basic_rows(self):
        """Basic case: record with languages."""
        lang = MagicMock(spec=TranslationLanguage)
        lang.language_code = "fr"
        lang.final_value = "bonjour"
        lang.translated_value = "bonjour"
        lang.source_language_detected = "en"

        rec = self._make_record(
            source_sql="hello", target_sql="bonjour",
            source_data={"id": 1}, languages=[lang],
        )
        job = MagicMock(spec=TranslationJob)
        job.target_key_cols = ["id"]
        job.target_source_column = "source"
        job.target_source_language_column = "src_lang"
        job.target_language_column = "lang"
        job.context_columns = []

        rows = _build_insert_rows([rec], job, "translated_text", "en", [])
        # Should have 2 rows: original + translation
        assert len(rows) == 2
        original = rows[0]
        translation = rows[1]
        assert original["is_original"] == 1
        assert translation["is_original"] == 0
        assert translation["lang"] == "fr"
        assert translation["translated_text"] == "bonjour"
        assert original["translated_text"] == "hello"

    def test_no_languages_fallback(self):
        """Record with no languages -> fallback row with primary_language."""
        rec = self._make_record(
            source_sql="hello", target_sql="bonjour",
            source_data={"id": 1}, languages=[],
        )
        job = MagicMock(spec=TranslationJob)
        job.target_key_cols = ["id"]
        job.target_source_column = None
        job.target_source_language_column = None
        job.target_language_column = "lang"
        job.context_columns = []

        rows = _build_insert_rows([rec], job, "translated_text", "fr", [])
        assert len(rows) == 2
        assert rows[1]["lang"] == "fr"
        assert rows[1]["is_original"] == 0
        assert rows[1]["translated_text"] == "bonjour"

    def test_skip_same_language(self):
        """Skip translation row with same language as detected source."""
        lang = MagicMock(spec=TranslationLanguage)
        lang.language_code = "en"
        lang.final_value = "hello"
        lang.translated_value = "hello"
        lang.source_language_detected = "en"

        rec = self._make_record(
            source_sql="hello", target_sql="bonjour",
            source_data={"id": 1}, languages=[lang],
        )
        job = MagicMock(spec=TranslationJob)
        job.target_key_cols = []
        job.target_source_column = None
        job.target_source_language_column = None
        job.target_language_column = None
        job.context_columns = []

        rows = _build_insert_rows([rec], job, "translated_text", "en", [])
        # Only the original row (fr translation has same code as detected "en")
        # Wait: lang.language_code == "en" but detected_src_lang from rec.languages[0].source_language_detected = "en"
        # So the skip condition fires and only original is kept
        assert len(rows) == 1

    def test_no_source_language_detected(self):
        """When rec.languages has source_language_detected missing."""
        lang = MagicMock(spec=TranslationLanguage)
        lang.language_code = "fr"
        lang.final_value = "bonjour"
        lang.translated_value = "bonjour"
        lang.source_language_detected = None

        rec = self._make_record(
            source_sql="hello", target_sql="bonjour",
            source_data={}, languages=[lang],
        )
        job = MagicMock(spec=TranslationJob)
        job.target_key_cols = []
        job.target_source_column = None
        job.target_source_language_column = None
        job.target_language_column = None
        job.context_columns = []

        rows = _build_insert_rows([rec], job, "translated_text", "en", [])
        assert len(rows) == 2

    def test_context_data_included(self):
        """Context keys are collected."""
        lang = MagicMock(spec=TranslationLanguage)
        lang.language_code = "fr"
        lang.final_value = "bonjour"
        lang.translated_value = "bonjour"
        lang.source_language_detected = "en"

        rec = self._make_record(
            source_sql="hello", target_sql="bonjour",
            source_data={"id": 1, "desc": "test"}, languages=[lang],
        )
        job = MagicMock(spec=TranslationJob)
        job.target_key_cols = ["id"]
        job.target_source_column = None
        job.target_source_language_column = None
        job.target_language_column = None
        job.context_columns = []

        rows = _build_insert_rows([rec], job, "translated_text", "en", [])
        context = json.loads(rows[0]["context"])
        assert context == {}  # no context_keys

    def test_with_context_keys(self):
        """Context keys are included in context JSON."""
        lang = MagicMock(spec=TranslationLanguage)
        lang.language_code = "fr"
        lang.final_value = "bonjour"
        lang.translated_value = "bonjour"
        lang.source_language_detected = "en"

        rec = self._make_record(
            source_sql="hello", target_sql="bonjour",
            source_data={"id": 1, "desc": "test"}, languages=[lang],
        )
        job = MagicMock(spec=TranslationJob)
        job.target_key_cols = ["id"]
        job.target_source_column = None
        job.target_source_language_column = None
        job.target_language_column = None
        job.context_columns = []

        rows = _build_insert_rows([rec], job, "translated_text", "en", ["desc"])
        context = json.loads(rows[0]["context"])
        assert context.get("desc") == "test"


class TestFetchBatchRecords:
    """_fetch_batch_records — DB query."""

    def _create_run_and_batch(self, db_session):
        """Helper to create a TranslationRun and TranslationBatch for FK compliance."""
        from src.models.translate import TranslationRun, TranslationBatch
        from datetime import datetime, UTC
        run = TranslationRun(id="test-run-id", job_id=JOB_ID, status="RUNNING",
                             started_at=datetime.now(UTC))
        db_session.add(run)
        db_session.flush()
        batch = TranslationBatch(id="batch-1", run_id="test-run-id",
                                 batch_index=0, status="PENDING")
        db_session.add(batch)
        db_session.commit()

    def test_returns_records(self, db_session):
        """Returns records with SUCCESS status and target_sql."""
        self._create_run_and_batch(db_session)
        from src.models.translate import TranslationRecord, TranslationLanguage
        rec = TranslationRecord(
            id="rec-1", batch_id="batch-1", run_id="test-run-id",
            source_sql="hello", target_sql="bonjour", status="SUCCESS",
        )
        db_session.add(rec)
        db_session.commit()

        results = _fetch_batch_records(db_session, "batch-1")
        assert len(results) == 1
        assert results[0].id == "rec-1"

    def test_skips_non_success(self, db_session):
        """Records without SUCCESS status are excluded."""
        self._create_run_and_batch(db_session)
        from src.models.translate import TranslationRecord
        rec = TranslationRecord(
            id="rec-2", batch_id="batch-1", run_id="test-run-id",
            source_sql="hello", target_sql="bonjour", status="FAILED",
        )
        db_session.add(rec)
        db_session.commit()

        results = _fetch_batch_records(db_session, "batch-1")
        assert len(results) == 0

    def test_skips_no_target_sql(self, db_session):
        """Records with null target_sql are excluded."""
        self._create_run_and_batch(db_session)
        from src.models.translate import TranslationRecord
        rec = TranslationRecord(
            id="rec-3", batch_id="batch-1", run_id="test-run-id",
            source_sql="hello", target_sql=None, status="SUCCESS",
        )
        db_session.add(rec)
        db_session.commit()

        results = _fetch_batch_records(db_session, "batch-1")
        assert len(results) == 0

    def test_returns_empty_for_missing_batch(self, db_session):
        """Non-existent batch returns empty list."""
        results = _fetch_batch_records(db_session, "nonexistent")
        assert results == []


class TestGenerateInsertSql:
    """_generate_insert_sql — SQL generation."""

    def test_generates_sql(self):
        """Returns SQL and row count."""
        job = MagicMock(spec=TranslationJob)
        job.target_schema = "public"
        job.target_table = "translations"
        job.target_key_cols = None
        job.upsert_strategy = "MERGE"

        rows = [{"col1": "val1", "is_original": 1}]
        sql, count = _generate_insert_sql("postgresql", job, ["col1", "is_original"], rows, "batch-1")
        assert sql is not None
        assert "INSERT" in sql.upper()
        assert count > 0

    def test_value_error_returns_none(self):
        """ValueError in SQL generation returns (None, 0)."""
        job = MagicMock(spec=TranslationJob)
        job.target_schema = "public"
        job.target_table = None  # Will cause error
        job.target_key_cols = None
        job.upsert_strategy = "MERGE"

        with patch("src.plugins.translate._batch_insert.SQLGenerator.generate",
                   side_effect=ValueError("test error")):
            sql, count = _generate_insert_sql("postgresql", job, ["col"], [{"col": "v"}], "batch-1")
            assert sql is None
            assert count == 0


class TestExecuteInsertSql:
    """_execute_insert_sql — execute via Superset."""

    @pytest.mark.asyncio
    async def test_success(self):
        """Successful execution logs reason."""
        executor = AsyncMock()
        executor.execute_and_poll.return_value = {"status": "success"}
        await _execute_insert_sql(executor, "INSERT ...", "batch-1", 5)
        executor.execute_and_poll.assert_called_once_with(sql="INSERT ...", max_polls=30, poll_interval_seconds=2.0)

    @pytest.mark.asyncio
    async def test_exception_caught(self):
        """Exception in execute_and_poll is caught gracefully."""
        executor = AsyncMock()
        executor.execute_and_poll.side_effect = Exception("API error")
        await _execute_insert_sql(executor, "INSERT ...", "batch-1", 5)
        executor.execute_and_poll.assert_called_once()

    @pytest.mark.asyncio
    async def test_direct_db_success(self):
        """DbExecutor path: successful execution."""
        from src.core.db_executor import DbExecutor, DbExecutionResult
        executor = MagicMock(spec=DbExecutor)
        executor.execute_sql = AsyncMock(return_value=DbExecutionResult(
            success=True, rows_affected=5, execution_time_ms=12.0
        ))
        await _execute_insert_sql(executor, "INSERT ...", "batch-1", 5, connection_id="conn-1")
        executor.execute_sql.assert_called_once_with("conn-1", "INSERT ...")

    @pytest.mark.asyncio
    async def test_direct_db_failure(self):
        """DbExecutor path: failed execution with error."""
        from src.core.db_executor import DbExecutor, DbExecutionResult
        executor = MagicMock(spec=DbExecutor)
        executor.execute_sql = AsyncMock(return_value=DbExecutionResult(
            success=False, error="Connection refused"
        ))
        await _execute_insert_sql(executor, "INSERT ...", "batch-1", 5, connection_id="conn-1")
        executor.execute_sql.assert_called_once_with("conn-1", "INSERT ...")


class TestResolveInsertBackend:
    """_resolve_insert_backend — resolve backend dialect and executor."""

    @pytest.mark.asyncio
    async def test_success(self):
        """Returns dialect and executor."""
        config_manager = MagicMock()
        job = MagicMock(spec=TranslationJob)
        job.environment_id = "env-1"
        job.source_dialect = None
        job.target_database_id = None
        job.database_dialect = None
        job.target_dialect = None

        with patch("src.plugins.translate._batch_insert.SupersetSqlLabExecutor") as mock_exec_cls:
            mock_exec = MagicMock()
            mock_exec.get_database_backend.return_value = "postgresql"
            mock_exec.resolve_database_id = AsyncMock()
            mock_exec_cls.return_value = mock_exec

            dialect, executor, connection_id = await _resolve_insert_backend(config_manager, job, "batch-1")
            assert dialect == "postgresql"
            assert executor is not None
            assert connection_id is None  # sqllab path has no connection_id

    @pytest.mark.asyncio
    async def test_failure_returns_none(self):
        """Exception in resolve returns (None, None, None)."""
        config_manager = MagicMock()
        job = MagicMock(spec=TranslationJob)
        job.environment_id = None
        job.source_dialect = None

        with patch("src.plugins.translate._batch_insert.SupersetSqlLabExecutor",
                   side_effect=Exception("Failed")):
            dialect, executor, connection_id = await _resolve_insert_backend(config_manager, job, "batch-1")
            assert dialect is None
            assert executor is None
            assert connection_id is None

    @pytest.mark.asyncio
    async def test_fallback_dialect(self):
        """Falls back to job.database_dialect or target_dialect."""
        config_manager = MagicMock()
        job = MagicMock(spec=TranslationJob)
        job.environment_id = None
        job.source_dialect = None
        job.target_database_id = None
        job.database_dialect = "mysql"
        job.target_dialect = None

        with patch("src.plugins.translate._batch_insert.SupersetSqlLabExecutor") as mock_exec_cls:
            mock_exec = MagicMock()
            mock_exec.get_database_backend.return_value = None
            mock_exec.resolve_database_id = AsyncMock()
            mock_exec_cls.return_value = mock_exec

            dialect, _, connection_id = await _resolve_insert_backend(config_manager, job, "batch-1")
            assert dialect == "mysql"
            assert connection_id is None

    @pytest.mark.asyncio
    async def test_direct_db_path(self):
        """direct_db path returns DbExecutor with connection_id."""
        config_manager = MagicMock()
        job = MagicMock(spec=TranslationJob)
        job.insert_method = "direct_db"
        job.connection_id = "clickhouse-conn-1"

        with patch("src.plugins.translate._batch_insert.ConnectionService") as mock_cs_cls:
            mock_cs = MagicMock()
            mock_conn = MagicMock()
            mock_conn.dialect = "clickhouse"
            mock_conn.name = "Test ClickHouse"
            mock_cs.get_connection.return_value = mock_conn
            mock_cs_cls.return_value = mock_cs

            dialect, executor, connection_id = await _resolve_insert_backend(config_manager, job, "batch-1")
            assert dialect == "clickhouse"
            assert connection_id == "clickhouse-conn-1"
            # Should return DbExecutor, not SupersetSqlLabExecutor
            from src.core.db_executor import DbExecutor
            assert isinstance(executor, DbExecutor)

    @pytest.mark.asyncio
    async def test_direct_db_missing_conn_raises(self):
        """When direct_db connection not found, raises ValueError (no fallback)."""
        config_manager = MagicMock()
        job = MagicMock(spec=TranslationJob)
        job.insert_method = "direct_db"
        job.connection_id = "missing-conn"

        with patch("src.plugins.translate._batch_insert.ConnectionService") as mock_cs_cls:
            mock_cs = MagicMock()
            mock_cs.get_connection.return_value = None
            mock_cs_cls.return_value = mock_cs

            with pytest.raises(ValueError, match="connection_id='missing-conn' not found"):
                await _resolve_insert_backend(config_manager, job, "batch-1")

    @pytest.mark.asyncio
    async def test_direct_db_no_connection_id_raises(self):
        """When direct_db is set but connection_id is None, raises ValueError (no fallback)."""
        config_manager = MagicMock()
        job = MagicMock(spec=TranslationJob)
        job.insert_method = "direct_db"
        job.connection_id = None

        with pytest.raises(ValueError, match="connection_id is missing"):
            await _resolve_insert_backend(config_manager, job, "batch-1")


class TestInsertBatchToTarget:
    """insert_batch_to_target — main integration function."""

    @pytest.mark.asyncio
    async def test_empty_batch_returns_early(self, db_session):
        """Empty batch returns without error."""
        config_manager = MagicMock()
        job = MagicMock(spec=TranslationJob)
        job.target_column = None
        job.translation_column = "text"
        job.target_languages = None

        with patch("src.plugins.translate._batch_insert._fetch_batch_records", return_value=[]):
            result = await insert_batch_to_target(db_session, config_manager, job, "batch-1", "run-1")
            assert result is None

    @pytest.mark.asyncio
    async def test_no_columns_fallback(self, db_session):
        """When columns empty, falls back to single column."""
        config_manager = MagicMock()
        job = MagicMock(spec=TranslationJob)
        job.target_column = None
        job.translation_column = None
        job.target_languages = None
        job.target_schema = None
        job.target_table = None
        job.target_key_cols = None
        job.upsert_strategy = "MERGE"
        job.environment_id = None
        job.source_dialect = None
        job.target_database_id = None
        job.database_dialect = "postgresql"
        job.target_dialect = None

        rec = MagicMock(spec=TranslationRecord)
        rec.target_sql = "bonjour"

        with patch("src.plugins.translate._batch_insert._fetch_batch_records", return_value=[rec]):
            with patch("src.plugins.translate._batch_insert._resolve_insert_backend",
                       return_value=("postgresql", AsyncMock(), None)):
                with patch("src.plugins.translate._batch_insert.SQLGenerator.generate_batch",
                           return_value=[("INSERT INTO ...", 1)]):
                    with patch("src.plugins.translate._batch_insert._execute_insert_sql",
                               AsyncMock()):
                        result = await insert_batch_to_target(
                            db_session, config_manager, job, "batch-1", "run-1"
                        )
                        assert result is None

    @pytest.mark.asyncio
    async def test_backend_resolve_none_returns_early(self, db_session):
        """When _resolve_insert_backend returns None, returns early."""
        config_manager = MagicMock()
        job = MagicMock(spec=TranslationJob)
        job.target_column = "text"
        job.translation_column = None
        job.target_languages = []

        rec = MagicMock(spec=TranslationRecord)
        rec.target_sql = "bonjour"
        rec.source_sql = "hello"
        rec.source_data = {}
        rec.languages = []

        with patch("src.plugins.translate._batch_insert._fetch_batch_records", return_value=[rec]):
            with patch("src.plugins.translate._batch_insert._build_target_columns", return_value=["text"]):
                with patch("src.plugins.translate._batch_insert._build_context_keys", return_value=[]):
                    with patch("src.plugins.translate._batch_insert._build_insert_rows",
                               return_value=[{"text": "bonjour"}]):
                        with patch("src.plugins.translate._batch_insert._resolve_insert_backend",
                                   return_value=(None, None, None)):
                            result = await insert_batch_to_target(
                                db_session, config_manager, job, "batch-1", "run-1"
                            )
                            assert result is None
# #endregion Test.BatchInsert
