"""
Tests for SOC 2 access controls:
  - Key scopes   (admin / read / evaluate)
  - Key expiry   (TTL enforcement)
  - Key rotation (POST /v1/keys/{id}/rotate)
  - Audit chain  (GET /v1/audit/verify)
"""

import uuid
from datetime import datetime, timedelta

import pytest
from fastapi.testclient import TestClient

from autopil.api.app import create_app


# ── helpers ───────────────────────────────────────────────────────────────────

EVALUATE_PAYLOAD = {
    "query": "show me risk data",
    "agent_role": "analyst",
    "user_id": "u1",
    "source_id": "reports",
    "sensitivity_level": "low",
    "session_id": str(uuid.uuid4()),
}


def make_client(policy_file, audit_db, key: str) -> TestClient:
    app = create_app(policy_path=policy_file, audit_db=audit_db)
    return TestClient(app, headers={"X-API-Key": key}, raise_server_exceptions=True)


def create_scoped_key(client: TestClient, scope: str, expires_days: int | None = None) -> dict:
    """Create a key with the given scope via the API and return the full response body."""
    body: dict = {"name": f"{scope}_key", "scope": scope}
    if expires_days is not None:
        body["expires_days"] = expires_days
    r = client.post("/v1/keys", json=body)
    assert r.status_code == 200, r.text
    return r.json()


# ── scope: evaluate ───────────────────────────────────────────────────────────

class TestEvaluateScope:
    """evaluate-scoped keys may only call POST /v1/context/evaluate."""

    def test_evaluate_key_can_call_evaluate(self, client, policy_file, audit_db):
        key_data = create_scoped_key(client, "evaluate")
        ec = make_client(policy_file, audit_db, key_data["key"])
        r = ec.post("/v1/context/evaluate", json={**EVALUATE_PAYLOAD, "session_id": str(uuid.uuid4())})
        assert r.status_code == 200

    def test_evaluate_key_blocked_from_get_policies(self, client, policy_file, audit_db):
        key_data = create_scoped_key(client, "evaluate")
        ec = make_client(policy_file, audit_db, key_data["key"])
        assert ec.get("/v1/policies").status_code == 403

    def test_evaluate_key_blocked_from_get_events(self, client, policy_file, audit_db):
        key_data = create_scoped_key(client, "evaluate")
        ec = make_client(policy_file, audit_db, key_data["key"])
        assert ec.get("/v1/audit/events").status_code == 403

    def test_evaluate_key_blocked_from_get_keys(self, client, policy_file, audit_db):
        key_data = create_scoped_key(client, "evaluate")
        ec = make_client(policy_file, audit_db, key_data["key"])
        assert ec.get("/v1/keys").status_code == 403

    def test_evaluate_key_blocked_from_create_policy(self, client, policy_file, audit_db):
        key_data = create_scoped_key(client, "evaluate")
        ec = make_client(policy_file, audit_db, key_data["key"])
        r = ec.post("/v1/policies", json={
            "name": "new_policy", "agent_role": "bot",
            "allowed_sources": [], "denied_sources": [],
        })
        assert r.status_code == 403

    def test_create_key_response_includes_scope(self, client):
        body = create_scoped_key(client, "evaluate")
        assert body["scope"] == "evaluate"

    def test_list_keys_includes_scope(self, client):
        create_scoped_key(client, "evaluate")
        keys = client.get("/v1/keys").json()
        scopes = {k["scope"] for k in keys}
        assert "evaluate" in scopes


# ── scope: read ───────────────────────────────────────────────────────────────

