"""
Lo que se hace con cada imagen antes de guardarla.

Las dos que pasan por acá se comprimen, y por motivos distintos:

- **La foto de origen** llega como la saca un teléfono: 5 a 8 MB, 4000 píxeles
  de lado y a veces girada según su EXIF. El modelo trabaja a un tamaño
  acotado, así que mandarle más no mejora nada y sí tarda más en subir. Se
  endereza, se reescala y se guarda como JPEG. Se hace **en la petición** y no
  en la cola: tarda menos de un segundo y así el archivo grande nunca toca el
  disco. Es lo que permite aceptar 25 MB a la entrada.
- **El resultado** vuelve como PNG de 2 a 3 MB. Es lo que la landing enlaza y lo
  que se descarga, y se decidió no borrarlo nunca: en JPEG de calidad alta pesa
  diez veces menos sin que se note. Antes de comprimirlo se le quema la marca
  de agua (`burn_badge`), así que no existe un resultado guardado sin ella.

Las marcas que la persona dibuja también pasan por acá, pero no se comprimen:
llegan como una capa PNG transparente del tamaño de la foto ya normalizada, se
comprueban y viajan tal cual o quemadas sobre la foto, según decida
`markup.provider_inputs`. Son PNG a propósito: la capa necesita canal alfa, y
un trazo en JPEG se ensucia en los bordes.

Las fotos de muestra de las opciones del catálogo no pasan por acá: son
archivos versionados en `static/`, se preparan una vez y de ahí en adelante
sólo se sirven.

Vive en un módulo aparte para que el servicio no sepa de píxeles y para que la
API, cuando llegue, pase por el mismo camino.
"""

from io import BytesIO
from pathlib import Path

from PIL import ExifTags, Image, ImageDraw, ImageFilter, ImageOps, UnidentifiedImageError

#: Lado mayor de la foto de origen. Es la resolución con la que trabaja el
#: modelo; más píxeles no cambian el resultado.
SOURCE_MAX_SIDE = 1536
SOURCE_QUALITY = 85
RESULT_QUALITY = 90
#: Un tope generoso: con los píxeles acotados, el peso lo decide la calidad.
MAX_PIXELS = 80_000_000
#: Qué tan opacos quedan los trazos en la copia anotada. No del todo: el modelo
#: tiene que ver qué hay debajo de cada marca, no sólo el color.
MARKUP_COPY_OPACITY = 0.6

#: La marca de agua se midió sobre un resultado de 1024 de lado: 180 de
#: diámetro, a 100 de cada borde de la esquina inferior derecha, con el fondo
#: desenfocado a radio 14. Un resultado de otro tamaño la lleva en la misma
#: proporción respecto de su lado menor, para que se vea igual en uno de
#: 1024 por 1536 que en uno cuadrado.
BADGE_REFERENCE_SIDE = 1024
BADGE_DIAMETER = 180
BADGE_MARGIN = 100
BADGE_BLUR = 14
#: Cuántas veces más grande se dibuja la máscara del círculo antes de achicarla:
#: dibujada al tamaño final, el borde queda en escalera.
BADGE_MASK_SUPERSAMPLE = 4


class NotAnImage(Exception):
    """Lo que llegó no se puede abrir como imagen."""


def normalize_source(raw: bytes) -> bytes:
    """
    La foto de origen, enderezada, reescalada y como JPEG.

    Lo que no se pueda abrir levanta `NotAnImage`: comprobar el tipo declarado
    no alcanza, porque es el navegador quien lo declara y un archivo renombrado
    pasa igual. Abrirlo es la única comprobación que vale.

    Una foto que ya llega como quedaría (`source_is_normalized`) se guarda tal
    cual: la landing la comprime en el navegador con más calidad que la
    nuestra, y recomprimirla perdería justo esa diferencia.
    """
    try:
        with Image.open(BytesIO(raw)) as opened:
            if opened.width * opened.height > MAX_PIXELS:
                raise NotAnImage('demasiados píxeles')
            if source_is_normalized(opened):
                # Decodificarla entera: un JPEG cortado abre igual y falla recién acá.
                opened.load()
                return raw
            image = ImageOps.exif_transpose(opened) or opened
            image = image.convert('RGB')
    except (UnidentifiedImageError, OSError, ValueError) as error:
        raise NotAnImage(str(error)) from error

    image.thumbnail((SOURCE_MAX_SIDE, SOURCE_MAX_SIDE), Image.Resampling.LANCZOS)
    return _jpeg(image, SOURCE_QUALITY)


