"""
El análisis de la foto: qué áreas exteriores hay en ella, según el modelo de visión.

Es el segundo paso del recorrido y el primero que cuesta dinero: una llamada
por foto subida, antes de que la persona elija nada. Pasa por cola como la
generación —la petición deja la fila en `QUEUED` y vuelve enseguida— y el
trabajador es quien espera al proveedor.

Lo que vuelve se pide **estructurado**: la llamada lleva el esquema JSON y el
modelo está obligado a cumplirlo. Aun así, el servidor no confía: extrae el
JSON aunque venga envuelto en prosa, valida cada valor contra el catálogo y
descarta lo que no entiende sin romper el pedido (R29, R46). Dos fallos
distintos tienen dos caminos distintos:

- **El proveedor no contesta** (timeout, 5xx, límite de tasa): la fila queda en
  `RETRYING` y la tarea vuelve a intentar una vez. La segunda cierra `FAILED`.
- **Contesta, pero no se le entiende**: se reenvía en la misma ejecución con el
  esquema recordado en el mensaje. Si la segunda tampoco sirve, cierra `FAILED`
  con su propio código, que en pantalla deja pasar a elegir áreas a mano.
"""

import base64
import json
import logging

from django.db import transaction
from django.utils import timezone
from openai import APIConnectionError, APIStatusError, APITimeoutError, RateLimitError

from apps.ai import images, provider
from apps.ai.catalog import Area
from apps.ai.models import (
    AiSettings,
    AnalysisError,
    AnalysisState,
    GenerationOrigin,
    PhotoAnalysis,
)
from apps.ai.services import (
    MAX_SOURCE_BYTES,
    SourceImageInvalid,
    TransientProviderError,
    _read_all,
    _store_source,
    _usage,
    media_path,
)

logger = logging.getLogger(__name__)

#: Cuántas ejecuciones contra el proveedor admite un análisis: la primera y un
#: reintento (R46). La tarea deriva de acá cuántas veces reintenta.
MAX_ATTEMPTS = 2
#: Cuántas veces se le pide el JSON **en la misma ejecución** antes de darlo
#: por mal formado: la primera y una más, recordándole el esquema.
MAX_SCHEMA_ATTEMPTS = 2
#: Nombre del formato de salida, que el proveedor exige y no muestra a nadie.
SCHEMA_NAME = 'photo_analysis'
#: Con qué detalle mira la foto. `auto` deja que el proveedor decida según el
#: tamaño; `low` sería más barato pero mira una sola versión de 512 píxeles,
#: y un walkway o un entryway pueden perderse ahí.
IMAGE_DETAIL = 'auto'

AREA_CODES: list[str] = list(Area.values)

#: El esquema que se le exige al modelo. Es estricto, y en modo estricto un
#: objeto no admite claves libres, así que `confidence` no es `{área: número}`
#: sino un objeto con las seis áreas y `null` donde no hay puntaje; el servidor
#: lo reduce al diccionario que la pantalla espera.
RESPONSE_SCHEMA: dict = {
    'type': 'object',
    'properties': {
        'areas_detected': {
            'type': 'array',
            'items': {'type': 'string', 'enum': AREA_CODES},
        },
        'pool_present': {'type': 'boolean'},
        'confidence': {
            'type': 'object',
            'properties': {code: {'type': ['number', 'null']} for code in AREA_CODES},
            'required': AREA_CODES,
            'additionalProperties': False,
        },
        # Un punto por área, en coordenadas relativas al ancho y al alto de la
        # foto. Es dónde marcarla encima de la imagen, no cuánto ocupa: una
        # caja sobre un sendero en diagonal se come media parcela, y las
        # máscaras de verdad no salen de un modelo de chat.
        'points': {
            'type': 'object',
            'properties': {
                code: {
                    'type': ['array', 'null'],
                    'items': {'type': 'number'},
                    'minItems': 2,
                    'maxItems': 2,
                }
                for code in AREA_CODES
            },
            'required': AREA_CODES,
            'additionalProperties': False,
        },
    },
    'required': ['areas_detected', 'pool_present', 'confidence', 'points'],
    'additionalProperties': False,
}

