"""
Tests for the Brynq Cloud Server API.

Covers:
    - POST /v1/plan — Plan generation, signing, model routing
    - POST /v1/report — Execution metadata ingestion
    - GET /v1/models — Model registry listing
    - GET /v1/agents — Agent marketplace listing
    - POST /v1/auth/register — Runtime registration
    - POST /v1/auth/verify — Token verification
    - JWT auth enforcement on all protected endpoints
    - Rate limiting
    - Request validation (Pydantic)
    - Health check

Uses FastAPI TestClient (synchronous) for all tests.
"""

from __future__ import annotations

import time
from unittest.mock import patch

import pytest
from fastapi.testclient import TestClient

from cloud.auth.jwt_handler import create_token, verify_token
from cloud.config import CloudConfig
from cloud.server import create_app


# ── Fixtures ──────────────────────────────────────────────────────────


TEST_JWT_SECRET = "test-jwt-secret-not-for-production"
TEST_HMAC_SECRET = "test-hmac-secret-not-for-production"


def make_test_config(**overrides) -> CloudConfig:
    """Create a test config with sensible defaults."""
    defaults = {
        "host": "127.0.0.1",
        "port": 8000,
        "jwt_secret": TEST_JWT_SECRET,
        "plan_hmac_secret": TEST_HMAC_SECRET,
        "plan_ttl_seconds": 300,
        "database_url": "sqlite:///test.db",
        "cors_origins": ["http://localhost:3000"],
        "rate_limit_plans_per_min": 100,
        "rate_limit_reports_per_min": 200,
        "debug": True,
    }
    defaults.update(overrides)
    return CloudConfig(**defaults)


@pytest.fixture
def config() -> CloudConfig:
    return make_test_config()


@pytest.fixture
def app(config):
    return create_app(config)


@pytest.fixture
def client(app) -> TestClient:
    return TestClient(app)


@pytest.fixture
def auth_token(config) -> str:
    """A valid JWT token for test requests."""
    return create_token(
        user_id="test-user-001",
        secret=config.jwt_secret,
        tier="pro",
        ttl_seconds=3600,
    )


@pytest.fixture
def auth_headers(auth_token) -> dict[str, str]:
    """Authorization headers with a valid Bearer token."""
    return {"Authorization": f"Bearer {auth_token}"}


def make_plan_request(
    user_input: str = "Analyze this financial report",
    models: list[dict] | None = None,
    agents: list[str] | None = None,
    preferences: dict | None = None,
) -> dict:
    """Build a valid plan request body."""
    if models is None:
        models = [
            {"name": "llama3", "backend": "ollama", "type": "local"},
            {"name": "claude-sonnet-4-20250514", "backend": "claude", "type": "cloud"},
        ]
    return {
        "user_input": user_input,
        "available_models": models,
        "available_agents": agents or [],
        "preferences": preferences or {"speed": "balanced"},
    }


# ── Health Check ──────────────────────────────────────────────────────


class TestHealth:
    """Health endpoint tests."""

    def test_health_returns_ok(self, client):
        resp = client.get("/health")
        assert resp.status_code == 200
        data = resp.json()
        assert data["status"] == "ok"
        assert data["service"] == "brynq-cloud"

    def test_health_no_auth_required(self, client):
        """Health check should work without any auth headers."""
        resp = client.get("/health")
        assert resp.status_code == 200


# ── Auth: Registration ────────────────────────────────────────────────


