"""Tests for S3 export handler."""

import json
from unittest.mock import MagicMock, patch

import pytest
from botocore.exceptions import ClientError
from pydantic import ValidationError

from caliper._annotation import Annotation
from caliper._config import S3Config
from caliper._export import create_handler
from caliper._record import Record


def _make_client_error(code: str, message: str = "Error", http_status: int = 403) -> ClientError:
    """Construct a botocore ClientError with the given error code."""
    return ClientError(
        {"Error": {"Code": code, "Message": message}, "ResponseMetadata": {"HTTPStatusCode": http_status}},
        "PutObject",
    )


def _make_record(**overrides) -> Record:
    defaults = {
        "request_id": "test-uuid",
        "timestamp": "2026-02-20T10:00:00+00:00",
        "provider": "anthropic",
        "model": "claude-sonnet-4-20250514",
        "endpoint": "chat",
        "duration_ms": 150,
        "tokens_input": 100,
        "tokens_output": 50,
        "tokens_total": 150,
        "status": "success",
        "cached": False,
        "sdk_version": "0.1.0",
    }
    defaults.update(overrides)
    return Record(**defaults)


# ---------------------------------------------------------------------------
# S3Handler tests
# ---------------------------------------------------------------------------


@patch("caliper._s3.boto3", create=True)
def test_s3_handler_export_uploads_json_array(mock_boto3):
    mock_client = MagicMock()
    mock_boto3.client.return_value = mock_client

    from caliper._s3 import S3Handler

    handler = S3Handler(bucket="test-bucket", prefix="events")
    handler.export([_make_record(model="model-a"), _make_record(model="model-b")])

    mock_client.put_object.assert_called_once()
    call_kwargs = mock_client.put_object.call_args[1]
    assert call_kwargs["Bucket"] == "test-bucket"
    assert call_kwargs["ContentType"] == "application/json"

    body = json.loads(call_kwargs["Body"])
    assert isinstance(body, list)
    assert len(body) == 2
    assert body[0]["model"] == "model-a"
    assert body[1]["model"] == "model-b"


@patch("caliper._s3.boto3", create=True)
def test_s3_handler_export_key_has_records_prefix(mock_boto3):
    mock_client = MagicMock()
    mock_boto3.client.return_value = mock_client

    from caliper._s3 import S3Handler

    handler = S3Handler(bucket="test-bucket", prefix="events")
    handler.export([_make_record()])

    key = mock_client.put_object.call_args[1]["Key"]
    assert key.startswith("events/records/")
    assert "/batch_" in key
    assert key.endswith(".json")


@patch("caliper._s3.boto3", create=True)
def test_s3_handler_export_annotations_key_has_annotations_prefix(mock_boto3):
    mock_client = MagicMock()
    mock_boto3.client.return_value = mock_client

    from caliper._s3 import S3Handler

    handler = S3Handler(bucket="test-bucket", prefix="events")
    handler.export_annotations(
        [Annotation(request_id="req-1", timestamp="2026-02-20T10:00:00+00:00", metadata={"k": "v"})]
    )

    key = mock_client.put_object.call_args[1]["Key"]
    assert key.startswith("events/annotations/")
    assert "/batch_" in key
    assert key.endswith(".json")

    body = json.loads(mock_client.put_object.call_args[1]["Body"])
    assert isinstance(body, list)
    assert len(body) == 1
    assert body[0]["request_id"] == "req-1"


@patch("caliper._s3.boto3", create=True)
def test_s3_handler_key_date_partitioning(mock_boto3):
    """Default partition='hour' produces records/YYYY/MM/DD/HH/batch_<ulid>.json."""
    mock_client = MagicMock()
    mock_boto3.client.return_value = mock_client

    from caliper._s3 import S3Handler

    handler = S3Handler(bucket="test-bucket", prefix="")
    handler.export([_make_record()])

    key = mock_client.put_object.call_args[1]["Key"]
    parts = key.split("/")
    assert parts[0] == "records"
    assert len(parts[1]) == 4  # year
    assert len(parts[2]) == 2  # month
    assert len(parts[3]) == 2  # day
    assert len(parts[4]) == 2  # hour
    assert parts[5].startswith("batch_")


@patch("caliper._s3.boto3", create=True)
def test_s3_handler_no_prefix(mock_boto3):
    mock_client = MagicMock()
    mock_boto3.client.return_value = mock_client

    from caliper._s3 import S3Handler

    handler = S3Handler(bucket="test-bucket")
    handler.export([_make_record()])

    key = mock_client.put_object.call_args[1]["Key"]
    assert key.startswith("records/")


@patch("caliper._s3.boto3", create=True)
def test_s3_handler_close_is_noop(mock_boto3):
    mock_client = MagicMock()
    mock_boto3.client.return_value = mock_client

    from caliper._s3 import S3Handler

    handler = S3Handler(bucket="test-bucket")
    handler.close()  # should not raise


