"""
Pantallas de la herramienta de IA.

La tercera herramienta de la plataforma, con la misma anatomía que las otras
dos: una raíz sin barra final y el resto adentro. Su primer módulo es el
generador de imágenes: **una sola página** que se extiende hacia abajo a medida
que se completa —foto, análisis, áreas, contenedores, resultado— (R47), dos
envíos que la alimentan (analizar, generar) y una vista de cola que responde
qué se pidió y en qué quedó.

Arman props y delegan, como el resto de la interfaz: lo que se pide, lo que se
le dice al proveedor y cómo se registra vive en `analysis` y `services`.
"""

import json

from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.core.exceptions import ValidationError
from django.http import FileResponse, Http404
from django.shortcuts import redirect
from django.urls import reverse
from django.views.decorators.http import require_GET, require_http_methods, require_POST
from inertia import render

from apps.accounts.models import AccountRole
from apps.ai import analysis as photo_analysis
from apps.ai import catalog, fake, markup, prompt, provider, services, watermark
from apps.ai.models import (
    AiSettings,
    GenerationOrigin,
    GenerationState,
    ImageGeneration,
    PhotoAnalysis,
)
from apps.ai.prompt import AnswerInvalid
from apps.core.errors import stash_errors
from apps.core.requests import field

#: Que la configuración se acaba de guardar, para que la pantalla lo anuncie.
#: Va por la sesión porque el envío redirige: quien tiene que decirlo es la
#: petición siguiente (RT-11), igual que en el perfil.
CONFIG_SAVED_KEY = '_ai_config_saved'

#: Quiénes pueden leer el prompt (R53). Es lo que se le dice al proveedor, con
#: sus reglas duras adentro, y no es lo que la herramienta vende: quien la usa
#: para vender ve el diseño y lo que pidió, no el texto que lo produce.
PROMPT_ROLES = frozenset({AccountRole.SUPER_ADMIN, AccountRole.ADMIN})


def _may_see_prompt(request) -> bool:
    return request.user.role in PROMPT_ROLES


#: Un valor que no está en su lista cerrada. Es un solo código para los tres
#: campos: los tres son selects que sólo ofrecen valores válidos, así que
#: llegar acá es un envío armado a mano o una lista que cambió con la pantalla
#: abierta, y en los dos casos la salida es la misma: recargar y elegir.
OPTION_INVALID = 'OPTION_INVALID'
#: Los dos topes por visitante de la landing: un número que no es entero, y uno
#: menor que 1.
LIMIT_NOT_INTEGER = 'LIMIT_NOT_INTEGER'
LIMIT_TOO_LOW = 'LIMIT_TOO_LOW'
#: El techo de `PositiveSmallIntegerField`.
MAX_VISITOR_LIMIT = 32767


@login_required
def home(request):
    """
    La raíz de la herramienta.

    Redirige al generador y no dibuja un tablero propio: la herramienta tiene
    un solo módulo y el módulo tiene una sola página, así que una raíz con una
    tarjeta repetiría el menú. El día que haya un segundo módulo, esta raíz
    pasa a elegir entre ellos.
    """
    return redirect('ai.image_generator')


@login_required
@require_GET
def image_generator(request):
    """
    La página única del generador.

    Lo que hay en pantalla lo dicen dos identificadores en la dirección:
    `?analysis=` es la foto ya analizada (o analizándose) sobre la que se
    trabaja, y `?generation=` el pedido que se está mirando. Que viajen en la
    dirección es lo que permite recargar, compartir el enlace y que el sondeo
    sepa a quién mirar. Sin ninguno, la página arranca en la foto.

    El catálogo entero viaja siempre: la pantalla lo necesita para dibujar los
    contenedores. La receta del prompt no: sin ella la pantalla no puede armar
    el prompt en vivo, y ése es justamente el punto para un rol que no puede
    leerlo (R53). No alcanzaba con no dibujarlo: un texto que igual viajó en
    las props está a un clic de la consola.
    """
    analysis = _requested_analysis(request)
    generation = _requested_generation(request)
    may_see_prompt = _may_see_prompt(request)
    recent, recent_total = services.recent(may_see_prompt)

    return render(
        request,
        'Ai/ImageGenerator/Index',
        props={
            'catalog': catalog.catalog_props(settings.STATIC_URL),
            'can_see_prompt': may_see_prompt,
            'prompt': prompt.recipe() if may_see_prompt else None,
            'analysis': photo_analysis.analysis_props(analysis) if analysis else None,
            'generation': (
                services.generation_props(generation, may_see_prompt) if generation else None
            ),
            'recent': recent,
            'recent_total': recent_total,
            'recent_limit': services.RECENT_LIMIT,
            'active_states': services.active_states(),
            # Sólo el análisis que se está mirando: la página no sondea análisis
            # ajenos, y uno terminado no tiene nada que esperar.
            'analysis_active_states': (
                [analysis.state] if analysis and not analysis.is_terminal else []
            ),
            'analyze_url': reverse('ai.image_generator.analyze'),
            'generate_url': reverse('ai.image_generator.generate'),
            'max_source_bytes': services.MAX_SOURCE_BYTES,
            'max_notes_length': prompt.NOTES_MAX_LENGTH,
            # El editor de marcas: los colores con que arranca el pincel y el
            # tope de su nota, que es el mismo que el de las notas libres.
            'markup': {
                'default_colors': markup.default_color_props(),
                'max_note_length': prompt.NOTES_MAX_LENGTH,
            },
            'options': _table_options(),
        },
    )


