"""
La herramienta de IA: sus direcciones existen y llevan adonde dicen.

El generador v2 se prueba por su contrato con la pantalla única: qué props
viajan, adónde vuelve cada envío y qué pasa con lo que no corresponde. Lo que
hace el proveedor vive en `tests/unit/test_ai_services.py` y en
`tests/unit/test_ai_analysis.py`; acá las tareas se sustituyen.
"""

import json
from io import BytesIO

import pytest
from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import NoReverseMatch, reverse
from PIL import Image

from apps.accounts.models import AccountRole
from apps.ai import prompt
from apps.ai.models import (
    AiSettings,
    AnalysisState,
    GenerationKind,
    GenerationOrigin,
    GenerationState,
    ImageGeneration,
    PhotoAnalysis,
)
from tests.factories import create_account, create_photo_analysis
from tests.images import jpeg_bytes, png_bytes

pytestmark = pytest.mark.django_db

PAGE = '/ai/image-generator'
ANALYZE = '/ai/image-generator/analyze'
GENERATE = '/ai/image-generator/generate'
CONFIG = '/ai/config'

PNG = png_bytes(64, 48)

DRIVEWAY_POUR = {'area': 'driveway', 'service': 'concrete_pour', 'answers': {'turf_strips': False}}
DRIVEWAY_PAVER = {
    'area': 'driveway',
    'service': 'paver',
    'answers': {
        'material': 'travertine',
        'color': 'ivory',
        'size': '24x24',
        'pattern': 'large_grid',
        'border': True,
        'seal': 'matte',
        'turf_strips': True,
    },
}
POOL_RENOVATION = {
    'area': 'pool',
    'service': 'pool_renovation',
    'answers': {
        'plaster_color': 'sky_blue',
        'waterline_color': 'blues',
        'waterline_size': '6x6',
    },
}


@pytest.fixture
def account(client):
    """Quien entra a la herramienta. Admin, que es el rol que puede leer el prompt (R53)."""
    account = create_account(role=AccountRole.ADMIN)
    client.force_login(account)
    return account


@pytest.fixture
def viewer(client):
    account = create_account(email='viewer@ejemplo.test', role=AccountRole.VIEWER)
    client.force_login(account)
    return account


@pytest.fixture
def ready(settings, tmp_path, monkeypatch):
    """Clave presente, disco temporal y las dos colas sustituidas."""
    settings.OPENAI_API_KEY = 'sk-prueba'
    settings.AI_MEDIA_ROOT = tmp_path
    enqueued = {'analyses': [], 'generations': []}
    monkeypatch.setattr('apps.ai.tasks.analyze_photo.delay', enqueued['analyses'].append)
    monkeypatch.setattr('apps.ai.tasks.generate_image.delay', enqueued['generations'].append)
    return enqueued


def v1_generation(**extra) -> ImageGeneration:
    """Una fila de la v1: con tipo y respuestas planas, sin análisis ni áreas."""
    defaults = {
        'kind': GenerationKind.POOL,
        'origin': GenerationOrigin.TOOLS_APP,
        'answers': {'pool_shape': 'kidney'},
        'prompt': 'Generate a kidney-shaped pool',
    }
    return ImageGeneration.objects.create(**{**defaults, **extra})


def v2_generation(analysis: PhotoAnalysis, **extra) -> ImageGeneration:
    defaults = {
        'analysis': analysis,
        'areas': [DRIVEWAY_POUR],
        'origin': GenerationOrigin.TOOLS_APP,
        'prompt': '## Driveway',
        'source_path': analysis.source_path,
    }
    return ImageGeneration.objects.create(**{**defaults, **extra})


def props(client, url):
    return client.get(url, HTTP_X_INERTIA='true').json()['props']


def errors(client) -> dict:
    return client.session['_validation_errors']


def generate(client, analysis, areas):
    payload = areas if isinstance(areas, str) else json.dumps(areas)
    return client.post(GENERATE, {'analysis': str(analysis.id), 'areas': payload})


class TestTheToolHome:
    def test_it_sends_to_the_generator(self, client, account):
        response = client.get('/ai')

        assert response.status_code == 302
        assert response.headers['Location'] == PAGE


