"""Tests for OpenAI adapter conversion functions."""

import base64
from pathlib import Path
from unittest.mock import AsyncMock

import pytest
from pydantic import BaseModel

from dotpromptz.adapters.openai import (
    OpenAIAdapter,
    _extract_text_from_openai_content,
    to_openai_image_request,
    to_openai_messages,
    to_openai_request,
)
from dotpromptz.credentials import Credential
from dotpromptz.typing import (
    MediaContent,
    MediaPart,
    Message,
    PromptOutputConfig,
    RenderedPrompt,
    Role,
    TextPart,
)


class TestRoleMapping:
    """Test role conversion from dotprompt to OpenAI."""

    def test_user_role(self) -> None:
        msgs = to_openai_messages([Message(role=Role.USER, content=[TextPart(text='hi')])])
        assert msgs[0].role == 'user'

    def test_model_to_assistant(self) -> None:
        msgs = to_openai_messages([Message(role=Role.MODEL, content=[TextPart(text='hello')])])
        assert msgs[0].role == 'assistant'

    def test_system_role(self) -> None:
        msgs = to_openai_messages([Message(role=Role.SYSTEM, content=[TextPart(text='you are helpful')])])
        assert msgs[0].role == 'system'


class TestContentConversion:
    """Test Part → OpenAI content conversion."""

    def test_text_only_becomes_string(self) -> None:
        msgs = to_openai_messages([Message(role=Role.USER, content=[TextPart(text='hello'), TextPart(text=' world')])])
        assert msgs[0].content == 'hello world'

    def test_media_part_becomes_image_url(self) -> None:
        msgs = to_openai_messages([
            Message(
                role=Role.USER,
                content=[
                    TextPart(text='Look at this:'),
                    MediaPart(media=MediaContent(url='https://example.com/img.png', content_type='image/png')),
                ],
            )
        ])
        content = msgs[0].content
        assert isinstance(content, list)
        assert len(content) == 2
        assert content[0].type.value == 'text'
        assert content[1].type.value == 'image_url'
        assert content[1].image_url.url == 'https://example.com/img.png'

    def test_local_media_path_becomes_data_url(self, tmp_path: Path) -> None:
        image_path = tmp_path / 'tiny.png'
        image_path.write_bytes(
            base64.b64decode(
                'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4////fwAJ+wP9KobjigAAAABJRU5ErkJggg=='
            )
        )
        msgs = to_openai_messages([
            Message(
                role=Role.USER,
                content=[MediaPart(media=MediaContent(url=str(image_path), content_type='image/png'))],
            )
        ])
        content = msgs[0].content
        assert isinstance(content, list)
        assert content[0].type.value == 'image_url'
        image_url = content[0].image_url
        assert image_url is not None
        assert image_url.url.startswith('data:image/png;base64,')

    def test_data_uri_media_part_rejected(self) -> None:
        with pytest.raises(ValueError, match='data: image URLs are not supported'):
            to_openai_messages([
                Message(
                    role=Role.USER,
                    content=[
                        MediaPart(media=MediaContent(url='data:image/png;base64,abc123', content_type='image/png'))
                    ],
                )
            ])


