from moto import mock_aws
from zodb_s3blobs.interfaces import IS3Client
from zodb_s3blobs.s3client import S3Client

import base64
import boto3
import os
import pytest
import stat


@pytest.fixture
def s3_env():
    with mock_aws():
        boto3.client("s3", region_name="us-east-1").create_bucket(Bucket="test-bucket")
        yield


@pytest.fixture
def client(s3_env):
    return S3Client(bucket_name="test-bucket", region_name="us-east-1")


@pytest.fixture
def prefixed_client(s3_env):
    return S3Client(
        bucket_name="test-bucket", prefix="myprefix", region_name="us-east-1"
    )


class TestS3ClientInterface:
    def test_interface_provided(self, client):
        assert IS3Client.providedBy(client)


class TestUploadDownload:
    def test_upload_and_download_roundtrip(self, client, tmp_path):
        src = tmp_path / "source.bin"
        src.write_bytes(b"hello blob data")

        client.upload_file(str(src), "test/key.blob")

        dst = tmp_path / "downloaded.bin"
        client.download_file("test/key.blob", str(dst))

        assert dst.read_bytes() == b"hello blob data"

    def test_download_atomic_rename(self, client, tmp_path):
        """Download should write to .tmp first, then rename."""
        src = tmp_path / "source.bin"
        src.write_bytes(b"atomic test")
        client.upload_file(str(src), "atomic/key.blob")

        dst = tmp_path / "final.bin"
        client.download_file("atomic/key.blob", str(dst))

        # Final file exists, temp file does not
        assert dst.exists()
        assert not (tmp_path / "final.bin.tmp").exists()

    def test_download_large_file(self, client, tmp_path):
        """Test download of a file larger than typical buffer sizes."""
        src = tmp_path / "large.bin"
        data = b"x" * (1024 * 1024)  # 1MB
        src.write_bytes(data)
        client.upload_file(str(src), "large/key.blob")

        dst = tmp_path / "large_dl.bin"
        client.download_file("large/key.blob", str(dst))
        assert dst.read_bytes() == data


class TestDeleteObject:
    def test_delete_object(self, client, tmp_path):
        src = tmp_path / "to_delete.bin"
        src.write_bytes(b"delete me")
        client.upload_file(str(src), "del/key.blob")

        assert client.head_object("del/key.blob") is not None
        client.delete_object("del/key.blob")
        assert client.head_object("del/key.blob") is None

    def test_delete_nonexistent_does_not_raise(self, client):
        """Deleting a non-existent key should not raise."""
        client.delete_object("nonexistent/key.blob")


class TestHeadObject:
    def test_head_object_exists(self, client, tmp_path):
        src = tmp_path / "head.bin"
        src.write_bytes(b"head test")
        client.upload_file(str(src), "head/key.blob")

        result = client.head_object("head/key.blob")
        assert result is not None
        assert "ContentLength" in result
        assert result["ContentLength"] == 9

    def test_head_object_missing(self, client):
        result = client.head_object("missing/key.blob")
        assert result is None


class TestListObjects:
    def test_list_objects(self, client, tmp_path):
        src = tmp_path / "list.bin"
        src.write_bytes(b"list test")
        for i in range(3):
            client.upload_file(str(src), f"list/{i}.blob")

        keys = list(client.list_objects("list/"))
        assert len(keys) == 3
        assert set(keys) == {"list/0.blob", "list/1.blob", "list/2.blob"}

    def test_list_objects_empty(self, client):
        keys = list(client.list_objects("nonexistent/"))
        assert keys == []


class TestPrefix:
    def test_prefix_applied_to_upload(self, prefixed_client, tmp_path):
        src = tmp_path / "prefixed.bin"
        src.write_bytes(b"prefixed data")
        prefixed_client.upload_file(str(src), "blobs/oid/tid.blob")

        # Should be stored under myprefix/blobs/oid/tid.blob
        result = prefixed_client.head_object("blobs/oid/tid.blob")
        assert result is not None

    def test_prefix_applied_to_list(self, prefixed_client, tmp_path):
        src = tmp_path / "prefixed.bin"
        src.write_bytes(b"prefixed data")
        prefixed_client.upload_file(str(src), "blobs/a.blob")
        prefixed_client.upload_file(str(src), "blobs/b.blob")

        keys = list(prefixed_client.list_objects("blobs/"))
        assert len(keys) == 2

    def test_prefix_isolation(self, s3_env, tmp_path):
        """Two clients with different prefixes don't see each other's data."""
        client_a = S3Client(
            bucket_name="test-bucket", prefix="ns_a", region_name="us-east-1"
        )
        client_b = S3Client(
            bucket_name="test-bucket", prefix="ns_b", region_name="us-east-1"
        )

        src = tmp_path / "iso.bin"
        src.write_bytes(b"isolation test")
        client_a.upload_file(str(src), "key.blob")

        assert client_a.head_object("key.blob") is not None
        assert client_b.head_object("key.blob") is None

    def test_no_prefix(self, client, tmp_path):
        """Client with no prefix stores keys as-is."""
        src = tmp_path / "no_prefix.bin"
        src.write_bytes(b"no prefix")
        client.upload_file(str(src), "raw/key.blob")

        # Verify via raw boto3 that the key is exactly "raw/key.blob"
        s3 = boto3.client("s3", region_name="us-east-1")
        resp = s3.list_objects_v2(Bucket="test-bucket", Prefix="raw/")
        keys = [obj["Key"] for obj in resp.get("Contents", [])]
        assert "raw/key.blob" in keys