#: Lo que el modelo tiene que saber para mirar la foto. Está en inglés porque
#: es para el modelo, no para una persona, y describe cada área para que no
#: confunda la pileta con su borde ni el entryway con el walkway.
INSTRUCTIONS = """\
You are a vision assistant for an outdoor hardscape and pool contractor. You look at one photo \
of a home exterior and report which outdoor areas are visible in it.

The only areas that exist are these six codes:
- patio_deck: a patio or deck surface next to the house, used for seating or dining.
- driveway: the paved surface where vehicles park or drive up to the garage.
- entryway: the paved landing or steps right at the front door.
- walkway: a path for walking between the driveway, the door, the yard or the pool.
- pool_deck: the paved surface surrounding a swimming pool.
- pool: the swimming pool itself (the water body), not the surface around it.

Rules:
- Report only areas that are actually visible in the photo. Do not guess areas that are out of \
frame.
- Grass, planting beds, fences, roofs and the house itself are not areas.
- pool_present is true only when a swimming pool (the water body) is visible. If it is, also \
list pool in areas_detected.
- confidence is your certainty from 0 to 1 for each area you listed; use null for areas you \
did not list.
- points is where each area you listed sits in the photo, as [x, y] with x and y between 0 and \
1: x is the fraction from the left edge, y the fraction from the top edge. The point must land \
**on the paved surface of that area** as it appears in the photo, never on grass, water, a wall \
or a roof. When an area wraps around another one, as a pool deck wraps around a pool, put the \
point on the widest part of the surface itself and never in the middle of what it encloses: the \
center of a ring is not part of the ring. When an area is a narrow strip, as a walkway, put the \
point on its widest visible stretch. Use null for areas you did not list.
- Answer with the JSON object only.
"""

QUESTION = 'Which of the six outdoor areas are visible in this photo?'

#: Lo que se agrega al mensaje en el segundo pedido de la misma ejecución,
#: cuando el primero no se pudo leer. Repite la forma exacta porque el fallo
#: fue justamente de forma.
SCHEMA_REMINDER = (
    'Your previous answer could not be read as the expected JSON. Reply with a single JSON '
    'object and nothing else, exactly in this shape: '
    '{"areas_detected": ["patio_deck", "driveway"], "pool_present": false, '
    '"confidence": {"patio_deck": 0.9, "driveway": 0.8, "entryway": null, "walkway": null, '
    '"pool_deck": null, "pool": null}, '
    '"points": {"patio_deck": [0.32, 0.61], "driveway": [0.78, 0.7], "entryway": null, '
    '"walkway": null, "pool_deck": null, "pool": null}}. '
    'Valid area codes: patio_deck, driveway, entryway, walkway, pool_deck, pool.'
)


class SourceImageTooLarge(Exception):
    """La foto supera el tope de entrada. Se rechaza antes de abrirla."""

    code = 'IMAGE_TOO_LARGE'


class MalformedResponse(Exception):
    """
    El proveedor contestó, pero no con un JSON que cumpla el esquema.

    Lleva el último texto y el consumo de cada llamada porque, aunque el
    análisis falle, las llamadas se pagaron y el texto es lo único que explica
    después qué contestó el modelo.
    """

    def __init__(self, raw_text: str = '', usages: list[dict] | None = None):
        super().__init__(raw_text)
        self.raw_text = raw_text
        self.usages = usages or []


def request_analysis(user, upload, origin: str = GenerationOrigin.TOOLS_APP) -> PhotoAnalysis:
    """
    Registra el análisis de una foto y lo encola.

    `origin` dice por dónde entró: la pantalla de Tools o la landing por la
    API, que lo pide sin cuenta (`user` nulo).

    La foto se comprime **en la petición**, como en la generación: el archivo
    grande nunca toca el disco, y es lo que permite aceptar 25 MB a la entrada
    (R20). Lo que no se pueda abrir como imagen sale como `SourceImageInvalid`
    sin dejar rastro.

    Sin clave del proveedor no se encola: la fila queda en `FAILED` con su
    código, que es lo que la pantalla va a decir. Encolarla igual dejaría un
    análisis esperando algo que no va a pasar.
    """
    from apps.ai import tasks

    size = getattr(upload, 'size', None)
    if size is not None and size > MAX_SOURCE_BYTES:
        raise SourceImageTooLarge()
    raw = _read_all(upload)
    if len(raw) > MAX_SOURCE_BYTES:
        raise SourceImageTooLarge()

    try:
        normalized = images.normalize_source(raw)
    except images.NotAnImage as error:
        raise SourceImageInvalid() from error

    analysis = PhotoAnalysis(
        requested_by=user if getattr(user, 'pk', None) else None, origin=origin
    )
    analysis.source_path = _store_source(analysis.id, normalized)

    if not provider.is_configured():
        logger.warning('IA: análisis %s rechazado, no hay OPENAI_API_KEY', analysis.id)
        return _fail(analysis, AnalysisError.PROVIDER_NOT_CONFIGURED)

    analysis.save()
    # Se encola al confirmar la transacción y no antes: si la fila no llegó a
    # escribirse, el trabajador no tiene que salir a buscarla.
    transaction.on_commit(lambda: tasks.analyze_photo.delay(str(analysis.id)))
    return analysis


