"""Tests for Phase 3 features: retry, jitter, webhook timestamp, error parity,
auto-pagination, idempotency keys."""

from __future__ import annotations

import hmac
import hashlib
import time
from unittest.mock import MagicMock, patch, PropertyMock

import pytest

# ═══════════════════════════════════════════════════════════════════════════
# WEBHOOK TIMESTAMP VALIDATION
# ═══════════════════════════════════════════════════════════════════════════

from optropic.webhook_verify import verify_webhook_signature, WebhookVerifyResult


def _make_signature(secret: str, timestamp: str, payload: str) -> str:
    """Compute a valid signature for testing."""
    signed = f"{timestamp}.{payload}"
    digest = hmac.new(
        secret.encode(), signed.encode(), hashlib.sha256
    ).hexdigest()
    return f"sha256={digest}"


class TestWebhookTimestamp:
    def test_valid_with_timestamp(self):
        ts = str(int(time.time()))
        payload = '{"event":"test"}'
        secret = "whsec_test123"
        sig = _make_signature(secret, ts, payload)
        result = verify_webhook_signature(payload, sig, secret, timestamp=ts)
        assert result.valid is True
        assert result.reason is None

    def test_stale_timestamp_rejected(self):
        ts = str(int(time.time()) - 600)  # 10 min old
        payload = '{"event":"test"}'
        secret = "whsec_test123"
        sig = _make_signature(secret, ts, payload)
        result = verify_webhook_signature(payload, sig, secret, timestamp=ts)
        assert result.valid is False
        assert "too old" in result.reason.lower()

    def test_custom_tolerance(self):
        ts = str(int(time.time()) - 600)
        payload = '{"event":"test"}'
        secret = "whsec_test123"
        sig = _make_signature(secret, ts, payload)
        # 1200s tolerance — should pass
        result = verify_webhook_signature(
            payload, sig, secret, timestamp=ts, tolerance=1200
        )
        assert result.valid is True

    def test_invalid_timestamp_format(self):
        payload = '{"event":"test"}'
        secret = "whsec_test123"
        sig = "sha256=abc"
        result = verify_webhook_signature(
            payload, sig, secret, timestamp="not-a-number"
        )
        assert result.valid is False
        assert "invalid timestamp" in result.reason.lower()

    def test_no_timestamp_backward_compat(self):
        """When timestamp is None, only HMAC is checked (backward compat)."""
        payload = '{"event":"test"}'
        secret = "whsec_test123"
        digest = hmac.new(
            secret.encode(), payload.encode(), hashlib.sha256
        ).hexdigest()
        sig = f"sha256={digest}"
        result = verify_webhook_signature(payload, sig, secret)
        assert result.valid is True

    def test_wrong_signature_with_timestamp(self):
        ts = str(int(time.time()))
        payload = '{"event":"test"}'
        result = verify_webhook_signature(
            payload, "sha256=wrong", "secret", timestamp=ts
        )
        assert result.valid is False
        assert "mismatch" in result.reason.lower()

    def test_invalid_sig_format(self):
        result = verify_webhook_signature("{}", "invalid", "secret")
        assert result.valid is False

    def test_result_bool(self):
        assert bool(WebhookVerifyResult(True)) is True
        assert bool(WebhookVerifyResult(False, "bad")) is False

    def test_result_repr(self):
        r = WebhookVerifyResult(True)
        assert "WebhookVerifyResult" in repr(r)


# ═══════════════════════════════════════════════════════════════════════════
# ERROR TYPE PARITY
# ═══════════════════════════════════════════════════════════════════════════

from optropic.errors import (
    OptropicError,
    AuthenticationError,
    ForbiddenError,
    ValidationError,
    NotFoundError,
    RateLimitError,
    InvalidSerialError,
    InvalidGTINError,
    InvalidCodeError,
    CodeNotFoundError,
    BatchNotFoundError,
    RevokedCodeError,
    QuotaExceededError,
    NetworkError,
    TimeoutError,
    ServiceUnavailableError,
    create_error_from_response,
)


