"""FastMCP application factory.

``create_server`` builds a fully configured FastMCP instance with all
tools registered. The returned server is run via ``mcp.run(transport=...)``.
"""

from __future__ import annotations

import asyncio
import logging
import signal
import threading
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager, suppress
from datetime import UTC, datetime
from typing import TYPE_CHECKING

from fastmcp import FastMCP
from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext

from biff._stdlib import BIFF_DATA_DIR, active_dir, remove_active_session, sentinel_dir
from biff.models import SessionEvent, UserSession
from biff.session_key import find_session_key

if TYPE_CHECKING:
    from mcp.types import InitializeRequest, InitializeResult

from biff.nats_relay import RESERVED_KV_NAMESPACES, NatsRelay
from biff.relay import LocalRelay
from biff.server.state import ServerState
from biff.server.tools import register_all_tools
from biff.server.tools._descriptions import (
    capture_session,
    get_tty_name,
    poll_inbox,
    refresh_read_messages,
    refresh_wall,
    set_tty_name,
)
from biff.server.tools._session import update_current_session
from biff.tty import build_session_key, claim_tty_name

logger = logging.getLogger(__name__)


class _SessionCaptureMiddleware(Middleware):
    """Capture the MCP session during ``initialize``.

    Corresponds to the ``CaptureSession`` operation in the Z
    specification (notification.tex §6).  Ensures the suspenders
    notification path has a valid session reference before any
    tool call or NATS subscription can fire.

    The MCP session does not exist during the server lifespan
    (it is created when the client sends ``initialize``), so
    this middleware is the earliest possible capture point.
    """

    async def on_initialize(
        self,
        context: MiddlewareContext[InitializeRequest],
        call_next: CallNext[InitializeRequest, InitializeResult | None],
    ) -> InitializeResult | None:
        result = await call_next(context)
        if context.fastmcp_context is not None:
            try:
                capture_session(context.fastmcp_context.session)
            except RuntimeError:
                logger.debug("Session not available during initialize")
        return result


def write_active_session(
    repo_name: str, session_key: str, worktree_root: str = ""
) -> None:
    """Mark a session as active so SessionEnd hooks can find it.

    Writes ``~/.punt-labs/biff/active/{safe_key}`` containing the session key,
    repo name, and worktree root (one per line).  The SessionEnd hook
    reads these files and converts them to sentinels for the reaper
    to process.  The third line is optional for backwards compatibility.
    """
    d = active_dir()
    d.mkdir(parents=True, exist_ok=True)
    safe = session_key.replace(":", "-")
    (d / safe).write_text(f"{session_key}\n{repo_name}\n{worktree_root}\n")


def _write_sentinel(repo_name: str, session_key: str) -> None:
    """Create a sentinel file marking a session for removal.

    Relay-agnostic — writes to ``~/.punt-labs/biff/sentinels/{repo}/`` so that
    any running server's reaper task can process it.  Safe to call
    from signal handlers (sync I/O only).
    """
    d = sentinel_dir(repo_name)
    d.mkdir(parents=True, exist_ok=True)
    safe = session_key.replace(":", "-")
    (d / safe).write_text(session_key)


async def _reap_sentinels(state: ServerState) -> None:
    """Process sentinel files, writing logout events and deleting sessions.

    When a biff server receives SIGTERM/SIGINT, the signal handler writes
    a sentinel file containing the session key.  This function processes
    those sentinels on startup (and periodically via the reaper loop):

    1. Fetch the session from KV (still present — 3-day TTL)
    2. Append a wtmp logout event with ``last_active`` as the timestamp
    3. Delete the KV entry
    4. Remove the sentinel file

    This is the primary logout mechanism for the common case (graceful
    shutdown via signal).  The orphan detector handles the fallback case
    (SIGKILL, OOM, power loss — no sentinel written).
    """
    d = sentinel_dir(state.config.repo_name)
    if not d.exists():
        return
    for sentinel in d.iterdir():
        if not sentinel.is_file():
            continue
        try:
            session_key = sentinel.read_text().strip()
        except OSError:
            continue
        # Write logout event before deleting the session.  The KV entry
        # is still present (3-day TTL), so we can fetch session data for
        # an accurate last-seen timestamp.
        session: UserSession | None = None
        try:
            session = await state.relay.get_session(session_key)
            if session is not None:
                await state.relay.append_wtmp(_build_logout_event(session_key, session))
        except Exception:  # noqa: BLE001
            logger.warning("Failed to write sentinel logout for %s", session_key)
        # Release TTY name reservation before deleting session (DES-035).
        # Separate try block so a wtmp failure doesn't leak the name reservation.
        if session is not None and session.tty_name:
            try:
                await state.relay.release_tty_name(session.user, session.tty_name)
            except Exception:  # noqa: BLE001
                logger.warning(
                    "Failed to release TTY name %s during sentinel reap",
                    session.tty_name,
                    exc_info=True,
                )
        try:
            await state.relay.delete_session(session_key)
        except Exception:  # noqa: BLE001 — relay errors vary by backend
            logger.warning("Failed to reap sentinel for %s", session_key, exc_info=True)
            continue
        sentinel.unlink(missing_ok=True)


