import base64
from unittest.mock import patch

import pytest
from ucm import UCMClient, generate_keypair, verify_message
from ucm.crypto import canonical_json, sha256_hex, sign_message
from ucm.exceptions import (
    ACPError,
    AuthenticationError,
    BudgetError,
    InvalidRequestError,
    NotFoundError,
    ConflictError,
    RateLimitError,
    WalletRequiredError,
    raise_for_acp_error,
)


# ── Crypto tests ──────────────────────────────────────────────────────


def test_generate_keypair():
    """Test that keypair generation produces valid hex strings."""
    priv, pub = generate_keypair()
    assert len(priv) == 64  # 32 bytes hex
    assert len(pub) == 64
    bytes.fromhex(priv)
    bytes.fromhex(pub)


def test_generate_keypair_unique():
    """Test that each call generates different keys."""
    priv1, pub1 = generate_keypair()
    priv2, pub2 = generate_keypair()
    assert priv1 != priv2
    assert pub1 != pub2


def test_canonical_json():
    """Test canonical JSON serialization with sorted keys."""
    obj = {'b': 1, 'a': 'hello'}
    result = canonical_json(obj)
    assert result == '{"a":"hello","b":1}'


def test_canonical_json_nested():
    """Test canonical JSON with nested objects."""
    obj = {'z': {'b': 2, 'a': 1}, 'a': [3, 2, 1]}
    result = canonical_json(obj)
    assert result == '{"a":[3,2,1],"z":{"a":1,"b":2}}'


def test_sha256():
    """Test SHA-256 hashing."""
    result = sha256_hex('test')
    assert len(result) == 64
    assert result == '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08'


def test_sha256_empty():
    """Test SHA-256 of empty string."""
    result = sha256_hex('')
    assert len(result) == 64
    assert result == 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'


def test_sign_message():
    """Test message signing produces valid base64url signature."""
    priv, pub = generate_keypair()
    message = '{"test":"data"}'
    signature = sign_message(priv, message)
    assert len(signature) == 86
    assert '=' not in signature
    padded = signature + '=' * (-len(signature) % 4)
    sig_bytes = base64.urlsafe_b64decode(padded)
    assert len(sig_bytes) == 64


def test_sign_message_deterministic():
    """Test that signing the same message with same key produces same signature."""
    priv, _ = generate_keypair()
    message = '{"test":"data"}'
    sig1 = sign_message(priv, message)
    sig2 = sign_message(priv, message)
    assert sig1 == sig2


def test_sign_message_different_keys():
    """Test that different keys produce different signatures."""
    priv1, _ = generate_keypair()
    priv2, _ = generate_keypair()
    message = '{"test":"data"}'
    sig1 = sign_message(priv1, message)
    sig2 = sign_message(priv2, message)
    assert sig1 != sig2


def test_sign_verify_roundtrip():
    """Test that sign then verify works with the same keypair."""
    priv, pub = generate_keypair()
    message = sha256_hex('{"test":"data"}')
    signature = sign_message(priv, message)
    assert verify_message(pub, message, signature) is True


def test_verify_wrong_key():
    """Test that verify fails with a different public key."""
    priv1, pub1 = generate_keypair()
    _, pub2 = generate_keypair()
    message = sha256_hex('{"test":"data"}')
    signature = sign_message(priv1, message)
    assert verify_message(pub2, message, signature) is False


def test_verify_wrong_message():
    """Test that verify fails with a different message."""
    priv, pub = generate_keypair()
    message = sha256_hex('{"test":"data"}')
    signature = sign_message(priv, message)
    assert verify_message(pub, 'wrong_message', signature) is False


def test_cross_compat_signature_format():
    """Verify signature is base64url decodable and exactly 64 bytes."""
    priv, pub = generate_keypair()
    message = sha256_hex('{"test":"data"}')
    signature = sign_message(priv, message)
    assert '=' not in signature
    assert '+' not in signature
    assert '/' not in signature
    padded = signature + '=' * (-len(signature) % 4)
    sig_bytes = base64.urlsafe_b64decode(padded)
    assert len(sig_bytes) == 64


