"""
Lo que el módulo de IA registra: el análisis de una foto y el pedido de imagen.

Una fila por análisis y una por pedido, con lo que se pidió, lo que se le dijo
al proveedor y lo que volvió. No reutiliza `Batch`: aquél está atado a un
dominio y sus tipos son de Google, y esto no depende de que haya un sitio
conectado.

Los estados tienen los **mismos nombres** que los de un lote —`QUEUED`,
`RUNNING`, `COMPLETED`, `FAILED`— a propósito: así la pantalla sondea con
`useBatchPolling`, que es el único mecanismo de actualización del producto, sin
tocarlo ni enseñarle otro vocabulario. El análisis suma `RETRYING`, que no es
terminal y por eso el sondeo lo sigue sin conocerlo.
"""

import uuid

from django.conf import settings
from django.db import models

from apps.ai import provider
from apps.ai.catalog import Area
from apps.ai.markup import DEFAULT_MODE, MarkupMode
from apps.ai.watermark import DEFAULT_WATERMARK, Watermark
from apps.core.models import BaseModel

#: La clave del único registro de configuración. Es un UUID fijo y no `pk=1`
#: porque toda tabla del sistema lleva UUID como clave (`BaseModel`), y un
#: entero acá sería la única excepción. El valor es arbitrario y estable: lo
#: que importa es que `current()` y la migración inicial hablen de la misma
#: fila.
AI_SETTINGS_ID = uuid.UUID('00000000-0000-4000-8000-00000000a15e')


class AiSettings(BaseModel):
    """
    La configuración del módulo de IA: qué modelo y qué calidad usa la compañía,
    cómo viajan las marcas dibujadas sobre la foto y qué marca de agua lleva el
    resultado.

    Es **un solo registro** para toda la instalación, como la credencial de
    Google o el sitio conectado: el producto es de empresa única y lo que un
    perfil elige acá lo usan los trabajadores de todos (P16). No lleva
    `account` a propósito: una configuración por cuenta es una decisión
    multitenant que todavía no se tomó, y el día que se tome pasa por el mismo
    puente que las demás tablas compartidas.

    Los tres campos son `choices` cerrados y **sin `blank`**: no existe una
    configuración sin valor. Lo que se ofrece lo declara `provider.py`; la
    pantalla nunca deja escribir un nombre de modelo (R52). Los `choices` se
    fijan en la migración, así que agregar un modelo a la lista pide una
    migración de `AlterField`: es el precio de que la base no acepte lo que la
    pantalla no ofrece.
    """

    image_model = models.CharField(
        max_length=60,
        choices=[(option.value, option.value) for option in provider.IMAGE_MODELS],
    )
    image_quality = models.CharField(
        max_length=20, choices=[(quality, quality) for quality in provider.IMAGE_QUALITIES]
    )
    vision_model = models.CharField(
        max_length=60, choices=[(model, model) for model in provider.VISION_MODELS]
    )
    #: En qué forma viaja el dibujo, además de la foto, cuando un pedido trae
    #: marcas (`markup.MarkupMode`). Lleva `default` y los otros tres no porque llegó
    #: con el registro ya creado: la migración tiene que poder llenarlo.
    markup_mode = models.CharField(max_length=20, choices=MarkupMode.choices, default=DEFAULT_MODE)
    #: Qué badge se quema en cada resultado, o rotar entre los tres
    #: (`watermark.Watermark`). Lleva `default` por lo mismo que el modo de las
    #: marcas: llegó con el registro ya creado.
    watermark = models.CharField(
        max_length=20, choices=Watermark.choices, default=DEFAULT_WATERMARK
    )
    #: El tope de generaciones por visitante de la landing, en dos ventanas: la
    #: última hora y el día. Cada generación es una llamada paga y la landing es
    #: pública; el número lo decide la compañía desde «Config», no un deploy.
    #: Los análisis no cuentan (decisión de la owner, 2026-10-08).
    website_hourly_limit = models.PositiveSmallIntegerField(default=10)
    website_daily_limit = models.PositiveSmallIntegerField(default=20)

    class Meta:
        verbose_name_plural = 'AI settings'

    @classmethod
    def current(cls) -> 'AiSettings':
        """
        El registro vigente; si no existe, nace con los valores de la v1.

        La migración inicial ya lo crea, así que en producción esto es un
        `get`. La creación queda igual como red: una base restaurada de antes
        de la migración, o la de pruebas, no pueden dejar la pantalla con un
        select vacío ni al trabajador sin modelo que pedir (R52).
        """
        row, _created = cls.objects.get_or_create(
            pk=AI_SETTINGS_ID,
            defaults={
                'image_model': provider.DEFAULT_IMAGE_MODEL,
                'image_quality': provider.DEFAULT_IMAGE_QUALITY,
                'vision_model': provider.DEFAULT_VISION_MODEL,
            },
        )
        return row