async def _reap_loop(
    state: ServerState, shutdown: asyncio.Event, *, interval: float = 2.0
) -> None:
    """Background task: reap shutdown sentinels every *interval* seconds."""
    while not shutdown.is_set():
        try:
            await asyncio.wait_for(shutdown.wait(), timeout=interval)
            return  # Shutdown requested
        except TimeoutError:
            pass
        await _reap_sentinels(state)


async def _heartbeat_loop(
    state: ServerState, shutdown: asyncio.Event, *, interval: float = 60.0
) -> None:
    """Periodic heartbeat to keep this session alive in the relay.

    Each ``heartbeat()`` call updates ``last_active`` and — for NATS KV —
    resets the key's TTL.  When the process sleeps (laptop lid closed) or
    dies (SIGKILL), heartbeats stop and the relay eventually expires the
    session.
    """
    while not shutdown.is_set():
        try:
            await asyncio.wait_for(shutdown.wait(), timeout=interval)
            return  # Shutdown requested
        except TimeoutError:
            pass
        try:
            await state.relay.heartbeat(state.session_key)
        except Exception:  # noqa: BLE001 — relay errors vary by backend
            logger.warning("Heartbeat failed", exc_info=True)


def _kv_key_to_session_key(kv_key: str, repo_name: str) -> str | None:
    """Convert a KV key (``{repo}.{user}.{tty}``) to a session key (``user:tty``).

    Returns ``None`` if the key format is unexpected, belongs to a
    different repo, or is a non-session key (wall, encryption keys).
    Structural filtering per DES-016.
    """
    parts = kv_key.split(".", maxsplit=2)
    if len(parts) != 3:
        return None
    if parts[0] != repo_name:
        return None
    # Skip reserved KV namespaces (encryption keys — DES-016).
    if parts[1] in RESERVED_KV_NAMESPACES:
        return None
    return f"{parts[1]}:{parts[2]}"


def _build_logout_event(session_key: str, cached: UserSession) -> SessionEvent:
    """Build a logout ``SessionEvent`` from a cached session."""
    return SessionEvent(
        session_key=session_key,
        event="logout",
        user=cached.user,
        tty=cached.tty,
        tty_name=cached.tty_name,
        hostname=cached.hostname,
        pwd=cached.pwd,
        timestamp=cached.last_active,
        plan=cached.plan,
        repo=cached.repo,
    )


async def _run_kv_watch(
    relay: NatsRelay,
    state: ServerState,
    shutdown: asyncio.Event,
    cache: dict[str, UserSession],
) -> None:
    """Run a single KV watch cycle until shutdown.

    Stays alive during napping — the NATS connection is no longer
    released on idle.  This ensures wall changes and session events
    are detected in real-time regardless of activity state.
    """
    kv = await relay.get_kv()
    watcher = await kv.watchall()  # pyright: ignore[reportUnknownMemberType]
    try:
        # Use watcher.updates() instead of ``async for`` because nats.py's
        # __anext__ raises StopAsyncIteration on the snapshot-done None
        # marker, terminating the iterator and creating a blind window
        # where notifications can be missed before the restart loop
        # re-creates the watcher.  See biff-udp.
        while not shutdown.is_set():
            try:
                entry = await watcher.updates(timeout=5.0)  # type: ignore[no-untyped-call]  # pyright: ignore[reportUnknownMemberType]
            except TimeoutError:
                continue  # No updates within timeout window
            if entry is None:
                continue  # Snapshot-done marker
            key = str(entry.key)  # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType,reportAttributeAccessIssue]
            if key == NatsRelay.wall_kv_key(state.config.repo_name):
                # Wake the poller so the next 2s tick runs _active_tick
                # and detects the wall change.  Do NOT call refresh_wall
                # here — sending MCP notifications from a background task
                # is unreliable.  The poller handles notification delivery
                # from its own coroutine context.
                state.activity.wake()
                continue
            await _handle_kv_entry(entry, relay, state, cache)
    finally:
        await watcher.stop()  # type: ignore[no-untyped-call]  # pyright: ignore[reportUnknownMemberType]


