"""
Utility helpers for the TeleStore client library.

Provides MIME type detection (with graceful fallback when ``python-magic``
is not installed), file size introspection, and source normalisation.
"""
from __future__ import annotations

import io
import mimetypes
import os
from io import IOBase
from pathlib import Path
from typing import IO, Union

# ---------------------------------------------------------------------------
# Optional python-magic detection — done once at import time so every call
# does not pay the ImportError overhead.
# ---------------------------------------------------------------------------
try:
    import magic as _magic  # type: ignore[import]

    # Verify the native library actually loads (on Windows python-magic can be
    # installed without the libmagic DLL, which causes a ctypes access violation
    # at import time rather than a clean ImportError).
    _magic.from_buffer(b"test", mime=True)  # probe — raises if DLL is missing

    _MAGIC_AVAILABLE: bool = True
except Exception:  # ImportError, OSError, ctypes fault wrapped by magic, etc.
    _MAGIC_AVAILABLE = False

# Sentinel used when a file-like object has no ``name`` attribute.
_UNKNOWN_FILENAME = "upload"

# How many bytes to feed to libmagic for MIME detection.
_MAGIC_SNIFF_BYTES = 2048


def detect_mime_type(source: Union[str, "os.PathLike[str]", bytes]) -> str:
    """
    Detect the MIME type of *source*.

    Tries ``python-magic`` first for reliable binary sniffing; falls back to
    :mod:`mimetypes` (filename-based) when the library is not installed.

    Parameters
    ----------
    source:
        A filesystem path (``str`` or :class:`pathlib.Path`) or raw ``bytes``
        content.  File-like objects are *not* accepted here — normalise them
        to bytes with :func:`normalize_source` first.

    Returns
    -------
    str
        A MIME type string such as ``"image/jpeg"`` or
        ``"application/octet-stream"`` when detection fails.
    """
    if isinstance(source, (str, Path)):
        path = Path(source)
        if _MAGIC_AVAILABLE:
            try:
                mime = _magic.from_file(str(path), mime=True)
                if mime:
                    return mime
            except Exception:
                pass
        # Fallback: guess by extension
        guessed, _ = mimetypes.guess_type(path.name)
        return guessed or "application/octet-stream"

    # bytes path
    if _MAGIC_AVAILABLE:
        try:
            mime = _magic.from_buffer(source[:_MAGIC_SNIFF_BYTES], mime=True)
            if mime:
                return mime
        except Exception:
            pass

    return "application/octet-stream"


def get_file_size(source: Union[str, "os.PathLike[str]", bytes, IO[bytes]]) -> int:
    """
    Return the size in bytes of *source* without consuming a file-like object.

    Parameters
    ----------
    source:
        A filesystem path, raw ``bytes``, or a binary file-like object
        (anything with ``.read()``, ``.seek()``, and ``.tell()``).

    Returns
    -------
    int
        Size in bytes.

    Raises
    ------
    TypeError
        If *source* is not a recognised type or is not seekable.
    """
    if isinstance(source, (str, Path)):
        return os.path.getsize(source)

    if isinstance(source, (bytes, bytearray)):
        return len(source)

    # File-like: check if seekable before attempting seek/tell
    if not hasattr(source, 'seek') or not hasattr(source, 'tell'):
        raise TypeError(
            f"File-like object must support .seek() and .tell(). "
            f"Got {type(source).__name__} — convert to bytes first or use a seekable file."
        )
    
    original_pos = None
    try:
        # Save position, seek to end, restore
        original_pos = source.tell()
        source.seek(0, 2)  # SEEK_END
        size = source.tell()
        source.seek(original_pos)  # restore
        return size
    except (OSError, io.UnsupportedOperation) as e:
        raise TypeError(
            f"Stream type {type(source).__name__} claims to support seek/tell "
            f"but raised {e.__class__.__name__}. Use a seekable stream."
        ) from e
    finally:
        if original_pos is not None:
            try:
                source.seek(original_pos)
            except Exception:
                pass  # If we can't restore, there's not much else we can do


def normalize_source(
    source: Union[str, "os.PathLike[str]", bytes, IO[bytes]],
) -> "tuple[bytes, str]":
    """
    Normalise any supported source type to ``(content_bytes, filename)``.

    Parameters
    ----------
    source:
        A filesystem path (``str`` / :class:`pathlib.Path`), raw ``bytes``,
        or a binary file-like object with a ``.read()`` method.

    Returns
    -------
    tuple[bytes, str]
        A 2-tuple of *(content_bytes, filename)*.  The filename is derived
        from the path, the ``name`` attribute of the file-like object, or
        ``"upload"`` as a last resort.

    Raises
    ------
    TypeError
        If *source* is not a recognised type.
    ValueError
        If a filesystem path does not exist.
    """
    if isinstance(source, (str, Path)):
        path = Path(source)
        if not path.exists():
            raise ValueError(f"Path does not exist: {path}")
        content = path.read_bytes()
        return content, path.name

    if isinstance(source, (bytes, bytearray)):
        return bytes(source), _UNKNOWN_FILENAME

    # Duck-type as file-like
    if hasattr(source, "read"):
        # Require a seekable stream so we can read from the beginning without
        # permanently consuming or repositioning the caller's stream.
        if not (hasattr(source, "seek") and hasattr(source, "tell")):
            raise TypeError(
                "normalize_source() requires a seekable file-like object with "
                ".seek() and .tell() methods."
            )

        # Preserve the original cursor position while reading the full content.
        original_pos = source.tell()
        try:
            source.seek(0)
            content = source.read()
        finally:
            try:
                source.seek(original_pos)
            except Exception:
                # If restoring the position fails, we still return the content;
                # the caller's stream may be left repositioned in this case.
                pass
        
        filename: str
        try:
            filename = Path(source.name).name  # type: ignore[union-attr]
        except AttributeError:
            filename = _UNKNOWN_FILENAME
        return content, filename or _UNKNOWN_FILENAME

    raise TypeError(
        f"Unsupported source type: {type(source).__name__!r}. "
        "Expected str, Path, bytes, or a file-like object."
    )