class TestAuthRegister:
    """POST /v1/auth/register tests."""

    def test_register_new_runtime(self, client):
        resp = client.post("/v1/auth/register", json={
            "runtime_id": "test-runtime-abc",
            "tier": "free",
        })
        assert resp.status_code == 200
        data = resp.json()
        assert "user_id" in data
        assert "token" in data
        assert data["tier"] == "free"
        assert data["expires_in"] == 86400

    def test_register_returns_valid_jwt(self, client, config):
        resp = client.post("/v1/auth/register", json={
            "runtime_id": "test-runtime-jwt",
        })
        data = resp.json()
        # The returned token should be verifiable
        payload = verify_token(data["token"], config.jwt_secret)
        assert payload["sub"] == data["user_id"]
        assert payload["tier"] == "free"

    def test_register_idempotent(self, client):
        """Registering the same runtime_id twice returns the same user_id."""
        body = {"runtime_id": "idempotent-test"}
        resp1 = client.post("/v1/auth/register", json=body)
        resp2 = client.post("/v1/auth/register", json=body)
        assert resp1.json()["user_id"] == resp2.json()["user_id"]

    def test_register_pro_tier(self, client):
        resp = client.post("/v1/auth/register", json={
            "runtime_id": "pro-runtime",
            "tier": "pro",
        })
        assert resp.json()["tier"] == "pro"

    def test_register_missing_runtime_id(self, client):
        resp = client.post("/v1/auth/register", json={})
        assert resp.status_code == 422  # Pydantic validation error

    def test_register_empty_runtime_id(self, client):
        resp = client.post("/v1/auth/register", json={"runtime_id": ""})
        assert resp.status_code == 422


# ── Auth: Verify ──────────────────────────────────────────────────────


class TestAuthVerify:
    """POST /v1/auth/verify tests."""

    def test_verify_valid_token(self, client, auth_token):
        resp = client.post("/v1/auth/verify", json={"token": auth_token})
        assert resp.status_code == 200
        data = resp.json()
        assert data["valid"] is True
        assert data["user_id"] == "test-user-001"
        assert data["tier"] == "pro"

    def test_verify_invalid_token(self, client):
        resp = client.post("/v1/auth/verify", json={"token": "garbage.token.here"})
        assert resp.status_code == 200
        data = resp.json()
        assert data["valid"] is False
        assert data["user_id"] is None

    def test_verify_expired_token(self, client, config):
        # Create a token that expires immediately
        token = create_token(
            user_id="expiring-user",
            secret=config.jwt_secret,
            ttl_seconds=-1,  # Already expired
        )
        # Give it a moment to be in the past
        resp = client.post("/v1/auth/verify", json={"token": token})
        data = resp.json()
        assert data["valid"] is False

    def test_verify_missing_token(self, client):
        resp = client.post("/v1/auth/verify", json={})
        assert resp.status_code == 422


# ── JWT Auth Enforcement ──────────────────────────────────────────────


class TestJWTAuth:
    """All protected endpoints require valid JWT."""

    def test_plan_requires_auth(self, client):
        resp = client.post("/v1/plan", json=make_plan_request())
        assert resp.status_code == 401

    def test_report_requires_auth(self, client):
        resp = client.post("/v1/report", json={
            "plan_id": "abc",
            "steps": [{"step_id": "s1", "elapsed_ms": 100, "tokens": 50, "success": True}],
            "total_elapsed_ms": 100,
        })
        assert resp.status_code == 401

    def test_models_requires_auth(self, client):
        resp = client.get("/v1/models")
        assert resp.status_code == 401

    def test_agents_requires_auth(self, client):
        resp = client.get("/v1/agents")
        assert resp.status_code == 401

    def test_invalid_token_rejected(self, client):
        headers = {"Authorization": "Bearer invalid.token.value"}
        resp = client.post("/v1/plan", json=make_plan_request(), headers=headers)
        assert resp.status_code == 401

    def test_bearer_prefix_required(self, client, auth_token):
        """Token without Bearer prefix should still work (bare token)."""
        headers = {"Authorization": auth_token}
        resp = client.get("/v1/models", headers=headers)
        assert resp.status_code == 200

    def test_malformed_auth_header(self, client):
        headers = {"Authorization": "Basic user:pass"}
        resp = client.get("/v1/models", headers=headers)
        assert resp.status_code == 401


# ── Plan Endpoint ─────────────────────────────────────────────────────


