"""Object collections for reading and writing objects."""

from __future__ import annotations

import contextlib
import pathlib
from typing import TYPE_CHECKING, BinaryIO

import httpx

from tilde._object_reader import ObjectReader
from tilde._pagination import DEFAULT_PAGE_SIZE, PageResult, PaginatedIterator
from tilde.exceptions import APIError, TransportError
from tilde.models import CopyObjectResult, ListingEntry, ObjectMetadata, PutObjectResult

if TYPE_CHECKING:
    import builtins
    from collections.abc import Iterable

    from tilde.client import Client

SINGLE_UPLOAD_LIMIT = 64 * 1024 * 1024  # 64 MB
MULTIPART_PART_SIZE = 64 * 1024 * 1024  # 64 MB


def _get_data_size(data: bytes | bytearray | memoryview | BinaryIO | Iterable[bytes]) -> int | None:
    """Return the size of *data* in bytes, or ``None`` if unknown."""
    if isinstance(data, (bytes, bytearray, memoryview)):
        return len(data)
    # BinaryIO with seek/tell
    if hasattr(data, "seek") and hasattr(data, "tell"):
        pos = data.tell()
        data.seek(0, 2)  # seek to end
        size: int = data.tell()
        data.seek(pos)  # seek back
        return size
    return None


def _iter_parts(
    data: bytes | bytearray | memoryview | BinaryIO | Iterable[bytes],
) -> Iterable[tuple[int, bytes]]:
    """Yield ``(part_number, chunk)`` tuples of up to ``MULTIPART_PART_SIZE`` bytes."""
    part_number = 1

    if isinstance(data, (bytes, bytearray, memoryview)):
        mv = memoryview(data)
        offset = 0
        while offset < len(mv):
            end = min(offset + MULTIPART_PART_SIZE, len(mv))
            yield part_number, bytes(mv[offset:end])
            part_number += 1
            offset = end
        return

    if isinstance(data, BinaryIO) or hasattr(data, "read"):
        while True:
            chunk = data.read(MULTIPART_PART_SIZE)
            if not chunk:
                break
            yield part_number, chunk if isinstance(chunk, bytes) else bytes(chunk)
            part_number += 1
        return

    # Iterable[bytes] — accumulate into part-sized buffers
    buf = bytearray()
    for piece in data:
        buf.extend(piece)
        while len(buf) >= MULTIPART_PART_SIZE:
            yield part_number, bytes(buf[:MULTIPART_PART_SIZE])
            buf = buf[MULTIPART_PART_SIZE:]
            part_number += 1
    if buf:
        yield part_number, bytes(buf)


class ReadOnlyObjectCollection:
    """Object access scoped to a committed snapshot (no session)."""

    def __init__(self, client: Client, org: str, repo: str) -> None:
        self._client = client
        self._org = org
        self._repo = repo

    @property
    def _repo_path(self) -> str:
        return f"/organizations/{self._org}/repositories/{self._repo}"

    def list(
        self,
        *,
        prefix: str | None = None,
        delimiter: str | None = None,
        after: str | None = None,
        amount: int | None = None,
    ) -> PaginatedIterator[ListingEntry]:
        """List objects, auto-paginating.

        Args:
            prefix: Filter by key prefix.
            delimiter: Directory grouping delimiter (e.g. ``"/"``).
            after: Starting offset for the first page.
            amount: Page size (default 100).
        """
        initial_after = after

        def fetch_page(cursor: str | None) -> PageResult[ListingEntry]:
            params: dict[str, str | int] = {}
            effective_after = cursor if cursor is not None else initial_after
            if effective_after is not None:
                params["after"] = effective_after
            if prefix is not None:
                params["prefix"] = prefix
            if delimiter is not None:
                params["delimiter"] = delimiter
            if amount is not None:
                params["amount"] = amount
            else:
                params["amount"] = DEFAULT_PAGE_SIZE
            data = self._client._get_json(f"{self._repo_path}/objects", params=params)
            items = [ListingEntry.from_dict(d) for d in data.get("results", [])]
            pagination = data.get("pagination", {})
            return PageResult(
                items=items,
                has_more=pagination.get("has_more", False),
                next_offset=pagination.get("next_offset"),
                max_per_page=pagination.get("max_per_page"),
            )

        return PaginatedIterator(fetch_page)

    def get(
        self,
        path: str,
        *,
        cache: bool = True,
        presign: bool = True,
        byte_range: tuple[int, int | None] | None = None,
    ) -> ObjectReader:
        """Get an object's content as a streaming reader.

        Args:
            path: Object path.
            cache: Cache content in memory after first read (default ``True``).
                For large objects, use ``cache=False`` and ``.iter_bytes()``.
            presign: Use presigned URL for download (default ``True``).
            byte_range: Optional ``(start, end)`` byte range for partial reads.
                For example, ``(0, 1023)`` requests the first 1024 bytes.
                Use ``(offset, None)`` to read from *offset* to the end.

        Returns:
            An :class:`~tilde._object_reader.ObjectReader`.
        """
        return ObjectReader(
            self._client,
            f"{self._repo_path}/object",
            {"path": path},
            cache=cache,
            presign=presign,
            byte_range=byte_range,
        )

    def head(self, path: str) -> ObjectMetadata:
        """Check object existence and retrieve metadata from headers.

        Returns:
            An :class:`~tilde.models.ObjectMetadata` with etag,
            content_type, content_length, and reproducible fields.
        """
        response = self._client._head(
            f"{self._repo_path}/object",
            params={"path": path},
        )
        repro_header = response.headers.get("x-tilde-reproducible")
        reproducible: bool | None = None
        if repro_header == "true":
            reproducible = True
        elif repro_header == "false":
            reproducible = False
        cl = response.headers.get("content-length")
        return ObjectMetadata(
            etag=response.headers.get("etag"),
            content_type=response.headers.get("content-type"),
            content_length=int(cl) if cl else None,
            reproducible=reproducible,
        )