class TestToOpenAIRequest:
    """Test full RenderedPrompt → OpenAIRequest conversion."""

    def test_basic_request(self) -> None:
        rendered = RenderedPrompt(
            config={'model': 'gpt-4o'},
            messages=[Message(role=Role.USER, content=[TextPart(text='hello')])],
        )
        req = to_openai_request(rendered)
        assert req.model == 'gpt-4o'
        assert len(req.messages) == 1
        assert req.messages[0].content == 'hello'

    def test_config_model_overrides_legacy(self) -> None:
        """config.model is the canonical source for model name."""
        rendered = RenderedPrompt(
            config={'model': 'gpt-4o-mini'},
            messages=[Message(role=Role.USER, content=[TextPart(text='hi')])],
        )
        req = to_openai_request(rendered)
        assert req.model == 'gpt-4o-mini'

    def test_no_model_raises(self) -> None:
        rendered = RenderedPrompt(
            messages=[Message(role=Role.USER, content=[TextPart(text='hi')])],
        )
        with pytest.raises(ValueError, match='No model specified'):
            to_openai_request(rendered)

    def test_config_params(self) -> None:
        rendered = RenderedPrompt(
            config={'model': 'gpt-4o', 'temperature': 1, 'max_tokens': 100, 'top_p': 0.9},
            messages=[Message(role=Role.USER, content=[TextPart(text='hi')])],
        )
        req = to_openai_request(rendered)
        assert req.temperature == 1
        assert req.max_completion_tokens == 100
        assert req.top_p == 0.9

    def test_thinking_defaults_to_high(self) -> None:
        rendered = RenderedPrompt(
            config={'model': 'gpt-5.2'},
            messages=[Message(role=Role.USER, content=[TextPart(text='hi')])],
        )
        req = to_openai_request(rendered)
        assert req.reasoning_effort == 'high'

    def test_thinking_overrides_reasoning_effort(self) -> None:
        rendered = RenderedPrompt(
            config={'model': 'gpt-5.2', 'thinking': 'low'},
            messages=[Message(role=Role.USER, content=[TextPart(text='hi')])],
        )
        req = to_openai_request(rendered)
        assert req.reasoning_effort == 'low'

    def test_json_output_format(self) -> None:
        rendered = RenderedPrompt(
            config={'model': 'gpt-4o'},
            messages=[Message(role=Role.USER, content=[TextPart(text='hi')])],
            output=PromptOutputConfig(format='json', file_name='output'),
        )
        req = to_openai_request(rendered)
        assert req.response_format is not None
        assert req.response_format.type.value == 'json_object'
        assert req.response_format.json_schema is None

    def test_json_output_format_with_schema(self) -> None:
        schema = {
            'type': 'object',
            'properties': {
                'name': {'type': 'string'},
                'age': {'type': 'integer'},
            },
            'required': ['name', 'age'],
            'additionalProperties': False,
        }
        rendered = RenderedPrompt(
            config={'model': 'gpt-4o'},
            messages=[Message(role=Role.USER, content=[TextPart(text='hi')])],
            output=PromptOutputConfig(format='json', schema=schema, file_name='output'),
        )
        req = to_openai_request(rendered)
        assert req.response_format is not None
        assert req.response_format.type.value == 'json_schema'
        assert req.response_format.json_schema is not None
        assert req.response_format.json_schema.name == 'response'
        assert req.response_format.json_schema.strict is True
        assert req.response_format.json_schema.schema_value == schema

    def test_json_output_format_with_schema_serialization(self) -> None:
        """Ensure the schema is serialized correctly under the 'schema' key."""
        schema = {
            'type': 'object',
            'properties': {'x': {'type': 'string'}},
            'required': ['x'],
            'additionalProperties': False,
        }
        rendered = RenderedPrompt(
            config={'model': 'gpt-4o'},
            messages=[Message(role=Role.USER, content=[TextPart(text='hi')])],
            output=PromptOutputConfig(format='json', schema=schema, file_name='output'),
        )
        req = to_openai_request(rendered)
        dumped = req.model_dump(exclude_none=True)
        rf = dumped['response_format']
        assert rf['type'] == 'json_schema'
        assert 'json_schema' in rf
        assert rf['json_schema']['name'] == 'response'
        assert rf['json_schema']['strict'] is True
        assert rf['json_schema']['schema'] == schema
        assert 'schema_' not in rf['json_schema']

    def test_yaml_output_format_uses_json_object_mode(self) -> None:
        rendered = RenderedPrompt(
            config={'model': 'gpt-4o'},
            messages=[Message(role=Role.USER, content=[TextPart(text='hi')])],
            output=PromptOutputConfig(format='yaml', file_name='output'),
        )
        req = to_openai_request(rendered)
        assert req.response_format is not None
        assert req.response_format.type.value == 'json_object'
        assert req.response_format.json_schema is None

    def test_yaml_output_with_schema_uses_json_schema_mode(self) -> None:
        schema = {
            'type': 'object',
            'properties': {'name': {'type': 'string'}},
            'required': ['name'],
            'additionalProperties': False,
        }
        rendered = RenderedPrompt(
            config={'model': 'gpt-4o'},
            messages=[Message(role=Role.USER, content=[TextPart(text='hi')])],
            output=PromptOutputConfig(format='yaml', schema=schema, file_name='output'),
        )
        req = to_openai_request(rendered)
        assert req.response_format is not None
        assert req.response_format.type.value == 'json_schema'
        assert req.response_format.json_schema is not None
        assert req.response_format.json_schema.schema_value == schema

    def test_txt_output_format_no_response_format(self) -> None:
        rendered = RenderedPrompt(
            config={'model': 'gpt-4o'},
            messages=[Message(role=Role.USER, content=[TextPart(text='hi')])],
            output=PromptOutputConfig(format='txt', file_name='output'),
        )
        req = to_openai_request(rendered)
        assert req.response_format is None