async def _kv_watcher_loop(
    state: ServerState,
    shutdown: asyncio.Event,
) -> None:
    """Watch KV changes for wall updates and session events (wtmp).

    Handles:
    - **Wall changes**: detected in ``_run_kv_watch()`` when the KV key
      matches ``NatsRelay.wall_kv_key(state.config.repo_name)``.  Wakes
      the poller from napping so the next 2s tick detects the change and
      fires the MCP notification from its own coroutine context.
    - **Session logout events**: for *other* sessions that disappear
      via TTL expiry or crash (no graceful shutdown).  Our own session's
      logout is written explicitly by ``_append_logout_event()`` during
      graceful shutdown.

    The NATS connection stays alive during napping so wall changes
    arrive in real-time regardless of activity state.

    **Duplicate risk**: If multiple servers are running, each may
    observe the same KV delete and write a logout event.  This is
    acceptable — ``/last`` pairs events by session_key, so extra
    logouts are harmless (they won't match a login).
    """
    relay = state.relay
    if not isinstance(relay, NatsRelay):
        return  # LocalRelay does not support KV watches

    cache: dict[str, UserSession] = {}

    while not shutdown.is_set():
        try:
            await _run_kv_watch(relay, state, shutdown, cache)
        except asyncio.CancelledError:
            return
        except Exception:  # noqa: BLE001
            logger.debug("KV watcher restarting after error", exc_info=True)
            with suppress(TimeoutError):
                await asyncio.wait_for(shutdown.wait(), timeout=2.0)


async def _handle_kv_entry(
    entry: object,  # nats KeyValue.Entry (untyped)
    relay: NatsRelay,
    state: ServerState,
    cache: dict[str, UserSession],
) -> None:
    """Route a single KV watch entry to the appropriate handler."""
    key = str(entry.key)  # type: ignore[attr-defined]  # pyright: ignore[reportUnknownMemberType,reportUnknownArgumentType,reportAttributeAccessIssue]
    session_key = _kv_key_to_session_key(key, state.config.repo_name)
    if session_key is None:
        return

    op = entry.operation  # type: ignore[attr-defined]  # pyright: ignore[reportUnknownMemberType,reportAttributeAccessIssue]
    val = entry.value  # type: ignore[attr-defined]  # pyright: ignore[reportUnknownMemberType,reportAttributeAccessIssue]
    if op is None and val is not None:
        # PUT — cache the session data
        try:
            session = UserSession.model_validate_json(
                val  # pyright: ignore[reportUnknownArgumentType]
            )
            cache[session_key] = session
        except Exception:  # noqa: BLE001
            logger.debug("Failed to parse KV entry %s", key)
    elif op in ("DEL", "PURGE"):
        await _handle_kv_delete(relay, state, cache, session_key)


async def _handle_kv_delete(
    relay: NatsRelay,
    state: ServerState,
    cache: dict[str, UserSession],
    session_key: str,
) -> None:
    """Handle a KV delete event — append logout for crashed/expired sessions.

    Skips our own session key because graceful shutdown writes the
    logout event explicitly in ``_append_logout_event()`` before the
    watcher is stopped.  For *other* sessions (TTL expiry, crash),
    any running server can write the logout on their behalf.
    """
    if session_key == state.session_key:
        return  # Our own shutdown writes logout explicitly
    cached = cache.pop(session_key, None)
    if cached is None:
        logger.debug(
            "No cached session for %s on DEL, skipping wtmp",
            session_key,
        )
        return
    try:
        await relay.append_wtmp(_build_logout_event(session_key, cached))
    except Exception:  # noqa: BLE001
        logger.warning(
            "Failed to append wtmp logout for %s",
            session_key,
            exc_info=True,
        )