def _requested_analysis(request) -> PhotoAnalysis | None:
    """
    El análisis que la dirección nombra, de quien sea.

    El producto es de empresa única: cualquiera con sesión ve cualquier
    análisis, igual que ve cualquier pedido en la cola. Un identificador mal
    formado se ignora en vez de romper: una dirección armada a mano no es algo
    que la persona pueda corregir leyendo un mensaje.
    """
    identifier = request.GET.get('analysis', '')
    if not identifier:
        return None
    try:
        return PhotoAnalysis.objects.filter(id=identifier).first()
    except (ValueError, TypeError, ValidationError):
        return None


def _requested_generation(request) -> ImageGeneration | None:
    """El pedido que la dirección nombra, de cualquier versión. Mismo criterio que el análisis."""
    identifier = request.GET.get('generation', '')
    if not identifier:
        return None
    try:
        return ImageGeneration.objects.filter(id=identifier).select_related('requested_by').first()
    except (ValueError, TypeError, ValidationError):
        return None


@login_required
@require_POST
def analyze(request):
    """
    Sube la foto y encola su análisis; vuelve a la página con `?analysis=<id>`.

    No analiza nada en la petición: registra, encola y vuelve. El tamaño y que
    sea una imagen los decide el servicio al abrirla —el tipo que declara el
    navegador es una promesa, no una comprobación—, y cada rechazo vuelve
    pegado al campo de la foto con su código.
    """
    back = reverse('ai.image_generator')

    upload = request.FILES.get('image')
    if upload is None:
        stash_errors(request, {'image': {'code': 'IMAGE_MISSING'}})
        return redirect(back)

    try:
        analysis = photo_analysis.request_analysis(request.user, upload)
    except (photo_analysis.SourceImageTooLarge, services.SourceImageInvalid) as error:
        stash_errors(request, {'image': {'code': error.code}})
        return redirect(back)

    return redirect(f'{back}?analysis={analysis.id}')


@login_required
@require_POST
def generate(request):
    """
    Registra el pedido sobre un análisis y lo encola; vuelve con los dos ids.

    `areas` y `guards` llegan como texto JSON dentro del formulario, que es la
    única manera de mandar una lista de objetos en multipart. Cada error vuelve
    pegado adonde se corrige (RT-08): `areas` si la lista no se puede leer,
    `analysis` si el análisis no terminó, `notes` si las notas libres pasan el
    tope, `guards` si lo que se bajó no está en el catálogo, `markup` si las
    marcas no sirven, y `areas[i].<campo>` para lo que el catálogo rechaza.
    Siempre se vuelve con `?analysis=<id>`: el análisis sigue siendo válido y no
    hay por qué repetirlo.

    Las marcas son opcionales y llegan en dos campos: `markup` (el archivo de
    la capa) y `markup_note`.
    """
    analysis = _analysis_or_404(field(request, 'analysis'))
    back = f'{reverse("ai.image_generator")}?analysis={analysis.id}'

    try:
        areas = json.loads(field(request, 'areas') or '[]')
    except json.JSONDecodeError:
        stash_errors(request, {'areas': {'code': 'ANSWER_INVALID'}})
        return redirect(back)

    try:
        guards = json.loads(field(request, 'guards') or '{}')
    except json.JSONDecodeError:
        stash_errors(request, {'guards': {'code': prompt.GUARD_INVALID}})
        return redirect(back)

    try:
        generation = services.request_generation(
            request.user,
            analysis,
            areas,
            field(request, 'notes'),
            guards,
            markup_layer=request.FILES.get('markup'),
            markup_note=field(request, 'markup_note'),
        )
    except services.AnalysisNotReady as error:
        stash_errors(request, {'analysis': {'code': error.code}})
        return redirect(back)
    except AnswerInvalid as error:
        stash_errors(request, {error.key: {'code': error.code}})
        return redirect(back)

    return redirect(f'{back}&generation={generation.id}')


