"""
El proveedor de prueba: el recorrido del generador de punta a punta sin pagar
imágenes.

Con `AI_FAKE_PROVIDER` prendido, `provider.client()` entrega este cliente en
vez del de OpenAI. Contesta con la misma forma que el SDK, así que todo lo
demás corre igual que de verdad: la API, la cola, los estados, el parseo, la
marca de agua, el disco y la landing. Sólo cambia de dónde sale la respuesta.

- **El análisis** se paga una vez por foto y después sale de una caché, por
  el hash de la foto que se le manda al modelo. Primero se busca entre los
  análisis versionados en `fake/analyses/` (el de la foto de prueba, para que
  quien clona el repo arranque sin gastar) y después entre los que se fueron
  guardando en `AI_MEDIA_ROOT/fake/analyses/`. Sólo se guarda una respuesta
  que es JSON: una rota se pagaría de nuevo en cada prueba.
- **La generación** no llama a nadie: después de una espera devuelve
  `fake/result.jpg` tal cual, como si lo hubiera dibujado el modelo.
- **Las fallas** se eligen en el `.env`, una para cada proceso, y se lanzan
  con las mismas excepciones del SDK: lo que se prueba es el camino real de
  los reintentos y los códigos, no uno paralelo.

Sólo existe en desarrollo: `base.py` lo deja apagado sin leer el `.env`, y
`check_settings` frena el arranque si se prende sin `DEBUG`.
"""

import base64
import hashlib
import json
import logging
import time
from collections.abc import Callable
from pathlib import Path
from types import SimpleNamespace

import httpx
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from openai import APIConnectionError

logger = logging.getLogger(__name__)

FIXTURES = Path(__file__).resolve().parent / 'fake'
#: La foto para probar: subida tal cual a Tools se guarda sin recomprimir, y su
#: análisis está versionado, así que recorrerla no cuesta nada. Desde la landing
#: el navegador la recomprime, así que ahí se paga una vez.
SOURCE_IMAGE = FIXTURES / 'source.jpg'
#: La imagen que devuelve toda generación de prueba. Ya trae el badge: el que
#: quema Tools cae en el mismo lugar y no se nota la doble capa.
RESULT_IMAGE = FIXTURES / 'result.jpg'
#: Los análisis versionados, por el hash de la foto.
COMMITTED_ANALYSES = FIXTURES / 'analyses'

#: Contesta como el proveedor cuando todo sale bien.
SUCCESS = 'success'
#: El proveedor no contesta: el pedido pasa por `RETRYING` y termina en
#: `FAILED PROVIDER_UNAVAILABLE`.
UNAVAILABLE = 'unavailable'
#: Contesta, pero sin servir: el análisis sin JSON (`MALFORMED_RESPONSE`) y la
#: generación sin imagen (`PROVIDER_EMPTY_RESPONSE`).
MALFORMED = 'malformed'
OUTCOMES = (SUCCESS, UNAVAILABLE, MALFORMED)


def is_active() -> bool:
    return settings.AI_FAKE_PROVIDER


def check_settings() -> None:
    """
    Frena el arranque si el proveedor de prueba está mal prendido.

    Prendido sin `DEBUG` es un entorno que no es el local, y ahí un resultado
    inventado pasaría por uno de verdad. Un desenlace mal escrito en el `.env`
    se avisa al arrancar y no en la primera prueba, que fallaría sin decir por
    qué.
    """
    if not is_active():
        return
    if not settings.DEBUG:
        raise ImproperlyConfigured('AI_FAKE_PROVIDER sólo se puede prender en desarrollo.')
    for name in ('AI_FAKE_ANALYSIS_OUTCOME', 'AI_FAKE_GENERATION_OUTCOME'):
        if getattr(settings, name) not in OUTCOMES:
            raise ImproperlyConfigured(f'{name} tiene que ser uno de {", ".join(OUTCOMES)}.')


class Client:
    """
    El cliente de prueba, con los dos recursos del SDK que usa el generador.

    Recibe cómo construir el cliente real porque el análisis lo necesita la
    primera vez que ve una foto.
    """

    def __init__(self, real: Callable):
        self.responses = _Responses(real)
        self.images = _Images()


class _Responses:
    def __init__(self, real: Callable):
        self._real = real

    def create(self, **kwargs):
        outcome = settings.AI_FAKE_ANALYSIS_OUTCOME
        if outcome == UNAVAILABLE:
            _pause()
            raise _unavailable('responses')
        if outcome == MALFORMED:
            _pause()
            return SimpleNamespace(output_text='The photo shows a backyard.', usage=None)

        key = _photo_key(kwargs['input'])
        cached = _cached_analysis(key)
        if cached is not None:
            _pause()
            return SimpleNamespace(output_text=cached, usage=None)

        logger.warning('IA (prueba): análisis nuevo, se paga una vez y queda en caché (%s)', key)
        response = self._real().responses.create(**kwargs)
        _store_analysis(key, getattr(response, 'output_text', None) or '')
        return response


class _Images:
    def edit(self, **kwargs):
        return self._answer()

    def generate(self, **kwargs):
        return self._answer()

    def _answer(self):
        outcome = settings.AI_FAKE_GENERATION_OUTCOME
        _pause()
        if outcome == UNAVAILABLE:
            raise _unavailable('images')
        if outcome == MALFORMED:
            return SimpleNamespace(data=[], usage=None)
        encoded = base64.b64encode(RESULT_IMAGE.read_bytes()).decode()
        return SimpleNamespace(data=[SimpleNamespace(b64_json=encoded)], usage=None)


def _pause() -> None:
    """La espera de un proveedor de verdad: sin ella, el loader y el sondeo no se ven."""
    time.sleep(settings.AI_FAKE_DELAY_SECONDS)


def _unavailable(resource: str) -> APIConnectionError:
    request = httpx.Request('POST', f'https://api.openai.com/v1/{resource}')
    return APIConnectionError(request=request)


def _photo_key(content: list[dict]) -> str:
    """El hash de la foto que va en el pedido: la misma foto, el mismo análisis."""
    image_url = next(
        part['image_url']
        for message in content
        for part in message['content']
        if part['type'] == 'input_image'
    )
    return hashlib.sha256(image_url.encode()).hexdigest()


def _cache_dir() -> Path:
    return Path(settings.AI_MEDIA_ROOT) / 'fake' / 'analyses'


def _cached_analysis(key: str) -> str | None:
    for folder in (COMMITTED_ANALYSES, _cache_dir()):
        path = folder / f'{key}.json'
        if path.exists():
            return path.read_text(encoding='utf-8')
    return None


def _store_analysis(key: str, text: str) -> None:
    try:
        json.loads(text)
    except json.JSONDecodeError:
        return
    folder = _cache_dir()
    folder.mkdir(parents=True, exist_ok=True)
    (folder / f'{key}.json').write_text(text, encoding='utf-8')