def execute(analysis: PhotoAnalysis) -> PhotoAnalysis:
    """
    Mira la foto de un análisis ya registrado y guarda lo que encontró.

    Es idempotente sobre un análisis terminado: la cola reconoce las tareas al
    terminarlas, así que una puede entregarse dos veces, y la segunda no tiene
    que pagar otra llamada.

    Cada ejecución suma un intento. Un fallo del proveedor en el primero deja
    la fila en `RETRYING` y sale como `TransientProviderError` para que la
    tarea vuelva a llamar; en el último cierra `FAILED` acá mismo, así la regla
    de «un reintento» vive en un solo lugar y no depende de que la tarea la
    aplique bien.
    """
    if analysis.is_terminal:
        return analysis

    if not provider.is_configured():
        logger.warning('IA: análisis %s sin ejecutar, no hay OPENAI_API_KEY', analysis.id)
        return _fail(analysis, AnalysisError.PROVIDER_NOT_CONFIGURED)

    analysis.state = AnalysisState.RUNNING
    analysis.started_at = timezone.now()
    analysis.attempts += 1
    analysis.save(update_fields=['state', 'started_at', 'attempts', 'updated_at'])

    # El modelo se lee una vez por ejecución, como en la generación: lo que
    # queda en `usage` es lo que se llamó, aunque alguien cambie «Config»
    # mientras el análisis espera en la cola.
    model = AiSettings.current().vision_model

    try:
        parsed, raw_text, usages = _analyze(analysis, model)
    except (RateLimitError, APITimeoutError, APIConnectionError) as error:
        return _transient(analysis, error)
    except APIStatusError as error:
        if error.status_code >= 500:
            return _transient(analysis, error)
        # Un 4xx no se arregla insistiendo: la foto o la llamada no le
        # gustaron. R46 no tiene otro código para eso, así que el detalle va
        # al registro y el estado dice lo único que la pantalla puede hacer
        # con él.
        logger.warning('IA: el proveedor rechazó el análisis %s: %s', analysis.id, error)
        return _fail(analysis, AnalysisError.PROVIDER_UNAVAILABLE)
    except MalformedResponse as error:
        logger.warning('IA: el análisis %s no trajo un JSON legible dos veces', analysis.id)
        analysis.raw_response = error.raw_text
        analysis.usage = _usage_of(model, error.usages)
        return _fail(analysis, AnalysisError.MALFORMED_RESPONSE)
    except Exception:
        logger.exception('IA: fallo inesperado analizando %s', analysis.id)
        return _fail(analysis, AnalysisError.UNEXPECTED)

    analysis.areas_detected = parsed['areas_detected']
    analysis.pool_present = parsed['pool_present']
    analysis.confidence = parsed['confidence']
    analysis.points = parsed['points']
    analysis.raw_response = raw_text
    analysis.usage = _usage_of(model, usages)
    analysis.state = AnalysisState.COMPLETED
    analysis.finished_at = timezone.now()
    analysis.save(
        update_fields=[
            'areas_detected',
            'pool_present',
            'confidence',
            'points',
            'raw_response',
            'usage',
            'state',
            'finished_at',
            'updated_at',
        ]
    )
    return analysis


def _analyze(analysis: PhotoAnalysis, model: str) -> tuple[dict, str, list[dict]]:
    """
    Le pide el JSON al modelo, hasta dos veces en la misma ejecución.

    La segunda vez lleva el esquema recordado en el mensaje (R46). Se reenvía
    acá y no en la tarea porque el fallo no fue del proveedor sino de la
    respuesta, y un reintento con espera no cambia nada de eso.
    """
    image_url = _data_url(analysis)
    usages: list[dict] = []
    raw_text = ''
    for attempt in range(1, MAX_SCHEMA_ATTEMPTS + 1):
        question = QUESTION if attempt == 1 else f'{QUESTION}\n\n{SCHEMA_REMINDER}'
        response = _call_provider(model, question, image_url)
        usages.append(_usage(response))
        raw_text = getattr(response, 'output_text', None) or ''
        payload = _extract_json(raw_text)
        if payload is None:
            logger.warning(
                'IA: el análisis %s no trajo JSON en el intento %s', analysis.id, attempt
            )
            continue
        try:
            return _parse(payload, analysis.id), raw_text, usages
        except MalformedResponse:
            logger.warning(
                'IA: el análisis %s trajo un JSON fuera del esquema en el intento %s',
                analysis.id,
                attempt,
            )
    raise MalformedResponse(raw_text, usages)