async def _shutdown_tasks(
    shutdown: asyncio.Event, tasks: list[asyncio.Task[None]], *, timeout: float = 5.0
) -> None:
    """Stop background tasks cooperatively, falling back to hard cancel.

    Sets the *shutdown* event so tasks exit cleanly between iterations
    (avoids cancelling mid-NATS-I/O which corrupts shared connections).
    Waits up to *timeout* seconds for all tasks to finish, then
    force-cancels any stragglers.  Suppresses ``CancelledError`` at
    every ``await`` because this runs inside a ``finally`` block that
    may itself be responding to cancellation.
    """
    shutdown.set()
    with suppress(asyncio.CancelledError):
        await asyncio.wait_for(
            asyncio.gather(*tasks, return_exceptions=True),
            timeout=timeout,
        )
    for t in tasks:
        if not t.done():
            t.cancel()
    for t in tasks:
        with suppress(asyncio.CancelledError):
            await t


async def _append_login_event(state: ServerState, tty_name: str) -> None:
    """Append a login event to wtmp for the current session."""
    session = await state.relay.get_session(state.session_key)
    if session is None:
        return
    login_event = SessionEvent(
        session_key=state.session_key,
        event="login",
        user=state.config.user,
        tty=state.tty,
        tty_name=tty_name,
        hostname=state.hostname,
        pwd=state.pwd,
        timestamp=session.last_active,
        plan=session.plan,
        repo=state.config.repo_name,
    )
    try:
        await state.relay.append_wtmp(login_event)
    except Exception:  # noqa: BLE001
        logger.warning("Failed to append wtmp login event", exc_info=True)


async def _append_logout_event(state: ServerState) -> None:
    """Append a logout event to wtmp for the current session.

    Uses only local state — no NATS round-trips to fetch session data.
    This is critical because the MCP subprocess may be killed at any
    moment after Claude Code closes stdio.  After publishing, flushes
    the NATS connection to ensure the event reaches the server before
    the process exits.
    """
    logout_event = SessionEvent(
        session_key=state.session_key,
        event="logout",
        user=state.config.user,
        tty=state.tty,
        tty_name=get_tty_name(),
        hostname=state.hostname,
        pwd=state.pwd,
        timestamp=datetime.now(UTC),
    )
    try:
        await state.relay.append_wtmp(logout_event)
        # Flush ensures the publish hits the wire before process exit.
        if isinstance(state.relay, NatsRelay):
            await state.relay.flush()
    except Exception:  # noqa: BLE001
        logger.warning("Failed to append wtmp logout event", exc_info=True)


def _find_orphaned_logins(
    events: list[SessionEvent],
    active_keys: set[str],
) -> list[SessionEvent]:
    """Return login events that have no matching logout and no active session."""
    logout_keys = {e.session_key for e in events if e.event == "logout"}
    orphans: list[SessionEvent] = []
    seen: set[str] = set()
    for event in events:
        if event.event != "login" or event.session_key in seen:
            continue
        seen.add(event.session_key)
        has_logout = event.session_key in logout_keys
        is_active = event.session_key in active_keys
        if not has_logout and not is_active:
            orphans.append(event)
    return orphans


async def _close_orphaned_logins(
    state: ServerState,
    active_sessions: list[UserSession],
) -> None:
    """Write retroactive logout events for sessions that died without cleanup.

    At startup, fetches recent wtmp events and identifies login events
    with no matching logout and no active KV session.  For each orphan,
    writes a logout event with the session's ``last_active`` timestamp
    as the logout time (last-seen heuristic).  Falls back to the login
    timestamp if the session is no longer in KV.

    This is the primary logout mechanism — MCP subprocess death prevents
    async cleanup in the ``finally`` block, so orphan detection on the
    next startup is the only reliable path.
    """
    try:
        events = await state.relay.get_wtmp(count=100)
    except Exception:  # noqa: BLE001
        return

    if not events:
        return

    # Build a map of session_key → last_active from current KV sessions.
    # Sessions with a stale last_active (>2 heartbeat intervals old) are
    # treated as dead — their KV entry just hasn't expired yet.
    now = datetime.now(UTC)
    stale_threshold = 120.0  # 2x heartbeat interval (60s)
    last_seen: dict[str, datetime] = {}
    active_keys: set[str] = {state.session_key}
    for session in active_sessions:
        key = build_session_key(session.user, session.tty)
        last_seen[key] = session.last_active
        age = (now - session.last_active).total_seconds()
        if age < stale_threshold:
            active_keys.add(key)
    orphaned = _find_orphaned_logins(events, active_keys)

    for login in orphaned:
        # Use the session's last heartbeat if available, otherwise
        # fall back to the login timestamp (best available data).
        logout_ts = last_seen.get(login.session_key, login.timestamp)
        logout = SessionEvent(
            session_key=login.session_key,
            event="logout",
            user=login.user,
            tty=login.tty,
            tty_name=login.tty_name,
            hostname=login.hostname,
            pwd=login.pwd,
            timestamp=logout_ts,
        )
        try:
            await state.relay.append_wtmp(logout)
        except Exception:  # noqa: BLE001
            logger.debug("Failed to close orphaned login %s", login.session_key)

    if orphaned:
        logger.info("Closed %d orphaned login(s)", len(orphaned))