class TestReadScope:
    """read-scoped keys may call GET endpoints and POST /v1/context/evaluate."""

    def test_read_key_can_get_policies(self, client, policy_file, audit_db):
        key_data = create_scoped_key(client, "read")
        rc = make_client(policy_file, audit_db, key_data["key"])
        assert rc.get("/v1/policies").status_code == 200

    def test_read_key_can_get_audit_events(self, client, policy_file, audit_db):
        key_data = create_scoped_key(client, "read")
        rc = make_client(policy_file, audit_db, key_data["key"])
        assert rc.get("/v1/audit/events").status_code == 200

    def test_read_key_can_call_evaluate(self, client, policy_file, audit_db):
        key_data = create_scoped_key(client, "read")
        rc = make_client(policy_file, audit_db, key_data["key"])
        r = rc.post("/v1/context/evaluate", json={**EVALUATE_PAYLOAD, "session_id": str(uuid.uuid4())})
        assert r.status_code == 200

    def test_read_key_blocked_from_create_key(self, client, policy_file, audit_db):
        key_data = create_scoped_key(client, "read")
        rc = make_client(policy_file, audit_db, key_data["key"])
        r = rc.post("/v1/keys", json={"name": "sneaky", "scope": "admin"})
        assert r.status_code == 403

    def test_read_key_blocked_from_create_policy(self, client, policy_file, audit_db):
        key_data = create_scoped_key(client, "read")
        rc = make_client(policy_file, audit_db, key_data["key"])
        r = rc.post("/v1/policies", json={
            "name": "readonly_attempt", "agent_role": "bot",
            "allowed_sources": [], "denied_sources": [],
        })
        assert r.status_code == 403

    def test_read_key_blocked_from_revoke_key(self, client, policy_file, audit_db):
        # Create a key to attempt revoking
        target = client.post("/v1/keys", json={"name": "target"}).json()
        key_data = create_scoped_key(client, "read")
        rc = make_client(policy_file, audit_db, key_data["key"])
        assert rc.delete(f"/v1/keys/{target['key_id']}").status_code == 403


# ── scope: admin ──────────────────────────────────────────────────────────────

class TestAdminScope:
    """admin-scoped keys (default) can access all endpoints."""

    def test_admin_key_can_read(self, client):
        assert client.get("/v1/policies").status_code == 200
        assert client.get("/v1/audit/events").status_code == 200
        assert client.get("/v1/keys").status_code == 200

    def test_admin_key_can_write(self, client):
        r = client.post("/v1/context/evaluate", json={**EVALUATE_PAYLOAD, "session_id": str(uuid.uuid4())})
        assert r.status_code == 200

    def test_admin_key_can_create_keys_of_any_scope(self, client):
        for scope in ("admin", "read", "evaluate"):
            body = create_scoped_key(client, scope)
            assert body["scope"] == scope

    def test_create_key_defaults_to_admin_scope(self, client):
        r = client.post("/v1/keys", json={"name": "default_scope_key"})
        assert r.status_code == 200
        assert r.json()["scope"] == "admin"


# ── key expiry ────────────────────────────────────────────────────────────────

class TestKeyExpiry:
    """Keys with expires_days are accepted while valid and rejected once expired."""

    def test_key_with_expiry_includes_expires_at(self, client):
        body = create_scoped_key(client, "admin", expires_days=30)
        assert body["expires_at"] is not None
        # Should be roughly 30 days from now
        exp = datetime.fromisoformat(body["expires_at"])
        delta = exp - datetime.utcnow()
        assert 28 <= delta.days <= 31

    def test_key_without_expiry_has_null_expires_at(self, client):
        body = create_scoped_key(client, "admin")
        assert body["expires_at"] is None

    def test_list_keys_includes_expires_at(self, client):
        create_scoped_key(client, "read", expires_days=10)
        keys = client.get("/v1/keys").json()
        with_expiry = [k for k in keys if k.get("expires_at") is not None]
        assert len(with_expiry) >= 1

    def test_expires_days_must_be_at_least_1(self, client):
        r = client.post("/v1/keys", json={"name": "bad_expiry", "scope": "admin", "expires_days": 0})
        assert r.status_code == 422

    def test_expires_days_max_3650(self, client):
        r = client.post("/v1/keys", json={"name": "too_long", "scope": "admin", "expires_days": 3651})
        assert r.status_code == 422

    def test_expired_key_is_rejected(self, policy_file, audit_db):
        """Directly insert an already-expired key and confirm it is rejected."""
        from autopil.db import create_stores
        from datetime import timezone

        _, key_store, tenant_store, _, _, _, _ = create_stores(db_path=audit_db)
        tenant_id = tenant_store.create_tenant("expiry_tenant")

        # Create key with 1-day TTL, then manually backdated — simulate via DB
        key_id, plaintext = key_store.create_key(
            "expired_key", tenant_id=tenant_id, scope="admin", expires_days=1
        )

        # Directly update expires_at in SQLite to the past
        import sqlite3
        conn = sqlite3.connect(audit_db)
        past = (datetime.utcnow() - timedelta(hours=1)).isoformat()
        conn.execute("UPDATE api_keys SET expires_at=? WHERE key_id=?", (past, key_id))
        conn.commit()
        conn.close()

        app = create_app(policy_path=policy_file, audit_db=audit_db)
        c = TestClient(app, headers={"X-API-Key": plaintext})
        assert c.get("/v1/policies").status_code == 403

    def test_valid_expiry_key_still_works(self, policy_file, audit_db):
        """A key with a future expiry is accepted normally."""
        from autopil.db import create_stores

        _, key_store, tenant_store, _, _, _, _ = create_stores(db_path=audit_db)
        tenant_id = tenant_store.create_tenant("valid_expiry_tenant")
        _, plaintext = key_store.create_key(
            "future_key", tenant_id=tenant_id, scope="admin", expires_days=365
        )

        app = create_app(policy_path=policy_file, audit_db=audit_db)
        c = TestClient(app, headers={"X-API-Key": plaintext})
        assert c.get("/v1/policies").status_code == 200


