import gzip
import re
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from io import IOBase, TextIOBase
from typing import IO, AsyncIterator, Dict, Iterator, Optional, Union, TypedDict

import httpx

from e2b.envd.filesystem import filesystem_pb2
from e2b.exceptions import InvalidArgumentException
from e2b.io_utils import agzip_iter, aiter_io_chunks, gzip_iter, iter_io_chunks


class FileType(Enum):
    """
    Enum representing the type of filesystem object.
    """

    FILE = "file"
    """
    Filesystem object is a file.
    """
    DIR = "dir"
    """
    Filesystem object is a directory.
    """


def map_file_type(ft: filesystem_pb2.FileType):
    if ft == filesystem_pb2.FileType.FILE_TYPE_FILE:
        return FileType.FILE
    elif ft == filesystem_pb2.FileType.FILE_TYPE_DIRECTORY:
        return FileType.DIR


def map_file_type_str(value: Optional[str]) -> Optional[FileType]:
    """Map a `/files` API type string to `FileType`, `None` when unknown."""
    if value == FileType.FILE.value:
        return FileType.FILE
    elif value == FileType.DIR.value:
        return FileType.DIR
    return None


@dataclass
class WriteInfo:
    """
    Sandbox filesystem object information.
    """

    name: str
    """
    Name of the filesystem object.
    """
    type: Optional[FileType]
    """
    Type of the filesystem object.
    """
    path: str
    """
    Path to the filesystem object.
    """
    metadata: Optional[Dict[str, str]] = field(default=None, kw_only=True)
    """
    User-defined metadata stored on the file as `user.e2b.*` extended
    attributes. On writes this reflects the metadata supplied on upload; on
    reads (`get_info`, `list`, `rename`) it reflects any `user.e2b.*` xattr on
    the file, including ones set out-of-band. `None` when none is set.
    """

    @classmethod
    def from_dict(cls, payload: Dict) -> "WriteInfo":
        """Build a `WriteInfo` from a `/files` upload response entry."""
        return cls(
            name=payload["name"],
            type=map_file_type_str(payload.get("type")),
            path=payload["path"],
            metadata=map_metadata(payload.get("metadata")),
        )


@dataclass
class EntryInfo(WriteInfo):
    """
    Extended sandbox filesystem object information.
    """

    size: int
    """
    Size of the filesystem object in bytes.
    """
    mode: int
    """
    File mode and permission bits.
    """
    permissions: str
    """
    String representation of file permissions (e.g. 'rwxr-xr-x').
    """
    owner: str
    """
    Owner of the filesystem object.
    """
    group: str
    """
    Group owner of the filesystem object.
    """
    modified_time: datetime
    """
    Last modification time of the filesystem object.
    """
    symlink_target: Optional[str] = None
    """
    Target of the symlink if the filesystem object is a symlink.
    If the filesystem object is not a symlink, this field is None.
    """


def map_entry_info(entry: filesystem_pb2.EntryInfo) -> EntryInfo:
    return EntryInfo(
        name=entry.name,
        type=map_file_type(entry.type),
        path=entry.path,
        size=entry.size,
        mode=entry.mode,
        permissions=entry.permissions,
        owner=entry.owner,
        group=entry.group,
        modified_time=entry.modified_time.ToDatetime(tzinfo=timezone.utc),
        # Optional, we can't directly access symlink_target otherwise it will be "" instead of None
        symlink_target=(
            entry.symlink_target if entry.HasField("symlink_target") else None
        ),
        metadata=map_metadata(entry.metadata),
    )


class WriteEntry(TypedDict):
    """
    Contains path and data of the file to be written to the filesystem.
    """

    path: str
    data: Union[str, bytes, IO]


class FileStreamReader(Iterator[bytes]):
    """Iterator over a streamed file download.

    Returned by ``Sandbox.files.read(format="stream")``. It owns the underlying
    HTTP response and releases its pooled connection as soon as the stream is
    fully consumed, an error is raised while reading (including the idle-read
    timeout, which raises ``httpx.ReadTimeout``), or the reader is closed.

    There is no garbage-collection safety net, so always consume it fully, use
    it as a context manager, or call :meth:`close`::

        with sandbox.files.read(path, format="stream") as stream:
            for chunk in stream:
                ...
    """

    def __init__(self, response: httpx.Response):
        self._response = response
        self._iterator = response.iter_bytes()
        self._closed = False

    def __iter__(self) -> Iterator[bytes]:
        return self

    def __next__(self) -> bytes:
        try:
            return next(self._iterator)
        except BaseException:
            # Covers normal end (StopIteration) and read errors alike.
            self.close()
            raise

    def close(self) -> None:
        """Release the underlying HTTP connection. Safe to call multiple times."""
        if self._closed:
            return
        self._closed = True
        self._response.close()

    def __enter__(self) -> "FileStreamReader":
        return self

    def __exit__(self, *exc_info) -> None:
        self.close()


