from __future__ import annotations

from dataclasses import dataclass, field
from heapq import nsmallest
import os
from pathlib import Path
import tempfile
from typing import Any, Iterable, Protocol
from urllib.parse import urlsplit

from govoplan_core.security.outbound_http import (
    OutboundHttpError,
    response_limit,
    validate_unpinned_sdk_http_url,
)


class StorageBackendError(RuntimeError):
    """Base error for the deployment-owned object-storage boundary."""


class StorageObjectMissing(StorageBackendError):
    """Raised when a referenced object no longer exists."""


@dataclass(frozen=True, slots=True)
class StorageObjectInfo:
    key: str
    size_bytes: int


@dataclass(frozen=True, slots=True)
class StorageObjectPage:
    objects: tuple[StorageObjectInfo, ...]
    next_cursor: str | None = None


class StorageBackend(Protocol):
    """Shared byte-object storage used by modules without cross-module imports."""

    name: str

    def put_bytes(
        self,
        key: str,
        data: bytes,
        *,
        content_type: str | None = None,
    ) -> None: ...

    def get_bytes(self, key: str) -> bytes: ...

    def iter_bytes(
        self,
        key: str,
        *,
        chunk_size: int = 1024 * 1024,
    ) -> Iterable[bytes]: ...

    def delete(self, key: str) -> None: ...

    def exists(self, key: str) -> bool: ...

    def stat(self, key: str) -> StorageObjectInfo: ...

    def list_objects(
        self,
        *,
        prefix: str,
        after: str | None = None,
        limit: int = 500,
    ) -> StorageObjectPage: ...


@dataclass(slots=True)
class LocalFilesystemStorageBackend:
    root: Path
    fallback_roots: tuple[Path, ...] = field(default_factory=tuple)
    name: str = "local"

    def __post_init__(self) -> None:
        self.root = self.root.expanduser().resolve()
        self.fallback_roots = tuple(
            root.expanduser().resolve() for root in self.fallback_roots if root
        )
        self.root.mkdir(parents=True, exist_ok=True)

    def _path_for_root(self, root: Path, key: str) -> Path:
        normalized = normalize_storage_key(key)
        path = (root / normalized).resolve()
        if not path.is_relative_to(root):
            raise StorageBackendError("Storage key escapes local storage root")
        return path

    def _path(self, key: str) -> Path:
        return self._path_for_root(self.root, key)

    def _readable_path(self, key: str) -> Path:
        primary = self._path(key)
        if primary.exists() and primary.is_file():
            return primary
        for root in self.fallback_roots:
            candidate = self._path_for_root(root, key)
            if candidate.exists() and candidate.is_file():
                return candidate
        raise StorageObjectMissing("Stored object does not exist")

    def put_bytes(
        self,
        key: str,
        data: bytes,
        *,
        content_type: str | None = None,
    ) -> None:
        del content_type
        path = self._path(key)
        path.parent.mkdir(parents=True, exist_ok=True)
        descriptor, temporary_name = tempfile.mkstemp(
            prefix=f".{path.name}.",
            suffix=".tmp",
            dir=path.parent,
        )
        temporary = Path(temporary_name)
        try:
            os.fchmod(descriptor, 0o600)
            with os.fdopen(descriptor, "wb") as stream:
                descriptor = -1
                stream.write(data)
                stream.flush()
                os.fsync(stream.fileno())
            temporary.replace(path)
        finally:
            if descriptor >= 0:
                os.close(descriptor)
            temporary.unlink(missing_ok=True)

    def get_bytes(self, key: str) -> bytes:
        return self._readable_path(key).read_bytes()

    def iter_bytes(
        self,
        key: str,
        *,
        chunk_size: int = 1024 * 1024,
    ) -> Iterable[bytes]:
        path = self._readable_path(key)
        with path.open("rb") as handle:
            while True:
                chunk = handle.read(chunk_size)
                if not chunk:
                    break
                yield chunk

    def delete(self, key: str) -> None:
        path = self._path(key)
        if path.exists() and path.is_file():
            path.unlink()

    def exists(self, key: str) -> bool:
        try:
            self._readable_path(key)
        except StorageObjectMissing:
            return False
        return True

    def stat(self, key: str) -> StorageObjectInfo:
        path = self._readable_path(key)
        return StorageObjectInfo(
            key=normalize_storage_key(key),
            size_bytes=path.stat().st_size,
        )

    def list_objects(
        self,
        *,
        prefix: str,
        after: str | None = None,
        limit: int = 500,
    ) -> StorageObjectPage:
        normalized_prefix = normalize_storage_prefix(prefix)
        normalized_after = normalize_storage_key(after) if after else None
        normalized_limit = max(1, min(int(limit), 5000))

        def matching_objects() -> Iterable[StorageObjectInfo]:
            for path in _iter_local_files(self.root):
                key = path.relative_to(self.root).as_posix()
                if not key.startswith(normalized_prefix) or (
                    normalized_after is not None and key <= normalized_after
                ):
                    continue
                yield StorageObjectInfo(
                    key=key,
                    size_bytes=path.stat().st_size,
                )

        candidates = nsmallest(
            normalized_limit + 1,
            matching_objects(),
            key=lambda item: item.key,
        )
        has_more = len(candidates) > normalized_limit
        page = tuple(candidates[:normalized_limit])
        return StorageObjectPage(
            objects=page,
            next_cursor=page[-1].key if has_more and page else None,
        )