class TestTheGeneratorScreen:
    def test_it_draws_the_single_page_with_the_catalog_and_the_recipe(self, client, account):
        response = client.get(PAGE, HTTP_X_INERTIA='true')

        assert response.status_code == 200
        page = response.json()
        assert page['component'] == 'Ai/ImageGenerator/Index'
        page = page['props']
        areas = [area['value'] for area in page['catalog']['areas']]
        assert areas[:2] == ['patio_deck', 'driveway']
        assert {schema['service'] for schema in page['catalog']['services']} == {
            'paver',
            'artificial_turf',
            'concrete_pour',
            'concrete_overlay',
            'pool_renovation',
        }
        assert page['prompt'] == prompt.recipe()
        assert page['can_see_prompt'] is True
        assert page['analysis'] is None
        assert page['generation'] is None
        assert page['analysis_active_states'] == []
        assert page['analyze_url'] == ANALYZE
        assert page['generate_url'] == GENERATE
        assert page['max_source_bytes'] == 25 * 1024 * 1024
        assert page['options'] == {
            'origin': ['TOOLS_APP', 'WEBSITE'],
            'state': ['QUEUED', 'RUNNING', 'COMPLETED', 'FAILED'],
            'service': [
                'paver',
                'artificial_turf',
                'concrete_pour',
                'concrete_overlay',
                'pool_renovation',
            ],
        }

    def test_the_named_analysis_travels_with_its_areas_and_is_watched_while_it_runs(
        self, client, account
    ):
        running = create_photo_analysis(state=AnalysisState.RUNNING)

        page = props(client, f'{PAGE}?analysis={running.id}')

        assert page['analysis']['id'] == str(running.id)
        assert page['analysis']['state'] == 'RUNNING'
        assert page['analysis']['areas'] == ['driveway', 'pool_deck']
        assert page['analysis']['source_url'] == f'/media/ai/sources/{running.id}.jpg'
        # El sondeo mira sólo este análisis, y sólo mientras no terminó.
        assert page['analysis_active_states'] == ['RUNNING']

    def test_a_finished_analysis_is_not_watched(self, client, account):
        done = create_photo_analysis(pool_present=True)

        page = props(client, f'{PAGE}?analysis={done.id}')

        assert page['analysis']['pool_present'] is True
        assert page['analysis']['areas'] == ['driveway', 'pool_deck', 'pool']
        assert page['analysis_active_states'] == []

    def test_an_analysis_of_someone_else_is_visible_too(self, client, account):
        """El producto es de empresa única: lo que analizó un compañero se puede retomar."""
        other = create_account(email='otra@ejemplo.test')
        theirs = create_photo_analysis(other)

        page = props(client, f'{PAGE}?analysis={theirs.id}')

        assert page['analysis']['id'] == str(theirs.id)

    def test_the_named_generation_travels_with_its_areas_and_its_analysis(self, client, account):
        analysis = create_photo_analysis()
        pending = v2_generation(analysis)

        page = props(client, f'{PAGE}?analysis={analysis.id}&generation={pending.id}')

        generation = page['generation']
        assert generation['id'] == str(pending.id)
        assert generation['state'] == 'QUEUED'
        assert generation['is_v2'] is True
        assert generation['analysis_id'] == str(analysis.id)
        assert generation['areas'] == [DRIVEWAY_POUR]
        assert generation['kind'] == ''
        assert generation['answers'] == {}
        assert generation['prompt'] == '## Driveway'
        assert generation['result_url'] is None
        assert generation['source_url'] == f'/media/ai/sources/{analysis.id}.jpg'

    def test_a_viewer_gets_neither_the_recipe_nor_the_prompt(self, client, viewer):
        """
        R53: el prompt lo leen admin y super admin. No alcanza con no dibujarlo,
        porque un texto que igual viajó en las props está a un clic de la
        consola: sin receta la pantalla no puede armarlo, y el del pedido llega
        vacío.
        """
        analysis = create_photo_analysis()
        pending = v2_generation(analysis)

        page = props(client, f'{PAGE}?analysis={analysis.id}&generation={pending.id}')

        assert page['can_see_prompt'] is False
        assert page['prompt'] is None
        assert page['generation']['prompt'] == ''
        # Y el resto del pedido llega igual: lo que se esconde es el prompt, no
        # lo que la persona pidió.
        assert page['generation']['areas'] == [DRIVEWAY_POUR]

    def test_a_viewer_does_not_get_the_prompt_of_the_recent_rows_either(self, client, viewer):
        analysis = create_photo_analysis()
        v2_generation(analysis)

        page = props(client, PAGE)

        assert [row['prompt'] for row in page['recent']] == ['']

    def test_a_v1_generation_still_opens_with_what_it_had(self, client, account):
        """Decisión 1: las filas viejas se ven, con su tipo y sus datos, sin resumen."""
        old = v1_generation(state=GenerationState.COMPLETED, result_path='results/x.jpg')

        page = props(client, f'{PAGE}?generation={old.id}')

        generation = page['generation']
        assert generation['is_v2'] is False
        assert generation['kind'] == 'POOL'
        assert generation['answers'] == {'pool_shape': 'kidney'}
        assert generation['areas'] == []
        assert generation['analysis_id'] is None

    @pytest.mark.parametrize('query', ['analysis=no-es-un-uuid', 'generation=no-es-un-uuid'])
    def test_a_malformed_identifier_is_ignored(self, client, account, query):
        page = props(client, f'{PAGE}?{query}')

        assert page['analysis'] is None
        assert page['generation'] is None

    def test_recent_ones_mix_both_versions_and_every_state(self, client, account):
        """Un registro que esconde lo que salió mal hace creer que todo salió bien."""
        analysis = create_photo_analysis()
        v1_generation(state=GenerationState.COMPLETED, result_path='results/x.jpg')
        v1_generation(state=GenerationState.FAILED)
        v2_generation(analysis, state=GenerationState.RUNNING)

        page = props(client, PAGE)

        assert {row['state'] for row in page['recent']} == {'COMPLETED', 'FAILED', 'RUNNING'}
        assert {row['is_v2'] for row in page['recent']} == {True, False}
        assert page['recent_total'] == 3
        assert page['active_states'] == ['RUNNING']

    def test_recent_ones_come_trimmed_with_their_total(self, client, account, monkeypatch):
        monkeypatch.setattr('apps.ai.services.RECENT_LIMIT', 2)
        for _ in range(3):
            v1_generation(state=GenerationState.COMPLETED, result_path='results/x.jpg')

        page = props(client, PAGE)

        assert len(page['recent']) == 2
        assert page['recent_total'] == 3
        assert page['recent_limit'] == 2


