import io
from typing import IO, Iterator, List, Literal, Optional, Union, cast, overload
from http import HTTPStatus

import httpx

from httpx._types import ProxyTypes
from typing_extensions import Unpack

from e2b.api import handle_api_exception
from e2b.api.client.api.volumes import (
    post_volumes,
    get_volumes,
    get_volumes_volume_id,
    delete_volumes_volume_id,
)
from e2b.api.client.models import (
    NewVolume as NewVolumeModel,
    Error,
)
from e2b.api.client.types import Response
from e2b.api.client_sync import get_api_client as get_core_api_client
from e2b.connection_config import ApiParams, ConnectionConfig
from e2b.exceptions import NotFoundException, VolumeException
from e2b.volume.client.api.volumes import (
    get_volumecontent_volume_id_path as get_path,
    get_volumecontent_volume_id_dir as get_dir,
    post_volumecontent_volume_id_dir as post_dir,
    delete_volumecontent_volume_id_path as delete_path,
    patch_volumecontent_volume_id_path as patch_path,
    put_volumecontent_volume_id_file as put_file,
)
from e2b.volume.client.models import (
    Error as VolumeError,
    PatchVolumecontentVolumeIDPathBody as PatchPathBody,
    VolumeEntryStat as VolumeEntryStatApi,
)
from e2b.volume.client.types import File as FilePayload, UNSET
from e2b.volume.client_sync import get_api_client as get_volume_api_client
from e2b.volume.connection_config import (
    VolumeApiParams,
    VolumeConnectionConfig,
    FILE_TIMEOUT,
)
from e2b.volume.types import (
    VolumeAndToken,
    VolumeInfo,
    VolumeEntryStat,
)
from e2b.io_utils import iter_io_chunks
from e2b.volume.utils import DualMethod, convert_volume_entry_stat


