# region ExecutorTests [TYPE Module]
# @SEMANTICS: test, translate, executor, null-handling, cancellation
# @PURPOSE: Tests for TranslationExecutor: null content handling, cancellation flag during execution.
# @LAYER Test
# @RELATION  BINDS_TO -> [TranslationExecutor]
# @TEST_CONTRACT  TranslationExecutor -> execute_run, _call_openai_compatible
# @TEST_EDGE  null_llm_content -> raises ValueError instead of TypeError
# @TEST_EDGE  cancellation_flag_during_execution -> stops batch processing

import pytest
from unittest.mock import MagicMock, patch

from src.models.translate import (
    TranslationJob,
    TranslationRun,
)
from src.plugins.translate.executor import TranslationExecutor, estimate_row_tokens


# region mock_job [TYPE Function]
# @PURPOSE: Create a mock TranslationJob with standard config.
@pytest.fixture
def mock_job() -> MagicMock:
    job = MagicMock(spec=TranslationJob)
    job.id = "job-exec-1"
    job.name = "Test Job"
    job.status = "ACTIVE"
    job.source_dialect = "postgresql"
    job.target_dialect = "postgresql"
    job.database_dialect = "postgresql"
    job.source_datasource_id = None
    job.translation_column = "name"
    job.context_columns = ["category"]
    job.target_schema = "public"
    job.target_table = "translated_data"
    job.target_key_cols = ["id"]
    job.source_key_cols = ["id"]
    job.source_table = "source_data"
    job.target_languages = ["en"]
    job.provider_id = "provider-1"
    job.batch_size = 50
    job.upsert_strategy = "MERGE"
    job.target_column = None
    return job


# endregion mock_job


# region mock_run [TYPE Function]
# @PURPOSE: Create a mock TranslationRun in PENDING status.
@pytest.fixture
def mock_run() -> MagicMock:
    run = MagicMock(spec=TranslationRun)
    run.id = "run-exec-1"
    run.job_id = "job-exec-1"
    run.status = "PENDING"
    run.started_at = None
    run.completed_at = None
    run.error_message = None
    run.total_records = 0
    run.successful_records = 0
    run.failed_records = 0
    run.skipped_records = 0
    run.trigger_type = "manual"
    run.config_snapshot = {"full_translation": False}
    return run


# endregion mock_run


# region TestNullContentHandling [TYPE Class]
# @PURPOSE: Tests for LLM null content crash fix (Fix 1).
class TestNullContentHandling:

    # region test_null_content_returns_empty_string [TYPE Function]
    # @PURPOSE: msg.get("content") returns None, fix should coerce to "".
    def test_null_content_returns_empty_string(self) -> None:
        """Verify that null content is coerced to empty string instead of crashing."""
        msg = {"content": None}
        content = msg.get("content") or ""
        assert content == "", f"Expected empty string, got {content!r}"

    # endregion test_null_content_returns_empty_string

    # region test_missing_key_returns_empty_string [TYPE Function]
    # @PURPOSE: msg dict without 'content' key should return "".
    def test_missing_key_returns_empty_string(self) -> None:
        """Verify that missing content key returns empty string (existing behavior preserved)."""
        msg = {"finish_reason": "stop"}
        content = msg.get("content") or ""
        assert content == "", f"Expected empty string, got {content!r}"

    # endregion test_missing_key_returns_empty_string

    # region test_null_content_triggers_value_error [TYPE Function]
    # @PURPOSE: When content is None, the existing `if not content:` guard
    #           should raise ValueError instead of crashing with TypeError.
    def test_null_content_triggers_value_error(self) -> None:
        """Verify that null content leads to ValueError rather than TypeError."""
        msg = {"content": None}
        content = msg.get("content") or ""

        # The guard in _call_openai_compatible:
        if not content:
            with pytest.raises(ValueError, match="LLM returned empty content"):
                raise ValueError("LLM returned empty content")

    # endregion test_null_content_triggers_value_error

    # region test_type_error_not_raised_on_null [TYPE Function]
    # @PURPOSE: Directly test that len(None) is NOT called when content is null.
    def test_type_error_not_raised_on_null(self) -> None:
        """Verify len(None) is never reached because null content is coerced to '' first."""
        msg = {"content": None}
        content = msg.get("content") or ""

        # This should NOT raise TypeError — content is "" not None
        assert isinstance(content, str), f"Expected str, got {type(content)}"
        # len("") is 0, len(None) would crash
        content_len = len(content)
        assert content_len == 0

    # endregion test_type_error_not_raised_on_null

    # region test_normal_content_unchanged [TYPE Function]
    # @PURPOSE: Normal string content should be unaffected by the fix.
    def test_normal_content_unchanged(self) -> None:
        """Verify that valid string content is returned unchanged."""
        msg = {"content": '{"rows": []}'}
        content = msg.get("content") or ""
        assert content == '{"rows": []}', f"Expected content unchanged, got {content!r}"

    # endregion test_normal_content_unchanged

    # region test_empty_string_content_unchanged [TYPE Function]
    # @PURPOSE: Empty string content should remain empty (already handled by guard).
    def test_empty_string_content_unchanged(self) -> None:
        """Verify that empty string content remains empty string."""
        msg = {"content": ""}
        content = msg.get("content") or ""
        assert content == "", f"Expected empty string, got {content!r}"

    # endregion test_empty_string_content_unchanged


