"""Dynamic tool description updates and inbox polling.

Refreshes tool descriptions based on current server state.
Called after every tool execution (belt) and by a background
poller (suspenders) so notifications stay fresh even between
tool calls.

After mutating the ``read_messages`` description, fires
``notifications/tools/list_changed`` so Claude Code re-reads
the tool list and sees the updated unread count.

Also writes an ``unread.json`` status file (when configured) so that
external tools like the Claude Code status bar can display a live
unread count without querying the MCP server.
"""

from __future__ import annotations

import asyncio
import json
import logging
from pathlib import Path
from typing import TYPE_CHECKING

from biff.models import UnreadSummary, WallPost
from biff.relay import atomic_write
from biff.server.activity import ActivityTracker


class _Sentinel:
    """Sentinel for distinguishing 'not provided' from ``None``."""


_SENTINEL = _Sentinel()

if TYPE_CHECKING:
    from fastmcp import FastMCP
    from mcp.server.session import ServerSession

    from biff.server.state import ServerState

logger = logging.getLogger(__name__)

_READ_MESSAGES_BASE = "Check your inbox for new messages. Marks all as read."

_DEFAULT_POLL_INTERVAL = 2.0
_DEFAULT_IDLE_THRESHOLD = 300.0  # 5 minutes — transition to napping
_DEFAULT_POP_INTERVAL = 600.0  # 10 minutes — POP fetch while napping

# Updated on every tool call so the background poller can send
# notifications outside a request context.
_session: ServerSession | None = None

# Set by the ``tty`` tool so the unread file includes the session name.
_tty_name: str = ""

# Set by the ``mesg`` tool so the unread file includes availability state.
_biff_enabled: bool = True

# Set by the ``wall`` tool and background poller so the unread file
# includes the active wall text and sender for status bar display.
_wall_text: str = ""
_wall_from: str = ""


def get_tty_name() -> str:
    """Return the module-level TTY name."""
    return _tty_name


def set_tty_name(name: str) -> None:
    """Update the module-level TTY name for unread file writes."""
    global _tty_name
    _tty_name = name


def set_biff_enabled(*, enabled: bool) -> None:
    """Update the module-level biff_enabled flag for unread file writes."""
    global _biff_enabled
    _biff_enabled = enabled


def _reset_session() -> None:
    """Clear stored session, tty name, biff_enabled, wall — test isolation."""
    global _session, _tty_name, _biff_enabled, _wall_text, _wall_from
    _session = None
    _tty_name = ""
    _biff_enabled = True
    _wall_text = ""
    _wall_from = ""


async def _notify_tool_list_changed() -> None:
    """Fire ``notifications/tools/list_changed`` via the best available path.

    Belt path (inside a tool handler): queues the notification on the
    FastMCP Context so it piggybacks on the tool response.

    Suspenders path (background poller): sends directly on the stored
    ServerSession when no request context is active.
    """
    global _session

    # Belt path — inside a tool handler, Context is available.
    try:
        from fastmcp.server.dependencies import get_context  # noqa: PLC0415
        from mcp.types import ToolListChangedNotification  # noqa: PLC0415

        ctx = get_context()
        await ctx.send_notification(ToolListChangedNotification())
        # Always update — the client may have reconnected with a new session.
        _session = ctx.session
        return
    except RuntimeError:
        pass

    # Suspenders path — no request context, use stored session.
    # Bare Exception matches FastMCP's own _flush_notifications pattern —
    # notification delivery is best-effort and must never crash the poller.
    if _session is not None:
        try:
            await _session.send_tool_list_changed()
        except Exception:  # noqa: BLE001
            logger.warning(
                "Failed to send tool list changed notification",
                exc_info=True,
            )


async def refresh_read_messages(mcp: FastMCP[ServerState], state: ServerState) -> None:
    """Update the ``read_messages`` tool description with unread count.

    When the user has unread messages, the description changes to show
    the count, e.g. ``"Check messages (2 unread). Marks all as read."``

    When the inbox is empty, the description reverts to the base text.

    After mutation, fires ``notifications/tools/list_changed`` so the
    client re-reads the tool list and sees the new description.

    If ``state.unread_path`` is set, also writes the unread summary to
    a JSON file for status bar consumption.
    """
    tool = await mcp.get_tool("read_messages")
    if tool is None:
        return
    summary = await state.relay.get_unread_summary(state.session_key)
    old_desc = tool.description
    if summary.count == 0:
        tool.description = _READ_MESSAGES_BASE
    else:
        tool.description = (
            f"Check messages ({summary.count} unread). Marks all as read."
        )
    if tool.description != old_desc:
        await _notify_tool_list_changed()
    if state.unread_path is not None:
        _write_unread_file(
            state.unread_path,
            summary,
            repo_name=state.config.repo_name,
            user=state.config.user,
            tty_name=_tty_name,
            biff_enabled=_biff_enabled,
            wall_text=_wall_text,
            wall_from=_wall_from,
        )