@dataclass(slots=True)
class S3StorageBackend:
    bucket: str
    endpoint_url: str
    region_name: str
    access_key_id: str
    secret_access_key: str
    deployment_managed: bool = False
    endpoint_trusted: bool = False
    name: str = "s3"
    _client: Any = field(default=None, init=False, repr=False)

    @property
    def client(self):
        if self._client is not None:
            return self._client
        if self.deployment_managed:
            endpoint_url = _deployment_managed_garage_endpoint(self.endpoint_url)
        elif self.endpoint_trusted:
            endpoint_url = _trusted_deployment_endpoint(self.endpoint_url)
        else:
            try:
                endpoint_url = validate_unpinned_sdk_http_url(
                    self.endpoint_url,
                    label="Object storage S3 endpoint",
                )
            except OutboundHttpError as exc:
                raise StorageBackendError(str(exc)) from exc
        try:
            import boto3
            from botocore.config import Config
        except ModuleNotFoundError as exc:
            raise StorageBackendError(
                "boto3 is required for the S3 storage backend"
            ) from exc
        options: dict[str, object] = {
            "endpoint_url": endpoint_url,
            "region_name": self.region_name,
            "aws_access_key_id": self.access_key_id,
            "aws_secret_access_key": self.secret_access_key,
        }
        if self.deployment_managed:
            options["config"] = Config(s3={"addressing_style": "path"})
        self._client = boto3.client("s3", **options)
        return self._client

    def put_bytes(
        self,
        key: str,
        data: bytes,
        *,
        content_type: str | None = None,
    ) -> None:
        normalized = normalize_storage_key(key)
        max_bytes = response_limit("file")
        if len(data) > max_bytes:
            raise StorageBackendError(
                f"Stored object exceeds the deployment limit of {max_bytes} bytes"
            )
        kwargs: dict[str, object] = {
            "Bucket": self.bucket,
            "Key": normalized,
            "Body": data,
        }
        if content_type:
            kwargs["ContentType"] = content_type
        try:
            self.client.put_object(**kwargs)
        except Exception as exc:  # pragma: no cover - depends on S3 backend
            raise StorageBackendError(str(exc)) from exc

    def get_bytes(self, key: str) -> bytes:
        normalized = normalize_storage_key(key)
        try:
            obj = self.client.get_object(
                Bucket=self.bucket,
                Key=normalized,
            )
            max_bytes = response_limit("file")
            body = obj["Body"]
            try:
                _reject_declared_object_size(obj, max_bytes=max_bytes)
                data = body.read(max_bytes + 1)
                if len(data) > max_bytes:
                    raise StorageBackendError(
                        "Stored object exceeds the deployment limit of "
                        f"{max_bytes} bytes"
                    )
                return data
            finally:
                if hasattr(body, "close"):
                    body.close()
        except StorageBackendError:
            raise
        except Exception as exc:  # pragma: no cover - depends on S3 backend
            if _s3_missing_error(exc):
                raise StorageObjectMissing("Stored object does not exist") from exc
            raise StorageBackendError(str(exc)) from exc

    def iter_bytes(
        self,
        key: str,
        *,
        chunk_size: int = 1024 * 1024,
    ) -> Iterable[bytes]:
        normalized = normalize_storage_key(key)
        try:
            obj = self.client.get_object(
                Bucket=self.bucket,
                Key=normalized,
            )
            max_bytes = response_limit("file")
            body = obj["Body"]
            try:
                _reject_declared_object_size(obj, max_bytes=max_bytes)
                total = 0
                while True:
                    chunk = body.read(chunk_size)
                    if not chunk:
                        break
                    total += len(chunk)
                    if total > max_bytes:
                        raise StorageBackendError(
                            "Stored object exceeds the deployment limit of "
                            f"{max_bytes} bytes"
                        )
                    yield chunk
            finally:
                if hasattr(body, "close"):
                    body.close()
        except StorageBackendError:
            raise
        except Exception as exc:  # pragma: no cover - depends on S3 backend
            if _s3_missing_error(exc):
                raise StorageObjectMissing("Stored object does not exist") from exc
            raise StorageBackendError(str(exc)) from exc

    def delete(self, key: str) -> None:
        try:
            self.client.delete_object(
                Bucket=self.bucket,
                Key=normalize_storage_key(key),
            )
        except Exception as exc:  # pragma: no cover - depends on S3 backend
            raise StorageBackendError(str(exc)) from exc

    def exists(self, key: str) -> bool:
        try:
            self.client.head_object(
                Bucket=self.bucket,
                Key=normalize_storage_key(key),
            )
            return True
        except Exception as exc:
            if _s3_missing_error(exc):
                return False
            raise StorageBackendError(str(exc)) from exc

    def stat(self, key: str) -> StorageObjectInfo:
        normalized = normalize_storage_key(key)
        try:
            response = self.client.head_object(
                Bucket=self.bucket,
                Key=normalized,
            )
        except Exception as exc:  # pragma: no cover - depends on S3 backend
            if _s3_missing_error(exc):
                raise StorageObjectMissing("Stored object does not exist") from exc
            raise StorageBackendError(str(exc)) from exc
        try:
            size = int(response.get("ContentLength"))
        except (AttributeError, TypeError, ValueError) as exc:
            raise StorageBackendError(
                "S3 object metadata did not include a valid size"
            ) from exc
        return StorageObjectInfo(key=normalized, size_bytes=size)

    def list_objects(
        self,
        *,
        prefix: str,
        after: str | None = None,
        limit: int = 500,
    ) -> StorageObjectPage:
        normalized_prefix = normalize_storage_prefix(prefix)
        normalized_limit = max(1, min(int(limit), 1000))
        kwargs: dict[str, object] = {
            "Bucket": self.bucket,
            "Prefix": normalized_prefix,
            "MaxKeys": normalized_limit,
        }
        if after:
            kwargs["StartAfter"] = normalize_storage_key(after)
        try:
            response = self.client.list_objects_v2(**kwargs)
        except Exception as exc:  # pragma: no cover - depends on S3 backend
            raise StorageBackendError(str(exc)) from exc
        objects = tuple(
            StorageObjectInfo(
                key=str(item["Key"]),
                size_bytes=int(item.get("Size") or 0),
            )
            for item in response.get("Contents", ())
            if isinstance(item, dict) and item.get("Key")
        )
        has_more = bool(response.get("IsTruncated"))
        return StorageObjectPage(
            objects=objects,
            next_cursor=objects[-1].key if has_more and objects else None,
        )