# endregion TestNullContentHandling


# region TestCancellationFlag [TYPE Class]
# @PURPOSE: Tests for cancellation flag detection during batch execution (Fix 2 + 3).
class TestCancellationFlag:

    # region test_cancel_requested_detected_after_commit [TYPE Function]
    # @PURPOSE: After batch commit, executor re-fetches run and detects CANCEL_REQUESTED flag.
    def test_cancel_requested_detected_after_commit(self, mock_job: MagicMock, mock_run: MagicMock) -> None:
        """Verify that executor detects CANCEL_REQUESTED flag after commit and stops."""
        db = MagicMock()
        config_manager = MagicMock()
        executor = TranslationExecutor(db, config_manager)

        # Mock job query
        db.query.return_value.filter.return_value.first.return_value = mock_job

        # Mock preview edits cache (empty)
        executor._preview_edits_cache = {}

        # Mock source rows and _process_batch
        mock_source_rows = [
            {"row_index": "0", "source_text": "hello", "approved_translation": None,
             "source_object_name": "Row 0", "source_data": {"id": "0", "name": "hello"}},
            {"row_index": "1", "source_text": "world", "approved_translation": None,
             "source_object_name": "Row 1", "source_data": {"id": "1", "name": "world"}},
        ]
        with (
            patch.object(executor, '_fetch_source_rows', return_value=mock_source_rows),
            patch.object(executor, '_process_batch', return_value={
                "successful": 2, "failed": 0, "skipped": 0,
            }) as mock_process_batch,
        ):
            # After first commit, set error_message to CANCEL_REQUESTED
            # db.refresh should set run.error_message = "CANCEL_REQUESTED"
            def refresh_side_effect(obj):
                if isinstance(obj, MagicMock):
                    obj.error_message = "CANCEL_REQUESTED"
            db.refresh.side_effect = refresh_side_effect

            result = executor.execute_run(mock_run)

            # Should have stopped after first batch (not processing more)
            assert mock_process_batch.call_count == 1, (
                f"Expected 1 batch, got {mock_process_batch.call_count}"
            )
            # Should have committed at least once (the after-batch commit)
            assert result.status == "CANCELLED", (
                f"Expected CANCELLED status, got {result.status}"
            )
            db.commit.assert_called()

    # endregion test_cancel_requested_detected_after_commit

    # region test_no_cancellation_completes_normally [TYPE Function]
    # @PURPOSE: Without cancellation flag, executor processes all batches normally.
    def test_no_cancellation_completes_normally(self, mock_job: MagicMock, mock_run: MagicMock) -> None:
        """Verify that without cancellation flag, all batches are processed."""
        db = MagicMock()
        config_manager = MagicMock()
        executor = TranslationExecutor(db, config_manager)

        # Mock job query
        db.query.return_value.filter.return_value.first.return_value = mock_job

        # Mock preview edits cache (empty)
        executor._preview_edits_cache = {}

        mock_source_rows = [
            {"row_index": "0", "source_text": "hello", "approved_translation": None,
             "source_object_name": "Row 0", "source_data": {"id": "0", "name": "hello"}},
        ]
        with (
            patch.object(executor, '_fetch_source_rows', return_value=mock_source_rows),
            patch.object(executor, '_process_batch', return_value={
                "successful": 1, "failed": 0, "skipped": 0,
            }) as mock_process_batch,
        ):
            # db.refresh should NOT set cancellation
            db.refresh.side_effect = None
            db.refresh.return_value = None

            result = executor.execute_run(mock_run)

            assert mock_process_batch.call_count == 1
            assert result.status != "CANCELLED", (
                f"Expected non-CANCELLED status, got {result.status}"
            )

    # endregion test_no_cancellation_completes_normally

    # region test_cancel_requested_no_rows [TYPE Function]
    # @PURPOSE: When there are no source rows, cancellation is not relevant.
    def test_cancel_requested_no_rows(self, mock_job: MagicMock, mock_run: MagicMock) -> None:
        """Zero source rows should return early without processing batches."""
        db = MagicMock()
        config_manager = MagicMock()
        executor = TranslationExecutor(db, config_manager)

        db.query.return_value.filter.return_value.first.return_value = mock_job
        executor._preview_edits_cache = {}

        with (
            patch.object(executor, '_fetch_source_rows', return_value=[]),
            patch.object(executor, '_process_batch') as mock_process_batch,
        ):
            result = executor.execute_run(mock_run)

            mock_process_batch.assert_not_called()
            # Should be COMPLETED (no rows) not CANCELLED
            assert result.status == "COMPLETED"

    # endregion test_cancel_requested_no_rows


