import logging
import random
import re
import uuid
import xmlrpc.client
from locust import HttpUser, task, between, events
from requests.exceptions import RequestException

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger("locust")

SEED_USERS = []
ALL_USERNAMES = []
USER_UIDS = []
POST_UIDS = []
POST_SLUGS = []
PROJECT_UIDS = []
PROJECT_SLUGS = []
COMMENT_UIDS = []
NEWS_UIDS = []
NEWS_SLUGS = []
GIST_SLUGS = []
GIST_UIDS = []
ISSUE_UIDS = []
ISSUE_JOB_UIDS = []
SEO_JOB_UIDS = []
POLLS = []
QUIZ_SLUGS = []
QUIZ_QUESTIONS = {}
BATTLE_UIDS = []
DR_RANT_IDS = []
DR_COMMENT_IDS = []
ADMIN_USER = {}
ADMIN_TARGETS = {}
TOPICS = ["devlog", "showcase", "question", "rant", "fun", "random", "politics"]
PROJECT_TYPES = ["game", "game_asset", "software", "mobile_app", "website"]
GIST_LANGUAGES = ["python", "javascript", "go", "rust", "bash", "sql", "json"]
REACTION_EMOJIS = [
    "\U0001f44d",
    "\U0001f680",
    "\U0001f389",
    "\U0001f525",
    "\U0001f984",
    "\U0001f44d\U0001f3ff",
    "\U0001f9d1\u200d\U0001f4bb",
]
NOTIFICATION_TYPES = ["comment", "reply", "vote", "follow", "message"]
DOCS_SLUGS = [
    "index",
    "getting-started",
    "devii",
    "components",
    "dashboard",
    "claude",
    "terms",
    "community-guidelines",
    "privacy",
    "content-moderation",
    "intellectual-property",
    "contact",
]
ADMIN_DOCS_SLUGS = ["moderation-operations"]
UPLOAD_FILE = ("load_test.txt", b"locust upload payload", "text/plain")
UUID_RE = r"[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}"
PASSWORD = "testpass123"
BIRTH_DATE = "01/01/1990"
ACCEPT_TERMS = "on"
REPORT_STATUSES = ["open", "acknowledged", "actioned", "dismissed"]

# ── Deliberately NOT load tested (kept out by design) ───────────────
#   /openai/v1/*, /devii/clippy/ai/chat   - real upstream AI spend / billing.
#   /p/{slug}, /projects/.../containers/*, /admin/containers/*
#                                          - drive the host docker daemon.
#   /issues/{number}, /issues/{number}/comment, /issues/{number}/status
#                                          - live external Gitea REST calls.
#   /tools/seo/run                         - launches a real Playwright crawl of
#                                            an external URL, per-owner rate-capped
#                                            (one active job) and resource-heavy;
#                                            enqueuing real audit jobs is unsafe to
#                                            load test. The read surface (page +
#                                            status/report lookups) IS covered below.
#   /tools/seo/{uid}/ws                    - WebSocket; HttpUser cannot drive it.
#   /admin/ai-quota/reset-guests|reset-all - global destructive quota wipe.
#   /admin/services/{name}/run|start|stop|config|clear-logs
#                                          - toggles real background services
#                                            (news grading bills AI; could
#                                            destabilize the load target).
#   /admin/trash/.../purge, /admin/media/{uid}/purge of seeded content
#                                          - hard GC of real content (only the
#                                            dedicated disposable media is purged).
#   POST /reports/{target_type}/{target_uid}
#                                          - floods the real moderation queue
#                                            with junk reports. Exactly one
#                                            disposable report is seeded below so
#                                            the queue read tasks have a live uid.
#   /admin/moderation/{uid}/decide|status, /admin/users/{uid}/suspend|lift|ban
#                                          - real enforcement against real
#                                            accounts. The read surface (queue +
#                                            report detail) IS covered below.
#   /profile/{username}/delete             - destroys accounts (GET and POST).
#   /profile/{username}/consent, /mature-content
#                                          - flips real consent state.
#   /auth/accept-terms                     - mutates real acceptance records.
#   WebSockets (/devii/ws, exec ws, /tools/seo/{uid}/ws, /tools/deepsearch/{uid}/ws,
#               /tools/deepsearch/{uid}/chat, /pubsub/ws, /messages/ws)
#                                          - HttpUser is HTTP-only.
#   /game/prestige, /game/legacy           - prestige is a full irreversible farm
#                                            reset (coins/xp/level/ci/perks) gated
#                                            behind a level most simulated users
#                                            never reach; firing it under continuous
#                                            load would repeatedly wipe a shared
#                                            farm's progression and corrupt the
#                                            load test's own state model for no
#                                            realistic traffic benefit. Every other
#                                            Code Farm mutation is covered.
#   /admin/game/era/start|end              - global, server-wide game-state reset
#                                            (admin-triggered Era transition).
#   /dbapi/*                                - primary-administrator-only (a single
#                                            fixed identity, not "any admin"); no
#                                            disposable multi-actor target exists.
#   POST /profile/{username}/award         - enqueues a real AI-generated award
#                                            image job (billing-sensitive), same
#                                            class as /tools/seo/run.
#   DELETE /api/users/me                   - devRant-protocol account deletion,
#                                            same class as /profile/{username}/delete.


def random_username():
    return f"lu_{uuid.uuid4().hex[:10]}"


def random_client_ip():
    return "10.%d.%d.%d" % (
        random.randint(0, 255),
        random.randint(0, 255),
        random.randint(1, 254),
    )


def assign_client_ip(client):
    client.headers["X-Real-IP"] = random_client_ip()


def do_signup(client, username, email):
    with client.post(
        "/auth/signup",
        data={
            "username": username,
            "email": email,
            "password": PASSWORD,
            "confirm_password": PASSWORD,
            "birth_date": BIRTH_DATE,
            "accept_terms": ACCEPT_TERMS,
        },
        catch_response=True,
        name="signup",
    ) as resp:
        if resp.status_code == 200 and resp.history:
            resp.success()
            return True
        resp.failure(f"signup failed: status={resp.status_code}")
        return False


def do_login(client, email):
    with client.post(
        "/auth/login",
        data={
            "email": email,
            "password": PASSWORD,
        },
        catch_response=True,
        name="login",
    ) as resp:
        if resp.status_code == 200 and resp.history:
            resp.success()
            return True
        resp.failure(f"login failed: status={resp.status_code}")
        return False


