"""
HTTP-level integration tests for the agentpaas FastAPI surface.

audit/AUDIT_2026-06-05.md critical #6 baseline:

  $ grep -rln "TestClient\|httpx" tests/
  (no matches)

3,477 LOC of route handlers had ZERO route-level coverage before this
file existed. A `tenant_id` filter typo could ship to production and
CI would still be green. This file is the bootstrap skeleton — it
covers the *most expensive* failure modes (auth bypass, cross-tenant
read, loopback guard) so the framework is in place; subsequent PRs
should fan out per-route happy + sad paths from here.

Conventions for new tests:
  - Use the `client` fixture (TestClient bound to an isolated in-memory
    SQLite DB).
  - Use `tenant_and_key` for an authenticated request.
  - For cross-tenant tests, build the second tenant inline via the `db`
    fixture exposed alongside.
  - Assertions favor *what must not happen* over *exact response shape*;
    we are testing security boundaries, not API ergonomics.
"""
from __future__ import annotations

# IMPORTANT: the env var has to be set BEFORE agentpaas.config is imported,
# because settings.database_url is read at module-import time. conftest.py
# runs first (Python imports), and *this* module's top-level os.environ
# call lands before the `from agentpaas...` lines below.
import os
os.environ.setdefault("AGENTPAAS_DATABASE_URL", "sqlite:///:memory:")

import json
import secrets

import pytest

try:
    from fastapi.testclient import TestClient
except ImportError:  # httpx not installed — skip entire file gracefully
    pytest.skip(
        "fastapi.testclient requires httpx — `pip install -e ./agentpaas/[dev]`",
        allow_module_level=True,
    )

from agentpaas.api.app import app
from agentpaas.api.middleware.auth import hash_key
from agentpaas.db.models import Database, gen_id, now_utc
import agentpaas.db.session as _session_mod


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

@pytest.fixture
def db():
    """Fresh in-memory SQLite per test. Database.__init__ re-runs all
    migrations + the zombie-reaper migration; a brand-new `:memory:` DB
    is guaranteed clean. We swap the module-level singleton in
    `agentpaas.db.session` so both `Depends(get_database)` AND the
    direct `get_db()` calls inside `authenticate()` see the same DB."""
    prev = _session_mod._db
    _session_mod._db = Database("sqlite:///:memory:")
    try:
        yield _session_mod._db
    finally:
        _session_mod._db = prev


@pytest.fixture
def client(db):
    """TestClient bound to the isolated DB. We don't override any
    dependencies — by patching `_session_mod._db`, every code path that
    calls `get_db()` (Depends, or direct) hits the test DB."""
    with TestClient(app) as c:
        yield c


@pytest.fixture
def tenant_and_key(db):
    """Insert a tenant + a user + an active admin-scope API key.
    Returns (tenant_id, raw_api_key). The raw key is shown ONCE — same
    contract as production /bootstrap."""
    tid = gen_id("tn_")
    uid = gen_id("usr_")
    key = f"ap_{secrets.token_hex(16)}"
    now = now_utc()

    db.execute(
        "INSERT INTO tenants (id, name, plan, status, created_at) "
        "VALUES (?, ?, 'free', 'active', ?)",
        (tid, "test-tenant", now),
    )
    db.execute(
        "INSERT INTO users (id, tenant_id, email, role, created_at) "
        "VALUES (?, ?, '', 'admin', ?)",
        (uid, tid, now),
    )
    db.execute(
        "INSERT INTO api_keys "
        "(id, tenant_id, user_id, key_hash, key_prefix, name, scopes, "
        " rate_limit, status, created_at) "
        "VALUES (?, ?, ?, ?, ?, 'test-key', ?, 600, 'active', ?)",
        (
            gen_id("key_"), tid, uid, hash_key(key), key[:8],
            json.dumps(["agents:*", "keys:*", "admin:*"]), now,
        ),
    )
    db.commit()
    return tid, key


def _auth(key: str) -> dict:
    return {"Authorization": f"Bearer {key}"}