def _call_provider(model: str, question: str, image_url: str):
    """
    Una llamada a la Responses API con la foto y el esquema.

    `store=False`: es el patio real de un cliente y no tiene por qué quedar
    guardado del lado del proveedor más allá de la llamada. El timeout va por
    petición porque el cliente compartido tiene el de las imágenes, que es
    tres veces más largo.
    """
    api = provider.client()
    return api.responses.create(
        model=model,
        instructions=INSTRUCTIONS,
        input=[
            {
                'role': 'user',
                'content': [
                    {'type': 'input_text', 'text': question},
                    {'type': 'input_image', 'image_url': image_url, 'detail': IMAGE_DETAIL},
                ],
            }
        ],
        text={
            'format': {
                'type': 'json_schema',
                'name': SCHEMA_NAME,
                'schema': RESPONSE_SCHEMA,
                'strict': True,
            }
        },
        store=False,
        timeout=provider.ANALYSIS_TIMEOUT,
    )


def _data_url(analysis: PhotoAnalysis) -> str:
    """La foto ya comprimida, como la espera el proveedor: base64 en línea."""
    raw = media_path(analysis.source_path).read_bytes()
    return f'data:image/jpeg;base64,{base64.b64encode(raw).decode()}'


def _extract_json(text: str) -> dict | None:
    """
    El objeto JSON que hay en el texto, o nada.

    Primero se prueba el texto entero, que es lo normal con salida
    estructurada. Si no, se busca la primera llave y se avanza hasta la que la
    cierra —contando las anidadas y saltando las que están dentro de una
    cadena— para quedarse con ese tramo: es lo que rescata un objeto envuelto
    en prosa o en una cerca de código. Un tramo que no se puede parsear, o que
    no es un objeto, se contesta como nada: el que llama decide qué hacer.
    """
    if not text:
        return None
    stripped = text.strip()
    parsed = _loads(stripped)
    if parsed is not None:
        return parsed

    start = stripped.find('{')
    if start == -1:
        return None
    depth = 0
    in_string = False
    escaped = False
    for index in range(start, len(stripped)):
        char = stripped[index]
        if in_string:
            if escaped:
                escaped = False
            elif char == '\\':
                escaped = True
            elif char == '"':
                in_string = False
            continue
        if char == '"':
            in_string = True
        elif char == '{':
            depth += 1
        elif char == '}':
            depth -= 1
            if depth == 0:
                return _loads(stripped[start : index + 1])
    return None


def _loads(text: str) -> dict | None:
    try:
        parsed = json.loads(text)
    except (json.JSONDecodeError, TypeError):
        return None
    return parsed if isinstance(parsed, dict) else None


def _parse(payload: dict, analysis_id) -> dict:
    """
    El JSON del modelo, validado contra el catálogo.

    Lo que no tiene la forma del esquema —la lista que no es lista, el booleano
    que no es booleano— es una respuesta mal formada y se pide de nuevo. Lo
    que sí tiene la forma pero trae valores que el catálogo no conoce se
    descarta y se registra (R29): un área inventada no rompe el análisis, se
    queda afuera de la lista.
    """
    areas = payload.get('areas_detected')
    pool_present = payload.get('pool_present')
    confidence = payload.get('confidence')
    if not isinstance(areas, list) or not isinstance(pool_present, bool):
        raise MalformedResponse()
    if confidence is None:
        confidence = {}
    if not isinstance(confidence, dict):
        raise MalformedResponse()
    points = payload.get('points')
    if points is None:
        points = {}
    if not isinstance(points, dict):
        raise MalformedResponse()

    known: list[str] = []
    for value in areas:
        if value in AREA_CODES:
            if value not in known:
                known.append(value)
            continue
        logger.warning('IA: el análisis %s trajo un área desconocida: %r', analysis_id, value)

    scores: dict[str, float] = {}
    for key, score in confidence.items():
        if key not in AREA_CODES:
            logger.warning(
                'IA: el análisis %s trajo un puntaje de un área desconocida: %r',
                analysis_id,
                key,
            )
            continue
        if score is None:
            continue
        if isinstance(score, bool) or not isinstance(score, int | float) or not 0 <= score <= 1:
            logger.warning(
                'IA: el análisis %s trajo un puntaje fuera de 0-1 para %s: %r',
                analysis_id,
                key,
                score,
            )
            continue
        scores[key] = float(score)

    # Un punto se descarta con el mismo criterio que un puntaje: fuera de 0-1 o
    # con otra forma, se registra y no viaja. La foto se marca con lo que quedó,
    # y un área sin punto simplemente no lleva marca.
    marks: dict[str, list[float]] = {}
    for key, point in points.items():
        if key not in AREA_CODES:
            logger.warning(
                'IA: el análisis %s trajo un punto de un área desconocida: %r', analysis_id, key
            )
            continue
        if point is None:
            continue
        if not isinstance(point, list | tuple) or len(point) != 2:
            logger.warning(
                'IA: el análisis %s trajo un punto con otra forma para %s: %r',
                analysis_id,
                key,
                point,
            )
            continue
        if any(
            isinstance(value, bool) or not isinstance(value, int | float) or not 0 <= value <= 1
            for value in point
        ):
            logger.warning(
                'IA: el análisis %s trajo un punto fuera de 0-1 para %s: %r',
                analysis_id,
                key,
                point,
            )
            continue
        marks[key] = [float(point[0]), float(point[1])]

    return {
        'areas_detected': known,
        'pool_present': pool_present,
        'confidence': scores,
        'points': marks,
    }