class GenerationKind(models.TextChoices):
    """
    El tipo de un pedido **de la v1**, que elegía la persona al entrar.

    Sobrevive sólo para leer las filas viejas: en la v2 el pedido no tiene
    tipo, tiene áreas, y `kind` queda vacío. Las opciones se conservan para que
    la cola siga pudiendo nombrar lo que se pidió entonces (decisión 1 del plan
    de `ai-image-generator-v2`).
    """

    POOL = 'POOL', 'Pool'
    PAVER = 'PAVER', 'Paver'


class GenerationOrigin(models.TextChoices):
    """
    Por dónde entró el pedido.

    Es lo que la vista de cola responde primero: una generación desde la landing
    la pidió un visitante anónimo, una desde acá la pidió una cuenta.
    """

    TOOLS_APP = 'TOOLS_APP', 'Tools app'
    WEBSITE = 'WEBSITE', 'Website'


class GenerationState(models.TextChoices):
    QUEUED = 'QUEUED', 'Queued'
    RUNNING = 'RUNNING', 'Running'
    COMPLETED = 'COMPLETED', 'Completed'
    FAILED = 'FAILED', 'Failed'


#: Estados en los que un pedido ya no cambia. Coincide con `TERMINAL_STATES` de
#: `useBatchPolling`, que es quien decide cuándo dejar de sondear.
TERMINAL_GENERATION_STATES = frozenset({GenerationState.COMPLETED, GenerationState.FAILED})


class GenerationError:
    """
    Por qué un pedido terminó en `FAILED`, en claves cerradas.

    Va como código y no como frase: la pantalla lo traduce y la API lo publica
    tal cual, y ninguna de las dos tiene que leer el texto de una excepción.
    """

    #: No hay clave del proveedor en el entorno: el módulo no puede trabajar.
    PROVIDER_NOT_CONFIGURED = 'PROVIDER_NOT_CONFIGURED'
    #: El proveedor pidió esperar. Se reintenta.
    PROVIDER_RATE_LIMIT = 'PROVIDER_RATE_LIMIT'
    #: El proveedor rechazó el pedido: un 400, o su política de contenido.
    PROVIDER_REJECTED = 'PROVIDER_REJECTED'
    #: El proveedor no contestó o contestó 5xx. Se reintenta.
    PROVIDER_UNAVAILABLE = 'PROVIDER_UNAVAILABLE'
    #: Contestó bien pero sin imagen adentro.
    PROVIDER_EMPTY_RESPONSE = 'PROVIDER_EMPTY_RESPONSE'
    #: Falta en disco una muestra que el prompt nombra. Es nuestra: el catálogo
    #: la declara y el pedido ya la prometió, así que no se genera sin ella.
    REFERENCE_MISSING = 'REFERENCE_MISSING'
    #: Cualquier otra cosa, que ya quedó en el registro.
    UNEXPECTED = 'UNEXPECTED'


