from __future__ import annotations

import asyncio
import collections
import json
import logging
import os
import socket
import time
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Coroutine, Optional, Sequence

if TYPE_CHECKING:
    from .progress_stream import TurnProgressTracker
    from .state import TelegramTopicRecord

from ...agents.opencode.supervisor import OpenCodeSupervisor
from ...core.flows.models import FlowRunRecord
from ...core.flows.pause_dispatch import format_pause_reason, latest_dispatch_seq
from ...core.hub import HubSupervisor
from ...core.locks import process_alive
from ...core.logging_utils import log_event
from ...core.request_context import reset_conversation_id, set_conversation_id
from ...core.runtime_services import RuntimeServices
from ...core.state import now_iso
from ...core.state_roots import resolve_global_state_root
from ...core.text_delta_coalescer import TextDeltaCoalescer
from ...core.utils import build_opencode_supervisor
from ...flows.ticket_flow.runtime_helpers import build_ticket_flow_runtime_resources
from ...housekeeping import HousekeepingConfig, run_housekeeping_for_roots
from ...integrations.app_server.threads import (
    AppServerThreadRegistry,
    default_app_server_threads_path,
)
from ...manifest import load_manifest
from ...tickets.replies import dispatch_reply, ensure_reply_dirs, resolve_reply_paths
from ...voice import VoiceConfig, VoiceService
from ..app_server.supervisor import WorkspaceAppServerSupervisor
from ..chat.service import ChatBotServiceCore
from ..chat.update_notifier import ChatUpdateStatusNotifier
from .adapter import (
    TelegramBotClient,
    TelegramCallbackQuery,
    TelegramDocument,
    TelegramMessage,
    TelegramPhotoSize,
    TelegramUpdate,
    TelegramUpdatePoller,
)
from .chat_adapter import TelegramChatAdapter
from .chat_state_store import TelegramChatStateStore
from .chat_transport import TelegramChatTransport
from .commands_registry import build_command_payloads, diff_command_lists
from .config import (
    AppServerUnavailableError,
    TelegramBotConfig,
    TelegramBotLockError,
    TelegramMediaCandidate,
)
from .config import TelegramBotConfigError as TelegramBotConfigError  # re-export
from .constants import (
    DEFAULT_INTERRUPT_TIMEOUT_SECONDS,
    QUEUED_PLACEHOLDER_TEXT,
    TurnKey,
)
from .dispatch import dispatch_update
from .handlers import callbacks as callback_handlers
from .handlers import messages as message_handlers
from .handlers.approvals import TelegramApprovalHandlers
from .handlers.commands import build_command_specs
from .handlers.commands_runtime import TelegramCommandHandlers
from .handlers.messages import _CoalescedBuffer
from .handlers.questions import TelegramQuestionHandlers
from .handlers.selections import TelegramSelectionHandlers
from .helpers import (
    ModelOption,
    _lock_payload_summary,
    _read_lock_payload,
    _split_topic_key,
    _telegram_lock_path,
    _with_conversation_id,
)
from .notifications import TelegramNotificationHandlers
from .outbox import TelegramOutboxManager
from .runtime import TelegramRuntimeHelpers
from .state import (
    TelegramStateStore,
    TopicRouter,
    parse_topic_key,
    topic_key,
)
from .ticket_flow_bridge import (
    TelegramTicketFlowBridge,
)
from .transport import TelegramMessageTransport
from .types import (
    CompactState,
    ModelPickerState,
    PendingApproval,
    PendingQuestion,
    ReviewCommitSelectionState,
    SelectionState,
    TurnContext,
)
from .voice import TelegramVoiceManager

TICKET_FLOW_WATCH_INTERVAL_SECONDS = 20


def _build_opencode_supervisor(
    config: TelegramBotConfig,
    *,
    logger: logging.Logger,
) -> Optional[OpenCodeSupervisor]:
    opencode_command = config.opencode_command or None
    opencode_binary = config.agent_binaries.get("opencode")

    supervisor = build_opencode_supervisor(
        opencode_command=opencode_command,
        opencode_binary=opencode_binary,
        workspace_root=config.root,
        logger=logger,
        request_timeout=None,
        max_handles=config.app_server_max_handles,
        idle_ttl_seconds=config.app_server_idle_ttl_seconds,
        base_env=None,
        subagent_models=None,
    )

    if supervisor is None:
        log_event(
            logger,
            logging.INFO,
            "telegram.opencode.unavailable",
            reason="command_missing",
        )
        return None

    return supervisor


def _next_reply_seq_sync(reply_history_dir: Any) -> int:
    from pathlib import Path

    path = Path(reply_history_dir)
    if not path.exists() or not path.is_dir():
        return 1
    existing: list[int] = []
    _SEQ_RE = __import__("re").compile(r"^[0-9]{4}$")
    for child in path.iterdir():
        try:
            if not child.is_dir():
                continue
            if not _SEQ_RE.fullmatch(child.name):
                continue
            existing.append(int(child.name))
        except OSError:
            continue
    return (max(existing) + 1) if existing else 1


