from __future__ import annotations

import asyncio
import json
import os
import re
import shutil
import subprocess
import tempfile
from uuid import uuid4
from collections import defaultdict, deque
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any

import discord
import yaml
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from deepagents import create_deep_agent
from deepagents.backends import FilesystemBackend
from deepagents.backends.composite import CompositeBackend
from deepagents.backends.protocol import EditResult, FileUploadResponse, WriteResult
from dotenv import load_dotenv
from langchain.chat_models import init_chat_model
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage

from .builtin_skills import BUILTIN_HOME_DIRNAME, sync_builtin_skills_home
from .config import (
    DEFAULT_CONFIG,
    DEFAULT_MODEL,
    DEFAULT_MODEL_PROVIDER,
    DEFAULT_PRE_COMMIT_SCRIPT,
    DEFAULT_SCHEDULER,
    STATE_DIR_NAME,
    AppConfig,
    DEFAULT_MODEL_MAX_RETRIES,
    RepoLayout,
    bootstrap_home_repo,
    load_config,
)
from .mcp_client import MCPManager
from .phone_book import load_phone_book
from .discord import (
    DISCORD_HISTORY_REFRESH_LIMIT,
    DISCORD_MESSAGE_CHAR_LIMIT,
    ERROR_REACTION_EMOJI,
    WARNING_REACTION_EMOJI,
    DiscordBridge,
    DiscordMixin,
    _chunk_discord_message,
)
from .models import AgentEvent
from .prompts import DEFAULT_CHECKPOINT, SYSTEM_PROMPT, render_folders_section, render_turn_prompt
from .readonly_backend import BUILTIN_SKILLS_ROUTE, WriteGuardBackend, build_builtin_skills_backend
from .scheduler import SchedulerJob, SchedulerMixin
from .supervisor import Supervisor
from .tools import (
    SEND_MESSAGE_LOOP_HARD_LIMIT,
    SEND_MESSAGE_LOOP_SIMILARITY_THRESHOLD,
    SEND_MESSAGE_LOOP_SOFT_LIMIT,
    SendMessageCircuitBreakerStop,
    ToolsMixin,
)
from .web_ui import WebChatMixin

UTC = timezone.utc
LOG_ROLL_BYTES = 1_000_000
TRANSIENT_PROVIDER_STATUS_CODES = frozenset({408, 409, 429, 500, 502, 503, 504, 529})


def utc_now_iso() -> str:
    return datetime.now(tz=UTC).isoformat()


def _roll_if_needed(path: Path, max_bytes: int = LOG_ROLL_BYTES) -> None:
    if not path.exists():
        return
    if path.stat().st_size <= max_bytes:
        return
    stamp = datetime.now(tz=UTC).strftime("%Y%m%dT%H%M%SZ")
    path.rename(path.with_suffix(f"{path.suffix}.{stamp}"))


def _append_jsonl(path: Path, record: dict[str, Any]) -> None:
    _roll_if_needed(path)
    with path.open("a", encoding="utf-8") as f:
        f.write(json.dumps(record, ensure_ascii=True, default=str) + "\n")