class TestCreatePlan:
    """POST /v1/plan tests."""

    def test_basic_plan_creation(self, client, auth_headers):
        resp = client.post("/v1/plan", json=make_plan_request(), headers=auth_headers)
        assert resp.status_code == 200
        data = resp.json()
        assert "plan_id" in data
        assert "steps" in data
        assert "signature" in data
        assert "expires_at" in data
        assert len(data["steps"]) >= 1
        assert data["version"] == 1

    def test_plan_has_valid_signature(self, client, auth_headers):
        """The returned signature should be a non-empty hex string."""
        resp = client.post("/v1/plan", json=make_plan_request(), headers=auth_headers)
        data = resp.json()
        sig = data["signature"]
        assert len(sig) == 64  # SHA-256 hex digest
        assert all(c in "0123456789abcdef" for c in sig)

    def test_plan_expires_in_future(self, client, auth_headers):
        resp = client.post("/v1/plan", json=make_plan_request(), headers=auth_headers)
        data = resp.json()
        assert data["expires_at"] > time.time()

    def test_plan_step_structure(self, client, auth_headers):
        resp = client.post("/v1/plan", json=make_plan_request(), headers=auth_headers)
        step = resp.json()["steps"][0]
        assert "step_id" in step
        assert "type" in step
        assert "backend" in step
        assert "model" in step
        assert "prompt" in step
        assert step["type"] in ("local", "cloud", "local_agent", "remote_agent")

    def test_plan_routes_analysis_to_cloud(self, client, auth_headers):
        """Analysis tasks should prefer cloud models with analysis strength."""
        body = make_plan_request(user_input="Analyze this financial report in detail")
        resp = client.post("/v1/plan", json=body, headers=auth_headers)
        steps = resp.json()["steps"]
        # Should pick claude (has "analysis" strength) over llama3
        assert any(s["backend"] == "claude" for s in steps)

    def test_plan_with_single_local_model(self, client, auth_headers):
        """When only local models available, plan uses them."""
        body = make_plan_request(
            user_input="Summarize this document",
            models=[{"name": "llama3", "backend": "ollama", "type": "local"}],
        )
        resp = client.post("/v1/plan", json=body, headers=auth_headers)
        steps = resp.json()["steps"]
        assert all(s["type"] == "local" for s in steps)
        assert all(s["backend"] == "ollama" for s in steps)

    def test_multi_task_plan(self, client, auth_headers):
        """Request with multiple tasks should produce multiple steps."""
        body = make_plan_request(
            user_input="Analyze this report then summarize the findings",
        )
        resp = client.post("/v1/plan", json=body, headers=auth_headers)
        steps = resp.json()["steps"]
        assert len(steps) >= 2

    def test_plan_context_refs(self, client, auth_headers):
        """Multi-step plans should chain context between steps."""
        body = make_plan_request(
            user_input="Analyze this data and then write a summary",
        )
        resp = client.post("/v1/plan", json=body, headers=auth_headers)
        steps = resp.json()["steps"]
        if len(steps) >= 2:
            # First step references original input
            assert "original_input" in steps[0]["context_refs"]
            # Second step references first step's output
            assert "step_1_output" in steps[1]["context_refs"]

    def test_plan_empty_user_input_rejected(self, client, auth_headers):
        body = make_plan_request(user_input="")
        # Pydantic min_length=1 should reject this
        resp = client.post("/v1/plan", json=body, headers=auth_headers)
        assert resp.status_code == 422

    def test_plan_no_models_rejected(self, client, auth_headers):
        body = make_plan_request(models=[])
        resp = client.post("/v1/plan", json=body, headers=auth_headers)
        assert resp.status_code == 422

    def test_plan_missing_fields_rejected(self, client, auth_headers):
        resp = client.post("/v1/plan", json={}, headers=auth_headers)
        assert resp.status_code == 422

    def test_plan_speed_fast_prefers_local(self, client, auth_headers):
        """Speed=fast preference should favor local models."""
        body = make_plan_request(
            user_input="Do something general",
            preferences={"speed": "fast"},
        )
        resp = client.post("/v1/plan", json=body, headers=auth_headers)
        steps = resp.json()["steps"]
        # With "fast" preference, local models get a boost
        assert steps[0]["type"] == "local"


# ── Report Endpoint ───────────────────────────────────────────────────