SSE_KEY = base64.b64encode(os.urandom(32)).decode()


class TestSSEC:
    @pytest.fixture
    def sse_client(self, s3_env):
        return S3Client(
            bucket_name="test-bucket",
            region_name="us-east-1",
            sse_customer_key=SSE_KEY,
        )

    def test_upload_download_roundtrip(self, sse_client, tmp_path):
        src = tmp_path / "secret.bin"
        src.write_bytes(b"encrypted blob data")
        sse_client.upload_file(str(src), "enc/key.blob")

        dst = tmp_path / "decrypted.bin"
        sse_client.download_file("enc/key.blob", str(dst))
        assert dst.read_bytes() == b"encrypted blob data"

    def test_head_object(self, sse_client, tmp_path):
        src = tmp_path / "head_enc.bin"
        src.write_bytes(b"head test enc")
        sse_client.upload_file(str(src), "enc/head.blob")

        result = sse_client.head_object("enc/head.blob")
        assert result is not None
        assert result["ContentLength"] == 13

    def test_delete_object(self, sse_client, tmp_path):
        src = tmp_path / "del_enc.bin"
        src.write_bytes(b"delete me enc")
        sse_client.upload_file(str(src), "enc/del.blob")

        sse_client.delete_object("enc/del.blob")
        assert sse_client.head_object("enc/del.blob") is None

    def test_list_objects(self, sse_client, tmp_path):
        src = tmp_path / "list_enc.bin"
        src.write_bytes(b"list test")
        sse_client.upload_file(str(src), "enc/a.blob")
        sse_client.upload_file(str(src), "enc/b.blob")

        keys = list(sse_client.list_objects("enc/"))
        assert set(keys) == {"enc/a.blob", "enc/b.blob"}

    def test_ssl_required(self, s3_env):
        with pytest.raises(ValueError, match="SSE-C requires SSL"):
            S3Client(
                bucket_name="test-bucket",
                region_name="us-east-1",
                sse_customer_key=SSE_KEY,
                use_ssl=False,
            )

    def test_invalid_key_length(self, s3_env):
        short_key = base64.b64encode(b"too short").decode()
        with pytest.raises(ValueError, match="32 bytes"):
            S3Client(
                bucket_name="test-bucket",
                region_name="us-east-1",
                sse_customer_key=short_key,
            )


class TestPrefixValidation:
    def test_valid_prefix_accepted(self, s3_env):
        client = S3Client(
            bucket_name="test-bucket",
            prefix="my-prefix_v2/sub",
            region_name="us-east-1",
        )
        assert client._prefix == "my-prefix_v2/sub"

    def test_valid_prefix_with_dots(self, s3_env):
        client = S3Client(
            bucket_name="test-bucket", prefix="my.prefix", region_name="us-east-1"
        )
        assert client._prefix == "my.prefix"

    def test_empty_prefix_accepted(self, s3_env):
        client = S3Client(bucket_name="test-bucket", prefix="", region_name="us-east-1")
        assert client._prefix == ""

    def test_invalid_prefix_with_spaces(self, s3_env):
        with pytest.raises(ValueError, match="invalid characters"):
            S3Client(
                bucket_name="test-bucket", prefix="bad prefix", region_name="us-east-1"
            )

    def test_prefix_with_null_bytes_rejected(self, s3_env):
        with pytest.raises(ValueError, match="invalid characters"):
            S3Client(
                bucket_name="test-bucket",
                prefix="bad\x00prefix",
                region_name="us-east-1",
            )

    def test_prefix_with_dotdot_rejected(self, s3_env):
        with pytest.raises(ValueError, match="must not contain"):
            S3Client(
                bucket_name="test-bucket",
                prefix="path/../escape",
                region_name="us-east-1",
            )


class TestDownloadDirPermissions:
    def test_download_creates_dir_with_restricted_mode(self, client, tmp_path):
        src = tmp_path / "source.bin"
        src.write_bytes(b"perm test")
        client.upload_file(str(src), "perm/key.blob")

        new_dir = tmp_path / "restricted_dir"
        dst = new_dir / "downloaded.bin"
        client.download_file("perm/key.blob", str(dst))

        mode = stat.S_IMODE(os.stat(str(new_dir)).st_mode)
        assert mode == 0o700


class TestS3OperationError:
    def test_error_type_is_importable(self):
        from zodb_s3blobs.s3client import S3OperationError

        assert issubclass(S3OperationError, Exception)
