"""Tests for S3 storage backend."""

import json
from datetime import UTC, datetime, timedelta
from unittest.mock import MagicMock, patch

import pytest

from graftpunk.exceptions import SessionNotFoundError, StorageError

pytest.importorskip("boto3")

from graftpunk.storage.base import SessionMetadata


@pytest.fixture
def sample_metadata():
    """Create sample session metadata."""
    now = datetime.now(UTC)
    return SessionMetadata(
        name="test-session",
        checksum="abc123",
        created_at=now,
        modified_at=now,
        expires_at=now + timedelta(hours=24),
        domain="example.com",
        current_url="https://example.com/dashboard",
        cookie_count=5,
        cookie_domains=["example.com", ".example.com"],
        status="active",
    )


@pytest.fixture
def mock_s3_client():
    """Create a mock boto3 S3 client."""
    return MagicMock()


@pytest.fixture
def storage(mock_s3_client):
    """Create S3SessionStorage with mocked client."""
    from graftpunk.storage.s3 import S3SessionStorage

    return S3SessionStorage(
        bucket="test-bucket",
        region="us-east-1",
        endpoint_url="https://test.r2.example.com",
        client=mock_s3_client,
    )


class TestS3SessionStorageInit:
    """Tests for S3SessionStorage initialization."""

    def test_storage_initialization_with_injected_client(self, mock_s3_client):
        """Test that storage initializes correctly with injected client."""
        from graftpunk.storage.s3 import S3SessionStorage

        storage = S3SessionStorage(
            bucket="test-bucket",
            endpoint_url="https://test.r2.example.com",
            client=mock_s3_client,
        )
        assert storage.bucket == "test-bucket"
        assert storage.endpoint_url == "https://test.r2.example.com"

    @patch("boto3.client")
    def test_storage_creates_client_when_not_injected(self, mock_boto_client):
        """Test that storage creates boto3 client when not injected."""
        from graftpunk.storage.s3 import S3SessionStorage

        mock_boto_client.return_value = MagicMock()
        storage = S3SessionStorage(
            bucket="test-bucket",
            region="us-west-2",
            endpoint_url="https://r2.example.com",
        )
        mock_boto_client.assert_called_once_with(
            "s3",
            region_name="us-west-2",
            endpoint_url="https://r2.example.com",
        )
        assert storage.bucket == "test-bucket"

    @patch("boto3.client")
    def test_storage_skips_region_when_auto(self, mock_boto_client):
        """Test that region='auto' is treated as no region (for R2)."""
        from graftpunk.storage.s3 import S3SessionStorage

        mock_boto_client.return_value = MagicMock()
        S3SessionStorage(
            bucket="test-bucket",
            region="auto",
            endpoint_url="https://r2.example.com",
        )
        mock_boto_client.assert_called_once_with(
            "s3",
            endpoint_url="https://r2.example.com",
        )

    def test_boto3_import_error_raises_storage_error(self):
        """Test that missing boto3 raises StorageError with helpful message."""
        import sys

        # Temporarily hide boto3 to simulate ImportError
        boto3_backup = sys.modules.get("boto3")
        sys.modules["boto3"] = None  # type: ignore[assignment]

        try:
            # Reload the s3 module to trigger import error
            from importlib import reload

            import graftpunk.storage.s3 as s3_module

            # Force reload to clear cached import
            reload(s3_module)

            with pytest.raises(StorageError, match="boto3 is required"):
                s3_module.S3SessionStorage(bucket="test")
        finally:
            # Restore boto3
            if boto3_backup is not None:
                sys.modules["boto3"] = boto3_backup
            else:
                sys.modules.pop("boto3", None)


