"""Unit tests for the dotpromptz runner module."""

import asyncio
import json
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock

import pytest

from dotpromptz.adapters._base import Adapter, GenerateResponse, ImageResult, ToolCallResult
from dotpromptz.runner import (
    BatchResult,
    _compute_delay,
    _execute_with_retry,
    aggregate_batch_results,
    extract_response_content,
    part_to_dict,
    resolve_adapter,
    run,
    serialize_result,
    write_output,
)
from dotpromptz.typing import RetryConfig, RuntimeConfig

SIMPLE_PROMPT = """\
---
config:
  model: gpt-4o
input:
  schema:
    topic: string
---
Tell me about {{topic}}.
"""


def _make_adapter(*, text: str = 'response', side_effect: Exception | None = None) -> Adapter:
    """Create a mock adapter with a configurable generate response."""
    adapter = MagicMock(spec=Adapter)
    if side_effect:
        adapter.generate = AsyncMock(side_effect=side_effect)
    else:
        adapter.generate = AsyncMock(
            return_value=GenerateResponse(text=text),
        )
    return adapter


class TestRunBatch:
    @pytest.mark.asyncio
    async def test_basic_batch(self) -> None:
        adapter = _make_adapter(text='result')
        inputs = [{'topic': 'AI'}, {'topic': 'ML'}, {'topic': 'DL'}]
        results = await run(SIMPLE_PROMPT, inputs, adapter)

        assert len(results) == 3
        assert all(r.status == 'success' for r in results)
        assert all(r.response is not None for r in results)
        assert all(r.error is None for r in results)
        # Verify sorted by index
        assert [r.index for r in results] == [0, 1, 2]

    @pytest.mark.asyncio
    async def test_batch_preserves_input(self) -> None:
        adapter = _make_adapter(text='ok')
        inputs = [{'topic': 'AI'}, {'topic': 'ML'}]
        results = await run(SIMPLE_PROMPT, inputs, adapter)

        assert results[0].input == {'topic': 'AI'}
        assert results[1].input == {'topic': 'ML'}

    @pytest.mark.asyncio
    async def test_batch_handles_partial_failures(self) -> None:
        """When some items fail, they get status='error' while others succeed."""
        call_count = 0

        async def _generate_with_failure(rendered: Any, **kwargs: Any) -> GenerateResponse:
            nonlocal call_count
            call_count += 1
            if call_count == 2:
                raise RuntimeError('Item 2 failed')
            return GenerateResponse(text=f'result_{call_count}')

        adapter = MagicMock(spec=Adapter)
        adapter.generate = AsyncMock(side_effect=_generate_with_failure)

        inputs = [{'topic': 'AI'}, {'topic': 'ML'}, {'topic': 'DL'}]
        results = await run(SIMPLE_PROMPT, inputs, adapter)

        assert len(results) == 3
        # One should have failed
        errors = [r for r in results if r.status == 'error']
        successes = [r for r in results if r.status == 'success']
        assert len(errors) == 1
        assert len(successes) == 2
        assert errors[0].error == 'Item 2 failed'
        assert errors[0].response is None

    @pytest.mark.asyncio
    async def test_batch_all_failures(self) -> None:
        adapter = _make_adapter(side_effect=RuntimeError('all fail'))
        inputs = [{'topic': 'AI'}, {'topic': 'ML'}]
        results = await run(SIMPLE_PROMPT, inputs, adapter)

        assert len(results) == 2
        assert all(r.status == 'error' for r in results)
        assert all('all fail' in r.error for r in results)

    @pytest.mark.asyncio
    async def test_batch_respects_max_workers(self) -> None:
        """Verify concurrency is limited by max_workers."""
        concurrent_count = 0
        max_concurrent = 0

        async def _counting_generate(rendered: Any, **kwargs: Any) -> GenerateResponse:
            nonlocal concurrent_count, max_concurrent
            concurrent_count += 1
            max_concurrent = max(max_concurrent, concurrent_count)
            await asyncio.sleep(0.01)  # Small delay to allow overlap
            concurrent_count -= 1
            return GenerateResponse(text='ok')

        adapter = MagicMock(spec=Adapter)
        adapter.generate = AsyncMock(side_effect=_counting_generate)

        inputs = [{'topic': f't{i}'} for i in range(10)]
        results = await run(SIMPLE_PROMPT, inputs, adapter, max_workers=2)

        assert len(results) == 10
        assert all(r.status == 'success' for r in results)
        assert max_concurrent <= 2

    @pytest.mark.asyncio
    async def test_batch_on_item_complete_callback(self) -> None:
        adapter = _make_adapter(text='done')
        completed: list[BatchResult] = []

        def _on_complete(result: BatchResult) -> None:
            completed.append(result)

        inputs = [{'topic': 'AI'}, {'topic': 'ML'}]
        results = await run(
            SIMPLE_PROMPT,
            inputs,
            adapter,
            on_item_complete=_on_complete,
        )

        assert len(completed) == 2
        assert len(results) == 2

    @pytest.mark.asyncio
    async def test_batch_empty_input_list(self) -> None:
        adapter = _make_adapter(text='unused')
        results = await run(SIMPLE_PROMPT, [], adapter)
        assert results == []
        adapter.generate.assert_not_awaited()

    @pytest.mark.asyncio
    async def test_batch_reuses_provided_dp(self) -> None:
        from dotpromptz import Dotprompt

        dp = Dotprompt()
        adapter = _make_adapter(text='reused')
        inputs = [{'topic': 'AI'}]
        results = await run(SIMPLE_PROMPT, inputs, adapter, dp=dp)

        assert len(results) == 1
        assert results[0].status == 'success'