# ---------------------------------------------------------------------------
# Factory tests
# ---------------------------------------------------------------------------


@patch("caliper._s3.boto3", create=True)
def test_factory_creates_s3_handler(mock_boto3):
    mock_boto3.client.return_value = MagicMock()

    from caliper._s3 import S3Handler

    config = S3Config(s3_bucket="my-bucket", s3_region="eu-west-1", s3_prefix="data/")
    handler = create_handler("s3", config)
    assert isinstance(handler, S3Handler)


def test_factory_raises_without_s3_bucket(monkeypatch):
    monkeypatch.delenv("CALIPER_S3_BUCKET", raising=False)
    config = S3Config()
    with pytest.raises(ValueError, match="s3_bucket is required"):
        create_handler("s3", config)


@patch("caliper._s3.boto3", create=True)
def test_s3_handler_export_failure_logs_warning(mock_boto3):
    mock_client = MagicMock()
    mock_client.put_object.side_effect = _make_client_error("Throttling", http_status=429)
    mock_boto3.client.return_value = mock_client

    from caliper._s3 import S3Handler

    handler = S3Handler(bucket="test-bucket", max_retries=1)
    # Should not raise — logs a warning instead
    handler.export([_make_record()])


@patch("caliper._s3.boto3", create=True)
def test_s3_handler_passes_credentials_to_boto3(mock_boto3):
    mock_boto3.client.return_value = MagicMock()

    from caliper._s3 import S3Handler

    S3Handler(
        bucket="test-bucket",
        access_key="AKIAEXAMPLE",
        secret_key="wJalrXUtnFEMI",
        region="us-west-2",
        endpoint="https://s3.custom.endpoint",
    )

    mock_boto3.client.assert_called_once_with(
        "s3",
        region_name="us-west-2",
        aws_access_key_id="AKIAEXAMPLE",
        aws_secret_access_key="wJalrXUtnFEMI",
        endpoint_url="https://s3.custom.endpoint",
    )


@patch("caliper._s3.boto3", create=True)
def test_create_handler_unwraps_secret_str(mock_boto3):
    """SecretStr on S3Config.s3_secret_key must be unwrapped to a plain str for boto3."""
    mock_boto3.client.return_value = MagicMock()

    config = S3Config(
        s3_bucket="test-bucket",
        s3_access_key="AKIAEXAMPLE",
        s3_secret_key="wJalrXUtnFEMI",
        s3_region="us-west-2",
    )
    create_handler("s3", config)

    call_kwargs = mock_boto3.client.call_args[1]
    assert call_kwargs["aws_secret_access_key"] == "wJalrXUtnFEMI"
    assert isinstance(call_kwargs["aws_secret_access_key"], str)


# ---------------------------------------------------------------------------
# Partition granularity tests
# ---------------------------------------------------------------------------


@patch("caliper._s3.boto3", create=True)
def test_s3_handler_partition_none(mock_boto3):
    """partition='none' produces flat keys: prefix/category/batch_<ulid>.json."""
    mock_client = MagicMock()
    mock_boto3.client.return_value = mock_client

    from caliper._s3 import S3Handler

    handler = S3Handler(bucket="test-bucket", prefix="data", partition="none")
    handler.export([_make_record()])

    key = mock_client.put_object.call_args[1]["Key"]
    parts = key.split("/")
    assert parts[0] == "data"
    assert parts[1] == "records"
    assert parts[2].startswith("batch_")
    assert len(parts) == 3


@patch("caliper._s3.boto3", create=True)
def test_s3_handler_partition_year(mock_boto3):
    """partition='year' produces prefix/category/YYYY/batch_<ulid>.json."""
    mock_client = MagicMock()
    mock_boto3.client.return_value = mock_client

    from caliper._s3 import S3Handler

    handler = S3Handler(bucket="test-bucket", prefix="data", partition="year")
    handler.export([_make_record()])

    key = mock_client.put_object.call_args[1]["Key"]
    parts = key.split("/")
    assert parts[0] == "data"
    assert parts[1] == "records"
    assert len(parts[2]) == 4  # year
    assert parts[3].startswith("batch_")
    assert len(parts) == 4


@patch("caliper._s3.boto3", create=True)
def test_s3_handler_partition_month(mock_boto3):
    """partition='month' produces prefix/category/YYYY/MM/batch_<ulid>.json."""
    mock_client = MagicMock()
    mock_boto3.client.return_value = mock_client

    from caliper._s3 import S3Handler

    handler = S3Handler(bucket="test-bucket", prefix="data", partition="month")
    handler.export([_make_record()])

    key = mock_client.put_object.call_args[1]["Key"]
    parts = key.split("/")
    assert parts[0] == "data"
    assert parts[1] == "records"
    assert len(parts[2]) == 4  # year
    assert len(parts[3]) == 2  # month
    assert parts[4].startswith("batch_")
    assert len(parts) == 5


