"""Unit tests for CertiSigma Python SDK."""

import pytest
import httpx
import respx

from certisigma import (
    CertiSigmaClient,
    AsyncCertiSigmaClient,
    AuthenticationError,
    RateLimitError,
    QuotaExceededError,
)

BASE = "https://test.certisigma.ch/api"
KEY = "cs_live_test_key_000"
VALID_HASH = "a" * 64


class TestSyncClient:
    def _client(self):
        return CertiSigmaClient(api_key=KEY, base_url=BASE)

    @respx.mock
    def test_attest_success(self):
        respx.post(f"{BASE}/attest").mock(return_value=httpx.Response(200, json={
            "id": "att_1", "hash_hex": VALID_HASH, "timestamp": "2026-01-01T00:00:00Z",
            "status": "created", "signature": "sig123", "signing_key_id": "k1",
        }))
        with self._client() as c:
            r = c.attest(VALID_HASH, source="test")
        assert r.id == "att_1"
        assert r.status == "created"

    @respx.mock
    def test_verify_exists(self):
        respx.post(f"{BASE}/verify").mock(return_value=httpx.Response(200, json={
            "exists": True, "hash_hex": VALID_HASH, "id": "att_1",
            "timestamp": "2026-01-01T00:00:00Z", "verified_at": "2026-01-02T00:00:00Z",
        }))
        with self._client() as c:
            r = c.verify(VALID_HASH)
        assert r.exists is True

    @respx.mock
    def test_auth_error(self):
        respx.post(f"{BASE}/attest").mock(return_value=httpx.Response(401, json={
            "detail": "Invalid API key",
        }))
        with pytest.raises(AuthenticationError):
            with self._client() as c:
                c.attest(VALID_HASH)

    @respx.mock
    def test_rate_limit(self):
        respx.post(f"{BASE}/attest").mock(return_value=httpx.Response(
            429, json={"detail": "Rate limit exceeded"}, headers={"Retry-After": "30"},
        ))
        with pytest.raises(RateLimitError) as exc_info:
            with self._client() as c:
                c.attest(VALID_HASH)
        assert exc_info.value.retry_after == 30

    @respx.mock
    def test_quota_exceeded(self):
        respx.post(f"{BASE}/attest").mock(return_value=httpx.Response(
            429, json={"detail": "Monthly quota exhausted"},
        ))
        with pytest.raises(QuotaExceededError):
            with self._client() as c:
                c.attest(VALID_HASH)

    def test_invalid_hash(self):
        with self._client() as c:
            with pytest.raises(ValueError, match="Invalid SHA-256"):
                c.attest("not_a_valid_hash")

    @respx.mock
    def test_batch_attest(self):
        respx.post(f"{BASE}/attest/batch").mock(return_value=httpx.Response(200, json={
            "batch_id": "b1", "count": 2, "created": 2, "existing": 0, "attestations": [],
        }))
        with self._client() as c:
            r = c.batch_attest([VALID_HASH, "b" * 64])
        assert r.count == 2

    @respx.mock
    def test_batch_attest_enriched_response(self):
        respx.post(f"{BASE}/attest/batch").mock(return_value=httpx.Response(200, json={
            "batch_id": "b1", "count": 1, "created": 1, "existing": 0,
            "attestations": [{
                "id": "att_100", "hash_hex": VALID_HASH,
                "timestamp": "2026-02-28T00:00:00+00:00", "status": "created",
                "signature": "MEUCIQDx...", "format_version": "2.0.0",
                "signing_key_id": "key-1", "claim_id": 42,
                "source": "my-app", "extra_data": {"p": "alpha"},
                "client_encrypted": False, "etag": 'W/"abc"',
            }],
        }))
        with self._client() as c:
            r = c.batch_attest([VALID_HASH])
        assert r.count == 1
        att = r.attestations[0]
        assert att["claim_id"] == 42
        assert att["source"] == "my-app"
        assert att["etag"] == 'W/"abc"'

    @respx.mock
    def test_health(self):
        respx.get(f"{BASE}/healthz").mock(return_value=httpx.Response(200, json={
            "status": "ok", "database": "connected",
        }))
        with self._client() as c:
            r = c.health()
        assert r["status"] == "ok"

    @respx.mock
    def test_update_metadata(self):
        respx.patch(f"{BASE}/attestation/42/metadata").mock(return_value=httpx.Response(200, json={
            "success": True, "claim_id": 10, "attestation_id": "att_42",
            "source": "new-src", "extra_data": {"k": "v"},
            "deleted": False, "audit_entries": 2,
        }))
        with self._client() as c:
            r = c.update_metadata("att_42", source="new-src", extra_data={"k": "v"})
        assert r.success is True
        assert r.claim_id == 10
        assert r.source == "new-src"
        assert r.extra_data == {"k": "v"}
        assert r.audit_entries == 2

    @respx.mock
    def test_delete_metadata(self):
        respx.patch(f"{BASE}/attestation/5/metadata").mock(return_value=httpx.Response(200, json={
            "success": True, "claim_id": 5, "attestation_id": "att_5",
            "source": None, "extra_data": None,
            "deleted": True, "audit_entries": 1,
        }))
        with self._client() as c:
            r = c.delete_metadata(5)
        assert r.success is True
        assert r.deleted is True

    @respx.mock
    def test_get_evidence(self):
        respx.get(f"{BASE}/attestation/99/evidence").mock(return_value=httpx.Response(200, json={
            "id": "att_99", "hash_hex": VALID_HASH, "level": "T1",
            "t0": {"signature": "sig"}, "t1": {"batchId": 1}, "t2": None,
        }))
        with self._client() as c:
            r = c.get_evidence("att_99")
        assert r.level == "T1"
        assert r.t0 == {"signature": "sig"}
        assert r.t2 is None

    @respx.mock
    def test_attest_file(self, tmp_path):
        respx.post(f"{BASE}/attest").mock(return_value=httpx.Response(200, json={
            "id": "att_file", "hash_hex": VALID_HASH, "timestamp": "2026-01-01T00:00:00Z",
            "status": "created",
        }))
        f = tmp_path / "test.txt"
        f.write_text("hello world")
        with self._client() as c:
            r = c.attest_file(str(f))
        assert r.id == "att_file"
        assert r.status == "created"