class SessionObjectCollection:
    """Object access within a session — supports read and write operations."""

    def __init__(self, client: Client, org: str, repo: str, session_id: str) -> None:
        self._client = client
        self._org = org
        self._repo = repo
        self._session_id = session_id

    @property
    def _repo_path(self) -> str:
        return f"/organizations/{self._org}/repositories/{self._repo}"

    def list(
        self,
        *,
        prefix: str | None = None,
        delimiter: str | None = None,
        after: str | None = None,
        amount: int | None = None,
    ) -> PaginatedIterator[ListingEntry]:
        """List objects in this session, auto-paginating."""
        initial_after = after

        def fetch_page(cursor: str | None) -> PageResult[ListingEntry]:
            params: dict[str, str | int] = {"session_id": self._session_id}
            effective_after = cursor if cursor is not None else initial_after
            if effective_after is not None:
                params["after"] = effective_after
            if prefix is not None:
                params["prefix"] = prefix
            if delimiter is not None:
                params["delimiter"] = delimiter
            if amount is not None:
                params["amount"] = amount
            else:
                params["amount"] = DEFAULT_PAGE_SIZE
            data = self._client._get_json(f"{self._repo_path}/objects", params=params)
            items = [ListingEntry.from_dict(d) for d in data.get("results", [])]
            pagination = data.get("pagination", {})
            return PageResult(
                items=items,
                has_more=pagination.get("has_more", False),
                next_offset=pagination.get("next_offset"),
                max_per_page=pagination.get("max_per_page"),
            )

        return PaginatedIterator(fetch_page)

    def get(
        self,
        path: str,
        *,
        cache: bool = True,
        presign: bool = True,
        byte_range: tuple[int, int | None] | None = None,
    ) -> ObjectReader:
        """Get an object's content as a streaming reader."""
        return ObjectReader(
            self._client,
            f"{self._repo_path}/object",
            {"session_id": self._session_id, "path": path},
            cache=cache,
            presign=presign,
            byte_range=byte_range,
        )

    def head(self, path: str) -> ObjectMetadata:
        """Check object existence and retrieve metadata from headers."""
        response = self._client._head(
            f"{self._repo_path}/object",
            params={"session_id": self._session_id, "path": path},
        )
        repro_header = response.headers.get("x-tilde-reproducible")
        reproducible: bool | None = None
        if repro_header == "true":
            reproducible = True
        elif repro_header == "false":
            reproducible = False
        cl = response.headers.get("content-length")
        return ObjectMetadata(
            etag=response.headers.get("etag"),
            content_type=response.headers.get("content-type"),
            content_length=int(cl) if cl else None,
            reproducible=reproducible,
        )

    def put(
        self,
        path: str,
        data: bytes | bytearray | memoryview | BinaryIO | pathlib.Path | Iterable[bytes],
    ) -> PutObjectResult:
        """Upload an object into this session.

        For data smaller than 64 MB (with known size), uses a single presigned
        upload (stage/finalize).  Otherwise defaults to multipart upload.  If
        the server returns 501 for multipart, falls back to single upload and
        caches that fact for subsequent puts.

        Args:
            path: Object path.
            data: Content as bytes, file-like, Path, or iterable of bytes chunks.
        """
        fh: BinaryIO | None = None
        try:
            if isinstance(data, pathlib.Path):
                fh = data.open("rb")
                data = fh

            size = _get_data_size(data)

            use_single = (
                size is not None and size < SINGLE_UPLOAD_LIMIT
            ) or self._client._multipart_unsupported

            if use_single:
                return self._put_single(path, data)

            # Attempt multipart
            try:
                return self._put_multipart(path, data)
            except APIError as exc:
                if exc.status_code == 501:
                    self._client._multipart_unsupported = True
                    # Reset stream position if possible
                    if hasattr(data, "seek"):
                        data.seek(0)
                    return self._put_single(path, data)
                raise
        finally:
            if fh is not None:
                fh.close()

    def _put_single(
        self,
        path: str,
        data: bytes | bytearray | memoryview | BinaryIO | Iterable[bytes],
    ) -> PutObjectResult:
        """Upload via single presigned URL (stage → upload → finalize)."""
        stage = self._client._post_json(
            f"{self._repo_path}/object/stage",
            params={"session_id": self._session_id, "path": path},
        )
        upload_url: str = stage["upload_url"]
        physical_address: str = stage["physical_address"]
        signature: str = stage["signature"]
        expires_at: str = stage["expires_at"]

        if isinstance(data, (bytes, bytearray, memoryview)):
            content: bytes | BinaryIO | Iterable[bytes] = bytes(data)
        else:
            content = data

        try:
            upload_resp = self._client._http.put(
                upload_url,
                content=content,
                headers={"Content-Type": "application/octet-stream"},
            )
        except httpx.TransportError as exc:
            raise TransportError(f"Upload to presigned URL failed: {exc}", cause=exc) from exc
        if not upload_resp.is_success:
            raise TransportError(
                f"Upload to presigned URL failed with status {upload_resp.status_code}"
            )

        finalize = self._client._put_json(
            f"{self._repo_path}/object/finalize",
            params={
                "session_id": self._session_id,
                "path": path,
                "expires_at": expires_at,
            },
            json={
                "physical_address": physical_address,
                "signature": signature,
                "content_type": "application/octet-stream",
            },
        )
        return PutObjectResult.from_dict(finalize)

    def _put_multipart(
        self,
        path: str,
        data: bytes | bytearray | memoryview | BinaryIO | Iterable[bytes],
    ) -> PutObjectResult:
        """Upload via multipart (initiate → upload parts → complete)."""
        # 1. Initiate multipart upload
        initiate = self._client._post_json(
            f"{self._repo_path}/object/multipart",
            params={"session_id": self._session_id, "path": path},
        )
        upload_id: str = initiate["upload_id"]
        physical_address: str = initiate["physical_address"]
        token: str = initiate["token"]

        parts: list[dict[str, object]] = []
        total_size = 0

        try:
            for part_number, chunk in _iter_parts(data):
                # 2. Get presigned URL for this part
                part_resp = self._client._http.get(
                    f"{self._repo_path}/object/multipart/part",
                    params={
                        "token": token,
                        "part_number": part_number,
                        "upload_id": upload_id,
                    },
                    headers=self._client._auth_headers(),
                    follow_redirects=False,
                )
                if part_resp.status_code not in (301, 302, 307, 308):
                    raise TransportError(
                        f"Expected redirect for part upload, got {part_resp.status_code}"
                    )
                presigned_url = part_resp.headers["Location"]

                # Upload part to presigned URL
                try:
                    put_resp = self._client._http.put(
                        presigned_url,
                        content=chunk,
                        headers={"Content-Type": "application/octet-stream"},
                    )
                except httpx.TransportError as exc:
                    raise TransportError(
                        f"Part {part_number} upload failed: {exc}", cause=exc
                    ) from exc
                if not put_resp.is_success:
                    raise TransportError(
                        f"Part {part_number} upload failed with status {put_resp.status_code}"
                    )

                etag = put_resp.headers.get("ETag", "")
                parts.append({"part_number": part_number, "etag": etag})
                total_size += len(chunk)

            # 3. Complete multipart upload
            complete = self._client._post_json(
                f"{self._repo_path}/object/multipart/complete",
                params={"session_id": self._session_id, "path": path},
                json={
                    "token": token,
                    "upload_id": upload_id,
                    "physical_address": physical_address,
                    "parts": parts,
                    "total_size": total_size,
                    "content_type": "application/octet-stream",
                },
            )
            return PutObjectResult.from_dict(complete)

        except Exception:
            # Abort on any failure
            with contextlib.suppress(Exception):
                self._client._post(
                    f"{self._repo_path}/object/multipart/abort",
                    params={"session_id": self._session_id, "path": path},
                    json={
                        "token": token,
                        "upload_id": upload_id,
                        "physical_address": physical_address,
                    },
                )
            raise

    def copy(self, source_path: str, destination_path: str) -> CopyObjectResult:
        """Copy an object within this session.

        Args:
            source_path: Path of the source object.
            destination_path: Path for the new copy.

        Returns:
            A :class:`~tilde.models.CopyObjectResult`.
        """
        data = self._client._post_json(
            f"{self._repo_path}/object/copy",
            params={
                "session_id": self._session_id,
                "source_path": source_path,
                "destination_path": destination_path,
            },
        )
        return CopyObjectResult.from_dict(data)

    def delete(self, path: str) -> None:
        """Delete an object (stage tombstone in this session)."""
        self._client._delete(
            f"{self._repo_path}/object",
            params={"session_id": self._session_id, "path": path},
        )

    def delete_many(self, paths: builtins.list[str]) -> int:
        """Delete multiple objects in a single request.

        Args:
            paths: List of object paths to delete.

        Returns:
            The number of objects deleted.
        """
        data = self._client._post_json(
            f"{self._repo_path}/objects/delete",
            params={"session_id": self._session_id},
            json={"paths": paths},
        )
        count: int = data.get("deleted", 0)
        return count
