"""
`manage.py ai_image`: una imagen del proveedor, a pedido, desde la terminal.

Lo que se defiende es que sea una herramienta y nada más: que arme la llamada
con lo que el proyecto tiene configurado, que edite cuando le dan imágenes y
genere cuando no, que deje el archivo fuera de git e imprima dónde, y que sin
clave se niegue antes de gastar. **No sabe del catálogo**, así que ningún
cambio de esquema tiene por qué llegar hasta acá.
"""

import base64
from io import StringIO
from pathlib import Path
from types import SimpleNamespace

import pytest
from django.core.management import call_command
from django.core.management.base import CommandError
from PIL import Image

from apps.ai import provider
from apps.ai.management.commands import ai_image
from tests.doubles import openai_rate_limit_error
from tests.images import png_bytes

pytestmark = pytest.mark.django_db

PNG = png_bytes(64, 64)


class FakeImages:
    """El recurso `images` del SDK: contesta lo que le dieron y registra cada llamada."""

    def __init__(self, outcome):
        self.outcome = outcome
        self.calls: list[tuple[str, dict]] = []

    def _answer(self, method, kwargs):
        self.calls.append((method, kwargs))
        if isinstance(self.outcome, Exception):
            raise self.outcome
        return self.outcome

    def generate(self, **kwargs):
        return self._answer('generate', kwargs)

    def edit(self, **kwargs):
        return self._answer('edit', kwargs)


def image_response():
    return SimpleNamespace(
        data=[SimpleNamespace(b64_json=base64.b64encode(PNG).decode())],
        usage=SimpleNamespace(input_tokens=10, output_tokens=100),
    )


@pytest.fixture
def workspace(settings, tmp_path):
    settings.BASE_DIR = tmp_path
    settings.OPENAI_API_KEY = 'sk-prueba'
    return tmp_path


@pytest.fixture
def fake_images(monkeypatch):
    def install(outcome):
        images = FakeImages(outcome)
        monkeypatch.setattr(ai_image.provider, 'client', lambda: SimpleNamespace(images=images))
        return images

    return install


def run(*args) -> str:
    out = StringIO()
    call_command('ai_image', *args, stdout=out)
    return out.getvalue()


def last_line(output: str) -> str:
    return output.strip().splitlines()[-1]


class TestGenerating:
    def test_it_asks_for_the_image_and_says_where_it_left_it(self, workspace, fake_images):
        """La última línea es la ruta sola: es lo que se lee para mover el archivo."""
        images = fake_images(image_response())

        out = run('a limestone paver texture')

        method, kwargs = images.calls[0]
        assert method == 'generate'
        assert kwargs['prompt'] == 'a limestone paver texture'
        saved = Path(last_line(out))
        assert saved.parent == workspace / 'var' / ai_image.OUTPUT_DIR
        assert saved.read_bytes() == PNG

    def test_the_name_carries_the_prompt_so_the_file_is_recognizable(self, workspace, fake_images):
        fake_images(image_response())

        saved = Path(last_line(run('A limestone paver texture, top down!')))

        assert saved.name.endswith('-a-limestone-paver-texture-top-down.png')

    def test_it_writes_outside_git(self, workspace, fake_images):
        """Una imagen de prueba no se versiona sin querer: cae adonde git no mira."""
        fake_images(image_response())

        saved = Path(last_line(run('anything')))
        ignored = (Path(__file__).parents[2] / '.gitignore').read_text(encoding='utf-8')

        assert 'var' in saved.parts
        assert '/var/' in ignored.splitlines()

    def test_out_puts_it_where_it_is_told(self, workspace, fake_images):
        fake_images(image_response())
        target = workspace / 'elsewhere' / 'sample.png'

        out = run('anything', '--out', str(target))

        assert Path(last_line(out)) == target.resolve()
        assert target.read_bytes() == PNG


class TestEditing:
    def test_an_input_image_switches_to_editing(self, workspace, fake_images):
        images = fake_images(image_response())
        source = workspace / 'patio.jpg'
        source.write_bytes(PNG)

        run('replace the floor', '--image', str(source))

        method, kwargs = images.calls[0]
        assert method == 'edit'
        assert Path(kwargs['image'].name) == source

    def test_several_images_travel_as_a_list_in_the_order_given(self, workspace, fake_images):
        images = fake_images(image_response())
        first, second = workspace / 'a.jpg', workspace / 'b.jpg'
        first.write_bytes(PNG)
        second.write_bytes(PNG)

        run('use the second as a reference', '--image', str(first), '--image', str(second))

        _method, kwargs = images.calls[0]
        assert [Path(handle.name).name for handle in kwargs['image']] == ['a.jpg', 'b.jpg']

    def test_past_the_provider_limit_it_refuses_before_calling(self, workspace, fake_images):
        images = fake_images(image_response())
        source = workspace / 'a.jpg'
        source.write_bytes(PNG)
        too_many = []
        for _ in range(ai_image.MAX_INPUT_IMAGES + 1):
            too_many += ['--image', str(source)]

        with pytest.raises(CommandError, match='At most'):
            run('anything', *too_many)

        assert images.calls == []

    def test_a_missing_file_is_said_before_calling(self, workspace, fake_images):
        images = fake_images(image_response())

        with pytest.raises(CommandError, match='is not a file'):
            run('anything', '--image', str(workspace / 'nope.jpg'))

        assert images.calls == []


