#!/usr/bin/env python3
"""Policy-overhead benchmark — measures the p50/p95/p99 latency Gateco's
policy layer adds on top of the raw vector-DB leg.

Methodology (matches the published description on /docs/performance):
  - pgvector connector against a local table of --vectors embeddings (1536-dim)
  - --resources registered GatedResources with classification/sensitivity/
    domain metadata, 10 chunks each (sidecar metadata resolution, the default)
  - one ACTIVE 5-rule RBAC policy (allow-by-group, classification ceiling,
    restricted deny, department/domain allow, critical-sensitivity deny)
  - one active principal with groups, resolved per request
  - requests supply a precomputed query_vector so server-side embedding is
    excluded — the measured overhead is principal load + policy evaluation +
    sidecar registry resolution + audit/usage writes + response assembly

Two overhead series are reported per request:
  service_overhead = SecuredRetrieval.latency_ms - connector_latency_ms
      (inside the retrieval service, excludes HTTP middleware)
  full_overhead    = client wall-clock ms - connector_latency_ms
      (everything Gateco adds, including auth middleware + serialization)

Usage (backend running on --base-url with DEBUG=true and ADMIN_SETUP_ENABLED=true):
    cd gateco/apps/backend && python scripts/policy_overhead_bench.py \
        --requests 300 --json-out /tmp/bench.json
"""

from __future__ import annotations

import argparse
import asyncio
import json
import math
import os
import random
import secrets
import statistics
import sys
import time
import uuid
from pathlib import Path

import asyncpg
import httpx

sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))

BENCH_TABLE = "bench_vectors"
EMBED_DIM = 1536
CHUNKS_PER_RESOURCE = 10

# Required: the admin token of the target environment (used once, to flip the
# bench org's plan tier). No default on purpose — set ADMIN_TOKEN explicitly.
ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "")


def _percentile(values: list[float], pct: float) -> float:
    if not values:
        return 0.0
    ordered = sorted(values)
    k = (len(ordered) - 1) * pct / 100.0
    lo, hi = math.floor(k), math.ceil(k)
    if lo == hi:
        return ordered[lo]
    return ordered[lo] + (ordered[hi] - ordered[lo]) * (k - lo)


def _stats(name: str, values: list[float]) -> dict:
    return {
        "series": name,
        "n": len(values),
        "p50_ms": round(_percentile(values, 50), 2),
        "p95_ms": round(_percentile(values, 95), 2),
        "p99_ms": round(_percentile(values, 99), 2),
        "mean_ms": round(statistics.fmean(values), 2) if values else 0.0,
        "max_ms": round(max(values), 2) if values else 0.0,
    }


def _unit_vector(rng: random.Random) -> list[float]:
    v = [rng.gauss(0, 1) for _ in range(EMBED_DIM)]
    norm = math.sqrt(sum(x * x for x in v)) or 1.0
    return [x / norm for x in v]


def _vec_literal(v: list[float]) -> str:
    return "[" + ",".join(f"{x:.6f}" for x in v) + "]"


async def _setup_bench_table(dsn: str, n_vectors: int, rng: random.Random) -> list[str]:
    conn = await asyncpg.connect(dsn)
    try:
        await conn.execute("CREATE EXTENSION IF NOT EXISTS vector")
        await conn.execute(f"DROP TABLE IF EXISTS {BENCH_TABLE}")
        await conn.execute(
            f"CREATE TABLE {BENCH_TABLE} ("
            f"id text PRIMARY KEY, embedding vector({EMBED_DIM}), content text)"
        )
        vector_ids = [f"bench-v-{i:05d}" for i in range(n_vectors)]
        rows = [
            (vid, _vec_literal(_unit_vector(rng)), f"bench content {vid}")
            for vid in vector_ids
        ]
        await conn.executemany(
            f"INSERT INTO {BENCH_TABLE} (id, embedding, content) "
            f"VALUES ($1, $2::vector, $3)",
            rows,
        )
        await conn.execute(
            f"CREATE INDEX ON {BENCH_TABLE} USING hnsw (embedding vector_cosine_ops)"
        )
        return vector_ids
    finally:
        await conn.close()