class TestSaveSession:
    """Tests for save_session method."""

    def test_save_session_uploads_pickle_and_metadata(
        self, storage, mock_s3_client, sample_metadata
    ):
        """Test that save uploads both pickle and metadata files."""
        encrypted_data = b"encrypted-pickle-data"

        location = storage.save_session("test-session", encrypted_data, sample_metadata)

        assert location == "s3://test-bucket/sessions/test-session/session.pickle"
        assert mock_s3_client.put_object.call_count == 2

        # Verify pickle upload
        pickle_call = mock_s3_client.put_object.call_args_list[0]
        assert pickle_call.kwargs["Bucket"] == "test-bucket"
        assert pickle_call.kwargs["Key"] == "sessions/test-session/session.pickle"
        assert pickle_call.kwargs["Body"] == encrypted_data
        assert pickle_call.kwargs["ContentType"] == "application/octet-stream"

        # Verify metadata upload
        metadata_call = mock_s3_client.put_object.call_args_list[1]
        assert metadata_call.kwargs["Key"] == "sessions/test-session/metadata.json"
        assert metadata_call.kwargs["ContentType"] == "application/json"


class TestLoadSession:
    """Tests for load_session method."""

    def test_load_session_success(self, storage, mock_s3_client, sample_metadata):
        """Test successful session load."""
        metadata_dict = {
            "name": "test-session",
            "checksum": "abc123",
            "created_at": sample_metadata.created_at.isoformat(),
            "modified_at": sample_metadata.modified_at.isoformat(),
            "expires_at": sample_metadata.expires_at.isoformat(),
            "domain": "example.com",
            "current_url": "https://example.com/dashboard",
            "cookie_count": 5,
            "cookie_domains": ["example.com", ".example.com"],
            "status": "active",
        }
        mock_metadata_body = MagicMock()
        mock_metadata_body.read.return_value = json.dumps(metadata_dict).encode()

        mock_session_body = MagicMock()
        mock_session_body.read.return_value = b"encrypted-data"

        def get_object_side_effect(**kwargs):
            if kwargs["Key"].endswith("metadata.json"):
                return {"Body": mock_metadata_body}
            return {"Body": mock_session_body}

        mock_s3_client.get_object.side_effect = get_object_side_effect

        data, metadata = storage.load_session("test-session")

        assert data == b"encrypted-data"
        assert metadata.name == "test-session"
        assert metadata.domain == "example.com"

    def test_load_session_not_found(self, storage, mock_s3_client):
        """Test load raises SessionNotFoundError when metadata not found."""
        from botocore.exceptions import ClientError

        error_response = {
            "Error": {"Code": "NoSuchKey"},
            "ResponseMetadata": {"HTTPStatusCode": 404},
        }
        mock_s3_client.get_object.side_effect = ClientError(error_response, "GetObject")

        with pytest.raises(SessionNotFoundError, match="not found"):
            storage.load_session("nonexistent")

    def test_load_session_expired_ttl(self, storage, mock_s3_client):
        """Test load raises SessionExpiredError when TTL has passed."""
        from graftpunk.exceptions import SessionExpiredError

        now = datetime.now(UTC)
        past = now - timedelta(hours=24)

        metadata_dict = {
            "name": "expired-session",
            "checksum": "abc",
            "created_at": (now - timedelta(hours=48)).isoformat(),
            "modified_at": (now - timedelta(hours=48)).isoformat(),
            "expires_at": past.isoformat(),
            "domain": "example.com",
            "current_url": None,
            "cookie_count": 0,
            "cookie_domains": [],
            "status": "active",
        }
        mock_body = MagicMock()
        mock_body.read.return_value = json.dumps(metadata_dict).encode()
        mock_s3_client.get_object.return_value = {"Body": mock_body}

        with pytest.raises(SessionExpiredError, match="expired"):
            storage.load_session("expired-session")