async def _release_relay(state: ServerState) -> None:
    """Release TTY name, delete session, and close the relay."""
    tty_name = get_tty_name()
    if tty_name:
        try:
            await state.relay.release_tty_name(state.config.user, tty_name)
        except Exception:  # noqa: BLE001
            logger.warning("Failed to release TTY name %s", tty_name, exc_info=True)
    try:
        await state.relay.delete_session(state.session_key)
    except Exception:
        logger.exception("Failed to delete session %s", state.session_key)
    try:
        await state.relay.close()
    except Exception:  # noqa: BLE001
        logger.warning("Failed to close relay", exc_info=True)


async def _lifespan_cleanup(
    state: ServerState,
    shutdown: asyncio.Event,
    tasks: list[asyncio.Task[None]],
    lux_stop: threading.Event,
    lux_thread: threading.Thread | None,
    lux_session_key: str | None = None,
) -> None:
    """Shutdown sequence for the active lifespan.

    Write logout FIRST — before stopping tasks or closing
    anything.  The MCP subprocess may be killed at any moment
    after Claude Code closes stdio, so the logout publish
    must happen while the NATS connection is still healthy.
    """
    if lux_thread is not None:
        lux_stop.set()  # signal stop early so thread exits during cleanup
    if state.owns_relay:
        await _append_logout_event(state)
    await _shutdown_tasks(shutdown, tasks)
    # Drain vox background tasks so child process waiters complete
    # before the event loop closes.  Without this, SIGCHLD fires
    # _do_waitpid on a closed loop, producing logging errors.
    from biff.integration.vox import drain_background_tasks  # noqa: PLC0415

    await drain_background_tasks()
    if state.unread_path is not None:
        with suppress(FileNotFoundError):
            state.unread_path.unlink()
    if lux_session_key is not None:
        sd_path = BIFF_DATA_DIR / "session-data" / f"{lux_session_key}.json"
        with suppress(FileNotFoundError):
            sd_path.unlink()
    if state.owns_relay:
        await _release_relay(state)
    with suppress(OSError):
        remove_active_session(state.session_key)
    if lux_thread is not None:
        lux_thread.join(timeout=2.0)
        if lux_thread.is_alive():
            logger.warning("Lux applet thread did not stop within 2s timeout")


def _start_lux_applet(
    session_key: str,
    tty: str = "",
) -> tuple[threading.Event, threading.Thread | None]:
    """Start the lux session dashboard if punt-lux is available."""
    stop = threading.Event()
    try:
        from biff.integration.lux import start_session_applet  # noqa: PLC0415

        thread = start_session_applet(session_key, stop, tty=tty)
    except Exception:  # noqa: BLE001
        logger.debug("Failed to start lux applet", exc_info=True)
        return stop, None
    return stop, thread