class TestToOpenAIImageRequest:
    """Test RenderedPrompt -> OpenAI image request conversion."""

    def test_basic_image_request(self) -> None:
        rendered = RenderedPrompt(
            config={'model': 'gpt-image-1.5'},
            messages=[Message(role=Role.USER, content=[TextPart(text='draw a cat')])],
            output=PromptOutputConfig(format='image', file_name='img'),
        )
        req = to_openai_image_request(rendered)
        assert req['model'] == 'gpt-image-1.5'
        assert req['prompt'] == 'User: draw a cat'
        assert req['output_format'] == 'png'

    def test_aspect_ratio_maps_to_size(self) -> None:
        rendered = RenderedPrompt(
            config={'model': 'gpt-image-1.5', 'aspect_ratio': '3:2'},
            messages=[Message(role=Role.USER, content=[TextPart(text='draw a dog')])],
            output=PromptOutputConfig(format='image', file_name='img'),
        )
        req = to_openai_image_request(rendered)
        assert req['size'] == '1536x1024'

    def test_resolution_and_aspect_ratio_conflict_raises(self) -> None:
        rendered = RenderedPrompt(
            config={'model': 'gpt-image-1.5', 'resolution': '1024x1024', 'aspect_ratio': '3:2'},
            messages=[Message(role=Role.USER, content=[TextPart(text='draw a dog')])],
            output=PromptOutputConfig(format='image', file_name='img'),
        )
        with pytest.raises(ValueError, match='conflict'):
            to_openai_image_request(rendered)

    def test_transparent_true_maps_background(self) -> None:
        rendered = RenderedPrompt(
            config={'model': 'gpt-image-1.5', 'transparent': True},
            messages=[Message(role=Role.USER, content=[TextPart(text='draw a logo')])],
            output=PromptOutputConfig(format='image', file_name='img'),
        )
        req = to_openai_image_request(rendered)
        assert req['background'] == 'transparent'


class TestOpenAIAdapterConvert:
    """Test the adapter's convert method (no API call)."""

    def test_convert_returns_dict(self) -> None:
        cred = Credential(name='test', adapter='openai', api_key='test-key')
        adapter = OpenAIAdapter(credential=cred)
        rendered = RenderedPrompt(
            config={'model': 'gpt-4o'},
            messages=[Message(role=Role.USER, content=[TextPart(text='hello')])],
        )
        result = adapter.convert(rendered)
        assert isinstance(result, dict)
        assert result['model'] == 'gpt-4o'
        assert 'messages' in result

    def test_convert_uses_default_model(self) -> None:
        cred = Credential(name='test', adapter='openai', api_key='test-key')
        adapter = OpenAIAdapter(credential=cred, default_model='gpt-4o-mini')
        rendered = RenderedPrompt(
            messages=[Message(role=Role.USER, content=[TextPart(text='hello')])],
        )
        result = adapter.convert(rendered)
        assert result['model'] == 'gpt-4o-mini'


class TestOpenAIAdapterBaseUrl:
    """Test base_url from Credential is used correctly."""

    def test_credential_base_url(self) -> None:
        cred = Credential(name='test', adapter='openai', api_key='test-key', base_url='https://api.deepseek.com')
        adapter = OpenAIAdapter(credential=cred)
        assert adapter._credential.base_url == 'https://api.deepseek.com'

    def test_credential_no_base_url(self) -> None:
        cred = Credential(name='test', adapter='openai', api_key='test-key')
        adapter = OpenAIAdapter(credential=cred)
        assert adapter._credential.base_url is None


class _TextValue:
    def __init__(self, value: str) -> None:
        self.value = value


class _PartObj:
    def __init__(self, text: str | _TextValue) -> None:
        self.text = text


class TestOpenAIContentTextExtraction:
    """Tests for text extraction from OpenAI message content shapes."""

    def test_extract_from_plain_string(self) -> None:
        assert _extract_text_from_openai_content('hello') == 'hello'

    def test_extract_from_mixed_part_list(self) -> None:
        content = [
            {'type': 'text', 'text': 'a'},
            _PartObj('b'),
            {'text': {'value': 'c'}},
            _PartObj(_TextValue('d')),
        ]
        assert _extract_text_from_openai_content(content) == 'abcd'

    def test_extract_returns_none_when_no_text(self) -> None:
        assert _extract_text_from_openai_content([{'type': 'image_url'}]) is None