class TestErrorParity:
    def test_base_error_fields(self):
        e = OptropicError("test", 500, "TEST", retryable=True, request_id="req-1")
        assert e.message == "test"
        assert e.status_code == 500
        assert e.code == "TEST"
        assert e.retryable is True
        assert e.request_id == "req-1"

    def test_to_dict(self):
        e = OptropicError("test", 500, "TEST", request_id="req-1")
        d = e.to_dict()
        assert d["name"] == "OptropicError"
        assert d["request_id"] == "req-1"

    def test_invalid_serial(self):
        e = InvalidSerialError("BAD123")
        assert e.serial == "BAD123"
        assert e.status_code == 400
        assert e.code == "INVALID_SERIAL"
        assert "BAD123" in e.message

    def test_invalid_gtin(self):
        e = InvalidGTINError("12345")
        assert e.gtin == "12345"
        assert e.code == "INVALID_GTIN"

    def test_invalid_code(self):
        e = InvalidCodeError("xyz")
        assert e.invalid_code == "xyz"
        assert e.code == "INVALID_CODE_FORMAT"

    def test_code_not_found(self):
        e = CodeNotFoundError("abc-123")
        assert e.searched_code == "abc-123"
        assert e.status_code == 404

    def test_batch_not_found(self):
        e = BatchNotFoundError("batch-1")
        assert e.batch_id == "batch-1"
        assert e.status_code == 404

    def test_revoked_code(self):
        e = RevokedCodeError("code-1", "counterfeit", "2026-01-01T00:00:00Z")
        assert e.revoked_code == "code-1"
        assert e.reason == "counterfeit"
        assert e.revoked_at == "2026-01-01T00:00:00Z"
        assert e.status_code == 410
        assert e.retryable is False

    def test_rate_limit(self):
        e = RateLimitError(retry_after=30, limit=100, remaining=0)
        assert e.retry_after == 30
        assert e.limit == 100
        assert e.retryable is True

    def test_quota_exceeded(self):
        e = QuotaExceededError(1000, 1001, "2026-04-01T00:00:00Z")
        assert e.quota_limit == 1000
        assert e.quota_used == 1001
        assert e.resets_at == "2026-04-01T00:00:00Z"
        assert e.status_code == 402
        assert e.retryable is False

    def test_network_error(self):
        e = NetworkError("Connection refused")
        assert e.retryable is True
        assert e.code == "NETWORK_ERROR"

    def test_timeout_error(self):
        e = TimeoutError(5000)
        assert e.timeout_ms == 5000
        assert e.retryable is True
        assert e.code == "TIMEOUT"

    def test_service_unavailable(self):
        e = ServiceUnavailableError()
        assert e.status_code == 503
        assert e.retryable is True

    def test_inheritance(self):
        assert issubclass(InvalidSerialError, ValidationError)
        assert issubclass(InvalidGTINError, ValidationError)
        assert issubclass(InvalidCodeError, ValidationError)
        assert issubclass(CodeNotFoundError, NotFoundError)
        assert issubclass(BatchNotFoundError, NotFoundError)


class TestCreateErrorFromResponse:
    def test_auth_error(self):
        e = create_error_from_response(401, {"code": "INVALID_API_KEY", "message": "Bad key"})
        assert isinstance(e, AuthenticationError)

    def test_rate_limited(self):
        e = create_error_from_response(429, {
            "code": "RATE_LIMITED",
            "message": "Slow down",
            "details": {"retryAfter": 30},
        })
        assert isinstance(e, RateLimitError)
        assert e.retry_after == 30

    def test_code_revoked(self):
        e = create_error_from_response(410, {
            "code": "CODE_REVOKED",
            "message": "Revoked",
            "details": {"code": "c1", "reason": "counterfeit", "revokedAt": "2026-01-01"},
        })
        assert isinstance(e, RevokedCodeError)
        assert e.revoked_code == "c1"

    def test_unknown_code_5xx(self):
        e = create_error_from_response(502, {"code": "WEIRD_ERROR", "message": "Oops"})
        assert isinstance(e, OptropicError)
        assert e.retryable is True

    def test_unknown_code_4xx(self):
        e = create_error_from_response(418, {"code": "TEAPOT", "message": "I'm a teapot"})
        assert isinstance(e, OptropicError)
        assert e.retryable is False

    def test_request_id_propagated(self):
        e = create_error_from_response(404, {
            "code": "CODE_NOT_FOUND",
            "message": "Gone",
            "requestId": "req-42",
            "details": {"code": "c1"},
        })
        assert e.request_id == "req-42"


