#!/usr/bin/env python3
"""
m10_bench_smoke.py — M10-bench broker+router ceiling test (10k/s on bench profile)

Usage:
  docker compose --profile bench up -d
  python3 scripts/m10_bench_smoke.py

Exit code 0 = all green. Exit code 1 = assertion failed.

Step 1 — pre-flight (bench profile up, deliverd-bench consuming)
Step 2 — 10k/s soak (5 min, ramp 15s)
Step 3 — broker ceiling check (p99 ≤ 50ms, NATS queue depth < 1000)
Step 4 — teardown

NOTE: The bench profile has 2 loadgen-bench instances × 5000/s = 10k/s.
M10_PLAN.md described 10 instances × 5000/s = 50k/s; only 2 exist in
docker-compose.yml. This smoke targets 10k/s and adapts thresholds accordingly.
"""
import subprocess
import sys
import time
import urllib.request
import urllib.parse
import json

sys.path.insert(0, __file__.rsplit("/", 1)[0])
import m10_lib as lib

PROM = "http://localhost:9090"
NATS_MONITOR = "http://localhost:8222"
BENCH_DURATION_MIN = 5       # minutes
BENCH_RAMP_SEC = 15           # ramp-up seconds
CLUSTER_TARGET = 10000        # alerts/sec cluster-wide target (2 × 5000)
P99_ROUTER_THRESHOLD = 0.050  # seconds — router p99 must be under this
NATS_QUEUE_DEPTH_MAX = 1000   # NATS queue depth must stay below this
RATE_TOLERANCE = 0.10         # ±10% (loadgen ramp variance is higher at 10k/s)


def pass_(msg: str):
    print(f"  ✅ {msg}")


def warn_(msg: str):
    print(f"  ⚠️  {msg}")


def fail_(msg: str):
    print(f"  ❌ {msg}")
    sys.exit(1)


# ---------------------------------------------------------------------------
# NATS monitoring (port 8222)
# ---------------------------------------------------------------------------

def nats_varz() -> dict:
    """Fetch NATS /varz JSON. Returns {} on error."""
    try:
        with urllib.request.urlopen(f"{NATS_MONITOR}/varz", timeout=10) as r:
            return json.loads(r.read())
    except Exception:
        return {}


def nats_queue_depth() -> int:
    """Return current NATS JetStream total message count across all streams.
    
    Uses GET /jsz (JetStream info endpoint, no auth required on default NATS).
    Returns total messages stored in JetStream. High values indicate the
    consumer is not keeping up with the producer.
    """
    try:
        with urllib.request.urlopen(f"{NATS_MONITOR}/jsz", timeout=10) as r:
            d = json.loads(r.read())
        return int(d.get("messages", 0))
    except Exception:
        return -1


# ---------------------------------------------------------------------------
# Prometheus-backed assertions
# ---------------------------------------------------------------------------

def assert_router_p99_under(threshold: float, window_seconds: int = 60) -> float:
    """
    Assert p99 router recipient expansion latency is under threshold.
    Metric: ba_recipient_expansion_seconds_bucket
    Returns the actual p99, or raises AssertionError.
    """
    query = (
        f'histogram_quantile(0.99, '
        f'rate(ba_recipient_expansion_seconds_bucket[{window_seconds}s]))'
    )
    results = lib.scrape(query)
    if not results:
        raise AssertionError(
            f"router p99 query returned no data. Is routerd receiving alerts? "
            f"(query: {query})"
        )
    p99 = float(results[0]["value"][1])
    if p99 > threshold:
        raise AssertionError(
            f"router p99 {p99*1000:.1f}ms exceeds threshold {threshold*1000:.1f}ms "
            f"(window={window_seconds}s)"
        )
    return p99


def assert_bench_rate_near(target: float, tolerance: float = 0.10,
                           window_seconds: int = 30) -> float:
    """
    Assert the bench cluster-wide accept rate is within tolerance of target.
    tolerance=0.10 means ±10%.
    Returns the actual rate.
    """
    query = (
        f'rate(ba_ingestd_alerts_received_total{{result="accepted"}}[{window_seconds}s])'
    )
    results = lib.scrape(query)
    if not results:
        raise AssertionError(
            f"rate query returned no data. Is ingestd receiving alerts? (query: {query})"
        )
    actual = sum(float(r["value"][1]) for r in results)
    lower = target * (1 - tolerance)
    upper = target * (1 + tolerance)
    if not (lower <= actual <= upper):
        raise AssertionError(
            f"bench rate {actual:.1f}/s is outside ±{tolerance*100:.0f}% "
            f"tolerance of target {target}/s (range: {lower:.1f}–{upper:.1f})"
        )
    return actual


# ---------------------------------------------------------------------------
# Steps
# ---------------------------------------------------------------------------

def step1_preflight() -> None:
    """Verify all bench services are up."""
    print("Step 1 — pre-flight (bench profile)")
    targets = lib.get_prometheus_targets()
    required = ["ingestd", "routerd", "prometheus",
                "loadgen-bench-1", "loadgen-bench-2"]
    all_up = True
    for svc in required:
        status = targets.get(svc, "0")
        if status == "1":
            pass_(f"{svc} is up")
        else:
            warn_(f"{svc} is {'not scraped' if status == '0' else status}")
            all_up = False

    # Verify deliverd-bench is consuming (check NATS consumer lag)
    # A lagging consumer shows up as non-zero "pending" on the deliverd-bench
    # subject. We check that the subject has active consumers.
    nats_info = nats_varz()
    js = nats_info.get("jetstream", {})
    if js:
        pass_("NATS JetStream is available")
    else:
        warn_("NATS JetStream stats unavailable — cannot verify consumer lag")

    if not all_up:
        fail_("not all bench services are up — fix before running bench smoke")


