# #region Test.AdaptiveBatchSizer [C:3] [TYPE Module] [SEMANTICS test,batch,sizer,token,budget]
# @BRIEF Tests for _batch_sizer.py — AdaptiveBatchSizer.
# @RELATION BINDS_TO -> [AdaptiveBatchSizer]
# @TEST_EDGE: empty_rows -> empty list
# @TEST_EDGE: budget_zero -> fallback to fixed size
# @TEST_EDGE: per_batch_budget_collapsed -> fallback

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

import pytest
from unittest.mock import MagicMock, patch

from src.models.translate import TranslationJob
from src.plugins.translate._batch_sizer import AdaptiveBatchSizer


class TestResolveProviderConfig:
    """AdaptiveBatchSizer.resolve_provider_config."""

    def test_no_provider_id(self):
        """No provider_id returns empty config."""
        sizer = AdaptiveBatchSizer(MagicMock(), MagicMock())
        job = MagicMock(spec=TranslationJob)
        job.provider_id = None
        config = sizer.resolve_provider_config(job)
        assert config["model"] is None
        assert config["context_window"] is None
        assert config["max_output_tokens"] is None

    def test_with_provider_id(self):
        """Provider ID returns token config."""
        sizer = AdaptiveBatchSizer(MagicMock(), MagicMock())
        job = MagicMock(spec=TranslationJob)
        job.provider_id = "prov-1"

        with patch("src.plugins.translate._batch_sizer.LLMProviderService") as mock_svc:
            mock_instance = MagicMock()
            mock_svc.return_value = mock_instance
            mock_instance.get_provider_token_config.return_value = {
                "model": "gpt-4o", "context_window": 128000, "max_output_tokens": 16384,
            }
            config = sizer.resolve_provider_config(job)
            assert config["model"] == "gpt-4o"
            assert config["context_window"] == 128000

    def test_exception_returns_empty(self):
        """Exception in provider returns empty config."""
        sizer = AdaptiveBatchSizer(MagicMock(), MagicMock())
        job = MagicMock(spec=TranslationJob)
        job.provider_id = "prov-err"

        with patch("src.plugins.translate._batch_sizer.LLMProviderService") as mock_svc:
            mock_instance = MagicMock()
            mock_svc.return_value = mock_instance
            mock_instance.get_provider_token_config.side_effect = Exception("DB error")

            config = sizer.resolve_provider_config(job)
            assert config["model"] is None