# ═══════════════════════════════════════════════════════════════════════════
# RETRY + JITTER + TIMEOUT
# ═══════════════════════════════════════════════════════════════════════════

import requests as _requests
from optropic.client import Optropic


class TestRetryLogic:
    def _make_client(self, **kwargs):
        return Optropic(api_key="optr_test_aaaabbbbccccddddeeee", **kwargs)

    @patch("optropic.client.time.sleep")
    def test_retries_on_500(self, mock_sleep):
        client = self._make_client(max_retries=2, base_delay=0.1)
        mock_resp_fail = MagicMock()
        mock_resp_fail.ok = False
        mock_resp_fail.status_code = 503
        mock_resp_fail.json.return_value = {"code": "SERVICE_UNAVAILABLE", "message": "Down"}
        mock_resp_fail.text = "Down"
        mock_resp_fail.headers = {}

        mock_resp_ok = MagicMock()
        mock_resp_ok.ok = True
        mock_resp_ok.status_code = 200
        mock_resp_ok.json.return_value = {"id": "asset-1"}

        client._session.request = MagicMock(side_effect=[mock_resp_fail, mock_resp_ok])
        result = client._request("GET", "/assets/1")
        assert result["id"] == "asset-1"
        assert mock_sleep.call_count == 1

    @patch("optropic.client.time.sleep")
    def test_no_retry_on_400(self, mock_sleep):
        client = self._make_client(max_retries=3)
        mock_resp = MagicMock()
        mock_resp.ok = False
        mock_resp.status_code = 400
        mock_resp.json.return_value = {"code": "VALIDATION_ERROR", "message": "Bad input"}
        mock_resp.text = "Bad input"
        mock_resp.headers = {}

        client._session.request = MagicMock(return_value=mock_resp)
        with pytest.raises(ValidationError):
            client._request("GET", "/assets")
        assert mock_sleep.call_count == 0

    @patch("optropic.client.time.sleep")
    def test_exhausted_retries(self, mock_sleep):
        client = self._make_client(max_retries=2, base_delay=0.01)
        mock_resp = MagicMock()
        mock_resp.ok = False
        mock_resp.status_code = 503
        mock_resp.json.return_value = {"code": "SERVICE_UNAVAILABLE", "message": "Down"}
        mock_resp.text = "Down"
        mock_resp.headers = {}

        client._session.request = MagicMock(return_value=mock_resp)
        with pytest.raises(ServiceUnavailableError):
            client._request("GET", "/health")
        # Should have retried max_retries times
        assert client._session.request.call_count == 3  # initial + 2 retries

    @patch("optropic.client.time.sleep")
    def test_rate_limit_uses_retry_after(self, mock_sleep):
        client = self._make_client(max_retries=1)
        mock_resp_429 = MagicMock()
        mock_resp_429.ok = False
        mock_resp_429.status_code = 429
        mock_resp_429.json.return_value = {
            "code": "RATE_LIMITED",
            "message": "Slow down",
            "details": {"retryAfter": 5},
        }
        mock_resp_429.text = "Slow"
        mock_resp_429.headers = {}

        mock_resp_ok = MagicMock()
        mock_resp_ok.ok = True
        mock_resp_ok.status_code = 200
        mock_resp_ok.json.return_value = {"data": []}

        client._session.request = MagicMock(side_effect=[mock_resp_429, mock_resp_ok])
        client._request("GET", "/assets")
        # Should have slept for retry_after seconds
        mock_sleep.assert_called_once_with(5)

    def test_timeout_config(self):
        client = self._make_client(timeout=5.0)
        assert client._timeout == 5.0

    @patch("optropic.client.time.sleep")
    def test_connection_error_retried(self, mock_sleep):
        client = self._make_client(max_retries=1, base_delay=0.01)
        mock_resp_ok = MagicMock()
        mock_resp_ok.ok = True
        mock_resp_ok.status_code = 200
        mock_resp_ok.json.return_value = {"id": "1"}

        client._session.request = MagicMock(
            side_effect=[_requests.exceptions.ConnectionError("refused"), mock_resp_ok]
        )
        result = client._request("GET", "/health")
        assert result["id"] == "1"


# ═══════════════════════════════════════════════════════════════════════════
# IDEMPOTENCY KEYS
# ═══════════════════════════════════════════════════════════════════════════

