"""Pythonic wrapper types and functions for slimg.

This module imports from the UniFFI-generated ``_lowlevel`` package and
re-exports a cleaner, more Pythonic public API.
"""

from __future__ import annotations

import builtins
from enum import Enum
from typing import Optional, Tuple, Union

from slimg import _lowlevel

# Keep a reference to Python's built-in ``open`` since this module
# defines its own ``open`` function that shadows the builtin.
_builtin_open = builtins.open

# ---------------------------------------------------------------------------
# Error re-export
# ---------------------------------------------------------------------------

# ``_lowlevel.SlimgError`` is the *namespace* class generated by UniFFI.
# The actual base exception class that all error variants inherit from is
# stored as ``_UniffiTempSlimgError`` internally. However, the namespace
# class is what users import to access subclasses like
# ``SlimgError.Decode``, etc.  Fortunately the lowlevel module re-attaches
# the subclasses onto the temp class, so ``isinstance(e, SlimgError)``
# still works when we expose the temp (base) class.
#
# We expose *both*: the base exception (for isinstance checks / catching)
# and the namespace (for accessing specific variant subclasses).  Since the
# lowlevel module sets up the aliases, exposing the namespace class is
# sufficient -- ``except SlimgError:`` works because the variants inherit
# from _UniffiTempSlimgError which IS the SlimgError namespace before it
# gets overwritten.  In practice, the lowlevel wildcard import already set
# this up correctly.  We just need the reference.
SlimgError = _lowlevel.SlimgError  # noqa: N816


# ---------------------------------------------------------------------------
# Format enum
# ---------------------------------------------------------------------------

class Format(Enum):
    """Image format enumeration with utility methods."""

    JPEG = "jpeg"
    PNG = "png"
    WEBP = "webp"
    AVIF = "avif"
    JXL = "jxl"
    QOI = "qoi"

    @property
    def extension(self) -> str:
        """Canonical file extension (e.g. ``'jpg'`` for JPEG)."""
        return _lowlevel.format_extension(self._to_lowlevel())

    @property
    def can_encode(self) -> bool:
        """Whether slimg can encode images in this format."""
        return _lowlevel.format_can_encode(self._to_lowlevel())

    @classmethod
    def from_path(cls, path: str) -> Optional[Format]:
        """Detect format from a file path's extension.

        Returns ``None`` if the extension is unrecognised.
        """
        result = _lowlevel.format_from_extension(path)
        return cls._from_lowlevel(result) if result is not None else None

    @classmethod
    def from_bytes(cls, data: bytes) -> Optional[Format]:
        """Detect format from the magic bytes at the start of *data*.

        Returns ``None`` if the bytes don't match a known format.
        """
        result = _lowlevel.format_from_magic_bytes(data)
        return cls._from_lowlevel(result) if result is not None else None

    # -- internal helpers ----------------------------------------------------

    def _to_lowlevel(self) -> _lowlevel.Format:
        return _FORMAT_TO_LL[self]

    @classmethod
    def _from_lowlevel(cls, ll_fmt: _lowlevel.Format) -> Format:
        return _FORMAT_FROM_LL[ll_fmt]

    @classmethod
    def _resolve(cls, fmt: Union[Format, str]) -> Format:
        """Accept a ``Format`` enum member or a string and return ``Format``.

        Strings are matched case-insensitively; ``'jpg'`` is accepted as an
        alias for JPEG.
        """
        if isinstance(fmt, cls):
            return fmt
        if isinstance(fmt, str):
            normalised = fmt.lower().strip()
            name_map = {
                "jpeg": cls.JPEG,
                "jpg": cls.JPEG,
                "png": cls.PNG,
                "webp": cls.WEBP,
                "avif": cls.AVIF,
                "jxl": cls.JXL,
                "qoi": cls.QOI,
            }
            result = name_map.get(normalised)
            if result is None:
                raise ValueError(f"Unknown format: {fmt}")
            return result
        raise TypeError(f"Expected Format or str, got {type(fmt)}")