@events.init.add_listener
def seed_data(environment, **kwargs):
    host = environment.host or "http://127.0.0.1:10502"
    logger.info(f"Seeding {host}...")

    import urllib.request
    import urllib.parse
    import http.cookiejar

    created = 0
    attempts = 0
    max_attempts = 25
    while created < 5 and attempts < max_attempts:
        attempts += 1
        username = random_username()
        email = f"{username}@locust.devplace"
        body = urllib.parse.urlencode(
            {
                "username": username,
                "email": email,
                "password": PASSWORD,
                "confirm_password": PASSWORD,
                "birth_date": BIRTH_DATE,
                "accept_terms": ACCEPT_TERMS,
            }
        ).encode()
        req = urllib.request.Request(
            f"{host}/auth/signup", data=body, headers={"X-Real-IP": random_client_ip()}
        )
        try:
            urllib.request.urlopen(req, timeout=10)
            SEED_USERS.append({"username": username, "email": email})
            ALL_USERNAMES.append(username)
            created += 1
        except Exception as e:
            logger.warning(f"Create seed user failed: {e}")

    if created < 5:
        logger.error(
            f"Seeding aborted: only {created}/5 users created after "
            f"{attempts} attempts. Is the server healthy at {host}?"
        )
        return

    logger.info(f"Created {len(SEED_USERS)} seed users")

    def logged_in_opener(email):
        jar = http.cookiejar.CookieJar()
        opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
        opener.addheaders = [("X-Real-IP", random_client_ip())]
        body = urllib.parse.urlencode({"email": email, "password": PASSWORD}).encode()
        opener.open(urllib.request.Request(f"{host}/auth/login", data=body), timeout=10)
        return opener

    for index, seed in enumerate(SEED_USERS[:3]):
        try:
            opener = logged_in_opener(seed["email"])
            fields = [
                ("content", "x " * 100),
                ("title", f"Seed post {uuid.uuid4().hex[:6]}"),
                ("topic", random.choice(TOPICS)),
                ("poll_question", f"Seed poll {uuid.uuid4().hex[:4]}?"),
                ("poll_options", "alpha"),
                ("poll_options", "beta"),
                ("poll_options", "gamma"),
            ]
            if index == 0:
                fields.extend(
                    [
                        ("war_faction_a", "Tabs"),
                        ("war_faction_b", "Spaces"),
                    ]
                )
            body = urllib.parse.urlencode(fields).encode()
            resp = opener.open(
                urllib.request.Request(f"{host}/posts/create", data=body),
                timeout=10,
            )
            m = re.search(r"/posts/(\S+)", resp.url)
            if m and m.group(1) not in POST_SLUGS:
                POST_SLUGS.append(m.group(1))
            html = resp.read().decode("utf-8", errors="replace")
            m2 = re.search(rf"/votes/post/({UUID_RE})", html)
            if m2 and m2.group(1) not in POST_UIDS:
                POST_UIDS.append(m2.group(1))
            m3 = re.search(rf'<dp-opinion-war[^>]*\buid="({UUID_RE})"', html)
            if m3 and m3.group(1) not in BATTLE_UIDS:
                BATTLE_UIDS.append(m3.group(1))
        except Exception as e:
            logger.warning(f"Create seed post failed: {e}")

    logger.info(f"Created {len(POST_UIDS)} seed posts, {len(BATTLE_UIDS)} battles")

    for post_uid in POST_UIDS:
        for seed in SEED_USERS[:2]:
            try:
                opener = logged_in_opener(seed["email"])
                body = urllib.parse.urlencode(
                    {
                        "content": f"Seed comment by {seed['username']}",
                        "post_uid": post_uid,
                    }
                ).encode()
                opener.open(
                    urllib.request.Request(f"{host}/comments/create", data=body),
                    timeout=10,
                )
            except Exception as e:
                logger.warning(f"Create seed comment failed: {e}")

    for seed in SEED_USERS[:2]:
        try:
            opener = logged_in_opener(seed["email"])
            body = urllib.parse.urlencode(
                {
                    "title": f"Project {uuid.uuid4().hex[:6]}",
                    "description": "z " * 50,
                    "project_type": random.choice(PROJECT_TYPES),
                }
            ).encode()
            opener.open(
                urllib.request.Request(f"{host}/projects/create", data=body),
                timeout=10,
            )
        except Exception as e:
            logger.warning(f"Create seed project failed: {e}")

    for seed in SEED_USERS[:2]:
        try:
            opener = logged_in_opener(seed["email"])
            body = urllib.parse.urlencode(
                {
                    "title": f"Gist {uuid.uuid4().hex[:6]}",
                    "description": "Seed gist for load testing.",
                    "source_code": "print('hello')\n" * 5,
                    "language": random.choice(GIST_LANGUAGES),
                }
            ).encode()
            resp = opener.open(
                urllib.request.Request(f"{host}/gists/create", data=body),
                timeout=10,
            )
            m = re.search(r"/gists/(\S+)", resp.url)
            if m and m.group(1) not in GIST_SLUGS:
                GIST_SLUGS.append(m.group(1))
            html = resp.read().decode("utf-8", errors="replace")
            m2 = re.search(rf"/votes/gist/({UUID_RE})", html)
            if m2 and m2.group(1) not in GIST_UIDS:
                GIST_UIDS.append(m2.group(1))
        except Exception as e:
            logger.warning(f"Create seed gist failed: {e}")

    logger.info(f"Created {len(GIST_SLUGS)} seed gists")

    for seed in SEED_USERS[:2]:
        try:
            opener = logged_in_opener(seed["email"])
            body = urllib.parse.urlencode(
                {
                    "title": f"Issue {uuid.uuid4().hex[:6]}",
                    "description": "Seed issue report for load testing.",
                }
            ).encode()
            opener.open(
                urllib.request.Request(f"{host}/issues/create", data=body),
                timeout=10,
            )
        except Exception as e:
            logger.warning(f"Create seed issue failed: {e}")

    # ── Seed news articles (direct DB insert) ───────────────────
    try:
        from devplacepy.database import get_table, init_db
        from devplacepy.utils import make_combined_slug

        init_db()
        news_table = get_table("news")
        for i in range(3):
            title = f"Locust news article {i} {uuid.uuid4().hex[:4]}"
            article_uid = str(uuid.uuid4())
            slug = make_combined_slug(title, article_uid)
            news_table.insert(
                {
                    "uid": article_uid,
                    "slug": slug,
                    "title": title,
                    "external_id": f"locust_seed_{i}",
                    "grade": 8,
                    "status": "published",
                    "show_on_landing": 1,
                    "source_name": "Locust",
                    "synced_at": "2026-01-01T00:00:00",
                    "description": "Locust seed article for load testing.",
                }
            )
            NEWS_UIDS.append(article_uid)
            NEWS_SLUGS.append(slug)

        admin_news_uid = str(uuid.uuid4())
        admin_news_title = f"Admin target news {uuid.uuid4().hex[:4]}"
        news_table.insert(
            {
                "uid": admin_news_uid,
                "slug": make_combined_slug(admin_news_title, admin_news_uid),
                "title": admin_news_title,
                "external_id": f"locust_admin_target_{uuid.uuid4().hex[:6]}",
                "grade": 5,
                "status": "published",
                "show_on_landing": 0,
                "source_name": "Locust",
                "synced_at": "2026-01-01T00:00:00",
                "description": "Dedicated target for admin news mutations.",
            }
        )
        ADMIN_TARGETS["news_uid"] = admin_news_uid
    except Exception as e:
        logger.warning(f"Seed news articles failed: {e}")

    # ── Harvest existing SEO audit job uids (never enqueue) ─────
    #   The heavy POST /tools/seo/run is deliberately excluded (real
    #   Playwright crawl, rate-capped), so we never create jobs here.
    #   We only pick up any pre-existing seo job rows so the status/report
    #   read tasks can hit a live uid when one happens to exist.
    try:
        from devplacepy.services.jobs import queue

        for job in queue.list_jobs(kind="seo")[:10]:
            uid = job.get("uid")
            if uid and uid not in SEO_JOB_UIDS:
                SEO_JOB_UIDS.append(uid)
    except Exception as e:
        logger.warning(f"Harvest SEO job uids failed: {e}")

    # ── Seed an admin user (direct DB insert) ───────────────────
    #   terms_version/terms_accepted_at are mandatory: the terms-acceptance
    #   middleware redirects every mutating request of a user whose version
    #   is stale to /auth/accept-terms, which would silently gut every
    #   admin write task. age_band keeps the maturity gates open.
    try:
        from datetime import datetime, timezone
        from devplacepy.database import get_setting, get_table, init_db
        from devplacepy.utils import hash_password, generate_uid

        init_db()
        now = datetime.now(timezone.utc).isoformat()
        terms_version = get_setting("terms_version", "1") or "1"
        username = f"lu_admin_{uuid.uuid4().hex[:6]}"
        email = f"{username}@locust.devplace"
        get_table("users").insert(
            {
                "uid": generate_uid(),
                "username": username,
                "email": email,
                "password_hash": hash_password(PASSWORD),
                "bio": "",
                "location": "",
                "git_link": "",
                "website": "",
                "role": "Admin",
                "is_active": True,
                "level": 1,
                "xp": 0,
                "stars": 0,
                "terms_version": terms_version,
                "terms_accepted_at": now,
                "age_band": "adult",
                "suspended_until": "",
                "created_at": now,
            }
        )
        ADMIN_USER["username"] = username
        ADMIN_USER["email"] = email
        ALL_USERNAMES.append(username)

        disposable_uid = generate_uid()
        disposable_name = f"lu_target_{uuid.uuid4().hex[:6]}"
        get_table("users").insert(
            {
                "uid": disposable_uid,
                "username": disposable_name,
                "email": f"{disposable_name}@locust.devplace",
                "password_hash": hash_password(PASSWORD),
                "bio": "",
                "location": "",
                "git_link": "",
                "website": "",
                "role": "Member",
                "is_active": True,
                "level": 1,
                "xp": 0,
                "stars": 0,
                "terms_version": terms_version,
                "terms_accepted_at": now,
                "age_band": "adult",
                "suspended_until": "",
                "created_at": now,
            }
        )
        ADMIN_TARGETS["disposable_uid"] = disposable_uid
        logger.info(f"Created admin user {username} and disposable target")
    except Exception as e:
        logger.warning(f"Seed admin user failed: {e}")

    # ── Seed one disposable moderation report ───────────────────
    #   Filing reports under load is excluded (it would flood the real
    #   queue), so exactly one row is raised here, by the disposable user
    #   against a seed post, to keep the admin queue/detail read tasks on
    #   a live uid instead of an empty pool.
    try:
        from devplacepy.services.moderation import queue as report_queue

        reporter_uid = ADMIN_TARGETS.get("disposable_uid")
        if reporter_uid and POST_UIDS:
            report = report_queue.raise_report(
                target_type="post",
                target_uid=POST_UIDS[0],
                reporter_uid=reporter_uid,
                reason="spam",
                detail="Disposable report for the moderation queue read tasks.",
            )
            if report:
                ADMIN_TARGETS["report_uid"] = report["uid"]
                logger.info(f"Seeded moderation report {report['uid']}")
    except Exception as e:
        logger.warning(f"Seed moderation report failed: {e}")

    # ── Harvest UIDs from HTML responses ──────────────────────
    #   projects sidebar:  /projects?user_uid={uuid}
    #   project cards:     /votes/project/{uuid}
    #   comment deletion:  /comments/delete/{uuid}

    uuid_pat = rf"({UUID_RE})"

    for seed in SEED_USERS:
        try:
            opener = logged_in_opener(seed["email"])
            resp = opener.open(urllib.request.Request(f"{host}/projects"), timeout=10)
            html = resp.read().decode("utf-8", errors="replace")
            m = re.search(rf"/projects\?user_uid={uuid_pat}", html)
            if m and m.group(1) not in USER_UIDS:
                USER_UIDS.append(m.group(1))
            proj_matches = re.findall(rf"/votes/project/{uuid_pat}", html)
            PROJECT_UIDS.extend(p for p in proj_matches if p not in PROJECT_UIDS)
            slug_matches = re.findall(r'href="/projects/([^"/?#]+)"', html)
            PROJECT_SLUGS.extend(
                s
                for s in slug_matches
                if s not in PROJECT_SLUGS and s not in ("create",)
            )
        except Exception as e:
            logger.warning(f"Harvest from {seed['username']} failed: {e}")

    for post_uid in POST_UIDS:
        try:
            opener = logged_in_opener(SEED_USERS[0]["email"])
            resp = opener.open(
                urllib.request.Request(f"{host}/posts/{post_uid}"),
                timeout=10,
            )
            html = resp.read().decode("utf-8", errors="replace")
            matches = re.findall(rf"/comments/delete/{uuid_pat}", html)
            COMMENT_UIDS.extend(m for m in matches if m not in COMMENT_UIDS)
            poll_uid = re.search(rf'data-poll-uid="{uuid_pat}"', html)
            option_uids = re.findall(rf'data-option-uid="{uuid_pat}"', html)
            if poll_uid and option_uids:
                entry = {"poll_uid": poll_uid.group(1), "options": option_uids}
                if entry not in POLLS:
                    POLLS.append(entry)
        except Exception as e:
            logger.warning(f"Harvest comment/poll UIDs failed: {e}")

    try:
        opener = logged_in_opener(SEED_USERS[0]["email"])
        resp = opener.open(urllib.request.Request(f"{host}/issues"), timeout=10)
        html = resp.read().decode("utf-8", errors="replace")
        issue_matches = re.findall(rf"/votes/issue/{uuid_pat}", html)
        ISSUE_UIDS.extend(b for b in issue_matches if b not in ISSUE_UIDS)
    except Exception as e:
        logger.warning(f"Harvest issue UIDs failed: {e}")

    logger.info(
        f"Seed complete: {len(SEED_USERS)} users, {len(POST_UIDS)} posts, "
        f"{len(PROJECT_UIDS)} projects ({len(PROJECT_SLUGS)} slugs), "
        f"{len(GIST_UIDS)} gists, {len(COMMENT_UIDS)} comments, "
        f"{len(ISSUE_UIDS)} issues, {len(USER_UIDS)} uids"
    )