def source_is_normalized(image: Image.Image) -> bool:
    """
    Si la foto ya es lo que `normalize_source` guardaría: JPEG en RGB, sin
    pasarse de `SOURCE_MAX_SIDE` y sin un giro EXIF pendiente.
    """
    orientation = image.getexif().get(ExifTags.Base.Orientation, 1)
    return (
        image.format == 'JPEG'
        and image.mode == 'RGB'
        and max(image.size) <= SOURCE_MAX_SIDE
        and orientation == 1
    )


def encode_result(raw: bytes, badge: Path | None = None) -> bytes:
    """El resultado del proveedor, con la marca de agua si se la pasa, como JPEG de calidad alta."""
    try:
        with Image.open(BytesIO(raw)) as opened:
            image = opened.convert('RGB')
    except (UnidentifiedImageError, OSError, ValueError) as error:
        raise NotAnImage(str(error)) from error

    if badge is not None:
        image = burn_badge(image, badge)
    return _jpeg(image, RESULT_QUALITY)


def burn_badge(image: Image.Image, badge: Path) -> Image.Image:
    """
    La imagen con el badge quemado en la esquina inferior derecha.

    El vidrio se arma acá y no en el PNG: primero se desenfoca el círculo de la
    foto que va a quedar debajo, y encima se pega el badge, que trae el tinte
    semitransparente, el brillo y el logo. Un PNG solo no puede desenfocar lo
    que tiene detrás.
    """
    scale = min(image.size) / BADGE_REFERENCE_SIDE
    diameter = max(1, round(BADGE_DIAMETER * scale))
    margin = round(BADGE_MARGIN * scale)
    left = image.width - margin - diameter
    top = image.height - margin - diameter
    box = (left, top, left + diameter, top + diameter)

    with Image.open(badge) as opened:
        layer = opened.convert('RGBA').resize((diameter, diameter), Image.Resampling.LANCZOS)

    large = diameter * BADGE_MASK_SUPERSAMPLE
    circle = Image.new('L', (large, large), 0)
    ImageDraw.Draw(circle).ellipse((0, 0, large - 1, large - 1), fill=255)
    circle = circle.resize((diameter, diameter), Image.Resampling.LANCZOS)

    burned = image.copy()
    blurred = burned.crop(box).filter(ImageFilter.GaussianBlur(BADGE_BLUR * scale))
    burned.paste(blurred, box, circle)
    burned = burned.convert('RGBA')
    burned.alpha_composite(layer, (left, top))
    return burned.convert('RGB')


def size_of(raw: bytes) -> tuple[int, int]:
    """El ancho y el alto de una imagen, sin decodificarla entera."""
    try:
        with Image.open(BytesIO(raw)) as opened:
            return opened.size
    except (UnidentifiedImageError, OSError, ValueError) as error:
        raise NotAnImage(str(error)) from error


def normalize_markup(raw: bytes, size: tuple[int, int]) -> bytes:
    """
    La capa de trazos, comprobada y como PNG RGBA.

    Tiene que tener transparencia, el tamaño exacto de la foto normalizada y
    algo pintado. El tamaño no se corrige: un trazo reescalado cae corrido
    respecto de lo que se marcó, y el modelo lee la capa superpuesta a la foto.
    Una capa vacía tampoco se acepta: no hay nada que mandar y el prompt
    diría que sí.
    """
    try:
        with Image.open(BytesIO(raw)) as opened:
            if opened.size != size:
                raise NotAnImage(f'mide {opened.size} y la foto {size}')
            if not _has_alpha(opened):
                raise NotAnImage('sin transparencia')
            layer = opened.convert('RGBA')
    except (UnidentifiedImageError, OSError, ValueError) as error:
        raise NotAnImage(str(error)) from error

    if layer.getchannel('A').getbbox() is None:
        raise NotAnImage('no hay nada pintado')
    return _png(layer)


def annotated_copy(source: bytes, markup: bytes) -> bytes:
    """La foto con los trazos encima, semitransparentes (`MARKUP_COPY_OPACITY`)."""
    with Image.open(BytesIO(source)) as opened_source, Image.open(BytesIO(markup)) as opened:
        photo = opened_source.convert('RGBA')
        layer = opened.convert('RGBA')
    alpha = layer.getchannel('A').point(lambda value: round(value * MARKUP_COPY_OPACITY))
    layer.putalpha(alpha)
    photo.alpha_composite(layer)
    return _png(photo.convert('RGB'))


def _has_alpha(image: Image.Image) -> bool:
    return 'A' in image.getbands() or (image.mode == 'P' and 'transparency' in image.info)


def _png(image: Image.Image) -> bytes:
    buffer = BytesIO()
    image.save(buffer, format='PNG', optimize=True)
    return buffer.getvalue()


def _jpeg(image: Image.Image, quality: int) -> bytes:
    buffer = BytesIO()
    image.save(buffer, format='JPEG', quality=quality, optimize=True, progressive=True)
    return buffer.getvalue()