# Bidirectional mapping between the Pythonic enum and the lowlevel enum.
_FORMAT_TO_LL = {
    Format.JPEG: _lowlevel.Format.JPEG,
    Format.PNG: _lowlevel.Format.PNG,
    Format.WEBP: _lowlevel.Format.WEB_P,
    Format.AVIF: _lowlevel.Format.AVIF,
    Format.JXL: _lowlevel.Format.JXL,
    Format.QOI: _lowlevel.Format.QOI,
}
_FORMAT_FROM_LL = {v: k for k, v in _FORMAT_TO_LL.items()}


# ---------------------------------------------------------------------------
# Image
# ---------------------------------------------------------------------------

class Image:
    """Decoded image with RGBA pixel data."""

    def __init__(
        self,
        width: int,
        height: int,
        data: bytes,
        format: Optional[Format] = None,
    ):
        expected = width * height * 4
        if len(data) != expected:
            raise ValueError(
                f"Buffer size mismatch: expected {expected} bytes "
                f"({width}x{height} RGBA), got {len(data)}"
            )
        self._width = width
        self._height = height
        self._data = data
        self._format = format

    @property
    def width(self) -> int:
        return self._width

    @property
    def height(self) -> int:
        return self._height

    @property
    def data(self) -> bytes:
        return self._data

    @property
    def format(self) -> Optional[Format]:
        return self._format

    # -- internal helpers ----------------------------------------------------

    def _to_lowlevel(self) -> _lowlevel.ImageData:
        return _lowlevel.ImageData(
            width=self._width, height=self._height, data=self._data,
        )

    @classmethod
    def _from_lowlevel(
        cls, img: _lowlevel.ImageData, fmt: Optional[Format] = None,
    ) -> Image:
        return cls(width=img.width, height=img.height, data=img.data, format=fmt)

    @classmethod
    def _from_raw(cls, width: int, height: int, data: bytes) -> Image:
        """Create an Image from raw RGBA bytes (used by test fixtures)."""
        return cls(width=width, height=height, data=data)


# ---------------------------------------------------------------------------
# Result
# ---------------------------------------------------------------------------

class Result:
    """Result of an encoding / optimisation operation."""

    def __init__(self, data: bytes, format: Format):
        self._data = data
        self._format = format

    @property
    def data(self) -> bytes:
        return self._data

    @property
    def format(self) -> Format:
        return self._format

    def save(self, path: str) -> None:
        """Write the encoded bytes to *path*."""
        with _builtin_open(path, "wb") as f:
            f.write(self._data)


# ---------------------------------------------------------------------------
# Namespace helpers — Resize, Crop, Extend
# ---------------------------------------------------------------------------

class Resize:
    """Factory for lowlevel ``ResizeMode`` variants."""

    @staticmethod
    def width(value: int):
        """Resize to *value* pixels wide, preserving aspect ratio."""
        return _lowlevel.ResizeMode.WIDTH(value=value)

    @staticmethod
    def height(value: int):
        """Resize to *value* pixels tall, preserving aspect ratio."""
        return _lowlevel.ResizeMode.HEIGHT(value=value)

    @staticmethod
    def exact(width: int, height: int):
        """Resize to exact *width* x *height* (may distort)."""
        return _lowlevel.ResizeMode.EXACT(width=width, height=height)

    @staticmethod
    def fit(max_width: int, max_height: int):
        """Fit within *max_width* x *max_height*, preserving aspect ratio."""
        return _lowlevel.ResizeMode.FIT(max_width=max_width, max_height=max_height)

    @staticmethod
    def scale(factor: float):
        """Scale by *factor* (e.g. 0.5 = half size, 2.0 = double)."""
        return _lowlevel.ResizeMode.SCALE(factor=factor)


class Crop:
    """Factory for lowlevel ``CropMode`` variants."""

    @staticmethod
    def region(x: int, y: int, width: int, height: int):
        """Extract a specific pixel region."""
        return _lowlevel.CropMode.REGION(x=x, y=y, width=width, height=height)

    @staticmethod
    def aspect_ratio(width: int, height: int):
        """Centre-crop to the given aspect ratio."""
        return _lowlevel.CropMode.ASPECT_RATIO(width=width, height=height)