class DevPlaceUser(HttpUser):
    weight = 20
    wait_time = between(1, 5)

    def on_start(self):
        assign_client_ip(self.client)
        self.own_post_slugs = []
        self.own_gist_slugs = []
        self.own_project_slugs = []
        self.own_media_uids = []
        self.own_comment_uids = []
        self.notification_uids = []
        self.own_quiz_slugs = []
        if random.random() < 0.3 and SEED_USERS:
            seed = random.choice(SEED_USERS)
            do_login(self.client, seed["email"])
            self.username = seed["username"]
        else:
            username = random_username()
            email = f"{username}@locust.devplace"
            if do_signup(self.client, username, email):
                ALL_USERNAMES.append(username)
            self.username = username

    # ── browsing ────────────────────────────────────────────────

    @task(5)
    def view_feed(self):
        tab = random.choice(["all", "trending", "recent", "following"])
        topic = random.choice([None] + TOPICS)
        params = {"tab": tab}
        if topic:
            params["topic"] = topic
        resp = self.client.get("/feed", params=params, name="feed")
        cursor = re.search(r'/feed\?before=([^"&]+)', resp.text)
        if cursor:
            params["before"] = cursor.group(1)
            self.client.get("/feed", params=params, name="feed?before")

    @task(2)
    def view_topics(self):
        self.client.get("/topics", name="topics")
        topic = random.choice(TOPICS)
        resp = self.client.get(f"/topics/{topic}", name="topics/[topic]")
        cursor = re.search(r'/topics/[^"?]+\?before=([^"&]+)', resp.text)
        if cursor:
            self.client.get(
                f"/topics/{topic}",
                params={"before": cursor.group(1)},
                name="topics/[topic]?before",
            )

    @task(4)
    def view_post(self):
        slugs = POST_SLUGS if POST_SLUGS else POST_UIDS
        if not slugs:
            return
        self.client.get(f"/posts/{random.choice(slugs)}", name="posts/[uid]")

    @task(3)
    def view_profile(self):
        if not ALL_USERNAMES:
            return
        self.client.get(
            f"/profile/{random.choice(ALL_USERNAMES)}",
            params={"tab": random.choice(["posts", "activity"])},
            name="profile/[username]",
        )

    @task(3)
    def view_projects(self):
        params = {"tab": random.choice(["recent", "released", "popular", "new"])}
        if random.random() < 0.3:
            params["project_type"] = random.choice(PROJECT_TYPES)
        if random.random() < 0.2 and USER_UIDS:
            params["user_uid"] = random.choice(USER_UIDS)
        self.client.get("/projects", params=params, name="projects")

    @task(1)
    def view_landing(self):
        self.client.get("/", name="landing")

    @task(1)
    def view_auth_forms(self):
        self.client.get(
            random.choice(["/auth/signup", "/auth/login"]), name="auth/form"
        )

    @task(3)
    def view_news(self):
        self.client.get("/news", name="news")

    @task(2)
    def view_news_detail(self):
        slugs = NEWS_SLUGS if NEWS_SLUGS else NEWS_UIDS
        if not slugs:
            return
        self.client.get(f"/news/{random.choice(slugs)}", name="news/[slug]")

    @task(1)
    def view_issues(self):
        self.client.get("/issues", name="issues")

    @task(3)
    def view_project_detail(self):
        if not PROJECT_SLUGS:
            return
        self.client.get(
            f"/projects/{random.choice(PROJECT_SLUGS)}", name="projects/[slug]"
        )

    @task(2)
    def view_gists(self):
        params = {}
        if random.random() < 0.3:
            params["language"] = random.choice(GIST_LANGUAGES)
        if random.random() < 0.2 and USER_UIDS:
            params["user_uid"] = random.choice(USER_UIDS)
        self.client.get("/gists", params=params, name="gists")

    @task(1)
    def view_leaderboard(self):
        self.client.get("/leaderboard", name="leaderboard")

    @task(1)
    def view_pwa(self):
        self.client.get(
            random.choice(["/push.json", "/service-worker.js", "/manifest.json"]),
            name="pwa",
        )

    @task(2)
    def view_gist_detail(self):
        if not GIST_SLUGS:
            return
        self.client.get(f"/gists/{random.choice(GIST_SLUGS)}", name="gists/[slug]")

    @task(1)
    def search_profiles(self):
        term = random.choice(ALL_USERNAMES)[:5] if ALL_USERNAMES else "lu"
        self.client.get("/profile/search", params={"q": term}, name="profile/search")

    @task(1)
    def search_messages(self):
        term = random.choice(ALL_USERNAMES)[:5] if ALL_USERNAMES else "lu"
        self.client.get("/messages/search", params={"q": term}, name="messages/search")

    @task(1)
    def view_seo(self):
        self.client.get(random.choice(["/robots.txt", "/sitemap.xml"]), name="seo")

    @task(2)
    def view_seo_diagnostics(self):
        self.client.get("/tools", name="tools")
        self.client.get("/tools/seo", name="tools/seo")

    @task(1)
    def view_seo_audit_status(self):
        uid = random.choice(SEO_JOB_UIDS) if SEO_JOB_UIDS else str(uuid.uuid4())
        with self.client.get(
            f"/tools/seo/{uid}", catch_response=True, name="tools/seo/[uid]"
        ) as resp:
            if resp.status_code in (200, 404):
                resp.success()
            else:
                resp.failure(f"seo status: status={resp.status_code}")
        with self.client.get(
            f"/tools/seo/{uid}/report",
            catch_response=True,
            name="tools/seo/[uid]/report",
        ) as report:
            if report.status_code in (200, 404):
                report.success()
            else:
                report.failure(f"seo report: status={report.status_code}")

    @task(1)
    def view_static(self):
        self.client.get("/static/css/base.css", name="static")

    @task(2)
    def view_docs(self):
        if random.random() < 0.2:
            self.client.get(
                random.choice(["/docs", "/docs/download.md", "/docs/download.html"]),
                name="docs",
            )
            return
        self.client.get(
            f"/docs/{random.choice(DOCS_SLUGS)}.html", name="docs/[slug]"
        )

    @task(1)
    def view_devii_pages(self):
        self.client.get(
            random.choice(["/devii/", "/devii/session", "/devii/usage"]),
            name="devii/[page]",
        )

    @task(1)
    def view_devii_adopt(self):
        self.client.get("/devii/adopt", params={"next": "/feed"}, name="devii/adopt")

    @task(2)
    def view_followers(self):
        if not ALL_USERNAMES:
            return
        username = random.choice(ALL_USERNAMES)
        self.client.get(
            f"/profile/{username}/{random.choice(['followers', 'following'])}",
            name="profile/[username]/follows",
        )

    @task(2)
    def view_notification_counts(self):
        self.client.get("/notifications/counts", name="notifications/counts")

    @task(2)
    def view_saved(self):
        self.client.get("/bookmarks/saved", name="bookmarks/saved")

    @task(1)
    def view_report_reasons(self):
        self.client.get("/reports/reasons", name="reports/reasons")

    @task(1)
    def view_my_reports(self):
        params = {}
        if random.random() < 0.5:
            params["status"] = random.choice(REPORT_STATUSES)
        self.client.get("/reports/mine", params=params, name="reports/mine")

    @task(2)
    def view_workspace_index(self):
        self.client.get("/workspaces/index", name="workspaces/index")

    # ── auth flows ──────────────────────────────────────────────

    @task(1)
    def login_flow(self):
        if not SEED_USERS:
            return
        do_login(self.client, random.choice(SEED_USERS)["email"])

    @task(1)
    def logout_and_relogin(self):
        self.client.get("/auth/logout", name="auth/logout")
        self.client.get("/", name="landing")
        if SEED_USERS:
            do_login(self.client, random.choice(SEED_USERS)["email"])

    @task(1)
    def view_forgot_password(self):
        self.client.get("/auth/forgot-password", name="auth/forgot-password")

    @task(1)
    def request_password_reset(self):
        email = (
            random.choice(SEED_USERS)["email"] if SEED_USERS else "x@locust.devplace"
        )
        self.client.post(
            "/auth/forgot-password",
            data={"email": email},
            name="auth/forgot-password/post",
        )

    @task(1)
    def reset_password_flow(self):
        token = uuid.uuid4().hex + uuid.uuid4().hex
        self.client.get(f"/auth/reset-password/{token}", name="auth/reset-password")
        self.client.post(
            f"/auth/reset-password/{token}",
            data={"password": PASSWORD, "confirm_password": PASSWORD},
            name="auth/reset-password/post",
        )

    # ── content creation ────────────────────────────────────────

    @task(2)
    def create_post(self):
        data = {
            "content": "x " * 100,
            "title": f"Load test {uuid.uuid4().hex[:6]}",
            "topic": random.choice(TOPICS),
        }
        with self.client.post(
            "/posts/create", data=data, catch_response=True, name="posts/create"
        ) as resp:
            if resp.status_code == 200 and resp.history:
                m = re.search(r"/posts/(\S+)", resp.url or "")
                if m:
                    slug = m.group(1)
                    if slug not in POST_SLUGS:
                        POST_SLUGS.append(slug)
                    self.own_post_slugs.append(slug)
                m2 = re.search(rf"/votes/post/({UUID_RE})", resp.text)
                if m2 and m2.group(1) not in POST_UIDS:
                    POST_UIDS.append(m2.group(1))
                resp.success()
            else:
                resp.failure("post not created")

    @task(2)
    def comment_on_post(self):
        if not POST_UIDS:
            return
        with self.client.post(
            "/comments/create",
            data={
                "content": f"Comment {uuid.uuid4().hex[:6]} " * 3,
                "post_uid": random.choice(POST_UIDS),
            },
            headers={"Accept": "application/json"},
            catch_response=True,
            name="comments/create",
        ) as resp:
            if resp.status_code != 200:
                resp.failure(f"comment create: status={resp.status_code}")
                return
            try:
                uid = (resp.json().get("data") or {}).get("uid")
            except (ValueError, RequestException, AttributeError):
                uid = None
            if uid:
                if uid not in COMMENT_UIDS:
                    COMMENT_UIDS.append(uid)
                self.own_comment_uids.append(uid)
            resp.success()

    @task(1)
    def create_project(self):
        with self.client.post(
            "/projects/create",
            data={
                "title": f"Project {uuid.uuid4().hex[:6]}",
                "description": "y " * 50,
                "project_type": random.choice(PROJECT_TYPES),
            },
            catch_response=True,
            name="projects/create",
        ) as resp:
            m = re.search(r"/projects/(\S+)", resp.url or "")
            if resp.status_code == 200 and m:
                slug = m.group(1)
                if slug not in PROJECT_SLUGS:
                    PROJECT_SLUGS.append(slug)
                self.own_project_slugs.append(slug)
                resp.success()
            else:
                resp.failure("project not created")

    @task(1)
    def create_gist(self):
        with self.client.post(
            "/gists/create",
            data={
                "title": f"Gist {uuid.uuid4().hex[:6]}",
                "description": "Load test gist.",
                "source_code": "x = 1\n" * 20,
                "language": random.choice(GIST_LANGUAGES),
            },
            catch_response=True,
            name="gists/create",
        ) as resp:
            m = re.search(r"/gists/(\S+)", resp.url or "")
            if resp.status_code == 200 and m:
                slug = m.group(1)
                if slug not in GIST_SLUGS:
                    GIST_SLUGS.append(slug)
                self.own_gist_slugs.append(slug)
                m2 = re.search(rf"/votes/gist/({UUID_RE})", resp.text)
                if m2 and m2.group(1) not in GIST_UIDS:
                    GIST_UIDS.append(m2.group(1))
                resp.success()
            else:
                resp.failure("gist not created")

    @task(1)
    def create_issue(self):
        with self.client.post(
            "/issues/create",
            data={
                "title": f"Issue {uuid.uuid4().hex[:6]}",
                "description": "Load test issue report description.",
            },
            catch_response=True,
            name="issues/create",
        ) as resp:
            if resp.status_code == 503:
                resp.success()
                return
            if resp.status_code != 200:
                resp.failure(f"issue create: status={resp.status_code}")
                return
            resp.success()
            try:
                uid = resp.json().get("uid")
            except (ValueError, RequestException, AttributeError):
                uid = None
        if uid and uid not in ISSUE_JOB_UIDS:
            ISSUE_JOB_UIDS.append(uid)

    # ── social interaction ──────────────────────────────────────

    def _vote(self, target_type, uid):
        data = {"value": random.choice([1, -1])}
        name = f"votes/{target_type}"
        if random.random() < 0.5:
            self.client.post(f"/votes/{target_type}/{uid}", data=data, name=name)
            return
        with self.client.post(
            f"/votes/{target_type}/{uid}",
            data=data,
            headers={"x-requested-with": "fetch"},
            catch_response=True,
            name=f"{name}/ajax",
        ) as resp:
            try:
                payload = resp.json()
            except (ValueError, RequestException):
                resp.failure("vote ajax response not JSON")
                return
            if all(k in payload for k in ("net", "up", "down", "value")):
                resp.success()
            else:
                resp.failure(f"vote ajax missing keys: {payload}")

    @task(2)
    def vote_on_post(self):
        if not POST_UIDS:
            return
        self._vote("post", random.choice(POST_UIDS))

    @task(1)
    def vote_on_project(self):
        if not PROJECT_UIDS:
            return
        self._vote("project", random.choice(PROJECT_UIDS))

    @task(1)
    def vote_on_comment(self):
        if not COMMENT_UIDS:
            return
        self._vote("comment", random.choice(COMMENT_UIDS))

    @task(1)
    def vote_on_gist(self):
        if not GIST_UIDS:
            return
        self._vote("gist", random.choice(GIST_UIDS))

    @task(1)
    def vote_on_issue(self):
        if not ISSUE_UIDS:
            return
        self._vote("issue", random.choice(ISSUE_UIDS))

    def _react(self, target_type, uid):
        emoji = random.choice(REACTION_EMOJIS)
        with self.client.post(
            f"/reactions/{target_type}/{uid}",
            data={"emoji": emoji},
            headers={"x-requested-with": "fetch"},
            catch_response=True,
            name=f"reactions/{target_type}",
        ) as resp:
            try:
                payload = resp.json()
            except (ValueError, RequestException):
                resp.failure("reaction response not JSON")
                return
            if "counts" in payload and "mine" in payload:
                resp.success()
            else:
                resp.failure(f"reaction missing keys: {payload}")

    @task(2)
    def react_on_post(self):
        if not POST_UIDS:
            return
        self._react("post", random.choice(POST_UIDS))

    @task(1)
    def react_on_target(self):
        pools = []
        if GIST_UIDS:
            pools.append(("gist", GIST_UIDS))
        if PROJECT_UIDS:
            pools.append(("project", PROJECT_UIDS))
        if COMMENT_UIDS:
            pools.append(("comment", COMMENT_UIDS))
        if not pools:
            return
        target_type, uids = random.choice(pools)
        self._react(target_type, random.choice(uids))

    def _bookmark(self, target_type, uid):
        with self.client.post(
            f"/bookmarks/{target_type}/{uid}",
            headers={"x-requested-with": "fetch"},
            catch_response=True,
            name=f"bookmarks/{target_type}",
        ) as resp:
            try:
                payload = resp.json()
            except (ValueError, RequestException):
                resp.failure("bookmark response not JSON")
                return
            if "saved" in payload:
                resp.success()
            else:
                resp.failure(f"bookmark missing keys: {payload}")

    @task(2)
    def bookmark_post(self):
        if not POST_UIDS:
            return
        self._bookmark("post", random.choice(POST_UIDS))

    @task(1)
    def bookmark_target(self):
        pools = []
        if GIST_UIDS:
            pools.append(("gist", GIST_UIDS))
        if PROJECT_UIDS:
            pools.append(("project", PROJECT_UIDS))
        if NEWS_UIDS:
            pools.append(("news", NEWS_UIDS))
        if not pools:
            return
        target_type, uids = random.choice(pools)
        self._bookmark(target_type, random.choice(uids))

    @task(2)
    def vote_on_poll(self):
        if not POLLS:
            return
        poll = random.choice(POLLS)
        with self.client.post(
            f"/polls/{poll['poll_uid']}/vote",
            data={"option_uid": random.choice(poll["options"])},
            headers={"x-requested-with": "fetch"},
            catch_response=True,
            name="polls/[uid]/vote",
        ) as resp:
            try:
                payload = resp.json()
            except (ValueError, RequestException):
                resp.failure("poll vote response not JSON")
                return
            if "options" in payload or "total" in payload:
                resp.success()
            else:
                resp.failure(f"poll vote missing keys: {payload}")

    @task(1)
    def follow_user(self):
        targets = [u for u in ALL_USERNAMES if u != self.username]
        if not targets:
            return
        self.client.post(f"/follow/{random.choice(targets)}", name="follow/[username]")

    @task(1)
    def unfollow_user(self):
        targets = [u for u in ALL_USERNAMES if u != self.username]
        if not targets:
            return
        self.client.post(
            f"/follow/unfollow/{random.choice(targets)}", name="follow/unfollow"
        )

    @task(1)
    def send_message(self):
        if not USER_UIDS or not SEED_USERS:
            return
        target_uid = random.choice(USER_UIDS)
        self.client.post(
            "/messages/send",
            data={
                "content": f"Msg from {self.username}",
                "receiver_uid": target_uid,
            },
            name="messages/send",
        )

    # ── user self-management ────────────────────────────────────

    @task(1)
    def update_profile(self):
        self.client.post(
            "/profile/update",
            data={
                "bio": f"Updated at {uuid.uuid4().hex[:6]}",
                "location": random.choice(["NL", "US", "DE", "UK", " "]),
            },
            name="profile/update",
        )

    @task(1)
    def view_messages(self):
        self.client.get("/messages", name="messages")

    @task(1)
    def view_conversation(self):
        if not USER_UIDS:
            return
        self.client.get(
            "/messages",
            params={"with_uid": random.choice(USER_UIDS)},
            name="messages?with_uid",
        )

    @task(1)
    def view_notifications(self):
        resp = self.client.get("/notifications", name="notifications")
        matches = re.findall(rf"/notifications/mark-read/({UUID_RE})", resp.text)
        self.notification_uids.extend(
            m for m in matches if m not in self.notification_uids
        )
        cursor = re.search(r'/notifications\?before=([^"]+)', resp.text)
        if cursor:
            self.client.get(
                "/notifications",
                params={"before": cursor.group(1)},
                name="notifications?before",
            )

    @task(1)
    def open_notification(self):
        if not self.notification_uids:
            return
        self.client.get(
            f"/notifications/open/{random.choice(self.notification_uids)}",
            name="notifications/open",
        )

    @task(1)
    def mark_all_notifications_read(self):
        self.client.post(
            "/notifications/mark-all-read", name="notifications/mark-all-read"
        )

    @task(1)
    def mark_one_notification_read(self):
        if not self.notification_uids:
            return
        self.client.post(
            f"/notifications/mark-read/{random.choice(self.notification_uids)}",
            name="notifications/mark-read",
        )

    @task(1)
    def delete_comment(self):
        if not self.own_comment_uids:
            return
        uid = self.own_comment_uids.pop()
        if uid in COMMENT_UIDS:
            COMMENT_UIDS.remove(uid)
        self.client.post(f"/comments/delete/{uid}", name="comments/delete")

    # ── owner-scoped mutations ──────────────────────────────────

    @task(1)
    def edit_post(self):
        if not self.own_post_slugs:
            return
        self.client.post(
            f"/posts/edit/{random.choice(self.own_post_slugs)}",
            data={
                "content": f"Edited {uuid.uuid4().hex} " * 3,
                "title": "Edited post",
                "topic": random.choice(TOPICS),
            },
            name="posts/edit",
        )

    @task(1)
    def delete_post(self):
        if not self.own_post_slugs:
            return
        slug = self.own_post_slugs.pop()
        if slug in POST_SLUGS:
            POST_SLUGS.remove(slug)
        self.client.post(f"/posts/delete/{slug}", name="posts/delete")

    @task(1)
    def edit_gist(self):
        if not self.own_gist_slugs:
            return
        self.client.post(
            f"/gists/edit/{random.choice(self.own_gist_slugs)}",
            data={
                "title": "Edited gist",
                "description": "Edited description.",
                "source_code": "y = 2\n" * 20,
                "language": random.choice(GIST_LANGUAGES),
            },
            name="gists/edit",
        )

    @task(1)
    def delete_gist(self):
        if not self.own_gist_slugs:
            return
        slug = self.own_gist_slugs.pop()
        if slug in GIST_SLUGS:
            GIST_SLUGS.remove(slug)
        self.client.post(f"/gists/delete/{slug}", name="gists/delete")

    @task(1)
    def delete_project(self):
        if not self.own_project_slugs:
            return
        slug = self.own_project_slugs.pop()
        if slug in PROJECT_SLUGS:
            PROJECT_SLUGS.remove(slug)
        self.client.post(f"/projects/delete/{slug}", name="projects/delete")

    @task(1)
    def upload_file(self):
        name, content, mime = UPLOAD_FILE
        with self.client.post(
            "/uploads/upload",
            files={"file": (name, content, mime)},
            catch_response=True,
            name="uploads/upload",
        ) as resp:
            if resp.status_code == 201:
                resp.success()
                try:
                    uid = resp.json().get("uid")
                except (ValueError, RequestException, AttributeError):
                    uid = None
            else:
                resp.failure(f"upload failed: status={resp.status_code}")
                uid = None
        if uid:
            if len(self.own_media_uids) < 5 and random.random() < 0.5:
                self.own_media_uids.append(uid)
            else:
                self.client.delete(f"/uploads/delete/{uid}", name="uploads/delete")

    @task(1)
    def register_push(self):
        token = uuid.uuid4().hex
        body = {
            "endpoint": f"https://push.locust/endpoint/{token}",
            "keys": {
                "auth": uuid.uuid4().hex[:22],
                "p256dh": (uuid.uuid4().hex + uuid.uuid4().hex)[:43],
            },
        }
        with self.client.post(
            "/push.json",
            json=body,
            catch_response=True,
            name="push.json/register",
        ) as resp:
            if resp.status_code in (200, 400):
                resp.success()
            else:
                resp.failure(f"push register: status={resp.status_code}")

    @task(1)
    def regenerate_api_key(self):
        self.client.post(
            "/profile/regenerate-api-key", name="profile/regenerate-api-key"
        )

    @task(1)
    def toggle_customization(self):
        scope = random.choice(["global", "pagetype"])
        self.client.post(
            f"/profile/{self.username}/customization/{scope}",
            data={"value": random.choice(["true", "false"])},
            name=f"profile/customization/{scope}",
        )

    @task(1)
    def set_notification_preference(self):
        self.client.post(
            f"/profile/{self.username}/notifications",
            data={
                "notification_type": random.choice(NOTIFICATION_TYPES),
                "channel": random.choice(["in_app", "push"]),
                "value": random.choice(["true", "false"]),
            },
            name="profile/notifications",
        )

    @task(1)
    def reset_notification_preferences(self):
        self.client.post(
            f"/profile/{self.username}/notifications/reset",
            name="profile/notifications/reset",
        )

    @task(1)
    def upload_url_attachment(self):
        with self.client.post(
            "/uploads/upload-url",
            data={"url": "https://placehold.co/64x64.png"},
            catch_response=True,
            name="uploads/upload-url",
        ) as resp:
            if resp.status_code in (201, 400, 422):
                resp.success()
            else:
                resp.failure(f"upload-url: status={resp.status_code}")

    @task(1)
    def manage_own_media(self):
        if not self.own_media_uids:
            return
        uid = random.choice(self.own_media_uids)
        self.client.post(f"/media/{uid}/delete", name="media/delete")
        self.client.post(f"/media/{uid}/restore", name="media/restore")

    @task(1)
    def file_issue_job_status(self):
        if not ISSUE_JOB_UIDS:
            return
        with self.client.get(
            f"/issues/jobs/{random.choice(ISSUE_JOB_UIDS)}",
            catch_response=True,
            name="issues/jobs/[uid]",
        ) as resp:
            if resp.status_code in (200, 404):
                resp.success()
            else:
                resp.failure(f"issue job status: status={resp.status_code}")

    # ── owner-scoped project mutations ──────────────────────────

    @task(1)
    def edit_project(self):
        if not self.own_project_slugs:
            return
        self.client.post(
            f"/projects/edit/{random.choice(self.own_project_slugs)}",
            data={
                "title": "Edited project",
                "description": "Edited description for load test.",
                "project_type": random.choice(PROJECT_TYPES),
            },
            name="projects/edit",
        )

    @task(1)
    def toggle_project_flags(self):
        if not self.own_project_slugs:
            return
        slug = random.choice(self.own_project_slugs)
        self.client.post(
            f"/projects/{slug}/{random.choice(['private', 'readonly'])}",
            name="projects/[slug]/flag",
        )

    @task(2)
    def browse_project_files(self):
        if not PROJECT_SLUGS:
            return
        slug = random.choice(PROJECT_SLUGS)
        self.client.get(
            f"/projects/{slug}/files", params={"path": ""}, name="projects/[slug]/files"
        )

    @task(2)
    def edit_own_project_files(self):
        if not self.own_project_slugs:
            return
        slug = random.choice(self.own_project_slugs)
        path = f"notes/{uuid.uuid4().hex[:6]}.txt"
        with self.client.post(
            f"/projects/{slug}/files/write",
            data={"path": path, "content": "line one\nline two\nline three\n"},
            catch_response=True,
            name="projects/[slug]/files/write",
        ) as resp:
            if resp.status_code in (200, 302, 303):
                resp.success()
            else:
                resp.failure(f"file write: status={resp.status_code}")
                return
        self.client.get(
            f"/projects/{slug}/files/raw",
            params={"path": path},
            name="projects/[slug]/files/raw",
        )
        self.client.get(
            f"/projects/{slug}/files/lines",
            params={"path": path, "start": 1, "end": 2},
            name="projects/[slug]/files/lines",
        )
        self.client.post(
            f"/projects/{slug}/files/append",
            data={"path": path, "content": "appended line\n"},
            name="projects/[slug]/files/append",
        )
        self.client.post(
            f"/projects/{slug}/files/replace-lines",
            data={"path": path, "start": 1, "end": 1, "content": "replaced line"},
            name="projects/[slug]/files/replace-lines",
        )
        self.client.post(
            f"/projects/{slug}/files/insert-lines",
            data={"path": path, "at": 1, "content": "inserted line"},
            name="projects/[slug]/files/insert-lines",
        )
        self.client.post(
            f"/projects/{slug}/files/delete-lines",
            data={"path": path, "start": 1, "end": 1},
            name="projects/[slug]/files/delete-lines",
        )
        moved = f"notes/{uuid.uuid4().hex[:6]}.txt"
        self.client.post(
            f"/projects/{slug}/files/move",
            data={"from_path": path, "to_path": moved},
            name="projects/[slug]/files/move",
        )
        self.client.post(
            f"/projects/{slug}/files/delete",
            data={"path": moved},
            name="projects/[slug]/files/delete",
        )

    @task(1)
    def make_project_dir_and_upload(self):
        if not self.own_project_slugs:
            return
        slug = random.choice(self.own_project_slugs)
        directory = f"dir_{uuid.uuid4().hex[:6]}"
        self.client.post(
            f"/projects/{slug}/files/mkdir",
            data={"path": directory},
            name="projects/[slug]/files/mkdir",
        )
        name, content, mime = UPLOAD_FILE
        self.client.post(
            f"/projects/{slug}/files/upload",
            data={"path": directory},
            files={"file": (name, content, mime)},
            name="projects/[slug]/files/upload",
        )

    @task(1)
    def zip_project(self):
        if not self.own_project_slugs:
            return
        slug = random.choice(self.own_project_slugs)
        endpoint = random.choice(["zip", "files/zip"])
        with self.client.post(
            f"/projects/{slug}/{endpoint}",
            params={"path": ""},
            catch_response=True,
            name="projects/[slug]/zip",
        ) as resp:
            if resp.status_code not in (200, 202):
                resp.failure(f"zip enqueue: status={resp.status_code}")
                return
            try:
                uid = resp.json().get("uid")
            except (ValueError, RequestException, AttributeError):
                match = re.search(rf"/zips/({UUID_RE})", resp.text or "")
                uid = match.group(1) if match else None
            resp.success()
        if uid:
            self.client.get(f"/zips/{uid}", name="zips/[uid]")
            with self.client.get(
                f"/zips/{uid}/download",
                name="zips/[uid]/download",
                catch_response=True,
            ) as download:
                if download.status_code in (200, 202, 404):
                    download.success()
                else:
                    download.failure(f"zip download: status={download.status_code}")

    @task(1)
    def fork_project(self):
        if not PROJECT_SLUGS:
            return
        slug = random.choice(PROJECT_SLUGS)
        with self.client.post(
            f"/projects/{slug}/fork",
            data={"title": f"Fork {uuid.uuid4().hex[:6]}"},
            catch_response=True,
            name="projects/[slug]/fork",
        ) as resp:
            if resp.status_code not in (200, 202):
                resp.failure(f"fork enqueue: status={resp.status_code}")
                return
            try:
                uid = resp.json().get("uid")
            except (ValueError, RequestException, AttributeError):
                uid = None
            resp.success()
        if uid:
            self.client.get(f"/forks/{uid}", name="forks/[uid]")

    # ── static / media ──────────────────────────────────────────

    @task(2)
    def view_multiavatar(self):
        seed = random.choice(ALL_USERNAMES) if ALL_USERNAMES else "anon"
        resp = self.client.get(
            f"/avatar/multiavatar/{seed}?size=32", name="avatar/multiavatar"
        )
        etag = resp.headers.get("ETag")
        if not etag:
            return
        with self.client.get(
            f"/avatar/multiavatar/{seed}?size=32",
            headers={"If-None-Match": etag},
            catch_response=True,
            name="avatar/multiavatar/304",
        ) as cached:
            if cached.status_code == 304:
                cached.success()
            else:
                cached.failure(f"expected 304, got {cached.status_code}")

    @task(1)
    def comment_on_news(self):
        if not NEWS_UIDS:
            return
        self.client.post(
            "/comments/create",
            data={
                "content": f"News comment {uuid.uuid4().hex[:6]} " * 2,
                "target_uid": random.choice(NEWS_UIDS),
                "target_type": "news",
            },
            name="comments/news",
        )

    @task(1)
    def comment_on_target(self):
        pools = []
        if PROJECT_UIDS:
            pools.append(("project", PROJECT_UIDS))
        if GIST_UIDS:
            pools.append(("gist", GIST_UIDS))
        if ISSUE_UIDS:
            pools.append(("issue", ISSUE_UIDS))
        if not pools:
            return
        target_type, uids = random.choice(pools)
        self.client.post(
            "/comments/create",
            data={
                "content": f"{target_type} comment {uuid.uuid4().hex[:6]} " * 2,
                "target_uid": random.choice(uids),
                "target_type": target_type,
            },
            name=f"comments/{target_type}",
        )

    @task(1)
    def reply_to_comment(self):
        if not POST_UIDS or not COMMENT_UIDS:
            return
        self.client.post(
            "/comments/create",
            data={
                "content": f"Reply {uuid.uuid4().hex[:6]} " * 2,
                "post_uid": random.choice(POST_UIDS),
                "parent_uid": random.choice(COMMENT_UIDS),
            },
            name="comments/reply",
        )

    @task(2)
    def browse_and_engage(self):
        slugs = POST_SLUGS if POST_SLUGS else POST_UIDS
        if not slugs:
            return
        resp = self.client.get(f"/posts/{random.choice(slugs)}", name="posts/[uid]")
        vote_uid = re.search(rf"/votes/post/({UUID_RE})", resp.text)
        if vote_uid:
            self._vote("post", vote_uid.group(1))
        comment_uid = re.search(rf"/comments/delete/({UUID_RE})", resp.text)
        if comment_uid and comment_uid.group(1) not in COMMENT_UIDS:
            COMMENT_UIDS.append(comment_uid.group(1))

    # ── block / mute ────────────────────────────────────────────

    @task(1)
    def block_user(self):
        targets = [u for u in ALL_USERNAMES if u != self.username]
        if not targets:
            return
        self.client.post(f"/block/{random.choice(targets)}", name="block/[username]")

    @task(1)
    def unblock_user(self):
        targets = [u for u in ALL_USERNAMES if u != self.username]
        if not targets:
            return
        self.client.post(
            f"/block/unblock/{random.choice(targets)}", name="block/unblock"
        )

    @task(1)
    def mute_user(self):
        targets = [u for u in ALL_USERNAMES if u != self.username]
        if not targets:
            return
        self.client.post(f"/mute/{random.choice(targets)}", name="mute/[username]")

    @task(1)
    def unmute_user(self):
        targets = [u for u in ALL_USERNAMES if u != self.username]
        if not targets:
            return
        self.client.post(
            f"/mute/unmute/{random.choice(targets)}", name="mute/unmute"
        )

    # ── Code Farm ───────────────────────────────────────────────

    @task(2)
    def view_game_home(self):
        self.client.get("/game", name="game")

    @task(2)
    def view_game_state(self):
        self.client.get("/game/state", name="game/state")

    @task(1)
    def view_game_leaderboard(self):
        board = random.choice(
            ["score", "prestige", "harvests", "raids", "time_to_kernel", "fair_play"]
        )
        self.client.get(
            "/game/leaderboard", params={"board": board}, name="game/leaderboard"
        )

    @task(2)
    def game_plant(self):
        self.client.post(
            "/game/plant",
            data={"slot": random.randint(0, 3), "crop": "shell"},
            name="game/plant",
        )

    @task(2)
    def game_harvest(self):
        self.client.post(
            "/game/harvest",
            data={"slot": random.randint(0, 3)},
            name="game/harvest",
        )

    @task(1)
    def game_buy_plot(self):
        self.client.post("/game/buy-plot", name="game/buy-plot")

    @task(1)
    def game_upgrade_ci(self):
        self.client.post("/game/upgrade", name="game/upgrade")

    @task(1)
    def game_fertilize(self):
        self.client.post(
            "/game/fertilize",
            data={"slot": random.randint(0, 3)},
            name="game/fertilize",
        )

    @task(1)
    def game_daily(self):
        self.client.post("/game/daily", name="game/daily")

    @task(1)
    def game_claim_grant(self):
        self.client.post("/game/grant", name="game/grant")

    @task(1)
    def game_perk(self):
        perk = random.choice(["yield", "growth", "discount", "xp"])
        self.client.post("/game/perk", data={"perk": perk}, name="game/perk")

    @task(1)
    def game_quests_claim(self):
        quest = random.choice(["plant", "harvest", "water", "earn"])
        self.client.post(
            "/game/quests/claim",
            data={"quest": quest, "scope": "daily"},
            name="game/quests/claim",
        )

    @task(1)
    def game_defense(self):
        self.client.post(
            f"/game/defense/{random.choice(['upgrade', 'downgrade'])}",
            name="game/defense",
        )

    @task(1)
    def game_infrastructure_buy(self):
        key = random.choice(["registry", "canary", "observability"])
        self.client.post(
            "/game/infrastructure/buy", data={"key": key}, name="game/infrastructure/buy"
        )

    @task(1)
    def game_mastery(self):
        key = random.choice(["autoreplant", "analytics", "contracts"])
        self.client.post("/game/mastery", data={"key": key}, name="game/mastery")

    @task(1)
    def game_cosmetics(self):
        key = random.choice(
            [
                "title_architect",
                "title_refactorer",
                "title_kernel_hacker",
                "skin_neon",
            ]
        )
        if random.random() < 0.5:
            self.client.post(
                "/game/cosmetics/buy", data={"key": key}, name="game/cosmetics/buy"
            )
        else:
            self.client.post(
                "/game/cosmetics/equip", data={"key": key}, name="game/cosmetics/equip"
            )

    @task(2)
    def view_game_farm(self):
        if not ALL_USERNAMES:
            return
        self.client.get(
            f"/game/farm/{random.choice(ALL_USERNAMES)}", name="game/farm/[username]"
        )

    @task(1)
    def game_water_farm(self):
        targets = [u for u in ALL_USERNAMES if u != self.username]
        if not targets:
            return
        self.client.post(
            f"/game/farm/{random.choice(targets)}/water",
            data={"slot": random.randint(0, 3)},
            name="game/farm/[username]/water",
        )

    @task(1)
    def game_steal_farm(self):
        targets = [u for u in ALL_USERNAMES if u != self.username]
        if not targets:
            return
        self.client.post(
            f"/game/farm/{random.choice(targets)}/steal",
            data={"slot": random.randint(0, 3)},
            name="game/farm/[username]/steal",
        )

    # ── Quizzes ─────────────────────────────────────────────────

    @task(2)
    def view_quizzes_hub(self):
        params = {"filter": random.choice(["all", "todo", "done", "mine", "drafts"])}
        self.client.get("/quizzes", params=params, name="quizzes")

    @task(1)
    def view_quiz_scoreboard(self):
        self.client.get("/quizzes/scoreboard", name="quizzes/scoreboard")

    @task(2)
    def view_quiz_detail(self):
        if not QUIZ_SLUGS:
            return
        self.client.get(f"/quizzes/{random.choice(QUIZ_SLUGS)}", name="quizzes/[slug]")

    @task(1)
    def view_quiz_leaderboard(self):
        if not QUIZ_SLUGS:
            return
        self.client.get(
            f"/quizzes/{random.choice(QUIZ_SLUGS)}/leaderboard",
            name="quizzes/[slug]/leaderboard",
        )

    @task(1)
    def view_quiz_export(self):
        if not QUIZ_SLUGS:
            return
        self.client.get(
            f"/quizzes/{random.choice(QUIZ_SLUGS)}/export", name="quizzes/[slug]/export"
        )

    @task(1)
    def view_quiz_builder(self):
        if not self.own_quiz_slugs:
            return
        slug = random.choice(self.own_quiz_slugs)
        self.client.get(f"/quizzes/{slug}/edit", name="quizzes/[slug]/edit")

    @task(1)
    def view_quiz_new_page(self):
        self.client.get("/quizzes/new", name="quizzes/new")

    @task(1)
    def create_quiz(self):
        with self.client.post(
            "/quizzes/create",
            json={
                "title": f"Quiz {uuid.uuid4().hex[:6]}",
                "description": "Load test quiz.",
            },
            catch_response=True,
            name="quizzes/create",
        ) as resp:
            if resp.status_code != 200:
                resp.failure(f"quiz create: status={resp.status_code}")
                return
            try:
                slug = (resp.json().get("data") or {}).get("slug")
            except (ValueError, RequestException, AttributeError):
                slug = None
            if slug:
                self.own_quiz_slugs.append(slug)
            resp.success()

    @task(1)
    def edit_quiz_settings(self):
        if not self.own_quiz_slugs:
            return
        slug = random.choice(self.own_quiz_slugs)
        self.client.post(
            f"/quizzes/edit/{slug}",
            json={
                "title": f"Edited quiz {uuid.uuid4().hex[:6]}",
                "description": "Edited load test quiz.",
            },
            name="quizzes/edit",
        )

    @task(2)
    def add_quiz_question(self):
        if not self.own_quiz_slugs:
            return
        slug = random.choice(self.own_quiz_slugs)
        answer = random.choice([True, False])
        with self.client.post(
            f"/quizzes/{slug}/questions",
            json={
                "kind": "true_false",
                "prompt": f"Load test question {uuid.uuid4().hex[:6]}?",
                "correct_boolean": answer,
                "points": 1,
            },
            catch_response=True,
            name="quizzes/[slug]/questions",
        ) as resp:
            if resp.status_code != 200:
                resp.failure(f"quiz question add: status={resp.status_code}")
                return
            try:
                uid = (resp.json().get("data") or {}).get("uid")
            except (ValueError, RequestException, AttributeError):
                uid = None
            if uid:
                QUIZ_QUESTIONS.setdefault(slug, []).append(uid)
            resp.success()

    @task(1)
    def publish_quiz(self):
        ready = [s for s in self.own_quiz_slugs if QUIZ_QUESTIONS.get(s)]
        if not ready:
            return
        slug = random.choice(ready)
        with self.client.post(
            f"/quizzes/{slug}/publish",
            headers={"Accept": "application/json"},
            catch_response=True,
            name="quizzes/[slug]/publish",
        ) as resp:
            if resp.status_code != 200:
                resp.failure(f"quiz publish: status={resp.status_code}")
                return
            try:
                ok = bool(resp.json().get("ok"))
            except (ValueError, RequestException, AttributeError):
                ok = False
            if not ok:
                resp.failure(f"quiz publish rejected: {resp.text}")
                return
            if slug not in QUIZ_SLUGS:
                QUIZ_SLUGS.append(slug)
            self.own_quiz_slugs.remove(slug)
            resp.success()

    @task(1)
    def delete_quiz(self):
        if not self.own_quiz_slugs:
            return
        slug = self.own_quiz_slugs.pop()
        QUIZ_QUESTIONS.pop(slug, None)
        self.client.post(f"/quizzes/delete/{slug}", name="quizzes/delete")

    @task(2)
    def play_quiz(self):
        candidates = [s for s in QUIZ_SLUGS if QUIZ_QUESTIONS.get(s)]
        if not candidates:
            return
        slug = random.choice(candidates)
        with self.client.post(
            f"/quizzes/{slug}/attempts",
            headers={"Accept": "application/json"},
            catch_response=True,
            name="quizzes/[slug]/attempts",
        ) as resp:
            if resp.status_code != 200:
                resp.failure(f"quiz attempt start: status={resp.status_code}")
                return
            try:
                attempt_uid = (resp.json().get("data") or {}).get("uid")
            except (ValueError, RequestException, AttributeError):
                attempt_uid = None
            resp.success()
        if not attempt_uid:
            return
        for question_uid in QUIZ_QUESTIONS.get(slug, []):
            self.client.post(
                f"/quizzes/{slug}/attempts/{attempt_uid}/answer",
                json={
                    "question_uid": question_uid,
                    "answer_text": random.choice(["true", "false"]),
                },
                name="quizzes/[slug]/attempts/[uid]/answer",
            )
        self.client.get(
            f"/quizzes/{slug}/attempts/{attempt_uid}",
            name="quizzes/[slug]/attempts/[uid]",
        )
        self.client.post(
            f"/quizzes/{slug}/attempts/{attempt_uid}/finish",
            name="quizzes/[slug]/attempts/[uid]/finish",
        )
        self.client.get(
            f"/quizzes/{slug}/attempts/{attempt_uid}/results",
            name="quizzes/[slug]/attempts/[uid]/results",
        )

    # ── Opinion Wars ────────────────────────────────────────────

    @task(2)
    def view_battles(self):
        params = {"filter": random.choice(["active", "ended", "mine"])}
        self.client.get("/battles", params=params, name="battles")

    @task(2)
    def view_battle_state(self):
        if not BATTLE_UIDS:
            return
        self.client.get(f"/battles/{random.choice(BATTLE_UIDS)}", name="battles/[uid]")

    @task(1)
    def view_battle_events(self):
        if not BATTLE_UIDS:
            return
        self.client.get(
            f"/battles/{random.choice(BATTLE_UIDS)}/events", name="battles/[uid]/events"
        )

    @task(1)
    def join_battle(self):
        if not BATTLE_UIDS:
            return
        self.client.post(
            f"/battles/{random.choice(BATTLE_UIDS)}/join",
            data={"faction": random.choice(["a", "b"])},
            name="battles/[uid]/join",
        )

    @task(1)
    def fight_battle(self):
        if not BATTLE_UIDS:
            return
        self.client.post(
            f"/battles/{random.choice(BATTLE_UIDS)}/fight", name="battles/[uid]/fight"
        )

    # ── developer tools landing pages ───────────────────────────

    @task(1)
    def view_tools_landing(self):
        self.client.get(
            random.choice(["/tools/deepsearch", "/tools/isslop", "/tools/isslop/list"]),
            name="tools/[page]",
        )

    @task(1)
    def call_xmlrpc(self):
        self.client.get("/xmlrpc", name="xmlrpc")
        body = xmlrpc.client.dumps((), methodname="system.listMethods").encode()
        with self.client.post(
            "/xmlrpc",
            data=body,
            headers={"Content-Type": "text/xml"},
            catch_response=True,
            name="xmlrpc/call",
        ) as resp:
            if resp.status_code == 200:
                resp.success()
            else:
                resp.failure(f"xmlrpc call: status={resp.status_code}")

    # ── access tokens, uploads, profile sub-settings, messages ──

    @task(1)
    def request_access_token(self):
        if not SEED_USERS:
            return
        seed = random.choice(SEED_USERS)
        self.client.post(
            "/auth/token",
            json={"email": seed["email"], "password": PASSWORD},
            name="auth/token",
        )

    @task(1)
    def list_uploads(self):
        self.client.get("/uploads", name="uploads")

    @task(1)
    def get_upload(self):
        if not self.own_media_uids:
            return
        self.client.get(
            f"/uploads/{random.choice(self.own_media_uids)}", name="uploads/[uid]"
        )

    @task(1)
    def rename_upload(self):
        if not self.own_media_uids:
            return
        uid = random.choice(self.own_media_uids)
        self.client.patch(
            f"/uploads/{uid}",
            json={"filename": f"renamed_{uuid.uuid4().hex[:6]}.txt"},
            name="uploads/[uid]/rename",
        )

    @task(1)
    def set_ai_correction(self):
        self.client.post(
            f"/profile/{self.username}/ai-correction",
            json={"enabled": random.choice([True, False]), "sync": False},
            name="profile/[username]/ai-correction",
        )

    @task(1)
    def set_ai_modifier(self):
        self.client.post(
            f"/profile/{self.username}/ai-modifier",
            json={"enabled": random.choice([True, False]), "sync": True},
            name="profile/[username]/ai-modifier",
        )

    @task(1)
    def set_interactions(self):
        self.client.post(
            f"/profile/{self.username}/interactions",
            json={"enabled": random.choice([True, False])},
            name="profile/[username]/interactions",
        )

    @task(1)
    def telegram_pairing(self):
        self.client.post(
            f"/profile/{self.username}/telegram",
            json={"action": random.choice(["request", "unpair"])},
            name="profile/[username]/telegram",
        )

    @task(1)
    def regenerate_avatar(self):
        self.client.post(
            f"/profile/{self.username}/regenerate-avatar",
            name="profile/[username]/regenerate-avatar",
        )

    @task(1)
    def view_conversations_json(self):
        self.client.get("/messages/conversations", name="messages/conversations")

    @task(1)
    def request_ws_ticket(self):
        self.client.post("/messages/ws-ticket", name="messages/ws-ticket")


