"""
El proveedor de prueba (`apps/ai/fake.py`).

Lo que se defiende: que con el flag prendido todo pase por el camino de
siempre —los mismos estados, el mismo disco, la misma marca de agua— y que lo
único que cambie sea de dónde sale la respuesta. Que una foto se pague una sola
vez. Que las fallas elegidas terminen con los códigos de una falla de verdad.
Y que el flag no se pueda prender fuera de desarrollo.
"""

import base64
import hashlib
import json
from io import BytesIO
from types import SimpleNamespace

import pytest
from django.core.exceptions import ImproperlyConfigured
from PIL import Image

from apps.ai import analysis, fake, images, provider, services
from apps.ai.models import (
    AnalysisError,
    AnalysisState,
    GenerationError,
    GenerationKind,
    GenerationOrigin,
    GenerationState,
    ImageGeneration,
    PhotoAnalysis,
)
from apps.ai.services import TransientProviderError
from tests.doubles import FakeResponses, vision_response
from tests.images import jpeg_bytes, png_bytes

pytestmark = pytest.mark.django_db

CLEAN = json.dumps(
    {
        'areas_detected': ['driveway'],
        'pool_present': False,
        'confidence': {
            'patio_deck': None,
            'driveway': 0.93,
            'entryway': None,
            'walkway': None,
            'pool_deck': None,
            'pool': None,
        },
    }
)


@pytest.fixture
def fake_on(settings, tmp_path, monkeypatch):
    """El proveedor de prueba prendido, sin espera y con una imagen resultante propia."""
    settings.AI_FAKE_PROVIDER = True
    settings.AI_FAKE_ANALYSIS_OUTCOME = fake.SUCCESS
    settings.AI_FAKE_GENERATION_OUTCOME = fake.SUCCESS
    settings.AI_FAKE_DELAY_SECONDS = 0
    settings.AI_MEDIA_ROOT = tmp_path / 'media'
    settings.OPENAI_API_KEY = ''
    result = tmp_path / 'result.jpg'
    result.write_bytes(jpeg_bytes(300, 200))
    monkeypatch.setattr(fake, 'RESULT_IMAGE', result)
    monkeypatch.setattr(fake, 'COMMITTED_ANALYSES', tmp_path / 'committed')
    return settings


@pytest.fixture
def real_openai(monkeypatch):
    """El cliente real, reemplazado: cuenta cuántas veces se pagó un análisis."""

    def install(*outcomes):
        responses = FakeResponses(*outcomes)
        monkeypatch.setattr(provider, '_openai', lambda: SimpleNamespace(responses=responses))
        return responses

    return install


def queued_analysis(settings, photo: bytes = b'') -> PhotoAnalysis:
    row = PhotoAnalysis()
    source = settings.AI_MEDIA_ROOT / 'sources' / f'{row.id}.jpg'
    source.parent.mkdir(parents=True, exist_ok=True)
    source.write_bytes(photo or png_bytes(64, 48))
    row.source_path = f'sources/{row.id}.jpg'
    row.save()
    return row


def queued_generation() -> ImageGeneration:
    return ImageGeneration.objects.create(
        kind=GenerationKind.POOL,
        origin=GenerationOrigin.TOOLS_APP,
        answers={'pool_shape': 'lap'},
        prompt='Generate a lap-shaped pool',
    )


class TestFixtures:
    def test_the_test_photo_and_its_analysis_go_together(self):
        """Si cambia la foto, su análisis versionado deja de encontrarse y se vuelve a pagar."""
        raw = fake.SOURCE_IMAGE.read_bytes()
        image_url = f'data:image/jpeg;base64,{base64.b64encode(raw).decode()}'
        key = hashlib.sha256(image_url.encode()).hexdigest()

        assert images.source_is_normalized(Image.open(fake.SOURCE_IMAGE))
        assert json.loads((fake.COMMITTED_ANALYSES / f'{key}.json').read_text())['pool_present']

    def test_the_result_keeps_the_proportion_of_the_test_photo(self):
        source = Image.open(fake.SOURCE_IMAGE).size
        result = Image.open(fake.RESULT_IMAGE).size

        assert round(source[0] / source[1], 2) == round(result[0] / result[1], 2)