def _analysis_or_404(identifier: str) -> PhotoAnalysis:
    """
    El análisis del envío, o 404.

    Acá sí es 404 y no un error de formulario: un envío sin análisis o con uno
    inexistente no sale de la pantalla, que siempre manda el que tiene.
    """
    try:
        analysis = PhotoAnalysis.objects.filter(id=identifier).first() if identifier else None
    except (ValueError, TypeError, ValidationError):
        analysis = None
    if analysis is None:
        raise Http404('Ese análisis no existe.')
    return analysis


@login_required
def queue(request):
    """
    Qué se pidió y en qué quedó, de cualquier versión y por cualquier puerta.

    Es la vista de seguimiento: el generador sólo muestra el pedido propio, y
    acá se ve el de todos, con su origen —esta app o el sitio— y su estado.
    Las filas v1 y v2 van juntas: es un solo registro.
    """
    rows, total = services.queue_rows(_may_see_prompt(request))

    return render(
        request,
        'Ai/Queue',
        props={
            'generations': rows,
            'total': total,
            'limit': services.QUEUE_LIMIT,
            'options': _table_options(),
            'active_states': services.active_states(),
        },
    )


@login_required
@require_http_methods(['GET', 'POST'])
def config(request):
    """
    La configuración del módulo: la pantalla y su envío, en la misma dirección.

    Tres selects de lista cerrada —modelo de imagen, calidad, modelo de visión—
    sobre el único registro de la compañía (P16, R52). La pantalla recibe el
    valor vigente y las listas **como códigos**; los rótulos los pone el
    catálogo de traducción. Las calidades viajan por modelo porque no todos
    admiten las mismas, y la pantalla recorta el select según el modelo elegido.

    Cualquier perfil con sesión puede cambiarla: es configuración de la
    compañía, no de la cuenta, y la misma regla que rige la credencial de
    Google rige acá.

    Un cuarto valor, el modo de las marcas (D6), va en su propia tarjeta pero
    en el mismo envío: es un solo registro y un solo formulario. La marca de
    agua, el quinto, también, y los dos topes por visitante de la landing.
    """
    if request.method == 'POST':
        return _save_config(request)

    current = AiSettings.current()
    return render(
        request,
        'Ai/Config',
        props={
            'settings': {
                'image_model': current.image_model,
                'image_quality': current.image_quality,
                'vision_model': current.vision_model,
                'markup_mode': current.markup_mode,
                'watermark': current.watermark,
                'website_hourly_limit': current.website_hourly_limit,
                'website_daily_limit': current.website_daily_limit,
            },
            'options': {
                'image_models': [
                    {'value': option.value, 'qualities': list(option.qualities)}
                    for option in provider.IMAGE_MODELS
                ],
                'image_qualities': list(provider.IMAGE_QUALITIES),
                'vision_models': list(provider.VISION_MODELS),
                'markup_modes': markup.mode_props(),
                'watermarks': watermark.option_props(settings.STATIC_URL),
            },
            'saved': bool(request.session.pop(CONFIG_SAVED_KEY, False)),
            'fake_provider': fake.is_active(),
        },
    )