@pytest.mark.asyncio
class TestAsyncClient:
    @respx.mock
    async def test_async_attest(self):
        respx.post(f"{BASE}/attest").mock(return_value=httpx.Response(200, json={
            "id": "att_2", "hash_hex": VALID_HASH, "timestamp": "2026-01-01T00:00:00Z",
            "status": "created",
        }))
        async with AsyncCertiSigmaClient(api_key=KEY, base_url=BASE) as c:
            r = await c.attest(VALID_HASH)
        assert r.id == "att_2"

    @respx.mock
    async def test_async_verify(self):
        respx.post(f"{BASE}/verify").mock(return_value=httpx.Response(200, json={
            "exists": False, "hash_hex": VALID_HASH, "verified_at": "2026-01-02T00:00:00Z",
        }))
        async with AsyncCertiSigmaClient(api_key=KEY, base_url=BASE) as c:
            r = await c.verify(VALID_HASH)
        assert r.exists is False

    @respx.mock
    async def test_async_update_metadata(self):
        respx.patch(f"{BASE}/attestation/10/metadata").mock(return_value=httpx.Response(200, json={
            "success": True, "claim_id": 10, "attestation_id": "att_10",
            "source": "async-src", "extra_data": None,
            "deleted": False, "audit_entries": 1,
        }))
        async with AsyncCertiSigmaClient(api_key=KEY, base_url=BASE) as c:
            r = await c.update_metadata(10, source="async-src")
        assert r.success is True
        assert r.source == "async-src"

    @respx.mock
    async def test_async_delete_metadata(self):
        respx.patch(f"{BASE}/attestation/10/metadata").mock(return_value=httpx.Response(200, json={
            "success": True, "claim_id": 10, "attestation_id": "att_10",
            "deleted": True, "audit_entries": 1,
        }))
        async with AsyncCertiSigmaClient(api_key=KEY, base_url=BASE) as c:
            r = await c.delete_metadata(10)
        assert r.deleted is True

    @respx.mock
    async def test_async_get_evidence(self):
        respx.get(f"{BASE}/attestation/55/evidence").mock(return_value=httpx.Response(200, json={
            "id": "att_55", "hash_hex": VALID_HASH, "level": "T0",
            "t0": {"signature": "abc"}, "t1": None, "t2": None,
        }))
        async with AsyncCertiSigmaClient(api_key=KEY, base_url=BASE) as c:
            r = await c.get_evidence(55)
        assert r.level == "T0"