class TestBatchResult:
    def test_dataclass_defaults(self) -> None:
        r = BatchResult(index=0, input={'a': 1}, status='success')
        assert r.response is None
        assert r.error is None

    def test_success_result(self) -> None:
        resp = GenerateResponse(text='hello')
        r = BatchResult(index=1, input={'x': 'y'}, status='success', response=resp)
        assert r.status == 'success'
        assert r.response.text == 'hello'
        assert r.error is None

    def test_error_result(self) -> None:
        r = BatchResult(index=2, input={'x': 'y'}, status='error', error='boom')
        assert r.status == 'error'
        assert r.response is None
        assert r.error == 'boom'


class TestResolveAdapter:
    def test_adapter_config_object(self) -> None:
        from unittest.mock import patch

        from dotpromptz.credentials import Credential, CredentialPool
        from dotpromptz.typing import AdapterConfig

        cred = Credential(name='test', adapter='openai', api_key='sk-test')
        pool = CredentialPool([cred])
        mock_rendered = MagicMock()
        mock_rendered.adapter = AdapterConfig(name='openai')
        mock_rendered.config = {}

        with patch('dotpromptz.runner.get_adapter') as mock_get_adapter:
            resolve_adapter(mock_rendered, pool=pool)
            mock_get_adapter.assert_called_once_with('openai', credential=cred)

    def test_adapter_config_with_group(self) -> None:
        from unittest.mock import patch

        from dotpromptz.credentials import Credential, CredentialPool
        from dotpromptz.typing import AdapterConfig

        cred_east = Credential(name='east', adapter='openai', api_key='sk-east')
        cred_west = Credential(name='west', adapter='openai', api_key='sk-west')
        pool = CredentialPool([cred_east, cred_west])
        mock_rendered = MagicMock()
        mock_rendered.adapter = AdapterConfig(name='openai', group='east')
        mock_rendered.config = {}

        with patch('dotpromptz.runner.get_adapter') as mock_get_adapter:
            resolve_adapter(mock_rendered, pool=pool)
            mock_get_adapter.assert_called_once_with('openai', credential=cred_east)

    def test_string_adapter(self) -> None:
        from unittest.mock import patch

        from dotpromptz.credentials import Credential, CredentialPool

        cred = Credential(name='main', adapter='anthropic', api_key='sk-test')
        pool = CredentialPool([cred])
        mock_rendered = MagicMock()
        mock_rendered.adapter = 'anthropic'
        mock_rendered.config = {}

        with patch('dotpromptz.runner.get_adapter') as mock_get_adapter:
            resolve_adapter(mock_rendered, pool=pool)
            mock_get_adapter.assert_called_once_with('anthropic', credential=cred)

    def test_none_adapter_raises_value_error(self) -> None:
        mock_rendered = MagicMock()
        mock_rendered.adapter = None

        with pytest.raises(ValueError, match='No adapter specified'):
            resolve_adapter(mock_rendered)

    def test_empty_string_adapter_raises_value_error(self) -> None:
        mock_rendered = MagicMock()
        mock_rendered.adapter = ''

        with pytest.raises(ValueError, match='No adapter specified'):
            resolve_adapter(mock_rendered)