async def _register_resources(
    org_id: str, connector_id: str, vector_ids: list[str], n_resources: int
) -> None:
    """Insert GatedResource + ResourceChunk registry rows directly."""
    from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine

    from gateco.database.enums import Classification, ResourceType, Sensitivity
    from gateco.database.models.resource import GatedResource
    from gateco.database.models.resource_chunk import ResourceChunk
    from gateco.database.settings import DatabaseSettings

    engine = create_async_engine(DatabaseSettings().database_url)
    factory = async_sessionmaker(engine)
    classifications = [
        Classification.public,
        Classification.internal,
        Classification.confidential,
        Classification.restricted,
    ]
    sensitivities = [Sensitivity.low, Sensitivity.medium, Sensitivity.high, Sensitivity.critical]
    domains = ["engineering", "finance", "marketing", "management"]

    async with factory() as session:
        vid_iter = iter(vector_ids)
        for i in range(n_resources):
            resource = GatedResource(
                organization_id=uuid.UUID(org_id),
                type=ResourceType.file,
                title=f"Bench Resource {i:03d}",
                content_url=f"bench://resource/{i:03d}",
                classification=classifications[i % 4],
                sensitivity=sensitivities[i % 4],
                domain=domains[i % 4],
                source_connector_id=uuid.UUID(connector_id),
            )
            session.add(resource)
            await session.flush()
            for c in range(CHUNKS_PER_RESOURCE):
                vid = next(vid_iter, None)
                if vid is None:
                    break
                session.add(
                    ResourceChunk(
                        resource_id=resource.id,
                        index=c,
                        preview=f"bench chunk {vid}",
                        vector_id=vid,
                        source_connector_id=uuid.UUID(connector_id),
                    )
                )
        await session.commit()
    await engine.dispose()