class ImageGeneration(BaseModel):
    """
    Un pedido de imagen.

    Un pedido v2 nace de un análisis (`analysis`) y lleva sus contenedores en
    `areas`: `[{area, service, answers}]`, con las respuestas **por código de
    opción** (R18), no por su rótulo ni por el texto que fue al prompt: el
    rótulo cambia con el idioma y el texto del prompt cambia cuando se afina el
    catálogo, y lo que se eligió tiene que poder leerse igual mañana. `prompt`
    es lo que efectivamente se le dijo al proveedor, congelado, para poder
    explicar un resultado después.

    Las filas de la v1 no tienen análisis ni áreas: tienen `kind` y `answers`
    planas. Se conservan tal cual y se distinguen por `is_v2`; nada las
    reescribe, porque lo que se muestra de un pedido sale del pedido.
    """

    #: Sólo en las filas v1. Vacío en la v2, donde el tipo lo dicen las áreas.
    kind = models.CharField(max_length=20, choices=GenerationKind.choices, blank=True, default='')
    origin = models.CharField(max_length=20, choices=GenerationOrigin.choices)
    state = models.CharField(
        max_length=20, choices=GenerationState.choices, default=GenerationState.QUEUED
    )
    #: El análisis del que sale el pedido. Nulo en las filas v1. `PROTECT` y no
    #: `SET_NULL`: la foto de origen vive en `sources/<analysis.id>.jpg` y es
    #: del análisis, así que borrar el análisis dejaría al pedido apuntando a
    #: una foto que ya no está y sin poder explicar de dónde salió. Un análisis
    #: con pedidos es historia, no se borra.
    analysis = models.ForeignKey(
        'PhotoAnalysis',
        null=True,
        blank=True,
        on_delete=models.PROTECT,
        related_name='generations',
    )
    #: Los contenedores del pedido v2, ya limpios y en el orden de la taxonomía:
    #: `[{area, service, answers}]`. Vacío en las filas v1.
    areas = models.JSONField(default=list, blank=True)
    #: Las respuestas planas de la v1. Vacío en la v2.
    answers = models.JSONField(default=dict, blank=True)
    #: Las notas libres que escribió quien pidió, ya limpias. Se guardan aparte
    #: del prompt aunque también estén adentro: son lo único que la persona
    #: redactó, y para volver a pedir con un ajuste hay que poder devolvérselas
    #: al formulario sin tener que recortar el prompt.
    notes = models.TextField(blank=True, default='')
    #: El estado de cada guard cuando se pidió, `{id: True}` con el catálogo
    #: entero. Se guarda por lo mismo que las notas —ajustar tiene que devolver
    #: el formulario como estaba— y porque explica el resultado: un pedido que
    #: hizo algo que ninguna respuesta pide se entiende mirando qué se bajó.
    guards = models.JSONField(default=dict, blank=True)
    #: Las muestras que se adjuntan a la llamada, por su ruta bajo `STATIC_URL`,
    #: **en el orden en que van**. Se guardan y no se recalculan porque el
    #: prompt las nombra por número —«Image 2»— y afinar el catálogo entre el
    #: pedido y la ejecución dejaría el texto apuntando a otra imagen.
    references = models.JSONField(default=list, blank=True)
    #: Las marcas dibujadas sobre la foto, como capa PNG transparente relativa a
    #: `AI_MEDIA_ROOT` (`markups/<id>.png`). Vacío si el pedido no trae marcas.
    #: Se guarda la capa y no la copia anotada: la copia se deriva de ella al
    #: ejecutar, y guardar la fuente permite devolverla al editor al ajustar el
    #: pedido.
    markup_path = models.CharField(max_length=200, blank=True, default='')
    #: La nota de las marcas, ya limpia: qué marcó la persona y para qué, en sus
    #: palabras. El código no interpreta los colores; lo hace esta nota.
    markup_note = models.TextField(blank=True, default='')
    #: El modo con que viajan las marcas, **congelado al pedir** (el prompt
    #: depende de él). Vacío si no hay marcas. Un pedido hecho con los modos de
    #: máscara, que ya no existen, conserva el suyo: es lo que se le mandó.
    markup_mode = models.CharField(
        max_length=20, choices=MarkupMode.choices, blank=True, default=''
    )
    prompt = models.TextField()
    #: Rutas relativas a `AI_MEDIA_ROOT`. Vacías mientras no existan.
    source_path = models.CharField(max_length=200, blank=True, default='')
    result_path = models.CharField(max_length=200, blank=True, default='')
    error_code = models.CharField(max_length=40, blank=True, default='')
    #: Lo que el proveedor reportó haber consumido, crudo. Sirve para costear.
    usage = models.JSONField(default=dict, blank=True)
    #: Nulo cuando el pedido entró por la API: ahí no hay cuenta, hay una clave.
    requested_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name='image_generations',
    )
    #: Quién pidió desde la landing, para el tope por visitante: el hash de IP,
    #: user agent y día (`quota.visitor_fingerprint`). Se guarda el hash y no
    #: la IP: alcanza para contar y no deja un dato personal en la tabla.
    #: Vacío en lo que no entró por la API.
    visitor_fingerprint = models.CharField(max_length=64, blank=True, default='')
    started_at = models.DateTimeField(null=True, blank=True)
    finished_at = models.DateTimeField(null=True, blank=True)

    class Meta:
        ordering = ['-created_at']
        indexes = [
            models.Index(fields=['state'], name='ai_generation_state_idx'),
            models.Index(fields=['kind', 'state'], name='ai_generation_kind_state_idx'),
            models.Index(
                fields=['visitor_fingerprint', 'created_at'], name='ai_generation_visitor_idx'
            ),
        ]

    @property
    def has_source_image(self) -> bool:
        return bool(self.source_path)

    @property
    def is_v2(self) -> bool:
        """
        Si es un pedido por áreas o uno de la v1.

        Se mira el análisis **o** las áreas: un pedido v2 siempre tiene los
        dos, pero alcanza con uno para no confundirlo con una fila vieja.
        """
        return bool(self.analysis_id) or bool(self.areas)

    @property
    def is_terminal(self) -> bool:
        return self.state in TERMINAL_GENERATION_STATES

    @property
    def result_url(self) -> str | None:
        """La dirección pública del resultado, o nada mientras no exista."""
        if not self.result_path:
            return None
        return f'{settings.AI_MEDIA_URL}{self.result_path.rsplit("/", 1)[-1]}'

    @property
    def duration_seconds(self) -> int | None:
        if self.started_at is None or self.finished_at is None:
            return None
        return int((self.finished_at - self.started_at).total_seconds())


