"""Image sanitization for user-uploaded photos.

Re-encodes inbound images through Pillow to:
  - strip EXIF (GPS lat/lon, device fingerprint, owner metadata)
  - normalize byte stream (a polyglot JPEG with appended payload survives MIME check
    but is dropped on re-encode — only the actual image survives)
  - enforce max dimension (defend against pixel-flood DoS in downstream renders)
  - produce uniform JPEG output (smaller storage; no transparency support needed
    for apartment photos)

Closes finding #6 from 2026-05-24 trade-in audit.
"""

from __future__ import annotations

import io
import logging

from PIL import Image, ImageOps, UnidentifiedImageError

logger = logging.getLogger(__name__)

# Max longest edge after re-encode. 2400px = enough for retina apt photo,
# small enough to bound storage & decode cost.
_MAX_DIMENSION = 2400

# Hard cap on declared pixel count (width*height) enforced BEFORE any decode.
# A highly-compressible ≤10 MB upload can declare ~89-178 MP; once img.load()
# inflates it to raw RGB (3 B/px) that is a 260-530 MB buffer (+ transpose/convert
# copies) → OOM-kills the 768 MiB backend (#2214), amplified ×12 photo slots.
# 40 MP (~8000×5000) sits far above any real listing photo yet well below the
# ~89 MP default Pillow bomb threshold. img.size is read from the header on
# open() without decoding pixels, so this check is cheap and pre-decode.
_MAX_PIXELS = 40_000_000

# Backstop byte cap (the upload endpoint already enforces 10 MB → 413, but this
# service is standalone; keep it self-defending against oversized inputs).
_MAX_BYTES = 10 * 1024 * 1024

# Belt-and-braces: lower Pillow's own DecompressionBombError threshold from its
# ~89 MP default to our cap, so oversized declared dimensions raise even on any
# decode path that bypasses the explicit size guard below.
Image.MAX_IMAGE_PIXELS = _MAX_PIXELS

# JPEG quality. 85 is industry standard for photos — visually lossless,
# ~70% size reduction vs original phone JPEGs.
_JPEG_QUALITY = 85

# Output content type after sanitization (all inputs transcoded to JPEG).
SANITIZED_CONTENT_TYPE = "image/jpeg"


class ImageSanitizationError(ValueError):
    """Raised when inbound bytes cannot be decoded as an image."""


def sanitize_image(content: bytes) -> tuple[bytes, str]:
    """Decode arbitrary image bytes and re-encode as clean JPEG.

    Args:
        content: raw uploaded bytes from the client.

    Returns:
        (sanitized_bytes, "image/jpeg") — both ready for storage/serving.

    Raises:
        ImageSanitizationError: bytes are not a recognized image format, or the
            declared dimensions / byte size exceed the pixel-flood safety caps.
    """
    if len(content) > _MAX_BYTES:
        raise ImageSanitizationError(f"image too large: {len(content)} bytes exceeds cap")
    try:
        with Image.open(io.BytesIO(content)) as img:
            # #2214: reject pixel-flood bombs BEFORE img.load(). img.size comes
            # from the header (no pixel decode); loading an oversized declared
            # image would inflate a small file into a hundreds-of-MB RGB buffer.
            width, height = img.size
            if width * height > _MAX_PIXELS:
                logger.warning(
                    "image rejected: %dx%d (%d px) exceeds %d px cap",
                    width,
                    height,
                    width * height,
                    _MAX_PIXELS,
                )
                raise ImageSanitizationError(
                    f"image too large: {width}x{height} exceeds {_MAX_PIXELS} px cap"
                )
            img.load()  # force full decode; surfaces UnidentifiedImageError early
            # Bake EXIF Orientation into pixels BEFORE stripping EXIF, else phone
            # portrait photos (Orientation=6/8) end up rotated 90/270° once the tag
            # is gone. No-op when there is no orientation tag.
            img = ImageOps.exif_transpose(img)
            # Convert any mode (RGBA, P, CMYK, ...) to RGB for JPEG output.
            if img.mode != "RGB":
                img = img.convert("RGB")
            # Resize if oversized; preserve aspect ratio.
            if max(img.size) > _MAX_DIMENSION:
                img.thumbnail((_MAX_DIMENSION, _MAX_DIMENSION), Image.Resampling.LANCZOS)
            buf = io.BytesIO()
            # save() does NOT carry over .info / EXIF unless we pass exif=...; we don't.
            img.save(buf, format="JPEG", quality=_JPEG_QUALITY, optimize=True)
            return buf.getvalue(), SANITIZED_CONTENT_TYPE
    except ImageSanitizationError:
        raise  # already a handled, user-facing rejection (byte/pixel cap)
    except UnidentifiedImageError as e:
        raise ImageSanitizationError("not a recognized image format") from e
    except Image.DecompressionBombError as e:
        # Any decode path that outran the size guard above (e.g. >2× cap) lands here.
        logger.warning("decompression bomb rejected: %s", e)
        raise ImageSanitizationError("image dimensions exceed safe limits") from e
    except Exception as e:
        # PIL can raise broad errors (OSError on truncated, DecompressionBombWarning
        # promoted to error, ...); normalize to one user-facing error type.
        logger.warning("image sanitization failed: %s", e)
        raise ImageSanitizationError(f"image decode failed: {e}") from e