class TestKeylessPublicEndpoints:
    """Verify that public endpoints work without an API key."""

    def _client(self):
        return CertiSigmaClient(base_url=BASE)

    @respx.mock
    def test_verify_without_key(self):
        respx.post(f"{BASE}/verify").mock(return_value=httpx.Response(200, json={
            "exists": True, "hash_hex": VALID_HASH, "id": "att_1",
            "timestamp": "2026-01-01T00:00:00Z", "verified_at": "2026-01-02T00:00:00Z",
        }))
        with self._client() as c:
            r = c.verify(VALID_HASH)
        assert r.exists is True

    @respx.mock
    def test_batch_verify_without_key(self):
        respx.post(f"{BASE}/verify/batch").mock(return_value=httpx.Response(200, json={
            "count": 1, "found": 1, "not_found": 0, "results": [],
        }))
        with self._client() as c:
            r = c.batch_verify([VALID_HASH])
        assert r.count == 1

    @respx.mock
    def test_batch_verify_detailed(self):
        route = respx.post(f"{BASE}/verify/batch").mock(return_value=httpx.Response(200, json={
            "count": 1, "found": 1, "not_found": 0,
            "results": [{
                "hash_hex": VALID_HASH, "exists": True,
                "id": "att_100", "timestamp": "2026-02-28T00:00:00+00:00",
                "source": "my-app", "level": "T1",
                "format_version": "2.0.0",
                "t1_timestamp": "2026-02-28T01:00:00+00:00",
                "t1_provider": "DigiCert",
            }],
        }))
        with self._client() as c:
            r = c.batch_verify([VALID_HASH], detailed=True)
        assert r.count == 1
        assert r.results[0]["level"] == "T1"
        assert r.results[0]["source"] == "my-app"
        body = route.calls[0].request.content
        import json
        assert json.loads(body)["detailed"] is True

    @respx.mock
    def test_health_without_key(self):
        respx.get(f"{BASE}/healthz").mock(return_value=httpx.Response(200, json={
            "status": "ok", "database": "connected",
        }))
        with self._client() as c:
            r = c.health()
        assert r["status"] == "ok"

    @respx.mock
    def test_no_auth_header_when_keyless(self):
        route = respx.post(f"{BASE}/verify").mock(return_value=httpx.Response(200, json={
            "exists": False, "hash_hex": VALID_HASH,
        }))
        with self._client() as c:
            c.verify(VALID_HASH)
        req = route.calls[0].request
        assert "authorization" not in {k.lower() for k in dict(req.headers).keys()}

    def test_attest_raises_without_key(self):
        with self._client() as c:
            with pytest.raises(AuthenticationError, match="api_key is required"):
                c.attest(VALID_HASH)

    def test_batch_attest_raises_without_key(self):
        with self._client() as c:
            with pytest.raises(AuthenticationError, match="api_key is required"):
                c.batch_attest([VALID_HASH])

    def test_attest_file_raises_without_key(self, tmp_path):
        f = tmp_path / "test.txt"
        f.write_text("hello")
        with self._client() as c:
            with pytest.raises(AuthenticationError, match="api_key is required"):
                c.attest_file(str(f))

    def test_update_metadata_raises_without_key(self):
        with self._client() as c:
            with pytest.raises(AuthenticationError, match="api_key is required"):
                c.update_metadata(1, source="x")

    def test_delete_metadata_raises_without_key(self):
        with self._client() as c:
            with pytest.raises(AuthenticationError, match="api_key is required"):
                c.delete_metadata(1)

    def test_default_constructor_no_key(self):
        c = CertiSigmaClient(base_url=BASE)
        assert c._api_key is None
        c.close()