class AdminUser(HttpUser):
    weight = 1
    wait_time = between(2, 6)

    def on_start(self):
        assign_client_ip(self.client)
        if ADMIN_USER:
            do_login(self.client, ADMIN_USER["email"])

    @task(2)
    def view_admin_index(self):
        self.client.get("/admin", name="admin")

    @task(3)
    def view_admin_users(self):
        self.client.get("/admin/users", name="admin/users")

    @task(2)
    def view_admin_settings(self):
        self.client.get("/admin/settings", name="admin/settings")

    @task(2)
    def view_admin_news(self):
        self.client.get("/admin/news", name="admin/news")

    @task(2)
    def view_admin_services(self):
        self.client.get("/admin/services", name="admin/services")

    @task(2)
    def view_admin_services_data(self):
        self.client.get("/admin/services/data", name="admin/services/data")

    @task(1)
    def view_admin_service_detail(self):
        self.client.get("/admin/services/audit", name="admin/services/[name]")
        self.client.get(
            "/admin/services/audit/data", name="admin/services/[name]/data"
        )

    @task(2)
    def view_admin_ai_usage(self):
        self.client.get("/admin/ai-usage", name="admin/ai-usage")
        self.client.get("/admin/ai-usage/data", name="admin/ai-usage/data")

    @task(1)
    def view_admin_analytics(self):
        self.client.get("/admin/analytics", name="admin/analytics")

    @task(2)
    def view_admin_audit_log(self):
        resp = self.client.get("/admin/audit-log", name="admin/audit-log")
        m = re.search(rf"/admin/audit-log/({UUID_RE})", resp.text)
        if m:
            self.client.get(
                f"/admin/audit-log/{m.group(1)}", name="admin/audit-log/[uid]"
            )

    @task(3)
    def view_moderation_queue(self):
        resp = self.client.get(
            "/admin/moderation",
            params={"status": random.choice(REPORT_STATUSES)},
            name="admin/moderation",
        )
        m = re.search(rf"/admin/moderation/({UUID_RE})", resp.text)
        uid = m.group(1) if m else ADMIN_TARGETS.get("report_uid")
        if not uid:
            return
        with self.client.get(
            f"/admin/moderation/{uid}",
            catch_response=True,
            name="admin/moderation/[uid]",
        ) as report:
            if report.status_code == 200:
                report.success()
            else:
                report.failure(f"report detail: status={report.status_code}")

    @task(1)
    def view_admin_docs(self):
        self.client.get(
            f"/docs/{random.choice(ADMIN_DOCS_SLUGS)}.html", name="docs/[slug]"
        )

    @task(1)
    def view_admin_media(self):
        self.client.get("/admin/media", name="admin/media")

    @task(1)
    def view_admin_notifications(self):
        self.client.get("/admin/notifications", name="admin/notifications")

    @task(1)
    def save_admin_notification_default(self):
        self.client.post(
            "/admin/notifications",
            data={
                "notification_type": random.choice(NOTIFICATION_TYPES),
                "channel": random.choice(["in_app", "push"]),
                "value": random.choice(["true", "false"]),
            },
            name="admin/notifications/save",
        )

    @task(1)
    def view_admin_user_ai_usage(self):
        uid = ADMIN_TARGETS.get("disposable_uid")
        if not uid:
            return
        self.client.get(
            f"/admin/users/{uid}/ai-usage", name="admin/users/[uid]/ai-usage"
        )

    @task(1)
    def reset_user_ai_quota(self):
        uid = ADMIN_TARGETS.get("disposable_uid")
        if not uid:
            return
        self.client.post(
            f"/admin/users/{uid}/reset-ai-quota", name="admin/users/reset-ai-quota"
        )

    @task(1)
    def trash_view_and_restore(self):
        resp = self.client.get("/admin/trash", name="admin/trash")
        m = re.search(
            rf"/admin/trash/([a-z_]+)/({UUID_RE})/restore", resp.text
        )
        if m:
            self.client.post(
                f"/admin/trash/{m.group(1)}/{m.group(2)}/restore",
                name="admin/trash/restore",
            )

    @task(1)
    def save_settings(self):
        self.client.post(
            "/admin/settings",
            data={
                "max_upload_size_mb": "10",
            },
            name="admin/settings/save",
        )

    @task(1)
    def news_toggle(self):
        uid = ADMIN_TARGETS.get("news_uid")
        if not uid:
            return
        self.client.post(f"/admin/news/{uid}/toggle", name="admin/news/toggle")

    @task(1)
    def news_publish(self):
        uid = ADMIN_TARGETS.get("news_uid")
        if not uid:
            return
        self.client.post(f"/admin/news/{uid}/publish", name="admin/news/publish")

    @task(1)
    def news_landing(self):
        uid = ADMIN_TARGETS.get("news_uid")
        if not uid:
            return
        self.client.post(f"/admin/news/{uid}/landing", name="admin/news/landing")

    @task(1)
    def news_delete(self):
        uid = ADMIN_TARGETS.get("news_uid")
        if not uid:
            return
        self.client.post(f"/admin/news/{uid}/delete", name="admin/news/delete")

    @task(1)
    def user_role(self):
        uid = ADMIN_TARGETS.get("disposable_uid")
        if not uid:
            return
        self.client.post(
            f"/admin/users/{uid}/role",
            data={"role": "Member"},
            name="admin/users/role",
        )

    @task(1)
    def user_password(self):
        uid = ADMIN_TARGETS.get("disposable_uid")
        if not uid:
            return
        self.client.post(
            f"/admin/users/{uid}/password",
            data={"password": PASSWORD},
            name="admin/users/password",
        )

    @task(1)
    def user_toggle(self):
        uid = ADMIN_TARGETS.get("disposable_uid")
        if not uid:
            return
        self.client.post(f"/admin/users/{uid}/toggle", name="admin/users/toggle")

    @task(1)
    def view_pubsub_topics(self):
        self.client.get("/pubsub/topics", name="pubsub/topics")

    @task(1)
    def publish_pubsub(self):
        with self.client.post(
            "/pubsub/publish",
            json={"topic": "public.locust.loadtest", "data": {"ping": True}},
            catch_response=True,
            name="pubsub/publish",
        ) as resp:
            if resp.status_code in (200, 409):
                resp.success()
            else:
                resp.failure(f"pubsub publish: status={resp.status_code}")