# ─────────────────────────────────────────────────────────────────────────────
# Health / no-auth surface
# ─────────────────────────────────────────────────────────────────────────────

def test_setup_health_no_auth(client):
    """SetupWizard polls /api/v1/setup/health before bootstrap — must be
    reachable without a key, and must not leak DB state."""
    r = client.get("/api/v1/setup/health")
    assert r.status_code == 200
    body = r.json()
    assert body.get("ok") is True
    # Should not include things like raw env vars / paths / tenant counts
    assert "secret" not in str(body).lower()
    assert "password" not in str(body).lower()


# ─────────────────────────────────────────────────────────────────────────────
# Auth dependency — critical surface, was untested
# ─────────────────────────────────────────────────────────────────────────────

def test_list_agents_requires_auth(client):
    """No Authorization header → 401. audit #14 root cause: handlers
    without `Depends(get_tenant)` slip through this gate silently;
    this test enforces it on /agents."""
    r = client.get("/api/v1/agents")
    assert r.status_code == 401, (
        f"Expected 401 for missing auth, got {r.status_code}: {r.text[:300]}"
    )


def test_list_agents_rejects_bad_key(client):
    """Garbage Bearer → 401, not 500. Catches both 'key absent in DB'
    and 'authenticate() raised unhandled' regressions."""
    r = client.get("/api/v1/agents", headers=_auth("ap_doesnotexist_0000000000000000"))
    assert r.status_code == 401, (
        f"Expected 401 for invalid key, got {r.status_code}: {r.text[:300]}"
    )


def test_list_agents_accepts_valid_key(client, tenant_and_key):
    """Happy path: real key → 200 + list payload. Anchors the
    authenticate() success path; without this, the legacy-hash auto-
    migration logic (audit SEC-01 leftover at api/middleware/auth.py
    around _hash_key_legacy) is structurally untested."""
    _, key = tenant_and_key
    r = client.get("/api/v1/agents", headers=_auth(key))
    assert r.status_code == 200, r.text[:300]
    body = r.json()
    # tolerant: response may be a bare list or {"items": [...]} or {"agents": [...]}
    items = body if isinstance(body, list) else (
        body.get("items") or body.get("agents") or []
    )
    assert isinstance(items, list)


# ─────────────────────────────────────────────────────────────────────────────
# Phase 0 verification — loopback guard on /bootstrap
# ─────────────────────────────────────────────────────────────────────────────

def test_bootstrap_remote_caller_hidden(client):
    """audit critical #1 fix (commit 9a3f22b): /bootstrap from a non-
    loopback client returns 404, never 200-with-admin-key.

    `TestClient`'s default client host is 'testclient', not '127.0.0.1'
    (https://www.starlette.io/testclient — the host is fabricated). So
    this test runs against the same code path a remote attacker would
    hit, and our `_require_loopback` guard should refuse."""
    r = client.post("/api/v1/setup/bootstrap")
    assert r.status_code == 404, (
        f"/bootstrap leaked from non-loopback host! "
        f"status={r.status_code}, body={r.text[:300]}"
    )


# ─────────────────────────────────────────────────────────────────────────────
# Multi-tenant isolation — audit critical/high category
# ─────────────────────────────────────────────────────────────────────────────

def _insert_tenant_with_agent(db, tenant_name: str, agent_name: str) -> tuple[str, str]:
    """Helper: drop in a tenant + a single agent row. Returns (tid, aid)."""
    tid = gen_id("tn_")
    aid = gen_id("ag_")
    now = now_utc()
    db.execute(
        "INSERT INTO tenants (id, name, plan, status, created_at) "
        "VALUES (?, ?, 'free', 'active', ?)",
        (tid, tenant_name, now),
    )
    db.execute(
        "INSERT INTO agents "
        "(id, tenant_id, name, description, status, created_at, updated_at) "
        "VALUES (?, ?, ?, '', 'active', ?, ?)",
        (aid, tid, agent_name, now, now),
    )
    db.commit()
    return tid, aid