def write_mask(path: Path, size=(64, 64), mode='RGBA', image_format='PNG') -> Path:
    """Una máscara: opaca salvo un cuadrado transparente, que es donde se edita."""
    mask = Image.new(mode, size, (0, 0, 0, 255) if mode == 'RGBA' else (0, 0, 0))
    if mode == 'RGBA':
        mask.paste((0, 0, 0, 0), (8, 8, 24, 24))
    mask.save(path, format=image_format)
    return path


class TestMask:
    """
    La máscara es un parámetro más del proveedor: el comando no sabe de zonas
    ni de marcas. Lo único que hace es negarse antes de gastar cuando el
    proveedor la iba a rechazar igual.
    """

    @pytest.fixture
    def source(self, workspace):
        path = workspace / 'patio.png'
        path.write_bytes(PNG)
        return path

    def test_the_mask_travels_with_the_edit(self, workspace, fake_images, source):
        images = fake_images(image_response())
        mask = write_mask(workspace / 'mask.png')

        run('pave the marked area', '--image', str(source), '--mask', str(mask))

        method, kwargs = images.calls[0]
        assert method == 'edit'
        assert Path(kwargs['mask'].name) == mask

    def test_without_a_mask_none_is_sent(self, workspace, fake_images, source):
        images = fake_images(image_response())

        run('anything', '--image', str(source))

        _method, kwargs = images.calls[0]
        assert 'mask' not in kwargs

    def test_a_mask_without_an_image_is_refused_before_calling(self, workspace, fake_images):
        images = fake_images(image_response())
        mask = write_mask(workspace / 'mask.png')

        with pytest.raises(CommandError, match='needs at least one --image'):
            run('anything', '--mask', str(mask))

        assert images.calls == []

    def test_a_mask_of_another_size_is_refused_before_calling(self, workspace, fake_images, source):
        images = fake_images(image_response())
        mask = write_mask(workspace / 'mask.png', size=(32, 32))

        with pytest.raises(CommandError, match='they must match'):
            run('anything', '--image', str(source), '--mask', str(mask))

        assert images.calls == []

    def test_a_mask_without_alpha_is_refused_before_calling(self, workspace, fake_images, source):
        images = fake_images(image_response())
        mask = write_mask(workspace / 'mask.png', mode='RGB')

        with pytest.raises(CommandError, match='alpha channel'):
            run('anything', '--image', str(source), '--mask', str(mask))

        assert images.calls == []

    def test_a_missing_mask_is_said_before_calling(self, workspace, fake_images, source):
        images = fake_images(image_response())

        with pytest.raises(CommandError, match='is not a file'):
            run('anything', '--image', str(source), '--mask', str(workspace / 'nope.png'))

        assert images.calls == []


class TestSettings:
    def test_it_starts_from_what_the_project_has_configured(self, workspace, fake_images):
        """
        Pedir a mano con un modelo que la pantalla no ofrece esconde la
        diferencia cuando después se compara con lo que sale del producto.
        """
        images = fake_images(image_response())

        run('anything')

        _method, kwargs = images.calls[0]
        assert kwargs['model'] == provider.GENERATE_MODEL
        assert kwargs['quality'] == provider.DEFAULT_IMAGE_QUALITY

    def test_editing_uses_the_configured_edit_model(self, workspace, fake_images):
        images = fake_images(image_response())
        source = workspace / 'patio.jpg'
        source.write_bytes(PNG)

        run('anything', '--image', str(source))

        _method, kwargs = images.calls[0]
        assert kwargs['model'] == provider.DEFAULT_IMAGE_MODEL

    def test_the_flags_win_over_the_configuration(self, workspace, fake_images):
        images = fake_images(image_response())

        run('anything', '--model', 'gpt-image-1', '--quality', 'low', '--size', '1536x864')

        _method, kwargs = images.calls[0]
        assert kwargs['model'] == 'gpt-image-1'
        assert kwargs['quality'] == 'low'
        assert kwargs['size'] == '1536x864'

    def test_without_a_size_it_does_not_invent_one(self, workspace, fake_images):
        images = fake_images(image_response())

        run('anything')

        _method, kwargs = images.calls[0]
        assert 'size' not in kwargs


class TestFailures:
    def test_without_a_key_it_refuses_before_calling(self, workspace, settings, fake_images):
        settings.OPENAI_API_KEY = ''
        images = fake_images(image_response())

        with pytest.raises(CommandError, match='OPENAI_API_KEY'):
            run('anything')

        assert images.calls == []

    def test_a_provider_failure_is_reported_and_never_retried(self, workspace, fake_images):
        """Es una corrida a mano: quien la pidió está mirando y puede volver a tipearla."""
        images = fake_images(openai_rate_limit_error())

        with pytest.raises(CommandError, match='The provider failed'):
            run('anything')

        assert len(images.calls) == 1

    def test_an_answer_without_an_image_writes_nothing(self, workspace, fake_images):
        fake_images(SimpleNamespace(data=[], usage=None))

        with pytest.raises(CommandError, match='without an image'):
            run('anything')

        assert not (workspace / 'var' / ai_image.OUTPUT_DIR).exists()