@asynccontextmanager
async def _active_lifespan(
    mcp: FastMCP[ServerState], state: ServerState
) -> AsyncIterator[ServerState]:
    """Lifespan for an active (non-dormant) server.

    Registers signal handlers, starts background tasks, and manages
    session lifecycle.  Extracted from :func:`create_server` for
    complexity management.
    """
    _cleaned_up = False

    def _signal_handler(_signum: int, _frame: object) -> None:
        nonlocal _cleaned_up
        if _cleaned_up:
            return
        _cleaned_up = True
        # Write sentinel — relay-agnostic, picked up by any
        # running server's reaper task.  Smallest possible
        # operation (touch a file), runs first.
        with suppress(OSError):
            _write_sentinel(state.config.repo_name, state.session_key)
        # Best-effort sync cleanup for LocalRelay only.
        if isinstance(state.relay, LocalRelay):
            with suppress(OSError):
                state.relay.write_remove_sentinel(state.session_key)
            with suppress(OSError, ValueError):
                state.relay.delete_session_sync(state.session_key)

    for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
        signal.signal(sig, _signal_handler)

    # Register this session as active so SessionEnd hooks can find it.
    with suppress(OSError):
        write_active_session(
            state.config.repo_name,
            state.session_key,
            worktree_root=str(state.repo_root) if state.repo_root else "",
        )

    # Register session in KV before TTY name claim.
    await update_current_session(state)

    # Org discovery (DES-034): discover repos for configured orgs.
    # Runs after session registration (which ensures NATS is connected)
    # but before tty assignment (which needs visible_repos).
    if state.config.orgs and isinstance(state.relay, NatsRelay):
        logger.info("Org discovery: querying orgs=%s", state.config.orgs)
        org_results = await asyncio.gather(
            *(state.relay.discover_repos_for_org(org) for org in state.config.orgs)
        )
        org_repos = frozenset[str]().union(*org_results)
        logger.info(
            "Org discovery: found %d repos: %s",
            len(org_repos),
            sorted(org_repos),
        )
        if org_repos:
            # Mutate in-place so tool closures (which captured `state`
            # during registration) see the updated visible_repos.
            object.__setattr__(state, "org_repos", org_repos)
        else:
            logger.warning(
                "Org discovery returned no repos — cross-repo visibility disabled",
            )

    logger.info(
        "Visible repos: %s",
        sorted(state.visible_repos),
    )

    # Auto-assign a ttyN name via atomic reservation (DES-035).
    # Let claim failures propagate — an unreserved hex fallback would
    # defeat the DES-035 invariant (every active name must be reserved).
    final_name = await claim_tty_name(state.relay, state.config.user, state.session_key)
    set_tty_name(final_name)
    await update_current_session(state, tty_name=final_name)
    logger.info("Session ready: %s (%s)", state.session_key, final_name)

    # Write the initial unread file and wall state immediately so the
    # status line has identity from the first render (before the poller ticks).
    await refresh_read_messages(mcp, state)
    await refresh_wall(mcp, state)

    # Start lux session dashboard applet (daemon thread, not asyncio).
    # The lux key is the PID-based session key (same as statusline tee).
    lux_session_key = str(find_session_key())
    lux_stop, lux_thread = _start_lux_applet(lux_session_key, tty=final_name)

    # Reap sentinels FIRST — writes logout events for sessions that
    # received SIGTERM/SIGINT (the signal handler wrote a sentinel).
    # Then re-fetch sessions so orphan detection has clean KV state.
    await _reap_sentinels(state)
    sessions = await state.relay.get_sessions()

    await _append_login_event(state, final_name)
    await _close_orphaned_logins(state, sessions)

    shutdown = asyncio.Event()
    poller = asyncio.create_task(poll_inbox(mcp, state, shutdown=shutdown))
    reaper = asyncio.create_task(_reap_loop(state, shutdown))
    heartbeat = asyncio.create_task(_heartbeat_loop(state, shutdown))
    watcher = asyncio.create_task(_kv_watcher_loop(state, shutdown))
    try:
        yield state
    finally:
        await _lifespan_cleanup(
            state,
            shutdown,
            [poller, reaper, heartbeat, watcher],
            lux_stop,
            lux_thread,
            lux_session_key=lux_session_key,
        )


def create_server(state: ServerState) -> FastMCP[ServerState]:
    """Create a FastMCP server with all biff tools registered.

    The returned server is ready to run via ``mcp.run(transport=...)``.
    Starts a background inbox poller that keeps the tool description
    and status file in sync with incoming messages.
    """

    @asynccontextmanager
    async def lifespan(mcp: FastMCP[ServerState]) -> AsyncIterator[ServerState]:
        if state.dormant:
            try:
                yield state
            finally:
                if state.unread_path is not None:
                    with suppress(FileNotFoundError):
                        state.unread_path.unlink()
            return
        async with _active_lifespan(mcp, state) as ctx:
            yield ctx

    mcp: FastMCP[ServerState] = FastMCP(
        "biff",
        instructions=(
            "Biff is a communication tool for software engineers. "
            "Use these tools to send messages, check presence, "
            "and coordinate with your team.\n\n"
            "All biff tool output is pre-formatted plain text using unicode "
            "characters for alignment. Always emit biff output verbatim — "
            "never reformat, never convert to markdown tables, never wrap "
            "in code fences or boxes."
        ),
        lifespan=lifespan,
    )

    mcp.add_middleware(_SessionCaptureMiddleware())
    register_all_tools(mcp, state)
    return mcp