@patch("caliper._s3.boto3", create=True)
def test_s3_handler_partition_day(mock_boto3):
    """partition='day' produces prefix/category/YYYY/MM/DD/batch_<ulid>.json."""
    mock_client = MagicMock()
    mock_boto3.client.return_value = mock_client

    from caliper._s3 import S3Handler

    handler = S3Handler(bucket="test-bucket", prefix="data", partition="day")
    handler.export([_make_record()])

    key = mock_client.put_object.call_args[1]["Key"]
    parts = key.split("/")
    assert parts[0] == "data"
    assert parts[1] == "records"
    assert len(parts[2]) == 4  # year
    assert len(parts[3]) == 2  # month
    assert len(parts[4]) == 2  # day
    assert parts[5].startswith("batch_")
    assert len(parts) == 6


def test_s3_config_invalid_partition():
    """Invalid s3_partition value raises Pydantic validation error."""
    with pytest.raises(ValidationError):
        S3Config(s3_partition="weekly")


# ---------------------------------------------------------------------------
# Retry tests
# ---------------------------------------------------------------------------


@patch("caliper._s3.boto3", create=True)
def test_s3_retry_succeeds_on_second_attempt(mock_boto3):
    mock_client = MagicMock()
    mock_client.put_object.side_effect = [_make_client_error("InternalError", http_status=500), None]
    mock_boto3.client.return_value = mock_client

    from caliper._s3 import S3Handler

    handler = S3Handler(bucket="test-bucket", max_retries=3)
    handler.export([_make_record()])

    assert mock_client.put_object.call_count == 2


@patch("caliper._s3.boto3", create=True)
def test_s3_retry_drops_after_max_retries(mock_boto3):
    mock_client = MagicMock()
    mock_client.put_object.side_effect = _make_client_error("InternalError", http_status=500)
    mock_boto3.client.return_value = mock_client

    from caliper._s3 import S3Handler

    handler = S3Handler(bucket="test-bucket", max_retries=2)
    # Should not raise — export() catches and logs
    handler.export([_make_record()])

    assert mock_client.put_object.call_count == 2


# ---------------------------------------------------------------------------
# Permanent error — no retry tests
# ---------------------------------------------------------------------------


@patch("caliper._s3.boto3", create=True)
def test_s3_no_retry_on_access_denied(mock_boto3):
    """AccessDenied is permanent — should not retry."""
    mock_client = MagicMock()
    mock_client.put_object.side_effect = _make_client_error("AccessDenied")
    mock_boto3.client.return_value = mock_client

    from caliper._s3 import S3Handler

    handler = S3Handler(bucket="test-bucket", max_retries=3)
    handler.export([_make_record()])

    assert mock_client.put_object.call_count == 1


@patch("caliper._s3.boto3", create=True)
def test_s3_no_retry_on_no_such_bucket(mock_boto3):
    """NoSuchBucket is permanent — should not retry."""
    mock_client = MagicMock()
    mock_client.put_object.side_effect = _make_client_error("NoSuchBucket", http_status=404)
    mock_boto3.client.return_value = mock_client

    from caliper._s3 import S3Handler

    handler = S3Handler(bucket="bad-bucket", max_retries=3)
    handler.export([_make_record()])

    assert mock_client.put_object.call_count == 1


# ---------------------------------------------------------------------------
# S3Handler log-level tests
# ---------------------------------------------------------------------------


@patch("caliper._s3.boto3", create=True)
def test_s3_handler_logs_error_on_access_denied(mock_boto3):
    """Permanent S3 errors should log at error level."""
    mock_client = MagicMock()
    mock_client.put_object.side_effect = _make_client_error("AccessDenied")
    mock_boto3.client.return_value = mock_client

    from caliper._s3 import S3Handler

    handler = S3Handler(bucket="test-bucket", max_retries=1)
    with patch("caliper._s3.logger") as mock_logger:
        handler.export([_make_record()])

    mock_logger.error.assert_called_once()
    call_kwargs = mock_logger.error.call_args[1]
    assert call_kwargs["error_class"] == "permanent"
    assert call_kwargs["s3_error_code"] == "AccessDenied"
    assert call_kwargs["bucket"] == "test-bucket"


@patch("caliper._s3.boto3", create=True)
def test_s3_handler_logs_warning_on_transient_error(mock_boto3):
    """Transient S3 errors should log at warning level."""
    mock_client = MagicMock()
    mock_client.put_object.side_effect = _make_client_error("InternalError", http_status=500)
    mock_boto3.client.return_value = mock_client

    from caliper._s3 import S3Handler

    handler = S3Handler(bucket="test-bucket", max_retries=1)
    with patch("caliper._s3.logger") as mock_logger:
        handler.export([_make_record()])

    mock_logger.warning.assert_called_once()
    call_kwargs = mock_logger.warning.call_args[1]
    assert call_kwargs["error_class"] == "transient"
    assert call_kwargs["s3_error_code"] == "InternalError"
    assert call_kwargs["bucket"] == "test-bucket"
