# retoor <retoor@molodetz.nl>

import asyncio
import hashlib
import logging
import random
import re
import time
from datetime import datetime
from typing import Any, Optional

from faker import Faker

from devplacepy.services.bot.browser import BotBrowser
from devplacepy.services.bot.config import (
    COMMENT_COOLDOWN_MAX_SECONDS,
    COMMENT_COOLDOWN_MIN_SECONDS,
    FEED_TOPICS,
    GIST_LANGUAGES,
    MAX_SIBLING_COMMENTS,
    MAX_THREAD_REPLIES,
    MENTION_REPLIES_PER_SESSION,
    NOTIFICATION_CHECK_PROB,
    PERSONAS,
    PROJECT_STATUSES,
    PROJECT_TYPES,
    REACT_RATE_DEFAULT,
    REACT_RATES,
    SEARCH_TERMS,
    SESSION_COMMENT_CAP_MAX,
    SESSION_COMMENT_CAP_MIN,
    SIBLING_SNIPPET_LEN,
    USER_AGENTS,
    VIEWPORTS,
    persona_article_score,
    pick_category,
)
from devplacepy.services.bot.handles import make_handle
from devplacepy.services.bot.llm import LLMClient
from devplacepy.services.bot.news_fetcher import NewsFetcher
from devplacepy.services.bot.registry import ArticleRegistry
from devplacepy.services.bot.runtime import BotRuntimeConfig
from devplacepy.services.bot.state import BotState

logger = logging.getLogger(__name__)