class AnonymousUser(HttpUser):
    weight = 3
    wait_time = between(1, 5)

    def on_start(self):
        assign_client_ip(self.client)

    @task(5)
    def browse_feed(self):
        self.client.get("/feed", name="feed")

    @task(3)
    def browse_post(self):
        if POST_SLUGS:
            self.client.get(f"/posts/{random.choice(POST_SLUGS)}", name="posts/[uid]")

    @task(2)
    def browse_public(self):
        path = random.choice(
            [
                "/",
                "/news",
                "/gists",
                "/projects",
                "/leaderboard",
                "/reports/reasons",
                "/workspaces/index",
                "/topics",
            ]
        )
        self.client.get(path, name="public/[page]")

    @task(2)
    def browse_topic(self):
        self.client.get(f"/topics/{random.choice(TOPICS)}", name="topics/[topic]")

    @task(1)
    def browse_docs(self):
        self.client.get(
            f"/docs/{random.choice(DOCS_SLUGS)}.html", name="docs/[slug]"
        )

    @task(2)
    def browse_detail(self):
        if NEWS_SLUGS:
            self.client.get(f"/news/{random.choice(NEWS_SLUGS)}", name="news/[slug]")
        if GIST_SLUGS:
            self.client.get(f"/gists/{random.choice(GIST_SLUGS)}", name="gists/[slug]")
        if PROJECT_SLUGS:
            self.client.get(
                f"/projects/{random.choice(PROJECT_SLUGS)}", name="projects/[slug]"
            )

    @task(2)
    def guest_vote_redirects_to_login(self):
        if not POST_UIDS:
            return
        uid = random.choice(POST_UIDS)
        with self.client.post(
            f"/votes/post/{uid}",
            data={"value": 1},
            headers={"x-requested-with": "fetch"},
            catch_response=True,
            name="votes/post/guest-redirect",
        ) as resp:
            if "/auth/login" in (resp.url or ""):
                resp.success()
            else:
                resp.failure(f"guest vote not redirected to login: {resp.url}")

    @task(1)
    def guest_guarded_get_redirects_to_login(self):
        with self.client.get(
            "/messages",
            catch_response=True,
            name="guarded/guest-redirect",
        ) as resp:
            if "/auth/login" in (resp.url or ""):
                resp.success()
            else:
                resp.failure(f"guarded GET not redirected to login: {resp.url}")

    @task(1)
    def login_with_next(self):
        self.client.get("/auth/login", params={"next": "/feed"}, name="auth/login?next")