class TestSubmitReport:
    """POST /v1/report tests."""

    def test_basic_report(self, client, auth_headers):
        resp = client.post("/v1/report", json={
            "plan_id": "test-plan-001",
            "steps": [
                {"step_id": "step_1", "elapsed_ms": 2340, "tokens": 1500, "success": True},
                {"step_id": "step_2", "elapsed_ms": 890, "tokens": 400, "success": True},
            ],
            "total_elapsed_ms": 3230,
        }, headers=auth_headers)
        assert resp.status_code == 200
        data = resp.json()
        assert data["status"] == "received"
        assert data["plan_id"] == "test-plan-001"

    def test_report_with_failure(self, client, auth_headers):
        resp = client.post("/v1/report", json={
            "plan_id": "failed-plan",
            "steps": [
                {"step_id": "step_1", "elapsed_ms": 100, "tokens": 50, "success": True},
                {"step_id": "step_2", "elapsed_ms": 50, "tokens": 0, "success": False, "error": "Model unavailable"},
            ],
            "total_elapsed_ms": 150,
        }, headers=auth_headers)
        assert resp.status_code == 200

    def test_report_stored(self, client, auth_headers, app):
        """Reports should be stored for quality tracking."""
        client.post("/v1/report", json={
            "plan_id": "stored-plan",
            "steps": [{"step_id": "s1", "elapsed_ms": 100, "tokens": 50, "success": True}],
            "total_elapsed_ms": 100,
        }, headers=auth_headers)
        assert app.state.report_store.count >= 1
        stored = app.state.report_store.get_for_plan("stored-plan")
        assert stored is not None
        assert stored["plan_id"] == "stored-plan"

    def test_report_missing_steps(self, client, auth_headers):
        resp = client.post("/v1/report", json={
            "plan_id": "no-steps",
            "steps": [],
            "total_elapsed_ms": 0,
        }, headers=auth_headers)
        assert resp.status_code == 422

    def test_report_missing_plan_id(self, client, auth_headers):
        resp = client.post("/v1/report", json={
            "steps": [{"step_id": "s1", "elapsed_ms": 100, "tokens": 0, "success": True}],
            "total_elapsed_ms": 100,
        }, headers=auth_headers)
        assert resp.status_code == 422

    def test_report_negative_elapsed(self, client, auth_headers):
        resp = client.post("/v1/report", json={
            "plan_id": "bad-timing",
            "steps": [{"step_id": "s1", "elapsed_ms": -1, "tokens": 0, "success": True}],
            "total_elapsed_ms": 0,
        }, headers=auth_headers)
        assert resp.status_code == 422


# ── Models Endpoint ───────────────────────────────────────────────────


class TestListModels:
    """GET /v1/models tests."""

    def test_returns_models(self, client, auth_headers):
        resp = client.get("/v1/models", headers=auth_headers)
        assert resp.status_code == 200
        data = resp.json()
        assert "models" in data
        assert len(data["models"]) > 0

    def test_model_structure(self, client, auth_headers):
        resp = client.get("/v1/models", headers=auth_headers)
        model = resp.json()["models"][0]
        assert "id" in model
        assert "backend" in model
        assert "type" in model
        assert "strengths" in model
        assert isinstance(model["strengths"], list)

    def test_includes_local_and_cloud(self, client, auth_headers):
        resp = client.get("/v1/models", headers=auth_headers)
        models = resp.json()["models"]
        types = {m["type"] for m in models}
        assert "local" in types
        assert "cloud" in types

    def test_includes_known_models(self, client, auth_headers):
        resp = client.get("/v1/models", headers=auth_headers)
        model_ids = {m["id"] for m in resp.json()["models"]}
        assert "llama3" in model_ids
        assert "claude-opus-4-6-20260301" in model_ids

    def test_model_has_strengths(self, client, auth_headers):
        resp = client.get("/v1/models", headers=auth_headers)
        for model in resp.json()["models"]:
            assert len(model["strengths"]) > 0, f"Model {model['id']} has no strengths"


# ── Agents Endpoint ───────────────────────────────────────────────────