class TestSerializeResult:
    def test_json_dict(self) -> None:
        import json

        result = serialize_result({'key': 'value'}, 'json')
        assert json.loads(result) == {'key': 'value'}

    def test_json_string_pretty_prints(self) -> None:
        import json

        result = serialize_result('{"a": 1}', 'json')
        parsed = json.loads(result)
        assert parsed == {'a': 1}
        # Pretty-printed means it has newlines
        assert '\n' in result

    def test_json_non_json_string(self) -> None:
        import json

        result = serialize_result('plain text', 'json')
        assert json.loads(result) == 'plain text'

    def test_txt_format(self) -> None:
        result = serialize_result({'key': 'value'}, 'txt')
        assert result == str({'key': 'value'})

    def test_txt_string(self) -> None:
        result = serialize_result('hello', 'txt')
        assert result == 'hello'

    def test_yaml_format(self) -> None:
        result = serialize_result({'name': 'Alice', 'age': 30}, 'yaml')
        assert 'name: Alice' in result
        assert 'age: 30' in result

    def test_yaml_from_json_string(self) -> None:
        result = serialize_result('{"x": 1}', 'yaml')
        assert 'x: 1' in result

    def test_yaml_missing_raises(self) -> None:
        # yaml is installed in this env; verify the error message format
        # by confirming the RuntimeError path exists in serialize_result.
        pass  # yaml is installed — cannot realistically unload C extension


class TestPartToDict:
    def test_text_part(self) -> None:
        from dotpromptz.typing import TextPart

        result = part_to_dict(TextPart(text='hello'))
        assert result == {'text': 'hello'}

    def test_media_part(self) -> None:
        from dotpromptz.typing import MediaContent, MediaPart

        part = MediaPart(media=MediaContent(url='https://example.com/img.png', content_type='image/png'))
        result = part_to_dict(part)
        assert 'media' in result
        assert result['media']['url'] == 'https://example.com/img.png'

    def test_data_part(self) -> None:
        from dotpromptz.typing import DataPart

        result = part_to_dict(DataPart(data={'key': 'value'}))
        assert result == {'data': {'key': 'value'}}

    def test_tool_request_part(self) -> None:
        from dotpromptz.typing import ToolRequestContent, ToolRequestPart

        part = ToolRequestPart(tool_request=ToolRequestContent(name='search', input={'q': 'test'}))
        result = part_to_dict(part)
        assert 'tool_request' in result
        assert result['tool_request']['name'] == 'search'

    def test_tool_response_part(self) -> None:
        from dotpromptz.typing import ToolResponseContent, ToolResponsePart

        part = ToolResponsePart(tool_response=ToolResponseContent(name='search', output={'result': 1}))
        result = part_to_dict(part)
        assert 'tool_response' in result
        assert result['tool_response']['name'] == 'search'

    def test_unknown_part_type(self) -> None:
        class FakePart:
            pass

        result = part_to_dict(FakePart())
        assert result == {'type': 'FakePart'}