# ── key rotation ──────────────────────────────────────────────────────────────

class TestKeyRotation:
    """POST /v1/keys/{id}/rotate returns a new key and immediately revokes the old one."""

    def test_rotate_returns_new_key(self, client):
        original = create_scoped_key(client, "admin")
        r = client.post(f"/v1/keys/{original['key_id']}/rotate")
        assert r.status_code == 200
        body = r.json()
        assert "key" in body
        assert body["key"].startswith("apl_")
        assert body["key_id"] != original["key_id"]

    def test_rotate_response_includes_scope(self, client):
        original = create_scoped_key(client, "read")
        r = client.post(f"/v1/keys/{original['key_id']}/rotate")
        assert r.status_code == 200
        assert r.json()["scope"] == "read"

    def test_old_key_rejected_after_rotation(self, client, policy_file, audit_db):
        original = create_scoped_key(client, "admin")
        old_client = make_client(policy_file, audit_db, original["key"])
        assert old_client.get("/v1/policies").status_code == 200

        client.post(f"/v1/keys/{original['key_id']}/rotate")

        assert old_client.get("/v1/policies").status_code == 403

    def test_new_key_works_after_rotation(self, client, policy_file, audit_db):
        original = create_scoped_key(client, "admin")
        r = client.post(f"/v1/keys/{original['key_id']}/rotate")
        new_key = r.json()["key"]

        new_client = make_client(policy_file, audit_db, new_key)
        assert new_client.get("/v1/policies").status_code == 200

    def test_rotate_preserves_scope(self, client, policy_file, audit_db):
        original = create_scoped_key(client, "evaluate")
        r = client.post(f"/v1/keys/{original['key_id']}/rotate")
        new_key = r.json()["key"]

        new_client = make_client(policy_file, audit_db, new_key)
        # evaluate scope: can evaluate but not read policies
        eval_r = new_client.post("/v1/context/evaluate", json={**EVALUATE_PAYLOAD, "session_id": str(uuid.uuid4())})
        assert eval_r.status_code == 200
        assert new_client.get("/v1/policies").status_code == 403

    def test_rotate_unknown_key_returns_404(self, client):
        r = client.post("/v1/keys/doesnotexist/rotate")
        assert r.status_code == 404

    def test_rotate_with_expiry_preserves_expires_at(self, client):
        original = create_scoped_key(client, "admin", expires_days=90)
        r = client.post(f"/v1/keys/{original['key_id']}/rotate")
        assert r.status_code == 200
        body = r.json()
        # New key should also have an expiry
        assert body["expires_at"] is not None

    def test_rotated_old_key_still_appears_in_list_as_revoked(self, client):
        original = create_scoped_key(client, "admin")
        client.post(f"/v1/keys/{original['key_id']}/rotate")
        keys = client.get("/v1/keys").json()
        old = next((k for k in keys if k["key_id"] == original["key_id"]), None)
        assert old is not None
        assert old["is_active"] is False


# ── audit chain verify ────────────────────────────────────────────────────────