class AnalysisState(models.TextChoices):
    """
    Por dónde va el análisis de una foto.

    `RETRYING` existe para que la pantalla pueda decir «falló, reintentando»
    mientras la tarea espera el segundo intento: un `QUEUED` a secas no
    distingue «todavía no empezó» de «ya falló una vez».
    """

    QUEUED = 'QUEUED', 'Queued'
    RUNNING = 'RUNNING', 'Running'
    RETRYING = 'RETRYING', 'Retrying'
    COMPLETED = 'COMPLETED', 'Completed'
    FAILED = 'FAILED', 'Failed'


#: Estados en los que un análisis ya no cambia. Los mismos dos que reconoce
#: `useBatchPolling`, que es quien decide cuándo dejar de sondear.
TERMINAL_ANALYSIS_STATES = frozenset({AnalysisState.COMPLETED, AnalysisState.FAILED})


class AnalysisError:
    """
    Por qué un análisis terminó en `FAILED`, en claves cerradas.

    Es una lista aparte de `GenerationError` y no una extensión suya: los dos
    procesos fallan de formas distintas —el análisis puede contestar bien y
    aun así no servir (`MALFORMED_RESPONSE`), la generación no— y la pantalla
    los traduce por catálogos distintos (`ai.analysis.error.*`). Que tres
    nombres coincidan es porque el proveedor es el mismo, no porque el juego
    sea el mismo.
    """

    #: No hay clave del proveedor en el entorno: el módulo no puede trabajar.
    PROVIDER_NOT_CONFIGURED = 'PROVIDER_NOT_CONFIGURED'
    #: El proveedor no contestó, pidió esperar o contestó con error, dos veces
    #: seguidas. También cuando rechazó la llamada: R46 no distingue.
    PROVIDER_UNAVAILABLE = 'PROVIDER_UNAVAILABLE'
    #: Contestó, pero dos veces sin un JSON que cumpla el esquema.
    MALFORMED_RESPONSE = 'MALFORMED_RESPONSE'
    #: Cualquier otra cosa, que ya quedó en el registro.
    UNEXPECTED = 'UNEXPECTED'