def test_cross_tenant_get_agent_blocked(client, db, tenant_and_key):
    """Tenant A's key must NOT be able to GET tenant B's agent.

    audit #14 + #30: the `agents` and KB endpoints largely DO scope by
    `tenant_id`, but a single missing `AND tenant_id = ?` in any WHERE
    clause leaks cross-tenant data. Without this test the regression is
    invisible to CI."""
    _, key_a = tenant_and_key
    _, aid_b = _insert_tenant_with_agent(db, "tenant-b", "tenant-b-private")

    r = client.get(f"/api/v1/agents/{aid_b}", headers=_auth(key_a))
    # 403 (you're not allowed) OR 404 (we pretend it doesn't exist) — both
    # acceptable defenses; only 200 is a security regression.
    assert r.status_code in (403, 404), (
        f"Cross-tenant read leaked! status={r.status_code}, body={r.text[:300]}"
    )


def test_cross_tenant_list_does_not_include_other_tenant(client, db, tenant_and_key):
    """Tenant A's /agents list must not include tenant B's rows.
    Catches the easier 'forgot WHERE tenant_id=?' typo on the list
    endpoint."""
    _, key_a = tenant_and_key
    _, aid_b = _insert_tenant_with_agent(db, "tenant-b", "tenant-b-private")

    r = client.get("/api/v1/agents", headers=_auth(key_a))
    assert r.status_code == 200, r.text[:300]
    body = r.json()
    items = body if isinstance(body, list) else (
        body.get("items") or body.get("agents") or []
    )
    leaked = [a for a in items if a.get("id") == aid_b]
    assert not leaked, (
        f"Tenant A's list leaked tenant B's agent! Got: {leaked}"
    )


# ─────────────────────────────────────────────────────────────────────────────
# audit #26 — /analyze/* router auth + body size cap
# ─────────────────────────────────────────────────────────────────────────────

def test_analyze_lint_requires_auth(client):
    """audit #26: was `(req: AnalyzeRequest)` — no Depends(get_tenant).
    Free-compute / DoS amplifier. Now must 401 without a Bearer."""
    r = client.post("/api/v1/analyze/lint", json={"config": "type: react"})
    assert r.status_code == 401, (
        f"/analyze/lint accepted unauthenticated request! got {r.status_code}"
    )


def test_analyze_lint_accepts_valid_key(client, tenant_and_key):
    _, key = tenant_and_key
    r = client.post("/api/v1/analyze/lint",
                    json={"config": "type: react\nname: test"},
                    headers=_auth(key))
    assert r.status_code == 200, r.text[:300]
    body = r.json()
    assert "framework" in body or "errors" in body


def test_analyze_lint_rejects_oversize_yaml(client, tenant_and_key):
    """audit #26 + low #90: cap YAML body before yaml.safe_load to neuter
    billion-laughs alias amplification. Sending >256KB should 413."""
    _, key = tenant_and_key
    huge = "type: react\nname: " + ("x" * (260 * 1024))
    r = client.post("/api/v1/analyze/lint",
                    json={"config": huge},
                    headers=_auth(key))
    assert r.status_code == 413, (
        f"expected 413 CONFIG_TOO_LARGE, got {r.status_code}: {r.text[:200]}"
    )


# ─────────────────────────────────────────────────────────────────────────────
# audit #14 — rollback_agent RBAC + tenant scope
# ─────────────────────────────────────────────────────────────────────────────

def test_rollback_cross_tenant_returns_404(client, db, tenant_and_key):
    """Tenant A asks to rollback tenant B's agent → 404 (id oracle defense).
    Even if A guessed B's agent_id correctly, response is identical to
    'agent does not exist'."""
    _, key_a = tenant_and_key
    _, aid_b = _insert_tenant_with_agent(db, "tenant-b", "tenant-b-private")

    r = client.post(f"/api/v1/agents/{aid_b}/rollback",
                    json={"target_version": 1},
                    headers=_auth(key_a))
    assert r.status_code == 404, (
        f"rollback across tenant boundary leaked! status={r.status_code}, "
        f"body={r.text[:300]}"
    )