class TestAnalyzing:
    def test_it_stores_the_photo_enqueues_and_comes_back_to_watch_it(
        self, client, account, ready, settings, django_capture_on_commit_callbacks
    ):
        photo = SimpleUploadedFile('patio.png', PNG, content_type='image/png')

        with django_capture_on_commit_callbacks(execute=True):
            response = client.post(ANALYZE, {'image': photo})

        created = PhotoAnalysis.objects.get()
        assert response.status_code == 302
        assert response.headers['Location'] == f'{PAGE}?analysis={created.id}'
        assert created.state == AnalysisState.QUEUED
        assert created.requested_by == account
        assert created.source_path == f'sources/{created.id}.jpg'
        assert (settings.AI_MEDIA_ROOT / created.source_path).is_file()
        assert ready['analyses'] == [str(created.id)]
        assert ready['generations'] == []

    def test_the_photo_is_served_with_a_session_and_never_by_the_public_route(
        self, client, account, ready
    ):
        photo = SimpleUploadedFile('patio.png', PNG, content_type='image/png')
        client.post(ANALYZE, {'image': photo})

        created = PhotoAnalysis.objects.get()
        # Está en `sources/`, no en `results/`: la dirección pública no la sirve.
        assert client.get(f'/media/ai/{created.id}.jpg').status_code == 404
        page = props(client, f'{PAGE}?analysis={created.id}')
        response = client.get(page['analysis']['source_url'])
        assert response.status_code == 200
        assert response['Content-Type'] == 'image/jpeg'
        assert 'private' in response['Cache-Control']

    def test_without_a_photo_there_is_nothing_to_analyze(self, client, account, ready):
        response = client.post(ANALYZE, {})

        assert response.status_code == 302
        assert response.headers['Location'] == PAGE
        assert errors(client) == {'image': {'code': 'IMAGE_MISSING'}}
        assert not PhotoAnalysis.objects.exists()

    def test_a_heavy_photo_is_refused_before_being_opened(
        self, client, account, ready, monkeypatch
    ):
        monkeypatch.setattr('apps.ai.analysis.MAX_SOURCE_BYTES', 16)

        response = client.post(
            ANALYZE, {'image': SimpleUploadedFile('patio.png', PNG, content_type='image/png')}
        )

        assert response.status_code == 302
        assert errors(client) == {'image': {'code': 'IMAGE_TOO_LARGE'}}
        assert not PhotoAnalysis.objects.exists()

    def test_a_file_that_is_not_an_image_is_refused(self, client, account, ready, settings):
        fake = SimpleUploadedFile('clave.json', b'{}', content_type='image/png')

        response = client.post(ANALYZE, {'image': fake})

        assert response.status_code == 302
        assert errors(client) == {'image': {'code': 'IMAGE_INVALID'}}
        assert not PhotoAnalysis.objects.exists()
        assert not (settings.AI_MEDIA_ROOT / 'sources').exists()

    def test_without_a_key_the_analysis_fails_and_the_screen_says_so(
        self, client, account, ready, settings
    ):
        settings.OPENAI_API_KEY = ''

        response = client.post(
            ANALYZE, {'image': SimpleUploadedFile('patio.png', PNG, content_type='image/png')}
        )

        created = PhotoAnalysis.objects.get()
        page = props(client, response.headers['Location'])
        assert page['analysis']['id'] == str(created.id)
        assert page['analysis']['state'] == 'FAILED'
        assert page['analysis']['error_code'] == 'PROVIDER_NOT_CONFIGURED'
        assert ready['analyses'] == []