class TestAutoSizeBatches:
    """AdaptiveBatchSizer.auto_size_batches."""

    def _make_job(self):
        job = MagicMock(spec=TranslationJob)
        job.provider_id = "prov-1"
        job.translation_column = "source_text"
        job.context_columns = None
        job.batch_size = None
        return job

    def _make_sizer(self):
        return AdaptiveBatchSizer(MagicMock(), MagicMock())

    def test_empty_rows(self):
        """Empty source_rows returns empty list."""
        sizer = self._make_sizer()
        job = self._make_job()
        batches = sizer.auto_size_batches(job, [], ["fr"])
        assert batches == []

    def test_single_batch(self):
        """Few rows fit in one batch."""
        sizer = self._make_sizer()
        job = self._make_job()

        rows = [
            {"source_text": f"text {i}", "source_data": {}}
            for i in range(3)
        ]

        with patch.object(sizer, "resolve_provider_config",
                          return_value={"model": "gpt-4o", "context_window": 128000,
                                        "max_output_tokens": 16384}):
            with patch("src.plugins.translate._batch_sizer.estimate_token_budget") as mock_budget:
                mock_budget.return_value = {
                    "batch_size_adjusted": 10,
                    "available_input_budget": 100000,
                    "estimated_input_tokens": 50000,
                    "max_rows_by_output": 20,
                    "warning": None,
                }
                with patch("src.plugins.translate._batch_sizer.estimate_row_tokens",
                           return_value=100):
                    batches = sizer.auto_size_batches(job, rows, ["fr"])
                    assert len(batches) == 1
                    assert len(batches[0]) == 3

    def test_fallback_to_fixed_size(self):
        """When budget returns zero, falls back to fixed batch_size."""
        sizer = self._make_sizer()
        job = self._make_job()
        job.batch_size = 2

        rows = [
            {"source_text": f"text {i}", "source_data": {}}
            for i in range(5)
        ]

        with patch.object(sizer, "resolve_provider_config",
                          return_value={"model": "gpt-4o", "context_window": 128000,
                                        "max_output_tokens": 16384}):
            with patch("src.plugins.translate._batch_sizer.estimate_token_budget") as mock_budget:
                mock_budget.return_value = {
                    "batch_size_adjusted": 0,
                    "available_input_budget": 0,
                    "estimated_input_tokens": 0,
                    "max_rows_by_output": 0,
                }
                with patch("src.plugins.translate._batch_sizer.estimate_row_tokens",
                           return_value=100):
                    batches = sizer.auto_size_batches(job, rows, ["fr"])
                    # 5 rows / 2 per batch = 3 batches
                    assert len(batches) == 3

    def test_fallback_when_budget_collapsed(self):
        """When per_batch_budget <= 0, falls back."""
        sizer = self._make_sizer()
        job = self._make_job()

        rows = [
            {"source_text": "text", "source_data": {}}
            for _ in range(3)
        ]

        with patch.object(sizer, "resolve_provider_config",
                          return_value={"model": "gpt-4o", "context_window": 128000,
                                        "max_output_tokens": 16384}):
            with patch("src.plugins.translate._batch_sizer.estimate_token_budget") as mock_budget:
                mock_budget.return_value = {
                    "batch_size_adjusted": 5,
                    "available_input_budget": 100,
                    "estimated_input_tokens": 50,
                    "max_rows_by_output": 1,
                }
                with patch("src.plugins.translate._batch_sizer.estimate_row_tokens",
                           return_value=10000):
                    batches = sizer.auto_size_batches(job, rows, ["fr"])
                    assert len(batches) >= 1

    def test_single_row_exceeds_budget(self):
        """A row exceeding per-batch budget gets its own batch."""
        sizer = self._make_sizer()
        job = self._make_job()

        rows = [
            {"source_text": "short text", "source_data": {}},
            {"source_text": "x" * 10000, "source_data": {}},
        ]

        with patch.object(sizer, "resolve_provider_config",
                          return_value={"model": "gpt-4o", "context_window": 128000,
                                        "max_output_tokens": 16384}):
            with patch("src.plugins.translate._batch_sizer.estimate_token_budget") as mock_budget:
                mock_budget.return_value = {
                    "batch_size_adjusted": 10,
                    "available_input_budget": 100000,
                    "estimated_input_tokens": 50000,
                    "max_rows_by_output": 20,
                }
                # Second row tokens must exceed per_batch_budget (~74550)
                with patch("src.plugins.translate._batch_sizer.estimate_row_tokens",
                           side_effect=[50, 80000]):
                    batches = sizer.auto_size_batches(job, rows, ["fr"])
                    # Large row gets its own batch (row_tokens > per_batch_budget)
                    assert len(batches) >= 2

    def test_respects_job_batch_size(self):
        """job.batch_size limits row count per batch."""
        sizer = self._make_sizer()
        job = self._make_job()
        job.batch_size = 2

        rows = [
            {"source_text": f"text {i}", "source_data": {}}
            for i in range(10)
        ]

        with patch.object(sizer, "resolve_provider_config",
                          return_value={"model": "gpt-4o", "context_window": 128000,
                                        "max_output_tokens": 16384}):
            with patch("src.plugins.translate._batch_sizer.estimate_token_budget") as mock_budget:
                mock_budget.return_value = {
                    "batch_size_adjusted": 10,
                    "available_input_budget": 100000,
                    "estimated_input_tokens": 50000,
                    "max_rows_by_output": 50,
                }
                with patch("src.plugins.translate._batch_sizer.estimate_row_tokens",
                           return_value=500):
                    batches = sizer.auto_size_batches(job, rows, ["fr"])
                    # Max 2 per batch due to job.batch_size
                    assert all(len(b) <= 2 for b in batches)

    def test_uses_provider_info_fallback(self):
        """When provider_info is given and model is None, uses it."""
        sizer = self._make_sizer()
        job = self._make_job()
        job.provider_id = None

        rows = [{"source_text": "text", "source_data": {}}]

        with patch.object(sizer, "resolve_provider_config",
                          return_value={"model": None, "context_window": None,
                                        "max_output_tokens": None}):
            with patch("src.plugins.translate._batch_sizer.estimate_token_budget") as mock_budget:
                mock_budget.return_value = {
                    "batch_size_adjusted": 5,
                    "available_input_budget": 100000,
                    "estimated_input_tokens": 50000,
                    "max_rows_by_output": 20,
                }
                with patch("src.plugins.translate._batch_sizer.estimate_row_tokens",
                           return_value=100):
                    batches = sizer.auto_size_batches(job, rows, ["fr"],
                                                      provider_info="gpt-4o-mini")
                    assert len(batches) == 1

    def test_available_input_budget_none(self):
        """When available_input_budget is None, uses estimated_input_tokens."""
        sizer = self._make_sizer()
        job = self._make_job()

        rows = [{"source_text": "text", "source_data": {}} for _ in range(3)]

        with patch.object(sizer, "resolve_provider_config",
                          return_value={"model": "gpt-4o", "context_window": 128000,
                                        "max_output_tokens": 16384}):
            with patch("src.plugins.translate._batch_sizer.estimate_token_budget") as mock_budget:
                mock_budget.return_value = {
                    "batch_size_adjusted": 5,
                    "available_input_budget": None,
                    "estimated_input_tokens": 50000,
                    "max_rows_by_output": 20,
                }
                with patch("src.plugins.translate._batch_sizer.estimate_row_tokens",
                           return_value=100):
                    batches = sizer.auto_size_batches(job, rows, ["fr"])
                    assert len(batches) == 1
# #endregion Test.AdaptiveBatchSizer