class TestListAgents:
    """GET /v1/agents tests."""

    def test_returns_agents(self, client, auth_headers):
        resp = client.get("/v1/agents", headers=auth_headers)
        assert resp.status_code == 200
        data = resp.json()
        assert "agents" in data
        assert len(data["agents"]) > 0

    def test_agent_structure(self, client, auth_headers):
        resp = client.get("/v1/agents", headers=auth_headers)
        agent = resp.json()["agents"][0]
        assert "id" in agent
        assert "type" in agent
        assert "description" in agent
        assert "endpoint_type" in agent

    def test_includes_open_source_and_commercial(self, client, auth_headers):
        resp = client.get("/v1/agents", headers=auth_headers)
        agents = resp.json()["agents"]
        types = {a["type"] for a in agents}
        assert "open_source" in types
        assert "commercial" in types

    def test_commercial_agent_has_price(self, client, auth_headers):
        resp = client.get("/v1/agents", headers=auth_headers)
        commercial = [a for a in resp.json()["agents"] if a["type"] == "commercial"]
        assert len(commercial) > 0
        for agent in commercial:
            assert agent["price_per_call"] is not None
            assert agent["price_per_call"] > 0

    def test_open_source_agent_no_price(self, client, auth_headers):
        resp = client.get("/v1/agents", headers=auth_headers)
        oss = [a for a in resp.json()["agents"] if a["type"] == "open_source"]
        assert len(oss) > 0
        for agent in oss:
            assert agent["price_per_call"] is None


# ── Rate Limiting ─────────────────────────────────────────────────────


class TestRateLimiting:
    """Rate limiting tests."""

    def test_plan_rate_limit(self):
        """Exceeding plan rate limit returns 429."""
        config = make_test_config(rate_limit_plans_per_min=3)
        app = create_app(config)
        client = TestClient(app)
        token = create_token("rate-test-user", config.jwt_secret)
        headers = {"Authorization": f"Bearer {token}"}

        # First 3 should succeed
        for _ in range(3):
            resp = client.post("/v1/plan", json=make_plan_request(), headers=headers)
            assert resp.status_code == 200

        # 4th should be rate limited
        resp = client.post("/v1/plan", json=make_plan_request(), headers=headers)
        assert resp.status_code == 429

    def test_rate_limit_per_user(self):
        """Different users have independent rate limits."""
        config = make_test_config(rate_limit_plans_per_min=2)
        app = create_app(config)
        client = TestClient(app)

        token_a = create_token("user-a", config.jwt_secret)
        token_b = create_token("user-b", config.jwt_secret)
        headers_a = {"Authorization": f"Bearer {token_a}"}
        headers_b = {"Authorization": f"Bearer {token_b}"}

        # User A exhausts their limit
        for _ in range(2):
            client.post("/v1/plan", json=make_plan_request(), headers=headers_a)

        # User A is now limited
        resp = client.post("/v1/plan", json=make_plan_request(), headers=headers_a)
        assert resp.status_code == 429

        # User B should still be fine
        resp = client.post("/v1/plan", json=make_plan_request(), headers=headers_b)
        assert resp.status_code == 200

    def test_report_rate_limit(self):
        """Exceeding report rate limit returns 429."""
        config = make_test_config(rate_limit_reports_per_min=2)
        app = create_app(config)
        client = TestClient(app)
        token = create_token("report-rate-user", config.jwt_secret)
        headers = {"Authorization": f"Bearer {token}"}
        report_body = {
            "plan_id": "test",
            "steps": [{"step_id": "s1", "elapsed_ms": 100, "tokens": 50, "success": True}],
            "total_elapsed_ms": 100,
        }

        for _ in range(2):
            resp = client.post("/v1/report", json=report_body, headers=headers)
            assert resp.status_code == 200

        resp = client.post("/v1/report", json=report_body, headers=headers)
        assert resp.status_code == 429


# ── End-to-End Flow ───────────────────────────────────────────────────


