from __future__ import annotations

from io import BytesIO
from pathlib import Path
import tempfile
import unittest

from govoplan_core.core.object_storage import (
    LocalFilesystemStorageBackend,
    S3StorageBackend,
    StorageBackendError,
    StorageObjectMissing,
    normalize_storage_key,
)


class _S3Error(RuntimeError):
    def __init__(self, code: str, status: int) -> None:
        super().__init__(code)
        self.response = {
            "Error": {"Code": code},
            "ResponseMetadata": {"HTTPStatusCode": status},
        }


class _FakeS3Client:
    def __init__(self) -> None:
        self.objects: dict[str, bytes] = {}
        self.head_error: Exception | None = None

    def put_object(self, *, Key: str, Body: bytes, **_kwargs) -> None:
        self.objects[Key] = Body

    def get_object(self, *, Key: str, **_kwargs):
        try:
            payload = self.objects[Key]
        except KeyError as exc:
            raise _S3Error("NoSuchKey", 404) from exc
        return {"Body": BytesIO(payload), "ContentLength": len(payload)}

    def head_object(self, *, Key: str, **_kwargs):
        if self.head_error is not None:
            raise self.head_error
        try:
            payload = self.objects[Key]
        except KeyError as exc:
            raise _S3Error("NotFound", 404) from exc
        return {"ContentLength": len(payload)}

    def delete_object(self, *, Key: str, **_kwargs) -> None:
        self.objects.pop(Key, None)


class ObjectStorageTests(unittest.TestCase):
    def test_local_backend_round_trip_pagination_and_safe_keys(self) -> None:
        with tempfile.TemporaryDirectory(prefix="govoplan-object-store-") as directory:
            backend = LocalFilesystemStorageBackend(Path(directory))
            backend.put_bytes("campaign/a.eml", b"a")
            backend.put_bytes("campaign/b.eml", b"bb")

            first = backend.list_objects(prefix="campaign/", limit=1)
            second = backend.list_objects(
                prefix="campaign/",
                after=first.next_cursor,
                limit=1,
            )

            self.assertEqual(b"a", backend.get_bytes("campaign/a.eml"))
            self.assertEqual(1, backend.stat("campaign/a.eml").size_bytes)
            self.assertEqual(
                ("campaign/a.eml",), tuple(item.key for item in first.objects)
            )
            self.assertEqual("campaign/a.eml", first.next_cursor)
            self.assertEqual(
                ("campaign/b.eml",), tuple(item.key for item in second.objects)
            )
            self.assertIsNone(second.next_cursor)
            with self.assertRaises(StorageBackendError):
                normalize_storage_key("../outside")
            with self.assertRaises(StorageObjectMissing):
                backend.get_bytes("campaign/missing.eml")

    def test_local_listing_is_globally_sorted_and_ignores_symlinks(self) -> None:
        with (
            tempfile.TemporaryDirectory(prefix="govoplan-object-store-") as directory,
            tempfile.TemporaryDirectory(prefix="govoplan-object-outside-") as outside,
        ):
            root = Path(directory)
            backend = LocalFilesystemStorageBackend(root)
            backend.put_bytes("a/x.txt", b"nested")
            backend.put_bytes("a.txt", b"sibling")
            backend.put_bytes("b.txt", b"last")
            outside_file = Path(outside) / "secret.txt"
            outside_file.write_bytes(b"outside")
            (root / "linked-file.txt").symlink_to(outside_file)
            (root / "linked-directory").symlink_to(
                Path(outside), target_is_directory=True
            )

            first = backend.list_objects(prefix="", limit=2)
            second = backend.list_objects(
                prefix="",
                after=first.next_cursor,
                limit=2,
            )

            self.assertEqual(
                ("a.txt", "a/x.txt"),
                tuple(item.key for item in first.objects),
            )
            self.assertEqual("a/x.txt", first.next_cursor)
            self.assertEqual(
                ("b.txt",),
                tuple(item.key for item in second.objects),
            )
            self.assertIsNone(second.next_cursor)
            with self.assertRaises(StorageBackendError):
                backend.exists("../outside")

    def test_s3_backend_distinguishes_missing_objects_from_backend_failure(
        self,
    ) -> None:
        backend = S3StorageBackend(
            bucket="files",
            endpoint_url="http://garage:3900",
            region_name="garage",
            access_key_id="key",
            secret_access_key="secret",
            deployment_managed=True,
        )
        client = _FakeS3Client()
        backend._client = client

        backend.put_bytes("campaign/message.eml", b"message/rfc822")
        self.assertTrue(backend.exists("campaign/message.eml"))
        self.assertEqual(b"message/rfc822", backend.get_bytes("campaign/message.eml"))
        self.assertFalse(backend.exists("campaign/missing.eml"))

        client.head_error = _S3Error("AccessDenied", 403)
        with self.assertRaises(StorageBackendError):
            backend.exists("campaign/message.eml")

    def test_external_s3_requires_explicit_https_deployment_trust(self) -> None:
        trusted = S3StorageBackend(
            bucket="files",
            endpoint_url="https://s3.internal.example.test",
            region_name="eu-test-1",
            access_key_id="key",
            secret_access_key="secret",
            endpoint_trusted=True,
        )
        trusted._client = _FakeS3Client()
        trusted.put_bytes("files/document.txt", b"document")
        self.assertEqual(b"document", trusted.get_bytes("files/document.txt"))

        rejected = S3StorageBackend(
            bucket="files",
            endpoint_url="http://s3.internal.example.test",
            region_name="eu-test-1",
            access_key_id="key",
            secret_access_key="secret",
            endpoint_trusted=True,
        )
        with self.assertRaisesRegex(StorageBackendError, "HTTPS origin"):
            rejected.client


if __name__ == "__main__":
    unittest.main()
