"""
Locust user class for DECNET API stress testing.

Hammers every endpoint from the OpenAPI spec with realistic traffic weights.
Can be used standalone (`locust -f tests/stress/locustfile.py`) or
programmatically via the pytest fixtures in conftest.py.
"""

import os
import random
import time

from locust import HttpUser, task, between


ADMIN_USER = os.environ.get("DECNET_ADMIN_USER", "admin")
ADMIN_PASS = os.environ.get("DECNET_ADMIN_PASSWORD", "admin")

_MAX_LOGIN_RETRIES = 5
_LOGIN_BACKOFF_BASE = 0.5  # seconds, doubles each retry


class DecnetUser(HttpUser):
    wait_time = between(0.01, 0.05)  # near-zero think time — max pressure

    def _login_with_retry(self):
        """Login with exponential backoff — handles connection storms
        and (if the server still has rate limits on) 429 throttling.

        Returns (access_token, must_change_password)."""
        for attempt in range(_MAX_LOGIN_RETRIES):
            resp = self.client.post(
                "/api/v1/auth/login",
                json={"username": ADMIN_USER, "password": ADMIN_PASS},
                name="/api/v1/auth/login [on_start]",
            )
            if resp.status_code == 200:
                body = resp.json()
                return body["access_token"], bool(body.get("must_change_password", False))
            # Status 0 = connection refused, retry with backoff
            if resp.status_code == 0 or resp.status_code >= 500:
                time.sleep(_LOGIN_BACKOFF_BASE * (2 ** attempt))
                continue
            # 429: the server is rate-limiting logins. In stress runs the
            # fixture sets DECNET_LIMITER_ENABLED=false so we should
            # never see this — but if someone points locust at a real
            # server, honour Retry-After so the run degrades gracefully
            # instead of crashing on_start.
            if resp.status_code == 429:
                retry_after = resp.headers.get("Retry-After")
                delay = _LOGIN_BACKOFF_BASE * (2 ** attempt)
                if retry_after:
                    try:
                        delay = max(delay, float(retry_after))
                    except ValueError:
                        pass
                time.sleep(delay)
                continue
            raise RuntimeError(f"Login failed (non-retryable): {resp.status_code} {resp.text}")
        raise RuntimeError(f"Login failed after {_MAX_LOGIN_RETRIES} retries (last status: {resp.status_code})")

    def on_start(self):
        # Prefer the fixture-supplied token: 1000 simultaneous bcrypt logins
        # never finish inside a spike window, leaving aggregated requests at 0.
        preset = os.environ.get("DECNET_STRESS_TOKEN")
        if preset:
            self.token = preset
        else:
            token, must_change = self._login_with_retry()
            if must_change:
                self.client.post(
                    "/api/v1/auth/change-password",
                    json={"old_password": ADMIN_PASS, "new_password": ADMIN_PASS},
                    headers={"Authorization": f"Bearer {token}"},
                )
                token, _ = self._login_with_retry()
            self.token = token
        self.client.headers.update({"Authorization": f"Bearer {self.token}"})

    # --- Read-hot paths (high weight) ---

    @task(10)
    def get_stats(self):
        self.client.get("/api/v1/stats")

    @task(8)
    def get_logs(self):
        self.client.get("/api/v1/logs", params={"limit": 50})

    @task(8)
    def get_attackers(self):
        self.client.get("/api/v1/attackers")

    @task(7)
    def get_deckies(self):
        self.client.get("/api/v1/deckies")

    @task(6)
    def get_bounties(self):
        self.client.get("/api/v1/bounty")

    @task(5)
    def get_logs_histogram(self):
        self.client.get("/api/v1/logs/histogram")

    @task(5)
    def search_logs(self):
        self.client.get("/api/v1/logs", params={"search": "ssh", "limit": 100})

    @task(4)
    def search_attackers(self):
        self.client.get(
            "/api/v1/attackers", params={"search": "brute", "sort_by": "recent"}
        )

    @task(4)
    def paginate_logs(self):
        offset = random.randint(0, 1000)
        self.client.get("/api/v1/logs", params={"limit": 100, "offset": offset})

    @task(3)
    def get_health(self):
        self.client.get("/api/v1/health")

    @task(3)
    def get_config(self):
        self.client.get("/api/v1/config")

    # --- Write / auth paths (low weight) ---

    # N.B. a previous revision had a @task(2) login here that re-hit
    # /auth/login during the run. Under N>10 virtual users it burned
    # the 10/5min per-IP + per-username limits and turned the whole
    # stress run into a 429 factory. The login hot path is already
    # covered by on_start for every simulated user; re-logging in on
    # every tick adds no coverage, just contention.

    @task(1)
    def stream_sse(self):
        """Short-lived SSE connection — read a few bytes then close."""
        with self.client.get(
            "/api/v1/stream",
            params={"maxOutput": 3},
            stream=True,
            catch_response=True,
            name="/api/v1/stream",
        ) as resp:
            if resp.status_code == 200:
                # Read up to 4KB then bail — we're stress-testing connection setup
                for chunk in resp.iter_content(chunk_size=1024):
                    break
                resp.success()
            else:
                resp.failure(f"SSE returned {resp.status_code}")