def test_sign_headers_uses_sha256():
    """Test that _sign_headers passes sha256_hex of payload, not raw payload."""
    priv, pub = generate_keypair()
    client = UCMClient('http://localhost:3000', priv, pub)

    with patch('ucm.client.sign_message', wraps=sign_message) as mock_sign:
        headers = client._sign_headers('POST', '/v1/buy', '{"service_id":"test"}')

        mock_sign.assert_called_once()
        call_args = mock_sign.call_args
        signed_message = call_args[0][1]

        assert len(signed_message) == 64
        bytes.fromhex(signed_message)

        payload = canonical_json({
            'body_hash': sha256_hex('{"service_id":"test"}'),
            'method': 'POST',
            'nonce': headers['X-ACP-Nonce'],
            'path': '/v1/buy',
            'timestamp': int(headers['X-ACP-Timestamp']),
        })
        expected_hash = sha256_hex(payload)
        assert signed_message == expected_hash


# ── Constructor validation tests ──────────────────────────────────────


def test_constructor_requires_auth():
    """Test that constructor requires either keys or api_key."""
    with pytest.raises(ValueError, match='Either'):
        UCMClient('http://localhost:3000')


def test_constructor_requires_public_key_with_private():
    """Test that constructor requires public_key when private_key given."""
    priv, _ = generate_keypair()
    with pytest.raises(ValueError, match='public_key'):
        UCMClient('http://localhost:3000', private_key=priv)


def test_constructor_api_key_only():
    """Test that constructor accepts api_key without Ed25519 keys."""
    client = UCMClient('http://localhost:3000', api_key='ucm_key_test123')
    assert client.api_key == 'ucm_key_test123'
    assert client.private_key is None


def test_constructor_both_auth_methods():
    """Test that constructor accepts both Ed25519 keys and api_key."""
    priv, pub = generate_keypair()
    client = UCMClient('http://localhost:3000', priv, pub, api_key='ucm_key_test')
    assert client.private_key == priv
    assert client.api_key == 'ucm_key_test'


def test_constructor_strips_trailing_slash():
    """Test that registry_url trailing slash is stripped."""
    client = UCMClient('http://localhost:3000/', api_key='ucm_key_test')
    assert client.registry_url == 'http://localhost:3000'


# ── Auth header tests ─────────────────────────────────────────────────


def test_bearer_headers():
    """Test that _bearer_headers returns correct Authorization header."""
    client = UCMClient('http://localhost:3000', api_key='ucm_key_abc123')
    headers = client._bearer_headers()
    assert headers['Authorization'] == 'Bearer ucm_key_abc123'
    assert headers['Content-Type'] == 'application/json'


def test_auth_headers_prefers_ed25519():
    """Test that _auth_headers prefers Ed25519 when both are available."""
    priv, pub = generate_keypair()
    client = UCMClient('http://localhost:3000', priv, pub, api_key='ucm_key_test')
    headers = client._auth_headers('GET', '/v1/balance')
    assert 'X-ACP-Signature' in headers
    assert 'Authorization' not in headers


def test_auth_headers_falls_back_to_bearer():
    """Test that _auth_headers uses Bearer when no Ed25519 keys."""
    client = UCMClient('http://localhost:3000', api_key='ucm_key_test')
    headers = client._auth_headers('GET', '/v1/balance')
    assert 'Authorization' in headers
    assert 'X-ACP-Signature' not in headers


def test_sign_headers_requires_keys():
    """Test that _sign_headers fails without Ed25519 keys."""
    client = UCMClient('http://localhost:3000', api_key='ucm_key_test')
    with pytest.raises(ValueError, match='Ed25519 keys'):
        client._sign_headers('POST', '/v1/publish')


# ── Exception tests ───────────────────────────────────────────────────


def test_raise_for_acp_error_maps_code():
    """Test that ACP error codes map to correct exception classes."""
    with pytest.raises(InvalidRequestError) as exc_info:
        raise_for_acp_error(400, {'error': {'code': 'INVALID_REQUEST', 'message': 'bad'}})
    assert exc_info.value.code == 'INVALID_REQUEST'
    assert exc_info.value.status_code == 400


