"""
TeleStore Python client.

The :class:`TeleStoreClient` class is the single entry point for all
operations against the TeleStore REST API.
"""
from __future__ import annotations

import json as _json
from pathlib import Path
from typing import IO, Any, Dict, List, Optional, Union, cast

import requests

from .exceptions import (
    AuthenticationError,
    DownloadError,
    FileSizeError,
    ServerError,
    UploadError,
)
from .models import UploadedFile
from .utils import detect_mime_type, get_file_size, normalize_source

# 2 GiB — TeleStore's hard upload limit.
_MAX_FILE_SIZE: int = 2 * 1024 ** 3

# Chunk size used when streaming download responses.
_DOWNLOAD_CHUNK_SIZE: int = 8192


class TeleStoreClient:
    """
    Synchronous HTTP client for the TeleStore file-storage API.

    TeleStore stores files inside Telegram channels and exposes them through
    a REST API.  All mutating operations (uploads) are authenticated via the
    ``x-api-key`` header; view and download URLs are publicly accessible.

    Parameters
    ----------
    api_key:
        Your TeleStore API key.  Generate one at
        ``https://telestore.onrender.com/api-keys/``.
    base_url:
        Root URL of the TeleStore server.  Defaults to the hosted instance at
        ``https://telestore.onrender.com``.  Trailing slashes are stripped
        automatically.
    timeout:
        Request timeout in seconds.  Defaults to 120 s — large file uploads
        (>50 MB) route through a slower Telethon path on the server side and
        need a generous timeout.

    Raises
    ------
    :exc:`~telestore.exceptions.AuthenticationError`
        Immediately, if *api_key* is empty or whitespace-only.
    """

    def __init__(
        self,
        api_key: str,
        base_url: str = "https://telestore.onrender.com",
        timeout: int = 120,
    ) -> None:
        if not api_key or not api_key.strip():
            raise AuthenticationError(
                "api_key must be a non-empty string. "
                "Generate one at https://telestore.onrender.com/api-keys/"
            )

        self._api_key = api_key
        self.base_url: str = base_url.rstrip("/")
        self.timeout: int = timeout

        self._session = requests.Session()
        self._session.headers.update({"x-api-key": self._api_key})

    # ------------------------------------------------------------------
    # Internal helpers
    # ------------------------------------------------------------------

    def _raise_for_response(self, response: requests.Response, error_class: type) -> None:
        """
        Inspect *response* and raise an appropriate exception if it indicates
        a failure.  Should be called after every API request.

        Parameters
        ----------
        response:
            The :class:`requests.Response` to inspect.
        error_class:
            The exception class to raise for generic/non-status errors
            (e.g. :exc:`~telestore.exceptions.UploadError`).
        """
        if response.status_code == 401:
            raise AuthenticationError(
                "Request rejected: invalid or missing API key (HTTP 401)."
            )

        if response.status_code == 404:
            from .exceptions import FileNotFoundError as _FNF  # local import avoids shadowing built-in
            request_url = getattr(getattr(response, "request", None), "url", "unknown URL")
            raise _FNF(
                f"The requested resource at {request_url} was not found on the TeleStore server (HTTP 404)."
            )

        if response.status_code >= 500:
            try:
                payload = response.json()
                detail = payload.get("error") or payload.get("detail") or response.text
            except Exception:
                detail = response.text or "(no message)"
            raise ServerError(
                f"TeleStore server error (HTTP {response.status_code}): {detail}"
            )

        if not response.ok:
            # Try to extract a message from the JSON error body.
            try:
                payload = response.json()
                msg = payload.get("error") or payload.get("detail") or response.text
            except Exception:
                msg = response.text or f"HTTP {response.status_code}"
            raise error_class(msg)

    # ------------------------------------------------------------------
    # Core methods
    # ------------------------------------------------------------------

    def upload(
        self,
        source: Union[str, Path, bytes, IO[bytes]],
        filename: Optional[str] = None,
    ) -> UploadedFile:
        """
        Upload a file to TeleStore.

        *source* may be:

        * A **filesystem path** (``str`` or :class:`pathlib.Path`)
        * Raw **bytes**
        * A **file-like object** (anything with a ``.read()`` method)

        Parameters
        ----------
        source:
            The content to upload.
        filename:
            Override the filename sent to TeleStore.  When omitted the name
            is derived from the path or the ``name`` attribute of the
            file-like object.  Falls back to ``"upload"`` if neither is
            available.

        Returns
        -------
        UploadedFile
            Metadata returned by TeleStore plus locally detected ``mime_type``
            and ``size_bytes``.

        Raises
        ------
        :exc:`~telestore.exceptions.FileSizeError`
            If the source exceeds 2 GiB.
        :exc:`~telestore.exceptions.AuthenticationError`
            If the API key is rejected (HTTP 401).
        :exc:`~telestore.exceptions.UploadError`
            For any other upload failure.
        """
        size = get_file_size(source)
        if size > _MAX_FILE_SIZE:
            raise FileSizeError(
                f"File size {size:,} bytes exceeds the maximum allowed size "
                f"of {_MAX_FILE_SIZE:,} bytes (2 GiB)."
            )

        content, derived_name = normalize_source(source)
        effective_filename = filename or derived_name
        mime = detect_mime_type(content)

        url = f"{self.base_url}/files/upload/"
        files = {"file": (effective_filename, content, mime)}

        try:
            response = self._session.post(url, files=files, timeout=self.timeout)
        except requests.RequestException as exc:
            raise UploadError(f"Upload request failed: {exc}") from exc

        self._raise_for_response(response, UploadError)

        payload = response.json()
        return UploadedFile(
            file_id=payload["file_id"],
            view_url=payload["view_url"],
            download_url=payload["download_url"],
            original_filename=effective_filename,
            size_bytes=size,
            mime_type=mime,
        )

    def download(
        self,
        file_id: str,
        destination: Optional[Union[str, Path]] = None,
    ) -> Union[bytes, Path]:
        """
        Download a file from TeleStore.

        The response is streamed in chunks so that very large files are never
        loaded fully into memory at once.

        Parameters
        ----------
        file_id:
            The opaque file identifier returned by :meth:`upload`.
        destination:
            Optional filesystem path.  When provided the downloaded bytes are
            written to this location and the resolved :class:`~pathlib.Path`
            is returned.  When omitted the raw ``bytes`` are returned.

        Returns
        -------
        bytes or Path
            Raw bytes when *destination* is ``None``; the destination
            :class:`~pathlib.Path` otherwise.

        Raises
        ------
        :exc:`~telestore.exceptions.DownloadError`
            If the server returns a non-200 response or the connection fails.
        """
        url = f"{self.base_url}/files/download/{file_id}/"

        try:
            response = self._session.get(url, stream=True, timeout=self.timeout)
        except requests.RequestException as exc:
            raise DownloadError(f"Download request failed: {exc}") from exc

        self._raise_for_response(response, DownloadError)

        if destination is not None:
            dest_path = Path(destination)
            dest_path.parent.mkdir(parents=True, exist_ok=True)
            with dest_path.open("wb") as fh:
                for chunk in response.iter_content(chunk_size=_DOWNLOAD_CHUNK_SIZE):
                    if chunk:
                        fh.write(chunk)
            return dest_path

        chunks = []
        for chunk in response.iter_content(chunk_size=_DOWNLOAD_CHUNK_SIZE):
            if chunk:
                chunks.append(chunk)
        return b"".join(chunks)

    def get_url(self, file_id: str, mode: str = "view") -> str:
        """
        Return the public URL for a TeleStore file **without** making a
        network request.

        Parameters
        ----------
        file_id:
            The opaque file identifier returned by :meth:`upload`.
        mode:
            Either ``"view"`` (inline) or ``"download"`` (attachment).

        Returns
        -------
        str
            The fully qualified URL.

        Raises
        ------
        ValueError
            If *mode* is not ``"view"`` or ``"download"``.
        """
        if mode == "view":
            return f"{self.base_url}/files/view/{file_id}/"
        if mode == "download":
            return f"{self.base_url}/files/download/{file_id}/"
        raise ValueError(
            f"Invalid mode {mode!r}. Expected 'view' or 'download'."
        )

    def delete(self, file_id: str) -> None:
        """Delete a file by ``file_id`` using the API-key protected endpoint.

        Raises
        ------
        AuthenticationError
            If the API key is invalid (HTTP 401).
        FileNotFoundError
            If the file does not exist (HTTP 404).
        UploadError
            For other client errors (4xx).
        ServerError
            For server-side failures (5xx).
        """
        url = f"{self.base_url}/files/delete/{file_id}/"

        try:
            response = self._session.delete(url, timeout=self.timeout)
        except requests.RequestException as exc:
            from .exceptions import UploadError
            raise UploadError(f"Delete request failed: {exc}") from exc

        from .exceptions import UploadError as _UploadError
        self._raise_for_response(response, _UploadError)
        # No payload expected on success.

    def list_files(self, ids_only: bool = False) -> Union[List[Dict[str, Any]], List[str]]:
        """Return metadata for every file owned by the authenticated user.

        Parameters
        ----------
        ids_only:
            When ``True`` only the ``file_id`` values are returned.  Defaults
            to ``False`` which yields the raw JSON dictionaries provided by
            the TeleStore API (including filename, size, URLs, etc.).

        Returns
        -------
        list
            Either a list of dictionaries (full metadata) or a list of
            ``file_id`` strings when *ids_only* is ``True``.

        Raises
        ------
        AuthenticationError
            If the API key is invalid (HTTP 401).
        UploadError
            For other client errors (4xx).
        ServerError
            For server-side failures (5xx).
        """
        url = f"{self.base_url}/files/list/"

        try:
            response = self._session.get(url, timeout=self.timeout)
        except requests.RequestException as exc:
            from .exceptions import UploadError
            raise UploadError(f"List request failed: {exc}") from exc

        from .exceptions import UploadError as _UploadError
        self._raise_for_response(response, _UploadError)

        payload = response.json()
        files = payload.get("files", [])
        if ids_only:
            return [item["file_id"] for item in files]
        return files

    def list_file_ids(self) -> List[str]:
        """Convenience wrapper that returns only ``file_id`` strings.

        Equivalent to calling :meth:`list_files` with ``ids_only=True``
        but with a narrower return type for easier consumption.
        """

        return cast(List[str], self.list_files(ids_only=True))

    def url_upload(
        self,
        url: str,
        filename: Optional[str] = None,
    ) -> UploadedFile:
        """
        Download content from an arbitrary URL and upload it to TeleStore.

        Useful for mirroring publicly accessible files without saving them to
        disk first.

        Parameters
        ----------
        url:
            The URL to fetch.
        filename:
            Override the filename used when uploading to TeleStore.  When
            omitted the last path segment of *url* is used, falling back to
            ``"upload"``.

        Returns
        -------
        UploadedFile
            Metadata for the uploaded file.

        Raises
        ------
        :exc:`~telestore.exceptions.UploadError`
            If the remote URL cannot be fetched or the upload fails.
        """
        try:
            remote = requests.get(url, timeout=self.timeout)
            remote.raise_for_status()
        except requests.RequestException as exc:
            raise UploadError(f"Failed to fetch remote URL {url!r}: {exc}") from exc

        if filename is None:
            url_path = url.split("?")[0].rstrip("/")
            filename = url_path.split("/")[-1] or "upload"

        return self.upload(remote.content, filename=filename)

    # ------------------------------------------------------------------
    # Convenience wrappers
    # ------------------------------------------------------------------

    def upload_image(
        self,
        source: Union[str, Path, bytes, IO[bytes]],
        filename: Optional[str] = None,
    ) -> UploadedFile:
        """
        Upload an image file and validate its MIME type.

        Behaves identically to :meth:`upload` but raises
        :exc:`~telestore.exceptions.UploadError` if the detected MIME type
        does not begin with ``"image/"``.

        Parameters
        ----------
        source:
            The image content (path, bytes, or file-like object).
        filename:
            Optional filename override.

        Returns
        -------
        UploadedFile

        Raises
        ------
        :exc:`~telestore.exceptions.UploadError`
            If the content is not detected as an image.
        """
        result = self.upload(source, filename=filename)
        if not result.mime_type.startswith("image/"):
            raise UploadError(
                f"Expected an image but detected MIME type {result.mime_type!r}. "
                "Use client.upload() to upload non-image files."
            )
        return result

    def upload_text(
        self,
        text: str,
        filename: str = "file.txt",
        encoding: str = "utf-8",
    ) -> UploadedFile:
        """
        Encode a string and upload it as a plain-text file.

        Parameters
        ----------
        text:
            The string content to upload.
        filename:
            Filename to use on TeleStore.  Defaults to ``"file.txt"``.
        encoding:
            Character encoding used to convert the string to bytes.
            Defaults to ``"utf-8"``.

        Returns
        -------
        UploadedFile
        """
        content = text.encode(encoding)
        return self.upload(content, filename=filename)

    def upload_json(
        self,
        data: Dict,
        filename: str = "data.json",
    ) -> UploadedFile:
        """
        Serialise a dictionary to JSON and upload it to TeleStore.

        Parameters
        ----------
        data:
            A JSON-serialisable dictionary.
        filename:
            Filename to use on TeleStore.  Defaults to ``"data.json"``.

        Returns
        -------
        UploadedFile
        """
        content = _json.dumps(data, ensure_ascii=False).encode("utf-8")
        return self.upload(content, filename=filename)

    def download_text(
        self,
        file_id: str,
        encoding: str = "utf-8",
    ) -> str:
        """
        Download a file and decode its contents as a string.

        Parameters
        ----------
        file_id:
            The opaque file identifier returned by :meth:`upload`.
        encoding:
            Character encoding to use when decoding.  Defaults to ``"utf-8"``.

        Returns
        -------
        str
        """
        raw: bytes = self.download(file_id)  # type: ignore[assignment]
        return raw.decode(encoding)

    def download_json(self, file_id: str) -> Dict:
        """
        Download a file and parse its contents as JSON.

        Parameters
        ----------
        file_id:
            The opaque file identifier returned by :meth:`upload`.

        Returns
        -------
        dict
        """
        raw: bytes = self.download(file_id)  # type: ignore[assignment]
        return _json.loads(raw)