def _usage_of(model: str, usages: list[dict]) -> dict:
    """
    El consumo de la ejecución, con el modelo y una entrada por llamada.

    Va por llamada y no sumado porque una ejecución puede pagar dos —la del
    reenvío por JSON mal formado— y costear exige verlas. El modelo es el que
    se llamó, no el configurado ahora: pueden diferir.
    """
    return {'model': model, 'requests': usages}


def _transient(analysis: PhotoAnalysis, error: Exception) -> PhotoAnalysis:
    """
    Un fallo que vale la pena reintentar, si todavía queda intento.

    En el último, cierra acá: la pantalla no tiene que ver un `RETRYING` que no
    va a reintentar nada.
    """
    if analysis.attempts >= MAX_ATTEMPTS:
        logger.warning(
            'IA: análisis %s abandonado tras %s intentos', analysis.id, analysis.attempts
        )
        return _fail(analysis, AnalysisError.PROVIDER_UNAVAILABLE)
    logger.warning('IA: el proveedor falló en el análisis %s, se reintenta: %s', analysis.id, error)
    analysis.state = AnalysisState.RETRYING
    analysis.save(update_fields=['state', 'updated_at'])
    raise TransientProviderError(AnalysisError.PROVIDER_UNAVAILABLE) from None


def _fail(analysis: PhotoAnalysis, code: str) -> PhotoAnalysis:
    analysis.state = AnalysisState.FAILED
    analysis.error_code = code
    analysis.finished_at = timezone.now()
    analysis.save()
    return analysis


def fail(analysis: PhotoAnalysis, code: str) -> PhotoAnalysis:
    """Cierra un análisis que la tarea dejó de reintentar."""
    return _fail(analysis, code)


def analysis_props(analysis: PhotoAnalysis) -> dict:
    """
    La forma en que un análisis viaja a la pantalla. Códigos, nunca texto.

    `areas_detected` y `pool_present` van como se guardaron, salvo que
    `pool_present` ya lleva la fusión (`has_pool`): es la llave del contenedor
    de Pool Renovation y la pantalla no tiene por qué repetir la regla. `areas`
    es la lista fusionada que precarga el multi-select. `can_generate` dice si
    se puede seguir hasta generar (`PhotoAnalysis.can_generate`): la pantalla
    lo lee en vez de repetir la regla.
    """
    return {
        'id': str(analysis.id),
        'state': analysis.state,
        'error_code': analysis.error_code or None,
        'can_generate': analysis.can_generate,
        'areas_detected': list(analysis.areas_detected or []),
        'pool_present': analysis.has_pool,
        'confidence': dict(analysis.confidence or {}),
        'points': dict(analysis.points or {}),
        'areas': analysis.areas,
        'source_url': analysis.source_url,
        'attempts': analysis.attempts,
        'created_at': analysis.created_at.isoformat(),
        'started_at': analysis.started_at.isoformat() if analysis.started_at else None,
        'finished_at': analysis.finished_at.isoformat() if analysis.finished_at else None,
        'duration_seconds': analysis.duration_seconds,
    }