class AsyncFileStreamReader(AsyncIterator[bytes]):
    """Async iterator over a streamed file download.

    Returned by ``AsyncSandbox.files.read(format="stream")``. It owns the
    underlying HTTP response and releases its pooled connection as soon as the
    stream is fully consumed, an error is raised while reading (including the
    idle-read timeout, which raises ``httpx.ReadTimeout``), or the reader is
    closed.

    There is no garbage-collection safety net (releasing an async connection
    requires awaiting ``aclose()``, which a finalizer cannot do reliably), so
    always consume it fully, use it as an async context manager, or call
    :meth:`aclose`::

        async with await sandbox.files.read(path, format="stream") as stream:
            async for chunk in stream:
                ...
    """

    def __init__(self, response: httpx.Response):
        self._response = response
        self._iterator = response.aiter_bytes()
        self._closed = False

    def __aiter__(self) -> AsyncIterator[bytes]:
        return self

    async def __anext__(self) -> bytes:
        try:
            return await self._iterator.__anext__()
        except BaseException:
            # Covers normal end (StopAsyncIteration) and read errors alike.
            await self.aclose()
            raise

    async def aclose(self) -> None:
        """Release the underlying HTTP connection. Safe to call multiple times."""
        if self._closed:
            return
        self._closed = True
        await self._response.aclose()

    async def __aenter__(self) -> "AsyncFileStreamReader":
        return self

    async def __aexit__(self, *exc_info) -> None:
        await self.aclose()


def _to_httpx_file(file_path: str, file_data: Union[str, bytes, IO]):
    """Build an httpx multipart `("file", (name, data))` tuple for the upload."""
    if isinstance(file_data, (str, bytes)):
        return ("file", (file_path, file_data))
    elif isinstance(file_data, TextIOBase):
        return ("file", (file_path, file_data.read()))
    elif isinstance(file_data, IOBase):
        return ("file", (file_path, file_data))
    else:
        raise InvalidArgumentException(f"Unsupported data type for file {file_path}")


def to_upload_body(
    data: Union[str, bytes, IO],
    use_gzip: bool = False,
) -> Union[bytes, IO, Iterator[bytes]]:
    """Prepare file data for upload, optionally gzip-compressed.

    File-like objects are streamed in chunks instead of being buffered in
    memory.
    """
    if isinstance(data, (str, bytes)):
        raw = data.encode("utf-8") if isinstance(data, str) else data
        return gzip.compress(raw) if use_gzip else raw
    elif isinstance(data, (TextIOBase, IOBase)):
        if use_gzip:
            return gzip_iter(iter_io_chunks(data))
        if isinstance(data, TextIOBase):
            # Text-mode IO yields str chunks—encode them while streaming.
            return iter_io_chunks(data)
        # httpx streams binary file-like objects in chunks without buffering.
        return data
    else:
        raise InvalidArgumentException(f"Unsupported data type: {type(data)}")


def to_upload_body_async(
    data: Union[str, bytes, IO],
    use_gzip: bool = False,
) -> Union[bytes, AsyncIterator[bytes]]:
    """Prepare file data for upload with async httpx, optionally gzip-compressed.

    File-like objects are streamed in chunks instead of being buffered in
    memory. Async httpx requires an async iterable for streamed request bodies.
    """
    if isinstance(data, (str, bytes)):
        raw = data.encode("utf-8") if isinstance(data, str) else data
        return gzip.compress(raw) if use_gzip else raw
    elif isinstance(data, (TextIOBase, IOBase)):
        chunks = aiter_io_chunks(data)
        return agzip_iter(chunks) if use_gzip else chunks
    else:
        raise InvalidArgumentException(f"Unsupported data type: {type(data)}")


METADATA_HEADER_PREFIX = "X-Metadata-"

# Metadata keys travel as `X-Metadata-<key>` HTTP header names, so they must be
# valid header tokens (RFC 7230); values travel as header values, restricted to
# printable US-ASCII.
_METADATA_KEY_REGEX = re.compile(r"\A[A-Za-z0-9!#$%&'*+\-.^_`|~]+\Z")
_METADATA_VALUE_REGEX = re.compile(r"\A[\x20-\x7e]*\Z")


def validate_metadata(metadata: Optional[Dict[str, str]]) -> None:
    """Validate metadata keys/values before they are sent as upload headers."""
    if not metadata:
        return
    for key, value in metadata.items():
        if not _METADATA_KEY_REGEX.match(key):
            raise InvalidArgumentException(
                f"Invalid metadata key {key!r}: keys must be non-empty and use only "
                "HTTP token characters (letters, digits and !#$%&'*+-.^_`|~)."
            )
        if not _METADATA_VALUE_REGEX.match(value):
            raise InvalidArgumentException(
                f"Invalid metadata value for key {key!r}: values must be printable US-ASCII."
            )


def metadata_to_headers(
    metadata: Optional[Dict[str, str]],
) -> Dict[str, str]:
    """Translate user metadata into the `X-Metadata-*` upload headers envd reads."""
    if not metadata:
        return {}
    return {f"{METADATA_HEADER_PREFIX}{key}": value for key, value in metadata.items()}


def map_metadata(metadata) -> Optional[Dict[str, str]]:
    """Normalize a proto/HTTP metadata map: drop empties and return a plain dict or None."""
    if not metadata:
        return None
    return dict(metadata)