def configured_storage_backend(settings: object) -> StorageBackend:
    """Build the deployment-wide object store from Core settings.

    Modules own their metadata and key namespaces. The deployment owns the
    storage endpoint and credentials, so modules do not need to depend on the
    Files package merely to persist opaque generated bytes.
    """

    configured = (
        str(getattr(settings, "file_storage_backend", "local") or "local")
        .strip()
        .lower()
    )
    if configured in {"local", "filesystem", "fs"}:
        raw_fallbacks = str(
            getattr(settings, "file_storage_local_fallback_roots", "") or ""
        )
        return LocalFilesystemStorageBackend(
            Path(
                str(
                    getattr(
                        settings,
                        "file_storage_local_root",
                        "runtime/files",
                    )
                )
            ),
            fallback_roots=tuple(
                Path(item.strip()) for item in raw_fallbacks.split(",") if item.strip()
            ),
        )
    if configured in {"s3", "garage"}:
        return S3StorageBackend(
            bucket=str(
                getattr(settings, "file_storage_s3_bucket", None)
                or getattr(settings, "s3_bucket", "files")
            ),
            endpoint_url=str(
                getattr(settings, "file_storage_s3_endpoint_url", None)
                or getattr(settings, "s3_endpoint_url", "")
            ),
            region_name=str(
                getattr(settings, "file_storage_s3_region", None)
                or getattr(settings, "s3_region", "")
            ),
            access_key_id=str(
                getattr(settings, "file_storage_s3_access_key_id", None)
                or getattr(settings, "s3_access_key_id", "")
            ),
            secret_access_key=str(
                getattr(
                    settings,
                    "file_storage_s3_secret_access_key",
                    None,
                )
                or getattr(settings, "s3_secret_access_key", "")
            ),
            deployment_managed=bool(
                getattr(
                    settings,
                    "file_storage_s3_deployment_managed",
                    False,
                )
            ),
            endpoint_trusted=bool(
                getattr(
                    settings,
                    "file_storage_s3_endpoint_trusted",
                    False,
                )
            ),
        )
    raise StorageBackendError(f"Unsupported object storage backend: {configured}")