class TestWriteOutput:
    def test_writes_text_file(self, tmp_path: Path) -> None:
        from dotpromptz.typing import PromptOutputConfig

        cfg = PromptOutputConfig(format='json', file_name='output')
        result = write_output('{"a": 1}', cfg, tmp_path, 'result')
        assert result == tmp_path / 'result.json'
        assert result.read_text(encoding='utf-8') == '{"a": 1}'

    def test_writes_bytes_file(self, tmp_path: Path) -> None:
        from dotpromptz.typing import PromptOutputConfig

        cfg = PromptOutputConfig(format='image', file_name='output')
        data = b'\x89PNG\r\n'
        result = write_output(data, cfg, tmp_path, 'pic')
        assert result == tmp_path / 'pic.png'
        assert result.read_bytes() == data

    def test_creates_nested_directories(self, tmp_path: Path) -> None:
        from dotpromptz.typing import PromptOutputConfig

        cfg = PromptOutputConfig(format='txt', file_name='output')
        nested = tmp_path / 'a' / 'b' / 'c'
        result = write_output('hello', cfg, nested, 'out')
        assert result == nested / 'out.txt'
        assert result.read_text(encoding='utf-8') == 'hello'

    def test_yaml_extension(self, tmp_path: Path) -> None:
        from dotpromptz.typing import PromptOutputConfig

        cfg = PromptOutputConfig(format='yaml', file_name='output')
        result = write_output('name: Alice', cfg, tmp_path, 'data')
        assert result.suffix == '.yaml'

    def test_overwrites_existing_file(self, tmp_path: Path) -> None:
        from dotpromptz.typing import PromptOutputConfig

        cfg = PromptOutputConfig(format='txt', file_name='output')
        filepath = tmp_path / 'out.txt'
        filepath.write_text('old', encoding='utf-8')
        write_output('new', cfg, tmp_path, 'out')
        assert filepath.read_text(encoding='utf-8') == 'new'


class TestExtractResponseContent:
    def test_text_json(self) -> None:
        response = GenerateResponse(text='{"key": "value"}')
        result = extract_response_content(response, 'json')
        assert isinstance(result, str)
        parsed = json.loads(result)
        assert parsed == {'key': 'value'}

    def test_text_yaml(self) -> None:
        response = GenerateResponse(text='{"x": 1}')
        result = extract_response_content(response, 'yaml')
        assert isinstance(result, str)
        assert 'x: 1' in result

    def test_text_txt(self) -> None:
        response = GenerateResponse(text='plain output')
        result = extract_response_content(response, 'txt')
        assert result == 'plain output'

    def test_image_returns_bytes(self) -> None:
        img_data = b'\x89PNG_DATA'
        response = GenerateResponse(image=ImageResult(data=img_data, mime_type='image/png'))
        result = extract_response_content(response, 'image')
        assert isinstance(result, bytes)
        assert result == img_data

    def test_image_no_data_raises(self) -> None:
        response = GenerateResponse(text='no image here')
        with pytest.raises(ValueError, match='Expected image output'):
            extract_response_content(response, 'image')

    def test_tool_calls_json(self) -> None:
        tc = ToolCallResult(id='tc1', name='search', arguments={'q': 'test'})
        response = GenerateResponse(tool_calls=[tc])
        result = extract_response_content(response, 'json')
        assert isinstance(result, str)
        parsed = json.loads(result)
        assert isinstance(parsed, list)
        assert parsed[0]['name'] == 'search'

    def test_empty_response(self) -> None:
        response = GenerateResponse()
        result = extract_response_content(response, 'json')
        assert result == ''

    def test_text_takes_priority_over_tool_calls(self) -> None:
        tc = ToolCallResult(id='tc1', name='search', arguments={'q': 'test'})
        response = GenerateResponse(text='priority text', tool_calls=[tc])
        result = extract_response_content(response, 'txt')
        assert result == 'priority text'