class Volume:
    """E2B Volume for persistent storage that can be mounted to sandboxes."""

    def __init__(
        self,
        volume_id: str,
        name: str,
        token: Optional[str] = None,
        domain: Optional[str] = None,
        debug: Optional[bool] = None,
        proxy: Optional[ProxyTypes] = None,
    ):
        self._volume_id = volume_id
        self._name = name
        self._token = token
        self._domain = domain
        self._debug = debug
        self._proxy = proxy

    @property
    def volume_id(self) -> str:
        return self._volume_id

    @property
    def name(self) -> str:
        return self._name

    @property
    def token(self) -> Optional[str]:
        return self._token

    def _get_volume_config(
        self, **opts: Unpack[VolumeApiParams]
    ) -> VolumeConnectionConfig:
        return VolumeConnectionConfig(
            domain=opts.get("domain") or self._domain,
            debug=opts.get("debug") if opts.get("debug") is not None else self._debug,
            token=opts.get("token") or self._token,
            api_url=opts.get("api_url"),
            request_timeout=opts.get("request_timeout"),
            headers=opts.get("headers"),
            logger=opts.get("logger"),
            proxy=opts.get("proxy") if opts.get("proxy") is not None else self._proxy,
        )

    @classmethod
    def create(cls, name: str, **opts: Unpack[ApiParams]) -> "Volume":
        """
        Create a new volume.

        :param name: Name of the volume

        :return: A Volume instance for the new volume
        """
        config = ConnectionConfig(**opts)

        api_client = get_core_api_client(config)
        res = post_volumes.sync_detailed(
            body=NewVolumeModel(name=name),
            client=api_client,
        )

        if res.status_code >= 300:
            raise handle_api_exception(res, VolumeException)

        if res.parsed is None:
            raise Exception("Body of the request is None")

        if isinstance(res.parsed, Error):
            raise Exception(f"{res.parsed.message}: Request failed")

        vol = cls(
            volume_id=res.parsed.volume_id,
            name=res.parsed.name,
            token=res.parsed.token,
            domain=config.domain,
            debug=config.debug,
            proxy=config.proxy,
        )
        return vol

    @classmethod
    def connect(cls, volume_id: str, **opts: Unpack[ApiParams]) -> "Volume":
        """
        Connect to an existing volume by ID.

        :param volume_id: Volume ID

        :return: A Volume instance for the existing volume
        """
        info = cls.get_info(volume_id, **opts)
        config = ConnectionConfig(**opts)
        return cls(
            volume_id=volume_id,
            name=info.name,
            token=info.token,
            domain=config.domain,
            debug=config.debug,
            proxy=config.proxy,
        )

    @staticmethod
    def _class_get_info(volume_id: str, **opts: Unpack[ApiParams]) -> VolumeAndToken:
        """
        Get information about a volume.

        :param volume_id: Volume ID

        :return: Volume info
        """
        config = ConnectionConfig(**opts)

        api_client = get_core_api_client(config)
        res = get_volumes_volume_id.sync_detailed(
            volume_id,
            client=api_client,
        )

        if res.status_code == 404:
            raise NotFoundException(f"Volume {volume_id} not found")

        if res.status_code >= 300:
            raise handle_api_exception(res, VolumeException)

        if res.parsed is None:
            raise Exception("Body of the request is None")

        if isinstance(res.parsed, Error):
            raise Exception(f"{res.parsed.message}: Request failed")

        return VolumeAndToken(
            volume_id=res.parsed.volume_id,
            name=res.parsed.name,
            token=res.parsed.token,
        )

    @staticmethod
    def _class_list(**opts: Unpack[ApiParams]) -> List[VolumeInfo]:
        """
        List all volumes.

        :return: List of volumes
        """
        config = ConnectionConfig(**opts)

        api_client = get_core_api_client(config)
        res = get_volumes.sync_detailed(
            client=api_client,
        )

        if res.status_code >= 300:
            raise handle_api_exception(res, VolumeException)

        if res.parsed is None:
            return []

        if isinstance(res.parsed, Error):
            raise Exception(f"{res.parsed.message}: Request failed")

        return [VolumeInfo(volume_id=v.volume_id, name=v.name) for v in res.parsed]

    @staticmethod
    def destroy(volume_id: str, **opts: Unpack[ApiParams]) -> bool:
        """
        Destroy a volume.

        :param volume_id: Volume ID
        """
        config = ConnectionConfig(**opts)

        api_client = get_core_api_client(config)
        res = delete_volumes_volume_id.sync_detailed(
            volume_id,
            client=api_client,
        )

        if res.status_code == 404:
            return False

        if res.status_code >= 300:
            raise handle_api_exception(res, VolumeException)

        return True

    def _instance_list(
        self, path: str, depth: Optional[int] = None, **opts: Unpack[VolumeApiParams]
    ) -> List[VolumeEntryStat]:
        """
        List directory contents.

        :param path: Path to the directory
        :param depth: Number of layers deep to recurse into the directory
        :param opts: Connection options

        :return: List of items (files and directories) in the directory
        """
        config = self._get_volume_config(**opts)
        api_client = get_volume_api_client(config)

        res = get_dir.sync_detailed(
            self._volume_id,
            path=path,
            depth=depth if depth is not None else UNSET,
            client=api_client,
        )

        if res.status_code == 404:
            raise NotFoundException(f"Path {path} not found")

        if res.status_code >= 300:
            raise handle_api_exception(res, VolumeException)

        if res.parsed is None:
            return []

        if isinstance(res.parsed, VolumeError):
            raise Exception(f"{res.parsed.message}: Request failed")

        # VolumeDirectoryListing is a list according to the spec
        if isinstance(res.parsed, list):
            parsed_entries = cast(List[VolumeEntryStatApi], res.parsed)
            return [convert_volume_entry_stat(entry) for entry in parsed_entries]
        return []

    def make_dir(
        self,
        path: str,
        uid: Optional[int] = None,
        gid: Optional[int] = None,
        mode: Optional[int] = None,
        force: Optional[bool] = None,
        **opts: Unpack[VolumeApiParams],
    ) -> VolumeEntryStat:
        """
        Create a directory.

        :param path: Path to the directory to create
        :param uid: User ID of the created directory
        :param gid: Group ID of the created directory
        :param mode: Mode of the created directory
        :param force: Create parent directories if they don't exist
        :param opts: Connection options

        :return: Information about the created directory
        """
        config = self._get_volume_config(**opts)
        api_client = get_volume_api_client(config)

        res = post_dir.sync_detailed(
            self._volume_id,
            path=path,
            uid=uid if uid is not None else UNSET,
            gid=gid if gid is not None else UNSET,
            mode=mode if mode is not None else UNSET,
            force=force if force is not None else UNSET,
            client=api_client,
        )

        if res.status_code == 404:
            raise NotFoundException(f"Path {path} not found")

        if res.status_code >= 300:
            raise handle_api_exception(res, VolumeException)

        if res.parsed is None:
            raise Exception("Body of the request is None")

        if isinstance(res.parsed, VolumeError):
            raise Exception(f"{res.parsed.message}: Request failed")

        return convert_volume_entry_stat(res.parsed)

    def exists(self, path: str, **opts: Unpack[VolumeApiParams]) -> bool:
        """
        Check whether a file or directory exists.

        Uses get_info under the hood. Returns True if the path exists,
        False if it does not (404). Other errors are re-raised.

        :param path: Path to the file or directory
        :param opts: Connection options

        :return: True if the path exists, False otherwise
        """
        try:
            self.get_info(path, **opts)
            return True
        except NotFoundException:
            return False

    def _instance_get_info(
        self, path: str, **opts: Unpack[VolumeApiParams]
    ) -> VolumeEntryStat:
        """
        Get information about a file or directory.

        :param path: Path to the file or directory
        :param opts: Connection options

        :return: Information about the entry
        """
        config = self._get_volume_config(**opts)
        api_client = get_volume_api_client(config)

        res = get_path.sync_detailed(
            self._volume_id,
            path=path,
            client=api_client,
        )

        if res.status_code == 404:
            raise NotFoundException(f"Path {path} not found")

        if res.status_code >= 300:
            raise handle_api_exception(res, VolumeException)

        if res.parsed is None:
            raise Exception("Body of the request is None")

        if isinstance(res.parsed, VolumeError):
            raise Exception(f"{res.parsed.message}: Request failed")

        return convert_volume_entry_stat(cast(VolumeEntryStatApi, res.parsed))

    get_info = DualMethod(_class_get_info.__func__, _instance_get_info)
    list = DualMethod(_class_list.__func__, _instance_list)

    def update_metadata(
        self,
        path: str,
        uid: Optional[int] = None,
        gid: Optional[int] = None,
        mode: Optional[int] = None,
        **opts: Unpack[VolumeApiParams],
    ) -> VolumeEntryStat:
        """
        Update file or directory metadata.

        :param path: Path to the file or directory
        :param uid: User ID of the file or directory
        :param gid: Group ID of the file or directory
        :param mode: Mode of the file or directory
        :param opts: Connection options

        :return: Updated entry information
        """
        config = self._get_volume_config(**opts)
        api_client = get_volume_api_client(config)

        body = PatchPathBody(
            uid=uid if uid is not None else UNSET,
            gid=gid if gid is not None else UNSET,
            mode=mode if mode is not None else UNSET,
        )

        res = patch_path.sync_detailed(
            self._volume_id,
            path=path,
            body=body,
            client=api_client,
        )

        if res.status_code == 404:
            raise NotFoundException(f"Path {path} not found")

        if res.status_code >= 300:
            raise handle_api_exception(res, VolumeException)

        if res.parsed is None:
            raise Exception("Body of the request is None")

        return convert_volume_entry_stat(cast(VolumeEntryStatApi, res.parsed))

    @overload
    def read_file(
        self,
        path: str,
        format: Literal["text"] = "text",
        **opts: Unpack[VolumeApiParams],
    ) -> str: ...

    @overload
    def read_file(
        self,
        path: str,
        format: Literal["bytes"],
        **opts: Unpack[VolumeApiParams],
    ) -> bytes: ...

    @overload
    def read_file(
        self,
        path: str,
        format: Literal["stream"],
        stream_idle_timeout: Optional[float] = None,
        **opts: Unpack[VolumeApiParams],
    ) -> Iterator[bytes]: ...

    def read_file(
        self,
        path: str,
        format: Literal["text", "bytes", "stream"] = "text",
        stream_idle_timeout: Optional[float] = None,
        **opts: Unpack[VolumeApiParams],
    ) -> Union[str, bytes, Iterator[bytes]]:
        """
        Read file content.

        You can pass `text`, `bytes`, or `stream` to `format` to change the return type.

        :param path: Path to the file
        :param format: Format of the file content—`text` by default
        :param stream_idle_timeout: Idle timeout in **seconds** for a streamed
            read (`format="stream"`)—abort if no chunk arrives within this
            window while reading. Resets on every chunk, so it bounds a stalled
            stream without limiting total transfer time. Defaults to the request
            timeout; pass `0` to disable.
        :param opts: Connection options

        :return: File content as string, bytes, or iterator of bytes
        """
        config = self._get_volume_config(**opts)
        api_client = get_volume_api_client(config)

        params = {"path": path}
        timeout = VolumeConnectionConfig._get_request_timeout(
            FILE_TIMEOUT, opts.get("request_timeout")
        )

        if format == "stream":
            # The request timeout bounds connection setup, not total transfer;
            # consuming the body must not be killed by it. httpx's per-chunk
            # `read` timeout becomes the idle-read timeout for the body
            # (defaults to the request timeout), bounding a stalled stream
            # without limiting total transfer time. Pass `0` to disable.
            # Mirrors the sandbox files stream path.
            idle_timeout = (
                timeout if stream_idle_timeout is None else stream_idle_timeout
            )
            stream_timeout = httpx.Timeout(timeout, read=idle_timeout or None)

            def stream_file() -> Iterator[bytes]:
                with api_client.get_httpx_client().stream(
                    method="GET",
                    url=f"/volumecontent/{self._volume_id}/file",
                    params=params,
                    timeout=stream_timeout,
                ) as response:
                    if response.status_code == 404:
                        raise NotFoundException(f"Path {path} not found")

                    if response.status_code >= 300:
                        api_response = Response(
                            status_code=HTTPStatus(response.status_code),
                            content=response.read(),
                            headers=response.headers,
                            parsed=None,
                        )
                        raise handle_api_exception(api_response, VolumeException)

                    yield from response.iter_bytes()

            return stream_file()

        response = api_client.get_httpx_client().request(
            method="GET",
            url=f"/volumecontent/{self._volume_id}/file",
            params=params,
            timeout=timeout,
        )

        if response.status_code == 404:
            raise NotFoundException(f"Path {path} not found")

        if response.status_code >= 300:
            api_response = Response(
                status_code=HTTPStatus(response.status_code),
                content=response.content,
                headers=response.headers,
                parsed=None,
            )
            raise handle_api_exception(api_response, VolumeException)

        if format == "bytes":
            return response.content
        else:
            return response.text

    def write_file(
        self,
        path: str,
        data: Union[str, bytes, IO],
        uid: Optional[int] = None,
        gid: Optional[int] = None,
        mode: Optional[int] = None,
        force: Optional[bool] = None,
        **opts: Unpack[VolumeApiParams],
    ) -> VolumeEntryStat:
        """
        Write content to a file.

        Writing to a file that doesn't exist creates the file.
        Writing to a file that already exists overwrites the file.

        :param path: Path to the file
        :param data: Data to write to the file. Data can be a string, bytes, or IO. File-like objects are streamed in chunks instead of being buffered in memory.
        :param uid: User ID of the created file
        :param gid: Group ID of the created file
        :param mode: Mode of the created file
        :param force: Force overwrite of an existing file
        :param opts: Connection options

        :return: Information about the written file
        """
        config = self._get_volume_config(**opts)
        upload_timeout = VolumeConnectionConfig._get_request_timeout(
            FILE_TIMEOUT, opts.get("request_timeout")
        )
        api_client = get_volume_api_client(config)
        if upload_timeout is not None:
            api_client = api_client.with_timeout(httpx.Timeout(upload_timeout))

        content: Union[bytes, IO[bytes], Iterator[bytes]]
        if isinstance(data, str):
            content = data.encode("utf-8")
        elif isinstance(data, bytes):
            content = data
        elif isinstance(data, io.TextIOBase):
            # Text-mode IO yields str chunks—encode them while streaming.
            content = iter_io_chunks(data)
        elif hasattr(data, "read"):
            # httpx streams file-like objects in chunks without buffering.
            content = data
        else:
            raise ValueError(f"Unsupported data type: {type(data)}")

        res = put_file.sync_detailed(
            self._volume_id,
            body=FilePayload(payload=content),  # type: ignore[arg-type]  # httpx accepts bytes and streamable content directly
            path=path,
            uid=uid if uid is not None else UNSET,
            gid=gid if gid is not None else UNSET,
            mode=mode if mode is not None else UNSET,
            force=force if force is not None else UNSET,
            client=api_client,
        )

        if res.status_code == 404:
            raise NotFoundException(f"Path {path} not found")

        if res.status_code >= 300:
            raise handle_api_exception(res, VolumeException)

        if res.parsed is None:
            raise Exception("Body of the request is None")

        if isinstance(res.parsed, VolumeError):
            raise Exception(f"{res.parsed.message}: Request failed")

        return convert_volume_entry_stat(cast(VolumeEntryStatApi, res.parsed))

    def remove(
        self,
        path: str,
        **opts: Unpack[VolumeApiParams],
    ) -> None:
        """
        Remove a file or directory.

        :param path: Path to the file or directory to remove
        :param opts: Connection options
        """
        config = self._get_volume_config(**opts)
        api_client = get_volume_api_client(config)

        res = delete_path.sync_detailed(
            self._volume_id,
            path=path,
            client=api_client,
        )

        if res.status_code == 404:
            raise NotFoundException(f"Path {path} not found")

        if res.status_code >= 300:
            raise handle_api_exception(res, VolumeException)