class TestListSessions:
    """Tests for list_sessions method."""

    def test_list_sessions_empty(self, storage, mock_s3_client):
        """Test listing sessions when none exist."""
        mock_paginator = MagicMock()
        mock_paginator.paginate.return_value = [{"Contents": []}]
        mock_s3_client.get_paginator.return_value = mock_paginator

        sessions = storage.list_sessions()
        assert sessions == []

    def test_list_sessions_with_results(self, storage, mock_s3_client):
        """Test listing sessions with results."""
        mock_paginator = MagicMock()
        mock_paginator.paginate.return_value = [
            {
                "Contents": [
                    {"Key": "sessions/session-b/session.pickle"},
                    {"Key": "sessions/session-b/metadata.json"},
                    {"Key": "sessions/session-a/session.pickle"},
                    {"Key": "sessions/session-a/metadata.json"},
                ]
            }
        ]
        mock_s3_client.get_paginator.return_value = mock_paginator

        sessions = storage.list_sessions()
        assert sessions == ["session-a", "session-b"]


class TestDeleteSession:
    """Tests for delete_session method."""

    def test_delete_session_success(self, storage, mock_s3_client):
        """Test successful session deletion."""
        mock_s3_client.delete_object.return_value = {}

        result = storage.delete_session("test-session")

        assert result is True
        assert mock_s3_client.delete_object.call_count == 2


class TestGetSessionMetadata:
    """Tests for get_session_metadata method."""

    def test_get_session_metadata_not_found(self, storage, mock_s3_client):
        """Test getting metadata for non-existent session."""
        from botocore.exceptions import ClientError

        error_response = {
            "Error": {"Code": "NoSuchKey"},
            "ResponseMetadata": {"HTTPStatusCode": 404},
        }
        mock_s3_client.get_object.side_effect = ClientError(error_response, "GetObject")

        metadata = storage.get_session_metadata("non-existent")
        assert metadata is None

    def test_get_session_metadata_success(self, storage, mock_s3_client, sample_metadata):
        """Test getting metadata for existing session."""
        metadata_dict = {
            "name": "my-session",
            "checksum": "sha256abc",
            "created_at": sample_metadata.created_at.isoformat(),
            "modified_at": sample_metadata.modified_at.isoformat(),
            "expires_at": None,
            "domain": "example.com",
            "current_url": "https://example.com",
            "cookie_count": 3,
            "cookie_domains": ["example.com"],
            "status": "active",
        }
        mock_body = MagicMock()
        mock_body.read.return_value = json.dumps(metadata_dict).encode()
        mock_s3_client.get_object.return_value = {"Body": mock_body}

        metadata = storage.get_session_metadata("my-session")
        assert metadata is not None
        assert metadata.name == "my-session"
        assert metadata.domain == "example.com"


class TestUpdateSessionMetadata:
    """Tests for update_session_metadata method."""

    def test_update_session_metadata_invalid_status(self, storage):
        """Test that invalid status raises ValueError."""
        with pytest.raises(ValueError, match="Invalid status"):
            storage.update_session_metadata("test", status="invalid-status")

    def test_update_session_metadata_success(self, storage, mock_s3_client, sample_metadata):
        """Test successful metadata update."""
        metadata_dict = {
            "name": "test",
            "checksum": "abc",
            "created_at": sample_metadata.created_at.isoformat(),
            "modified_at": sample_metadata.modified_at.isoformat(),
            "expires_at": None,
            "domain": None,
            "current_url": None,
            "cookie_count": 0,
            "cookie_domains": [],
            "status": "active",
        }
        mock_body = MagicMock()
        mock_body.read.return_value = json.dumps(metadata_dict).encode()
        mock_s3_client.get_object.return_value = {"Body": mock_body}

        result = storage.update_session_metadata("test", status="logged_out")
        assert result is True
        mock_s3_client.put_object.assert_called_once()

    def test_update_session_metadata_not_found(self, storage, mock_s3_client):
        """Test update returns False when session not found."""
        from botocore.exceptions import ClientError

        error_response = {
            "Error": {"Code": "NoSuchKey"},
            "ResponseMetadata": {"HTTPStatusCode": 404},
        }
        mock_s3_client.get_object.side_effect = ClientError(error_response, "GetObject")

        result = storage.update_session_metadata("nonexistent", status="active")
        assert result is False


