import asyncio
import json
import os
import shlex
import shutil
import signal
import subprocess
from collections.abc import Sequence
from contextlib import suppress
from pathlib import Path
from typing import Any

from filelock import FileLock
from filelock import Timeout as LockTimeout

from otto import sandbox
from otto.autonomy import PulseRunner
from otto.chat import Chat
from otto.config import OTTO_HOME, Config, TelegramConfig, load_config
from otto.delegation import BOARD_DIR, LOGS_DIR
from otto.gateway import Gateway, Policy
from otto.log import get_logger
from otto.log import setup as log_setup
from otto.memory import Memory
from otto.sessions import SessionStore
from otto.telegram import TelegramBot, TelegramRenderer

try:  # Optional until Discord runtime lands in later tasks.
    from otto.discord import DiscordBot
except Exception:  # pragma: no cover - optional dependency boundary
    DiscordBot = None  # type: ignore[assignment]

log = get_logger("otto.daemon")

_PID_FILE_NAME = "otto.pid"
_LOCK_FILE_NAME = "otto.lock"


def _parse_scheduler_chat_id(chat_id: str) -> tuple[str | None, int | None, int | None]:
    """Parse scheduler chat IDs from legacy and current formats.

    Supported formats:
    - "123456789"                  -> (None, 123456789, None)
    - "123456789#t987"             -> (None, 123456789, 987)
    - "main:123456789"             -> ("main", 123456789, None)
    - "default:123456789#t987"     -> ("default", 123456789, 987)
    """
    raw = str(chat_id).strip()
    if not raw:
        return None, None, None

    thread_id: int | None = None
    if "#t" in raw:
        base_raw, _, raw_thread = raw.rpartition("#t")
        if raw_thread.isdigit():
            thread_id = int(raw_thread)
            raw = base_raw

    if ":" in raw:
        alias, _, telegram_id = raw.partition(":")
        if alias and telegram_id.isdigit():
            return alias, int(telegram_id), thread_id
        return None, None, None

    if raw.isdigit():
        return None, int(raw), thread_id

    return None, None, None


def _resolve_scheduler_destination(
    chat_id: str,
    bots: Sequence[TelegramBot],
    config: Config | None = None,
) -> tuple[str | None, int | None, int | None]:
    """Return alias + numeric chat + thread id when available, including named contexts."""
    alias, telegram_chat_id, thread_id = _parse_scheduler_chat_id(chat_id)
    if telegram_chat_id is not None:
        return alias, telegram_chat_id, thread_id

    for bot in bots:
        context_map = getattr(bot, "_chat_context_to_telegram_id", {})
        mapped = context_map.get(chat_id)
        if mapped is not None:
            thread_map = getattr(bot, "_chat_context_to_thread_id", {})
            mapped_thread = thread_map.get(chat_id)
            return bot.alias, mapped, mapped_thread

    if config is not None:
        for user in getattr(config, "users", []) or []:
            if getattr(user, "name", None) != chat_id:
                continue
            telegram_id = getattr(user, "telegram_id", None)
            if isinstance(telegram_id, int):
                return alias, telegram_id, thread_id
            if isinstance(telegram_id, str) and telegram_id.isdigit():
                return alias, int(telegram_id), thread_id

    return None, None, thread_id


def _describe_loop_task(value: Any) -> str | None:
    if value is None:
        return None
    get_name = getattr(value, "get_name", None)
    if callable(get_name):
        with suppress(Exception):
            name = get_name()
            if isinstance(name, str) and name:
                return name
    return type(value).__name__


def _build_loop_exception_handler(stop_event: asyncio.Event):
    critical_tasks = {"otto.pulse_loop", "otto.cli_channel.start"}

    def _handler(_loop: Any, context: dict[str, Any]) -> None:
        exc = context.get("exception")
        future = context.get("task") or context.get("future")
        task_name = _describe_loop_task(future)
        message = str(context.get("message", "asyncio loop exception"))

        if exc is None:
            log.error(
                "unhandled asyncio loop error",
                message=message,
                task=task_name,
            )
        else:
            log.exception(
                "unhandled asyncio loop error",
                message=message,
                task=task_name,
                error=str(exc),
                error_type=type(exc).__name__,
            )

        if task_name in critical_tasks:
            log.error(
                "critical background task crashed; initiating shutdown",
                task=task_name,
            )
            stop_event.set()

    return _handler


def _normalize_workspace_mode(value: Any) -> str:
    if isinstance(value, str):
        mode = value.strip().lower()
        if mode in {"default", "strict"}:
            return mode
    return "default"


def _coerce_path(value: Any, *, fallback: Path) -> Path:
    if isinstance(value, Path):
        path = value
    elif isinstance(value, str) and value.strip():
        path = Path(value.strip())
    else:
        path = fallback

    path = path.expanduser()
    if not path.is_absolute():
        path = Path.cwd() / path
    return path