def _tail_jsonl(path: Path, count: int) -> list[dict[str, Any]]:
    if count <= 0 or not path.exists():
        return []
    lines: deque[dict[str, Any]] = deque(maxlen=count)
    with path.open("r", encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if not line:
                continue
            try:
                lines.append(json.loads(line))
            except json.JSONDecodeError:
                continue
    return list(lines)


def _slugify(value: str) -> str:
    slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
    return slug or "block"


def _model_for_deep_agents(model_name: str) -> str:
    cleaned = model_name.strip()
    if ":" in cleaned:
        return cleaned
    return f"{DEFAULT_MODEL_PROVIDER}:{cleaned}"


def _build_chat_model(model_name: str, *, max_retries: int = DEFAULT_MODEL_MAX_RETRIES) -> Any:
    # Keep the same OpenAI initialization behavior as deepagents while making
    # provider retries explicit in open-strix config.
    model_init_params: dict[str, Any] = {
        "max_retries": max(0, int(max_retries)),
    }
    if model_name.startswith("openai:"):
        model_init_params["use_responses_api"] = True
    return init_chat_model(model_name, **model_init_params)


def _web_ui_url(host: str, port: int) -> str:
    display_host = host.strip() or "127.0.0.1"
    if display_host == "0.0.0.0":
        display_host = "127.0.0.1"
    return f"http://{display_host}:{port}/"


def _exception_status_code(exc: Exception) -> int | None:
    status_code = getattr(exc, "status_code", None)
    if isinstance(status_code, int):
        return status_code
    if isinstance(status_code, str):
        stripped = status_code.strip()
        if stripped.isdigit():
            return int(stripped)
    return None


def _exception_request_id(exc: Exception) -> str | None:
    request_id = getattr(exc, "request_id", None)
    if request_id is None:
        return None
    text = str(request_id).strip()
    return text or None


def _error_log_fields(exc: Exception) -> dict[str, Any]:
    payload: dict[str, Any] = {"error_class": type(exc).__name__}
    status_code = _exception_status_code(exc)
    if status_code is not None:
        payload["error_status_code"] = status_code
    request_id = _exception_request_id(exc)
    if request_id is not None:
        payload["provider_request_id"] = request_id
    return payload


def _is_transient_provider_error(exc: Exception) -> bool:
    status_code = _exception_status_code(exc)
    if status_code in TRANSIENT_PROVIDER_STATUS_CODES:
        return True
    if status_code is not None and status_code >= 500:
        return True

    error_name = type(exc).__name__.lower()
    if error_name in {
        "apiconnectionerror",
        "apitimeouterror",
        "connecterror",
        "readtimeout",
        "timeoutexception",
    }:
        return True

    raw = str(exc).lower()
    return (
        "connection error" in raw
        or "timed out" in raw
        or "temporarily unavailable" in raw
    )


def _should_react_to_error(event: "AgentEvent") -> bool:
    return bool(event.channel_id) and bool(event.author)


def _humanize_local_web_error(exc: Exception) -> str:
    raw = str(exc).strip() or type(exc).__name__
    if "Could not resolve authentication method" in raw:
        return (
            "I couldn't reach the configured model because no API credentials are set. "
            "Add `ANTHROPIC_API_KEY` to `.env` and set `ANTHROPIC_BASE_URL` if you're not using the default MiniMax endpoint, then try again."
        )
    if _is_transient_provider_error(exc):
        request_id = _exception_request_id(exc)
        request_suffix = f" Provider request ID: {request_id}." if request_id else ""
        return (
            "The model provider returned a temporary server or network error while processing that message. "
            "Please retry in a moment."
            f"{request_suffix}"
        )
    if len(raw) > 280:
        raw = raw[:280].rstrip() + "..."
    return (
        "I hit an internal error while processing that message. "
        f"Check `logs/events.jsonl` for details. Error: {raw}"
    )


def _skill_name_from_file(path: Path) -> str:
    try:
        text = path.read_text(encoding="utf-8")
    except OSError:
        return path.parent.name

    lines = text.splitlines()
    if len(lines) < 3 or lines[0].strip() != "---":
        return path.parent.name

    end_idx = -1
    for idx in range(1, len(lines)):
        if lines[idx].strip() == "---":
            end_idx = idx
            break
    if end_idx <= 1:
        return path.parent.name

    frontmatter = "\n".join(lines[1:end_idx])
    try:
        parsed = yaml.safe_load(frontmatter)
    except yaml.YAMLError:
        return path.parent.name
    if isinstance(parsed, dict):
        name = str(parsed.get("name", "")).strip()
        if name:
            return name
    return path.parent.name


def _git_sync(home: Path) -> str:
    git_dir = home / ".git"
    if not git_dir.exists():
        return "skip: not a git repo"
    add_proc = subprocess.run(
        ["git", "add", "-A"],
        cwd=home,
        capture_output=True,
        text=True,
        check=False,
    )
    if add_proc.returncode != 0:
        return f"git add failed: {add_proc.stderr.strip()}"

    status_proc = subprocess.run(
        ["git", "status", "--porcelain"],
        cwd=home,
        capture_output=True,
        text=True,
        check=False,
    )
    if status_proc.returncode != 0:
        return f"git status failed: {status_proc.stderr.strip()}"
    if not status_proc.stdout.strip():
        return "clean: no changes"

    commit_proc = subprocess.run(
        ["git", "commit", "-m", f"open-strix auto-commit {utc_now_iso()}"],
        cwd=home,
        capture_output=True,
        text=True,
        check=False,
    )
    if commit_proc.returncode != 0:
        return f"git commit failed: {commit_proc.stderr.strip()}"

    push_proc = subprocess.run(
        ["git", "push"],
        cwd=home,
        capture_output=True,
        text=True,
        check=False,
    )
    if push_proc.returncode != 0:
        return f"git push failed: {push_proc.stderr.strip()}"

    return "ok: committed and pushed"


def _cleanup_old_sessions(sessions_dir: Path, retention_days: int) -> int:
    """Remove session log directories older than retention_days."""
    if not sessions_dir.exists():
        return 0
    cutoff = datetime.now(tz=UTC) - timedelta(days=retention_days)
    removed = 0
    for entry in sessions_dir.iterdir():
        if not entry.is_dir():
            continue
        # Session dirs are named like "20260226T013800Z-abcd1234".
        # Parse the timestamp prefix to determine age.
        name = entry.name
        timestamp_part = name.split("-")[0] if "-" in name else name
        try:
            dir_time = datetime.strptime(timestamp_part, "%Y%m%dT%H%M%SZ").replace(tzinfo=UTC)
        except ValueError:
            continue
        if dir_time < cutoff:
            shutil.rmtree(entry, ignore_errors=True)
            removed += 1
    return removed


class OpenStrixApp(DiscordMixin, SchedulerMixin, ToolsMixin, WebChatMixin):
    def __init__(self, home: Path) -> None:
        self.home = home.resolve()
        self.layout = RepoLayout(home=self.home, state_dir_name=STATE_DIR_NAME)
        bootstrap_home_repo(self.layout, checkpoint_text=DEFAULT_CHECKPOINT)
        self.config = load_config(self.layout)
        if self.config.disable_builtin_skills:
            sync_builtin_skills_home(
                self.home, disabled_skills=self.config.disable_builtin_skills,
            )
        load_dotenv(dotenv_path=self.layout.env_file, override=False)
        self.tavily_api_key = os.getenv("TAVILY_API_KEY", "").strip()
        self.tavily_search_url = os.getenv("TAVILY_SEARCH_URL", "").strip()
        self.web_search_enabled = bool(self.tavily_api_key)
        if not self.web_search_enabled:
            print(
                "[open-strix] warning: TAVILY_API_KEY is not set; web_search tool is disabled.",
                flush=True,
            )

        self.queue: asyncio.Queue[AgentEvent] = asyncio.Queue()
        self.scheduler = AsyncIOScheduler(timezone=UTC)
        self.pending_scheduler_keys: set[str] = set()
        self.current_channel_id: str | None = None
        self.session_id = f"{datetime.now(tz=UTC).strftime('%Y%m%dT%H%M%SZ')}-{uuid4().hex[:8]}"

        self.message_history_all: deque[dict[str, Any]] = deque(maxlen=500)
        self.message_history_by_channel: dict[str, deque[dict[str, Any]]] = defaultdict(
            lambda: deque(maxlen=250),
        )
        self._load_chat_history()
        # Session-scoped cache for fetched web content; cleaned up on shutdown.
        self.fetch_cache_dir = Path(tempfile.mkdtemp(prefix="fetch-cache-", dir=self.layout.logs_dir))

        self.discord_client: DiscordBridge | None = None
        self.api_runner: Any | None = None
        self.web_ui_runner: Any | None = None
        self.worker_task: asyncio.Task[Any] | None = None
        self._current_turn_sent_messages: list[tuple[str, str]] | None = None
        self.send_message_loop_soft_limit = SEND_MESSAGE_LOOP_SOFT_LIMIT
        self.send_message_loop_hard_limit = SEND_MESSAGE_LOOP_HARD_LIMIT
        self.send_message_loop_similarity_threshold = SEND_MESSAGE_LOOP_SIMILARITY_THRESHOLD
        self._send_message_last_text_normalized: str | None = None
        self._send_message_similarity_streak = 0
        self._send_message_circuit_breaker_active = False
        self._send_message_warning_reaction_sent = False

        self.phone_book = load_phone_book(self.layout.phone_book_file)
        self.supervisor = Supervisor(self.layout.state_dir / "climbers")
        self.mcp_manager: MCPManager | None = None
        self.agent = self._create_agent()

    def _load_chat_history(self) -> None:
        path = self.layout.chat_history_log
        if not path.exists():
            return

        with path.open("r", encoding="utf-8") as handle:
            for raw_line in handle:
                line = raw_line.strip()
                if not line:
                    continue
                try:
                    record = json.loads(line)
                except json.JSONDecodeError:
                    continue
                if not isinstance(record, dict):
                    continue

                record_type = str(record.get("type", "")).strip()
                if record_type == "message":
                    channel_id = str(record.get("channel_id", "")).strip()
                    author = str(record.get("author", "")).strip()
                    if not channel_id or not author:
                        continue
                    attachments = record.get("attachments")
                    self._remember_message(
                        channel_id=channel_id,
                        author=author,
                        content=str(record.get("content", "")),
                        attachment_names=[
                            str(item).strip()
                            for item in attachments
                            if str(item).strip()
                        ]
                        if isinstance(attachments, list)
                        else [],
                        message_id=str(record.get("message_id", "")).strip() or None,
                        is_bot=bool(record.get("is_bot")),
                        source=str(record.get("source", "discord")).strip() or "discord",
                        timestamp=str(record.get("timestamp", "")).strip() or None,
                        reactions=[
                            str(item).strip()
                            for item in record.get("reactions", [])
                            if str(item).strip()
                        ]
                        if isinstance(record.get("reactions"), list)
                        else [],
                        persist=False,
                    )
                    continue

                if record_type == "reaction":
                    channel_id = str(record.get("channel_id", "")).strip()
                    message_id = str(record.get("message_id", "")).strip()
                    emoji = str(record.get("emoji", "")).strip()
                    if not channel_id or not message_id or not emoji:
                        continue
                    self._apply_reaction_to_memory(
                        channel_id=channel_id,
                        message_id=message_id,
                        emoji=emoji,
                    )

    def _create_agent(self, extra_tools: list[Any] | None = None) -> Any:
        """Build the LangGraph agent with all tools."""
        mutable_backend = WriteGuardBackend(
            root_dir=self.home,
            writable_dirs=self.config.writable_dirs,
        )
        builtin_backend = build_builtin_skills_backend(root_dir=self.home / BUILTIN_HOME_DIRNAME)
        backend = CompositeBackend(
            default=mutable_backend,
            routes={BUILTIN_SKILLS_ROUTE: builtin_backend},
        )
        model_name = _model_for_deep_agents(self.config.model)
        model = _build_chat_model(
            model_name,
            max_retries=self.config.model_max_retries,
        )
        skills_sources: list[str] = []
        if self.layout.skills_dir.exists():
            skills_sources.append("/skills")
        # Keep built-ins last so packaged defaults win on name collision.
        skills_sources.append(BUILTIN_SKILLS_ROUTE.rstrip("/"))
        skills = skills_sources or None
        self._log_loaded_skills(skills_sources)

        folders_text = render_folders_section(self.config.folders)
        system_prompt = SYSTEM_PROMPT
        if folders_text:
            system_prompt = system_prompt.replace(
                "Skills:", f"{folders_text}\n\nSkills:",
            )

        tools = self._build_tools()
        if extra_tools:
            tools.extend(extra_tools)

        return create_deep_agent(
            model=model,
            tools=tools,
            system_prompt=system_prompt,
            backend=backend,
            skills=skills,
        )

    def _skill_root_for_source(self, source: str) -> Path | None:
        if source == "/skills":
            return self.layout.skills_dir
        if source == BUILTIN_SKILLS_ROUTE.rstrip("/"):
            return self.home / BUILTIN_HOME_DIRNAME
        return None

    def _skills_for_source(self, source: str) -> list[tuple[str, str]]:
        root = self._skill_root_for_source(source)
        if root is None or not root.exists():
            return []

        rows: list[tuple[str, str]] = []
        for skill_file in sorted(root.rglob("SKILL.md")):
            rel_path = skill_file.relative_to(root).as_posix()
            virtual_path = f"{source}/{rel_path}"
            skill_name = _skill_name_from_file(skill_file)
            rows.append((skill_name, virtual_path))
        return rows

    def _log_loaded_skills(self, skills_sources: list[str]) -> None:
        print(
            f"[open-strix] skills passed to deepagents: {skills_sources}",
            flush=True,
        )
        print("[open-strix] discovered skill files:", flush=True)
        for source in skills_sources:
            rows = self._skills_for_source(source)
            if not rows:
                print(f"[open-strix]   {source}: (no SKILL.md files)", flush=True)
                continue
            for skill_name, virtual_path in rows:
                print(f"[open-strix]   {skill_name} -> {virtual_path}", flush=True)

    def log_event(self, event_type: str, **payload: Any) -> None:
        record = {
            "timestamp": utc_now_iso(),
            "type": event_type,
            "session_id": self.session_id,
            **payload,
        }
        _append_jsonl(self.layout.events_log, record)
        print(json.dumps(record, ensure_ascii=True, default=str), flush=True)

    def append_journal(
        self,
        user_wanted: str,
        agent_did: str,
        predictions: str,
        channel_id: str | None = None,
    ) -> None:
        entry = {
            "timestamp": utc_now_iso(),
            "session_id": self.session_id,
            "channel_id": channel_id if channel_id is not None else self.current_channel_id,
            "user_wanted": user_wanted,
            "agent_did": agent_did,
            "predictions": predictions,
        }
        _append_jsonl(self.layout.journal_log, entry)

    def should_respond_to_bot(self, author_id: str | int | None) -> bool:
        if author_id is None:
            return False
        return str(author_id) in self.config.always_respond_bot_ids

    def should_process_discord_message(
        self,
        *,
        author_is_bot: bool,
        author_id: str | int | None,
    ) -> bool:
        if not author_is_bot:
            return True
        return self.should_respond_to_bot(author_id)

    def _iter_block_files(self) -> list[Path]:
        files = list(self.layout.blocks_dir.glob("*.yaml"))
        files.extend(self.layout.blocks_dir.glob("*.yml"))
        return sorted(files)

    def _load_memory_blocks(self) -> list[dict[str, Any]]:
        rows: list[tuple[int, str, dict[str, Any]]] = []
        for path in self._iter_block_files():
            try:
                loaded = yaml.safe_load(path.read_text(encoding="utf-8"))
            except FileNotFoundError:
                continue  # block deleted between glob and read (TOCTOU race)
            if not isinstance(loaded, dict):
                continue

            name = str(loaded.get("name", path.stem))
            text = str(loaded.get("text", ""))
            sort_raw = loaded.get("sort_order", loaded.get("sort", 0))
            try:
                sort_order = int(sort_raw)
            except (TypeError, ValueError):
                sort_order = 0

            block = {
                "id": path.stem,
                "name": name,
                "sort_order": sort_order,
                "text": text,
                "path": str(path.relative_to(self.home)),
            }
            rows.append((sort_order, name, block))

        rows.sort(key=lambda row: (row[0], row[1]))
        return [row[2] for row in rows]

    def _memory_block_path(self, block_id: str) -> Path:
        return self.layout.blocks_dir / f"{block_id}.yaml"

    def _find_memory_block_path(self, block_id: str) -> Path | None:
        candidates = [
            self.layout.blocks_dir / f"{block_id}.yaml",
            self.layout.blocks_dir / f"{block_id}.yml",
        ]
        for candidate in candidates:
            if candidate.exists():
                return candidate
        return None

    def _generate_block_id(self, preferred: str) -> str:
        block_id = _slugify(preferred)
        if self._find_memory_block_path(block_id) is None:
            return block_id
        idx = 2
        while self._find_memory_block_path(f"{block_id}-{idx}") is not None:
            idx += 1
        return f"{block_id}-{idx}"

    async def enqueue_event(self, event: AgentEvent) -> None:
        if event.dedupe_key:
            if event.dedupe_key in self.pending_scheduler_keys:
                self.log_event("event_deduped", key=event.dedupe_key)
                return
            self.pending_scheduler_keys.add(event.dedupe_key)

        await self.queue.put(event)
        self.log_event(
            "event_queued",
            source_event_type=event.event_type,
            channel_id=event.channel_id,
            scheduler_name=event.scheduler_name,
            queue_size=self.queue.qsize(),
            source_id=event.source_id,
        )

    async def _run_post_turn_git_sync(self, event: AgentEvent) -> str:
        git_result = await asyncio.to_thread(_git_sync, self.home)
        self.log_event(
            "git_sync_after_turn",
            source_event_type=event.event_type,
            channel_id=event.channel_id,
            git_sync=git_result,
        )

        if "failed:" not in git_result:
            return git_result

        sent_messages = self._current_turn_sent_messages or []
        if not sent_messages:
            return git_result

        channel_id, message_id = sent_messages[-1]
        self.log_event(
            "warning",
            where="post_turn_git_sync",
            warning_type="git_sync_failed",
            git_sync=git_result,
            channel_id=channel_id,
            message_id=message_id,
        )
        await self._react_to_message(
            channel_id=channel_id,
            message_id=message_id,
            emoji=WARNING_REACTION_EMOJI,
        )
        return git_result

    def _render_prompt(self, event: AgentEvent) -> str:
        journal_entries = _tail_jsonl(
            self.layout.journal_log,
            self.config.journal_entries_in_prompt,
        )
        blocks = self._load_blocks_for_prompt()
        recent_candidates = [
            item
            for item in self.message_history_all
            if item.get("source") in {"discord", "web", "stdin"}
        ]
        if event.channel_id:
            channel_recent = [
                item
                for item in recent_candidates
                if item.get("channel_id") == event.channel_id
            ]
            if channel_recent:
                recent_candidates = channel_recent
        recent_messages = recent_candidates[-self.config.discord_messages_in_prompt :]

        return render_turn_prompt(
            journal_entries=journal_entries,
            memory_blocks=blocks,
            recent_messages=recent_messages,
            current_event={
                "event_type": event.event_type,
                "prompt": event.prompt,
                "channel_id": event.channel_id,
                "channel_name": event.channel_name,
                "channel_conversation_type": event.channel_conversation_type,
                "channel_visibility": event.channel_visibility,
                "author": event.author,
                "attachment_names": event.attachment_names,
                "scheduler_name": event.scheduler_name,
                "source_id": event.source_id,
                "source_platform": event.source_platform,
            },
        )

    def _load_blocks_for_prompt(self) -> list[dict[str, Any]]:
        blocks = self._load_memory_blocks()
        return [
            {
                "id": block["id"],
                "name": block["name"],
                "sort_order": block["sort_order"],
                "text": block["text"],
            }
            for block in blocks
        ]

    async def _event_worker(self) -> None:
        while True:
            event = await self.queue.get()
            self.current_channel_id = event.channel_id
            try:
                await self._process_event(event)
            except SendMessageCircuitBreakerStop as exc:
                self.log_event(
                    "warning",
                    where="event_worker",
                    warning_type="send_message_loop_hard_stop",
                    source_event_type=event.event_type,
                    channel_id=event.channel_id,
                    error=str(exc),
                    **_error_log_fields(exc),
                )
            except Exception as exc:
                reacted = False
                if _should_react_to_error(event):
                    reacted = await self._react_to_latest_message(
                        channel_id=event.channel_id,
                        emoji=ERROR_REACTION_EMOJI,
                        include_bot=False,
                    )
                error_message_sent = False
                if self.is_local_web_channel(event.channel_id):
                    error_message_sent = await self._send_local_web_error_message(event, exc)
                self.log_event(
                    "error",
                    where="event_worker",
                    source_event_type=event.event_type,
                    error=str(exc),
                    reacted_to_last_user_message=reacted,
                    error_message_sent=error_message_sent,
                    **_error_log_fields(exc),
                )
            finally:
                if event.dedupe_key:
                    self.pending_scheduler_keys.discard(event.dedupe_key)
                self.current_channel_id = None
                self.queue.task_done()

    async def _send_local_web_error_message(self, event: AgentEvent, exc: Exception) -> bool:
        if not self.is_local_web_channel(event.channel_id):
            return False
        text = _humanize_local_web_error(exc)
        sent, _, _ = await self._send_web_message(
            channel_id=str(event.channel_id),
            text=text,
        )
        return sent

    async def _process_event(self, event: AgentEvent) -> None:
        self._current_turn_sent_messages = []
        self._reset_send_message_circuit_breaker()
        prompt = self._render_prompt(event)
        self.log_event(
            "agent_invoke_start",
            source_event_type=event.event_type,
            channel_id=event.channel_id,
            scheduler_name=event.scheduler_name,
        )
        try:
            async with self._typing_indicator(event):
                result = await self.agent.ainvoke({"messages": [HumanMessage(content=prompt)]})
            self._log_agent_trace(result)
            self._write_session_log(event, prompt, result)

            final_text = self._extract_final_text(result)
            self.log_event(
                "agent_final_message_discarded",
                source_event_type=event.event_type,
                channel_id=event.channel_id,
                final_text=final_text,
            )
            await self._run_post_turn_git_sync(event)
        finally:
            self._reset_send_message_circuit_breaker()
            self._current_turn_sent_messages = None

    def _log_agent_trace(self, result: dict[str, Any]) -> None:
        messages = result.get("messages")
        if not isinstance(messages, list):
            return
        for message in messages:
            if isinstance(message, AIMessage):
                for call in message.tool_calls:
                    self.log_event(
                        "tool_call",
                        tool=call.get("name"),
                        args=call.get("args"),
                    )

    def _write_session_log(
        self,
        event: AgentEvent,
        prompt: str,
        result: dict[str, Any],
    ) -> None:
        session_dir = self.layout.sessions_dir / self.session_id
        session_dir.mkdir(parents=True, exist_ok=True)
        timestamp = datetime.now(tz=UTC).strftime("%Y%m%dT%H%M%SZ")
        slug = re.sub(r"[^a-z0-9]+", "-", (event.event_type or "unknown").lower()).strip("-")
        filename = f"{timestamp}_{slug}.json"

        messages = result.get("messages")
        serialized_messages: list[dict[str, Any]] = []
        if isinstance(messages, list):
            for msg in messages:
                if isinstance(msg, BaseMessage):
                    try:
                        serialized_messages.append(msg.model_dump())
                    except Exception:
                        serialized_messages.append(
                            {"type": getattr(msg, "type", "unknown"), "content": str(getattr(msg, "content", ""))}
                        )
                elif isinstance(msg, dict):
                    serialized_messages.append(msg)

        record = {
            "session_id": self.session_id,
            "timestamp": utc_now_iso(),
            "event": {
                "event_type": event.event_type,
                "channel_id": event.channel_id,
                "channel_name": event.channel_name,
                "author": event.author,
                "scheduler_name": event.scheduler_name,
                "source_id": event.source_id,
            },
            "prompt": prompt,
            "messages": serialized_messages,
        }
        log_path = session_dir / filename
        try:
            log_path.write_text(
                json.dumps(record, ensure_ascii=True, default=str, indent=2) + "\n",
                encoding="utf-8",
            )
        except Exception as exc:
            self.log_event(
                "warning",
                where="write_session_log",
                warning_type="session_log_write_failed",
                error=str(exc),
            )

    def _extract_final_text(self, result: dict[str, Any]) -> str:
        messages = result.get("messages")
        if not isinstance(messages, list):
            return ""
        for message in reversed(messages):
            if not isinstance(message, AIMessage):
                continue
            content = message.content
            if isinstance(content, str):
                return content
            if isinstance(content, list):
                text_parts: list[str] = []
                for part in content:
                    if isinstance(part, dict) and part.get("type") == "text":
                        text_parts.append(str(part.get("text", "")))
                return "\n".join(text_parts).strip()
        return ""

    async def _stdin_mode(self) -> None:
        self.log_event("stdin_mode_start")
        print("No Discord token configured. Running in stdin mode.")
        while True:
            try:
                line = await asyncio.to_thread(input, "open-strix> ")
            except EOFError:
                self.log_event("stdin_mode_eof")
                return
            prompt = line.strip()
            if not prompt:
                continue
            self._remember_message(
                channel_id="stdin",
                author="local_user",
                content=prompt,
                attachment_names=[],
                message_id=None,
                source="stdin",
            )
            await self.enqueue_event(
                AgentEvent(
                    event_type="stdin_message",
                    prompt=prompt,
                    channel_id="stdin",
                    author="local_user",
                ),
            )

    async def run(self) -> None:
        # Start MCP servers and recreate agent with MCP tools if configured.
        if self.config.mcp_servers:
            self.mcp_manager = MCPManager()
            mcp_tools = await self.mcp_manager.start_servers(
                self.config.mcp_servers,
                log_fn=self.log_event,
            )
            if mcp_tools:
                self.agent = self._create_agent(extra_tools=mcp_tools)
                print(
                    f"[open-strix] Agent recreated with {len(mcp_tools)} MCP tool(s)",
                    flush=True,
                )

        self.worker_task = asyncio.create_task(self._event_worker())
        self.scheduler.start()
        self._reload_scheduler_jobs()
        self.supervisor.start_all()
        removed = _cleanup_old_sessions(
            self.layout.sessions_dir,
            self.config.session_log_retention_days,
        )
        self.log_event(
            "app_started",
            home=str(self.home),
            session_logs_cleaned=removed,
            mcp_servers=[c.config.name for c in (self.mcp_manager.connections if self.mcp_manager else [])],
        )

        if self.config.api_port > 0:
            from .api import start_api

            self.api_runner = await start_api(self, self.config.api_port)

        if self.config.web_ui_port > 0:
            from .web_ui import start_web_ui

            self.web_ui_runner = await start_web_ui(
                self,
                host=self.config.web_ui_host,
                port=self.config.web_ui_port,
            )

        token = os.getenv(self.config.discord_token_env, "")
        if token:
            self.discord_client = DiscordBridge(self)
            self.log_event("discord_connecting")
            await self.discord_client.start(token)
            return

        if self.web_ui_runner is not None:
            print(
                "No Discord token configured. Local web UI available at "
                f"{_web_ui_url(self.config.web_ui_host, self.config.web_ui_port)}",
                flush=True,
            )
            await asyncio.Event().wait()
            return

        if self.api_runner is not None:
            print(
                "No Discord token configured. API-only mode is active.",
                flush=True,
            )
            await asyncio.Event().wait()
            return

        await self._stdin_mode()

    async def shutdown(self) -> None:
        self.log_event("app_shutdown_start")
        self.supervisor.stop_all()
        if self.mcp_manager is not None:
            await self.mcp_manager.shutdown()
        if self.api_runner is not None:
            await self.api_runner.cleanup()
        if self.web_ui_runner is not None:
            await self.web_ui_runner.cleanup()
        if self.discord_client is not None and not self.discord_client.is_closed():
            await self.discord_client.close()
        if self.scheduler.running:
            self.scheduler.shutdown(wait=False)
        if self.worker_task is not None:
            self.worker_task.cancel()
            try:
                await self.worker_task
            except asyncio.CancelledError:
                pass
        if self.fetch_cache_dir.exists():
            shutil.rmtree(self.fetch_cache_dir, ignore_errors=True)
        self.log_event("app_shutdown_complete")


def run_open_strix(home: Path | None = None) -> None:
    app = OpenStrixApp(home=home or Path.cwd())

    async def _runner() -> None:
        try:
            await app.run()
        finally:
            await app.shutdown()

    try:
        asyncio.run(_runner())
    except KeyboardInterrupt:
        pass