class TestRetryLogic:
    """Tests for retry logic in S3 storage."""

    @patch("graftpunk.storage.s3.time.sleep")
    def test_save_session_retries_on_server_error(
        self, mock_sleep, storage, mock_s3_client, sample_metadata
    ):
        """Test that 5xx errors trigger retries."""
        from botocore.exceptions import ClientError

        storage.max_retries = 3
        storage.base_delay = 0.01

        error_response = {"Error": {"Code": "500"}, "ResponseMetadata": {"HTTPStatusCode": 500}}
        mock_s3_client.put_object.side_effect = ClientError(error_response, "PutObject")

        with pytest.raises(StorageError, match="after 3 attempts"):
            storage.save_session("test", b"data", sample_metadata)

        assert mock_s3_client.put_object.call_count == 3
        assert mock_sleep.call_count == 2  # (max_retries - 1) sleeps

    def test_save_session_no_retry_on_client_error(self, storage, mock_s3_client, sample_metadata):
        """Test that 4xx errors don't trigger retries."""
        from botocore.exceptions import ClientError

        error_response = {"Error": {"Code": "400"}, "ResponseMetadata": {"HTTPStatusCode": 400}}
        mock_s3_client.put_object.side_effect = ClientError(error_response, "PutObject")

        with pytest.raises(StorageError):
            storage.save_session("test", b"data", sample_metadata)

        assert mock_s3_client.put_object.call_count == 1

    @patch("graftpunk.storage.s3.time.sleep")
    def test_retry_on_throttling(self, mock_sleep, storage, mock_s3_client, sample_metadata):
        """Test retry on SlowDown/Throttling errors."""
        from botocore.exceptions import ClientError

        storage.max_retries = 2

        error_response = {
            "Error": {"Code": "SlowDown"},
            "ResponseMetadata": {"HTTPStatusCode": 503},
        }
        mock_s3_client.put_object.side_effect = [
            ClientError(error_response, "PutObject"),
            {},  # Success
            {},  # Metadata upload
        ]

        storage.save_session("test", b"data", sample_metadata)
        assert mock_s3_client.put_object.call_count == 3  # retry + 2 uploads

    @patch("graftpunk.storage.s3.time.sleep")
    def test_retry_on_connection_error(self, mock_sleep, storage, mock_s3_client, sample_metadata):
        """Test retry on ConnectionError."""
        storage.max_retries = 3
        storage.base_delay = 0.01

        mock_s3_client.put_object.side_effect = [
            ConnectionError("Network unreachable"),
            {},  # Success on second try
            {},  # Metadata upload
        ]

        storage.save_session("test", b"data", sample_metadata)
        assert mock_s3_client.put_object.call_count == 3  # retry + 2 uploads
        assert mock_sleep.call_count == 1

    @patch("graftpunk.storage.s3.time.sleep")
    def test_retry_on_timeout_error(self, mock_sleep, storage, mock_s3_client, sample_metadata):
        """Test retry on TimeoutError."""
        storage.max_retries = 3
        storage.base_delay = 0.01

        mock_s3_client.put_object.side_effect = [
            TimeoutError("Connection timed out"),
            {},  # Success on second try
            {},  # Metadata upload
        ]

        storage.save_session("test", b"data", sample_metadata)
        assert mock_s3_client.put_object.call_count == 3  # retry + 2 uploads

    @patch("graftpunk.storage.s3.time.sleep")
    def test_connection_error_exhausts_retries(
        self, mock_sleep, storage, mock_s3_client, sample_metadata
    ):
        """Test that ConnectionError after max retries raises StorageError."""
        storage.max_retries = 2
        storage.base_delay = 0.01

        mock_s3_client.put_object.side_effect = ConnectionError("Network unreachable")

        with pytest.raises(StorageError, match="after 2 attempts"):
            storage.save_session("test", b"data", sample_metadata)

        assert mock_s3_client.put_object.call_count == 2