async def run_bench(args: argparse.Namespace) -> dict:
    rng = random.Random(20260816)
    base = args.base_url.rstrip("/")
    suffix = secrets.token_hex(4)

    async with httpx.AsyncClient(base_url=base, timeout=60.0) as http:
        # 1. Bench org + admin user
        email = f"bench-admin-{suffix}@gateco-bench.example"
        password = secrets.token_urlsafe(16) + "!Aa1"
        r = await http.post(
            "/api/auth/signup",
            json={
                "name": "Bench Admin",
                "email": email,
                "password": password,
                "organization_name": f"Policy Bench {suffix}",
            },
        )
        r.raise_for_status()
        data = r.json()
        access_token = data["tokens"]["access_token"]
        org_id = data["user"]["organization"]["id"]

        r = await http.patch(
            f"/api/admin/db/organizations/{org_id}/plan",
            json={"plan": "team"},
            headers={"X-Admin-Token": ADMIN_TOKEN},
        )
        r.raise_for_status()

        # Re-login so the JWT carries the upgraded plan claim
        r = await http.post("/api/auth/login", json={"email": email, "password": password})
        r.raise_for_status()
        access_token = r.json()["tokens"]["access_token"]
        auth = {"Authorization": f"Bearer {access_token}"}

        # 2. Stub IDP + principal
        r = await http.post(
            "/api/identity-providers",
            headers=auth,
            json={
                "name": f"bench-idp-{suffix}",
                # The placeholder/vh-fake markers make _is_stub_config() true:
                # the StubAdapter serves the embedded principals and the
                # vendor_iam (Growth+) gate is bypassed — no real vendor calls.
                "type": "okta",
                "config": {
                    "domain": "bench.placeholder",
                    "api_token": "vh-fake-bench-token",
                    "principals": [
                        {
                            "external_id": f"bench-p-{suffix}",
                            "display_name": "Bench Principal",
                            "email": f"bench-p-{suffix}@gateco-bench.example",
                            "groups": ["bench-eng"],
                            "roles": ["viewer"],
                            "department": "engineering",
                        }
                    ]
                },
            },
        )
        r.raise_for_status()
        idp_id = r.json().get("data", r.json())["id"]
        r = await http.post(f"/api/identity-providers/{idp_id}/sync", headers=auth)
        r.raise_for_status()
        await asyncio.sleep(1)
        r = await http.post(
            "/api/principals/resolve",
            headers=auth,
            json={"email": f"bench-p-{suffix}@gateco-bench.example"},
        )
        r.raise_for_status()
        principal_id = r.json().get("data", r.json())["id"]

        # 3. pgvector connector
        pg = {
            "host": os.environ.get("BENCH_PG_HOST", "localhost"),
            "port": int(os.environ.get("BENCH_PG_PORT", "5432")),
            "database": os.environ.get("BENCH_PG_DB", "gateco_db"),
            "user": os.environ.get("BENCH_PG_USER", "postgres"),
            "password": os.environ.get("BENCH_PG_PASSWORD", "postgres"),
        }
        r = await http.post(
            "/api/connectors",
            headers=auth,
            json={
                "name": f"bench-pgvector-{suffix}",
                "type": "pgvector",
                "config": pg,
                "metadata_resolution_mode": "sidecar",
            },
        )
        r.raise_for_status()
        connector_id = r.json().get("data", r.json())["id"]

        dsn = f"postgresql://{pg['user']}:{pg['password']}@{pg['host']}:{pg['port']}/{pg['database']}"
        vector_ids = await _setup_bench_table(dsn, args.vectors, rng)

        r = await http.patch(
            f"/api/connectors/{connector_id}/search-config",
            headers=auth,
            json={
                "search_config": {
                    "table_name": BENCH_TABLE,
                    "vector_column": "embedding",
                    "id_column": "id",
                    "content_column": "content",
                    "top_k": 50,
                    "expected_dimension": EMBED_DIM,
                }
            },
        )
        r.raise_for_status()

        # 4. Registry rows (100 resources x 10 chunks -> covers 1000 vectors)
        await _register_resources(org_id, connector_id, vector_ids, args.resources)

        # 5. One active 5-rule RBAC policy
        r = await http.post(
            "/api/policies",
            headers=auth,
            json={
                "name": f"bench-rbac-{suffix}",
                "description": "Benchmark policy: 5 RBAC rules",
                "type": "rbac",
                "effect": "allow",
                "resource_selectors": [
                    {"field": "connector_id", "op": "eq", "value": connector_id}
                ],
                "rules": [
                    {
                        "description": "group read public",
                        "effect": "allow",
                        "priority": 10,
                        "conditions": [
                            {"field": "principal.groups", "operator": "contains", "value": "bench-eng"},
                            {"field": "resource.classification", "operator": "eq", "value": "public"},
                        ],
                    },
                    {
                        "description": "group read internal",
                        "effect": "allow",
                        "priority": 20,
                        "conditions": [
                            {"field": "principal.groups", "operator": "contains", "value": "bench-eng"},
                            {"field": "resource.classification", "operator": "eq", "value": "internal"},
                        ],
                    },
                    {
                        "description": "deny restricted",
                        "effect": "deny",
                        "priority": 30,
                        "conditions": [
                            {"field": "resource.classification", "operator": "eq", "value": "restricted"},
                        ],
                    },
                    {
                        "description": "department domain allow",
                        "effect": "allow",
                        "priority": 40,
                        "conditions": [
                            {"field": "principal.attributes.department", "operator": "eq", "value": "engineering"},
                            {"field": "resource.domain", "operator": "eq", "value": "engineering"},
                        ],
                    },
                    {
                        "description": "deny critical sensitivity",
                        "effect": "deny",
                        "priority": 50,
                        "conditions": [
                            {"field": "resource.sensitivity", "operator": "eq", "value": "critical"},
                        ],
                    },
                ],
            },
        )
        r.raise_for_status()
        policy_id = r.json().get("data", r.json())["id"]
        r = await http.post(f"/api/policies/{policy_id}/activate", headers=auth)
        r.raise_for_status()

        # 6. Measure
        async def one_request() -> dict | None:
            qv = _unit_vector(rng)
            t0 = time.perf_counter()
            resp = await http.post(
                "/api/retrievals/execute",
                headers=auth,
                json={
                    "query_vector": qv,
                    "query": "bench probe",
                    "principal_id": principal_id,
                    "connector_id": connector_id,
                    "top_k": args.top_k,
                },
            )
            wall_ms = (time.perf_counter() - t0) * 1000.0
            if resp.status_code != 200:
                return None
            body = resp.json().get("data", resp.json())
            return {
                "wall_ms": wall_ms,
                "latency_ms": body.get("latency_ms"),
                "connector_latency_ms": body.get("connector_latency_ms"),
                "allowed": body.get("allowed_chunks"),
                "denied": body.get("denied_chunks"),
            }

        for _ in range(args.warmup):
            await one_request()
            await asyncio.sleep(1.0 / args.pace)

        samples: list[dict] = []
        errors = 0
        for _ in range(args.requests):
            s = await one_request()
            if s is None or s["latency_ms"] is None or s["connector_latency_ms"] is None:
                errors += 1
            else:
                samples.append(s)
            await asyncio.sleep(1.0 / args.pace)

    service_total = [s["latency_ms"] for s in samples]
    connector_leg = [s["connector_latency_ms"] for s in samples]
    service_overhead = [s["latency_ms"] - s["connector_latency_ms"] for s in samples]
    full_overhead = [s["wall_ms"] - s["connector_latency_ms"] for s in samples]

    report = {
        "methodology": {
            "connector": "pgvector (local)",
            "vectors": args.vectors,
            "embedding_dim": EMBED_DIM,
            "resources_registered": args.resources,
            "chunks_per_resource": CHUNKS_PER_RESOURCE,
            "policy": "1 active RBAC policy, 5 rules (2 group-allow, 1 classification deny, 1 department allow, 1 sensitivity deny)",
            "metadata_resolution": "sidecar",
            "top_k": args.top_k,
            "requests": args.requests,
            "warmup": args.warmup,
            "pace_rps": args.pace,
            "query_embedding": "client-supplied (server-side embedding excluded)",
            "notes": (
                "Run against a DEBUG=true dev backend (verbose logging and "
                "relaxed rate limits) — overhead numbers are conservative "
                "relative to production settings."
            ),
        },
        "errors": errors,
        "sample_allowed_denied": (
            {"allowed": samples[0]["allowed"], "denied": samples[0]["denied"]} if samples else {}
        ),
        "series": [
            _stats("connector_leg_ms (raw vector query)", connector_leg),
            _stats("service_total_ms (retrieval service end-to-end)", service_total),
            _stats("service_overhead_ms (policy layer, service-side)", service_overhead),
            _stats("full_overhead_ms (policy layer incl. HTTP middleware)", full_overhead),
        ],
    }
    return report