class TestGenerating:
    def test_it_enqueues_and_comes_back_with_both_identifiers(
        self, client, account, ready, django_capture_on_commit_callbacks
    ):
        analysis = create_photo_analysis(pool_present=True)

        with django_capture_on_commit_callbacks(execute=True):
            # La pileta va primero a propósito: se guarda en el orden de la
            # taxonomía, no en el que llegó.
            response = generate(client, analysis, [POOL_RENOVATION, DRIVEWAY_PAVER])

        created = ImageGeneration.objects.get()
        assert response.status_code == 302
        assert response.headers['Location'] == (
            f'{PAGE}?analysis={analysis.id}&generation={created.id}'
        )
        assert created.analysis == analysis
        assert created.is_v2 is True
        assert created.kind == ''
        assert created.answers == {}
        assert [container['area'] for container in created.areas] == ['driveway', 'pool']
        assert created.areas[0]['answers'] == DRIVEWAY_PAVER['answers']
        assert created.source_path == analysis.source_path
        assert created.origin == GenerationOrigin.TOOLS_APP
        assert created.requested_by == account
        assert created.state == GenerationState.QUEUED
        assert '## Driveway' in created.prompt and '## Pool' in created.prompt
        assert ready['generations'] == [str(created.id)]

    def test_the_prompt_is_the_one_the_server_builds(self, client, account, ready):
        analysis = create_photo_analysis()

        generate(client, analysis, [DRIVEWAY_POUR])

        created = ImageGeneration.objects.get()
        cleaned = prompt.clean_request([DRIVEWAY_POUR], False)
        assert created.prompt == prompt.build_prompt(cleaned, False)

    def test_without_areas_there_is_no_request(self, client, account, ready):
        analysis = create_photo_analysis()

        response = generate(client, analysis, [])

        assert response.status_code == 302
        assert response.headers['Location'] == f'{PAGE}?analysis={analysis.id}'
        assert errors(client) == {'areas': {'code': 'AREAS_EMPTY'}}
        assert not ImageGeneration.objects.exists()

    def test_areas_as_broken_text_are_rejected(self, client, account, ready):
        analysis = create_photo_analysis()

        response = generate(client, analysis, '{not json')

        assert response.headers['Location'] == f'{PAGE}?analysis={analysis.id}'
        assert errors(client) == {'areas': {'code': 'ANSWER_INVALID'}}
        assert not ImageGeneration.objects.exists()

    def test_an_area_outside_the_taxonomy_is_pinned_to_its_container(self, client, account, ready):
        analysis = create_photo_analysis()

        generate(client, analysis, [{**DRIVEWAY_POUR, 'area': 'garden'}])

        assert errors(client) == {'areas[0].area': {'code': 'AREA_INVALID'}}
        assert not ImageGeneration.objects.exists()

    def test_the_pool_cannot_be_added_by_hand(self, client, account, ready):
        """R41: sin pileta detectada no hay contenedor de pileta."""
        analysis = create_photo_analysis(pool_present=False)

        generate(client, analysis, [DRIVEWAY_POUR, POOL_RENOVATION])

        assert errors(client) == {'areas[1].area': {'code': 'AREA_NOT_ALLOWED'}}
        assert not ImageGeneration.objects.exists()

    def test_a_service_the_area_does_not_offer_is_pinned_to_its_select(
        self, client, account, ready
    ):
        analysis = create_photo_analysis()
        entryway_turf = {
            'area': 'entryway',
            'service': 'artificial_turf',
            'answers': {'use_type': 'general_use', 'shade': 'natural_green'},
        }

        generate(client, analysis, [entryway_turf])

        assert errors(client) == {'areas[0].service': {'code': 'SERVICE_NOT_ALLOWED'}}

    def test_a_missing_answer_comes_back_pinned_to_its_field(self, client, account, ready):
        analysis = create_photo_analysis()
        without_material = {**DRIVEWAY_PAVER, 'answers': {'color': 'ivory'}}

        response = generate(client, analysis, [without_material])

        assert response.status_code == 302
        assert errors(client) == {'areas[0].material': {'code': 'ANSWER_MISSING'}}
        assert not ImageGeneration.objects.exists()

    @pytest.mark.parametrize(
        'state', [AnalysisState.QUEUED, AnalysisState.RUNNING, AnalysisState.FAILED]
    )
    def test_an_unfinished_analysis_cannot_be_generated_on(self, client, account, ready, state):
        analysis = create_photo_analysis(state=state)

        response = generate(client, analysis, [DRIVEWAY_POUR])

        assert response.status_code == 302
        assert response.headers['Location'] == f'{PAGE}?analysis={analysis.id}'
        assert errors(client) == {'analysis': {'code': 'ANALYSIS_NOT_READY'}}
        assert not ImageGeneration.objects.exists()

    @pytest.mark.parametrize(
        'identifier', ['', 'no-es-un-uuid', '0195c0de-0000-7000-8000-000000000001']
    )
    def test_an_unknown_analysis_is_not_found(self, client, account, ready, identifier):
        response = client.post(
            GENERATE, {'analysis': identifier, 'areas': json.dumps([DRIVEWAY_POUR])}
        )

        assert response.status_code == 404
        assert not ImageGeneration.objects.exists()

    def test_without_a_key_the_request_fails_and_the_screen_says_so(
        self, client, account, ready, settings
    ):
        settings.OPENAI_API_KEY = ''
        analysis = create_photo_analysis()

        response = generate(client, analysis, [DRIVEWAY_POUR])

        created = ImageGeneration.objects.get()
        assert response.headers['Location'].endswith(f'&generation={created.id}')
        page = props(client, response.headers['Location'])
        assert page['generation']['state'] == 'FAILED'
        assert page['generation']['error_code'] == 'PROVIDER_NOT_CONFIGURED'
        assert ready['generations'] == []