def _workspace_runtime(config: Config) -> tuple[str, Path]:
    workspace = getattr(config, "workspace", None)
    mode = _normalize_workspace_mode(getattr(workspace, "mode", "default"))
    fallback_root = _coerce_path(getattr(config, "workdir", None), fallback=Path.cwd())
    root = _coerce_path(getattr(workspace, "root", None), fallback=fallback_root)
    return mode, root


def _migrate_local_skills_to_workspace(bot_name: str, workspace_root: Path) -> Path:
    """Move legacy bot-local skills from ~/.otto/bots/<bot>/skills to <workspace>/skills."""
    legacy_dir = OTTO_HOME / "bots" / bot_name / "skills"
    workspace_skills_dir = workspace_root / "skills"
    workspace_skills_dir.mkdir(parents=True, exist_ok=True)

    if not legacy_dir.exists() or legacy_dir == workspace_skills_dir:
        return workspace_skills_dir

    moved = 0
    for entry in legacy_dir.iterdir():
        target = workspace_skills_dir / entry.name
        if target.exists():
            log.warning(
                "local skill migration skipped existing target",
                bot=bot_name,
                source=str(entry),
                target=str(target),
            )
            continue
        try:
            shutil.move(str(entry), str(target))
            moved += 1
        except OSError as exc:
            log.warning(
                "local skill migration failed",
                bot=bot_name,
                source=str(entry),
                target=str(target),
                error=str(exc),
            )

    if moved > 0:
        log.info(
            "migrated local skills to workspace",
            bot=bot_name,
            moved=moved,
            target=str(workspace_skills_dir),
        )

    try:
        if legacy_dir.exists() and not any(legacy_dir.iterdir()):
            legacy_dir.rmdir()
    except OSError:
        pass

    return workspace_skills_dir


def _resolve_owner_telegram_id(config: Config, owner_name: str | None) -> int | None:
    if not owner_name:
        return None
    for user in config.users:
        if user.name == owner_name and user.telegram_id is not None:
            return user.telegram_id
    return None


def _sandbox_warning_text(bot_name: str) -> str:
    return (
        "⚠️ Sandbox warning: bwrap is not installed. Bash is disabled in strict mode "
        f'for bot "{bot_name}". Install bubblewrap (`apt install bubblewrap`) '
        "to enable sandboxed shell execution."
    )


def _pid_file(alias: str | None = None) -> Path:
    name = f"otto.{alias}.pid" if alias else _PID_FILE_NAME
    return OTTO_HOME / name


def _lock_file(alias: str | None = None) -> Path:
    name = f"otto.{alias}.lock" if alias else _LOCK_FILE_NAME
    return OTTO_HOME / name


def _write_pid_file(path: Path) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(f"{os.getpid()}\n", encoding="utf-8")


def _read_pid(path: Path) -> int | None:
    try:
        return int(path.read_text(encoding="utf-8").strip())
    except (OSError, ValueError):
        return None


def _pid_exists(pid: int) -> bool:
    try:
        os.kill(pid, 0)
        return True
    except PermissionError:
        return True
    except ProcessLookupError:
        return False
    except OSError:
        return False


def _discover_run_pids(alias: str | None = None) -> list[int]:
    """Find all live `python -m otto.cli run` processes."""
    try:
        output = subprocess.check_output(
            ["ps", "-eo", "pid=,args="],
            text=True,
            stderr=subprocess.DEVNULL,
        )
    except Exception:
        return []

    pids: list[int] = []
    current_pid = os.getpid()

    for raw_line in output.splitlines():
        line = raw_line.strip()
        if not line:
            continue
        parts = line.split(maxsplit=1)
        if len(parts) != 2:
            continue
        pid_text, args = parts
        try:
            tokens = shlex.split(args)
        except ValueError:
            tokens = args.split()

        run_index: int | None = None
        for index in range(len(tokens) - 2):
            if (
                tokens[index] == "-m"
                and tokens[index + 1] == "otto.cli"
                and tokens[index + 2] == "run"
            ):
                run_index = index + 2
                break
        if run_index is None:
            continue

        discovered_alias = tokens[run_index + 1] if run_index + 1 < len(tokens) else None
        if alias is not None and discovered_alias != alias:
            continue
        try:
            pid = int(pid_text)
        except ValueError:
            continue
        if pid != current_pid:
            pids.append(pid)
    return pids