def main() -> int:
    if not ADMIN_TOKEN:
        print(
            "ERROR: ADMIN_TOKEN is not set. Export the admin token of the "
            "target environment before running (the benchmark uses it once, "
            "to set the bench org's plan tier).",
            file=sys.stderr,
        )
        return 2

    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--base-url", default="http://localhost:8000")
    parser.add_argument("--requests", type=int, default=300)
    parser.add_argument("--warmup", type=int, default=20)
    parser.add_argument("--top-k", type=int, default=10)
    parser.add_argument("--vectors", type=int, default=1000)
    parser.add_argument("--resources", type=int, default=100)
    parser.add_argument("--pace", type=float, default=8.0, help="requests per second cap")
    parser.add_argument("--json-out", default="")
    args = parser.parse_args()

    report = asyncio.run(run_bench(args))

    print()
    print(f"{'series':<52} {'n':>4} {'p50':>8} {'p95':>8} {'p99':>8} {'mean':>8} {'max':>8}")
    for s in report["series"]:
        print(
            f"{s['series']:<52} {s['n']:>4} {s['p50_ms']:>8} {s['p95_ms']:>8} "
            f"{s['p99_ms']:>8} {s['mean_ms']:>8} {s['max_ms']:>8}"
        )
    print(f"\nerrors: {report['errors']}")

    if args.json_out:
        Path(args.json_out).write_text(json.dumps(report, indent=2))
        print(f"report written to {args.json_out}")

    overhead_p95 = next(
        s["p95_ms"] for s in report["series"] if s["series"].startswith("service_overhead")
    )
    print(f"\nservice policy-overhead p95: {overhead_p95} ms "
          f"({'WITHIN' if overhead_p95 < 25 else 'EXCEEDS'} the <25ms claim)")
    return 0


if __name__ == "__main__":
    sys.exit(main())