class TestAggregateBatchResults:
    def test_basic_aggregation(self) -> None:
        results = [
            BatchResult(index=0, input={'a': '1'}, status='success', response=GenerateResponse(text='{"v": 1}')),
            BatchResult(index=1, input={'a': '2'}, status='success', response=GenerateResponse(text='{"v": 2}')),
        ]
        agg = aggregate_batch_results(results, 'json', 'out')
        assert 'out_0' in agg
        assert 'out_1' in agg
        assert agg['out_0'] == {'v': 1}
        assert agg['out_1'] == {'v': 2}

    def test_error_results(self) -> None:
        results = [
            BatchResult(index=0, input={}, status='error', error='boom'),
        ]
        agg = aggregate_batch_results(results, 'json', 'result')
        assert agg['result_0'] == {'error': 'boom'}

    def test_tool_calls(self) -> None:
        tc = ToolCallResult(id='tc1', name='fn', arguments={'x': 1})
        results = [
            BatchResult(index=0, input={}, status='success', response=GenerateResponse(tool_calls=[tc])),
        ]
        agg = aggregate_batch_results(results, 'json', 'out')
        assert isinstance(agg['out_0'], list)
        assert agg['out_0'][0]['name'] == 'fn'

    def test_empty_response_is_none(self) -> None:
        results = [
            BatchResult(index=0, input={}, status='success', response=GenerateResponse()),
        ]
        agg = aggregate_batch_results(results, 'json', 'out')
        assert agg['out_0'] is None

    def test_txt_format_json_parse(self) -> None:
        results = [
            BatchResult(index=0, input={}, status='success', response=GenerateResponse(text='{"v": 1}')),
        ]
        agg = aggregate_batch_results(results, 'txt', 'out')
        # txt format now parses JSON — same as json/yaml formats
        assert agg['out_0'] == {'v': 1}

    def test_unparseable_json_keeps_string(self) -> None:
        results = [
            BatchResult(index=0, input={}, status='success', response=GenerateResponse(text='not json')),
        ]
        agg = aggregate_batch_results(results, 'json', 'out')
        assert agg['out_0'] == 'not json'

    def test_empty_results_list(self) -> None:
        agg = aggregate_batch_results([], 'json', 'out')
        assert agg == {}

    def test_yaml_format_parses_json(self) -> None:
        results = [
            BatchResult(index=0, input={}, status='success', response=GenerateResponse(text='{"k": "v"}')),
        ]
        agg = aggregate_batch_results(results, 'yaml', 'out')
        assert agg['out_0'] == {'k': 'v'}

    def test_with_prompt_path_resolves_template_keys(self) -> None:
        """When prompt_path is provided, dict keys use Handlebars-resolved file names."""
        results = [
            BatchResult(index=0, input={'lang': 'en'}, status='success', response=GenerateResponse(text='{"v": 1}')),
            BatchResult(index=1, input={'lang': 'zh'}, status='success', response=GenerateResponse(text='{"v": 2}')),
        ]
        agg = aggregate_batch_results(
            results, 'json', 'translation_{{lang}}_{{INDEX}}', prompt_path=Path('/tmp/translate.prompt'),
        )
        assert 'translation_en_0' in agg
        assert 'translation_zh_1' in agg
        assert agg['translation_en_0'] == {'v': 1}
        assert agg['translation_zh_1'] == {'v': 2}

    def test_with_prompt_path_at_name(self) -> None:
        """NAME resolves to the .prompt file stem in aggregation keys."""
        results = [
            BatchResult(index=0, input={}, status='success', response=GenerateResponse(text='"ok"')),
        ]
        agg = aggregate_batch_results(
            results, 'json', '{{NAME}}_{{INDEX}}', prompt_path=Path('/tmp/my_task.prompt'),
        )
        assert 'my_task_0' in agg

    def test_without_prompt_path_uses_fallback(self) -> None:
        """Without prompt_path, dict keys use the old '{tpl}_{index}' format."""
        results = [
            BatchResult(index=0, input={'lang': 'en'}, status='success', response=GenerateResponse(text='{"v": 1}')),
        ]
        agg = aggregate_batch_results(results, 'json', 'translation_{{lang}}')
        # No prompt_path → fallback to static template + index
        assert 'translation_{{lang}}_0' in agg