class Extend:
    """Factory for lowlevel ``ExtendMode`` variants."""

    @staticmethod
    def aspect_ratio(width: int, height: int):
        """Extend (pad) canvas to fit the given aspect ratio."""
        return _lowlevel.ExtendMode.ASPECT_RATIO(width=width, height=height)

    @staticmethod
    def size(width: int, height: int):
        """Extend (pad) canvas to an exact pixel *width* x *height*."""
        return _lowlevel.ExtendMode.SIZE(width=width, height=height)


# ---------------------------------------------------------------------------
# Fill colour helper
# ---------------------------------------------------------------------------

def _validate_channel(value: int, name: str) -> int:
    """Ensure a colour channel value is in 0-255."""
    if not isinstance(value, int) or not (0 <= value <= 255):
        raise ValueError(f"{name} must be an integer in 0-255, got {value!r}")
    return value


def _validate_quality(quality: int) -> int:
    """Ensure quality is in 0-100."""
    if not isinstance(quality, int) or not (0 <= quality <= 100):
        raise ValueError(f"quality must be an integer in 0-100, got {quality!r}")
    return quality


def _resolve_fill(
    fill: Union[None, str, Tuple[int, int, int], Tuple[int, int, int, int]],
) -> _lowlevel.FillColor:
    """Convert a user-friendly fill specification to a lowlevel ``FillColor``."""
    if fill is None or fill == "transparent":
        return _lowlevel.FillColor.TRANSPARENT()
    if isinstance(fill, tuple):
        if len(fill) == 3:
            r, g, b = fill
            _validate_channel(r, "r")
            _validate_channel(g, "g")
            _validate_channel(b, "b")
            return _lowlevel.FillColor.SOLID(r=r, g=g, b=b, a=255)
        if len(fill) == 4:
            r, g, b, a = fill
            _validate_channel(r, "r")
            _validate_channel(g, "g")
            _validate_channel(b, "b")
            _validate_channel(a, "a")
            return _lowlevel.FillColor.SOLID(r=r, g=g, b=b, a=a)
    raise ValueError(
        f"Invalid fill: {fill!r}. "
        "Use 'transparent', (r, g, b), or (r, g, b, a)."
    )


# ---------------------------------------------------------------------------
# Public functions
# ---------------------------------------------------------------------------

def open(path: str) -> Image:
    """Read an image file from disk and decode it.

    This shadows Python's built-in ``open``; the built-in is still
    available via ``builtins.open``.
    """
    result = _lowlevel.decode_file(path)
    fmt = Format._from_lowlevel(result.format)
    return Image._from_lowlevel(result.image, fmt)


def decode(data: bytes) -> Image:
    """Decode image bytes (format is auto-detected from magic bytes)."""
    result = _lowlevel.decode(data)
    fmt = Format._from_lowlevel(result.format)
    return Image._from_lowlevel(result.image, fmt)


def convert(
    image: Image,
    format: Union[Format, str],
    quality: int = 80,
    *,
    resize=None,
    crop=None,
    extend=None,
    fill=None,
) -> Result:
    """Encode *image* in the target *format*, optionally applying
    crop / extend / resize in a single pipeline.

    *format* may be a ``Format`` enum member or a string such as
    ``'png'``, ``'webp'``, ``'jpg'``, etc.

    *resize*, *crop*, *extend* accept values returned by the
    ``Resize``, ``Crop``, ``Extend`` helper classes respectively.

    *fill* accepts ``'transparent'``, ``(r, g, b)``, or
    ``(r, g, b, a)``.  Defaults to transparent when *extend* is set.
    """
    _validate_quality(quality)
    fmt = Format._resolve(format)
    fill_color = None
    if fill is not None or extend is not None:
        fill_color = _resolve_fill(fill)

    opts = _lowlevel.PipelineOptions(
        format=fmt._to_lowlevel(),
        quality=quality,
        resize=resize,
        crop=crop,
        extend=extend,
        fill_color=fill_color,
    )
    result = _lowlevel.convert(image._to_lowlevel(), opts)
    return Result(data=result.data, format=Format._from_lowlevel(result.format))


