from __future__ import annotations

import contextlib
import hashlib
import io
import mimetypes
from dataclasses import dataclass
from typing import TYPE_CHECKING

import puremagic
from infrahub_sdk.uuidt import UUIDT

from infrahub import config
from infrahub.core import registry
from infrahub.core.constants import InfrahubKind
from infrahub.exceptions import ValidationError

if TYPE_CHECKING:
    from starlette.datastructures import UploadFile

    from infrahub.core.node import Node


@dataclass
class FileMetadata:
    """Metadata extracted from an uploaded file."""

    file_name: str
    checksum: str
    file_size: int
    file_type: str


@dataclass
class FileUploadResult:
    """Result of a file upload including storage identifier."""

    storage_id: str
    metadata: FileMetadata


class FileUploadProcessor:
    """Processor for handling file uploads."""

    def __init__(self, file: UploadFile) -> None:
        self.file = file
        self._metadata: FileMetadata | None = None
        self.storage_id: str | None = None

    @staticmethod
    def _format_file_size(size_bytes: int) -> str:
        """Format a file size in bytes to a human-readable string.

        Args:
            size_bytes: The file size in bytes.

        Returns:
            A human-readable string like "1.5 MB" or "256 KB".
        """
        value = float(size_bytes)
        for unit in ("B", "KB", "MB", "GB", "TB"):
            if abs(value) < 1024:
                return f"{value:.1f} {unit}" if unit != "B" else f"{int(value)} {unit}"
            value /= 1024
        return f"{value:.1f} PB"

    @staticmethod
    def _looks_like_text(content: bytes) -> bool:
        """Heuristic to determine if file content looks like text.

        https://github.com/file/file/blob/f2a6e7cb7db9b5fd86100403df6b2f830c7f22ba/src/encoding.c#L151-L228

        Args:
            content: The initial bytes of the file content.

        Returns:
            True if the content looks like text, False otherwise.
        """
        if b"\x00" in content:
            return False

        textchars = bytearray({7, 8, 9, 10, 12, 13, 27} | set(range(0x20, 0x100)) - {0x7F})
        return not content.translate(None, textchars)

    @staticmethod
    def _detect_mime_type(content: bytes, filename: str | None = None) -> str:
        """Detect the MIME type of a file using magic bytes and optional filename.

        Passes the filename to puremagic so it can use the extension to improve detection
        of text-based formats (YAML, TOML, etc.) that lack distinctive magic bytes.

        Falls back to `application/octet-stream` if the type cannot be determined.

        Args:
            content: The file content (or first few KB) as bytes.
            filename: Optional original filename used to refine detection.

        Returns:
            The detected MIME type string.
        """
        with contextlib.suppress(puremagic.PureError):
            results = puremagic.magic_string(content, filename=filename)
            if results:
                return results[0].mime_type

        if filename and FileUploadProcessor._looks_like_text(content=content):
            # puremagic does not recognize some text-based format such as CSV
            guessed_type, _ = mimetypes.guess_type(filename)
            if guessed_type:
                return guessed_type

        return "application/octet-stream"

    @property
    def metadata(self) -> FileMetadata:
        """Get the extracted file metadata. Mostly to make type checkers happy.

        Returns:
            The extracted file metadata.

        Raises:
            RuntimeError: If metadata has not been extracted yet.
        """
        if not self._metadata:
            raise RuntimeError("File metadata has not been extracted yet.")

        return self._metadata

    def _get_file_size(self) -> int:
        """Get the size of the uploaded file without loading it into memory.

        Returns:
            The file size in bytes.
        """
        # Access underlying file object which supports seek with whence parameter
        # Starlette's UploadFile.seek() only accepts offset, not whence
        self.file.file.seek(0, io.SEEK_END)
        size = self.file.file.tell()
        self.file.file.seek(0)
        return size

    async def _compute_checksum(self) -> str:
        """Compute SHA-1 checksum of the file using chunked reading.

        Reads the file in 64KB chunks to avoid loading large files entirely into memory.

        Returns:
            The checksum as a hex string.
        """
        await self.file.seek(0)
        hasher = hashlib.sha1(usedforsecurity=False)

        while chunk := await self.file.read(65536):
            hasher.update(chunk)

        return hasher.hexdigest()

    async def _extract_metadata(self) -> None:
        """Extract and validate file metadata without storing the file.

        Raises:
            ValidationError: If the file exceeds the maximum allowed size.
        """
        if self._metadata:
            return

        file_size = self._get_file_size()
        if file_size > config.SETTINGS.storage.max_file_size * 1024 * 1024:
            raise ValidationError(
                f"File size ({self._format_file_size(size_bytes=file_size)}) exceeds maximum allowed size "
                f"({config.SETTINGS.storage.max_file_size} MB)"
            )

        magic_bytes = await self.file.read(2048)
        file_name = self.file.filename or ""
        file_type = self._detect_mime_type(content=magic_bytes, filename=file_name or None)
        checksum = await self._compute_checksum()

        self._metadata = FileMetadata(file_name=file_name, checksum=checksum, file_size=file_size, file_type=file_type)

    def _should_store_file(self, node: Node | None) -> bool:
        """Determine if the file should be stored based on checksum comparison.

        For idempotent upsert operations, compares the uploaded file's checksum
        with the existing node's checksum. If they match, storage can be skipped.

        Args:
            node: The existing node to compare checksums with, or `None` if creating new.

        Returns:
            True if the file should be stored, False if storage can be skipped.
        """
        # Skip storage only if node exists, is a FileObject, and checksums match
        return not (
            node
            and InfrahubKind.FILEOBJECT in node.get_schema().inherit_from
            and node.checksum.value == self.metadata.checksum  # type: ignore
        )

    async def _store_file(self) -> FileUploadResult:
        """Store the file in the storage backend.

        Returns:
            FileUploadResult containing all file metadata including storage_id.
        """
        self.storage_id = str(UUIDT())
        # Update file_name to use storage_id if original was unnamed
        self.metadata.file_name = self.file.filename or self.storage_id

        await self.file.seek(0)
        registry.storage.store(identifier=self.storage_id, content=self.file.file)

        return FileUploadResult(storage_id=self.storage_id, metadata=self.metadata)

    async def process(self, node: Node | None = None) -> FileUploadResult | None:
        """Process the file upload and store it in the storage backend.

        For idempotent operations (upsert), pass the existing node to compare checksums.
        If the checksums match, storage is skipped and `None` is returned.

        Args:
            node: Optional existing node to compare checksums with.
                  If provided and checksums match, storage is skipped.

        Returns:
            FileUploadResult if the file was stored, `None` if storage was skipped.

        Raises:
            ValidationError: If the file exceeds the maximum allowed size.
        """
        await self._extract_metadata()

        if not self._should_store_file(node=node):
            return None

        return await self._store_file()

    def delete_file(self) -> None:
        """Delete the uploaded file from the storage backend."""
        if self.storage_id:
            registry.storage.delete(identifier=self.storage_id)