class TestIdempotencyKeys:
    def _make_client(self):
        return Optropic(api_key="optr_test_aaaabbbbccccddddeeee")

    def test_auto_generated_for_post(self):
        client = self._make_client()
        mock_resp = MagicMock()
        mock_resp.ok = True
        mock_resp.status_code = 200
        mock_resp.json.return_value = {"id": "new"}

        client._session.request = MagicMock(return_value=mock_resp)
        client._request("POST", "/assets", json={"foo": "bar"})

        call_kwargs = client._session.request.call_args
        headers = call_kwargs.kwargs.get("headers") or call_kwargs[1].get("headers", {})
        assert "Idempotency-Key" in headers

    def test_not_generated_for_get(self):
        client = self._make_client()
        mock_resp = MagicMock()
        mock_resp.ok = True
        mock_resp.status_code = 200
        mock_resp.json.return_value = {"data": []}

        client._session.request = MagicMock(return_value=mock_resp)
        client._request("GET", "/assets")

        call_kwargs = client._session.request.call_args
        headers = call_kwargs.kwargs.get("headers") or call_kwargs[1].get("headers", {})
        assert "Idempotency-Key" not in headers

    def test_explicit_key_used(self):
        client = self._make_client()
        mock_resp = MagicMock()
        mock_resp.ok = True
        mock_resp.status_code = 200
        mock_resp.json.return_value = {"id": "new"}

        client._session.request = MagicMock(return_value=mock_resp)
        client._request("POST", "/assets", json={}, idempotency_key="my-key-123")

        call_kwargs = client._session.request.call_args
        headers = call_kwargs.kwargs.get("headers") or call_kwargs[1].get("headers", {})
        assert headers["Idempotency-Key"] == "my-key-123"


# ═══════════════════════════════════════════════════════════════════════════
# AUTO-PAGINATION
# ═══════════════════════════════════════════════════════════════════════════

class TestAutoPagination:
    def _make_client(self):
        return Optropic(api_key="optr_test_aaaabbbbccccddddeeee")

    def test_list_all_single_page(self):
        client = self._make_client()
        mock_resp = MagicMock()
        mock_resp.ok = True
        mock_resp.status_code = 200
        mock_resp.json.return_value = {
            "data": [{"id": "a1"}, {"id": "a2"}],
            "pagination": {"total": 2, "page": 1, "perPage": 100, "totalPages": 1},
        }
        client._session.request = MagicMock(return_value=mock_resp)

        items = list(client.assets.list_all())
        assert len(items) == 2
        assert items[0]["id"] == "a1"

    def test_list_all_multi_page(self):
        client = self._make_client()
        resp_page1 = MagicMock()
        resp_page1.ok = True
        resp_page1.status_code = 200
        resp_page1.json.return_value = {
            "data": [{"id": "a1"}],
            "pagination": {"total": 2, "page": 1, "perPage": 1, "totalPages": 2},
        }

        resp_page2 = MagicMock()
        resp_page2.ok = True
        resp_page2.status_code = 200
        resp_page2.json.return_value = {
            "data": [{"id": "a2"}],
            "pagination": {"total": 2, "page": 2, "perPage": 1, "totalPages": 2},
        }

        client._session.request = MagicMock(side_effect=[resp_page1, resp_page2])
        items = list(client.assets.list_all(page_size=1))
        assert len(items) == 2
        assert items[1]["id"] == "a2"

    def test_list_all_empty(self):
        client = self._make_client()
        mock_resp = MagicMock()
        mock_resp.ok = True
        mock_resp.status_code = 200
        mock_resp.json.return_value = {
            "data": [],
            "pagination": {"total": 0, "page": 1, "perPage": 100, "totalPages": 0},
        }
        client._session.request = MagicMock(return_value=mock_resp)
        items = list(client.assets.list_all())
        assert items == []

    def test_paginate_helper(self):
        client = self._make_client()
        resp1 = MagicMock()
        resp1.ok = True
        resp1.status_code = 200
        resp1.json.return_value = {
            "data": [{"id": "x"}],
            "pagination": {"total": 1, "page": 1, "perPage": 50, "totalPages": 1},
        }
        client._session.request = MagicMock(return_value=resp1)
        items = list(client._paginate("GET", "/assets", page_size=50))
        assert len(items) == 1