# endregion TestCancellationFlag


# region TestEstimateRowTokens [TYPE Class]
# @PURPOSE: Tests for estimate_row_tokens — per-row token estimation for adaptive batch sizing.
# @RELATION  BINDS_TO -> [estimate_row_tokens]
class TestEstimateRowTokens:
    """Unit tests for estimate_row_tokens()."""

    # region test_empty_text [TYPE Function]
    def test_empty_text(self) -> None:
        """Empty source_text returns minimal tokens."""
        job = MagicMock(spec=TranslationJob)
        job.context_columns = []
        tokens = estimate_row_tokens("", None, job)
        assert tokens >= 1, f"Expected >= 1 token for empty text, got {tokens}"

    # endregion test_empty_text

    # region test_short_text [TYPE Function]
    def test_short_text(self) -> None:
        """Short ASCII text estimates roughly chars/2.2 tokens."""
        job = MagicMock(spec=TranslationJob)
        job.context_columns = []
        tokens = estimate_row_tokens("Hello world", None, job)
        # "Hello world" = 11 chars → ~5 tokens at 2.2 chars/token
        assert 3 <= tokens <= 10, f"Expected reasonable token count, got {tokens}"

    # endregion test_short_text

    # region test_with_context [TYPE Function]
    def test_with_context(self) -> None:
        """Context columns contribute to token estimate."""
        job = MagicMock(spec=TranslationJob)
        job.context_columns = ["category", "description"]
        source_data = {"category": "Billing", "description": "Monthly invoice summary"}
        tokens_no_ctx = estimate_row_tokens("Product name", None, job)
        tokens_with_ctx = estimate_row_tokens("Product name", source_data, job)
        assert tokens_with_ctx > tokens_no_ctx, (
            f"Context should increase tokens: no_ctx={tokens_no_ctx}, with_ctx={tokens_with_ctx}"
        )

    # endregion test_with_context

    # region test_cjk_text [TYPE Function]
    def test_cjk_text(self) -> None:
        """CJK characters are token-denser (~1.5 chars/token)."""
        job = MagicMock(spec=TranslationJob)
        job.context_columns = []
        # 12 CJK chars → 12/1.5 = 8 tokens, plus 1 for empty context = 9
        tokens = estimate_row_tokens("你好世界这是一个测试消息", None, job)
        assert tokens == 9, f"Expected 9 tokens (8 CJK + 1 empty ctx) for CJK, got {tokens}"

    # endregion test_cjk_text

    # region test_long_text [TYPE Function]
    def test_long_text(self) -> None:
        """Long text produces proportionally more tokens."""
        job = MagicMock(spec=TranslationJob)
        job.context_columns = []
        long_text = "word " * 500  # ~2500 chars
        tokens = estimate_row_tokens(long_text, None, job)
        assert tokens > 100, f"Expected >100 tokens for long text, got {tokens}"

    # endregion test_long_text

    # region test_source_data_none_with_context_keys [TYPE Function]
    def test_source_data_none_with_context_keys(self) -> None:
        """When source_data is None but context_keys exist, no crash."""
        job = MagicMock(spec=TranslationJob)
        job.context_columns = ["category"]
        tokens = estimate_row_tokens("Hello", None, job)
        assert tokens >= 1, f"Expected at least 1 token, got {tokens}"

    # endregion test_source_data_none_with_context_keys