def test_raise_for_acp_error_auth():
    """Test authentication error mapping."""
    with pytest.raises(AuthenticationError):
        raise_for_acp_error(401, {'error': {'code': 'INVALID_SIGNATURE', 'message': 'bad sig'}})


def test_raise_for_acp_error_budget():
    """Test budget error mapping."""
    with pytest.raises(BudgetError):
        raise_for_acp_error(403, {'error': {'code': 'BUDGET_RULE_VIOLATED', 'message': 'over limit'}})


def test_raise_for_acp_error_not_found():
    """Test not found error mapping."""
    with pytest.raises(NotFoundError):
        raise_for_acp_error(404, {'error': {'code': 'SERVICE_NOT_FOUND', 'message': 'not found'}})


def test_raise_for_acp_error_conflict():
    """Test conflict error mapping."""
    with pytest.raises(ConflictError):
        raise_for_acp_error(409, {'error': {'code': 'SERVICE_ALREADY_EXISTS', 'message': 'exists'}})


def test_raise_for_acp_error_wallet_required():
    """Test wallet required error mapping."""
    with pytest.raises(WalletRequiredError):
        raise_for_acp_error(403, {'error': {'code': 'WALLET_REQUIRED', 'message': 'need wallet'}})


def test_raise_for_acp_error_rate_limit():
    """Test rate limit error mapping."""
    with pytest.raises(RateLimitError):
        raise_for_acp_error(429, {'error': {'code': 'RATE_LIMITED', 'message': 'slow down'}})


def test_raise_for_acp_error_nonce_reused():
    """Test nonce reused maps to AuthenticationError."""
    with pytest.raises(AuthenticationError):
        raise_for_acp_error(401, {'error': {'code': 'NONCE_REUSED', 'message': 'replay'}})


def test_raise_for_acp_error_insufficient_budget():
    """Test insufficient budget maps to BudgetError."""
    with pytest.raises(BudgetError):
        raise_for_acp_error(402, {'error': {'code': 'INSUFFICIENT_BUDGET', 'message': 'broke'}})


def test_raise_for_acp_error_unknown_code():
    """Test unknown error code falls back to ACPError."""
    with pytest.raises(ACPError) as exc_info:
        raise_for_acp_error(500, {'error': {'code': 'UNKNOWN_CODE', 'message': 'wat'}})
    assert exc_info.value.code == 'UNKNOWN_CODE'


def test_raise_for_acp_error_flat_body():
    """Test that error extraction works without nested 'error' key."""
    with pytest.raises(ACPError):
        raise_for_acp_error(500, {'code': 'INTERNAL_ERROR', 'message': 'oops'})


def test_acp_error_attributes():
    """Test ACPError carries all relevant attributes."""
    try:
        raise_for_acp_error(403, {
            'error': {'code': 'WALLET_REQUIRED', 'message': 'bind wallet first', 'bind_url': '/v1/agents/bind-wallet'}
        })
    except WalletRequiredError as e:
        assert e.code == 'WALLET_REQUIRED'
        assert e.message == 'bind wallet first'
        assert e.status_code == 403
        assert e.detail['bind_url'] == '/v1/agents/bind-wallet'
        assert 'WALLET_REQUIRED' in str(e)


# ── History limit bounds ──────────────────────────────────────────────


def test_history_clamps_limit():
    """Test that history() clamps limit to 1-100."""
    priv, pub = generate_keypair()
    client = UCMClient('http://localhost:3000', priv, pub)

    # Test lower bound
    headers = client._auth_headers('GET', '/v1/history?limit=1')
    assert headers is not None  # Just checking it doesn't crash

    # Verify the path would be clamped (can't easily test without HTTP)
    # The clamping logic is: min(max(limit, 1), 100)
    assert min(max(-5, 1), 100) == 1
    assert min(max(0, 1), 100) == 1
    assert min(max(50, 1), 100) == 50
    assert min(max(200, 1), 100) == 100


def test_discover_clamps_limit():
    """Test that discover() clamps limit to max 20."""
    assert min(50, 20) == 20
    assert min(5, 20) == 5