def crop_image(
    image: Image,
    *,
    region: Optional[Tuple[int, int, int, int]] = None,
    aspect_ratio: Optional[Tuple[int, int]] = None,
) -> Image:
    """Crop *image*.

    Provide exactly one of:
    - ``region=(x, y, width, height)``
    - ``aspect_ratio=(width, height)``
    """
    if region is not None and aspect_ratio is not None:
        raise ValueError("Specify either region or aspect_ratio, not both")
    if region is not None:
        x, y, w, h = region
        mode = _lowlevel.CropMode.REGION(x=x, y=y, width=w, height=h)
    elif aspect_ratio is not None:
        w, h = aspect_ratio
        mode = _lowlevel.CropMode.ASPECT_RATIO(width=w, height=h)
    else:
        raise ValueError("Specify region or aspect_ratio")
    result = _lowlevel.crop(image._to_lowlevel(), mode)
    return Image._from_lowlevel(result, image.format)


def extend_image(
    image: Image,
    *,
    aspect_ratio: Optional[Tuple[int, int]] = None,
    size: Optional[Tuple[int, int]] = None,
    fill: Union[str, Tuple[int, int, int], Tuple[int, int, int, int]] = "transparent",
) -> Image:
    """Extend (pad) *image*.

    Provide exactly one of:
    - ``aspect_ratio=(width, height)``
    - ``size=(width, height)``
    """
    if aspect_ratio is not None and size is not None:
        raise ValueError("Specify either aspect_ratio or size, not both")
    if aspect_ratio is not None:
        w, h = aspect_ratio
        mode = _lowlevel.ExtendMode.ASPECT_RATIO(width=w, height=h)
    elif size is not None:
        w, h = size
        mode = _lowlevel.ExtendMode.SIZE(width=w, height=h)
    else:
        raise ValueError("Specify aspect_ratio or size")
    fill_color = _resolve_fill(fill)
    result = _lowlevel.extend(image._to_lowlevel(), mode, fill_color)
    return Image._from_lowlevel(result, image.format)


def resize_image(
    image: Image,
    *,
    width: Optional[int] = None,
    height: Optional[int] = None,
    exact: Optional[Tuple[int, int]] = None,
    fit: Optional[Tuple[int, int]] = None,
    scale: Optional[float] = None,
) -> Image:
    """Resize *image*.

    Provide exactly one keyword argument to specify the resize mode.
    """
    modes = [
        (k, v)
        for k, v in [
            ("width", width),
            ("height", height),
            ("exact", exact),
            ("fit", fit),
            ("scale", scale),
        ]
        if v is not None
    ]
    if len(modes) != 1:
        raise ValueError("Specify exactly one of: width, height, exact, fit, scale")

    name, value = modes[0]
    if name == "width":
        resize_mode = _lowlevel.ResizeMode.WIDTH(value=value)
    elif name == "height":
        resize_mode = _lowlevel.ResizeMode.HEIGHT(value=value)
    elif name == "exact":
        resize_mode = _lowlevel.ResizeMode.EXACT(width=value[0], height=value[1])
    elif name == "fit":
        resize_mode = _lowlevel.ResizeMode.FIT(max_width=value[0], max_height=value[1])
    elif name == "scale":
        resize_mode = _lowlevel.ResizeMode.SCALE(factor=value)

    result = _lowlevel.resize(image._to_lowlevel(), resize_mode)
    return Image._from_lowlevel(result, image.format)


def optimize(data: bytes, quality: int = 80) -> Result:
    """Re-encode *data* at the given *quality* in the same format."""
    _validate_quality(quality)
    result = _lowlevel.optimize(data, quality)
    return Result(data=result.data, format=Format._from_lowlevel(result.format))


def optimize_file(path: str, quality: int = 80) -> Result:
    """Read a file from *path* and re-encode at the given *quality*.

    Raises ``SlimgError`` if the file cannot be read or optimised.
    """
    _validate_quality(quality)
    try:
        with _builtin_open(path, "rb") as f:
            data = f.read()
    except OSError as exc:
        raise _lowlevel.SlimgError.Io(str(exc)) from exc
    return optimize(data, quality)
