"""
Mizan Upload — first-class binary input for ``@client`` functions.

``Upload`` is a Pydantic-composable field type. Declaring an Upload-typed
parameter makes a function multipart-aware end to end: the generated client
switches that call to ``multipart/form-data``, and each backend adapter parses
the file part and binds a uniform :class:`UploadedFile` into the function's
``Input``. Constraints declared via :class:`File` are enforced at dispatch.

    from typing import Annotated
    from mizan import client, Upload, File

    @client(affects=UserContext)
    def set_avatar(
        request,
        user_id: int,
        avatar: Annotated[Upload, File(max_size="5MB", content_types=["image/png"])],
    ) -> dict:
        avatar.save(f"/media/{user_id}.png")
        return {"ok": True}

Bare ``Upload`` is an unconstrained file; ``Upload | None`` is optional;
``list[Upload]`` accepts multiple. The :class:`File` marker is optional and
carries the declarative constraints.
"""

from __future__ import annotations

import os
from dataclasses import dataclass
from typing import Any

from pydantic import GetCoreSchemaHandler
from pydantic_core import core_schema

from mizan_core.type_utils import extract_list_element, extract_optional


__all__ = [
    "Upload",
    "UploadedFile",
    "File",
    "parse_size",
    "validate_upload",
    "classify_upload",
    "upload_fields",
    "bind_uploads",
]


_SIZE_UNITS = {"GB": 1024**3, "MB": 1024**2, "KB": 1024, "B": 1}


def parse_size(value: int | str) -> int:
    """Parse a byte count. Accepts an int (bytes) or a string like ``"5MB"``."""
    if isinstance(value, int):
        return value
    s = value.strip().upper()
    for unit, mult in _SIZE_UNITS.items():
        if s.endswith(unit):
            return int(float(s[: -len(unit)].strip()) * mult)
    return int(s)


@dataclass(frozen=True)
class File:
    """Declarative constraints for an ``Upload`` field, placed in ``Annotated``.

    ``max_size`` accepts an int (bytes) or a human string (``"5MB"``).
    ``content_types`` is a list of allowed MIME types; an entry ending in
    ``/*`` (e.g. ``"image/*"``) matches any subtype.
    """

    max_size: int | str | None = None
    content_types: tuple[str, ...] | None = None

    def __post_init__(self) -> None:
        if self.max_size is not None:
            object.__setattr__(self, "max_size", parse_size(self.max_size))
        if self.content_types is not None and not isinstance(self.content_types, tuple):
            object.__setattr__(self, "content_types", tuple(self.content_types))


class UploadedFile:
    """Uniform file handle handed to ``@client`` functions, adapter-agnostic.

    Backends construct this from their native upload object (Django
    ``UploadedFile``, Starlette ``UploadFile``) so a function body stays
    portable across adapters.
    """

    __slots__ = ("filename", "content_type", "_data")

    def __init__(self, filename: str | None, content_type: str | None, data: bytes):
        self.filename = filename
        self.content_type = content_type
        self._data = data

    @property
    def size(self) -> int:
        return len(self._data)

    def read(self) -> bytes:
        return self._data

    def save(self, path: str | os.PathLike) -> None:
        with open(path, "wb") as fh:
            fh.write(self._data)


class Upload:
    """Pydantic-composable marker type for a binary file input.

    At validation time it accepts any :class:`UploadedFile` (the adapter has
    already bound the multipart part). The IR emitter recognizes Upload-typed
    fields and emits an ``upload`` node.
    """

    @classmethod
    def __get_pydantic_core_schema__(
        cls, source: Any, handler: GetCoreSchemaHandler
    ) -> core_schema.CoreSchema:
        return core_schema.no_info_plain_validator_function(cls._validate)

    @staticmethod
    def _validate(value: Any) -> UploadedFile:
        if isinstance(value, UploadedFile):
            return value
        raise ValueError("expected an uploaded file")


def _content_type_allowed(content_type: str | None, allowed: tuple[str, ...]) -> bool:
    if not content_type:
        return False
    for ct in allowed:
        if ct == content_type:
            return True
        if ct.endswith("/*") and content_type.startswith(ct[:-1]):
            return True
    return False


def validate_upload(file: UploadedFile, spec: File | None) -> str | None:
    """Enforce declared constraints. Returns an error message, or ``None`` if ok."""
    if spec is None:
        return None
    if spec.max_size is not None and file.size > spec.max_size:
        return f"file exceeds max size {spec.max_size} bytes (got {file.size})"
    if spec.content_types and not _content_type_allowed(file.content_type, spec.content_types):
        return f"content-type {file.content_type!r} not allowed (expected one of {list(spec.content_types)})"
    return None


# ─── Field classification + binding (shared by every backend adapter) ─────────


def _strip_annotated_meta(annotation: Any) -> tuple[Any, File | None]:
    """Unwrap a ``typing.Annotated``, returning ``(base_type, File-marker-or-None)``."""
    if hasattr(annotation, "__metadata__"):
        spec = next((m for m in annotation.__metadata__ if isinstance(m, File)), None)
        return annotation.__origin__, spec
    return annotation, None


def classify_upload(field_info: Any) -> tuple[bool, bool, bool, File | None]:
    """Detect an ``Upload``-typed field → ``(is_upload, is_list, is_optional, spec)``.

    Composes through ``Optional[...]``, ``list[...]``, and
    ``Annotated[..., File(...)]`` in any order, gathering the ``File`` marker
    wherever it appears (Pydantic lifts a top-level marker into
    ``field_info.metadata``; nested markers stay inside the annotation).
    """
    spec = next((m for m in getattr(field_info, "metadata", None) or [] if isinstance(m, File)), None)
    ann = field_info.annotation
    ann, s = _strip_annotated_meta(ann); spec = spec or s
    ann, is_optional = extract_optional(ann)
    ann, s = _strip_annotated_meta(ann); spec = spec or s
    elem = extract_list_element(ann)
    is_list = elem is not None
    if is_list:
        ann, s = _strip_annotated_meta(elem); spec = spec or s
    return ann is Upload, is_list, is_optional, spec


def upload_fields(model: Any) -> dict[str, tuple[bool, File | None]]:
    """Map each ``Upload``-typed field of a Pydantic model → ``(is_list, spec)``."""
    out: dict[str, tuple[bool, File | None]] = {}
    for name, field_info in model.model_fields.items():
        is_upload, is_list, _is_opt, spec = classify_upload(field_info)
        if is_upload:
            out[name] = (is_list, spec)
    return out


def bind_uploads(
    input_cls: Any,
    args: dict[str, Any],
    files: dict[str, list[UploadedFile]],
) -> str | None:
    """Place uploaded files into ``args`` by field name, enforcing constraints.

    Mutates ``args`` in place. ``files`` maps a field name to the parts received
    for it (an array field receives several). Returns an error message on the
    first constraint violation, else ``None``. Absent files are left for
    Pydantic's required/optional handling.
    """
    for fname, (is_list, spec) in upload_fields(input_cls).items():
        bucket = files.get(fname) or []
        if not bucket:
            continue
        for f in bucket:
            err = validate_upload(f, spec)
            if err is not None:
                return f"{fname}: {err}"
        args[fname] = list(bucket) if is_list else bucket[0]
    return None