def _save_config(request):
    """
    Valida los tres valores contra sus listas y guarda el registro.

    Cada rechazo vuelve pegado a su campo (RT-08). La calidad se valida contra
    el modelo elegido y no contra la lista global: `max` existe, pero no para
    `gpt-image-1`, y guardar esa pareja dejaría cada pedido en `PROVIDER_REJECTED`
    hasta que alguien adivine por qué.
    """
    image_model = field(request, 'image_model')
    image_quality = field(request, 'image_quality')
    vision_model = field(request, 'vision_model')
    markup_mode = field(request, 'markup_mode')
    watermark_choice = field(request, 'watermark')

    errors: dict[str, dict] = {}
    option = provider.image_model(image_model)
    if option is None:
        errors['image_model'] = {'code': OPTION_INVALID}
    elif not option.admits(image_quality):
        errors['image_quality'] = {'code': OPTION_INVALID}
    if vision_model not in provider.VISION_MODELS:
        errors['vision_model'] = {'code': OPTION_INVALID}
    if markup_mode not in markup.MarkupMode.values:
        errors['markup_mode'] = {'code': OPTION_INVALID}
    if watermark_choice not in watermark.Watermark.values:
        errors['watermark'] = {'code': OPTION_INVALID}
    limits = {}
    for name in ('website_hourly_limit', 'website_daily_limit'):
        limits[name], code = _visitor_limit(field(request, name))
        if code:
            errors[name] = {'code': code}

    if errors:
        stash_errors(request, errors)
        return redirect('ai.config')

    current = AiSettings.current()
    current.image_model = image_model
    current.image_quality = image_quality
    current.vision_model = vision_model
    current.markup_mode = markup_mode
    current.watermark = watermark_choice
    current.website_hourly_limit = limits['website_hourly_limit']
    current.website_daily_limit = limits['website_daily_limit']
    current.save(
        update_fields=[
            'image_model',
            'image_quality',
            'vision_model',
            'markup_mode',
            'watermark',
            'website_hourly_limit',
            'website_daily_limit',
            'updated_at',
        ]
    )

    request.session[CONFIG_SAVED_KEY] = True
    return redirect('ai.config')


def _visitor_limit(raw: str) -> tuple[int | None, str | None]:
    """
    Un tope por visitante de la landing, o el código de por qué no sirve.

    Al menos 1: con cero la landing no podría generar nunca, y eso no es un
    tope sino apagar la herramienta. El techo es el de la columna.
    """
    try:
        value = int(raw.strip())
    except ValueError:
        return None, LIMIT_NOT_INTEGER
    if value < 1:
        return None, LIMIT_TOO_LOW
    if value > MAX_VISITOR_LIMIT:
        return None, LIMIT_NOT_INTEGER
    return value, None


def _table_options() -> dict:
    """
    Por qué se puede filtrar la tabla de pedidos, en la cola y en «Recent».

    Sin filtro por tipo: el tipo era de la v1. Se filtra por servicio, que es
    lo que un pedido v2 lleva en sus contenedores; una fila v1 no tiene ninguno
    y queda afuera de cualquier servicio elegido.
    """
    return {
        'origin': list(GenerationOrigin.values),
        'state': list(GenerationState.values),
        'service': list(catalog.Service.values),
    }


def media(request, filename: str):
    """
    Una imagen generada, por su dirección pública.

    **Sin sesión, a propósito**: la landing la enlaza para mostrarla y bajarla
    sin pasos intermedios. Lo que la protege es el nombre, que no se adivina, y
    el servicio, que no sirve nada que no tenga esa forma. Un nombre que no es
    nuestro se contesta como inexistente, no como prohibido: no hay nada que
    prohibir.

    Inmutable en caché: el archivo nunca cambia bajo el mismo nombre.

    Con `?download=1` se baja en vez de abrirse. La landing lo necesita: un
    `<a download>` hacia otro dominio el navegador lo ignora, y sólo la
    cabecera `Content-Disposition` hace que se descargue.
    """
    path = services.result_file(filename)
    if path is None:
        raise Http404('Esa imagen no existe.')

    response = FileResponse(
        path.open('rb'),
        content_type='image/jpeg',
        as_attachment=request.GET.get('download') == '1',
        filename=filename,
    )
    response['Cache-Control'] = 'public, max-age=31536000, immutable'
    return response


@login_required
def source_media(request, filename: str):
    """
    La foto de origen de un análisis, para verla al lado del resultado.

    **Con sesión**, al revés que el resultado: es el patio real de un cliente
    y sólo se mira desde la app. Igual que el resultado, un nombre que no es
    nuestro se contesta como inexistente. Cacheable, pero sólo en el navegador
    de quien la pidió.
    """
    path = services.source_file(filename)
    if path is None:
        raise Http404('Esa foto no existe.')

    response = FileResponse(path.open('rb'), content_type='image/jpeg')
    response['Cache-Control'] = 'private, max-age=31536000, immutable'
    return response


@login_required
def markup_media(request, filename: str):
    """
    Las marcas de un pedido, para el resumen y para volver a cargarlas al ajustar.

    Con sesión, como la foto: se dibujan sobre el patio de un cliente. El
    archivo no cambia bajo el mismo nombre, porque cada pedido guarda las suyas.
    """
    path = services.markup_file(filename)
    if path is None:
        raise Http404('Esas marcas no existen.')

    response = FileResponse(path.open('rb'), content_type='image/png')
    response['Cache-Control'] = 'private, max-age=31536000, immutable'
    return response