# ── Retry / Timeout / Error-handling tests ──────────────────────────────────


def _default_retry_cfg(**overrides: Any) -> RetryConfig:
    """Create a RetryConfig with sensible test defaults."""
    defaults: dict[str, Any] = {'max_retries': 3, 'retry_delay': 0.001}
    defaults.update(overrides)
    return RetryConfig(**defaults)


class TestComputeDelay:
    """Tests for ``_compute_delay``."""

    def test_linear_default(self) -> None:
        cfg = _default_retry_cfg(retry_delay=1.0)
        assert _compute_delay(0, cfg) == 1.0  # 1.0 * (0+1)
        assert _compute_delay(1, cfg) == 2.0  # 1.0 * (1+1)
        assert _compute_delay(2, cfg) == 3.0  # 1.0 * (2+1)

    def test_linear_custom_delay(self) -> None:
        cfg = _default_retry_cfg(retry_delay=2.5)
        assert _compute_delay(0, cfg) == 2.5  # 2.5 * (0+1)
        assert _compute_delay(1, cfg) == 5.0  # 2.5 * (1+1)
        assert _compute_delay(2, cfg) == 7.5  # 2.5 * (2+1)


class TestExecuteWithRetry:
    """Tests for ``_execute_with_retry``."""

    @pytest.mark.asyncio
    async def test_success_no_retry(self) -> None:
        async def _ok(attempt: int) -> str:
            return 'ok'

        result, attempts = await _execute_with_retry(
            _ok,
            retry_cfg=None,
            timeout=None,
        )
        assert result == 'ok'
        assert attempts == 1

    @pytest.mark.asyncio
    async def test_success_after_retries(self) -> None:
        call_count = 0

        async def _factory(attempt: int) -> str:
            nonlocal call_count
            call_count += 1
            if call_count < 3:
                raise Exception(f'fail {call_count}')
            return 'ok'

        cfg = _default_retry_cfg(max_retries=3, retry_delay=0.001)
        result, attempts = await _execute_with_retry(
            _factory,
            retry_cfg=cfg,
            timeout=None,
        )
        assert result == 'ok'
        assert attempts == 3

    @pytest.mark.asyncio
    async def test_exhausts_retries_then_raises(self) -> None:
        async def _always_fail(attempt: int) -> str:
            raise Exception('persistent')

        cfg = _default_retry_cfg(max_retries=2, retry_delay=0.001)
        with pytest.raises(Exception, match='persistent'):
            await _execute_with_retry(
                _always_fail,
                retry_cfg=cfg,
                timeout=None,
            )

    @pytest.mark.asyncio
    async def test_timeout_triggers_retry(self) -> None:
        call_count = 0

        async def _factory(attempt: int) -> str:
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                await asyncio.sleep(10)
            return 'ok'

        cfg = _default_retry_cfg(max_retries=2, retry_delay=0.001)
        result, attempts = await _execute_with_retry(
            _factory,
            retry_cfg=cfg,
            timeout=0.05,
        )
        assert result == 'ok'
        assert attempts == 2

    @pytest.mark.asyncio
    async def test_timeout_all_attempts_raises(self) -> None:
        async def _slow(attempt: int) -> str:
            await asyncio.sleep(10)
            return 'never'

        cfg = _default_retry_cfg(max_retries=1, retry_delay=0.001)
        with pytest.raises(asyncio.TimeoutError):
            await _execute_with_retry(
                _slow,
                retry_cfg=cfg,
                timeout=0.01,
            )

    @pytest.mark.asyncio
    async def test_no_retry_cfg_raises_on_error(self) -> None:
        async def _fail(attempt: int) -> str:
            raise RuntimeError('no retry')

        with pytest.raises(RuntimeError, match='no retry'):
            await _execute_with_retry(
                _fail,
                retry_cfg=None,
                timeout=None,
            )