def normalize_storage_key(value: str) -> str:
    candidate = str(value or "").strip().replace("\\", "/")
    parts = candidate.split("/")
    if (
        not candidate
        or candidate.startswith("/")
        or any(part in {"", ".", ".."} for part in parts)
        or any(ord(character) < 32 for character in candidate)
    ):
        raise StorageBackendError("Storage key is not a safe relative key")
    return "/".join(parts)


def normalize_storage_prefix(value: str) -> str:
    candidate = str(value or "").strip().replace("\\", "/")
    if not candidate:
        return ""
    trailing_slash = candidate.endswith("/")
    normalized = normalize_storage_key(candidate.rstrip("/"))
    return normalized + ("/" if trailing_slash else "")


def _reject_declared_object_size(obj: object, *, max_bytes: int) -> None:
    if not isinstance(obj, dict):
        return
    try:
        declared_size = int(obj.get("ContentLength"))
    except (TypeError, ValueError):
        return
    if declared_size > max_bytes:
        raise StorageBackendError(
            f"Stored object exceeds the deployment limit of {max_bytes} bytes"
        )


def _iter_local_files(root: Path):
    for entry in sorted(root.iterdir(), key=lambda item: item.name):
        if entry.is_symlink():
            continue
        if entry.is_dir():
            yield from _iter_local_files(entry)
        elif entry.is_file():
            yield entry


def _s3_missing_error(exc: Exception) -> bool:
    response = getattr(exc, "response", None)
    if not isinstance(response, dict):
        return False
    error = response.get("Error")
    metadata = response.get("ResponseMetadata")
    code = str(error.get("Code") if isinstance(error, dict) else "")
    status_code = metadata.get("HTTPStatusCode") if isinstance(metadata, dict) else None
    return code in {"404", "NoSuchKey", "NotFound"} or status_code == 404


def _deployment_managed_garage_endpoint(value: str) -> str:
    endpoint = str(value or "").strip()
    if endpoint != "http://garage:3900":
        raise StorageBackendError(
            "Deployment-managed S3 trust is restricted to http://garage:3900"
        )
    return endpoint


def _trusted_deployment_endpoint(value: str) -> str:
    endpoint = str(value or "").strip()
    parsed = urlsplit(endpoint)
    try:
        parsed.port
    except ValueError as exc:
        raise StorageBackendError(
            "Deployment-trusted S3 endpoint has an invalid port"
        ) from exc
    if (
        parsed.scheme.lower() != "https"
        or not parsed.hostname
        or parsed.username
        or parsed.password
        or parsed.query
        or parsed.fragment
        or parsed.path not in {"", "/"}
    ):
        raise StorageBackendError(
            "Deployment-trusted S3 endpoint must be an HTTPS origin without "
            "credentials, query, fragment, or path"
        )
    return endpoint.rstrip("/")


__all__ = [
    "LocalFilesystemStorageBackend",
    "S3StorageBackend",
    "StorageBackend",
    "StorageBackendError",
    "StorageObjectInfo",
    "StorageObjectMissing",
    "StorageObjectPage",
    "configured_storage_backend",
    "normalize_storage_key",
    "normalize_storage_prefix",
]