async def refresh_wall(
    mcp: FastMCP[ServerState],
    state: ServerState,
    *,
    wall: WallPost | None | _Sentinel = _SENTINEL,
) -> None:
    """Update the ``wall`` tool description and module-level wall text.

    When a wall is active, the description shows the current banner.
    When no wall is active, the description reverts to base text.
    Also syncs ``_wall_text`` so the next unread file write includes it.

    Pass *wall* to skip the relay fetch when the caller already has
    the current wall (e.g. :func:`poll_inbox`).
    """
    global _wall_text, _wall_from

    from biff.server.tools.wall import (  # noqa: PLC0415
        WALL_BASE_DESCRIPTION,
        format_remaining,
    )

    tool = await mcp.get_tool("wall")
    if tool is None:
        return
    current = await state.relay.get_wall() if isinstance(wall, _Sentinel) else wall
    old_desc = tool.description
    if current is None:
        tool.description = WALL_BASE_DESCRIPTION
        _wall_text = ""
        _wall_from = ""
    else:
        remaining = format_remaining(current.expires_at)
        sender = f"@{current.from_user}"
        if current.from_tty:
            sender += f" ({current.from_tty})"
        tool.description = (
            f"[WALL] {current.text} — {sender}, "
            f"expires in {remaining}. "
            "Use wall(clear=True) to remove."
        )
        _wall_text = current.text
        _wall_from = current.from_user
    if tool.description != old_desc:
        await _notify_tool_list_changed()
    # Re-write the unread file so wall text is synced to status bar
    if state.unread_path is not None:
        summary = await state.relay.get_unread_summary(state.session_key)
        _write_unread_file(
            state.unread_path,
            summary,
            repo_name=state.config.repo_name,
            user=state.config.user,
            tty_name=_tty_name,
            biff_enabled=_biff_enabled,
            wall_text=_wall_text,
            wall_from=_wall_from,
        )


async def poll_inbox(
    mcp: FastMCP[ServerState],
    state: ServerState,
    *,
    shutdown: asyncio.Event | None = None,
    interval: float = _DEFAULT_POLL_INTERVAL,
    idle_threshold: float = _DEFAULT_IDLE_THRESHOLD,
    pop_interval: float = _DEFAULT_POP_INTERVAL,
) -> None:
    """Background task: poll inbox and wall, refresh notifications on change.

    Runs for the lifetime of the MCP server.  In **active** mode,
    polls the relay every *interval* seconds (normal operation).
    After *idle_threshold* seconds with no tool call, transitions to
    **napping** (POP mode): releases the TCP connection and only
    reconnects every *pop_interval* seconds for a brief fetch cycle.

    The poller always ticks at *interval* (2s default).  During
    napping, ticks are cheap no-ops (datetime comparison).  This
    keeps wake-up responsive — at most 2s lag when ``touch()``
    clears napping.

    When *shutdown* is set, exits cleanly between iterations —
    no NATS operations are interrupted mid-flight.
    """
    tracker = state.activity
    last_count = -1  # Force initial refresh
    last_wall: tuple[str, str] = ("", "")  # (text, posted_at) — Force initial refresh
    while shutdown is None or not shutdown.is_set():
        if shutdown is not None:
            try:
                await asyncio.wait_for(shutdown.wait(), timeout=interval)
                return  # Shutdown requested
            except TimeoutError:
                pass
        else:
            await asyncio.sleep(interval)

        # Transition: active → napping
        if not tracker.napping and tracker.idle_seconds() > idle_threshold:
            tracker.enter_nap()
            await state.relay.disconnect()
            continue

        # Napping: skip unless POP interval reached
        if tracker.napping:
            if tracker.seconds_since_pop() < pop_interval:
                continue
            last_count, last_wall = await _pop_fetch(mcp, state, tracker)
            continue

        # Active: normal polling
        summary = await state.relay.get_unread_summary(state.session_key)
        if summary.count != last_count:
            last_count = summary.count
            await refresh_read_messages(mcp, state)

        # Check wall state — another session may have posted or cleared.
        # Key on (text, posted_at) so re-posts with different expiry trigger refresh.
        current_wall = await state.relay.get_wall()
        wall_key = (
            (current_wall.text, current_wall.posted_at.isoformat())
            if current_wall
            else ("", "")
        )
        if wall_key != last_wall:
            last_wall = wall_key
            await refresh_wall(mcp, state, wall=current_wall)


async def _pop_fetch(
    mcp: FastMCP[ServerState],
    state: ServerState,
    tracker: ActivityTracker,
) -> tuple[int, tuple[str, str]]:
    """Reconnect, fetch unread/wall, heartbeat, disconnect.  Like email POP.

    The relay reconnects transparently on the first method call.
    After fetching, captures tracking state, piggybacks a heartbeat,
    then disconnects.  Returns ``(count, wall_key)`` so the caller
    can update its local tracking without reconnecting.
    """
    await refresh_read_messages(mcp, state)
    await refresh_wall(mcp, state)
    # Capture tracking data while still connected
    summary = await state.relay.get_unread_summary(state.session_key)
    current_wall = await state.relay.get_wall()
    await state.relay.heartbeat(state.session_key)
    await state.relay.disconnect()
    tracker.record_pop()
    wall_key = (
        (current_wall.text, current_wall.posted_at.isoformat())
        if current_wall
        else ("", "")
    )
    return summary.count, wall_key


def _write_unread_file(
    path: Path,
    summary: UnreadSummary,
    *,
    repo_name: str,
    user: str,
    tty_name: str,
    biff_enabled: bool,
    wall_text: str = "",
    wall_from: str = "",
) -> None:
    """Write unread count to a JSON status file.

    Includes ``user``, ``repo``, ``tty_name``, ``biff_enabled``,
    ``wall``, and ``wall_from`` metadata so the status line can display
    identity, session, availability, and wall banner information.

    Failures are logged but never propagated — tool execution must not
    break because a status file could not be written.
    """
    data = {
        "user": user,
        "repo": repo_name,
        "count": summary.count,
        "tty_name": tty_name,
        "biff_enabled": biff_enabled,
        "wall": wall_text,
        "wall_from": wall_from,
    }
    try:
        atomic_write(path, json.dumps(data, indent=2) + "\n")
    except OSError:
        logger.warning("Failed to write unread status file %s", path, exc_info=True)