@pytest.mark.asyncio
class TestAsyncKeylessPublicEndpoints:
    @respx.mock
    async def test_async_verify_without_key(self):
        respx.post(f"{BASE}/verify").mock(return_value=httpx.Response(200, json={
            "exists": True, "hash_hex": VALID_HASH, "verified_at": "2026-01-02T00:00:00Z",
        }))
        async with AsyncCertiSigmaClient(base_url=BASE) as c:
            r = await c.verify(VALID_HASH)
        assert r.exists is True

    async def test_async_attest_raises_without_key(self):
        async with AsyncCertiSigmaClient(base_url=BASE) as c:
            with pytest.raises(AuthenticationError, match="api_key is required"):
                await c.attest(VALID_HASH)


class TestCryptoModule:
    """Tests for certisigma.crypto client-side encryption."""

    def test_generate_key(self):
        from certisigma.crypto import generate_key
        key = generate_key()
        assert len(key) == 64
        assert all(c in "0123456789abcdef" for c in key)

    def test_encrypt_decrypt_roundtrip(self):
        from certisigma.crypto import generate_key, encrypt_metadata, decrypt_metadata
        key = generate_key()
        data = {"secret": "value", "num": 42}
        envelope = encrypt_metadata(data, key)

        assert "ciphertext" in envelope
        assert "nonce" in envelope
        assert envelope["v"] == 1
        assert len(envelope["nonce"]) == 24

        decrypted = decrypt_metadata(envelope, key)
        assert decrypted == data

    def test_is_encrypted(self):
        from certisigma.crypto import generate_key, encrypt_metadata, is_encrypted
        key = generate_key()
        envelope = encrypt_metadata({"a": 1}, key)
        assert is_encrypted(envelope) is True
        assert is_encrypted({"a": 1}) is False
        assert is_encrypted(None) is False
        assert is_encrypted("string") is False

    def test_rotate_key(self):
        from certisigma.crypto import generate_key, encrypt_metadata, decrypt_metadata, rotate_key
        old_key = generate_key()
        new_key = generate_key()
        data = {"rotated": True}

        env1 = encrypt_metadata(data, old_key)
        env2 = rotate_key(env1, old_key, new_key)

        assert env1["ciphertext"] != env2["ciphertext"]
        decrypted = decrypt_metadata(env2, new_key)
        assert decrypted == data

    def test_invalid_key_length(self):
        from certisigma.crypto import encrypt_metadata
        with pytest.raises(ValueError, match="Invalid key"):
            encrypt_metadata({"a": 1}, "abcd")

    def test_invalid_envelope(self):
        from certisigma.crypto import generate_key, decrypt_metadata
        key = generate_key()
        with pytest.raises(ValueError, match="Invalid envelope"):
            decrypt_metadata({}, key)
        with pytest.raises((ValueError, TypeError)):
            decrypt_metadata(None, key)

    def test_wrong_key_fails(self):
        from certisigma.crypto import generate_key, encrypt_metadata, decrypt_metadata
        key1 = generate_key()
        key2 = generate_key()
        envelope = encrypt_metadata({"secret": 1}, key1)
        with pytest.raises(Exception):
            decrypt_metadata(envelope, key2)