class _FakeUsage:
    def __init__(self) -> None:
        self.prompt_tokens = 1
        self.completion_tokens = 2
        self.total_tokens = 3


class _FakeMessage:
    def __init__(self, *, content: str | None, parsed: object = None, refusal: object = None) -> None:
        self.content = content
        self.parsed = parsed
        self.refusal = refusal


class _FakeChoice:
    def __init__(self, message: _FakeMessage) -> None:
        self.message = message
        self.finish_reason = 'stop'


class _FakeResponse:
    def __init__(self, message: _FakeMessage) -> None:
        self.choices = [_FakeChoice(message)]
        self.usage = _FakeUsage()
        self.model = 'gpt-5.2'


class _FakeCompletions:
    def __init__(
        self,
        *,
        parse_side_effect: list[object] | None = None,
        create_side_effect: list[object] | None = None,
        create_response: _FakeResponse | None = None,
    ) -> None:
        self.parse = AsyncMock(side_effect=parse_side_effect or [])
        if create_side_effect is not None:
            self.create = AsyncMock(side_effect=create_side_effect)
        else:
            self.create = AsyncMock(return_value=create_response)


class _FakeChat:
    def __init__(self, completions: _FakeCompletions) -> None:
        self.completions = completions


class _FakeClient:
    def __init__(self, completions: _FakeCompletions | None = None, images: object | None = None) -> None:
        if completions is not None:
            self.chat = _FakeChat(completions)
        self.images = images


class _ParsedPayload(BaseModel):
    value: int


class _FakeImageUsage:
    def __init__(self) -> None:
        self.input_tokens = 11
        self.output_tokens = 22
        self.total_tokens = 33


class _FakeImageData:
    def __init__(self, b64_json: str) -> None:
        self.b64_json = b64_json


class _FakeImageResponse:
    def __init__(self, b64_json: str) -> None:
        self.data = [_FakeImageData(b64_json)]
        self.usage = _FakeImageUsage()


class _FakeImages:
    def __init__(
        self, *, generate_response: _FakeImageResponse | None = None, edit_response: _FakeImageResponse | None = None
    ) -> None:
        self.generate = AsyncMock(return_value=generate_response)
        self.edit = AsyncMock(return_value=edit_response)


class TestOpenAIGenerateStructuredPath:
    """Tests for OpenAI adapter generate path selection."""

    @pytest.mark.asyncio
    async def test_structured_output_uses_parse(self) -> None:
        cred = Credential(name='test', adapter='openai', api_key='test-key')
        adapter = OpenAIAdapter(credential=cred)
        completions = _FakeCompletions(
            parse_side_effect=[_FakeResponse(_FakeMessage(content='{"ok":1}'))],
        )
        adapter._get_client = lambda: _FakeClient(completions)  # type: ignore[method-assign]

        rendered = RenderedPrompt(
            config={'model': 'gpt-5.2'},
            messages=[Message(role=Role.USER, content=[TextPart(text='hi')])],
            output=PromptOutputConfig(
                format='json',
                schema={
                    'type': 'object',
                    'properties': {'ok': {'type': 'integer'}},
                    'required': ['ok'],
                    'additionalProperties': False,
                },
                file_name='out',
            ),
        )

        result = await adapter.generate(rendered)

        assert result.text == '{"ok":1}'
        assert completions.parse.await_count == 1
        assert completions.create.await_count == 0

    @pytest.mark.asyncio
    async def test_non_structured_output_uses_create(self) -> None:
        cred = Credential(name='test', adapter='openai', api_key='test-key')
        adapter = OpenAIAdapter(credential=cred)
        completions = _FakeCompletions(
            create_response=_FakeResponse(_FakeMessage(content='plain text')),
        )
        adapter._get_client = lambda: _FakeClient(completions)  # type: ignore[method-assign]

        rendered = RenderedPrompt(
            config={'model': 'gpt-5.2'},
            messages=[Message(role=Role.USER, content=[TextPart(text='hi')])],
        )

        result = await adapter.generate(rendered)

        assert result.text == 'plain text'
        assert completions.create.await_count == 1
        assert completions.parse.await_count == 0

    @pytest.mark.asyncio
    async def test_structured_empty_content_retries_once(self) -> None:
        cred = Credential(name='test', adapter='openai', api_key='test-key')
        adapter = OpenAIAdapter(credential=cred)
        completions = _FakeCompletions(
            create_side_effect=[
                _FakeResponse(_FakeMessage(content=None)),
                _FakeResponse(_FakeMessage(content='{"ok":2}')),
            ],
        )
        adapter._get_client = lambda: _FakeClient(completions)  # type: ignore[method-assign]

        rendered = RenderedPrompt(
            config={'model': 'gpt-5.2'},
            messages=[Message(role=Role.USER, content=[TextPart(text='hi')])],
            output=PromptOutputConfig(format='json', file_name='out'),
        )

        result = await adapter.generate(rendered)

        assert result.text == '{"ok":2}'
        assert completions.create.await_count == 2
        assert completions.parse.await_count == 0

    @pytest.mark.asyncio
    async def test_structured_prefers_parsed_value(self) -> None:
        cred = Credential(name='test', adapter='openai', api_key='test-key')
        adapter = OpenAIAdapter(credential=cred)
        completions = _FakeCompletions(
            parse_side_effect=[
                _FakeResponse(_FakeMessage(content='ignored', parsed=_ParsedPayload(value=7))),
            ],
        )
        adapter._get_client = lambda: _FakeClient(completions)  # type: ignore[method-assign]

        rendered = RenderedPrompt(
            config={'model': 'gpt-5.2'},
            messages=[Message(role=Role.USER, content=[TextPart(text='hi')])],
            output=PromptOutputConfig(
                format='json',
                schema={
                    'type': 'object',
                    'properties': {'value': {'type': 'integer'}},
                    'required': ['value'],
                    'additionalProperties': False,
                },
                file_name='out',
            ),
        )

        result = await adapter.generate(rendered)

        assert result.text == '{"value": 7}'