# endregion TestEstimateRowTokens


# region TestAutoSizeBatches [TYPE Class]
# @PURPOSE: Tests for _auto_size_batches — variable-sized batch splitting based on content length.
# @RELATION  BINDS_TO -> [EXT:method:TranslationExecutor._auto_size_batches]
# @RELATION  BINDS_TO -> [estimate_token_budget]
class TestAutoSizeBatches:
    """Tests for TranslationExecutor._auto_size_batches()."""

    # region _make_executor [TYPE Function]
    @pytest.fixture
    def executor(self) -> TranslationExecutor:
        db = MagicMock()
        config_manager = MagicMock()
        return TranslationExecutor(db, config_manager)

    # endregion _make_executor

    # region _make_job [TYPE Function]
    @pytest.fixture
    def job(self) -> MagicMock:
        j = MagicMock(spec=TranslationJob)
        j.id = "job-autosize-1"
        j.translation_column = "source_text"
        j.context_columns = []
        j.target_languages = ["en"]
        j.target_dialect = None
        j.batch_size = 50
        return j

    # endregion _make_job

    # region test_empty_rows [TYPE Function]
    def test_empty_rows(self, executor: TranslationExecutor, job: MagicMock) -> None:
        """Empty source_rows returns empty list."""
        batches = executor._auto_size_batches(job, [], ["en"], provider_info="gpt-4o-mini")
        assert batches == [], f"Expected empty list, got {batches}"

    # endregion test_empty_rows

    # region test_small_dataset_single_batch [TYPE Function]
    @patch("src.plugins.translate._batch_sizer.estimate_token_budget")
    def test_small_dataset_single_batch(
        self,
        mock_estimate: MagicMock,
        executor: TranslationExecutor,
        job: MagicMock,
    ) -> None:
        """Few short rows → single batch (all fit within budget)."""
        mock_estimate.return_value = {
            "batch_size_adjusted": 10,
            "estimated_input_tokens": 5000,
            "estimated_output_tokens": 2000,
            "max_output_needed": 4096,
            "warning": None,
        }
        source_rows = [
            {"row_index": str(i), "source_text": "short text", "source_data": None}
            for i in range(5)
        ]
        batches = executor._auto_size_batches(
            job, source_rows, ["en"], provider_info="gpt-4o-mini",
        )
        assert len(batches) == 1, f"Expected 1 batch, got {len(batches)}"
        assert len(batches[0]) == 5, f"Expected 5 rows in batch, got {len(batches[0])}"

    # endregion test_small_dataset_single_batch

    # region test_homogeneous_rows [TYPE Function]
    @patch("src.plugins.translate._batch_sizer.estimate_token_budget")
    def test_homogeneous_rows(
        self,
        mock_estimate: MagicMock,
        executor: TranslationExecutor,
        job: MagicMock,
    ) -> None:
        """Homogeneous short rows → many rows per batch (budget-efficient)."""
        mock_estimate.return_value = {
            "batch_size_adjusted": 20,
            "estimated_input_tokens": 8000,
            "estimated_output_tokens": 4000,
            "max_output_needed": 4096,
            "warning": None,
        }
        # 50 very short rows (10 chars each) → fits in ~3 batches of ~20
        source_rows = [
            {"row_index": str(i), "source_text": "short", "source_data": None}
            for i in range(50)
        ]
        batches = executor._auto_size_batches(
            job, source_rows, ["en"], provider_info="gpt-4o-mini",
        )
        # Should produce fewer batches than the fixed 50-size approach (1 batch)
        # With budget ~8K tokens and rows ~2 tokens each → ~20-25 rows per batch
        assert len(batches) >= 1, "Expected at least 1 batch"
        total = sum(len(b) for b in batches)
        assert total == 50, f"Expected 50 total rows, got {total}"
        # Average batch size should be higher than 1
        avg_size = sum(len(b) for b in batches) / len(batches)
        assert avg_size >= 5, f"Expected avg batch size >= 5, got {avg_size}"

    # endregion test_homogeneous_rows

    # region test_mixed_length_rows [TYPE Function]
    @patch("src.plugins.translate._batch_sizer.estimate_token_budget")
    def test_mixed_length_rows(
        self,
        mock_estimate: MagicMock,
        executor: TranslationExecutor,
        job: MagicMock,
    ) -> None:
        """Mixed-length rows → variable-sized batches based on content length."""
        # Tight budget: per_batch_budget = 1200 - 600 = 600 tokens
        # Short rows (~1 token) can fit ~600 per batch.
        # Long row (~1136 tokens) ALONE exceeds the per-batch budget → own batch.
        mock_estimate.return_value = {
            "batch_size_adjusted": 3,
            "estimated_input_tokens": 1200,
            "estimated_output_tokens": 500,
            "max_output_needed": 4096,
            "warning": None,
        }
        # Rows with very different lengths: 2 short, 1 long, 2 short
        source_rows = [
            {"row_index": "0", "source_text": "a", "source_data": None},
            {"row_index": "1", "source_text": "b", "source_data": None},
            # Long row with ~2500 chars (~1136 tokens)
            {"row_index": "2", "source_text": "long " * 500, "source_data": None},
            {"row_index": "3", "source_text": "c", "source_data": None},
            {"row_index": "4", "source_text": "d", "source_data": None},
        ]
        batches = executor._auto_size_batches(
            job, source_rows, ["en"], provider_info="gpt-4o-mini",
        )
        total = sum(len(b) for b in batches)
        assert total == 5, f"Expected 5 total rows, got {total}"
        # The long row exceeds the per-batch budget → isolated in own batch
        # Short rows fit together → remaining 4 rows in 1-2 batches
        assert len(batches) >= 2, (
            f"Expected at least 2 batches (long row isolated), got {len(batches)}: "
            f"{[len(b) for b in batches]}"
        )
        # Verify the long row is in its own batch
        long_batches = [b for b in batches if any(r["source_text"] == "long " * 500 for r in b)]
        assert len(long_batches) == 1, "Long row should be in exactly one batch"
        assert len(long_batches[0]) == 1, "Long row batch should have exactly 1 row"

    # endregion test_mixed_length_rows

    # region test_row_exceeds_budget [TYPE Function]
    @patch("src.plugins.translate._batch_sizer.estimate_token_budget")
    def test_row_exceeds_budget(
        self,
        mock_estimate: MagicMock,
        executor: TranslationExecutor,
        job: MagicMock,
    ) -> None:
        """Single row exceeding per-batch budget → placed in own batch with WARN."""
        mock_estimate.return_value = {
            "batch_size_adjusted": 5,
            "estimated_input_tokens": 3000,
            "estimated_output_tokens": 2000,
            "max_output_needed": 4096,
            "warning": None,
        }
        # Row with 3000 chars text — token count will exceed the per_batch_budget
        # which is estimated_input_tokens - PROMPT_BASE_TOKENS = 3000 - 600 = 2400
        # The row text "x" * 3000 has ~3000/2.2 ≈ 1364 tokens → exceeds 2400...
        # Actually 1364 < 2400, so it would fit. Let me use longer text.
        # "x" * 6000 → ~6000/2.2 ≈ 2727 tokens > 2400
        source_rows = [
            {"row_index": "0", "source_text": "x" * 6000, "source_data": None},
            {"row_index": "1", "source_text": "short", "source_data": None},
            {"row_index": "2", "source_text": "tiny", "source_data": None},
        ]
        batches = executor._auto_size_batches(
            job, source_rows, ["en"], provider_info="gpt-4o-mini",
        )
        total = sum(len(b) for b in batches)
        assert total == 3, f"Expected 3 total rows, got {total}"
        # First row should be in its own batch
        assert len(batches[0]) == 1, "Expected oversized row in own batch"

    # endregion test_row_exceeds_budget

    # region test_budget_failure_fallback [TYPE Function]
    @patch("src.plugins.translate._batch_sizer.estimate_token_budget")
    def test_budget_failure_fallback(
        self,
        mock_estimate: MagicMock,
        executor: TranslationExecutor,
        job: MagicMock,
    ) -> None:
        """When estimate_token_budget fails (recommended=0), fallback to fixed batch_size."""
        mock_estimate.return_value = {
            "batch_size_adjusted": 0,  # Failure signal
            "estimated_input_tokens": 0,
            "estimated_output_tokens": 0,
            "max_output_needed": 0,
            "warning": "Budget error",
        }
        source_rows = [
            {"row_index": str(i), "source_text": "short text", "source_data": None}
            for i in range(60)
        ]
        batches = executor._auto_size_batches(
            job, source_rows, ["en"], provider_info="gpt-4o-mini",
        )
        # Fallback to job.batch_size=50 → should produce 2 batches (50 + 10)
        assert len(batches) == 2, f"Expected 2 fallback batches (50+10), got {len(batches)}"
        assert len(batches[0]) == 50, f"Expected 50 in first fallback batch, got {len(batches[0])}"

    # endregion test_budget_failure_fallback

    # region test_budget_zero_input_collapse [TYPE Function]
    @patch("src.plugins.translate._batch_sizer.estimate_token_budget")
    def test_budget_zero_input_collapse(
        self,
        mock_estimate: MagicMock,
        executor: TranslationExecutor,
        job: MagicMock,
    ) -> None:
        """When per_batch_budget collapses (<=0), fallback to fixed batch_size."""
        mock_estimate.return_value = {
            "batch_size_adjusted": 5,
            "estimated_input_tokens": 100,  # Less than PROMPT_BASE_TOKENS (600)
            "estimated_output_tokens": 50,
            "max_output_needed": 100,
            "warning": None,
        }
        source_rows = [
            {"row_index": str(i), "source_text": "test", "source_data": None}
            for i in range(60)
        ]
        batches = executor._auto_size_batches(
            job, source_rows, ["en"], provider_info="gpt-4o-mini",
        )
        # Should fallback to job.batch_size=50
        assert len(batches) == 2, f"Expected 2 fallback batches, got {len(batches)}"

    # endregion test_budget_zero_input_collapse

    # region test_provider_info_resolution [TYPE Function]
    @patch("src.plugins.translate._batch_sizer.estimate_token_budget")
    def test_provider_info_resolution(
        self,
        mock_estimate: MagicMock,
        executor: TranslationExecutor,
        job: MagicMock,
    ) -> None:
        """When provider_info is None, _resolve_provider_model is called."""
        mock_estimate.return_value = {
            "batch_size_adjusted": 10,
            "estimated_input_tokens": 5000,
            "estimated_output_tokens": 2000,
            "max_output_needed": 4096,
            "warning": None,
        }
        source_rows = [
            {"row_index": "0", "source_text": "hello", "source_data": None},
        ]
        # provider_info=None → should call _resolve_provider_model
        with patch.object(executor, '_resolve_provider_model', return_value="gpt-4o-mini") as mock_resolve:
            batches = executor._auto_size_batches(
                job, source_rows, ["en"], provider_info=None,
            )
            mock_resolve.assert_called_once_with(job)
            assert len(batches) == 1

    # endregion test_provider_info_resolution

    # region test_resolve_provider_model [TYPE Function]
    def test_resolve_provider_model_no_provider(self, executor: TranslationExecutor) -> None:
        """_resolve_provider_model returns None when job has no provider_id."""
        job = MagicMock(spec=TranslationJob)
        job.provider_id = None
        result = executor._resolve_provider_model(job)
        assert result is None, f"Expected None when no provider_id, got {result}"

    # endregion test_resolve_provider_model

    # region test_resolve_provider_model_with_provider [TYPE Function]
    @patch("src.plugins.translate.executor.LLMProviderService")
    def test_resolve_provider_model_with_provider(
        self,
        mock_provider_svc: MagicMock,
        executor: TranslationExecutor,
    ) -> None:
        """_resolve_provider_model returns the default model name."""
        job = MagicMock(spec=TranslationJob)
        job.provider_id = "provider-1"
        mock_svc_instance = MagicMock()
        mock_provider = MagicMock()
        mock_provider.default_model = "gpt-4o-mini"
        mock_svc_instance.get_provider.return_value = mock_provider
        mock_provider_svc.return_value = mock_svc_instance

        executor.db = MagicMock()
        result = executor._resolve_provider_model(job)
        assert result == "gpt-4o-mini", f"Expected 'gpt-4o-mini', got {result}"

    # endregion test_resolve_provider_model_with_provider

    # region test_resolve_provider_model_exception [TYPE Function]
    @patch("src.plugins.translate.executor.LLMProviderService")
    def test_resolve_provider_model_exception(
        self,
        mock_provider_svc: MagicMock,
        executor: TranslationExecutor,
    ) -> None:
        """_resolve_provider_model returns None on exception."""
        job = MagicMock(spec=TranslationJob)
        job.provider_id = "provider-1"
        mock_svc_instance = MagicMock()
        mock_svc_instance.get_provider.side_effect = Exception("DB error")
        mock_provider_svc.return_value = mock_svc_instance

        executor.db = MagicMock()
        result = executor._resolve_provider_model(job)
        assert result is None, f"Expected None on exception, got {result}"

    # endregion test_resolve_provider_model_exception

    # region test_at_least_one_row_per_batch [TYPE Function]
    @patch("src.plugins.translate._batch_sizer.estimate_token_budget")
    def test_at_least_one_row_per_batch(
        self,
        mock_estimate: MagicMock,
        executor: TranslationExecutor,
        job: MagicMock,
    ) -> None:
        """Even when rows are large, each batch has at least 1 row."""
        mock_estimate.return_value = {
            "batch_size_adjusted": 1,
            "estimated_input_tokens": 1000,
            "estimated_output_tokens": 500,
            "max_output_needed": 4096,
            "warning": None,
        }
        source_rows = [
            {"row_index": str(i), "source_text": "large " * 200, "source_data": None}
            for i in range(3)
        ]
        batches = executor._auto_size_batches(
            job, source_rows, ["en"], provider_info="gpt-4o-mini",
        )
        total = sum(len(b) for b in batches)
        assert total == 3, f"Expected 3 total rows, got {total}"
        assert all(len(b) >= 1 for b in batches), "Each batch must have at least 1 row"

    # endregion test_at_least_one_row_per_batch


# endregion TestAutoSizeBatches
# endregion ExecutorTests