class TelegramBotService(
    TelegramRuntimeHelpers,
    TelegramMessageTransport,
    TelegramNotificationHandlers,
    TelegramApprovalHandlers,
    TelegramQuestionHandlers,
    TelegramSelectionHandlers,
    TelegramCommandHandlers,
):
    TICKET_FLOW_WATCH_INTERVAL_SECONDS = TICKET_FLOW_WATCH_INTERVAL_SECONDS

    def __init__(
        self,
        config: TelegramBotConfig,
        *,
        logger: Optional[logging.Logger] = None,
        hub_root: Optional[Path] = None,
        manifest_path: Optional[Path] = None,
        voice_config: Optional[VoiceConfig] = None,
        voice_service: Optional[VoiceService] = None,
        housekeeping_config: Optional[HousekeepingConfig] = None,
        update_repo_url: Optional[str] = None,
        update_repo_ref: Optional[str] = None,
        update_skip_checks: bool = False,
        update_backend: str = "auto",
        update_linux_service_names: Optional[dict[str, str]] = None,
        app_server_auto_restart: Optional[bool] = None,
    ) -> None:
        self._config = config
        self._logger = logger or logging.getLogger(__name__)
        self._hub_root = hub_root
        self._manifest_path = manifest_path
        self._hub_supervisor = None
        self._hub_thread_registry = None
        if self._hub_root:
            try:
                self._hub_supervisor = HubSupervisor.from_path(self._hub_root)
            except Exception as exc:
                log_event(
                    self._logger,
                    logging.WARNING,
                    "telegram.pma.hub_supervisor.unavailable",
                    hub_root=str(self._hub_root),
                    exc=exc,
                )
            try:
                self._hub_thread_registry = AppServerThreadRegistry(
                    default_app_server_threads_path(self._hub_root)
                )
            except Exception as exc:
                log_event(
                    self._logger,
                    logging.WARNING,
                    "telegram.pma.thread_registry.unavailable",
                    hub_root=str(self._hub_root),
                    exc=exc,
                )
        self._update_repo_url = update_repo_url
        self._update_repo_ref = update_repo_ref
        self._update_skip_checks = update_skip_checks
        self._update_backend = update_backend
        self._update_linux_service_names = update_linux_service_names or {}
        self._app_server_auto_restart = app_server_auto_restart
        self._allowlist = config.allowlist()
        self._store = TelegramStateStore(
            config.state_file, default_approval_mode=config.defaults.approval_mode
        )
        self._router = TopicRouter(self._store)
        self._app_server_state_root = resolve_global_state_root() / "workspaces"
        self._app_server_supervisor = WorkspaceAppServerSupervisor(
            config.app_server_command,
            state_root=self._app_server_state_root,
            env_builder=self._build_workspace_env,
            approval_handler=self._handle_approval_request,
            notification_handler=self._handle_app_server_notification,
            logger=self._logger,
            auto_restart=self._app_server_auto_restart,
            max_handles=config.app_server_max_handles,
            idle_ttl_seconds=config.app_server_idle_ttl_seconds,
        )
        self._opencode_supervisor = _build_opencode_supervisor(
            config,
            logger=self._logger,
        )
        self._runtime_services = RuntimeServices(
            app_server_supervisor=self._app_server_supervisor,
            opencode_supervisor=self._opencode_supervisor,
            flow_runtime_builder=build_ticket_flow_runtime_resources,
        )
        poll_timeout = float(config.poll_timeout_seconds)
        request_timeout = config.poll_request_timeout_seconds
        if request_timeout is None:
            # Keep HTTP timeout above long-poll timeout to avoid ReadTimeout churn.
            request_timeout = max(poll_timeout + 5.0, 10.0)
        self._bot = TelegramBotClient(
            config.bot_token or "",
            logger=self._logger,
            timeout_seconds=float(request_timeout),
        )
        self._poller = TelegramUpdatePoller(
            self._bot, allowed_updates=config.poll_allowed_updates
        )
        self._chat_adapter = TelegramChatAdapter(
            self._bot,
            poller=self._poller,
        )
        self._chat_transport = TelegramChatTransport(self)
        self._chat_state_store = TelegramChatStateStore(self._store)
        self._chat_core = ChatBotServiceCore(
            owner=self,
            runtime_services=self._runtime_services,
            state_store=self._chat_state_store,
            adapter=self._chat_adapter,
            transport=self._chat_transport,
        )
        self._model_options: dict[str, ModelPickerState] = {}
        self._model_pending: dict[str, ModelOption] = {}
        self._model_catalog_cache: dict[str, tuple[Any, float]] = {}
        self._agent_options: dict[str, SelectionState] = {}
        self._voice_config = voice_config
        self._voice_service = voice_service
        self._housekeeping_config = housekeeping_config
        if self._voice_service is None and voice_config is not None:
            try:
                self._voice_service = VoiceService(voice_config, logger=self._logger)
            except Exception as exc:
                log_event(
                    self._logger,
                    logging.WARNING,
                    "telegram.voice.init_failed",
                    exc=exc,
                )
        self._turn_semaphore: Optional[asyncio.Semaphore] = None
        self._turn_contexts: dict[TurnKey, TurnContext] = {}
        self._reasoning_buffers: dict[str, TextDeltaCoalescer] = {}
        self._turn_preview_text: dict[TurnKey, str] = {}
        self._turn_preview_updated_at: dict[TurnKey, float] = {}
        self._turn_progress_trackers: dict[TurnKey, "TurnProgressTracker"] = {}
        self._turn_progress_rendered: dict[TurnKey, str] = {}
        self._turn_progress_updated_at: dict[TurnKey, float] = {}
        self._turn_progress_tasks: dict[TurnKey, asyncio.Task[None]] = {}
        self._turn_progress_heartbeat_tasks: dict[TurnKey, asyncio.Task[None]] = {}
        self._turn_progress_locks: dict[TurnKey, asyncio.Lock] = {}
        self._oversize_warnings: set[TurnKey] = set()
        self._pending_approvals: dict[str, PendingApproval] = {}
        self._pending_questions: dict[str, PendingQuestion] = {}
        self._ticket_flow_pause_targets: dict[str, str] = {}
        self._ticket_flow_bridge = TelegramTicketFlowBridge(
            logger=self._logger,
            store=self._store,
            pause_targets=self._ticket_flow_pause_targets,
            send_message_with_outbox=self._send_message_with_outbox,
            send_document=self._send_document,
            pause_config=self._config.pause_dispatch_notifications,
            default_notification_chat_id=self._config.default_notification_chat_id,
            hub_root=hub_root,
            manifest_path=manifest_path,
            config_root=self._config.root,
            runtime_services=self._runtime_services,
        )
        self._resume_options: dict[str, SelectionState] = {}
        self._bind_options: dict[str, SelectionState] = {}
        self._flow_run_options: dict[str, SelectionState] = {}
        self._update_options: dict[str, SelectionState] = {}
        self._update_confirm_options: dict[str, bool] = {}
        self._review_commit_options: dict[str, ReviewCommitSelectionState] = {}
        self._review_commit_subjects: dict[str, dict[str, str]] = {}
        self._pending_review_custom: dict[str, dict[str, Any]] = {}
        self._compact_pending: dict[str, CompactState] = {}
        self._coalesced_buffers: dict[str, _CoalescedBuffer] = {}
        self._coalesce_locks: dict[str, asyncio.Lock] = {}
        self._media_batch_buffers: dict[str, message_handlers._MediaBatchBuffer] = {}
        self._media_batch_locks: dict[str, asyncio.Lock] = {}
        self._outbox_inflight: set[str] = set()
        self._outbox_lock: Optional[asyncio.Lock] = None
        self._queued_placeholder_map: dict[tuple[int, int], int] = {}
        self._queued_placeholder_timestamps: dict[tuple[int, int], float] = {}
        self._bot_username: Optional[str] = None
        self._token_usage_by_thread: "collections.OrderedDict[str, dict[str, Any]]" = (
            collections.OrderedDict()
        )
        self._token_usage_by_turn: "collections.OrderedDict[str, dict[str, Any]]" = (
            collections.OrderedDict()
        )
        self._outbox_task: Optional[asyncio.Task[None]] = None
        self._cache_cleanup_task: Optional[asyncio.Task[None]] = None
        self._ticket_flow_watch_task: Optional[asyncio.Task[None]] = None
        self._cache_timestamps: dict[str, dict[object, float]] = {}
        self._last_update_ids: dict[str, int] = {}
        self._last_update_persisted_at: dict[str, float] = {}
        self._spawned_tasks: set[asyncio.Task[Any]] = set()
        self._update_status_notifier = ChatUpdateStatusNotifier(
            platform="telegram",
            logger=self._logger,
            read_status=self._read_update_status,
            send_notice=self._send_update_status_notice,
            spawn_task=self._spawn_task,
            mark_notified=self._mark_update_notified,
            format_status=self._format_update_status_message,
            running_message="Update still running. Use /update status for the latest state.",
        )
        self._outbox_manager = TelegramOutboxManager(
            self._store,
            send_message=self._send_message,
            edit_message_text=self._edit_message_text,
            delete_message=self._delete_message,
            logger=self._logger,
        )
        self._voice_manager = TelegramVoiceManager(
            self._config,
            self._store,
            voice_config=self._voice_config,
            voice_service=self._voice_service,
            send_message=self._send_message,
            edit_message_text=self._edit_message_text,
            send_progress_message=self._send_voice_progress_message,
            deliver_transcript=self._deliver_voice_transcript,
            download_file=self._download_telegram_file,
            logger=self._logger,
        )
        self._voice_task: Optional[asyncio.Task[None]] = None
        self._housekeeping_task: Optional[asyncio.Task[None]] = None
        self._command_specs = build_command_specs(self)
        self._instance_lock_path: Optional[Path] = None

    async def _housekeeping_roots(self) -> list[Path]:
        roots: set[Path] = set()
        try:
            state = await self._store.load()
            for record in state.topics.values():
                if isinstance(record.workspace_path, str) and record.workspace_path:
                    roots.add(Path(record.workspace_path).expanduser().resolve())
        except Exception as exc:
            log_event(
                self._logger,
                logging.WARNING,
                "telegram.housekeeping.state_failed",
                exc=exc,
            )
        if self._hub_root and self._manifest_path and self._manifest_path.exists():
            try:
                manifest = load_manifest(self._manifest_path, self._hub_root)
                for repo in manifest.repos:
                    roots.add((self._hub_root / repo.path).resolve())
            except Exception as exc:
                log_event(
                    self._logger,
                    logging.WARNING,
                    "telegram.housekeeping.manifest_failed",
                    exc=exc,
                )
        if self._config.root:
            roots.add(self._config.root.resolve())
        return sorted(roots)

    async def _gather_workspace_roots(self) -> list[Path]:
        roots: set[Path] = set()
        try:
            state = await self._store.load()
            for record in state.topics.values():
                if isinstance(record.workspace_path, str) and record.workspace_path:
                    roots.add(Path(record.workspace_path).expanduser().resolve())
        except Exception as exc:
            log_event(
                self._logger,
                logging.WARNING,
                "telegram.prewarm.state_failed",
                exc=exc,
            )
        return sorted(roots)

    async def _prewarm_workspace_clients(self) -> None:
        workspace_roots = await self._gather_workspace_roots()
        if not workspace_roots:
            log_event(
                self._logger,
                logging.INFO,
                "telegram.prewarm.skipped",
                reason="no_workspaces",
            )
            return

        log_event(
            self._logger,
            logging.INFO,
            "telegram.prewarm.started",
            workspace_count=len(workspace_roots),
            workspaces=[str(p) for p in workspace_roots],
        )

        sem = asyncio.Semaphore(3)
        prewarmed_count = 0
        failed_count = 0

        async def prewarm_one(workspace_root: Path) -> None:
            nonlocal prewarmed_count, failed_count
            async with sem:
                try:
                    await self._app_server_supervisor.get_client(workspace_root)
                    prewarmed_count += 1
                    log_event(
                        self._logger,
                        logging.INFO,
                        "telegram.prewarm.client_ready",
                        workspace_root=str(workspace_root),
                    )
                except Exception as exc:
                    failed_count += 1
                    log_event(
                        self._logger,
                        logging.WARNING,
                        "telegram.prewarm.client_failed",
                        workspace_root=str(workspace_root),
                        exc=exc,
                    )

        await asyncio.gather(
            *[prewarm_one(root) for root in workspace_roots],
            return_exceptions=True,
        )

        log_event(
            self._logger,
            logging.INFO,
            "telegram.prewarm.completed",
            workspace_count=len(workspace_roots),
            prewarmed_count=prewarmed_count,
            failed_count=failed_count,
        )

    async def _housekeeping_loop(self) -> None:
        config = self._housekeeping_config
        if config is None or not config.enabled:
            return
        interval = max(config.interval_seconds, 1)
        while True:
            try:
                roots = await self._housekeeping_roots()
                if roots:
                    await asyncio.to_thread(
                        run_housekeeping_for_roots,
                        config,
                        roots,
                        self._logger,
                    )
                await self._app_server_supervisor.prune_idle()
                if self._opencode_supervisor is not None:
                    await self._opencode_supervisor.prune_idle()
            except Exception as exc:
                log_event(
                    self._logger,
                    logging.WARNING,
                    "telegram.housekeeping.failed",
                    exc=exc,
                )
            await asyncio.sleep(interval)

    def _ensure_outbox_lock(self) -> asyncio.Lock:
        loop = asyncio.get_running_loop()
        lock = self._outbox_lock
        lock_loop = getattr(lock, "_loop", None) if lock else None
        if (
            lock is None
            or lock_loop is None
            or lock_loop is not loop
            or lock_loop.is_closed()
        ):
            lock = asyncio.Lock()
            self._outbox_lock = lock
        return lock

    async def _mark_outbox_inflight(self, record_id: str) -> bool:
        lock = self._ensure_outbox_lock()
        async with lock:
            if record_id in self._outbox_inflight:
                return False
            self._outbox_inflight.add(record_id)
            return True

    async def _clear_outbox_inflight(self, record_id: str) -> None:
        lock = self._ensure_outbox_lock()
        async with lock:
            self._outbox_inflight.discard(record_id)

    def _acquire_instance_lock(self) -> None:
        token = self._config.bot_token
        if not token:
            raise TelegramBotLockError("missing telegram bot token")
        lock_path = _telegram_lock_path(token)
        payload = {
            "pid": os.getpid(),
            "started_at": now_iso(),
            "host": socket.gethostname(),
            "cwd": os.getcwd(),
            "config_root": str(self._config.root),
        }
        lock_path.parent.mkdir(parents=True, exist_ok=True)
        try:
            fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
        except FileExistsError as exc:
            existing = _read_lock_payload(lock_path)
            pid = existing.get("pid") if isinstance(existing, dict) else None
            if isinstance(pid, int) and process_alive(pid):
                log_event(
                    self._logger,
                    logging.ERROR,
                    "telegram.lock.contended",
                    lock_path=str(lock_path),
                    **_lock_payload_summary(existing),
                )
                raise TelegramBotLockError(
                    "Telegram bot already running for this token."
                ) from exc
            try:
                lock_path.unlink()
            except OSError:
                pass
            try:
                fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
            except FileExistsError as exc:
                existing = _read_lock_payload(lock_path)
                log_event(
                    self._logger,
                    logging.ERROR,
                    "telegram.lock.contended",
                    lock_path=str(lock_path),
                    **_lock_payload_summary(existing),
                )
                raise TelegramBotLockError(
                    "Telegram bot already running for this token."
                ) from exc
        with os.fdopen(fd, "w", encoding="utf-8") as handle:
            handle.write(json.dumps(payload) + "\n")
        self._instance_lock_path = lock_path
        log_event(
            self._logger,
            logging.INFO,
            "telegram.lock.acquired",
            lock_path=str(lock_path),
            **_lock_payload_summary(payload),
        )

    def _release_instance_lock(self) -> None:
        lock_path = self._instance_lock_path
        if lock_path is None:
            return
        existing = _read_lock_payload(lock_path)
        if isinstance(existing, dict):
            pid = existing.get("pid")
            if isinstance(pid, int) and pid != os.getpid():
                return
        try:
            lock_path.unlink()
        except OSError:
            pass
        self._instance_lock_path = None

    def _ensure_turn_semaphore(self) -> asyncio.Semaphore:
        if self._turn_semaphore is None:
            self._turn_semaphore = asyncio.Semaphore(
                self._config.concurrency.max_parallel_turns
            )
        return self._turn_semaphore

    async def run_polling(self) -> None:
        await self._chat_core.run()

    async def _dispatch_update(self, update: TelegramUpdate) -> None:
        await dispatch_update(self, update)

    async def _prime_bot_identity(self) -> None:
        try:
            payload = await self._bot.get_me()
        except Exception:
            return
        if isinstance(payload, dict):
            username = payload.get("username")
            if isinstance(username, str) and username:
                self._bot_username = username

    async def _register_bot_commands(self) -> None:
        registration = self._config.command_registration
        if not registration.enabled:
            log_event(
                self._logger,
                logging.DEBUG,
                "telegram.commands.disabled",
            )
            return
        desired, invalid = build_command_payloads(self._command_specs)
        if invalid:
            log_event(
                self._logger,
                logging.WARNING,
                "telegram.commands.invalid",
                invalid=invalid,
            )
        if not desired:
            log_event(
                self._logger,
                logging.WARNING,
                "telegram.commands.empty",
            )
            return
        if len(desired) > 100:
            log_event(
                self._logger,
                logging.WARNING,
                "telegram.commands.truncated",
                desired_count=len(desired),
            )
            desired = desired[:100]
        for scope_spec in registration.scopes:
            scope = scope_spec.scope
            language_code = scope_spec.language_code
            try:
                current = await self._bot.get_my_commands(
                    scope=scope,
                    language_code=language_code,
                )
            except Exception as exc:
                log_event(
                    self._logger,
                    logging.WARNING,
                    "telegram.commands.get_failed",
                    scope=scope,
                    language_code=language_code,
                    exc=exc,
                )
                continue
            diff = diff_command_lists(desired, current)
            if not diff.needs_update:
                log_event(
                    self._logger,
                    logging.DEBUG,
                    "telegram.commands.up_to_date",
                    scope=scope,
                    language_code=language_code,
                )
                continue
            try:
                updated = await self._bot.set_my_commands(
                    desired,
                    scope=scope,
                    language_code=language_code,
                )
            except Exception as exc:
                log_event(
                    self._logger,
                    logging.WARNING,
                    "telegram.commands.set_failed",
                    scope=scope,
                    language_code=language_code,
                    exc=exc,
                )
                continue
            log_event(
                self._logger,
                logging.INFO,
                "telegram.commands.updated",
                scope=scope,
                language_code=language_code,
                updated=updated,
                added=diff.added,
                removed=diff.removed,
                changed=diff.changed,
                order_changed=diff.order_changed,
            )

    async def _prime_poller_offset(self) -> None:
        last_update_id = await self._store.get_last_update_id_global()
        if not isinstance(last_update_id, int) or isinstance(last_update_id, bool):
            return
        offset = last_update_id + 1
        self._poller.set_offset(offset)
        log_event(
            self._logger,
            logging.INFO,
            "telegram.poll.offset.init",
            stored_global_update_id=last_update_id,
            poller_offset=offset,
        )

    async def _record_poll_offset(self, updates: Sequence[TelegramUpdate]) -> None:
        offset = self._poller.offset
        if offset is None:
            return
        last_update_id = offset - 1
        if last_update_id < 0:
            return
        stored = await self._store.update_last_update_id_global(last_update_id)
        max_update_id = max((update.update_id for update in updates), default=None)
        log_event(
            self._logger,
            logging.INFO,
            "telegram.poll.offset.updated",
            incoming_update_id=max_update_id,
            stored_global_update_id=stored,
            poller_offset=offset,
        )

    def _spawn_task(self, coro: Coroutine[Any, Any, Any]) -> asyncio.Task[Any]:
        task: asyncio.Task[Any] = asyncio.create_task(coro)
        self._spawned_tasks.add(task)
        task.add_done_callback(self._log_task_result)
        return task

    def _log_task_result(self, task: asyncio.Future) -> None:
        if isinstance(task, asyncio.Task):
            self._spawned_tasks.discard(task)
        try:
            task.result()
        except asyncio.CancelledError:
            return
        except Exception as exc:
            log_event(self._logger, logging.WARNING, "telegram.task.failed", exc=exc)

    def _touch_cache_timestamp(self, cache_name: str, key: object) -> None:
        cache = self._cache_timestamps.setdefault(cache_name, {})
        cache[key] = time.monotonic()

    def _evict_expired_cache_entries(self, cache_name: str, ttl_seconds: float) -> None:
        cache = self._cache_timestamps.get(cache_name)
        if not cache:
            return
        now = time.monotonic()
        expired: list[object] = []
        for key, updated_at in cache.items():
            if (now - updated_at) > ttl_seconds:
                expired.append(key)
        if not expired:
            return
        for key in expired:
            cache.pop(key, None)
            if cache_name == "reasoning_buffers":
                self._reasoning_buffers.pop(key, None)
            elif cache_name == "turn_preview":
                self._turn_preview_text.pop(key, None)
                self._turn_preview_updated_at.pop(key, None)
            elif cache_name == "progress_trackers":
                self._turn_progress_trackers.pop(key, None)
                self._turn_progress_rendered.pop(key, None)
                self._turn_progress_updated_at.pop(key, None)
                task = self._turn_progress_tasks.pop(key, None)
                if task and not task.done():
                    task.cancel()
                heartbeat_task = self._turn_progress_heartbeat_tasks.pop(key, None)
                if heartbeat_task and not heartbeat_task.done():
                    heartbeat_task.cancel()
            elif cache_name == "oversize_warnings":
                self._oversize_warnings.discard(key)
            elif cache_name == "coalesced_buffers":
                self._coalesced_buffers.pop(key, None)
                self._coalesce_locks.pop(key, None)
            elif cache_name == "media_batch_buffers":
                self._media_batch_buffers.pop(key, None)
                self._media_batch_locks.pop(key, None)
            elif cache_name == "resume_options":
                self._resume_options.pop(key, None)
            elif cache_name == "bind_options":
                self._bind_options.pop(key, None)
            elif cache_name == "flow_run_options":
                self._flow_run_options.pop(key, None)
            elif cache_name == "agent_options":
                self._agent_options.pop(key, None)
            elif cache_name == "update_options":
                self._update_options.pop(key, None)
            elif cache_name == "update_confirm_options":
                self._update_confirm_options.pop(key, None)
            elif cache_name == "review_commit_options":
                self._review_commit_options.pop(key, None)
            elif cache_name == "review_commit_subjects":
                self._review_commit_subjects.pop(key, None)
            elif cache_name == "pending_review_custom":
                self._pending_review_custom.pop(key, None)
            elif cache_name == "compact_pending":
                self._compact_pending.pop(key, None)
            elif cache_name == "model_options":
                self._model_options.pop(key, None)
            elif cache_name == "model_pending":
                self._model_pending.pop(key, None)
            elif cache_name == "pending_approvals":
                self._pending_approvals.pop(key, None)
            elif cache_name == "pending_questions":
                self._pending_questions.pop(key, None)

    async def _cache_cleanup_loop(self) -> None:
        interval = max(self._config.cache.cleanup_interval_seconds, 1.0)
        while True:
            await asyncio.sleep(interval)
            self._evict_expired_cache_entries(
                "reasoning_buffers", self._config.cache.reasoning_buffer_ttl_seconds
            )
            self._evict_expired_cache_entries(
                "turn_preview", self._config.cache.turn_preview_ttl_seconds
            )
            self._evict_expired_cache_entries(
                "progress_trackers", self._config.cache.progress_stream_ttl_seconds
            )
            self._evict_expired_cache_entries(
                "oversize_warnings", self._config.cache.oversize_warning_ttl_seconds
            )
            self._evict_expired_cache_entries(
                "coalesced_buffers", self._config.cache.coalesce_buffer_ttl_seconds
            )
            self._evict_expired_cache_entries(
                "media_batch_buffers",
                self._config.cache.media_batch_buffer_ttl_seconds,
            )
            self._evict_expired_cache_entries(
                "resume_options", self._config.cache.selection_state_ttl_seconds
            )
            self._evict_expired_cache_entries(
                "bind_options", self._config.cache.selection_state_ttl_seconds
            )
            self._evict_expired_cache_entries(
                "flow_run_options", self._config.cache.selection_state_ttl_seconds
            )
            self._evict_expired_cache_entries(
                "agent_options", self._config.cache.selection_state_ttl_seconds
            )
            self._evict_expired_cache_entries(
                "update_options", self._config.cache.selection_state_ttl_seconds
            )
            self._evict_expired_cache_entries(
                "update_confirm_options",
                self._config.cache.selection_state_ttl_seconds,
            )
            self._evict_expired_cache_entries(
                "review_commit_options",
                self._config.cache.selection_state_ttl_seconds,
            )
            self._evict_expired_cache_entries(
                "review_commit_subjects",
                self._config.cache.selection_state_ttl_seconds,
            )
            self._evict_expired_cache_entries(
                "pending_review_custom",
                self._config.cache.selection_state_ttl_seconds,
            )
            self._evict_expired_cache_entries(
                "compact_pending", self._config.cache.selection_state_ttl_seconds
            )
            self._evict_expired_cache_entries(
                "model_options", self._config.cache.selection_state_ttl_seconds
            )
            self._evict_expired_cache_entries(
                "model_pending", self._config.cache.model_pending_ttl_seconds
            )
            self._evict_expired_cache_entries(
                "pending_approvals", self._config.cache.pending_approval_ttl_seconds
            )
            self._evict_expired_cache_entries(
                "pending_questions", self._config.cache.pending_question_ttl_seconds
            )
            now = time.monotonic()
            expired_placeholders = []
            for key, timestamp in self._queued_placeholder_timestamps.items():
                if (now - timestamp) > self._config.cache.pending_approval_ttl_seconds:
                    expired_placeholders.append(key)
            for key in expired_placeholders:
                self._queued_placeholder_map.pop(key, None)
                self._queued_placeholder_timestamps.pop(key, None)

    @staticmethod
    def _parse_last_active(record: "TelegramTopicRecord") -> float:
        raw = getattr(record, "last_active_at", None)
        if isinstance(raw, str):
            try:
                return datetime.strptime(raw, "%Y-%m-%dT%H:%M:%SZ").timestamp()
            except ValueError:
                return float("-inf")
        return float("-inf")

    def _select_ticket_flow_topic(
        self, entries: list[tuple[str, "TelegramTopicRecord"]]
    ) -> Optional[tuple[str, "TelegramTopicRecord"]]:
        return self._ticket_flow_bridge._select_ticket_flow_topic(entries)

    @staticmethod
    def _set_ticket_dispatch_marker(
        value: Optional[str],
    ) -> "Callable[[TelegramTopicRecord], None]":
        def apply(topic: "TelegramTopicRecord") -> None:
            topic.last_ticket_dispatch_seq = value

        return apply

    async def _ticket_flow_watch_loop(self) -> None:
        await self._ticket_flow_bridge.watch_ticket_flow_pauses(
            TICKET_FLOW_WATCH_INTERVAL_SECONDS
        )

    async def _watch_ticket_flow_pauses(self) -> None:
        await self._ticket_flow_bridge._scan_and_notify_pauses()

    async def _notify_ticket_flow_pause(
        self,
        workspace_root: Path,
        entries: list[tuple[str, "TelegramTopicRecord"]],
    ) -> None:
        await self._ticket_flow_bridge._notify_ticket_flow_pause(
            workspace_root, entries
        )

    def _load_ticket_flow_pause(
        self, workspace_root: Path
    ) -> Optional[tuple[str, str, str, Optional[Path]]]:
        return self._ticket_flow_bridge._load_ticket_flow_pause(workspace_root)

    def _latest_dispatch_seq(self, history_dir: Path) -> Optional[str]:
        return latest_dispatch_seq(history_dir)

    def _format_ticket_flow_pause_reason(self, record: "FlowRunRecord") -> str:
        return format_pause_reason(record)

    def _get_paused_ticket_flow(
        self, workspace_root: Path, preferred_run_id: Optional[str] = None
    ) -> Optional[tuple[str, FlowRunRecord]]:
        return self._ticket_flow_bridge.get_paused_ticket_flow(
            workspace_root, preferred_run_id=preferred_run_id
        )

    async def _write_user_reply_from_telegram(
        self,
        workspace_root: Path,
        run_id: str,
        run_record: FlowRunRecord,
        message: TelegramMessage,
        text: str,
        files: Optional[list[tuple[str, bytes]]] = None,
    ) -> tuple[bool, str]:
        try:
            input_data = dict(run_record.input_data or {})
            runs_dir_raw = input_data.get("runs_dir")
            runs_dir = (
                Path(runs_dir_raw)
                if isinstance(runs_dir_raw, str) and runs_dir_raw
                else Path(".codex-autorunner/runs")
            )
            reply_paths = resolve_reply_paths(
                workspace_root=workspace_root, runs_dir=runs_dir, run_id=run_id
            )
            ensure_reply_dirs(reply_paths)

            cleaned_text = text.strip()
            raw = cleaned_text
            if raw and not raw.endswith("\n"):
                raw += "\n"

            await asyncio.to_thread(
                reply_paths.user_reply_path.write_text, raw, encoding="utf-8"
            )

            if files:
                for filename, data in files:
                    safe_name = Path(filename).name
                    if not safe_name or safe_name in {".", ".."}:
                        safe_name = "attachment"
                    dest = reply_paths.reply_dir / safe_name
                    dest.parent.mkdir(parents=True, exist_ok=True)
                    await asyncio.to_thread(dest.write_bytes, data)

            seq = await asyncio.to_thread(
                lambda: _next_reply_seq_sync(reply_paths.reply_history_dir)
            )
            dispatch, errors = await asyncio.to_thread(
                dispatch_reply, reply_paths, next_seq=seq
            )
            if errors:
                return False, "\n".join(errors)
            if dispatch is None:
                return False, "Failed to archive reply"
            return (
                True,
                f"Reply archived (seq {dispatch.seq}). Use /flow resume to continue.",
            )
        except Exception as exc:
            self._logger.warning(
                "Failed to write USER_REPLY.md from Telegram",
                exc=exc,
                workspace_root=str(workspace_root),
                run_id=run_id,
            )
            return False, f"Failed to write reply: {exc}"

    async def _interrupt_timeout_check(
        self, key: str, turn_id: str, message_id: int
    ) -> None:
        await asyncio.sleep(DEFAULT_INTERRUPT_TIMEOUT_SECONDS)
        runtime = self._router.runtime_for(key)
        if runtime.current_turn_id != turn_id:
            return
        if runtime.interrupt_message_id != message_id:
            return
        if runtime.interrupt_turn_id != turn_id:
            return
        chat_id, _thread_id = _split_topic_key(key)
        await self._edit_message_text(
            chat_id,
            message_id,
            "Still stopping... (30s). If this is stuck, try /interrupt again.",
        )
        runtime.interrupt_requested = False

    async def _dispatch_interrupt_request(
        self,
        *,
        turn_id: str,
        codex_thread_id: Optional[str],
        runtime: Any,
        chat_id: int,
        thread_id: Optional[int],
    ) -> None:
        key = await self._resolve_topic_key(chat_id, thread_id)
        record = await self._router.get_topic(key)
        turn_ctx = self._resolve_turn_context(turn_id, thread_id=codex_thread_id)
        if turn_ctx is not None and turn_ctx.topic_key and turn_ctx.topic_key != key:
            scoped_record = await self._router.get_topic(turn_ctx.topic_key)
            if scoped_record is not None:
                key = turn_ctx.topic_key
                record = scoped_record
        workspace_path = record.workspace_path if record else None
        if record and getattr(record, "pma_enabled", False):
            # PMA topics are hub-scoped; their stored topic records may not carry a
            # workspace path. Turn execution patches workspace_path to hub_root, but
            # interrupt dispatch must do the same so /interrupt works in PMA mode.
            hub_root = getattr(self, "_hub_root", None)
            if hub_root is not None:
                workspace_path = str(hub_root)
        if record and record.agent == "opencode":
            session_id = record.active_thread_id or codex_thread_id
            if (
                not session_id
                or self._opencode_supervisor is None
                or not isinstance(workspace_path, str)
                or not workspace_path
            ):
                runtime.interrupt_requested = False
                if runtime.interrupt_message_id is not None:
                    await self._edit_message_text(
                        chat_id,
                        runtime.interrupt_message_id,
                        "Interrupt failed (OpenCode unavailable).",
                    )
                    runtime.interrupt_message_id = None
                    runtime.interrupt_turn_id = None
                return
            try:
                client = await self._opencode_supervisor.get_client(
                    Path(workspace_path)
                )
                await client.abort(session_id)
            except Exception as exc:
                log_event(
                    self._logger,
                    logging.WARNING,
                    "telegram.interrupt.failed",
                    chat_id=chat_id,
                    thread_id=thread_id,
                    turn_id=turn_id,
                    exc=exc,
                )
                if (
                    runtime.interrupt_message_id is not None
                    and runtime.interrupt_turn_id == turn_id
                ):
                    await self._edit_message_text(
                        chat_id,
                        runtime.interrupt_message_id,
                        "Interrupt failed (OpenCode error).",
                    )
                runtime.interrupt_message_id = None
                runtime.interrupt_turn_id = None
                runtime.interrupt_requested = False
                return
            runtime.interrupt_message_id = None
            runtime.interrupt_turn_id = None
            runtime.interrupt_requested = False
            return
        try:
            client = await self._client_for_workspace(workspace_path)
        except AppServerUnavailableError:
            runtime.interrupt_requested = False
            if runtime.interrupt_message_id is not None:
                await self._edit_message_text(
                    chat_id,
                    runtime.interrupt_message_id,
                    "Interrupt failed (app-server unavailable).",
                )
                runtime.interrupt_message_id = None
                runtime.interrupt_turn_id = None
            return
        if client is None:
            runtime.interrupt_requested = False
            if runtime.interrupt_message_id is not None:
                await self._edit_message_text(
                    chat_id,
                    runtime.interrupt_message_id,
                    "Interrupt failed (app-server error).",
                )
                runtime.interrupt_message_id = None
                runtime.interrupt_turn_id = None
            return
        try:
            await client.turn_interrupt(turn_id, thread_id=codex_thread_id)
        except Exception as exc:
            log_event(
                self._logger,
                logging.WARNING,
                "telegram.interrupt.failed",
                chat_id=chat_id,
                thread_id=thread_id,
                turn_id=turn_id,
                exc=exc,
            )
            if (
                runtime.interrupt_message_id is not None
                and runtime.interrupt_turn_id == turn_id
            ):
                await self._edit_message_text(
                    chat_id,
                    runtime.interrupt_message_id,
                    "Interrupt failed (app-server error).",
                )
                runtime.interrupt_message_id = None
                runtime.interrupt_turn_id = None
            runtime.interrupt_requested = False

    async def _handle_message(self, message: TelegramMessage) -> None:
        await message_handlers.handle_message(self, message)

    def _should_bypass_topic_queue(self, message: TelegramMessage) -> bool:
        return message_handlers.should_bypass_topic_queue(self, message)

    async def _handle_edited_message(self, message: TelegramMessage) -> None:
        await message_handlers.handle_edited_message(self, message)

    async def _handle_message_inner(
        self,
        message: TelegramMessage,
        *,
        topic_key: Optional[str] = None,
        placeholder_id: Optional[int] = None,
    ) -> None:
        await message_handlers.handle_message_inner(
            self, message, topic_key=topic_key, placeholder_id=placeholder_id
        )

    def _coalesce_key_for_topic(self, key: str, user_id: Optional[int]) -> str:
        return message_handlers.coalesce_key_for_topic(self, key, user_id)

    async def _coalesce_key(self, message: TelegramMessage) -> str:
        return await message_handlers.coalesce_key(self, message)

    async def _buffer_coalesced_message(
        self,
        message: TelegramMessage,
        text: str,
        *,
        placeholder_id: Optional[int] = None,
    ) -> None:
        await message_handlers.buffer_coalesced_message(
            self, message, text, placeholder_id=placeholder_id
        )

    async def _coalesce_flush_after(self, key: str) -> None:
        window_seconds = self._config.coalesce_window_seconds
        await message_handlers.coalesce_flush_after(self, key, window_seconds)

    async def _flush_coalesced_message(self, message: TelegramMessage) -> None:
        await message_handlers.flush_coalesced_message(self, message)

    async def _flush_coalesced_key(self, key: str) -> None:
        await message_handlers.flush_coalesced_key(self, key)

    def _build_coalesced_message(self, buffer: _CoalescedBuffer) -> TelegramMessage:
        return message_handlers.build_coalesced_message(buffer)

    def _message_has_media(self, message: TelegramMessage) -> bool:
        return message_handlers.message_has_media(message)

    def _select_photo(
        self, photos: Sequence[TelegramPhotoSize]
    ) -> Optional[TelegramPhotoSize]:
        return message_handlers.select_photo(photos)

    def _document_is_image(self, document: TelegramDocument) -> bool:
        return message_handlers.document_is_image(document)

    def _select_image_candidate(
        self, message: TelegramMessage
    ) -> Optional[TelegramMediaCandidate]:
        return message_handlers.select_image_candidate(message)

    def _select_voice_candidate(
        self, message: TelegramMessage
    ) -> Optional[TelegramMediaCandidate]:
        return message_handlers.select_voice_candidate(message)

    async def _handle_media_message(
        self,
        message: TelegramMessage,
        runtime: Any,
        caption_text: str,
        *,
        placeholder_id: Optional[int] = None,
    ) -> None:
        await message_handlers.handle_media_message(
            self,
            message,
            runtime,
            caption_text,
            placeholder_id=placeholder_id,
        )

    def _with_conversation_id(
        self, message: str, *, chat_id: int, thread_id: Optional[int]
    ) -> str:
        return _with_conversation_id(message, chat_id=chat_id, thread_id=thread_id)

    async def _should_process_update(self, key: str, update_id: int) -> bool:
        if not isinstance(update_id, int):
            return True
        if isinstance(update_id, bool):
            return True
        last_id = self._last_update_ids.get(key)
        if last_id is None:
            record = None
            try:
                record = await self._store.get_topic(key)
            except Exception as exc:
                log_event(
                    self._logger,
                    logging.WARNING,
                    "telegram.update_id.load.failed",
                    exc=exc,
                    topic_key=key,
                    update_id=update_id,
                )
            last_id = record.last_update_id if record else None
            if isinstance(last_id, int) and not isinstance(last_id, bool):
                self._last_update_ids[key] = last_id
            else:
                last_id = None
        if isinstance(last_id, int) and update_id <= last_id:
            return False
        self._last_update_ids[key] = update_id
        try:
            await self._maybe_persist_update_id(key, update_id)
        except Exception as exc:
            log_event(
                self._logger,
                logging.WARNING,
                "telegram.update_id.persist.failed",
                exc=exc,
                topic_key=key,
                update_id=update_id,
            )
        return True

    async def _maybe_persist_update_id(self, key: str, update_id: int) -> None:
        now = time.monotonic()
        last_persisted = self._last_update_persisted_at.get(key, 0.0)
        if (
            now - last_persisted
        ) < self._config.cache.update_id_persist_interval_seconds:
            return

        def apply(record: "TelegramTopicRecord") -> None:
            record.last_update_id = update_id

        await self._store.update_topic(key, apply)
        self._last_update_persisted_at[key] = now

    async def _handle_callback(self, callback: TelegramCallbackQuery) -> None:
        await callback_handlers.handle_callback(self, callback)

    def _enqueue_topic_work(
        self, key: str, work: Any, *, force_queue: bool = False
    ) -> None:
        runtime = self._router.runtime_for(key)
        wrapped = self._wrap_topic_work(key, work)
        if force_queue or self._config.concurrency.per_topic_queue:
            self._spawn_task(runtime.queue.enqueue(wrapped))
        else:
            self._spawn_task(wrapped())

    async def _maybe_send_queued_placeholder(
        self, message: TelegramMessage, *, topic_key: str
    ) -> Optional[int]:
        runtime = self._router.runtime_for(topic_key)
        is_busy = runtime.current_turn_id is not None or runtime.queue.pending() > 0
        if not is_busy:
            return None
        placeholder_id = await self._send_placeholder(
            message.chat_id,
            thread_id=message.thread_id,
            reply_to=message.message_id,
            text=QUEUED_PLACEHOLDER_TEXT,
        )
        if placeholder_id is None:
            return None
        self._set_queued_placeholder(
            message.chat_id, message.message_id, placeholder_id
        )
        log_event(
            self._logger,
            logging.INFO,
            "telegram.placeholder.queued",
            topic_key=topic_key,
            chat_id=message.chat_id,
            thread_id=message.thread_id,
            message_id=message.message_id,
            placeholder_id=placeholder_id,
        )
        return placeholder_id

    def _wrap_placeholder_work(
        self,
        *,
        chat_id: int,
        placeholder_id: Optional[int],
        work: Any,
    ) -> Any:
        if placeholder_id is None:
            return work

        async def wrapped() -> Any:
            try:
                return await work()
            finally:
                await self._delete_message(chat_id, placeholder_id)

        return wrapped

    def _claim_queued_placeholder(self, chat_id: int, message_id: int) -> Optional[int]:
        placeholder_id = self._queued_placeholder_map.pop((chat_id, message_id), None)
        self._queued_placeholder_timestamps.pop((chat_id, message_id), None)
        return placeholder_id

    def _get_queued_placeholder(self, chat_id: int, message_id: int) -> Optional[int]:
        return self._queued_placeholder_map.get((chat_id, message_id))

    def _set_queued_placeholder(
        self, chat_id: int, message_id: int, placeholder_id: int
    ) -> None:
        self._queued_placeholder_map[(chat_id, message_id)] = placeholder_id
        self._queued_placeholder_timestamps[(chat_id, message_id)] = time.monotonic()

    def _clear_queued_placeholder(self, chat_id: int, message_id: int) -> None:
        self._queued_placeholder_map.pop((chat_id, message_id), None)
        self._queued_placeholder_timestamps.pop((chat_id, message_id), None)

    def _wrap_topic_work(self, key: str, work: Any) -> Any:
        conversation_id = None
        try:
            chat_id, thread_id, _scope = parse_topic_key(key)
            conversation_id = topic_key(chat_id, thread_id)
        except Exception:
            conversation_id = None

        if not conversation_id:
            return work

        async def wrapped() -> Any:
            token = set_conversation_id(conversation_id)
            try:
                return await work()
            finally:
                reset_conversation_id(token)

        return wrapped