async def _pulse_loop(
    runner: PulseRunner,
    shutdown_event: asyncio.Event,
) -> None:
    """Simple asyncio timer that fires the pulse runner on an interval."""
    interval = runner.pulse_seconds
    while not shutdown_event.is_set():
        try:
            await runner.run_pulse()
        except Exception as exc:
            log.warning("pulse loop error: %s", exc)
        try:
            await asyncio.wait_for(shutdown_event.wait(), timeout=interval)
        except TimeoutError:
            continue


async def start_services(
    config: Config,
    bot_alias: str | None = None,
    cli_bot: str | None = None,
    cli_resume_id: str | None = None,
) -> None:
    """Start Otto services and keep running until a shutdown signal arrives.

    When *cli_bot* is set (e.g. ``"auto"``), a CLI chat channel is attached to
    the first eligible bot and runs as the foreground blocking task.  When the
    CLI channel exits (ctrl+C / ctrl+D) the entire service shuts down.
    """
    log_file = OTTO_HOME / "logs" / (f"otto.{bot_alias}.log" if bot_alias else "otto.log")
    log_setup(level=config.log_level, log_file=log_file, console=cli_bot is None)
    log.info("Starting Otto services...", bot=bot_alias or "all")
    workspace_mode, workspace_root = _workspace_runtime(config)
    runtime_workdir = (
        workspace_root
        if workspace_mode == "default"
        else _coerce_path(getattr(config, "workdir", None), fallback=workspace_root)
    )
    original_cwd = Path.cwd()
    changed_cwd = False

    bots: list[Any] = []
    discord_bots: list[Any] = []
    memories: list[Memory] = []
    gateways_to_close: list[Any] = []
    lsp_pools: list[Any] = []
    chats_by_bot: dict[str, Chat] = {}
    legacy_chat: Chat | None = None

    if getattr(config, "bots", None):
        # ------------------------------------------------------------------
        # New schema: per-bot resources — each bot gets its own Gateway
        # with workspace-scoped tools and isolated memory.
        # ------------------------------------------------------------------
        from otto.tools import create_tools

        bot_configs = config.bots
        if bot_alias:
            bot_configs = [b for b in config.bots if b.name == bot_alias]
            if not bot_configs:
                raise RuntimeError(f"Bot alias '{bot_alias}' not found in config.")

        for bot_conf in bot_configs:
            bot_home = OTTO_HOME / "bots" / bot_conf.name
            bot_home.mkdir(parents=True, exist_ok=True)

            memory = Memory(bot_home / "data" / "memory.db")
            memories.append(memory)

            ws_mode = bot_conf.workspace.mode
            ws_root_path = _coerce_path(bot_conf.workspace.root, fallback=Path.cwd())
            ws_root = str(ws_root_path)
            ws_sandbox = getattr(bot_conf.workspace, "sandbox", "none")
            ws_sandbox_network = getattr(bot_conf.workspace, "sandbox_network", True)
            local_skills_dir = _migrate_local_skills_to_workspace(bot_conf.name, ws_root_path)
            bot_skills_dirs: list[Path] = [local_skills_dir]
            include_shared = getattr(getattr(bot_conf, "skills", None), "include_shared", True)
            allow_global_in_sandbox = getattr(
                getattr(bot_conf, "skills", None), "allow_global_skills_in_sandbox", False
            )
            sandboxed = ws_sandbox != "none"
            if include_shared and (not sandboxed or allow_global_in_sandbox):
                bot_skills_dirs.append(OTTO_HOME / "skills")

            bot_gateway = Gateway(
                Policy(blocked_paths=[], max_calls_per_session=0, allowed_servers=None)
            )
            gateways_to_close.append(bot_gateway)
            try:
                from otto.lsp.pool import LSPPool

                bot_lsp_pool = LSPPool()
                lsp_pools.append(bot_lsp_pool)
            except Exception:
                bot_lsp_pool = None
            bot_gateway.add_tools(
                create_tools(
                    memory=memory,
                    workspace_mode=ws_mode,
                    workspace_root=ws_root,
                    workspace_sandbox=ws_sandbox,
                    workspace_sandbox_network=ws_sandbox_network,
                    skills_dirs=bot_skills_dirs,
                    lsp_pool=bot_lsp_pool,
                )
            )
            log.info(
                "registered per-bot tools",
                bot=bot_conf.name,
                count=len(bot_gateway.tools()),
                workspace_mode=ws_mode,
            )

            session_store = SessionStore(bot_home / "sessions")
            chat = Chat(
                config,
                bot_gateway,
                session_store,
                memory,
                bot_name=bot_conf.name,
                workspace_mode=ws_mode,
                workspace_root=ws_root,
                workspace_sandbox=ws_sandbox,
                workspace_sandbox_network=ws_sandbox_network,
                skills_dirs=bot_skills_dirs,
            )
            chats_by_bot[bot_conf.name] = chat

            for channel in bot_conf.channels:
                if channel.type == "telegram" and channel.token:
                    bots.append(
                        TelegramBot(
                            chat,
                            config,
                            bot_config=bot_conf,
                            channel_config=channel,
                        )
                    )
                elif channel.type == "discord" and channel.token:
                    if DiscordBot is None:
                        log.error(
                            "Discord channel configured but runtime is unavailable",
                            bot=bot_conf.name,
                            channel=channel.type,
                        )
                        raise RuntimeError(
                            "Discord channel is configured but otto.discord.DiscordBot is unavailable. "
                            "Install Discord runtime dependencies and retry."
                        )
                    discord_bots.append(
                        DiscordBot(
                            chat,
                            config,
                            bot_config=bot_conf,
                            channel_config=channel,
                        )
                    )

        if not sandbox.is_available():
            for bot_conf in bot_configs:
                if getattr(bot_conf.workspace, "sandbox", "none") != "bubblewrap":
                    continue

                log.warning(
                    f"Bot '{bot_conf.name}' configured with sandbox=bubblewrap but bwrap is not available. Falling back to bash-disabled."
                )

                owner_chat_id = _resolve_owner_telegram_id(config, bot_conf.auth.owner)
                if owner_chat_id is None:
                    continue

                target_bot = next(
                    (candidate for candidate in bots if candidate.alias == bot_conf.name), None
                )
                if target_bot is None:
                    continue

                target_bot.queue_startup_notification(
                    owner_chat_id,
                    _sandbox_warning_text(bot_conf.name),
                )
    else:
        # ------------------------------------------------------------------
        # Legacy schema: single shared resources from config.telegram
        # ------------------------------------------------------------------
        memory = Memory(OTTO_HOME / "data" / "memory.db")
        memories.append(memory)

        from otto.tools import create_tools

        gateway = Gateway(Policy(blocked_paths=[], max_calls_per_session=0, allowed_servers=None))
        gateways_to_close.append(gateway)
        workspace_cfg = getattr(config, "workspace", None)
        legacy_ws_sandbox = getattr(workspace_cfg, "sandbox", "none")
        legacy_ws_sandbox_network = getattr(workspace_cfg, "sandbox_network", True)
        gateway.add_tools(
            create_tools(
                memory=memory,
                workspace_mode=workspace_mode,
                workspace_root=str(workspace_root),
                workspace_sandbox=legacy_ws_sandbox,
                workspace_sandbox_network=legacy_ws_sandbox_network,
                skills_dirs=[OTTO_HOME / "skills"],
            )
        )
        log.info("registered tools", count=len(gateway.tools()))

        session_store = SessionStore(OTTO_HOME / "sessions")
        chat = Chat(
            config,
            gateway,
            session_store,
            memory,
            workspace_mode=workspace_mode,
            workspace_root=str(workspace_root),
            workspace_sandbox=legacy_ws_sandbox,
            workspace_sandbox_network=legacy_ws_sandbox_network,
            skills_dirs=[OTTO_HOME / "skills"],
        )
        legacy_chat = chat

        seen_aliases = {"main", "default"}

        if not bot_alias or bot_alias == "main":
            bots.append(TelegramBot(chat, config, config.telegram, alias="main"))

        if not bot_alias:
            for bot_conf in config.telegram.bots:
                if bot_conf.alias not in seen_aliases:
                    sub_bot_config = TelegramConfig(
                        token=bot_conf.token,
                        owner_id=config.telegram.owner_id,
                        allowed_users=config.telegram.allowed_users,
                        bootstrap=config.telegram.bootstrap,
                    )
                    bots.append(TelegramBot(chat, config, sub_bot_config, alias=bot_conf.alias))
                    seen_aliases.add(bot_conf.alias)
        elif bot_alias != "main":
            bot_conf = next((b for b in config.telegram.bots if b.alias == bot_alias), None)
            if bot_conf:
                sub_bot_config = TelegramConfig(
                    token=bot_conf.token,
                    owner_id=config.telegram.owner_id,
                    allowed_users=config.telegram.allowed_users,
                    bootstrap=config.telegram.bootstrap,
                )
                bots.append(TelegramBot(chat, config, sub_bot_config, alias=bot_alias))
            else:
                raise RuntimeError(f"Bot alias '{bot_alias}' not found in config.")

    # ------------------------------------------------------------------
    # CLI channel (foreground mode)
    # ------------------------------------------------------------------
    cli_channel: Any | None = None
    if cli_bot is not None:
        from otto.cli_chat import CLIChatChannel
        from otto.config import BotAuthConfig, BotConfig, ChannelConfig

        # Resolve which bot to attach the CLI channel to.
        _cli_chat: Chat | None = None
        _cli_bot_config: BotConfig | None = None

        if getattr(config, "bots", None):
            # New schema: look for a bot with a cli channel, or use first bot.
            target = None
            if cli_bot != "auto":
                target = next((b for b in config.bots if b.name == cli_bot), None)
            if target is None:
                # Find first bot with a cli channel type, or fall back to first bot.
                target = next(
                    (b for b in config.bots if any(c.type == "cli" for c in b.channels)),
                    None,
                )
            if target is None and config.bots:
                target = config.bots[0]
            if target is not None:
                _cli_bot_config = target
                _cli_chat = chats_by_bot.get(target.name)
                if _cli_chat is None:
                    # Build per-bot resources if not already built (e.g. bot_alias filter).
                    from otto.tools import create_tools as _ct

                    bot_home = OTTO_HOME / "bots" / target.name
                    bot_home.mkdir(parents=True, exist_ok=True)
                    _cli_memory = Memory(bot_home / "data" / "memory.db")
                    memories.append(_cli_memory)

                    _ws_mode = target.workspace.mode
                    _ws_root_path = _coerce_path(target.workspace.root, fallback=Path.cwd())
                    _ws_root = str(_ws_root_path)
                    _ws_sandbox = getattr(target.workspace, "sandbox", "none")
                    _ws_sandbox_network = getattr(target.workspace, "sandbox_network", True)
                    _local_skills_dir = _migrate_local_skills_to_workspace(
                        target.name, _ws_root_path
                    )
                    _cli_skills_dirs: list[Path] = [_local_skills_dir]
                    include_shared = getattr(
                        getattr(target, "skills", None), "include_shared", True
                    )
                    _allow_global_in_sandbox = getattr(
                        getattr(target, "skills", None), "allow_global_skills_in_sandbox", False
                    )
                    _cli_sandboxed = _ws_sandbox != "none"
                    if include_shared and (not _cli_sandboxed or _allow_global_in_sandbox):
                        _cli_skills_dirs.append(OTTO_HOME / "skills")
                    _cli_gateway = Gateway(
                        Policy(
                            blocked_paths=[],
                            max_calls_per_session=0,
                            allowed_servers=None,
                        )
                    )
                    gateways_to_close.append(_cli_gateway)
                    _cli_gateway.add_tools(
                        _ct(
                            memory=_cli_memory,
                            workspace_mode=_ws_mode,
                            workspace_root=_ws_root,
                            workspace_sandbox=_ws_sandbox,
                            workspace_sandbox_network=_ws_sandbox_network,
                            skills_dirs=_cli_skills_dirs,
                        )
                    )

                    _cli_session_store = SessionStore(bot_home / "sessions")
                    _cli_chat = Chat(
                        config,
                        _cli_gateway,
                        _cli_session_store,
                        _cli_memory,
                        bot_name=target.name,
                        workspace_mode=_ws_mode,
                        workspace_root=_ws_root,
                        workspace_sandbox=_ws_sandbox,
                        workspace_sandbox_network=_ws_sandbox_network,
                        skills_dirs=_cli_skills_dirs,
                    )
                    chats_by_bot[target.name] = _cli_chat
        else:
            # Legacy schema: use the shared chat.
            _cli_chat = legacy_chat

            # Build a synthetic BotConfig for the CLI channel.
            _cli_bot_config = BotConfig(
                name="default",
                model=config.agent.model,
                auth=BotAuthConfig(owner="cli", allowed_users=[]),
                workspace=config.workspace,
            )

        if _cli_chat is not None and _cli_bot_config is not None:
            cli_channel_config = ChannelConfig(type="cli")
            cli_channel = CLIChatChannel(
                _cli_chat,
                _cli_bot_config,
                cli_channel_config,
                resume_id=cli_resume_id,
            )
            log.info("CLI chat channel attached", bot=_cli_bot_config.name)

    async def fire(chat_id: str, prompt: str) -> bool | None:
        """Dispatch a pulse task to the appropriate bot/channel."""
        bot_by_alias = {bot.alias: bot for bot in bots}
        if "main" in bot_by_alias and "default" not in bot_by_alias:
            bot_by_alias["default"] = bot_by_alias["main"]

        target_alias, telegram_chat_id, thread_id = _resolve_scheduler_destination(
            chat_id, bots, config
        )
        if telegram_chat_id is None:
            log.warning(
                "pulse dispatch has non-telegram chat id; skipping",
                chat_id=chat_id,
            )
            return False

        if target_alias is None:
            if "main" in bot_by_alias:
                target_alias = "main"
            elif "default" in bot_by_alias:
                target_alias = "default"

        bot = bot_by_alias.get(target_alias) if target_alias else None
        if target_alias and bot is None:
            log.warning(
                "pulse dispatch references unknown bot alias; skipping",
                chat_id=chat_id,
                alias=target_alias,
            )
            return False

        if bot is None:
            bot = next((candidate for candidate in bots if candidate._app is not None), None)
        if bot is None or bot._app is None:
            return None

        renderer = TelegramRenderer(bot._app.bot, telegram_chat_id, message_thread_id=thread_id)

        if prompt.startswith("__notify__:"):
            await renderer.send_text(prompt[len("__notify__:") :])
            return True

        if prompt.startswith("__heartbeat__:"):
            payload_text = prompt[len("__heartbeat__:") :].strip()
            try:
                payload = json.loads(payload_text)
            except json.JSONDecodeError:
                log.warning("heartbeat payload invalid", chat_id=chat_id)
                return False
            action = payload.get("action") if isinstance(payload, dict) else None
            if not isinstance(action, dict):
                log.warning("heartbeat payload missing action", chat_id=chat_id)
                return False
            action_type = str(action.get("type", "")).strip().lower()
            if action_type == "notify":
                message = str(
                    action.get("message") or payload.get("summary") or "Heartbeat triggered"
                ).strip()
                if not message:
                    return False
                await renderer.send_text(message)
                return True

            bot_chat = chats_by_bot.get(bot.alias) if chats_by_bot else None
            if bot_chat is None:
                bot_chat = bots[0]._chat if bots else None
            if bot_chat is None:
                return None

            constraints = action.get("constraints")
            deliverables = action.get("deliverables")
            out_of_scope = action.get("out_of_scope")
            validation_cmds = action.get("validation_cmds")
            context_files = action.get("context_files")
            timeout = action.get("timeout")
            task_text = str(
                action.get("task") or payload.get("summary") or "Heartbeat follow-up"
            ).strip()
            if action_type == "delegate_task":
                from otto.delegation import DelegationContract
                from otto.orchestrator import get_orchestrator

                orchestrator = get_orchestrator()

                async def _notify(text: str) -> None:
                    await renderer.send_text(text)

                contract = DelegationContract(
                    task=task_text,
                    deliverables=deliverables if isinstance(deliverables, list) else [],
                    constraints=constraints if isinstance(constraints, list) else [],
                    out_of_scope=out_of_scope if isinstance(out_of_scope, list) else [],
                    validation_cmds=validation_cmds if isinstance(validation_cmds, list) else [],
                    context_files=context_files if isinstance(context_files, list) else [],
                    timeout=timeout if isinstance(timeout, int) and timeout > 0 else None,
                )
                job_id = await orchestrator.delegate(
                    contract,
                    chat_id,
                    notify=_notify,
                    tools=bot_chat._gateway.tools(),
                    session_model=bot_chat._active_model(chat_id),
                    bot_id=bot.alias,
                    auth_storage=bot_chat._auth_storage,
                    output_format=renderer.output_format,
                    subagent_name=(
                        str(action.get("subagent")).strip()
                        if isinstance(action.get("subagent"), str)
                        else None
                    ),
                    depends_on=[],
                )
                summary = str(payload.get("summary") or task_text).strip()
                await renderer.send_text(f"{summary}\nDelegated as job {job_id}.")
                return True

            if action_type == "prompt":
                await bot_chat.handle_message(
                    chat_id=chat_id,
                    text=task_text,
                    renderer=renderer,
                    background=True,
                )
                return True

            log.warning(
                "heartbeat payload action unsupported", chat_id=chat_id, action_type=action_type
            )
            return False

        bot_chat = chats_by_bot.get(bot.alias) if chats_by_bot else None
        if bot_chat is None:
            bot_chat = bots[0]._chat if bots else None
        if bot_chat is None:
            return None
        await bot_chat.handle_message(
            chat_id=chat_id,
            text=prompt,
            renderer=renderer,
            background=True,
        )
        return True

    pulse_runner = PulseRunner(config=config, dispatch=fire)

    # Inject pulse_runner into all Chat instances so tools can create pulse tasks.
    for chat_obj in chats_by_bot.values():
        chat_obj._pulse_runner = pulse_runner
    if legacy_chat is not None:
        legacy_chat._pulse_runner = pulse_runner

    log.info(
        "pulse runner configured",
        board=str(BOARD_DIR),
        logs=str(LOGS_DIR),
        interval=f"{pulse_runner.pulse_seconds}s",
    )

    stop_event = asyncio.Event()
    loop = asyncio.get_running_loop()
    previous_loop_exception_handler = None
    set_loop_exception_handler = getattr(loop, "set_exception_handler", None)
    get_loop_exception_handler = getattr(loop, "get_exception_handler", None)
    if callable(set_loop_exception_handler):
        if callable(get_loop_exception_handler):
            with suppress(Exception):
                previous_loop_exception_handler = get_loop_exception_handler()
        with suppress(Exception):
            set_loop_exception_handler(_build_loop_exception_handler(stop_event))

    registered_async_signals: list[signal.Signals] = []
    previous_sync_handlers: dict[signal.Signals, Any] = {}
    for sig in (signal.SIGINT, signal.SIGTERM):
        try:
            loop.add_signal_handler(sig, stop_event.set)
            registered_async_signals.append(sig)
        except (NotImplementedError, RuntimeError):
            previous_handler = signal.getsignal(sig)

            def _sync_handler(signum, frame) -> None:  # pragma: no cover - platform fallback
                _ = signum, frame
                stop_event.set()

            try:
                signal.signal(sig, _sync_handler)
                previous_sync_handlers[sig] = previous_handler
            except ValueError:  # pragma: no cover - non-main thread fallback
                continue

    pid_file = _pid_file(bot_alias)
    lock = FileLock(str(_lock_file(bot_alias)))
    pulse_task: asyncio.Task[None] | None = None
    shutdown_wait_task: asyncio.Task[None] | None = None
    cli_task: asyncio.Task[None] | None = None
    web_server: Any | None = None

    try:
        try:
            lock.acquire(timeout=0)
        except LockTimeout as exc:
            raise RuntimeError(
                f"Otto ({bot_alias or 'all'}) is already running. Use 'otto stop' before starting again."
            ) from exc

        # Cleanup stale pid files left by unclean exits.
        existing_pid = _read_pid(pid_file)
        if existing_pid is None:
            pid_file.unlink(missing_ok=True)
        elif existing_pid != os.getpid() and not _pid_exists(existing_pid):
            pid_file.unlink(missing_ok=True)

        if workspace_mode == "default":
            runtime_workdir.mkdir(parents=True, exist_ok=True)
            if runtime_workdir != original_cwd:
                os.chdir(runtime_workdir)
                changed_cwd = True

        log.info(
            "workspace runtime configured",
            mode=workspace_mode,
            root=str(workspace_root),
            cwd=str(Path.cwd()),
        )

        for bot in bots:
            try:
                await bot.start()
            except Exception as exc:
                log.exception(
                    "Telegram bot startup failed",
                    bot=getattr(bot, "alias", None),
                    error=str(exc),
                    error_type=type(exc).__name__,
                )
                raise
            log.info("Telegram bot polling started", bot=bot.alias)

        for bot in discord_bots:
            try:
                await bot.start()
            except Exception as exc:
                log.exception(
                    "Discord bot startup failed",
                    bot=getattr(bot, "alias", None),
                    error=str(exc),
                    error_type=type(exc).__name__,
                )
                raise
            log.info("Discord bot gateway started", bot=getattr(bot, "alias", None))

        # Only start web UI if specifically enabled AND this is either the global daemon or the 'main' bot.
        if config.web.enabled and (not bot_alias or bot_alias == "main"):
            if config.web.host == "0.0.0.0" and not config.web.hostname:
                log.error("web.hostname is required when web.host is '0.0.0.0' — web UI disabled")
            else:
                try:
                    from otto.web import ConfigServer

                    web_server = ConfigServer(
                        config,
                        host=config.web.host,
                        port=config.web.port,
                        bots=bots,
                        pulse_runner=pulse_runner,
                    )
                    await web_server.start()
                    log.info(f"web ui started on http://{config.web.host}:{config.web.port}")
                except Exception as exc:
                    log.error("web ui failed to start — continuing without it", error=str(exc))
                    web_server = None
        pulse_task = asyncio.create_task(
            _pulse_loop(pulse_runner, stop_event),
            name="otto.pulse_loop",
        )
        _write_pid_file(pid_file)
        log.info("Otto is running (PID %s). Waiting for messages...", os.getpid())

        # If a CLI channel is attached, run it as the foreground blocking task.
        # When the CLI exits, we trigger a full shutdown.
        if cli_channel is not None:
            cli_task = asyncio.create_task(
                cli_channel.start(),
                name="otto.cli_channel.start",
            )

        wait_tasks: set[asyncio.Task[None]] = {pulse_task}
        shutdown_wait_task = asyncio.create_task(stop_event.wait(), name="otto.shutdown_wait")
        wait_tasks.add(shutdown_wait_task)
        if cli_task is not None:
            wait_tasks.add(cli_task)

        done, _ = await asyncio.wait(wait_tasks, return_when=asyncio.FIRST_COMPLETED)

        # If the CLI channel finished, trigger shutdown.
        if cli_task is not None and cli_task in done:
            stop_event.set()

        if pulse_task in done:
            pulse_task.result()
    finally:
        stop_event.set()

        for task in (shutdown_wait_task, pulse_task, cli_task):
            if task is None:
                continue
            task.cancel()
            with suppress(asyncio.CancelledError):
                await task

        if cli_channel is not None:
            with suppress(Exception):
                await cli_channel.stop()

        for bot in discord_bots:
            try:
                await bot.stop()
                log.info("Discord bot gateway stopped", bot=getattr(bot, "alias", None))
            except Exception as exc:
                log.warning(
                    "Discord bot shutdown failed",
                    bot=getattr(bot, "alias", None),
                    error=str(exc),
                    error_type=type(exc).__name__,
                )
        for bot in bots:
            try:
                await bot.stop()
                log.info("Telegram bot polling stopped", bot=bot.alias)
            except Exception as exc:
                log.warning(
                    "Telegram bot shutdown failed",
                    bot=getattr(bot, "alias", None),
                    error=str(exc),
                    error_type=type(exc).__name__,
                )
        if web_server is not None:
            with suppress(Exception):
                await web_server.stop()
        for chat_inst in chats_by_bot.values():
            with suppress(Exception):
                await chat_inst.close()
        if legacy_chat is not None:
            with suppress(Exception):
                await legacy_chat.close()
        for memory in memories:
            with suppress(Exception):
                memory.close()
        for runtime_gateway in gateways_to_close:
            with suppress(Exception):
                await runtime_gateway.close()
        for pool in lsp_pools:
            with suppress(Exception):
                await pool.close()
        pid_file.unlink(missing_ok=True)
        with suppress(Exception):
            if lock.is_locked:
                lock.release()

        for sig in registered_async_signals:
            with suppress(Exception):
                loop.remove_signal_handler(sig)
        for sig, previous_handler in previous_sync_handlers.items():
            with suppress(Exception):
                signal.signal(sig, previous_handler)

        if callable(set_loop_exception_handler):
            with suppress(Exception):
                set_loop_exception_handler(previous_loop_exception_handler)

        if changed_cwd:
            with suppress(Exception):
                os.chdir(original_cwd)