class TestSwitch:
    def test_off_it_is_the_real_client(self, settings):
        settings.OPENAI_API_KEY = 'sk-prueba'

        assert not isinstance(provider.client(), fake.Client)

    def test_on_it_is_the_test_client_and_needs_no_key(self, fake_on):
        assert isinstance(provider.client(), fake.Client)
        assert provider.is_configured()

    def test_it_cannot_be_turned_on_outside_development(self, settings):
        settings.AI_FAKE_PROVIDER = True
        settings.DEBUG = False

        with pytest.raises(ImproperlyConfigured):
            fake.check_settings()

    def test_an_unknown_outcome_stops_the_start(self, settings):
        settings.AI_FAKE_PROVIDER = True
        settings.DEBUG = True
        settings.AI_FAKE_GENERATION_OUTCOME = 'explode'

        with pytest.raises(ImproperlyConfigured):
            fake.check_settings()


class TestAnalysis:
    def test_a_photo_is_paid_once_and_then_read_from_the_cache(self, fake_on, real_openai):
        paid = real_openai(vision_response(CLEAN))
        photo = jpeg_bytes(120, 80)

        first = analysis.execute(queued_analysis(fake_on, photo))
        second = analysis.execute(queued_analysis(fake_on, photo))

        assert len(paid.calls) == 1
        assert first.state == second.state == AnalysisState.COMPLETED
        assert second.areas_detected == ['driveway']
        assert second.raw_response == CLEAN

    def test_another_photo_is_paid_again(self, fake_on, real_openai):
        paid = real_openai(vision_response(CLEAN))

        analysis.execute(queued_analysis(fake_on, jpeg_bytes(120, 80)))
        analysis.execute(queued_analysis(fake_on, jpeg_bytes(90, 60)))

        assert len(paid.calls) == 2

    def test_a_committed_analysis_is_never_paid(self, fake_on, real_openai):
        paid = real_openai(vision_response(CLEAN))
        photo = jpeg_bytes(120, 80)
        analysis.execute(queued_analysis(fake_on, photo))
        stored = next((fake_on.AI_MEDIA_ROOT / 'fake' / 'analyses').iterdir())
        fake.COMMITTED_ANALYSES.mkdir()
        stored.rename(fake.COMMITTED_ANALYSES / stored.name)

        analysis.execute(queued_analysis(fake_on, photo))

        assert len(paid.calls) == 1

    def test_an_answer_that_is_not_json_is_not_cached(self, fake_on, real_openai):
        broken = vision_response('no JSON here')
        paid = real_openai(broken, broken, vision_response(CLEAN))
        photo = jpeg_bytes(120, 80)

        first = analysis.execute(queued_analysis(fake_on, photo))
        second = analysis.execute(queued_analysis(fake_on, photo))

        # La primera paga dos (el reenvío por JSON roto) y falla; la segunda
        # vuelve a pagar porque lo roto no quedó guardado.
        assert first.state == AnalysisState.FAILED
        assert second.state == AnalysisState.COMPLETED
        assert len(paid.calls) == 3

    def test_unavailable_goes_through_the_real_retries(self, fake_on):
        fake_on.AI_FAKE_ANALYSIS_OUTCOME = fake.UNAVAILABLE
        row = queued_analysis(fake_on)

        with pytest.raises(TransientProviderError):
            analysis.execute(row)

        row.refresh_from_db()
        assert row.state == AnalysisState.RETRYING

    def test_malformed_fails_as_a_malformed_answer(self, fake_on):
        fake_on.AI_FAKE_ANALYSIS_OUTCOME = fake.MALFORMED

        row = analysis.execute(queued_analysis(fake_on))

        assert row.state == AnalysisState.FAILED
        assert row.error_code == AnalysisError.MALFORMED_RESPONSE


class TestGeneration:
    def test_it_returns_the_test_image_through_the_usual_path(self, fake_on):
        generation = queued_generation()

        services.execute(generation)

        generation.refresh_from_db()
        assert generation.state == GenerationState.COMPLETED
        stored = Image.open(BytesIO((fake_on.AI_MEDIA_ROOT / generation.result_path).read_bytes()))
        assert stored.format == 'JPEG'
        assert stored.size == (300, 200)

    def test_unavailable_goes_through_the_real_retries(self, fake_on):
        fake_on.AI_FAKE_GENERATION_OUTCOME = fake.UNAVAILABLE
        generation = queued_generation()

        with pytest.raises(TransientProviderError):
            services.execute(generation)

    def test_malformed_fails_as_an_empty_answer(self, fake_on):
        fake_on.AI_FAKE_GENERATION_OUTCOME = fake.MALFORMED
        generation = queued_generation()

        services.execute(generation)

        generation.refresh_from_db()
        assert generation.state == GenerationState.FAILED
        assert generation.error_code == GenerationError.PROVIDER_EMPTY_RESPONSE