class PhotoAnalysis(BaseModel):
    """
    Lo que el modelo de visión encontró en una foto.

    Es una entidad propia y no columnas del pedido porque un análisis puede
    tener varios pedidos —ajustar y regenerar reusa la foto y lo detectado— y
    tiene su propio estado y sus propios reintentos.

    `areas_detected` y `pool_present` se guardan **como llegaron**; la fusión
    de las dos formas (R21) la hacen `areas` y `has_pool`, para poder explicar
    después qué dijo el modelo y qué dedujo el servidor. `raw_response` es el
    texto crudo, para lo mismo. `confidence` sólo se muestra: ninguna regla del
    servidor la lee.
    """

    state = models.CharField(
        max_length=20, choices=AnalysisState.choices, default=AnalysisState.QUEUED
    )
    #: Por dónde entró, con el mismo vocabulario que el pedido: la pantalla de
    #: Tools o la landing por la API.
    origin = models.CharField(
        max_length=20, choices=GenerationOrigin.choices, default=GenerationOrigin.TOOLS_APP
    )
    #: Ruta relativa a `AI_MEDIA_ROOT`: `sources/<id>.jpg`, ya comprimida.
    source_path = models.CharField(max_length=200)
    #: Códigos de `catalog.Area`, ya filtrados contra el enum.
    areas_detected = models.JSONField(default=list, blank=True)
    pool_present = models.BooleanField(default=False)
    #: `{area: puntaje 0-1}`, sólo para las áreas que el modelo puntuó.
    confidence = models.JSONField(default=dict, blank=True)
    #: `{area: [x, y]}` en coordenadas relativas 0-1, para marcar cada área
    #: sobre la foto. Sólo las que el modelo ubicó, y sólo se muestra: ninguna
    #: regla del servidor lo lee.
    points = models.JSONField(default=dict, blank=True)
    raw_response = models.TextField(blank=True, default='')
    error_code = models.CharField(max_length=40, blank=True, default='')
    #: Cuántas veces se ejecutó contra el proveedor. Es lo que decide si un
    #: fallo transitorio se reintenta o cierra el análisis.
    attempts = models.PositiveSmallIntegerField(default=0)
    #: Lo que el proveedor reportó haber consumido, por llamada. Sirve para
    #: costear: el análisis se paga antes de que la persona elija nada.
    usage = models.JSONField(default=dict, blank=True)
    requested_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name='photo_analyses',
    )
    started_at = models.DateTimeField(null=True, blank=True)
    finished_at = models.DateTimeField(null=True, blank=True)

    class Meta:
        ordering = ['-created_at']
        indexes = [models.Index(fields=['state'], name='ai_analysis_state_idx')]

    @property
    def is_terminal(self) -> bool:
        return self.state in TERMINAL_ANALYSIS_STATES

    @property
    def can_generate(self) -> bool:
        """
        Si se puede pedir una generación sobre este análisis.

        Terminado, sí. Fallido porque el modelo contestó algo que no se pudo
        leer (`MALFORMED_RESPONSE`), también: la foto está guardada y sirve,
        lo único que falta es saber qué áreas tiene, y eso lo dice la persona
        eligiéndolas a mano. Sin detección, `has_pool` es falso y la pileta no
        se ofrece. Los demás fallos son del pedido (el proveedor no contestó,
        algo inesperado): ahí corresponde reintentar el análisis, no seguir.
        """
        return self.state == AnalysisState.COMPLETED or (
            self.state == AnalysisState.FAILED
            and self.error_code == AnalysisError.MALFORMED_RESPONSE
        )

    @property
    def has_pool(self) -> bool:
        """
        Si hay pileta, dicho de cualquiera de las dos formas.

        El modelo puede traer `pool` en la lista, `pool_present` en verdadero,
        o las dos cosas: son equivalentes (R21) y ninguna vale más que la otra.
        """
        return bool(self.pool_present) or Area.POOL.value in (self.areas_detected or [])

    @property
    def areas(self) -> list[str]:
        """
        Las áreas detectadas, con la pileta fusionada, en el orden del catálogo.

        Es lo que precarga el multi-select. Va en el orden de la taxonomía y no
        en el que las nombró el modelo para que la pantalla y el pedido —que
        también ordena así— muestren lo mismo.
        """
        detected = set(self.areas_detected or [])
        if self.has_pool:
            detected.add(Area.POOL.value)
        return [area for area in Area.values if area in detected]

    @property
    def source_url(self) -> str:
        """La dirección de la foto, que sólo se sirve con sesión."""
        return f'{settings.AI_MEDIA_URL}{self.source_path}'

    @property
    def duration_seconds(self) -> int | None:
        if self.started_at is None or self.finished_at is None:
            return None
        return int((self.finished_at - self.started_at).total_seconds())