class TestRunBatchWithRetry:
    """Integration tests for ``run`` with retry/timeout/abort."""

    @pytest.mark.asyncio
    async def test_batch_retries_transient_errors(self) -> None:
        call_counts: dict[int, int] = {}

        async def _generate(rendered: Any, **kwargs: Any) -> GenerateResponse:
            idx = id(rendered)
            call_counts[idx] = call_counts.get(idx, 0) + 1
            if call_counts[idx] == 1:
                raise Exception('transient')
            return GenerateResponse(text='ok')

        adapter = MagicMock(spec=Adapter)
        adapter.generate = AsyncMock(side_effect=_generate)

        runtime = RuntimeConfig(
            retry=RetryConfig(max_retries=2, retry_delay=0.001),
        )
        inputs = [{'topic': 'AI'}, {'topic': 'ML'}]
        results = await run(
            SIMPLE_PROMPT,
            inputs,
            adapter,
            runtime=runtime,
        )

        assert len(results) == 2
        assert all(r.status == 'success' for r in results)
        assert all(r.attempts >= 1 for r in results)

    @pytest.mark.asyncio
    async def test_batch_timeout(self) -> None:
        async def _slow_generate(rendered: Any, **kwargs: Any) -> GenerateResponse:
            await asyncio.sleep(10)
            return GenerateResponse(text='never')

        adapter = MagicMock(spec=Adapter)
        adapter.generate = AsyncMock(side_effect=_slow_generate)

        runtime = RuntimeConfig(
            timeout=0.01,
            retry=RetryConfig(max_retries=1, retry_delay=0.001),
        )
        results = await run(
            SIMPLE_PROMPT,
            [{'topic': 'AI'}],
            adapter,
            runtime=runtime,
        )

        assert len(results) == 1
        assert results[0].status == 'error'

    @pytest.mark.asyncio
    async def test_batch_no_runtime_backward_compat(self) -> None:
        """Without runtime arg, batch works exactly as before (no retry)."""
        adapter = _make_adapter(text='hello')
        results = await run(
            SIMPLE_PROMPT,
            [{'topic': 'AI'}],
            adapter,
        )
        assert len(results) == 1
        assert results[0].status == 'success'
        assert results[0].attempts == 1
        assert results[0].elapsed >= 0.0

    @pytest.mark.asyncio
    async def test_batch_elapsed_tracked(self) -> None:
        """BatchResult.elapsed is populated on success."""

        async def _slow_generate(rendered: Any, **kwargs: Any) -> GenerateResponse:
            await asyncio.sleep(0.02)
            return GenerateResponse(text='done')

        adapter = MagicMock(spec=Adapter)
        adapter.generate = AsyncMock(side_effect=_slow_generate)

        results = await run(
            SIMPLE_PROMPT,
            [{'topic': 'AI'}],
            adapter,
        )
        assert results[0].elapsed >= 0.02

    @pytest.mark.asyncio
    async def test_batch_on_item_complete_with_retry(self) -> None:
        call_count = 0

        async def _generate(rendered: Any, **kwargs: Any) -> GenerateResponse:
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                raise Exception('transient')
            return GenerateResponse(text='recovered')

        adapter = MagicMock(spec=Adapter)
        adapter.generate = AsyncMock(side_effect=_generate)

        completed: list[BatchResult] = []
        runtime = RuntimeConfig(
            retry=RetryConfig(max_retries=2, retry_delay=0.001),
        )
        await run(
            SIMPLE_PROMPT,
            [{'topic': 'AI'}],
            adapter,
            on_item_complete=lambda r: completed.append(r),
            runtime=runtime,
        )

        assert len(completed) == 1
        assert completed[0].status == 'success'