class TestAuditChainVerify:
    """GET /v1/audit/verify walks the SHA-256 hash chain for the tenant."""

    def _emit_events(self, client, count: int = 3):
        for _ in range(count):
            client.post("/v1/context/evaluate", json={
                **EVALUATE_PAYLOAD,
                "session_id": str(uuid.uuid4()),
            })

    def test_verify_returns_valid_on_fresh_log(self, client):
        self._emit_events(client, 3)
        r = client.get("/v1/audit/verify")
        assert r.status_code == 200
        body = r.json()
        assert body["valid"] is True
        assert body["broken_at"] is None

    def test_verify_counts_match_events_emitted(self, client):
        self._emit_events(client, 4)
        r = client.get("/v1/audit/verify")
        body = r.json()
        assert body["total"] >= 4
        assert body["chained"] >= 4

    def test_verify_empty_log_is_valid(self, client):
        r = client.get("/v1/audit/verify")
        assert r.status_code == 200
        assert r.json()["valid"] is True

    def test_verify_detects_tampered_chain(self, policy_file, audit_db):
        """Directly corrupt a chain_hash in SQLite and confirm verify catches it."""
        import sqlite3

        from autopil.db import create_stores

        _, key_store, tenant_store, _, _, _, _ = create_stores(db_path=audit_db)
        tenant_id = tenant_store.create_tenant("chain_tenant")
        _, plaintext = key_store.create_key("chain_key", tenant_id=tenant_id)

        app = create_app(policy_path=policy_file, audit_db=audit_db)
        c = TestClient(app, headers={"X-API-Key": plaintext})

        # Emit a few events so the chain has something to break
        for _ in range(3):
            c.post("/v1/context/evaluate", json={**EVALUATE_PAYLOAD, "session_id": str(uuid.uuid4())})

        # Confirm chain is clean
        assert c.get("/v1/audit/verify").json()["valid"] is True

        # Corrupt the chain_hash of the second event
        conn = sqlite3.connect(audit_db)
        rows = conn.execute(
            "SELECT event_id FROM audit_events WHERE tenant_id=? AND chain_hash IS NOT NULL ORDER BY timestamp",
            (tenant_id,)
        ).fetchall()
        assert len(rows) >= 2, "Need at least 2 chained events to test a break"
        second_event_id = rows[1][0]
        conn.execute(
            "UPDATE audit_events SET chain_hash='deadbeef' WHERE event_id=?",
            (second_event_id,)
        )
        conn.commit()
        conn.close()

        # Chain should now be invalid
        result = c.get("/v1/audit/verify").json()
        assert result["valid"] is False
        assert result["broken_at"] is not None

    def test_verify_legacy_events_counted_separately(self, policy_file, audit_db):
        """
        Events with chain_hash=NULL that precede the first chained event are counted
        as legacy, not broken. Simulate by directly inserting a pre-chain event, then
        emitting new events normally (which anchor their chain to GENESIS).
        """
        import sqlite3

        from autopil.db import create_stores

        _, key_store, tenant_store, _, _, _, _ = create_stores(db_path=audit_db)
        tenant_id = tenant_store.create_tenant("legacy_tenant")
        _, plaintext = key_store.create_key("legacy_key", tenant_id=tenant_id)

        # Insert a "legacy" audit event with no chain_hash (pre-chaining era)
        legacy_event_id = str(uuid.uuid4())
        legacy_ts = (datetime.utcnow() - timedelta(seconds=10)).isoformat()
        conn = sqlite3.connect(audit_db)
        conn.execute(
            """INSERT INTO audit_events
               (event_id, session_id, agent_role, user_id, source_id, query,
                decision, policy_name, reason, context_hash, timestamp, tenant_id,
                sensitivity_level, source_type, catalog_enriched, catalog_connection_id,
                chain_hash, prev_hash)
               VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
            (legacy_event_id, str(uuid.uuid4()), "analyst", "u1", "reports",
             "legacy query", "ALLOW", "analyst_policy", "allowed", None,
             legacy_ts, tenant_id, "low", "rest", 0, None, None, None)
        )
        conn.commit()
        conn.close()

        # Now emit real events — they anchor to GENESIS independently
        app = create_app(policy_path=policy_file, audit_db=audit_db)
        c = TestClient(app, headers={"X-API-Key": plaintext})
        for _ in range(2):
            c.post("/v1/context/evaluate", json={**EVALUATE_PAYLOAD, "session_id": str(uuid.uuid4())})

        result = c.get("/v1/audit/verify").json()
        assert result["valid"] is True
        assert result["legacy"] >= 1
        assert result["chained"] >= 2

    def test_verify_requires_auth(self, policy_file, audit_db):
        app = create_app(policy_path=policy_file, audit_db=audit_db)
        c = TestClient(app)
        assert c.get("/v1/audit/verify").status_code in (401, 403)
