"""Tests for the dotprompt CLI."""

from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock

import pytest

from dotpromptz.cli import (
    _build_rendered_output,
    _merge_cli,
    _prepare_prompt_inputs,
    _resolve_adapter,
    _resolve_file_name,
    _resolve_output_dir,
    _run_batch,
    cli,
)
from dotpromptz.typing import Message, ParsedPrompt, PromptOutputConfig, RenderedPrompt, Role, TextPart

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

PROMPT_WITH_INLINE_DATA = """\
---
config:
  model: gpt-4o
input:
  data:
    topic: AI
---
Tell me about {{topic}}.
"""


class TestCLIArguments:
    def test_no_arguments_shows_error(self, capsys: pytest.CaptureFixture[str]) -> None:
        with pytest.raises(SystemExit) as exc_info:
            cli([])
        assert exc_info.value.code == 2

    def test_nonexistent_file_shows_error(self, capsys: pytest.CaptureFixture[str]) -> None:
        with pytest.raises(SystemExit) as exc_info:
            cli(['/nonexistent/file.prompt'])
        assert exc_info.value.code == 2
        captured = capsys.readouterr()
        assert 'does not exist' in captured.err.lower() or 'exist' in captured.err.lower()

    def test_help_flag_works(self, capsys: pytest.CaptureFixture[str]) -> None:
        with pytest.raises(SystemExit) as exc_info:
            cli(['--help'])
        assert exc_info.value.code == 0
        captured = capsys.readouterr()
        assert 'run' in captured.out
        assert 'build' in captured.out
        assert 'merge' in captured.out
        assert '.prompt' in captured.out

    def test_run_force_flag_passed_to_async(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
        prompt_file = tmp_path / 'force.prompt'
        prompt_file.write_text('---\noutput:\n  format: txt\n  file_name: out\n---\nHello', encoding='utf-8')

        captured: dict[str, object] = {}

        def _fake_run_cli_async(*, prompt_file: str, log_dir: Path, force: bool = False) -> None:
            captured['prompt_file'] = prompt_file
            captured['log_dir'] = log_dir
            captured['force'] = force

        monkeypatch.setattr('dotpromptz.cli._start_update_check', lambda: None)
        monkeypatch.setattr('dotpromptz.cli.configure_logging', lambda level: tmp_path)
        monkeypatch.setattr('dotpromptz.cli._run_cli_async', _fake_run_cli_async)
        monkeypatch.setattr('dotpromptz.cli.asyncio.run', lambda coro: None)

        cli(['run', str(prompt_file), '--force'])

        assert captured['prompt_file'] == str(prompt_file)
        assert captured['log_dir'] == tmp_path
        assert captured['force'] is True


class TestResolveAdapterCLIWrapper:
    """Tests that the CLI wrapper converts ValueError → sys.exit(2)."""

    def test_none_adapter_exits(self) -> None:
        mock_rendered = MagicMock()
        mock_rendered.adapter = None
        mock_rendered.config = {'model': 'gpt-4o'}

        with pytest.raises(SystemExit) as exc_info:
            _resolve_adapter(mock_rendered)
        assert exc_info.value.code == 2

    def test_no_adapter_no_model_exits(self) -> None:
        mock_rendered = MagicMock()
        mock_rendered.adapter = None
        mock_rendered.config = {}

        with pytest.raises(SystemExit) as exc_info:
            _resolve_adapter(mock_rendered)
        assert exc_info.value.code == 2


class TestMergeCLI:
    def test_merge_dispatches_to_merge_cli(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
        calls: dict[str, object] = {}

        def _fake_merge_cli(
            *,
            directory: str,
            pattern: str | None,
            with_filename: bool,
            force: bool,
        ) -> None:
            calls['directory'] = directory
            calls['pattern'] = pattern
            calls['with_filename'] = with_filename
            calls['force'] = force

        monkeypatch.setattr('dotpromptz.cli._start_update_check', lambda: None)
        monkeypatch.setattr('dotpromptz.cli._merge_cli', _fake_merge_cli)

        cli(['merge', str(tmp_path), '--pattern', '*.json', '-W', '--force'])

        assert calls == {
            'directory': str(tmp_path),
            'pattern': '*.json',
            'with_filename': True,
            'force': True,
        }

    def test_merge_cli_value_error_exits_2(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
        def _raise_error(*args: object, **kwargs: object) -> Path:
            raise ValueError('bad merge')

        monkeypatch.setattr('dotpromptz.cli.merge_directory', _raise_error)

        with pytest.raises(SystemExit) as exc_info:
            _merge_cli(
                directory=str(tmp_path),
                pattern='*.json',
                with_filename=False,
                force=False,
            )
        assert exc_info.value.code == 2


class TestResolveFileName:
    """Tests for _resolve_file_name — single Jinja2 pass with UPPERCASE variable keys."""

    def test_static_file_name_unchanged(self) -> None:
        result = _resolve_file_name('report', Path('/tmp/my_prompt.prompt'))
        assert result == 'report'

    def test_at_name_resolves_to_prompt_stem(self) -> None:
        result = _resolve_file_name('{{NAME}}_output', Path('/tmp/my_prompt.prompt'))
        assert result == 'my_prompt_output'

    def test_at_time_resolves_to_timestamp(self) -> None:
        result = _resolve_file_name('{{TIME}}', Path('/tmp/x.prompt'))
        # Timestamp format: YYYYMMDD_HHMMSS (15 chars)
        assert len(result) == 15
        assert result[8] == '_'

    def test_at_index_resolves_when_provided(self) -> None:
        result = _resolve_file_name('item_{{INDEX}}', Path('/tmp/x.prompt'), index=3)
        assert result == 'item_3'

    def test_at_index_absent_when_not_provided(self) -> None:
        result = _resolve_file_name('item_{{INDEX}}', Path('/tmp/x.prompt'))
        # INDEX not provided → Jinja2 renders empty string for missing data var
        assert result == 'item_'

    def test_input_variable_resolves(self) -> None:
        result = _resolve_file_name('profile_{{name}}', Path('/tmp/x.prompt'), item_vars={'name': 'alice'})
        assert result == 'profile_alice'

    def test_multiple_variables_combined(self) -> None:
        result = _resolve_file_name(
            '{{NAME}}_{{lang}}_{{INDEX}}',
            Path('/tmp/translate.prompt'),
            item_vars={'lang': 'zh'},
            index=2,
        )
        assert result == 'translate_zh_2'

    def test_at_name_and_input_name_coexist(self) -> None:
        """{{NAME}} and {{name}} resolve independently — no shadowing.

        UPPERCASE auto-variable keys (NAME) in Jinja2 context
        never conflict with lowercase input variables (name).
        """
        result = _resolve_file_name(
            '{{NAME}}_{{name}}',
            Path('/tmp/my_prompt.prompt'),
            item_vars={'name': 'alice'},
        )
        assert result == 'my_prompt_alice'

    def test_different_named_vars_no_conflict(self) -> None:
        """Input var 'user' does not conflict with NAME."""
        result = _resolve_file_name(
            '{{NAME}}_{{user}}',
            Path('/tmp/my_prompt.prompt'),
            item_vars={'user': 'alice'},
        )
        assert result == 'my_prompt_alice'

    def test_empty_item_vars(self) -> None:
        result = _resolve_file_name('{{NAME}}', Path('/tmp/x.prompt'), item_vars={})
        assert result == 'x'

    def test_none_item_vars(self) -> None:
        result = _resolve_file_name('{{NAME}}', Path('/tmp/x.prompt'), item_vars=None)
        assert result == 'x'


class TestResolveOutputDir:
    def test_relative_path_is_based_on_prompt_directory(self) -> None:
        prompt_path = Path('/tmp/workspace/prompts/task.prompt')
        result = _resolve_output_dir('out/{{NAME}}', prompt_path)
        assert result == Path('/tmp/workspace/prompts/out/task')

    def test_absolute_path_keeps_absolute(self) -> None:
        prompt_path = Path('/tmp/workspace/prompts/task.prompt')
        result = _resolve_output_dir('/var/tmp/result', prompt_path)
        assert result == Path('/var/tmp/result')

    def test_default_output_dir_is_based_on_prompt_directory(self) -> None:
        prompt_path = Path('/tmp/workspace/prompts/task.prompt')
        result = _resolve_output_dir(None, prompt_path)

        assert result.parent == Path('/tmp/workspace/prompts/output')
        assert result.name.startswith('task_')
        assert len(result.name) == len('task_YYYYMMDD_HHMMSS')


class TestBuildRenderedOutput:
    def test_handles_string_tools_without_crash(self) -> None:
        rendered = RenderedPrompt(
            messages=[Message(role=Role.USER, content=[TextPart(text='hello')])],
            tools=['search'],
        )

        output = _build_rendered_output(rendered)

        assert output['tools'] == ['search']


class TestPreparePromptInputs:
    def test_returns_defaults_when_no_records(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
        prompt_file = tmp_path / 'a.prompt'
        prompt_file.write_text('---\n---\nHello', encoding='utf-8')

        parsed = ParsedPrompt(
            template='Hello',
            input={'defaults': {'topic': 'AI'}},
            tool_defs=None,
        )

        monkeypatch.setattr('dotpromptz.cli.parse_document', lambda source: parsed)
        monkeypatch.setattr('dotpromptz.cli.adapt_prompt', lambda p: p)
        monkeypatch.setattr('dotpromptz.cli.resolve_input', lambda raw_input, path, defaults=None: [])

        prompt_path, source, input_list = _prepare_prompt_inputs(str(prompt_file))

        assert prompt_path == prompt_file
        assert source == '---\n---\nHello'
        assert input_list == [{'topic': 'AI'}]

    def test_returns_single_empty_record_when_no_input_and_no_defaults(
        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
    ) -> None:
        prompt_file = tmp_path / 'b.prompt'
        prompt_file.write_text('---\n---\nHello', encoding='utf-8')

        parsed = ParsedPrompt(template='Hello', input=None, tool_defs=None)

        monkeypatch.setattr('dotpromptz.cli.parse_document', lambda source: parsed)
        monkeypatch.setattr('dotpromptz.cli.adapt_prompt', lambda p: p)
        monkeypatch.setattr('dotpromptz.cli.resolve_input', lambda raw_input, path, defaults=None: [])

        _, _, input_list = _prepare_prompt_inputs(str(prompt_file))

        assert input_list == [{}]

    def test_uses_files_branch_when_files_is_configured(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
        prompt_file = tmp_path / 'c.prompt'
        prompt_file.write_text('---\n---\nHello', encoding='utf-8')

        parsed = ParsedPrompt(
            template='Hello',
            input={'files': 'data.json', 'defaults': {'lang': 'zh'}},
            tool_defs=None,
        )

        monkeypatch.setattr('dotpromptz.cli.parse_document', lambda source: parsed)
        monkeypatch.setattr('dotpromptz.cli.adapt_prompt', lambda p: p)
        monkeypatch.setattr(
            'dotpromptz.cli.resolve_files',
            lambda files_config, path, defaults=None: [{'topic': 'AI', 'lang': 'zh'}],
        )
        monkeypatch.setattr(
            'dotpromptz.cli.resolve_input',
            lambda raw_input, path, defaults=None: (_ for _ in ()).throw(AssertionError('should not be called')),
        )

        _, _, input_list = _prepare_prompt_inputs(str(prompt_file))

        assert input_list == [{'topic': 'AI', 'lang': 'zh'}]


@pytest.mark.asyncio
async def test_run_batch_image_missing_data_exits(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
    """Image output missing binary data should fail fast with exit code 1."""
    parsed_prompt = ParsedPrompt(
        template='Draw cat',
        output=PromptOutputConfig(format='image', file_name='img'),
        tool_defs=None,
    )
    prompt_path = tmp_path / 'image.prompt'
    prompt_path.write_text('dummy', encoding='utf-8')

    class _FakeDotprompt:
        def render(self, source: str, **kwargs: object) -> object:
            return object()

    async def _fake_run(*args: object, **kwargs: object) -> list[SimpleNamespace]:
        result = SimpleNamespace(
            index=0,
            input={'topic': 'cat'},
            status='success',
            response=object(),
            error=None,
        )
        on_item_complete = kwargs.get('on_item_complete')
        assert callable(on_item_complete)
        on_item_complete(result)
        return [result]

    def _raise_no_image(response: object, fmt: str) -> bytes:
        raise ValueError('Expected image output but model returned no image data.')

    monkeypatch.setattr('dotpromptz.cli.parse_document', lambda source: parsed_prompt)
    monkeypatch.setattr('dotpromptz.cli.Dotprompt', _FakeDotprompt)
    monkeypatch.setattr('dotpromptz.cli._resolve_adapter', lambda rendered: object())
    monkeypatch.setattr('dotpromptz.cli.run', _fake_run)
    monkeypatch.setattr('dotpromptz.cli._runner_extract_response_content', _raise_no_image)

    with pytest.raises(SystemExit) as exc_info:
        await _run_batch('ignored', [{'topic': 'cat'}], prompt_path)
    assert exc_info.value.code == 1


@pytest.mark.asyncio
async def test_run_batch_writes_each_item_in_on_complete(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
    parsed_prompt = ParsedPrompt(
        template='Tell me about {{topic}}.',
        output=PromptOutputConfig(format='txt', file_name='out_{{INDEX}}'),
        tool_defs=None,
    )
    prompt_path = tmp_path / 'batch.prompt'
    prompt_path.write_text('dummy', encoding='utf-8')

    events: list[str] = []
    writes: list[Path] = []

    class _FakeDotprompt:
        def render(self, source: str, **kwargs: object) -> object:
            return object()

    async def _fake_run(*args: object, **kwargs: object) -> list[SimpleNamespace]:
        events.append('run_start')
        on_item_complete = kwargs.get('on_item_complete')
        assert callable(on_item_complete)
        result = SimpleNamespace(
            index=0,
            input={'topic': 'AI'},
            status='success',
            response=SimpleNamespace(text='ok'),
            error=None,
        )
        on_item_complete(result)
        events.append('run_return')
        return [result]

    def _fake_extract(response: object, fmt: str) -> str:
        return 'result'

    def _fake_write(
        content: str | bytes,
        output_config: PromptOutputConfig,
        output_dir: Path,
        file_name: str,
        *,
        force: bool = False,
    ) -> Path:
        events.append('write')
        path = output_dir / f'{file_name}.{output_config.format}'
        writes.append(path)
        return path

    monkeypatch.setattr('dotpromptz.cli.parse_document', lambda source: parsed_prompt)
    monkeypatch.setattr('dotpromptz.cli.Dotprompt', _FakeDotprompt)
    monkeypatch.setattr('dotpromptz.cli._resolve_adapter', lambda rendered: object())
    monkeypatch.setattr('dotpromptz.cli.run', _fake_run)
    monkeypatch.setattr('dotpromptz.cli._runner_extract_response_content', _fake_extract)
    monkeypatch.setattr('dotpromptz.cli._write_output', _fake_write)

    await _run_batch('ignored', [{'topic': 'AI'}], prompt_path)

    assert events == ['run_start', 'write', 'run_return']
    assert len(writes) == 1


@pytest.mark.asyncio
async def test_run_batch_relative_output_dir_is_based_on_prompt_directory(
    monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
    prompt_dir = tmp_path / 'prompts'
    prompt_dir.mkdir()
    prompt_path = prompt_dir / 'batch.prompt'
    prompt_path.write_text('dummy', encoding='utf-8')

    parsed_prompt = ParsedPrompt(
        template='Tell me about {{topic}}.',
        output=PromptOutputConfig(format='txt', output_dir='outputs', file_name='out'),
        tool_defs=None,
    )

    class _FakeDotprompt:
        def render(self, source: str, **kwargs: object) -> object:
            return object()

    async def _fake_run(*args: object, **kwargs: object) -> list[SimpleNamespace]:
        on_item_complete = kwargs.get('on_item_complete')
        assert callable(on_item_complete)
        result = SimpleNamespace(
            index=0,
            input={'topic': 'AI'},
            status='success',
            response=SimpleNamespace(text='ok'),
            error=None,
        )
        on_item_complete(result)
        return [result]

    def _fake_extract(response: object, fmt: str) -> str:
        return 'result'

    written_dirs: list[Path] = []

    def _fake_write(
        content: str | bytes,
        output_config: PromptOutputConfig,
        output_dir: Path,
        file_name: str,
        *,
        force: bool = False,
    ) -> Path:
        written_dirs.append(output_dir)
        return output_dir / f'{file_name}.{output_config.format}'

    monkeypatch.setattr('dotpromptz.cli.parse_document', lambda source: parsed_prompt)
    monkeypatch.setattr('dotpromptz.cli.Dotprompt', _FakeDotprompt)
    monkeypatch.setattr('dotpromptz.cli._resolve_adapter', lambda rendered: object())
    monkeypatch.setattr('dotpromptz.cli.run', _fake_run)
    monkeypatch.setattr('dotpromptz.cli._runner_extract_response_content', _fake_extract)
    monkeypatch.setattr('dotpromptz.cli._write_output', _fake_write)

    await _run_batch('ignored', [{'topic': 'AI'}], prompt_path)

    assert written_dirs == [prompt_dir / 'outputs']


@pytest.mark.asyncio
async def test_run_batch_duplicate_output_path_exits_before_runner(
    monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
    parsed_prompt = ParsedPrompt(
        template='Tell me about {{topic}}.',
        output=PromptOutputConfig(format='txt', file_name='same'),
        tool_defs=None,
    )
    prompt_path = tmp_path / 'dup.prompt'
    prompt_path.write_text('dummy', encoding='utf-8')

    called = False

    async def _fake_run(*args: object, **kwargs: object) -> list[SimpleNamespace]:
        nonlocal called
        called = True
        return []

    monkeypatch.setattr('dotpromptz.cli.parse_document', lambda source: parsed_prompt)
    monkeypatch.setattr('dotpromptz.cli.run', _fake_run)

    with pytest.raises(SystemExit) as exc_info:
        await _run_batch('ignored', [{'topic': 'a'}, {'topic': 'b'}], prompt_path)

    assert exc_info.value.code == 2
    assert called is False


@pytest.mark.asyncio
async def test_run_batch_existing_output_path_exits_before_runner(
    monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
    output_dir = tmp_path / 'out'
    output_dir.mkdir()
    existing_file = output_dir / 'item_0.txt'
    existing_file.write_text('exists', encoding='utf-8')

    parsed_prompt = ParsedPrompt(
        template='Tell me about {{topic}}.',
        output=PromptOutputConfig(format='txt', output_dir=str(output_dir), file_name='item_{{INDEX}}'),
        tool_defs=None,
    )
    prompt_path = tmp_path / 'exists.prompt'
    prompt_path.write_text('dummy', encoding='utf-8')

    called = False

    async def _fake_run(*args: object, **kwargs: object) -> list[SimpleNamespace]:
        nonlocal called
        called = True
        return []

    monkeypatch.setattr('dotpromptz.cli.parse_document', lambda source: parsed_prompt)
    monkeypatch.setattr('dotpromptz.cli.run', _fake_run)

    with pytest.raises(SystemExit) as exc_info:
        await _run_batch('ignored', [{'topic': 'a'}, {'topic': 'b'}], prompt_path)

    assert exc_info.value.code == 2
    assert called is False