def test_rollback_unknown_version_returns_404(client, db, tenant_and_key):
    """Tenant A asks to rollback their own agent to a version that
    doesn't exist → 404 VERSION_NOT_FOUND. Anchors the version-scoping
    query so we don't accidentally leak via a 5xx."""
    tid_a, key_a = tenant_and_key
    aid_a = gen_id("ag_")
    now = now_utc()
    db.execute(
        "INSERT INTO agents "
        "(id, tenant_id, name, description, status, created_at, updated_at) "
        "VALUES (?, ?, ?, '', 'active', ?, ?)",
        (aid_a, tid_a, "my-agent", now, now),
    )
    db.commit()

    r = client.post(f"/api/v1/agents/{aid_a}/rollback",
                    json={"target_version": 99},
                    headers=_auth(key_a))
    assert r.status_code == 404


# ─────────────────────────────────────────────────────────────────────────────
# audit #27 — knowledge/{kb_id}/files path containment
# ─────────────────────────────────────────────────────────────────────────────

def test_add_files_rejects_path_outside_kb_root(client, db, tenant_and_key, tmp_path):
    """audit #27 fix: `/etc/passwd`-style host paths must be rejected
    even if the caller owns the KB. Containment check uses resolved
    absolute path + os.sep prefix."""
    tid, key = tenant_and_key

    # Set up a KB whose root_dir is inside tmp_path so we can also test
    # the positive case.
    kb_id = gen_id("kb_")
    kb_root = tmp_path / "kb"
    kb_root.mkdir()
    (kb_root / "ok.txt").write_text("inside kb_root")
    # Build a file OUTSIDE the kb_root to attempt to register.
    outside = tmp_path / "outside.txt"
    outside.write_text("attacker would love to chunk this")

    now = now_utc()
    # knowledge_bases schema: id, tenant_id, name, root_dir, description,
    # created_at, updated_at  (no `status` column)
    db.execute(
        "INSERT INTO knowledge_bases "
        "(id, tenant_id, name, root_dir, description, created_at, updated_at) "
        "VALUES (?, ?, ?, ?, '', ?, ?)",
        (kb_id, tid, "test-kb", str(kb_root), now, now),
    )
    db.commit()

    # Try to add the outside file — should be rejected.
    r = client.post(f"/api/v1/knowledge/{kb_id}/files",
                    json={"file_paths": [str(outside)]},
                    headers=_auth(key))
    # Endpoint returns 201 Created on successful POST regardless of how
    # many files were ultimately registered; the rejection shows up in
    # the body's `rejected` array.
    assert r.status_code in (200, 201), r.text[:300]
    body = r.json()
    assert body["added"] == 0, f"escaped file was registered! body={body}"
    assert "rejected" in body, "expected `rejected` array listing the refusal"

    # Adding the inside file works (positive case).
    r2 = client.post(f"/api/v1/knowledge/{kb_id}/files",
                     json={"file_paths": [str(kb_root / "ok.txt")]},
                     headers=_auth(key))
    assert r2.status_code in (200, 201), r2.text[:300]
    assert r2.json()["added"] == 1


# ─────────────────────────────────────────────────────────────────────────────
# audit #28 — /jobs/{job_id} IDOR
# ─────────────────────────────────────────────────────────────────────────────

def test_get_job_nonexistent_returns_404(client, tenant_and_key):
    """Sanity: unknown job_id → 404 (no 5xx, no leak)."""
    _, key = tenant_and_key
    r = client.get("/api/v1/jobs/job_doesnotexist", headers=_auth(key))
    assert r.status_code == 404


def test_cancel_job_nonexistent_returns_404(client, tenant_and_key):
    """Same for cancel — used to silently 200 even for nonexistent IDs."""
    _, key = tenant_and_key
    r = client.post("/api/v1/jobs/job_doesnotexist/cancel", headers=_auth(key))
    assert r.status_code == 404