class TestTheOldRoutes:
    @pytest.mark.parametrize('url', ['/ai/image-generator/pool', '/ai/image-generator/paver'])
    def test_a_saved_link_to_a_v1_generator_is_gone(self, client, account, url):
        """Decisión 4: las direcciones por tipo se borraron, no se redirigen."""
        assert client.get(url).status_code == 404
        assert client.post(url, {}).status_code == 404

    @pytest.mark.parametrize('name', ['ai.image_generator.pool', 'ai.image_generator.paver'])
    def test_their_names_no_longer_resolve(self, name):
        with pytest.raises(NoReverseMatch):
            reverse(name)


class TestTheQueue:
    def test_a_viewer_gets_the_rows_without_their_prompt(self, client, viewer):
        """La cola lista lo de todos, y el prompt sigue siendo de admin (R53)."""
        v1_generation()

        page = props(client, '/ai/image-generator/queue')

        [row] = page['generations']
        assert row['prompt'] == ''
        assert row['kind'] == 'POOL'

    def test_it_lists_both_versions_with_their_origin_and_state(self, client, account):
        analysis = create_photo_analysis()
        v1_generation(origin=GenerationOrigin.WEBSITE, state=GenerationState.COMPLETED)
        v2_generation(analysis, requested_by=account, state=GenerationState.RUNNING)

        page = props(client, '/ai/image-generator/queue')

        assert page['total'] == 2
        assert page['limit'] == 200
        rows = {row['is_v2']: row for row in page['generations']}
        assert rows[False]['kind'] == 'POOL'
        assert rows[False]['origin'] == 'WEBSITE'
        assert rows[False]['requested_by'] is None
        assert rows[True]['kind'] == ''
        assert rows[True]['areas'] == [DRIVEWAY_POUR]
        assert rows[True]['requested_by'] == account.email
        assert page['active_states'] == ['RUNNING']
        # Sin filtro por tipo: era de la v1. Por servicio sí, que es lo que
        # llevan los contenedores de una fila v2.
        assert page['options'] == {
            'origin': ['TOOLS_APP', 'WEBSITE'],
            'state': ['QUEUED', 'RUNNING', 'COMPLETED', 'FAILED'],
            'service': [
                'paver',
                'artificial_turf',
                'concrete_pour',
                'concrete_overlay',
                'pool_renovation',
            ],
        }