def start_daemon(bot_alias: str | None = None) -> None:
    """Load config and run Otto services."""
    config = load_config()
    asyncio.run(start_services(config, bot_alias=bot_alias))


def wait_for_dead(pids: set[int], timeout: float = 5.0) -> None:
    """Block until all pids have exited, escalating to SIGKILL after *timeout* seconds."""
    import time

    deadline = time.monotonic() + timeout
    remaining = set(pids)

    while remaining and time.monotonic() < deadline:
        time.sleep(0.1)
        remaining = {pid for pid in remaining if _pid_exists(pid)}

    # Escalate stragglers
    for pid in remaining:
        with suppress(ProcessLookupError):
            os.kill(pid, signal.SIGKILL)

    # Final wait after SIGKILL (give the kernel a moment)
    for _ in range(20):
        if not any(_pid_exists(pid) for pid in remaining):
            break
        time.sleep(0.1)


def stop_daemon(bot_alias: str | None = None) -> None:
    """Send SIGTERM to all detected Otto daemon processes or a specific bot."""
    pid_file = _pid_file(bot_alias)
    pids: set[int] = set(_discover_run_pids(bot_alias))
    tracked_pid = _read_pid(pid_file)
    if tracked_pid is not None:
        pids.add(tracked_pid)

    if not pids:
        pid_file.unlink(missing_ok=True)
        if bot_alias:
            print(f"Otto ({bot_alias}) is not running.")
        else:
            print("Otto is not running.")
        return

    stopped: list[int] = []
    for pid in sorted(pids):
        try:
            os.kill(pid, signal.SIGTERM)
        except ProcessLookupError:
            continue
        stopped.append(pid)

    pid_file.unlink(missing_ok=True)

    if not stopped:
        if bot_alias:
            print(f"Otto ({bot_alias}) is not running.")
        else:
            print("Otto is not running.")
        return

    target = f"Otto ({bot_alias})" if bot_alias else "Otto"
    if len(stopped) == 1:
        print(f"Sent stop signal to {target} (PID {stopped[0]}).")
        return
    joined = ", ".join(str(pid) for pid in stopped)
    print(f"Sent stop signal to {target} processes: {joined}.")


def is_running(bot_alias: str | None = None) -> bool:
    """Return whether a live daemon process exists."""
    pid_file = _pid_file(bot_alias)
    pid = _read_pid(pid_file)
    if pid is None:
        pid_file.unlink(missing_ok=True)
    elif _pid_exists(pid):
        return True

    if pid is not None:
        pid_file.unlink(missing_ok=True)

    return bool(_discover_run_pids(bot_alias))


__all__ = ["is_running", "start_daemon", "start_services", "stop_daemon", "wait_for_dead"]