class DevPlaceBot:
    def __init__(self, cfg: BotRuntimeConfig, on_event=None, slot: int = 0):
        self.cfg = cfg
        self.base_url = cfg.base_url
        self.state_path = cfg.state_path
        self.on_event = on_event
        self._slot = slot
        self.state = BotState.load(cfg.state_path)
        if not self.state.persona:
            self.state.persona = random.choice(PERSONAS)
        self._gateway_fallback_key = cfg.api_key
        self.llm = LLMClient(
            self.state.account_api_key or cfg.api_key,
            cfg.api_url,
            cfg.model,
            cfg.input_cost_per_1m,
            cfg.output_cost_per_1m,
            gist_min_lines=cfg.gist_min_lines,
        )
        self.llm.total_cost = self.state.total_cost
        self.llm.total_calls = self.state.total_calls
        self.llm.total_in_tokens = self.state.total_in_tokens
        self.llm.total_out_tokens = self.state.total_out_tokens
        self.news = NewsFetcher(cfg.news_api)
        self.registry = ArticleRegistry(
            cfg.registry_path, cfg.max_per_article, cfg.article_ttl_days
        )
        self._pause_min = min(cfg.action_pause_min, cfg.action_pause_max)
        self._pause_max = max(cfg.action_pause_min, cfg.action_pause_max)
        self._break_scale = max(0.1, cfg.break_scale)
        self._startup_jitter = max(0, cfg.startup_jitter_seconds)
        self._ai_decisions = cfg.ai_decisions
        self._decision_temperature = cfg.decision_temperature
        self.faker = Faker()
        if not self.state.user_agent:
            self.state.user_agent = random.choice(USER_AGENTS)
        if not self.state.viewport:
            self.state.viewport = random.choice(VIEWPORTS)
        self.b = BotBrowser(
            headless=cfg.headless,
            user_agent=self.state.user_agent,
            viewport=self.state.viewport,
        )
        self._session_comments = 0
        self._session_comment_cap = 0
        self._session_mention_replies = 0
        self._post_cache: list[tuple[str, str, str, str, str]] = []
        self._project_cache: list[tuple[str, str]] = []
        self._issue_cache: list[tuple[str, str]] = []
        self._session_start_cost: float = 0.0
        self._session_start_calls: int = 0
        self._session_history: list[str] = []
        self._session_energy: float = 0.5
        self._session_stop_after: int = 0

    def _identity(self) -> str:
        return f"{self.state.username or '?'}/{self.state.persona}"

    def _cost_tag(self) -> str:
        return f"${self.llm.total_cost:.4f} / {self.llm.total_calls} calls"

    def _notify(self, line: str) -> None:
        if self.on_event:
            try:
                self.on_event(line)
            except Exception as e:
                logger.debug("on_event failed: %s", e)

    @staticmethod
    def _thread_id(url: str) -> str:
        m = re.search(r"/(?:posts|gists|news|projects)/([^/?#]+)", url)
        return m.group(1) if m else ""

    def _sync_cost(self) -> None:
        self.state.total_cost = self.llm.total_cost
        self.state.total_calls = self.llm.total_calls
        self.state.total_in_tokens = self.llm.total_in_tokens
        self.state.total_out_tokens = self.llm.total_out_tokens

    def _save(self) -> None:
        self._sync_cost()
        self.state.save(self.state_path)

    def _log(self, msg: str) -> None:
        self.state.log.append(f"[{time.strftime('%H:%M:%S')}] {msg}")
        if len(self.state.log) > 500:
            self.state.log = self.state.log[-250:]
        logger.info("[%s] %s", self._identity(), msg)

    def _action(self, tag: str, detail: str) -> None:
        line = f"{tag}: {detail}"
        self.state.log.append(f"[{time.strftime('%H:%M:%S')}] {line}")
        if len(self.state.log) > 500:
            self.state.log = self.state.log[-250:]
        logger.info("[%s] %s (%s)", self._identity(), line, self._cost_tag())
        self._notify(f"[{self._identity()}] {line}")
        self.b.note(action=line)
        asyncio.create_task(self.b.capture(tag.lower(), force=True))

    def _bind_monitor(self) -> None:
        self.b.bind_monitor(
            self._slot,
            username=self.state.username,
            persona=self.state.persona,
        )

    async def _generate(self, fn: Any, *args: Any) -> Any:
        try:
            return await asyncio.to_thread(fn, *args)
        except Exception as e:
            logger.warning(
                "LLM generation failed (%s): %s", getattr(fn, "__name__", fn), e
            )
            return None

    def _cost_summary(self, label: str) -> None:
        self._notify(
            f"[{self._identity()}] cost ({label}): ${self.llm.total_cost:.6f} "
            f"over {self.llm.total_calls} calls (in={self.llm.total_in_tokens} out={self.llm.total_out_tokens})"
        )

    def _session_summary(self, mood: str, actions: int) -> None:
        s = self.state
        sess_cost = self.llm.total_cost - self._session_start_cost
        sess_calls = self.llm.total_calls - self._session_start_calls
        self._notify(
            f"[{self._identity()}] session done mood={mood} actions={actions} "
            f"posts={s.created_posts} comments={s.comments_posted} votes={s.votes_cast} "
            f"session=${sess_cost:.4f}/{sess_calls} lifetime=${self.llm.total_cost:.4f}"
        )

    def _session_banner(self, mood: str, session_len: int) -> None:
        self._notify(
            f"[{self._identity()}] session start mood={mood} target~{session_len} actions"
        )

    def _pick_article(self, articles: list[dict]) -> Optional[dict]:
        if not articles:
            return None
        ranked = sorted(
            articles,
            key=lambda a: persona_article_score(a, self.state.persona)
            + random.random(),
            reverse=True,
        )
        top = ranked[: max(3, len(ranked) // 3)]
        return random.choice(top)

    async def _refill_cache_async(self, kind: str) -> None:
        try:
            if kind == "post":
                articles = self.news.fetch()
                a = self._pick_article(articles)
                if a:
                    desc = a.get("description", "")[:500]
                    cat = pick_category(self.state.persona)
                    post_title = await asyncio.to_thread(
                        self.llm.generate_post_title,
                        a["title"],
                        self.state.persona,
                        cat,
                    )
                    content = await asyncio.to_thread(
                        self.llm.generate_post,
                        a["title"],
                        desc,
                        self.state.persona,
                        cat,
                        self.state.recent_post_titles,
                    )
                    self._post_cache.append(
                        (a["title"], post_title or "", desc, content, cat)
                    )
            elif kind == "project":
                t = await asyncio.to_thread(
                    self.llm.generate_project_title, self.state.persona
                )
                desc = await asyncio.to_thread(
                    self.llm.generate_project_desc, t, self.state.persona
                )
                self._project_cache.append((t, desc))
            elif kind == "issue":
                topic = random.choice(["UI glitch", "Broken link", "Performance issue"])
                issue = await asyncio.to_thread(self.llm.generate_issue, topic)
                self._issue_cache.append(issue)
        except Exception as e:
            logger.warning("Cache refill (%s) failed: %s", kind, e)

    async def _warm_cache(self) -> None:
        self._log("Warming content cache (background)...")
        self._post_cache = []
        self._project_cache = []
        self._issue_cache = []
        self.news.fetch()
        asyncio.create_task(self._refill_cache_async("post"))
        asyncio.create_task(self._refill_cache_async("project"))
        asyncio.create_task(self._refill_cache_async("issue"))

    def _generate_handle(self) -> str:
        return make_handle(SEARCH_TERMS.get(self.state.persona, []))

    def _next_handle(self) -> str:
        while self.state.handle_candidates:
            candidate = self.state.handle_candidates.pop(0)
            if candidate:
                return candidate
        return self._generate_handle()

    async def _ensure_auth(self) -> bool:
        b = self.b
        if not self.state.username:
            if not self.state.handle_candidates:
                self.state.handle_candidates = (
                    await self._generate(
                        self.llm.generate_handle_candidates, self.state.persona
                    )
                    or []
                )
            self.state.username = self._next_handle()
            self.state.email = self.faker.email()
            self.state.password = self.faker.password(length=random.randint(12, 18))
            self.state.started_at = datetime.now().isoformat()
            self._log(f"Identity: {self.state.username} / {self.state.email}")

        curl = await b.url()
        if "/feed" in curl:
            return True

        await b.goto(f"{self.base_url}/auth/login")
        curl = await b.url()
        if "/feed" in curl:
            return True

        if await b.fill("#email", self.state.email):
            await b._idle(0.2, 0.5)
            await b.fill("#password", self.state.password)
            await b._idle(0.3, 0.8)
            await b.click(".auth-submit")
            await asyncio.sleep(2)
            curl = await b.url()
            if "/feed" in curl:
                self._log("Logged in")
                return True

        self._log("Login failed, registering")
        for attempt in range(4):
            await b.goto(f"{self.base_url}/auth/signup")
            if not await b.fill("#username", self.state.username):
                return False
            await b._idle(0.2, 0.5)
            await b.fill("#email", self.state.email)
            await b._idle(0.2, 0.5)
            await b.fill("#password", self.state.password)
            await b._idle(0.2, 0.5)
            await b.fill("#confirm_password", self.state.password)
            await b._idle(0.5, 1.0)
            await b.click(".auth-submit")
            await asyncio.sleep(2)
            curl = await b.url()
            if "/feed" in curl:
                self._log(f"Registered as {self.state.username}")
                return True
            if attempt >= 2:
                base = self._next_handle()
                self.state.username = f"{base}{random.randint(10, 9999)}"[:20]
            else:
                self.state.username = self._next_handle()
            self.state.email = self.faker.email()
            self._log(f"Signup failed, retrying as {self.state.username}")
        return False

    async def _fetch_account_api_key(self) -> str:
        if not self.state.username:
            return ""
        try:
            key = await self.b.page.evaluate(
                """async (username) => {
                    const resp = await fetch(`/profile/${username}`, {
                        headers: {Accept: 'application/json'},
                    });
                    if (!resp.ok) return '';
                    const data = await resp.json();
                    return data.api_key || '';
                }""",
                self.state.username,
            )
        except Exception as e:
            logger.debug("fetch account api key failed: %s", e)
            return ""
        return (key or "").strip()

    async def _adopt_account_api_key(self) -> None:
        if self.state.account_api_key:
            return
        key = await self._fetch_account_api_key()
        if not key:
            return
        self.state.account_api_key = key
        self.llm.api_key = key
        self._save()
        self._log("Adopted account API key for gateway calls")

    async def _read_like_human(self, min_s: float = 3.0, max_s: float = 90.0) -> None:
        if not self.state.reading_speed_wpm:
            self.state.reading_speed_wpm = random.randint(180, 320)
        wpm = self.state.reading_speed_wpm
        sec_per_word = 60.0 / wpm
        curl = await self.b.url()
        if "/posts/" in curl:
            body = (
                await self.b.text("article") or await self.b.text(".post-content") or ""
            )
            wc = len(body.split())
            jitter = random.uniform(0.7, 1.25)
            pause = min(max(wc * sec_per_word * jitter, min_s), max_s)
            self._log(f"Reading {wc} words ({pause:.0f}s @ {wpm}wpm)")
            scrolls = max(1, int(pause / 12))
            chunk = pause / scrolls
            for _ in range(scrolls):
                await asyncio.sleep(chunk)
                if random.random() < 0.55:
                    await self.b.scroll(random.randint(150, 450))
        else:
            await asyncio.sleep(random.uniform(1.0, 4.0))

    async def _type_like_human(self, sel: str, val: str) -> bool:
        el = self.b.page.locator(sel).first
        if await el.count() == 0:
            return False
        try:
            await el.click(timeout=3000)
            await self.b._idle(0.1, 0.3)
            await el.fill("")
            words = val.split(" ")
            for i, word in enumerate(words):
                for ch in word:
                    delay = random.randint(15, 80)
                    if ch in ".!?":
                        delay = random.randint(200, 500)
                    elif ch == ",":
                        delay = random.randint(100, 250)
                    await self.b.page.keyboard.type(ch, delay=delay)
                if i < len(words) - 1:
                    space_delay = random.randint(30, 120)
                    await self.b.page.keyboard.type(" ", delay=space_delay)
                if random.random() < 0.08:
                    await self.b._idle(0.3, 1.0)
                if (i + 1) % 5 == 0 and random.random() < 0.3:
                    await self.b._idle(0.2, 0.8)
            return True
        except Exception as e:
            logger.debug("_type_like_human failed for '%s': %s", sel, e)
            return False

    async def _fatigue_check(self, session_actions: int) -> Optional[str]:
        if session_actions < 5:
            return None
        chance = session_actions / 100.0
        if random.random() < chance:
            if session_actions < 10:
                return "casual"
            elif session_actions < 20:
                return "normal"
            else:
                return "deep"
        return None

    async def _session_type(self) -> tuple[str, int, tuple]:
        roll = random.random()
        if roll < 0.25:
            return ("lurking", random.randint(2, 5), (3600, 21600))
        elif roll < 0.60:
            return ("casual", random.randint(4, 10), (1800, 7200))
        elif roll < 0.85:
            return ("normal", random.randint(8, 18), (3600, 14400))
        else:
            return ("deep", random.randint(15, 30), (14400, 43200))

    async def _ensure_identity(self) -> None:
        if self.state.identity:
            return
        identity = await self._generate(
            self.llm.generate_identity, self.state.persona
        )
        if identity:
            self.state.identity = identity
            self._log(
                f"Identity card: {identity.get('name', '?')} ({self.state.persona})"
            )
            self._save()

    async def _page_summary(self, page: str) -> str:
        b = self.b
        title = ""
        for sel in ("h1", ".post-title", ".content-title", "title"):
            title = (await b.text(sel) or "").strip()
            if title:
                break
        summary = f'title="{title[:100]}"' if title else "no title"
        try:
            comments = await b.page.locator(".comment, .comment-item").count()
            if comments:
                summary += f", {comments} comments"
        except Exception as e:
            logger.debug("page summary count failed: %s", e)
        return summary

    async def _unread_notification_count(self) -> int:
        try:
            badge = self.b.page.locator(
                "a[data-counter='notifications'] .nav-badge[data-counter-badge]"
            ).first
            if await badge.count() == 0:
                return 0
            if await badge.get_attribute("hidden") is not None:
                return 0
            text = (await badge.inner_text() or "").strip()
            digits = re.sub(r"[^0-9]", "", text)
            return int(digits) if digits else 0
        except Exception as e:
            logger.debug("_unread_notification_count failed: %s", e)
            return 0

    async def _page_state(
        self,
        curl: str,
        page_text: str,
        *,
        did_post: bool,
        follows: int,
        gists: int,
        can_project: bool,
        can_post: bool,
        can_message: bool,
    ) -> dict:
        pages = [
            ("post", "/posts/" in curl),
            ("profile", "/profile/" in curl),
            ("news_detail", "/news/" in curl),
            ("news_list", bool(re.search(r"/news(\?|$)", curl))),
            ("project_detail", "/projects/" in curl),
            ("projects_list", bool(re.search(r"/projects(\?|$)", curl))),
            ("gist_detail", "/gists/" in curl),
            ("gists_list", bool(re.search(r"/gists(\?|$)", curl))),
            ("notifications", "/notifications" in curl),
        ]
        page = next((name for name, hit in pages if hit), "feed")
        own_post = page == "post" and await self._is_own_post()
        mentioned = own_post and f"@{self.state.username.lower()}" in page_text
        own_profile = (
            page == "profile"
            and f"/profile/{self.state.username.lower()}" in curl.lower()
        )
        return {
            "page": page,
            "url": curl,
            "visible": await self._page_summary(page),
            "own_post": own_post,
            "own_profile": own_profile,
            "mentioned": mentioned,
            "did_post": did_post,
            "follows": follows,
            "gists": gists,
            "can_project": can_project,
            "can_post": can_post,
            "can_message": can_message,
            "unread_notifications": await self._unread_notification_count(),
        }

    def _build_menu(self, page_state: dict) -> list[dict]:
        page = page_state["page"]
        menu: list[dict] = []

        def add(action: str, desc: str) -> None:
            menu.append({"action": action, "desc": desc})

        if page == "post":
            if not page_state["own_post"] or page_state["mentioned"]:
                add("comment", "write a comment on this post")
                add("reply", "reply to an existing comment in the thread")
                add("vote", "upvote this post")
                add("vote_comment", "upvote a good comment in the thread")
                add("vote_poll", "answer the attached poll if there is one")
                add("react", "add an emoji reaction to this post")
        elif page == "news_detail":
            add("comment", "write a comment reacting to this news article")
        elif page == "project_detail":
            add("vote", "star this project")
            add("react", "add an emoji reaction")
            add("comment", "write a comment on this project")
        elif page == "gist_detail":
            add("comment", "write a comment on this code snippet")
            add("vote", "star this gist")
            add("react", "add an emoji reaction")
        elif page == "profile":
            if page_state.get("own_profile"):
                if not self.state.profile_filled:
                    add("update_profile", "fill in your own profile bio and links")
                add("open_post", "open one of your posts to read it")
            else:
                if not page_state["follows"]:
                    add("follow", "follow this developer")
                if page_state["can_message"]:
                    add("message", "send this developer a direct message")
                add("open_post", "open one of their posts to read it")
        elif page == "projects_list":
            add("vote", "star a project in the list")
            if page_state["can_project"]:
                add("create_project", "create a new project of your own")
            add("open_project", "open a project to read it")
        elif page == "gists_list":
            add("vote", "star a gist in the list")
            if not page_state["gists"]:
                add("create_gist", "publish a new code snippet of your own")
            add("open_gist", "open a gist to read it")
        elif page == "news_list":
            add("open_news", "open a news article to read it")
        elif page == "notifications":
            add("check_notifications", "read and act on your notifications")
        else:
            add("open_post", "open a post from the feed to read it")
            add("vote", "upvote a post in the feed")
            add("react", "react to a post in the feed")
            add("vote_poll", "answer a poll in the feed")
            if page_state["can_post"] and not page_state["did_post"]:
                add("create_post", "write a new post reacting to a tech news article")
            if not page_state["gists"]:
                add("create_gist", "publish a new code snippet of your own")
            add("open_profile", "open another developer's profile")
            add("search", "search the platform for a topic you care about")
            add("browse", "browse a category or section")
        if page != "notifications" and page_state.get("unread_notifications"):
            add(
                "check_notifications",
                "open your notifications and reply to anyone who mentioned or replied to you",
            )
        add("navigate", "move to another section: feed, projects, gists, or news")
        return menu

    def _record_history(self, action: str, rationale: str) -> None:
        note = action.replace("_", " ")
        if rationale:
            note = f"{note} ({rationale[:60]})"
        self._session_history.append(note)
        if len(self._session_history) > 20:
            self._session_history = self._session_history[-20:]

    async def _vote_for_page(self, page: str) -> bool:
        if page in ("project_detail", "projects_list"):
            return await self._vote_on_project()
        if page in ("gist_detail", "gists_list"):
            return await self._vote_on_gist()
        return await self._vote_on_feed()

    async def _navigate_to(self, target: str) -> bool:
        routes = {
            "feed": "/feed",
            "projects": "/projects",
            "gists": "/gists",
            "news": "/news",
            "notifications": "/notifications",
        }
        path = routes.get((target or "").lower())
        if path:
            await self.b.goto(f"{self.base_url}{path}")
            return True
        return await self._random_nav_click()

    async def _dispatch_action(self, action: str, target: str, page_state: dict) -> bool:
        if action == "navigate":
            return await self._navigate_to(target)
        if action == "vote":
            return await self._vote_for_page(page_state["page"])
        handlers = {
            "comment": self._comment_on_post,
            "reply": self._reply_to_random_comment,
            "vote_comment": self._vote_on_comments,
            "vote_poll": self._vote_on_poll,
            "react": self._react_to_object,
            "create_post": self._create_post,
            "create_gist": self._create_gist,
            "create_project": self._click_create_project,
            "update_profile": self._update_profile,
            "follow": self._follow_user,
            "message": self._send_message,
            "open_post": self._click_post_link,
            "open_gist": self._click_gist_link,
            "open_profile": self._click_profile_link,
            "open_project": lambda: self._open_content("project"),
            "open_news": lambda: self._open_content("news"),
            "check_notifications": self._check_notifications,
            "search": lambda: self._search("feed"),
            "browse": self._browse_section,
        }
        handler = handlers.get(action)
        if handler is None:
            return False
        try:
            result = await handler()
        except Exception as e:
            logger.debug("dispatch %s failed: %s", action, e)
            return False
        return True if result is None else bool(result)

    async def _run_plan(
        self, plan: list[dict], page_state: dict
    ) -> tuple[int, bool, int, int, int]:
        b = self.b
        actions = 0
        did_post = False
        projs = 0
        follows = 0
        gists = 0
        if not plan:
            await self._navigate_to("feed")
            return 0, did_post, projs, follows, gists
        for entry in plan:
            action = entry["action"]
            if action == "create_post" and (did_post or page_state["did_post"]):
                continue
            if action == "create_gist" and (gists or page_state["gists"]):
                continue
            if action == "create_project" and projs:
                continue
            if await self._dispatch_action(action, entry.get("target", ""), page_state):
                actions += 1
                self._record_history(action, entry.get("rationale", ""))
                if action == "create_post":
                    did_post = True
                elif action == "create_project":
                    projs += 1
                elif action == "follow":
                    follows += 1
                elif action == "create_gist":
                    gists += 1
            await b._idle(0.5, 1.5)
        return actions, did_post, projs, follows, gists

    async def _cycle_ai(
        self,
        mood: str = "normal",
        *,
        session_posted: bool = False,
        session_projects: int = 0,
        session_follows: int = 0,
        session_gists: int = 0,
        can_project: bool = False,
        can_post: bool = False,
        can_message: bool = False,
    ) -> tuple[int, bool, int, int, int]:
        b = self.b
        curl = await b.url()
        page_text = (await b.html()).lower()
        page_state = await self._page_state(
            curl,
            page_text,
            did_post=session_posted,
            follows=session_follows,
            gists=session_gists,
            can_project=can_project,
            can_post=can_post,
            can_message=can_message,
        )
        if page_state["page"] in (
            "post",
            "news_detail",
            "project_detail",
            "gist_detail",
        ):
            await self._read_like_human(4, 90)
        menu = self._build_menu(page_state)
        decision = await self._generate(
            self.llm.decide,
            self.state.identity,
            page_state,
            menu,
            self._session_history,
            self._decision_temperature,
        )
        if not decision:
            decision = {"plan": [], "energy": self._session_energy, "stop_after": 0}
        self._session_energy = decision.get("energy", self._session_energy)
        self._session_stop_after = decision.get("stop_after", 0)
        actions, did_post, projs, follows, gists = await self._run_plan(
            decision["plan"], page_state
        )
        return (
            actions,
            did_post or session_posted,
            session_projects + projs,
            session_follows + follows,
            session_gists + gists,
        )

    def _scaled_pause(self) -> int:
        base = random.randint(self._pause_min, self._pause_max)
        factor = 1.4 - 0.8 * self._session_energy
        return max(1, int(base * factor))

    async def _vote_on_feed(self) -> bool:
        b = self.b
        sel = "button.vote-up, button.post-action-btn.vote-up, form[action*='/votes/'] button[type='submit']"
        btns = b.page.locator(sel)
        count = await btns.count()
        if count == 0:
            return False
        voted_set = set(self.state.voted_post_ids)
        known_set = set(self.state.known_users)
        candidates: list[tuple[int, str, bool]] = []
        for i in range(min(count, 30)):
            btn = btns.nth(i)
            post_id = ""
            try:
                form = btn.locator("xpath=ancestor::form[1]").first
                if await form.count() > 0:
                    action = await form.get_attribute("action") or ""
                    m = re.search(r"/votes/(?:post|comment|gist)/([a-f0-9-]+)", action)
                    if m:
                        post_id = m.group(1)
            except Exception:
                pass
            if not post_id:
                try:
                    card = btn.locator(
                        "xpath=ancestor::*[contains(@class,'post-card') or self::article][1]"
                    ).first
                    if await card.count() > 0:
                        link = card.locator("a[href*='/posts/']").first
                        if await link.count() > 0:
                            href = await link.get_attribute("href") or ""
                            m = re.search(r"/posts/([^/?#]+)", href)
                            if m:
                                post_id = m.group(1)
                except Exception:
                    pass
            if post_id and post_id in voted_set:
                continue
            is_known_author = False
            try:
                card = btn.locator(
                    "xpath=ancestor::*[contains(@class,'post-card') or self::article][1]"
                ).first
                if await card.count() > 0:
                    author = card.locator("a[href*='/profile/']").first
                    if await author.count() > 0:
                        ah = await author.get_attribute("href") or ""
                        uname = ah.split("/profile/")[-1].split("?")[0].split("/")[0]
                        if uname and uname in known_set:
                            is_known_author = True
            except Exception:
                pass
            candidates.append((i, post_id, is_known_author))
        if not candidates:
            return False
        reciprocal = [c for c in candidates if c[2]]
        pool = reciprocal if reciprocal and random.random() < 0.65 else candidates
        idx, post_id, _ = random.choice(pool)
        try:
            await btns.nth(idx).click(timeout=3000)
            self.state.votes_cast += 1
            if post_id:
                self.state.voted_post_ids.append(post_id)
                if len(self.state.voted_post_ids) > 1000:
                    self.state.voted_post_ids = self.state.voted_post_ids[-500:]
            self._action(
                "VOTE", f"post {post_id[:12] or '?'} (total {self.state.votes_cast})"
            )
            await b._idle(1.0, 2.0)
            return True
        except Exception as e:
            logger.debug("_vote_on_feed failed: %s", e)
            return False

    async def _vote_on_comments(self) -> bool:
        b = self.b
        sel = "button.comment-vote-btn, form[action*='/vote/comment'] button[type='submit']"
        btns = b.page.locator(sel)
        count = await btns.count()
        if count < 2:
            return False
        idx = random.randrange(count)
        try:
            await btns.nth(idx).click(timeout=3000)
            self.state.votes_cast += 1
            self._action("VOTE", f"comment (total {self.state.votes_cast})")
            await b._idle(1.0, 2.0)
            return True
        except Exception as e:
            logger.debug("_vote_on_comments failed: %s", e)
            return False

    async def _is_own_post(self) -> bool:
        curl = await self.b.url()
        if "/posts/" not in curl:
            return False
        if curl in self.state.own_post_urls:
            return True
        author_selectors = [
            ".post-detail-header a[href*='/profile/']",
            ".post-detail .post-detail-header a[href*='/profile/']",
            ".post-author a[href*='/profile/']",
            ".post-header a[href*='/profile/']",
        ]
        for sel in author_selectors:
            loc = self.b.page.locator(sel).first
            if await loc.count() == 0:
                continue
            href = await loc.get_attribute("href") or ""
            if "/profile/" not in href:
                continue
            author = href.split("/profile/")[-1].split("?")[0].split("/")[0].lower()
            is_own = author == self.state.username.lower()
            if is_own and curl not in self.state.own_post_urls:
                self.state.own_post_urls.append(curl)
            return is_own
        try:
            author = await self.b.page.evaluate(
                """(uname) => {
                    const links = document.querySelectorAll('a[href*="/profile/"]');
                    for (const a of links) {
                        let p = a;
                        let in_nav = false;
                        while (p && p !== document.body) {
                            const cn = (p.className || '').toString();
                            if (p.tagName === 'HEADER' || p.tagName === 'NAV' || /topnav|navbar|sidebar/i.test(cn)) {
                                in_nav = true;
                                break;
                            }
                            p = p.parentElement;
                        }
                        if (in_nav) continue;
                        const m = a.getAttribute('href').match(/\\/profile\\/([^?/]+)/);
                        if (m) return m[1];
                    }
                    return '';
                }""",
                self.state.username,
            )
        except Exception as e:
            logger.debug("_is_own_post evaluate failed: %s", e)
            return False
        if not author:
            return False
        is_own = author.lower() == self.state.username.lower()
        if is_own and curl not in self.state.own_post_urls:
            self.state.own_post_urls.append(curl)
        return is_own

    async def _existing_comments(self) -> list[str]:
        bodies = self.b.page.locator(".comment-body")
        try:
            count = await bodies.count()
        except Exception:
            return []
        if count == 0:
            return []
        own = self.state.username.lower()
        collected: list[str] = []
        start = max(0, count - MAX_SIBLING_COMMENTS)
        for idx in range(start, count):
            node = bodies.nth(idx)
            try:
                text = (await node.locator(".comment-text").first.inner_text()).strip()
            except Exception:
                continue
            if not text:
                continue
            author = ""
            try:
                link = node.locator("a[href*='/profile/']").first
                if await link.count() > 0:
                    href = await link.get_attribute("href") or ""
                    author = href.split("/profile/")[-1].split("?")[0].split("/")[0]
            except Exception:
                author = ""
            if author and author.lower() == own:
                continue
            snippet = text[:SIBLING_SNIPPET_LEN]
            collected.append(f"{author}: {snippet}" if author else snippet)
        return collected

    async def _comment_on_post(self) -> bool:
        b = self.b
        curl = await b.url()
        if "/posts/" in curl:
            kind = "post"
        elif "/gists/" in curl:
            kind = "gist"
        elif "/news/" in curl:
            kind = "news"
        elif "/projects/" in curl:
            kind = "project"
        else:
            return False

        page_text = (await b.html()).lower()
        mentioned_here = f"@{self.state.username.lower()}" in page_text
        thread_id = self._thread_id(curl)

        if self._session_comments >= self._session_comment_cap and not mentioned_here:
            self._log(
                f"Comment cap reached ({self._session_comment_cap}/session), skipping"
            )
            return False

        if kind == "post":
            if await self._is_own_post() and not mentioned_here:
                self._log("Own post, no mention - not commenting")
                return False
        elif curl in self.state.own_post_urls:
            self._log(f"Own {kind} - not commenting")
            return False

        if (
            thread_id
            and thread_id in self.state.commented_post_ids
            and not mentioned_here
        ):
            self._log(f"Already commented on {thread_id[:12]}, not repeating")
            return False

        post_body = (
            await b.text(".post-content")
            or await b.text(".gist-detail-desc")
            or await b.text(".news-detail-content")
            or await b.text(".news-detail-desc")
            or await b.text(".project-detail-desc")
            or await b.text(".rendered-content")
            or await b.text("article")
            or ""
        )
        if not post_body:
            self._log("Comment: no content found")
            return False

        known = list(
            {u for u in self.state.known_users if u and u != self.state.username}
        )
        mention_target = ""
        if known and random.random() < 0.3:
            mention_target = random.choice(known)

        style = self.llm.pick_comment_style()
        siblings = await self._existing_comments()
        siblings_block = "\n".join(f"- {c}" for c in siblings)
        siblings_text = " ".join(siblings)

        comment = ""
        for attempt in range(2):
            candidate = await self._generate(
                self.llm.generate_comment,
                post_body[:1500],
                self.state.persona,
                mention_target,
                "",
                style,
                siblings_block,
            )
            if not candidate:
                continue
            verdict = await self._generate(
                self.llm.quality_check,
                "comment",
                candidate,
                post_body[:1500],
                style,
                siblings_text,
            )
            if verdict is None or verdict[0]:
                comment = candidate
                break
            self.state.quality_rejections += 1
            action = "regenerating" if attempt == 0 else "skipping"
            self._log(f"Comment quality reject ({verdict[1]}), {action}")
        if not comment:
            self._log("Comment: no quality candidate")
            return False

        if mention_target and f"@{mention_target.lower()}" not in comment.lower():
            words = comment.split(" ", 1)
            if len(words) > 1:
                comment = f"{words[0]} @{mention_target} {words[1]}"
            else:
                comment = f"@{mention_target} {comment}"

        comment = comment[:2000]
        mention_log = f" @{mention_target}" if mention_target else ""

        textarea_sels = [
            "form.comment-form textarea[name='content']",
            "textarea#comment-content",
            "textarea.emoji-picker-target",
            "textarea[name='content']",
            "#comment-content",
        ]
        ok = False
        used_sel = ""
        for sel in textarea_sels:
            if await self._type_like_human(sel, comment):
                ok = True
                used_sel = sel
                break

        if not ok:
            self._log("Comment: no textarea found")
            return False

        self._log(f"Typed comment into {used_sel}")

        await b._idle(0.5, 1.5)

        submit_sels = [
            "form.comment-form button[type='submit']",
            "button.btn-primary:has-text('Post')",
            "form[action*='/comments/create'] button[type='submit']",
            ".comment-form button[type='submit']",
        ]
        ok = False
        for sel in submit_sels:
            if await b.click(sel):
                ok = True
                break

        if ok:
            self.state.comments_posted += 1
            self._session_comments += 1
            if thread_id and thread_id not in self.state.commented_post_ids:
                self.state.commented_post_ids.append(thread_id)
                if len(self.state.commented_post_ids) > 1000:
                    self.state.commented_post_ids = self.state.commented_post_ids[-500:]
            if kind == "news":
                self.state.news_comments += 1
            tag = "NEWS" if kind == "news" else "COMMENT"
            self._action(
                tag,
                f"on {kind}{mention_log} [{self.state.comments_posted}]: {comment[:60]}",
            )
            await asyncio.sleep(
                random.uniform(
                    COMMENT_COOLDOWN_MIN_SECONDS, COMMENT_COOLDOWN_MAX_SECONDS
                )
            )
            return True
        self._log("Comment: submit button not found/enabled")
        return False

    async def _reply_to_random_comment(self) -> bool:
        b = self.b
        curl = await b.url()
        if "/posts/" not in curl:
            return False
        if self._session_comments >= self._session_comment_cap:
            self._log(
                f"Comment cap reached ({self._session_comment_cap}/session), skipping reply"
            )
            return False
        comments = b.page.locator(
            ".comment, li.comment, article.comment, .comment-item"
        )
        count = await comments.count()
        if count == 0:
            return False
        limit = min(count, 10)
        for _ in range(limit):
            idx = random.randrange(limit)
            target = comments.nth(idx)
            try:
                text = (await target.inner_text() or "")[:1000]
            except Exception:
                continue
            if not text:
                continue
            if f"@{self.state.username.lower()}" in text.lower():
                continue
            mentioner = ""
            try:
                author_link = target.locator("a[href*='/profile/']").first
                if await author_link.count() > 0:
                    ah = await author_link.get_attribute("href") or ""
                    if "/profile/" in ah:
                        mentioner = (
                            ah.split("/profile/")[-1].split("?")[0].split("/")[0]
                        )
            except Exception:
                pass
            if not mentioner or mentioner.lower() == self.state.username.lower():
                continue
            try:
                await target.scroll_into_view_if_needed(timeout=2000)
                await b._idle(0.3, 0.8)
                reply_btn = target.locator(
                    ".comment-action-btn, button:has-text('Reply'), a:has-text('Reply')"
                ).first
                if await reply_btn.count() == 0:
                    continue
                await reply_btn.click(timeout=3000)
                await b._idle(0.5, 1.5)
            except Exception as e:
                logger.debug("reply-btn click failed: %s", e)
                continue
            post_body = await b.text(".post-content") or await b.text("article") or ""
            siblings = [c for c in await self._existing_comments() if text[:60] not in c]
            siblings_block = "\n".join(f"- {c}" for c in siblings)
            reply = await self._generate(
                self.llm.generate_comment,
                post_body[:1200],
                self.state.persona,
                mentioner,
                text,
                self.llm.pick_comment_style(),
                siblings_block,
            )
            if not reply:
                self._log("Reply: generation failed/empty")
                return False
            if mentioner and f"@{mentioner.lower()}" not in reply.lower():
                reply = f"@{mentioner} {reply}"
            reply = reply[:2000]
            textarea_sels = [
                ".reply-form textarea[name='content']",
                ".comment-form textarea[name='content']",
                "textarea[name='content']",
            ]
            typed = False
            for sel in textarea_sels:
                if await self._type_like_human(sel, reply):
                    typed = True
                    break
            if not typed:
                return False
            await b._idle(0.5, 1.5)
            submit_sels = [
                ".reply-form button[type='submit']",
                "form.comment-form button[type='submit']",
                "button.btn-primary:has-text('Reply')",
                "button.btn-primary:has-text('Post')",
            ]
            for sel in submit_sels:
                if await b.click(sel):
                    self.state.comments_posted += 1
                    self._session_comments += 1
                    if mentioner not in self.state.known_users:
                        self.state.known_users.append(mentioner)
                    self._action(
                        "REPLY",
                        f"to @{mentioner} [{self.state.comments_posted}]: {reply[:60]}",
                    )
                    await asyncio.sleep(
                        random.uniform(
                            COMMENT_COOLDOWN_MIN_SECONDS, COMMENT_COOLDOWN_MAX_SECONDS
                        )
                    )
                    return True
            return False
        return False

    async def _vote_on_gist(self) -> bool:
        b = self.b
        sel = (
            "form[action*='/votes/gist/'] button.gist-card-star, "
            "form[action*='/votes/gist/'] button[type='submit']"
        )
        btns = b.page.locator(sel)
        count = await btns.count()
        if count == 0:
            return False
        voted = set(self.state.voted_post_ids)
        candidates: list[tuple[int, str]] = []
        for i in range(min(count, 30)):
            gist_id = ""
            try:
                form = btns.nth(i).locator("xpath=ancestor::form[1]").first
                if await form.count() > 0:
                    action = await form.get_attribute("action") or ""
                    m = re.search(r"/votes/gist/([a-f0-9-]+)", action)
                    if m:
                        gist_id = m.group(1)
            except Exception:
                pass
            if gist_id and gist_id in voted:
                continue
            candidates.append((i, gist_id))
        if not candidates:
            return False
        idx, gist_id = random.choice(candidates)
        try:
            await btns.nth(idx).click(timeout=3000)
            self.state.votes_cast += 1
            if gist_id:
                self.state.voted_post_ids.append(gist_id)
                if len(self.state.voted_post_ids) > 1000:
                    self.state.voted_post_ids = self.state.voted_post_ids[-500:]
            self._action(
                "VOTE", f"gist {gist_id[:12] or '?'} (total {self.state.votes_cast})"
            )
            await b._idle(1.0, 2.0)
            return True
        except Exception as e:
            logger.debug("_vote_on_gist failed: %s", e)
            return False

    async def _vote_on_project(self) -> bool:
        b = self.b
        sel = (
            "form[action*='/votes/project/'] button.project-star-btn, "
            "form[action*='/votes/project/'] button[type='submit']"
        )
        btns = b.page.locator(sel)
        count = await btns.count()
        if count == 0:
            return False
        voted = set(self.state.voted_post_ids)
        candidates: list[tuple[int, str]] = []
        for i in range(min(count, 30)):
            project_id = ""
            try:
                form = btns.nth(i).locator("xpath=ancestor::form[1]").first
                if await form.count() > 0:
                    action = await form.get_attribute("action") or ""
                    m = re.search(r"/votes/project/([a-f0-9-]+)", action)
                    if m:
                        project_id = m.group(1)
            except Exception:
                pass
            if project_id and project_id in voted:
                continue
            candidates.append((i, project_id))
        if not candidates:
            return False
        idx, project_id = random.choice(candidates)
        try:
            await btns.nth(idx).click(timeout=3000)
            self.state.votes_cast += 1
            self.state.projects_starred += 1
            if project_id:
                self.state.voted_post_ids.append(project_id)
                if len(self.state.voted_post_ids) > 1000:
                    self.state.voted_post_ids = self.state.voted_post_ids[-500:]
            self._action(
                "STAR",
                f"project {project_id[:12] or '?'} (total {self.state.votes_cast})",
            )
            await b._idle(1.0, 2.0)
            return True
        except Exception as e:
            logger.debug("_vote_on_project failed: %s", e)
            return False

    async def _vote_on_poll(self) -> bool:
        b = self.b
        polls = b.page.locator(".poll[data-poll-uid]")
        count = await polls.count()
        if count == 0:
            return False
        voted = set(self.state.polls_voted)
        for i in range(min(count, 20)):
            poll = polls.nth(i)
            poll_uid = await poll.get_attribute("data-poll-uid") or ""
            if not poll_uid or poll_uid in voted:
                continue
            if await poll.locator(".poll-option.chosen").count() > 0:
                self.state.polls_voted.append(poll_uid)
                continue
            options = poll.locator(".poll-option:not([disabled])")
            opt_count = await options.count()
            if opt_count == 0:
                continue
            choice = options.nth(random.randrange(opt_count))
            option_uid = await choice.get_attribute("data-option-uid") or ""
            try:
                await choice.scroll_into_view_if_needed(timeout=2000)
                await b._idle(0.3, 0.8)
                await choice.click(timeout=3000)
            except Exception as e:
                logger.debug("_vote_on_poll click failed: %s", e)
                continue
            try:
                await poll.locator(".poll-option.chosen").first.wait_for(
                    state="visible", timeout=4000
                )
            except Exception as e:
                logger.debug("_vote_on_poll confirm failed: %s", e)
            self.state.polls_voted.append(poll_uid)
            if len(self.state.polls_voted) > 1000:
                self.state.polls_voted = self.state.polls_voted[-500:]
            self.state.poll_votes_cast += 1
            self._action(
                "POLL",
                f"voted {poll_uid[:12]} opt {option_uid[:8] or '?'} (total {self.state.poll_votes_cast})",
            )
            await b._idle(1.0, 2.0)
            return True
        return False

    async def _reaction_content(self, bar: Any) -> str:
        try:
            container = bar.locator(
                "xpath=ancestor::article[contains(concat(' ',normalize-space(@class),' '),' post-card ')][1]"
                " | ancestor::div[contains(concat(' ',normalize-space(@class),' '),' comment ')][1]"
            ).first
            if await container.count() > 0:
                text = (await container.inner_text() or "").strip()
                if text:
                    return text[:1200]
        except Exception as e:
            logger.debug("_reaction_content container failed: %s", e)
        body = (
            await self.b.text(".post-content")
            or await self.b.text(".gist-detail-desc")
            or await self.b.text(".news-detail-content")
            or await self.b.text(".news-detail-desc")
            or await self.b.text(".project-detail-desc")
            or await self.b.text(".rendered-content")
            or await self.b.text("article")
            or ""
        )
        return body[:1200]

    async def _react_to_object(self) -> bool:
        b = self.b
        bars = b.page.locator(".reaction-bar[data-reaction-uid]")
        count = await bars.count()
        if count == 0:
            return False
        decided = set(self.state.reacted_targets)
        react_rate = REACT_RATES.get(self.state.persona, REACT_RATE_DEFAULT)
        for i in range(min(count, 20)):
            bar = bars.nth(i)
            uid = await bar.get_attribute("data-reaction-uid") or ""
            rtype = await bar.get_attribute("data-reaction-type") or ""
            if not uid or uid in decided:
                continue

            self.state.reacted_targets.append(uid)
            if len(self.state.reacted_targets) > 2000:
                self.state.reacted_targets = self.state.reacted_targets[-1000:]

            if random.random() > react_rate:
                self._log(f"Reaction: skipping {rtype} {uid[:12]} (persona reticence)")
                return False

            snippet = await self._reaction_content(bar)
            if not snippet:
                self._log(f"Reaction: no content for {rtype} {uid[:12]}")
                return False
            emoji = await self._generate(
                self.llm.select_reaction, snippet, self.state.persona
            )
            if not emoji:
                self._log(f"Reaction: nothing fitting for {rtype} {uid[:12]}")
                return False

            add_btn = bar.locator(".reaction-add-btn").first
            if await add_btn.count() == 0:
                return False
            palette_btn = bar.locator(
                f".reaction-palette-btn[data-reaction-emoji='{emoji}']"
            ).first
            if await palette_btn.count() == 0:
                return False
            try:
                await add_btn.scroll_into_view_if_needed(timeout=2000)
                await b._idle(0.2, 0.6)
                await add_btn.click(timeout=3000)
                await b._idle(0.3, 0.7)
                await palette_btn.click(timeout=3000)
            except Exception as e:
                logger.debug("_react_to_object click failed: %s", e)
                return False
            try:
                await bar.locator(".reaction-chip.reacted").first.wait_for(
                    state="visible", timeout=4000
                )
            except Exception as e:
                logger.debug("_react_to_object confirm failed: %s", e)
            self.state.reactions_cast += 1
            self._action(
                "REACT",
                f"{emoji} on {rtype} {uid[:12]} (total {self.state.reactions_cast})",
            )
            await b._idle(1.0, 2.0)
            return True
        return False

    async def _create_gist(self) -> bool:
        b = self.b
        await b.goto(f"{self.base_url}/gists")
        if not await b.click("#create-gist-btn, .gists-fab, .feed-fab"):
            self._log("Create gist: trigger not found")
            return False
        await b._idle(0.8, 1.5)
        if await b.page.locator("#create-gist-modal").count() == 0:
            self._log("Create gist: modal not found")
            return False

        generated = None
        for attempt in range(2):
            candidate = await self._generate(self.llm.generate_gist, self.state.persona)
            if not candidate:
                continue
            cand_title, cand_desc, cand_lang, cand_code = candidate
            if not cand_code.strip():
                continue
            verdict = await self._generate(
                self.llm.gist_quality_check, cand_title, cand_code, cand_lang
            )
            if verdict is None or verdict[0]:
                generated = candidate
                break
            self.state.quality_rejections += 1
            action = "regenerating" if attempt == 0 else "skipping"
            self._log(f"Gist quality reject ({verdict[1]}), {action}")
        if not generated:
            self._log("Create gist: no quality candidate")
            return False
        title, desc, language, code = generated

        title = self.llm.strip_md(title)[:200] or title[:200]
        if not await b.fill("#gist-title", title):
            self._log("Create gist: title field not found")
            return False
        await b._idle(0.2, 0.5)
        await b.fill("#gist-description", desc[:500])
        await b._idle(0.2, 0.5)
        try:
            await b.page.select_option("#gist-language", language)
        except Exception as e:
            logger.debug("gist language select failed: %s", e)
        await b._idle(0.3, 0.7)

        cm = b.page.locator("#create-gist-modal .CodeMirror")
        if await cm.count() == 0:
            self._log("Create gist: code editor not found")
            return False
        try:
            await cm.first.click()
            await b._idle(0.3, 0.6)
            await b.page.evaluate(
                """(code) => {
                    const el = document.querySelector('#create-gist-modal .CodeMirror');
                    if (el && el.CodeMirror) { el.CodeMirror.setValue(code); el.CodeMirror.save(); }
                    const ta = document.querySelector('#gist-source-editor');
                    if (ta) { ta.value = code; ta.dispatchEvent(new Event('input', {bubbles: true})); }
                }""",
                code,
            )
        except Exception as e:
            logger.debug("gist code fill failed: %s", e)
            return False
        await b._idle(0.5, 1.2)

        ok = await b.click(
            "#create-gist-modal button[type='submit'], #create-gist-modal .btn-primary"
        )
        if not ok:
            self._log("Create gist: submit not found")
            return False
        await asyncio.sleep(2)
        url = await b.url()
        if "/gists/" in url:
            if url not in self.state.own_post_urls:
                self.state.own_post_urls.append(url)
            self.state.gists_created += 1
            self._action(
                "GIST",
                f"{title[:40]} [{language}] [{self.state.gists_created}] -> {url[-50:]}",
            )
            return True
        self._log("Create gist: no redirect to gist detail")
        return False

    async def _create_post(self) -> bool:
        b = self.b
        await b.goto(f"{self.base_url}/feed")

        fab_sel = "#create-post-btn, .feed-fab, button.feed-fab, a.feed-fab"
        ok = await b.click(fab_sel)
        if not ok:
            self._log("Create post: FAB button not found")
            return False
        self._log("Create post: FAB clicked, waiting for modal")
        await b._idle(1.0, 2.0)

        modal = await b.page.locator("#create-post-modal").count()
        if modal == 0:
            self._log("Create post: modal #create-post-modal not in DOM")
            return False
        hidden = await b.page.locator("#create-post-modal").get_attribute("class") or ""
        if "hidden" in hidden or await b.page.locator("#create-post-modal").is_hidden():
            self._log("Create post: modal is hidden")
            return False
        self._log("Create post: modal visible")

        article_title = ""
        article_desc = ""
        post_title = ""

        if self._post_cache:
            article_title, post_title, article_desc, content, topic = (
                self._post_cache.pop(0)
            )
            asyncio.create_task(self._refill_cache_async("post"))
        else:
            articles = self.news.fetch()
            topic = pick_category(self.state.persona)
            article = self.registry.reserve_unused(
                articles,
                self.state.username,
                self.llm.clean,
                topic,
                self.state.persona,
            )
            if not article:
                self._log("Create post: no unused articles available")
                return False
            desc = article.get("description", "")[:500]
            post_title = await self._generate(
                self.llm.generate_post_title,
                article["title"],
                self.state.persona,
                topic,
            )
            content = await self._generate(
                self.llm.generate_post,
                article["title"],
                desc,
                self.state.persona,
                topic,
                self.state.recent_post_titles,
            )
            if not content:
                self._log("Create post: generation failed/empty")
                return False
            article_title = article["title"]
            article_desc = desc

        verdict = await self._generate(
            self.llm.quality_check, "post", content, article_desc
        )
        if verdict is not None and not verdict[0]:
            self.state.quality_rejections += 1
            self._log(f"Post quality reject ({verdict[1]}), regenerating")
            content = await self._generate(
                self.llm.generate_post,
                article_title,
                article_desc,
                self.state.persona,
                topic,
                self.state.recent_post_titles,
            )
            if not content:
                self._log("Create post: regeneration failed/empty")
                return False
            verdict = await self._generate(
                self.llm.quality_check, "post", content, article_desc
            )
            if verdict is not None and not verdict[0]:
                self.state.quality_rejections += 1
                self._log(f"Post quality reject ({verdict[1]}), skipping")
                return False

        content_hash = hashlib.sha256(content.encode()).hexdigest()[:16]
        if content_hash in self.state.posted_content_hashes:
            self._log(f"Create post: duplicate content {content_hash}, skipping")
            return False

        clean_title = self.llm.clean(article_title[:200]) if article_title else ""
        if clean_title:
            if not self.registry.reserve(clean_title, self.state.username, topic):
                self._log(
                    f"Create post: article '{clean_title[:50]}...' already covered, skipping"
                )
                return False

        type_title = self.llm.strip_md(post_title)[:120] if post_title else ""
        if not type_title:
            type_title = clean_title

        ts = f"#create-post-modal input[name='topic'][value='{topic}']"
        if await b.page.locator(ts).count() == 0:
            self._log(f"Create post: topic radio '{topic}' not found")
        else:
            await b.click(ts)
            await b._idle(0.2, 0.5)

        if type_title:
            ok = await b.fill("#create-post-modal input[name='title']", type_title)
            if not ok:
                self._log("Create post: title input not found")
            await b._idle(0.2, 0.4)

        content_sel = "#create-post-modal textarea[name='content']"
        if await b.page.locator(content_sel).count() == 0:
            self._log("Create post: content textarea not found")
            return False
        if not await self._type_like_human(content_sel, content[:5000]):
            self._log("Create post: typing content into modal failed")
            return False
        await b._idle(0.5, 1.5)

        submit_sels = [
            "#create-post-modal button[type='submit']",
            "#create-post-modal .btn-primary",
            ".modal-overlay#create-post-modal button[type='submit']",
            "#create-post-modal button:has-text('Post')",
        ]
        ok = False
        for sel in submit_sels:
            if await b.click(sel):
                ok = True
                break
        if not ok:
            self._log("Create post: submit button not found")
            return False

        post_url = await b.url()
        for _ in range(12):
            if "/posts/" in post_url:
                break
            await asyncio.sleep(1)
            post_url = await b.url()
        if "/posts/" not in post_url:
            self._log(f"Create post: submit did not create a post (url={post_url[-45:]})")
            return False
        self.state.created_posts += 1
        self.state.posted_content_hashes.append(content_hash)
        if type_title:
            self.state.recent_post_titles.append(type_title)
            self.state.recent_post_titles = self.state.recent_post_titles[-8:]
        if post_url not in self.state.own_post_urls:
            self.state.own_post_urls.append(post_url)
        if post_url not in self.state.known_posts:
            self.state.known_posts.append(post_url)
        self._action(
            "POST",
            f"{topic} [{self.state.created_posts}]: {type_title[:50] or content[:50]} -> {post_url[-45:]}",
        )
        return True

    async def _click_post_link(self) -> bool:
        b = self.b
        links = b.page.locator("a[href*='/posts/']")
        count = await links.count()
        if count == 0:
            return False

        candidates = []
        for i in range(count):
            h = await links.nth(i).get_attribute("href")
            if h and "/posts/" in h and len(h) > 20:
                candidates.append(i)

        if not candidates:
            return False

        idx = random.choice(candidates)
        href = await links.nth(idx).get_attribute("href")
        try:
            await links.nth(idx).click(timeout=5000)
            await b._idle(1.0, 2.0)
            if href and href not in self.state.known_posts:
                self.state.known_posts.append(href)
            self._action("OPEN", f"post {href[-45:] if href else '?'}")
            return True
        except Exception as e:
            logger.debug("_click_post_link failed: %s", e)
            return False

    async def _click_gist_link(self) -> bool:
        b = self.b
        links = b.page.locator("a[href*='/gists/']")
        count = await links.count()
        if count == 0:
            return False
        candidates = []
        for i in range(count):
            h = await links.nth(i).get_attribute("href")
            if h and "/gists/" in h and "?" not in h and h.rstrip("/") != "/gists":
                candidates.append(i)
        if not candidates:
            return False
        idx = random.choice(candidates)
        href = await links.nth(idx).get_attribute("href")
        try:
            await links.nth(idx).click(timeout=5000)
            await b._idle(1.0, 2.0)
            self._action("OPEN", f"gist {href[-45:] if href else '?'}")
            return True
        except Exception as e:
            logger.debug("_click_gist_link failed: %s", e)
            return False

    async def _open_content(self, kind: str) -> bool:
        b = self.b
        base = "/news/" if kind == "news" else f"/{kind}s/"
        links = b.page.locator(f"a[href*='{base}']")
        count = await links.count()
        if count == 0:
            return False
        candidates = []
        for i in range(count):
            h = await links.nth(i).get_attribute("href")
            if h and base in h and "?" not in h and h.rstrip("/") != base.rstrip("/"):
                candidates.append(i)
        if not candidates:
            return False
        idx = random.choice(candidates)
        href = await links.nth(idx).get_attribute("href")
        try:
            await links.nth(idx).click(timeout=5000)
            await b._idle(1.0, 2.0)
            self._action("OPEN", f"{kind} {href[-45:] if href else '?'}")
            return True
        except Exception as e:
            logger.debug("_open_content(%s) failed: %s", kind, e)
            return False

    async def _click_profile_link(self) -> bool:
        b = self.b
        links = b.page.locator("a[href*='/profile/']")
        count = await links.count()
        if count == 0:
            return False

        for i in range(count):
            h = await links.nth(i).get_attribute("href")
            if h and self.state.username not in h:
                try:
                    await links.nth(i).click(timeout=5000)
                    await b._idle(1.0, 2.0)
                    uname = h.split("/profile/")[-1].split("?")[0]
                    if uname not in self.state.known_users:
                        self.state.known_users.append(uname)
                    self.state.profiles_viewed += 1
                    self._action(
                        "PROFILE", f"viewed @{uname} [{self.state.profiles_viewed}]"
                    )
                    return True
                except Exception as e:
                    logger.debug("_click_profile_link failed: %s", e)
                    continue
        return False

    async def _click_create_project(self) -> bool:
        b = self.b
        await b.goto(f"{self.base_url}/projects")
        ok = await b.click("#create-project-btn")
        if not ok:
            return False
        await b._idle(0.8, 1.5)

        if not await b.text("#create-project-modal"):
            return False

        if self._project_cache:
            raw_title, desc = self._project_cache.pop(0)
            asyncio.create_task(self._refill_cache_async("project"))
        else:
            raw_title = await self._generate(
                self.llm.generate_project_title, self.state.persona
            )
            if not raw_title:
                self._log("Create project: title generation failed")
                return False
            raw_title = raw_title[:200]
            desc = await self._generate(
                self.llm.generate_project_desc, raw_title, self.state.persona
            )
            if not desc:
                self._log("Create project: description generation failed")
                return False
            desc = desc[:5000]

        title = self.llm.strip_md(raw_title)[:120] or raw_title[:120]
        types = ["game", "software", "mobile_app", "website", "game_asset"]
        ptype = random.choice(types)
        status = random.choice(PROJECT_STATUSES)

        await b.fill("#title", title)
        await b._idle(0.2, 0.5)
        await b.fill("#description", desc)
        await b._idle(0.2, 0.5)

        await b.click(f"input[name='project_type'][value='{ptype}']")
        await b._idle(0.2, 0.5)

        await b.click(f"input[name='status'][value='{status}']")
        await b._idle(0.2, 0.5)

        ok = await b.click("#create-project-modal .btn-primary")
        if ok:
            self.state.projects_created += 1
            self._action(
                "PROJECT", f"{title} ({ptype}) [{self.state.projects_created}]"
            )
            await asyncio.sleep(2)
            return True
        return False

    async def _report_issue(self) -> bool:
        b = self.b
        await b.goto(f"{self.base_url}/issues")
        ok = await b.click("[data-modal='create-issue-modal']")
        if not ok:
            return False
        await b._idle(0.5, 1.5)

        if not await b.text("#create-issue-modal"):
            return False

        topic = random.choice(
            [
                "UI glitch",
                "Broken link",
                "Performance issue",
                "Auth problem",
                "Formatting error",
            ]
        )
        if self._issue_cache:
            title, desc = self._issue_cache.pop(0)
            asyncio.create_task(self._refill_cache_async("issue"))
        else:
            generated = await self._generate(self.llm.generate_issue, topic)
            if not generated:
                self._log("Report issue: generation failed")
                return False
            title, desc = generated

        await b.fill("#issue-title", title[:200])
        await b._idle(0.2, 0.5)
        await b.fill("textarea[name='description']", desc[:2000])
        await b._idle(0.5, 1.0)

        ok = await b.click(
            "button:has-text('Submit Report'), #create-issue-modal .btn-primary"
        )
        if ok:
            self.state.issues_filed += 1
            self._action("ISSUE", f"{title[:50]} [{self.state.issues_filed}]")
            await asyncio.sleep(2)
            return True
        return False

    async def _update_profile(self) -> bool:
        b = self.b
        await b.goto(f"{self.base_url}/profile/{self.state.username}")

        ok = await b.click("[data-edit-field='bio']")
        if not ok:
            return False
        await b._idle(0.3, 0.8)

        bio = await self._generate(self.llm.generate_bio)
        if not bio:
            self._log("Update profile: bio generation failed")
            return False
        bio = bio[:500]
        await b.fill("textarea[name='bio']", bio)
        await b._idle(0.3, 0.8)

        filled = ["bio"]
        fields = await self._generate(
            self.llm.generate_profile_fields, self.state.username
        )
        if fields:
            location, git_link, website = fields
            for field_name, value in (
                ("location", location),
                ("git_link", git_link),
                ("website", website),
            ):
                if not value:
                    continue
                await b.click(f"[data-edit-field='{field_name}']")
                await b._idle(0.2, 0.5)
                if await b.fill(
                    f"input[name='{field_name}'], textarea[name='{field_name}']", value
                ):
                    filled.append(field_name)
                await b._idle(0.2, 0.5)

        ok = await b.click(
            "form[action*='/profile/update'] button[type='submit'], button[type='submit']"
        )
        if ok:
            self.state.profile_filled = True
            self._save()
            self._action("PROFILE", f"updated own profile ({', '.join(filled)})")
            await asyncio.sleep(2)
            return True
        return False

    async def _follow_user(self) -> bool:
        b = self.b
        curl = await b.url()
        if "/profile/" not in curl:
            return False
        uname = curl.split("/profile/")[-1].split("?")[0]
        if uname == self.state.username:
            return False
        form = b.page.locator("form[action*='/follow/'] button[type='submit']")
        if await form.count() == 0:
            return False
        text = (await form.first.inner_text()).strip()
        if text.lower() not in ("follow", "unfollow"):
            return False
        try:
            await form.first.click(timeout=3000)
            self._action(
                "FOLLOW",
                f"{'followed' if text.lower() == 'follow' else 'unfollowed'} @{uname}",
            )
            await b._idle(1.0, 2.0)
            return True
        except Exception as e:
            logger.debug("_follow_user failed: %s", e)
            return False

    def _classify_notification(self, text: str) -> bool:
        tl = text.lower()
        return (
            f"@{self.state.username.lower()}" in tl
            or "mentioned you" in tl
            or "replied to your" in tl
            or "replied to you" in tl
            or "tagged you" in tl
        )

    async def _collect_notifications(self) -> list[dict]:
        b = self.b
        cards = b.page.locator(".notification-card")
        try:
            count = await cards.count()
        except Exception:
            count = 0
        if count == 0:
            return await self._collect_notifications_fallback()
        items: list[dict] = []
        seen_keys: set = set()
        for i in range(min(count, 30)):
            card = cards.nth(i)
            text = ""
            try:
                body = card.locator(".notification-text").first
                if await body.count() > 0:
                    text = (await body.inner_text() or "")[:600]
            except Exception:
                text = ""
            if not text:
                try:
                    text = (await card.inner_text() or "")[:600]
                except Exception:
                    text = ""
            href = ""
            try:
                link = card.locator("a.card-link").first
                if await link.count() > 0:
                    href = (await link.get_attribute("href")) or ""
            except Exception:
                href = ""
            unread = False
            try:
                cls = (await card.get_attribute("class")) or ""
                unread = "unread" in cls.split()
            except Exception:
                unread = False
            if not text and not href:
                continue
            key = hashlib.sha256(f"{href}|{text[:200]}".encode()).hexdigest()[:24]
            if key in seen_keys:
                continue
            seen_keys.add(key)
            items.append(
                {
                    "key": key,
                    "href": href,
                    "text": text,
                    "is_mention": self._classify_notification(text),
                    "unread": unread,
                }
            )
        return items

    async def _collect_notifications_fallback(self) -> list[dict]:
        b = self.b
        loc = b.page.locator(".notification-item, .notif-item, [data-notification-id]")
        try:
            count = await loc.count()
        except Exception:
            count = 0
        items: list[dict] = []
        seen_keys: set = set()
        for i in range(min(count, 30)):
            el = loc.nth(i)
            try:
                text = (await el.inner_text() or "")[:600]
            except Exception:
                text = ""
            href = ""
            try:
                inner = el.locator("a[href]").first
                if await inner.count() > 0:
                    href = (await inner.get_attribute("href")) or ""
            except Exception:
                href = ""
            if not text and not href:
                continue
            key = hashlib.sha256(f"{href}|{text[:200]}".encode()).hexdigest()[:24]
            if key in seen_keys:
                continue
            seen_keys.add(key)
            items.append(
                {
                    "key": key,
                    "href": href,
                    "text": text,
                    "is_mention": self._classify_notification(text),
                    "unread": True,
                }
            )
        return items

    async def _check_notifications(self) -> bool:
        b = self.b
        await b.goto(f"{self.base_url}/notifications")
        await b._idle(0.8, 1.8)
        notifications = await self._collect_notifications()
        if not notifications:
            self._log("Notifications: none")
            return True
        unseen_mentions = [
            n
            for n in notifications
            if n["is_mention"]
            and n["unread"]
            and n["key"] not in self.state.notifications_seen
        ]
        self._log(
            f"Notifications: {len(notifications)} ({len(unseen_mentions)} unseen mentions)"
        )
        self.state.mentions_received += len(unseen_mentions)
        replied = 0
        for n in unseen_mentions:
            if self._session_mention_replies >= MENTION_REPLIES_PER_SESSION:
                self._log(
                    f"Mention reply cap reached ({MENTION_REPLIES_PER_SESSION}/session), leaving the rest"
                )
                break
            ok = await self._respond_to_mention(n["href"], n["text"])
            if ok:
                self.state.mentions_replied += 1
                self._session_mention_replies += 1
                replied += 1
            self.state.notifications_seen.append(n["key"])
            if len(self.state.notifications_seen) > 500:
                self.state.notifications_seen = self.state.notifications_seen[-250:]
            await b._idle(2.0, 5.0)
        if replied == 0:
            remaining = [
                n
                for n in notifications
                if n["key"] not in self.state.notifications_seen
            ]
            if remaining:
                pick = random.choice(remaining)
                target = pick["href"]
                if target and not target.startswith("http"):
                    target = self.base_url + (
                        target if target.startswith("/") else "/" + target
                    )
                if target:
                    try:
                        await b.goto(target)
                        self._log(f"Notifications: opened {target[-60:]}")
                    except Exception as e:
                        logger.debug("notification goto failed: %s", e)
                self.state.notifications_seen.append(pick["key"])
        try:
            mark = b.page.locator(
                "button:has-text('Mark all read'), form[action*='/mark-all-read'] button"
            )
            if await mark.count() > 0:
                await mark.first.click(timeout=3000)
                await b._idle(0.5, 1.5)
        except Exception as e:
            logger.debug("mark-read failed: %s", e)
        return True

    async def _respond_to_mention(self, href: str, snippet: str) -> bool:
        b = self.b
        target = href
        if target and not target.startswith("http"):
            target = self.base_url + (href if href.startswith("/") else "/" + href)
        if not target:
            return False
        try:
            await b.goto(target)
        except Exception as e:
            logger.debug("mention goto failed: %s", e)
            return False
        await b._idle(2.0, 4.0)
        curl = await b.url()
        if not any(k in curl for k in ("/posts/", "/gists/", "/projects/", "/news/")):
            self._log("Mention target has no comment thread, skipping")
            return False
        thread_id = self._thread_id(curl)
        if (
            thread_id
            and self.state.thread_reply_counts.get(thread_id, 0) >= MAX_THREAD_REPLIES
        ):
            self._log(
                f"Conversation taper on {thread_id[:12]} ({MAX_THREAD_REPLIES} replies), letting it rest"
            )
            return False
        needle = f"@{self.state.username}"
        comment_loc = None
        comment_selectors = [
            f".comment:has-text('{needle}')",
            f"li.comment:has-text('{needle}')",
            f".comment-item:has-text('{needle}')",
            f"article.comment:has-text('{needle}')",
        ]
        for sel in comment_selectors:
            try:
                loc = b.page.locator(sel).first
                if await loc.count() > 0:
                    comment_loc = loc
                    break
            except Exception:
                continue
        parent_text = ""
        mentioner = ""
        if comment_loc is not None:
            try:
                parent_text = (await comment_loc.inner_text() or "")[:1000]
            except Exception:
                parent_text = ""
            try:
                author_link = comment_loc.locator("a[href*='/profile/']").first
                if await author_link.count() > 0:
                    ah = await author_link.get_attribute("href") or ""
                    if "/profile/" in ah:
                        mentioner = (
                            ah.split("/profile/")[-1].split("?")[0].split("/")[0]
                        )
            except Exception:
                mentioner = ""
            try:
                await comment_loc.scroll_into_view_if_needed(timeout=2000)
                await b._idle(0.3, 0.9)
            except Exception as e:
                logger.debug("scroll mention failed: %s", e)
            try:
                reply_btn = comment_loc.locator(
                    ".comment-action-btn, button:has-text('Reply'), a:has-text('Reply')"
                ).first
                if await reply_btn.count() > 0:
                    await reply_btn.click(timeout=3000)
                    await b._idle(0.5, 1.5)
            except Exception as e:
                logger.debug("reply-btn click failed: %s", e)
        try:
            post_body = (
                await b.text(".post-content")
                or await b.text(".gist-detail-desc")
                or await b.text(".news-detail-content")
                or await b.text(".news-detail-desc")
                or await b.text(".project-detail-desc")
                or await b.text(".rendered-content")
                or await b.text("article")
                or snippet
            )
        except Exception:
            post_body = snippet
        reply = await self._generate(
            self.llm.generate_comment,
            post_body[:1500],
            self.state.persona,
            mentioner,
            parent_text,
        )
        if not reply:
            self._log("Mention reply: generation failed/empty")
            return False
        if mentioner and f"@{mentioner.lower()}" not in reply.lower():
            reply = f"@{mentioner} {reply}"
        reply = reply[:2000]
        textarea_sels = [
            ".comment .reply-form textarea[name='content']",
            ".reply-form textarea[name='content']",
            "form.comment-form textarea[name='content']",
            "textarea#comment-content",
            "textarea.emoji-picker-target",
            "textarea[name='content']",
        ]
        typed = False
        for sel in textarea_sels:
            if await self._type_like_human(sel, reply):
                typed = True
                break
        if not typed:
            self._log("Mention reply: no textarea found")
            return False
        await b._idle(0.5, 1.5)
        submit_sels = [
            ".reply-form button[type='submit']",
            "form.comment-form button[type='submit']",
            "button.btn-primary:has-text('Reply')",
            "button.btn-primary:has-text('Post')",
            "form[action*='/comments/create'] button[type='submit']",
            ".comment-form button[type='submit']",
        ]
        for sel in submit_sels:
            if await b.click(sel):
                self.state.comments_posted += 1
                self.state.replies_to_own_posts += 1
                if thread_id:
                    self.state.thread_reply_counts[thread_id] = (
                        self.state.thread_reply_counts.get(thread_id, 0) + 1
                    )
                    if len(self.state.thread_reply_counts) > 500:
                        recent = list(self.state.thread_reply_counts.items())[-250:]
                        self.state.thread_reply_counts = dict(recent)
                self._action("MENTION", f"replied to @{mentioner or '?'}: {reply[:60]}")
                await asyncio.sleep(2)
                return True
        self._log("Mention reply: submit failed")
        return False

    async def _check_messages(self) -> bool:
        b = self.b
        await b.goto(f"{self.base_url}/messages")
        await b._idle()
        conv = b.page.locator(
            ".messages-conversations a, .conversation-item, .message-item, .messages-list a"
        )
        if await conv.count() == 0:
            self._log("Messages: no conversations")
            return True
        try:
            await conv.first.click(timeout=3000)
            await b._idle(1.0, 2.0)
        except Exception as e:
            logger.debug("_check_messages open failed: %s", e)
            return True
        if random.random() < 0.7:
            return await self._compose_message(reply=True)
        self._log("Messages: read conversation")
        return True

    async def _spoke_last_in_thread(self) -> bool:
        bubbles = self.b.page.locator(".messages-thread .message-bubble")
        count = await bubbles.count()
        if count == 0:
            return False
        try:
            classes = await bubbles.nth(count - 1).get_attribute("class") or ""
        except Exception as e:
            logger.debug("_spoke_last_in_thread read failed: %s", e)
            return False
        return "mine" in classes.split()

    async def _compose_message(self, reply: bool = False) -> bool:
        b = self.b
        box = b.page.locator(
            "form[action*='/messages/send'] input[name='content'], "
            "input[name='content'][placeholder*='message'], "
            "form[action*='/messages/send'] textarea[name='content']"
        )
        if await box.count() == 0:
            self._log("Message: compose box not found")
            return False
        if await self._spoke_last_in_thread():
            self._log("Message: I spoke last; waiting for a reply (dialog, not monologue)")
            return False
        context = ""
        try:
            context = (
                await b.text(".messages-thread") or await b.text(".message-list") or ""
            )[:400]
        except Exception:
            context = ""
        text = await self._generate(self.llm.generate_dm, self.state.persona, context)
        if not text:
            return False
        text = text[:500]
        if not await self._type_like_human(
            "form[action*='/messages/send'] input[name='content'], input[name='content'], "
            "form[action*='/messages/send'] textarea[name='content']",
            text,
        ):
            return False
        await b._idle(0.4, 1.0)
        if await b.click(
            "button.messages-send-btn, form[action*='/messages/send'] button[type='submit']"
        ):
            self.state.messages_sent += 1
            self._action(
                "MESSAGE",
                f"{'replied' if reply else 'sent'} [{self.state.messages_sent}]: {text[:50]}",
            )
            await asyncio.sleep(2)
            return True
        return False

    async def _send_message(self) -> bool:
        b = self.b
        curl = await b.url()
        if "/profile/" not in curl:
            return False
        btn = b.page.locator("a[href*='with_uid=']").first
        if await btn.count() == 0:
            return False
        href = await btn.get_attribute("href") or ""
        if "with_uid=" not in href:
            return False
        try:
            await b.goto((self.base_url + href) if href.startswith("/") else href)
            await b._idle(1.0, 2.0)
        except Exception as e:
            logger.debug("_send_message goto failed: %s", e)
            return False
        return await self._compose_message(reply=False)

    async def _scroll_page(self) -> None:
        await self.b.scroll(random.randint(300, 900))
        self._log("Scrolled down")

    async def _random_nav_click(self) -> bool:
        b = self.b
        nav_links = b.page.locator(".topnav-links a, .topnav a, a.topnav-link")
        count = await nav_links.count()
        if count == 0:
            nav_links = b.page.locator(
                "a[href='/feed'], a[href='/projects'], a[href='/messages'], a[href='/notifications']"
            )
            count = await nav_links.count()
        if count == 0:
            return False
        picked = random.randrange(count)
        try:
            text = await nav_links.nth(picked).inner_text()
            href = await nav_links.nth(picked).get_attribute("href")
            await nav_links.nth(picked).click(timeout=5000)
            await b._idle(1.0, 2.0)
            self._log(f"Nav: {text.strip()[:30] if text else href}")
            return True
        except Exception as e:
            logger.debug("_random_nav_click failed: %s", e)
            return False

    async def _browse_section(self) -> bool:
        section, query = random.choice(
            [
                ("feed", f"?tab={random.choice(['trending', 'recent', 'following'])}"),
                ("feed", f"?topic={random.choice(FEED_TOPICS)}"),
                ("projects", f"?type={random.choice(PROJECT_TYPES)}"),
                ("gists", f"?language={random.choice(GIST_LANGUAGES)}"),
            ]
        )
        await self.b.goto(f"{self.base_url}/{section}{query}")
        self._action("BROWSE", f"/{section}{query}")
        await self.b._idle(0.8, 1.8)
        return True

    async def _search(self, section: str = "feed") -> bool:
        terms = list(
            SEARCH_TERMS.get(self.state.persona, ["python", "rust", "ai", "web"])
        )
        for href in self.state.known_posts[-10:]:
            slug = href.rsplit("/", 1)[-1]
            words = [w for w in re.split(r"[-_]", slug) if len(w) > 4]
            if words:
                terms.append(random.choice(words))
        term = random.choice(terms)
        await self.b.goto(f"{self.base_url}/{section}?search={term.replace(' ', '+')}")
        self.state.searches_run += 1
        self._action("SEARCH", f"/{section} '{term}'")
        await self.b._idle(0.8, 1.8)
        return True

    async def _engage_community(self) -> bool:
        b = self.b
        tab = random.choice(["recent", "recent", "trending"])
        await b.goto(f"{self.base_url}/feed?tab={tab}")
        await b._idle(0.8, 1.8)
        links = b.page.locator("a[href*='/posts/']")
        count = await links.count()
        if count == 0:
            return False
        own = set(self.state.own_post_urls)
        known = {u.lower() for u in self.state.known_users if u}
        me = self.state.username.lower()
        ranked: list[tuple[float, int, str]] = []
        for i in range(min(count, 30)):
            link = links.nth(i)
            href = await link.get_attribute("href") or ""
            if "/posts/" not in href or len(href) < 20:
                continue
            if any(href in u for u in own):
                continue
            score = random.random()
            try:
                card = link.locator(
                    "xpath=ancestor::*[contains(@class,'post-card') or self::article][1]"
                ).first
                if await card.count() > 0:
                    author = card.locator("a[href*='/profile/']").first
                    if await author.count() > 0:
                        ah = await author.get_attribute("href") or ""
                        uname = (
                            ah.split("/profile/")[-1].split("?")[0].split("/")[0].lower()
                        )
                        if uname and uname == me:
                            continue
                        if uname in known:
                            score += 2.0
            except Exception:
                pass
            ranked.append((score, i, href))
        if not ranked:
            return False
        ranked.sort(key=lambda r: r[0], reverse=True)
        idx, href = ranked[0][1], ranked[0][2]
        try:
            await links.nth(idx).click(timeout=5000)
        except Exception as e:
            logger.debug("_engage_community open failed: %s", e)
            return False
        await b._idle(1.0, 2.0)
        if href and href not in self.state.known_posts:
            self.state.known_posts.append(href)
        await self._read_like_human(3, 60)
        acted = False
        if await self._comment_on_post():
            acted = True
        await b._idle(0.5, 1.5)
        if random.random() < 0.6 and await self._reply_to_random_comment():
            acted = True
        await b._idle(0.4, 1.2)
        if random.random() < 0.7:
            await self._vote_on_feed()
        if random.random() < 0.5:
            await self._react_to_object()
        if acted:
            self._action("ENGAGE", f"community thread {href[-40:]}")
        return acted

    async def _cycle(
        self,
        mood: str = "normal",
        *,
        session_posted: bool = False,
        session_projects: int = 0,
        session_follows: int = 0,
        session_gists: int = 0,
        can_project: bool = False,
        can_post: bool = False,
        can_message: bool = False,
    ) -> tuple[int, bool, int, int, int]:
        b = self.b
        curl = await b.url()
        page_text = (await b.html()).lower()

        on_post = "/posts/" in curl
        on_profile = "/profile/" in curl
        on_project_detail = "/projects/" in curl
        on_projects_list = bool(re.search(r"/projects(\?|$)", curl))
        on_gist_detail = "/gists/" in curl
        on_gists_list = bool(re.search(r"/gists(\?|$)", curl))
        on_news_detail = "/news/" in curl
        on_news_list = bool(re.search(r"/news(\?|$)", curl))
        on_notifications = "/notifications" in curl

        actions = 0
        did_post = session_posted
        projs = session_projects
        follows = session_follows
        gists = session_gists
        msgs = 0
        mood_factor = {"lurking": 0.3, "casual": 0.6, "normal": 1.0, "deep": 1.5}

        burst = max(1, int(random.gauss(3, 1) * mood_factor[mood]))
        for _ in range(burst):
            if mood == "lurking" and not on_post and random.random() < 0.4:
                await self._scroll_page()
                await b._idle(0.3, 1.0)
                actions += 1
                curl = await b.url()
                page_text = (await b.html()).lower()
                on_post = "/posts/" in curl
                continue

            if on_post:
                page_text = (await b.html()).lower()
                own_post = await self._is_own_post()
                mentioned = own_post and f"@{self.state.username.lower()}" in page_text

                await self._read_like_human(4, 90)

                if own_post and not mentioned:
                    self._log("Own post, no mention - skipping interactions")
                    await self._random_nav_click()
                    await b._idle(1.0, 2.5)
                else:
                    comment_p = {
                        "lurking": 0.5,
                        "casual": 0.75,
                        "normal": 0.85,
                        "deep": 0.9,
                    }[mood]
                    if random.random() < comment_p:
                        if await self._comment_on_post():
                            actions += 1
                        await b._idle(0.5, 1.5)
                    if random.random() < 0.5:
                        if await self._reply_to_random_comment():
                            actions += 1
                        await b._idle(0.5, 1.5)
                    if random.random() < 0.7:
                        if await self._vote_on_feed():
                            actions += 1
                        await b._idle(0.5, 1.5)
                    if random.random() < 0.3:
                        if await self._vote_on_comments():
                            actions += 1
                        await b._idle(0.5, 1.5)
                    if random.random() < 0.6:
                        if await self._vote_on_poll():
                            actions += 1
                        await b._idle(0.5, 1.5)
                    if random.random() < 0.6:
                        if await self._react_to_object():
                            actions += 1
                        await b._idle(0.5, 1.5)
                    if random.random() < 0.75:
                        await b.goto(f"{self.base_url}/feed")
                        await b._idle(0.8, 2.0)
                    elif random.random() < 0.3:
                        await self._random_nav_click()
                        await b._idle(1.0, 2.5)

            elif on_profile:
                if not follows and random.random() < 0.4:
                    if await self._follow_user():
                        follows += 1
                        actions += 1
                    await b._idle(0.5, 1.5)
                if can_message and not msgs and random.random() < 0.3:
                    if await self._send_message():
                        msgs += 1
                        actions += 1
                    await b._idle(0.5, 1.5)
                await self._scroll_page()
                await b._idle(0.5, 1.0)
                if random.random() < 0.3:
                    if await self._click_post_link():
                        actions += 1
                await self._random_nav_click()
                await b._idle(1.0, 2.0)
                actions += 1

            elif on_news_detail:
                await self._read_like_human(4, 60)
                comment_p = {"lurking": 0.4, "casual": 0.6, "normal": 0.7, "deep": 0.8}[
                    mood
                ]
                if random.random() < comment_p:
                    if await self._comment_on_post():
                        actions += 1
                    await b._idle(0.5, 1.5)
                if random.random() < 0.7:
                    await b.goto(f"{self.base_url}/feed")
                    await b._idle(0.8, 1.8)
                else:
                    await self._random_nav_click()
                    await b._idle(1.0, 2.5)

            elif on_news_list:
                await self._scroll_page()
                await b._idle(0.4, 1.0)
                if random.random() < 0.7:
                    if await self._open_content("news"):
                        actions += 1
                else:
                    await self._random_nav_click()
                await b._idle(0.8, 1.8)
                actions += 1

            elif on_project_detail:
                await self._read_like_human(4, 60)
                if random.random() < 0.6:
                    if await self._vote_on_project():
                        actions += 1
                    await b._idle(0.5, 1.5)
                if random.random() < 0.5:
                    if await self._react_to_object():
                        actions += 1
                    await b._idle(0.5, 1.5)
                if random.random() < 0.4:
                    if await self._comment_on_post():
                        actions += 1
                    await b._idle(0.5, 1.5)
                if random.random() < 0.7:
                    await b.goto(f"{self.base_url}/projects")
                    await b._idle(0.8, 1.8)
                else:
                    await self._random_nav_click()
                    await b._idle(1.0, 2.5)

            elif on_projects_list:
                if random.random() < 0.55:
                    if await self._vote_on_project():
                        actions += 1
                    await b._idle(0.5, 1.5)
                if can_project and projs < 1 and random.random() < 0.3:
                    if await self._click_create_project():
                        projs += 1
                        actions += 1
                    await b._idle(0.5, 1.0)
                elif random.random() < 0.6:
                    if await self._open_content("project"):
                        actions += 1
                else:
                    await self._scroll_page()
                await b._idle(0.8, 1.8)
                actions += 1

            elif on_gist_detail:
                await self._read_like_human(4, 60)
                if random.random() < 0.45:
                    if await self._comment_on_post():
                        actions += 1
                    await b._idle(0.5, 1.5)
                if random.random() < 0.6:
                    if await self._vote_on_feed():
                        actions += 1
                    await b._idle(0.5, 1.5)
                if random.random() < 0.5:
                    if await self._react_to_object():
                        actions += 1
                    await b._idle(0.5, 1.5)
                if random.random() < 0.4:
                    await self._random_nav_click()
                    await b._idle(1.0, 2.5)

            elif on_gists_list:
                if random.random() < 0.6:
                    if await self._vote_on_gist():
                        actions += 1
                    await b._idle(0.5, 1.5)
                if gists < 1 and random.random() < 0.25:
                    if await self._create_gist():
                        gists += 1
                        actions += 1
                    await b._idle(0.5, 1.0)
                elif random.random() < 0.5:
                    if await self._click_gist_link():
                        actions += 1
                else:
                    await self._scroll_page()
                await b._idle(0.8, 1.8)
                actions += 1

            elif on_notifications:
                await self._check_notifications()
                await b._idle(1.0, 2.0)
                await self._random_nav_click()
                actions += 1

            else:
                clicked = False
                if random.random() < 0.3:
                    acted = await self._react_to_object()
                    if not acted:
                        acted = await self._vote_on_poll()
                    if acted:
                        actions += 1
                        clicked = True
                if not clicked and random.random() < 0.12:
                    if random.random() < 0.5:
                        await self._search(
                            "feed" if random.random() < 0.7 else "projects"
                        )
                    else:
                        await self._browse_section()
                    actions += 1
                    clicked = True
                if not clicked and random.random() < 0.85:
                    if await self._click_post_link():
                        clicked = True
                        actions += 1
                if not clicked and can_post and not did_post and random.random() < 0.5:
                    if await self._create_post():
                        did_post = True
                        actions += 1
                        clicked = True
                if not clicked and random.random() < 0.2:
                    if await self._click_profile_link():
                        clicked = True
                        actions += 1
                if not clicked and gists < 1 and random.random() < 0.1:
                    if await self._create_gist():
                        gists += 1
                        actions += 1
                        clicked = True
                if not clicked:
                    if await self._vote_on_feed():
                        actions += 1
                        clicked = True
                if not clicked:
                    if await self._random_nav_click():
                        clicked = True
                if not clicked:
                    await self._scroll_page()
                    actions += 1
                await b._idle(1.0, 2.5)

            curl = await b.url()
            page_text = (await b.html()).lower()
            on_post = "/posts/" in curl
            on_profile = "/profile/" in curl
            on_project_detail = "/projects/" in curl
            on_projects_list = bool(re.search(r"/projects(\?|$)", curl))
            on_gist_detail = "/gists/" in curl
            on_gists_list = bool(re.search(r"/gists(\?|$)", curl))
            on_news_detail = "/news/" in curl
            on_news_list = bool(re.search(r"/news(\?|$)", curl))
            on_notifications = "/notifications" in curl

        return actions, did_post, projs, follows, gists

    async def run_forever(self, action_limit: int = 0) -> None:
        b = self.b
        self._save()
        lifetime_actions = 0
        crash_streak = 0
        session_number = 0
        sessions_since_project = 99
        sessions_since_post = 99
        sessions_since_message = 99

        self._bind_monitor()

        if self._startup_jitter:
            delay = random.uniform(0, self._startup_jitter)
            self.b.note(status=f"warming up, first session in {delay / 60:.0f}m")
            self._log(f"Startup jitter: first session in {delay / 60:.1f}m")
            try:
                await asyncio.sleep(delay)
            except asyncio.CancelledError:
                self._save()
                raise

        while True:
            session_number += 1
            mood, session_len, break_range = await self._session_type()
            self._session_start_cost = self.llm.total_cost
            self._session_start_calls = self.llm.total_calls
            self._session_banner(mood, session_len)
            self._session_history = []
            self._session_energy = 0.5
            self._session_stop_after = 0
            self._session_comments = 0
            self._session_comment_cap = random.randint(
                SESSION_COMMENT_CAP_MIN, SESSION_COMMENT_CAP_MAX
            )
            self._session_mention_replies = 0
            use_ai = False

            try:
                await b.launch()
            except Exception as e:
                self._log(f"Launch failed: {e}")
                crash_streak += 1
                wait = min(30 * crash_streak, 300)
                await asyncio.sleep(wait)
                continue

            session_ok = False
            try:
                if not await self._ensure_auth():
                    self._log("Auth failed")
                    await b.close()
                    await asyncio.sleep(60)
                    continue

                await self._adopt_account_api_key()

                self._bind_monitor()

                if self._ai_decisions:
                    await self._ensure_identity()
                use_ai = self._ai_decisions and bool(self.state.identity)

                await self._warm_cache()
                self._log(f"Session active ({mood}) as {self._identity()}")

                if not self.state.profile_filled:
                    await self._update_profile()
                    await b.goto(f"{self.base_url}/feed")
                    await b._idle(0.8, 2.0)

                unread = await self._unread_notification_count()
                if unread:
                    self._log(f"Unread notifications badge: {unread}")
                check_prob = NOTIFICATION_CHECK_PROB.get(mood, 1.0)
                want_notifications = mood in ("normal", "deep") or (
                    unread > 0 and random.random() < check_prob
                )

                if mood in ("normal", "deep"):
                    await self._check_messages()
                    await b._idle(0.5, 1.5)
                if want_notifications:
                    await self._check_notifications()
                    await b._idle(1.0, 3.0)
                if mood in ("normal", "deep"):
                    await self._engage_community()
                    await b._idle(0.8, 2.0)
                await b.goto(f"{self.base_url}/feed")

                session_actions = 0
                max_actions = session_len + random.randint(0, 5)
                session_posted = False
                session_projects = 0
                session_follows = 0
                session_gists = 0
                session_messages_start = self.state.messages_sent
                sessions_since_project += 1
                sessions_since_post += 1
                sessions_since_message += 1
                can_create_project = (
                    sessions_since_project >= 3 and random.random() < 0.3
                )
                can_create_post = sessions_since_post >= 2 and random.random() < 0.5
                can_create_message = (
                    sessions_since_message >= 2 and random.random() < 0.4
                )

                while session_actions < max_actions:
                    if action_limit and lifetime_actions >= action_limit:
                        self._log(f"Hit max actions ({action_limit}), stopping")
                        self._save()
                        self._cost_summary("final")
                        return
                    (
                        taken,
                        session_posted,
                        session_projects,
                        session_follows,
                        session_gists,
                    ) = await (self._cycle_ai if use_ai else self._cycle)(
                        mood=mood,
                        session_posted=session_posted,
                        session_projects=session_projects,
                        session_follows=session_follows,
                        session_gists=session_gists,
                        can_project=can_create_project,
                        can_post=can_create_post,
                        can_message=can_create_message,
                    )
                    if session_posted:
                        sessions_since_post = 0
                    if session_projects:
                        sessions_since_project = 0
                    if self.state.messages_sent > session_messages_start:
                        sessions_since_message = 0
                    session_actions += max(taken, 1)
                    lifetime_actions += max(taken, 1)
                    self._save()
                    crash_streak = 0

                    if use_ai:
                        if (
                            self._session_stop_after
                            and session_actions >= self._session_stop_after
                        ):
                            self._log(
                                f"Energy spent (stop_after={self._session_stop_after}): ending session"
                            )
                            break
                        intra_pause = self._scaled_pause()
                    else:
                        fatigue = await self._fatigue_check(session_actions)
                        if fatigue:
                            self._log(f"Fatigue ({fatigue}): ending session")
                            break
                        intra_pause = random.randint(self._pause_min, self._pause_max)

                    self._log(
                        f"Session pause {intra_pause}s [{session_actions}/{max_actions}] {self._cost_tag()}"
                    )
                    await asyncio.sleep(intra_pause)

                session_ok = session_actions > 0
                self._session_summary(mood, session_actions)

            except asyncio.CancelledError:
                self._save()
                try:
                    await b.close()
                except Exception as e:
                    logger.debug("close on cancel failed: %s", e)
                raise
            except Exception as e:
                crash_streak += 1
                self._log(f"Session crash ({crash_streak}): {e}")
                self._save()
            finally:
                try:
                    await b.close()
                except Exception as e:
                    logger.debug("close in finally failed: %s", e)

            if crash_streak > 3:
                self._log("Too many consecutive crashes, exiting")
                self._save()
                self._cost_summary("final")
                break

            if not session_ok and crash_streak > 0:
                break_s = random.randint(30, 120)
                self._log(f"Recovery wait {break_s}s")
            else:
                break_s = max(5, int(random.randint(*break_range) * self._break_scale))
                if use_ai:
                    factor = 1.5 - 1.0 * self._session_energy
                    break_s = max(60, int(break_s * factor))
                break_h = break_s / 3600
                self._log(f"Session done. Break {break_h:.1f}h")

            self._save()
            try:
                await asyncio.sleep(break_s)
            except asyncio.CancelledError:
                self._save()
                raise

        self._cost_summary("final")
        self._log("Bot process exiting")