class TestConfig:
    """La configuración del módulo: un registro de la compañía, editado desde la pantalla."""

    VALID = {
        'image_model': 'gpt-image-2.5-flare',
        'image_quality': 'max',
        'vision_model': 'gpt-5.4',
        'markup_mode': 'layer',
        'watermark': 'rotate',
        'website_hourly_limit': '4',
        'website_daily_limit': '12',
    }

    def test_it_draws_the_current_values_and_the_closed_lists_as_codes(self, client, account):
        response = client.get(CONFIG, HTTP_X_INERTIA='true')

        assert response.status_code == 200
        page = response.json()
        assert page['component'] == 'Ai/Config'
        page = page['props']
        # Sin nadie que la haya tocado, nace con los valores de la v1 (R52).
        assert page['settings'] == {
            'image_model': 'gpt-image-2.5-sunburst',
            'image_quality': 'high',
            'vision_model': 'gpt-5.4-mini',
            'markup_mode': 'copy',
            'watermark': 'blue',
            'website_hourly_limit': 10,
            'website_daily_limit': 20,
        }
        models = page['options']['image_models']
        assert [model['value'] for model in models] == [
            'gpt-image-2.5-sunburst',
            'gpt-image-2.5-flare',
            'gpt-image-1',
        ]
        # Las calidades van por modelo: la primera generación no llega a `max`.
        assert models[0]['qualities'] == ['low', 'medium', 'high', 'xhigh', 'max']
        assert models[2]['qualities'] == ['low', 'medium', 'high']
        assert page['options']['image_qualities'] == ['low', 'medium', 'high', 'xhigh', 'max']
        assert page['options']['vision_models'] == [
            'gpt-5.4-nano',
            'gpt-5.4-mini',
            'gpt-5.4',
            'gpt-5.5',
        ]
        assert page['options']['markup_modes'] == ['copy', 'layer']
        # Cada badge viaja con su imagen para la miniatura; la rotación no tiene una propia.
        assert page['options']['watermarks'] == [
            {'value': 'blue', 'image': '/static/ai/watermark/blue.png'},
            {'value': 'cream', 'image': '/static/ai/watermark/cream.png'},
            {'value': 'clay', 'image': '/static/ai/watermark/clay.png'},
            {'value': 'rotate', 'image': None},
        ]
        assert page['saved'] is False
        assert page['fake_provider'] is False

    def test_it_says_when_the_test_provider_is_on(self, client, account, settings):
        settings.AI_FAKE_PROVIDER = True

        page = client.get(CONFIG, HTTP_X_INERTIA='true').json()['props']

        assert page['fake_provider'] is True
        assert AiSettings.objects.count() == 1

    def test_it_needs_a_session(self, client):
        response = client.get(CONFIG)

        assert response.status_code == 302
        assert response.headers['Location'].startswith('/login')

    def test_a_valid_submission_saves_and_announces_it_once(self, client, account):
        response = client.post(CONFIG, self.VALID)

        assert response.status_code == 302
        assert response.headers['Location'] == CONFIG
        current = AiSettings.current()
        assert current.image_model == 'gpt-image-2.5-flare'
        assert current.image_quality == 'max'
        assert current.vision_model == 'gpt-5.4'
        assert current.markup_mode == 'layer'
        assert current.watermark == 'rotate'
        assert current.website_hourly_limit == 4
        assert current.website_daily_limit == 12
        assert AiSettings.objects.count() == 1
        # El aviso viaja una vez: la petición siguiente lo consume.
        assert props(client, CONFIG)['saved'] is True
        assert props(client, CONFIG)['saved'] is False

    def test_anyone_with_a_session_changes_it_for_everyone(self, client, account):
        """Es configuración de la compañía, como la credencial de Google."""
        client.post(CONFIG, self.VALID)

        client.logout()
        other = create_account(email='otra@ejemplo.test')
        client.force_login(other)

        assert props(client, CONFIG)['settings']['image_quality'] == 'max'

    @pytest.mark.parametrize(
        ('overrides', 'field'),
        [
            ({'image_model': 'dall-e-3'}, 'image_model'),
            ({'image_model': ''}, 'image_model'),
            ({'image_quality': 'ultra'}, 'image_quality'),
            ({'vision_model': 'gpt-4o'}, 'vision_model'),
            ({'vision_model': ''}, 'vision_model'),
            ({'markup_mode': 'recompose'}, 'markup_mode'),
            ({'markup_mode': ''}, 'markup_mode'),
            ({'watermark': 'gold'}, 'watermark'),
            ({'watermark': ''}, 'watermark'),
        ],
    )
    def test_a_value_outside_its_list_is_pinned_to_its_field_and_nothing_is_saved(
        self, client, account, overrides, field
    ):
        response = client.post(CONFIG, {**self.VALID, **overrides})

        assert response.status_code == 302
        assert response.headers['Location'] == CONFIG
        assert errors(client) == {field: {'code': 'OPTION_INVALID'}}
        current = AiSettings.current()
        assert current.image_model == 'gpt-image-2.5-sunburst'
        assert current.image_quality == 'high'
        assert current.vision_model == 'gpt-5.4-mini'
        assert current.markup_mode == 'copy'
        assert current.watermark == 'blue'

    def test_a_quality_the_model_does_not_admit_is_rejected(self, client, account):
        """`max` existe, pero no para `gpt-image-1`: guardarlo dejaría cada pedido en 400."""
        client.post(CONFIG, {**self.VALID, 'image_model': 'gpt-image-1', 'image_quality': 'max'})

        assert errors(client) == {'image_quality': {'code': 'OPTION_INVALID'}}
        assert AiSettings.current().image_model == 'gpt-image-2.5-sunburst'

    def test_every_field_is_checked_in_one_round_trip(self, client, account):
        client.post(
            CONFIG,
            {
                'image_model': 'x',
                'image_quality': 'x',
                'vision_model': 'x',
                'markup_mode': 'x',
                'watermark': 'x',
                'website_hourly_limit': 'x',
                'website_daily_limit': '0',
            },
        )

        # La calidad no se juzga cuando el modelo ya está mal: no hay contra
        # qué juzgarla, y un segundo error sobre lo mismo no ayuda a corregir.
        assert errors(client) == {
            'image_model': {'code': 'OPTION_INVALID'},
            'vision_model': {'code': 'OPTION_INVALID'},
            'markup_mode': {'code': 'OPTION_INVALID'},
            'watermark': {'code': 'OPTION_INVALID'},
            'website_hourly_limit': {'code': 'LIMIT_NOT_INTEGER'},
            'website_daily_limit': {'code': 'LIMIT_TOO_LOW'},
        }

    @pytest.mark.parametrize(
        ('value', 'code'),
        [
            ('', 'LIMIT_NOT_INTEGER'),
            ('2.5', 'LIMIT_NOT_INTEGER'),
            ('diez', 'LIMIT_NOT_INTEGER'),
            ('40000', 'LIMIT_NOT_INTEGER'),
            ('0', 'LIMIT_TOO_LOW'),
            ('-3', 'LIMIT_TOO_LOW'),
        ],
    )
    def test_a_website_limit_has_to_be_a_whole_number_of_at_least_one(
        self, client, account, value, code
    ):
        """Con cero la landing no podría generar nunca: eso es apagarla, no ponerle un tope."""
        client.post(CONFIG, {**self.VALID, 'website_hourly_limit': value})

        assert errors(client) == {'website_hourly_limit': {'code': code}}
        assert AiSettings.current().website_hourly_limit == 10