class TestOpenAIGenerateImagePath:
    """Tests for OpenAI image generation/edit code paths."""

    @pytest.mark.asyncio
    async def test_image_output_uses_images_generate(self) -> None:
        cred = Credential(name='test', adapter='openai', api_key='test-key')
        adapter = OpenAIAdapter(credential=cred)
        images = _FakeImages(
            generate_response=_FakeImageResponse(base64.b64encode(b'\x89PNG').decode('ascii')),
        )
        adapter._get_client = lambda: _FakeClient(images=images)  # type: ignore[method-assign]
        rendered = RenderedPrompt(
            config={'model': 'gpt-image-1.5'},
            messages=[Message(role=Role.USER, content=[TextPart(text='draw a cat')])],
            output=PromptOutputConfig(format='image', file_name='img'),
        )

        response = await adapter.generate(rendered)

        assert images.generate.await_count == 1
        assert images.edit.await_count == 0
        assert response.images is not None
        assert response.images[0].data == b'\x89PNG'
        assert response.images[0].mime_type == 'image/png'
        assert response.usage is not None
        assert response.usage.total_tokens == 33

    @pytest.mark.asyncio
    async def test_image_output_with_media_uses_images_edit(self, tmp_path: Path) -> None:
        cred = Credential(name='test', adapter='openai', api_key='test-key')
        adapter = OpenAIAdapter(credential=cred)
        images = _FakeImages(
            edit_response=_FakeImageResponse(base64.b64encode(b'\x89PNG').decode('ascii')),
        )
        adapter._get_client = lambda: _FakeClient(images=images)  # type: ignore[method-assign]
        image_path = tmp_path / 'ref.png'
        image_path.write_bytes(
            base64.b64decode(
                'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4////fwAJ+wP9KobjigAAAABJRU5ErkJggg=='
            )
        )

        rendered = RenderedPrompt(
            config={'model': 'gpt-image-1.5'},
            messages=[
                Message(
                    role=Role.USER,
                    content=[
                        TextPart(text='add sunglasses'),
                        MediaPart(media=MediaContent(url=str(image_path), content_type='image/png')),
                    ],
                )
            ],
            output=PromptOutputConfig(format='image', file_name='img'),
        )

        response = await adapter.generate(rendered)

        assert images.generate.await_count == 0
        assert images.edit.await_count == 1
        call_kwargs = images.edit.await_args.kwargs
        assert 'image' in call_kwargs
        assert response.images is not None
        assert response.images[0].mime_type == 'image/png'