class TestEndToEnd:
    """Full register -> plan -> report flow."""

    def test_full_lifecycle(self, client, config):
        """Simulate a complete runtime lifecycle."""
        # 1. Register
        reg_resp = client.post("/v1/auth/register", json={"runtime_id": "e2e-runtime"})
        assert reg_resp.status_code == 200
        token = reg_resp.json()["token"]
        headers = {"Authorization": f"Bearer {token}"}

        # 2. Verify token
        verify_resp = client.post("/v1/auth/verify", json={"token": token})
        assert verify_resp.json()["valid"] is True

        # 3. Browse models
        models_resp = client.get("/v1/models", headers=headers)
        assert models_resp.status_code == 200
        assert len(models_resp.json()["models"]) > 0

        # 4. Browse agents
        agents_resp = client.get("/v1/agents", headers=headers)
        assert agents_resp.status_code == 200

        # 5. Request a plan
        plan_resp = client.post("/v1/plan", json=make_plan_request(
            user_input="Analyze this contract and summarize key terms",
        ), headers=headers)
        assert plan_resp.status_code == 200
        plan = plan_resp.json()
        assert len(plan["steps"]) >= 1
        assert plan["signature"]

        # 6. Submit execution report
        report_resp = client.post("/v1/report", json={
            "plan_id": plan["plan_id"],
            "steps": [
                {"step_id": s["step_id"], "elapsed_ms": 1000, "tokens": 500, "success": True}
                for s in plan["steps"]
            ],
            "total_elapsed_ms": 1000 * len(plan["steps"]),
        }, headers=headers)
        assert report_resp.status_code == 200
        assert report_resp.json()["plan_id"] == plan["plan_id"]

    def test_plan_then_report_round_trip(self, client, auth_headers):
        """Plan IDs should match between plan and report."""
        plan_resp = client.post("/v1/plan", json=make_plan_request(), headers=auth_headers)
        plan_id = plan_resp.json()["plan_id"]

        report_resp = client.post("/v1/report", json={
            "plan_id": plan_id,
            "steps": [{"step_id": "step_1", "elapsed_ms": 500, "tokens": 200, "success": True}],
            "total_elapsed_ms": 500,
        }, headers=auth_headers)
        assert report_resp.json()["plan_id"] == plan_id


# ── Edge Cases ────────────────────────────────────────────────────────


class TestEdgeCases:
    """Edge case and error handling tests."""

    def test_unknown_endpoint_returns_404(self, client, auth_headers):
        resp = client.get("/v1/nonexistent", headers=auth_headers)
        assert resp.status_code == 404

    def test_wrong_method_returns_405(self, client, auth_headers):
        resp = client.get("/v1/plan", headers=auth_headers)
        assert resp.status_code == 405

    def test_plan_with_long_input(self, client, auth_headers):
        """Long but valid input should work."""
        body = make_plan_request(user_input="Analyze " * 500)
        resp = client.post("/v1/plan", json=body, headers=auth_headers)
        assert resp.status_code == 200

    def test_plan_with_many_models(self, client, auth_headers):
        """Lots of available models should work fine."""
        models = [
            {"name": f"model-{i}", "backend": "ollama", "type": "local"}
            for i in range(20)
        ]
        body = make_plan_request(models=models)
        resp = client.post("/v1/plan", json=body, headers=auth_headers)
        assert resp.status_code == 200

    def test_plan_with_preferences(self, client, auth_headers):
        """Custom preferences should be accepted."""
        body = make_plan_request(preferences={
            "speed": "quality",
            "max_cost": 0.5,
            "language": "en",
        })
        resp = client.post("/v1/plan", json=body, headers=auth_headers)
        assert resp.status_code == 200

    def test_plan_unique_ids(self, client, auth_headers):
        """Each plan should get a unique ID."""
        ids = set()
        for _ in range(5):
            resp = client.post("/v1/plan", json=make_plan_request(), headers=auth_headers)
            ids.add(resp.json()["plan_id"])
        assert len(ids) == 5

    def test_plan_unique_signatures(self, client, auth_headers):
        """Different plans should have different signatures (different plan_ids)."""
        sigs = set()
        for _ in range(5):
            resp = client.post("/v1/plan", json=make_plan_request(), headers=auth_headers)
            sigs.add(resp.json()["signature"])
        assert len(sigs) == 5