class TestPublicMedia:
    def test_a_result_opens_without_a_session(self, client, settings, tmp_path):
        settings.AI_MEDIA_ROOT = tmp_path
        results = tmp_path / 'results'
        results.mkdir()
        (results / '0195c0de-0000-7000-8000-000000000001.jpg').write_bytes(PNG)

        response = client.get('/media/ai/0195c0de-0000-7000-8000-000000000001.jpg')

        assert response.status_code == 200
        assert response['Content-Type'] == 'image/jpeg'
        assert 'immutable' in response['Cache-Control']
        assert not response['Content-Disposition'].startswith('attachment')
        assert b''.join(response.streaming_content) == PNG

    def test_with_download_it_is_saved_instead_of_opened(self, client, settings, tmp_path):
        """La landing la baja desde otro dominio, donde `<a download>` no hace nada."""
        settings.AI_MEDIA_ROOT = tmp_path
        results = tmp_path / 'results'
        results.mkdir()
        (results / '0195c0de-0000-7000-8000-000000000001.jpg').write_bytes(PNG)

        response = client.get('/media/ai/0195c0de-0000-7000-8000-000000000001.jpg?download=1')

        assert response.status_code == 200
        assert response['Content-Disposition'] == (
            'attachment; filename="0195c0de-0000-7000-8000-000000000001.jpg"'
        )

    def test_anything_that_is_not_ours_is_not_found(self, client, settings, tmp_path):
        settings.AI_MEDIA_ROOT = tmp_path

        assert client.get('/media/ai/0195c0de-0000-7000-8000-000000000009.jpg').status_code == 404
        assert client.get('/media/ai/..%2Fsources%2Fpatio.jpg').status_code == 404

    def test_a_source_photo_needs_a_session(self, client, account, settings, tmp_path):
        """Es el patio real de un cliente: sin sesión se va al ingreso, no a la foto."""
        settings.AI_MEDIA_ROOT = tmp_path
        sources = tmp_path / 'sources'
        sources.mkdir()
        (sources / '0195c0de-0000-7000-8000-000000000002.jpg').write_bytes(PNG)
        url = '/media/ai/sources/0195c0de-0000-7000-8000-000000000002.jpg'

        assert client.get(url).status_code == 200
        assert client.get('/media/ai/sources/patio.jpg').status_code == 404

        client.logout()
        response = client.get(url)

        assert response.status_code == 302
        assert response.headers['Location'].startswith('/login')

    def test_the_marks_need_a_session_too(self, client, account, settings, tmp_path):
        """Se dibujan sobre el patio de un cliente: mismo criterio que la foto."""
        settings.AI_MEDIA_ROOT = tmp_path
        markups = tmp_path / 'markups'
        markups.mkdir()
        (markups / '0195c0de-0000-7000-8000-000000000003.png').write_bytes(PNG)
        url = '/media/ai/markups/0195c0de-0000-7000-8000-000000000003.png'

        response = client.get(url)

        assert response.status_code == 200
        assert response['Content-Type'] == 'image/png'
        assert client.get('/media/ai/markups/patio.png').status_code == 404

        client.logout()
        assert client.get(url).status_code == 302