class DevRantUser(HttpUser):
    weight = 4
    wait_time = between(1, 5)

    def on_start(self):
        assign_client_ip(self.client)
        self.token_id = None
        self.token_key = None
        self.own_rant_ids = []
        if random.random() < 0.3 and SEED_USERS:
            username = random.choice(SEED_USERS)["username"]
        else:
            username = random_username()
            email = f"{username}@locust.devplace"
            if not do_signup(self.client, username, email):
                return
            ALL_USERNAMES.append(username)
        self.username = username
        with self.client.post(
            "/api/users/auth-token",
            data={"username": username, "password": PASSWORD},
            catch_response=True,
            name="api/users/auth-token",
        ) as resp:
            if resp.status_code != 200:
                resp.failure(f"devrant auth-token: status={resp.status_code}")
                return
            try:
                payload = resp.json()
            except (ValueError, RequestException):
                payload = {}
            token = payload.get("auth_token") or {}
            if payload.get("success") and token.get("id") and token.get("key"):
                self.token_id = token["id"]
                self.token_key = token["key"]
                resp.success()
            else:
                resp.failure(f"devrant auth-token: unexpected payload {payload}")

    def _auth(self):
        return {"token_id": self.token_id, "token_key": self.token_key}

    @task(1)
    def dr_register(self):
        username = random_username()
        email = f"{username}@locust.devplace"
        with self.client.post(
            "/api/users",
            data={"username": username, "email": email, "password": PASSWORD},
            catch_response=True,
            name="api/users/register",
        ) as resp:
            if resp.status_code != 200:
                resp.failure(f"devrant register: status={resp.status_code}")
                return
            try:
                payload = resp.json()
            except (ValueError, RequestException):
                payload = {}
            if payload.get("success"):
                ALL_USERNAMES.append(username)
                resp.success()
            else:
                resp.failure(f"devrant register: {payload}")

    @task(5)
    def dr_feed(self):
        if not self.token_id:
            return
        params = {
            **self._auth(),
            "sort": random.choice(["algo", "recent", "top"]),
            "limit": 20,
        }
        self.client.get("/api/devrant/rants", params=params, name="api/devrant/rants")

    @task(1)
    def dr_search(self):
        if not self.token_id:
            return
        term = random.choice(ALL_USERNAMES)[:4] if ALL_USERNAMES else "lu"
        self.client.get(
            "/api/devrant/search",
            params={**self._auth(), "term": term},
            name="api/devrant/search",
        )

    @task(2)
    def dr_get_user_id(self):
        if not ALL_USERNAMES:
            return
        self.client.get(
            "/api/get-user-id",
            params={"username": random.choice(ALL_USERNAMES)},
            name="api/get-user-id",
        )

    @task(1)
    def dr_profile(self):
        if not self.token_id:
            return
        user_id = None
        with self.client.get(
            "/api/get-user-id",
            params={"username": self.username},
            catch_response=True,
            name="api/get-user-id",
        ) as resp:
            if resp.status_code == 200:
                try:
                    user_id = resp.json().get("user_id")
                except (ValueError, RequestException, AttributeError):
                    user_id = None
                resp.success()
            else:
                resp.failure(f"get-user-id: status={resp.status_code}")
        if not user_id:
            return
        self.client.get(
            f"/api/users/{user_id}", params=self._auth(), name="api/users/[id]"
        )

    @task(2)
    def dr_create_rant(self):
        if not self.token_id:
            return
        with self.client.post(
            "/api/devrant/rants",
            params=self._auth(),
            data={"rant": f"Load test rant {uuid.uuid4().hex[:6]}", "tags": "locust"},
            catch_response=True,
            name="api/devrant/rants/create",
        ) as resp:
            if resp.status_code != 200:
                resp.failure(f"devrant rant create: status={resp.status_code}")
                return
            try:
                payload = resp.json()
            except (ValueError, RequestException):
                payload = {}
            if not payload.get("success"):
                resp.failure(f"devrant rant create: {payload}")
                return
            rant_id = payload.get("rant_id")
            if rant_id:
                DR_RANT_IDS.append(rant_id)
                self.own_rant_ids.append(rant_id)
            resp.success()

    @task(3)
    def dr_get_rant(self):
        if not DR_RANT_IDS:
            return
        rant_id = random.choice(DR_RANT_IDS)
        with self.client.get(
            f"/api/devrant/rants/{rant_id}",
            params=self._auth() if self.token_id else {},
            catch_response=True,
            name="api/devrant/rants/[id]",
        ) as resp:
            if resp.status_code != 200:
                resp.failure(f"get rant: status={resp.status_code}")
                return
            try:
                payload = resp.json()
            except (ValueError, RequestException):
                payload = {}
            for comment in payload.get("comments") or []:
                cid = comment.get("id")
                if cid and cid not in DR_COMMENT_IDS:
                    DR_COMMENT_IDS.append(cid)
            resp.success()

    @task(1)
    def dr_edit_own_rant(self):
        if not self.token_id or not self.own_rant_ids:
            return
        rant_id = random.choice(self.own_rant_ids)
        self.client.post(
            f"/api/devrant/rants/{rant_id}",
            params=self._auth(),
            data={"rant": f"Edited {uuid.uuid4().hex[:6]}"},
            name="api/devrant/rants/[id]/edit",
        )

    @task(1)
    def dr_delete_own_rant(self):
        if not self.token_id or not self.own_rant_ids:
            return
        rant_id = self.own_rant_ids.pop()
        if rant_id in DR_RANT_IDS:
            DR_RANT_IDS.remove(rant_id)
        self.client.delete(
            f"/api/devrant/rants/{rant_id}",
            params=self._auth(),
            name="api/devrant/rants/[id]/delete",
        )

    @task(2)
    def dr_vote_rant(self):
        if not self.token_id or not DR_RANT_IDS:
            return
        self.client.post(
            f"/api/devrant/rants/{random.choice(DR_RANT_IDS)}/vote",
            params=self._auth(),
            data={"vote": random.choice([1, -1])},
            name="api/devrant/rants/[id]/vote",
        )

    @task(1)
    def dr_favorite_rant(self):
        if not self.token_id or not DR_RANT_IDS:
            return
        rant_id = random.choice(DR_RANT_IDS)
        endpoint = random.choice(["favorite", "unfavorite"])
        self.client.post(
            f"/api/devrant/rants/{rant_id}/{endpoint}",
            params=self._auth(),
            name=f"api/devrant/rants/[id]/{endpoint}",
        )

    @task(2)
    def dr_comment_rant(self):
        if not self.token_id or not DR_RANT_IDS:
            return
        self.client.post(
            f"/api/devrant/rants/{random.choice(DR_RANT_IDS)}/comments",
            params=self._auth(),
            data={"comment": f"Devrant comment {uuid.uuid4().hex[:6]}"},
            name="api/devrant/rants/[id]/comments",
        )

    @task(1)
    def dr_get_comment(self):
        if not DR_COMMENT_IDS:
            return
        self.client.get(
            f"/api/comments/{random.choice(DR_COMMENT_IDS)}",
            params=self._auth() if self.token_id else {},
            name="api/comments/[id]",
        )

    @task(1)
    def dr_vote_comment(self):
        if not self.token_id or not DR_COMMENT_IDS:
            return
        self.client.post(
            f"/api/comments/{random.choice(DR_COMMENT_IDS)}/vote",
            params=self._auth(),
            data={"vote": random.choice([1, -1])},
            name="api/comments/[id]/vote",
        )

    @task(1)
    def dr_notif_feed(self):
        if not self.token_id:
            return
        self.client.get(
            "/api/users/me/notif-feed",
            params=self._auth(),
            name="api/users/me/notif-feed",
        )

    @task(1)
    def dr_clear_notif_feed(self):
        if not self.token_id:
            return
        self.client.delete(
            "/api/users/me/notif-feed",
            params=self._auth(),
            name="api/users/me/notif-feed/clear",
        )

    @task(1)
    def dr_avatar(self):
        seed = random.choice(ALL_USERNAMES) if ALL_USERNAMES else "anon"
        self.client.get(f"/api/avatars/u/{seed}.png", name="api/avatars/u/[seed]")

    @task(1)
    def dr_misc_stubs(self):
        if not self.token_id:
            return
        endpoint = random.choice(
            [
                "/api/users/forgot-password",
                "/api/users/me/mark-news-read",
                "/api/users/me/resend-confirm",
            ]
        )
        self.client.post(endpoint, params=self._auth(), name="api/users/me/stub")