def step2_start_loadgen() -> subprocess.Popen:
    """Start the bench loadgen cluster."""
    print("\nStep 2 — starting 2-instance bench loadgen cluster (10k/s)")
    proc = subprocess.Popen(
        ["docker", "compose", "--profile", "bench", "up", "-d"],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
    )
    code = proc.wait()
    if code != 0:
        fail_(f"docker compose --profile bench up -d failed (exit {code})")
    pass_("2 loadgen-bench instances started")
    print(f"  waiting {BENCH_RAMP_SEC}s for ramp-up to complete...", flush=True)
    time.sleep(BENCH_RAMP_SEC)
    pass_(f"ramp-up complete — now targeting {CLUSTER_TARGET}/s")
    return proc


def step2_monitor_soak() -> list[dict]:
    """
    Monitor the bench soak: sample rate + router p99 + NATS queue depth every 30s.
    Fails fast on any breach.
    Returns list of samples for the log.
    """
    print(f"\n  Monitoring bench soak for {BENCH_DURATION_MIN} minutes...")
    samples = []
    start = time.time()
    deadline = start + BENCH_DURATION_MIN * 60
    sample_interval = 30  # seconds

    while time.time() < deadline:
        time.sleep(sample_interval)
        elapsed_min = int(time.time() - start) // 60
        elapsed_sec = int(time.time() - start)

        try:
            rate = assert_bench_rate_near(CLUSTER_TARGET, tolerance=RATE_TOLERANCE,
                                           window_seconds=30)
            p99 = assert_router_p99_under(P99_ROUTER_THRESHOLD, window_seconds=60)
            qd = nats_queue_depth()
            qd_ok = qd < NATS_QUEUE_DEPTH_MAX
            print(f"  [{elapsed_min}m] rate={rate:.0f}/s router_p99={p99*1000:.1f}ms "
                  f"nats_qd={qd} {'✅' if qd_ok else '❌'}")
            samples.append({
                "elapsed_min": elapsed_min,
                "elapsed_sec": elapsed_sec,
                "rate": rate,
                "router_p99_ms": p99 * 1000,
                "nats_qd": qd,
            })
            if not qd_ok:
                fail_(f"NATS queue depth {qd} exceeds max {NATS_QUEUE_DEPTH_MAX}")
        except AssertionError as e:
            fail_(f"bench soak breach at {elapsed_min}m: {e}")

    return samples


def step3_broker_ceiling_check(samples: list[dict]) -> None:
    """Final broker ceiling assertions over the full soak window."""
    print("\nStep 3 — broker ceiling check")
    # All samples already passed the router p99 check; do a final aggregate
    # assertion over the full window to confirm the ceiling held.
    p99_final = assert_router_p99_under(P99_ROUTER_THRESHOLD,
                                          window_seconds=BENCH_DURATION_MIN * 60)
    pass_(f"router p99 over full {BENCH_DURATION_MIN}min window: "
          f"{p99_final*1000:.1f}ms (threshold: {P99_ROUTER_THRESHOLD*1000:.1f}ms)")

    qd = nats_queue_depth()
    if qd < NATS_QUEUE_DEPTH_MAX:
        pass_(f"NATS queue depth: {qd} (max allowed: {NATS_QUEUE_DEPTH_MAX})")
    else:
        fail_(f"NATS queue depth {qd} exceeds max {NATS_QUEUE_DEPTH_MAX}")


def step4_teardown(proc) -> None:
    """Bring down the bench cluster."""
    print("\nStep 4 — teardown")
    r = subprocess.run(
        ["docker", "compose", "--profile", "bench", "down", "-v"],
        capture_output=True,
    )
    if r.returncode == 0:
        pass_("bench cluster torn down")
    else:
        warn_(f"teardown returned {r.returncode}: {r.stderr.decode().strip()}")


def print_summary(samples: list[dict]) -> None:
    """Print a summary table of the bench run."""
    print("\n=== M10-Bench Soak Summary ===")
    print(f"Duration:       {BENCH_DURATION_MIN} min")
    print(f"Target rate:   {CLUSTER_TARGET}/s (±{RATE_TOLERANCE*100:.0f}%)")
    print(f"Router p99 threshold: {P99_ROUTER_THRESHOLD*1000:.0f}ms")
    print(f"NATS qd max:  {NATS_QUEUE_DEPTH_MAX}")
    print()
    if samples:
        print(f"{'Time':>6}  {'Rate/s':>8}  {'rt_p99(ms)':>11}  {'nats_qd':>8}")
        print("-" * 42)
        for s in samples:
            print(f"{s['elapsed_min']:>5}m  {s['rate']:>8.0f}  "
                  f"{s['router_p99_ms']:>11.1f}  {s['nats_qd']:>8}")
    print()
    print("🎉 M10-bench smoke: all checks complete.")


def main():
    print(f"M10-Bench — target {CLUSTER_TARGET}/s for {BENCH_DURATION_MIN} min")
    print(f"Router p99 threshold: {P99_ROUTER_THRESHOLD*1000:.0f}ms | "
          f"NATS qd max: {NATS_QUEUE_DEPTH_MAX}")
    print()

    try:
        step1_preflight()
        proc = step2_start_loadgen()
        samples = step2_monitor_soak()
        step3_broker_ceiling_check(samples)
        step4_teardown(proc)
        print_summary(samples)
    except AssertionError as e:
        print(f"\n💥 M10-bench smoke FAILED: {e}")
        sys.exit(1)
    except KeyboardInterrupt:
        print("\nInterrupted.")
        sys.exit(1)


if __name__ == "__main__":
    main()