def marks_upload(size=(64, 48)) -> SimpleUploadedFile:
    """La capa que sube el editor: transparente, con un rectángulo pintado."""
    layer = Image.new('RGBA', size, (0, 0, 0, 0))
    layer.paste((239, 68, 68, 255), (10, 10, 30, 20))
    buffer = BytesIO()
    layer.save(buffer, format='PNG')
    return SimpleUploadedFile('markup.png', buffer.getvalue(), content_type='image/png')


class TestGeneratingWithMarks:
    NOTE = 'Red: a paver walkway.'

    @pytest.fixture
    def analysis(self, ready, tmp_path):
        analysis = create_photo_analysis()
        photo = tmp_path / analysis.source_path
        photo.parent.mkdir(parents=True, exist_ok=True)
        photo.write_bytes(jpeg_bytes(64, 48))
        return analysis

    def post(self, client, analysis, **fields):
        payload = {
            'analysis': str(analysis.id),
            'areas': json.dumps([DRIVEWAY_POUR]),
            'markup': marks_upload(),
            'markup_note': self.NOTE,
        }
        payload.update(fields)
        return client.post(
            GENERATE, {key: value for key, value in payload.items() if value is not None}
        )

    def test_the_marks_travel_with_the_request_and_come_back_in_its_props(
        self, client, account, analysis
    ):
        response = self.post(client, analysis)

        created = ImageGeneration.objects.get()
        assert response.headers['Location'].endswith(f'&generation={created.id}')
        generation = props(client, response.headers['Location'])['generation']
        assert generation['markup_url'] == f'/media/ai/markups/{created.id}.png'
        assert generation['markup_note'] == self.NOTE
        assert generation['markup_mode'] == 'copy'
        assert client.get(generation['markup_url']).status_code == 200

    @pytest.mark.parametrize(
        ('fields', 'code'),
        [
            ({'markup_note': ''}, 'MARKUP_NOTE_MISSING'),
            ({'markup': marks_upload(size=(32, 24))}, 'MARKUP_INVALID'),
            ({'markup': None}, 'MARKUP_INVALID'),
        ],
    )
    def test_what_does_not_serve_is_pinned_to_the_photo(
        self, client, account, analysis, fields, code
    ):
        response = self.post(client, analysis, **fields)

        assert response.headers['Location'] == f'{PAGE}?analysis={analysis.id}'
        assert errors(client) == {'markup': {'code': code}}
        assert not ImageGeneration.objects.exists()

    def test_a_request_without_marks_carries_none(self, client, account, analysis):
        response = generate(client, analysis, [DRIVEWAY_POUR])

        generation = props(client, response.headers['Location'])['generation']
        assert generation['markup_url'] is None
        assert generation['markup_mode'] is None

    def test_the_screen_gets_the_starting_colors_and_the_note_limit(self, client, account):
        page = props(client, PAGE)

        assert page['markup']['default_colors'] == ['#ef4444', '#facc15', '#3b82f6']
        assert page['markup']['max_note_length'] == prompt.NOTES_MAX_LENGTH
