import io
import mimetypes
import zipfile
from datetime import datetime
from pathlib import Path

from mmar_files.constants import SUFFIX_DIR, ResourceId
from mmar_files.file_storage_api import FileStorageAPI
from mmar_files.utils import generate_fname


def _extract_dtype(fname: str) -> str:
    if "." in fname:
        return fname.rsplit(".", 1)[-1].lower()
    return "bin"


def _is_dir_key(key: str) -> bool:
    return key.endswith(SUFFIX_DIR)


class FileStorageS3(FileStorageAPI):
    """
    A FileStorageAPI implementation using S3 (or compatible object storage).
    Fully compatible with FileStorage (file system).

    ResourceId is the key object in S3:
    - for regular files, the key is `<hash>.<ext>`;
    - for directories, it is `<hash>.dir`.

    The original filename stores in S3 metadata.
    """

    def __init__(
        self,
        bucket: str,
        region: str,
        endpoint_url: str,
        access_key: str,
        secret_key: str,
    ):
        """
        :param bucket: S3 bucket name
        :param region: S3 region
        :param endpoint_url: URL of your s3 server
        :param access_key: S3 access key
        :param secret_key: S3 password
        """

        # S3 dependencies - imported here to allow module loading without S3 packages
        try:
            import aioboto3  # noqa: autoimport
            import boto3  # noqa: autoimport
            from botocore.exceptions import ClientError  # noqa: autoimport
        except ImportError as err:
            raise ImportError(
                "S3 support requires optional dependencies. Install them with: pip install mmar-files[s3]"
            ) from err

        self.bucket = bucket
        self.session = boto3.Session(
            aws_access_key_id=access_key,
            aws_secret_access_key=secret_key,
            region_name=region,
        )
        self.client = self.session.client("s3", endpoint_url=endpoint_url)

        self.async_session = aioboto3.Session(
            aws_access_key_id=access_key,
            aws_secret_access_key=secret_key,
            region_name=region,
        )
        self.endpoint_url = endpoint_url

        # Store ClientError for use in exception handling
        self.ClientError = ClientError

    def _object_exists(self, key: str) -> bool:
        try:
            self.client.head_object(Bucket=self.bucket, Key=key)
            return True
        except self.ClientError as e:
            if e.response.get("Error", {}).get("Code", "") == "404":
                return False
            raise

    def upload_maybe(self, content: bytes | str | None, fname: str) -> ResourceId | None:
        if content is None:
            return None
        return self.upload(content, fname)

    def upload(self, content: bytes | str, fname: str, origin: str | None = None) -> ResourceId:
        if isinstance(content, str):
            content = content.encode("utf-8")

        dtype = _extract_dtype(fname)
        key = generate_fname(content, dtype)

        metadata = {
            "fname": fname,
            "update_date": datetime.now().strftime("%Y-%m-%d--%H-%M-%S"),
            "size": str(len(content)),  # S3 требует строки
            "origin": origin or "",
        }

        content_type, _ = mimetypes.guess_type(fname)
        if content_type is None:
            content_type = "application/octet-stream"

        self.client.put_object(
            Bucket=self.bucket,
            Key=key,
            Body=content,
            ContentType=content_type,
            Metadata=metadata,
        )
        return key

    def get_metadata(self, resource_id: ResourceId) -> dict[str, any] | None:
        try:
            response = self.client.head_object(
                Bucket=self.bucket,
                Key=resource_id,
            )
        except self.ClientError as e:
            if e.response.get("Error", {}).get("Code", "") == "404":
                return None
            raise

        meta: dict[str, any] = response.get("Metadata", {}).copy()
        meta["size"] = int(response["ContentLength"])
        meta["content_type"] = response.get("ContentType")
        return meta

    def get_fname(self, resource_id: ResourceId) -> str | None:
        meta = self.get_metadata(resource_id)
        if meta is None:
            return None
        return meta.get("fname")

    async def upload_async(self, content: bytes | str, fname: str) -> ResourceId:
        if isinstance(content, str):
            content = content.encode("utf-8")

        dtype = _extract_dtype(fname)
        key = generate_fname(content, dtype)
        metadata = {
            "fname": fname,
            "update_date": datetime.now().strftime("%Y-%m-%d--%H-%M-%S"),
            "size": str(len(content)),
            "origin": "",
        }
        content_type, _ = mimetypes.guess_type(fname)
        if content_type is None:
            content_type = "application/octet-stream"

        async with self.async_session.client("s3", endpoint_url=self.endpoint_url) as client:
            await client.put_object(
                Bucket=self.bucket,
                Key=key,
                Body=content,
                ContentType=content_type,
                Metadata=metadata,
            )
        return key

    def upload_dir(self, resource_ids: list[ResourceId], dir_name: str = "") -> ResourceId:
        content = "\n".join(resource_ids)
        fname = f"{dir_name}.dir" if dir_name else ".dir"
        return self.upload(content, fname)

    def download(self, resource_id: ResourceId) -> bytes:
        try:
            response = self.client.get_object(Bucket=self.bucket, Key=resource_id)
        except self.ClientError as e:
            if e.response.get("Error", {}).get("Code", "") == "NoSuchKey":
                raise FileNotFoundError(f"Resource {resource_id} not found")
            raise
        return response["Body"].read()

    async def download_async(self, resource_id: ResourceId) -> bytes:
        async with self.async_session.client("s3", endpoint_url=self.endpoint_url) as client:
            try:
                response = await client.get_object(
                    Bucket=self.bucket,
                    Key=resource_id,
                )
            except self.ClientError as e:
                if e.response.get("Error", {}).get("Code", "") == "NoSuchKey":
                    raise FileNotFoundError(f"Resource {resource_id} not found")
                raise
            return await response["Body"].read()

    def download_text(self, resource_id: ResourceId) -> str:
        return self.download(resource_id).decode("utf-8")

    def read_dir_or_none(self, resource_id: ResourceId) -> list[ResourceId] | None:
        if not self.is_dir(resource_id):
            return None
        content = self.download_text(resource_id)
        return [line for line in content.split("\n") if line]

    def get_path(self, resource_id: ResourceId | None) -> Path | None:
        # S3 have no path's
        return None

    def is_valid(self, resource_id: ResourceId | None) -> bool:
        if resource_id is None:
            return False
        return self._object_exists(resource_id)

    def is_file(self, resource_id: ResourceId | None) -> bool:
        if resource_id is None:
            return False
        return self._object_exists(resource_id) and not _is_dir_key(resource_id)

    def is_dir(self, resource_id: ResourceId | None) -> bool:
        if resource_id is None:
            return False
        return self._object_exists(resource_id) and _is_dir_key(resource_id)

    def get_dtype(self, resource_id: ResourceId | None) -> str | None:
        if resource_id is None:
            return None
        if "." in resource_id:
            return resource_id.rsplit(".", 1)[-1].lower()
        return None

    def unzip_file(self, resource_id: str) -> ResourceId:
        zip_data = self.download(resource_id)

        resource_ids = []
        with zipfile.ZipFile(io.BytesIO(zip_data)) as zf:
            for file_info in zf.infolist():
                if file_info.is_dir():
                    continue
                fname = file_info.filename.split("/")[-1]
                if not fname:
                    continue
                content = zf.read(file_info)
                rid = self.upload(content, fname)
                resource_ids.append(rid)

        dir_rid = self.upload_dir(resource_ids, dir_name="unzipped")
        return dir_rid
