"""FastAPI server with SSE endpoint for live transcript updates."""

import asyncio
import inspect
import json
import logging
import os
import time
from contextlib import asynccontextmanager
from pathlib import Path
from typing import AsyncGenerator

import watchfiles
from fastapi import FastAPI, HTTPException, Request, WebSocket
from fastapi.responses import HTMLResponse, Response
from sse_starlette.sse import EventSourceResponse

from .backends import CodingToolBackend, get_backend, get_multi_backend
from .backends.thinking import detect_thinking_level
from .broadcasting import (
    add_client,
    add_json_client,
    broadcast_event,
    broadcast_json_event,
    broadcast_json_message,
    broadcast_message,
    broadcast_permission_denied,
    broadcast_session_added,
    broadcast_session_catchup,
    broadcast_session_removed,
    broadcast_session_status,
    broadcast_session_summary_updated,
    broadcast_session_token_usage_updated,
    get_clients,
    get_json_clients,
    has_json_clients,
    remove_client,
    remove_json_client,
)
from .routes import (
    archives_router,
    diff_router,
    files_router,
    sessions_router,
    statuses_router,
)
from .routes.sessions import configure_session_routes
from .sessions import (
    MAX_SESSIONS,
    SessionInfo,
    add_session,
    get_current_backend,
    get_known_session_files,
    get_projects_dir,
    get_session,
    get_sessions,
    get_sessions_list,
    get_sessions_lock,
    session_count,
    set_backend,
)
from .summarizer import IdleTracker, LogWriter, Summarizer

logger = logging.getLogger(__name__)

# Configuration
CATCHUP_TIMEOUT = (
    30  # seconds - max time for catchup before telling client to reinitialize
)
HTML_SSE_QUEUE_MAXSIZE = 5000  # Large enough for bursty Codex transcript batches
JSON_SSE_QUEUE_MAXSIZE = 5000  # JSON clients receive the same message bursts
_send_enabled = True  # Enabled by default, disable with --disable-send CLI flag
_skip_permissions = False  # Enable with --dangerously-skip-permissions CLI flag
_fork_enabled = False  # Enable with --fork CLI flag
_default_send_backend: str | None = None  # Enable with --default-send-backend CLI flag
_include_subagents = False  # Enable with --include-subagents CLI flag
_enable_thinking = False  # Enable with --enable-thinking CLI flag
_thinking_budget: int | None = None  # Fixed budget with --thinking-budget CLI flag
_terminal_enabled = True  # Enabled by default, disable with --disable-terminal CLI flag
_terminal_motd_file: str | None = None  # Path to MOTD file shown on terminal open
DB_WATCH_SUPPRESSION_SECONDS = 0.5
DB_POLL_INTERVAL_SECONDS = 2.0
DB_NEW_SESSION_POLL_INTERVAL_SECONDS = 10.0

# Global state for server (not session-related)
_watch_task: asyncio.Task | None = None
_db_poll_task: asyncio.Task | None = None
_suppressed_watch_paths: dict[Path, float] = {}

# Summarization state
_summarizer: "Summarizer | None" = None
_idle_tracker: "IdleTracker | None" = None
_summarize_new_sessions = True
_summarize_after_idle_for: int | None = None
_idle_summary_model: str = "haiku"  # Model to use for idle summarization
_summary_after_long_running: int | None = (
    None  # Summarize if CLI runs longer than N seconds
)

# Pending processes for new sessions (cwd -> process)
# When a new session is started, we store the process here until the session file appears
_pending_new_session_processes: dict[str, asyncio.subprocess.Process] = {}

# Allowed directories for sandbox access (dynamically added via API)
_allowed_directories: set[str] = set()

# Backend and rendering
_backend: CodingToolBackend | None = None
_css: str | None = None

# Cached models per backend (backend_name -> list of model strings)
_cached_models: dict[str, list[str]] = {}


# Configuration setters/getters


def set_send_enabled(enabled: bool) -> None:
    """Set whether sending messages to Claude is enabled."""
    global _send_enabled
    _send_enabled = enabled


def set_skip_permissions(enabled: bool) -> None:
    """Set whether to skip permission prompts when running Claude."""
    global _skip_permissions
    _skip_permissions = enabled


def set_fork_enabled(enabled: bool) -> None:
    """Set whether the fork button is enabled."""
    global _fork_enabled
    _fork_enabled = enabled


def set_default_send_backend(backend: str | None) -> None:
    """Set the default backend for new sessions."""
    global _default_send_backend
    _default_send_backend = backend


def get_default_send_backend() -> str | None:
    """Get the default backend for new sessions."""
    return _default_send_backend


def set_include_subagents(enabled: bool) -> None:
    """Set whether to include subagent sessions in discovery."""
    global _include_subagents
    _include_subagents = enabled


def get_include_subagents() -> bool:
    """Get whether to include subagent sessions in discovery."""
    return _include_subagents


def set_enable_thinking(enabled: bool) -> None:
    """Set whether to enable thinking level detection."""
    global _enable_thinking
    _enable_thinking = enabled


def set_thinking_budget(budget: int | None) -> None:
    """Set a fixed thinking token budget (overrides keyword detection)."""
    global _thinking_budget
    _thinking_budget = budget


def is_send_enabled() -> bool:
    """Check if sending messages is enabled."""
    return _send_enabled


def is_fork_enabled() -> bool:
    """Check if fork is enabled."""
    return _fork_enabled


def is_skip_permissions() -> bool:
    """Check if skip permissions is enabled."""
    return _skip_permissions


def set_terminal_enabled(enabled: bool) -> None:
    """Set whether the terminal feature is enabled."""
    global _terminal_enabled
    _terminal_enabled = enabled


def is_terminal_enabled() -> bool:
    """Check if terminal feature is enabled."""
    return _terminal_enabled


def set_terminal_motd_file(path: str | None) -> None:
    """Set the MOTD file path to display when a terminal opens."""
    global _terminal_motd_file
    _terminal_motd_file = path


def get_terminal_motd_file() -> str | None:
    """Get the configured MOTD file path."""
    return _terminal_motd_file


# Allowed directories management


def _get_allowed_dirs_path() -> Path:
    """Get the path to the allowed directories config file."""
    config_dir = Path.home() / ".config" / "vibedeck"
    return config_dir / "allowed-dirs.json"


def _load_allowed_directories() -> set[str]:
    """Load allowed directories from config file."""
    config_path = _get_allowed_dirs_path()
    if not config_path.exists():
        return set()
    try:
        with open(config_path, "r") as f:
            data = json.load(f)
            return set(data.get("directories", []))
    except (json.JSONDecodeError, OSError) as e:
        logger.warning(f"Failed to load allowed directories: {e}")
        return set()


def _save_allowed_directories(directories: set[str]) -> bool:
    """Save allowed directories to config file."""
    config_path = _get_allowed_dirs_path()
    try:
        config_path.parent.mkdir(parents=True, exist_ok=True)
        with open(config_path, "w") as f:
            json.dump({"directories": sorted(directories)}, f, indent=2)
        return True
    except OSError as e:
        logger.error(f"Failed to save allowed directories: {e}")
        return False


def add_allowed_directory(directory: str) -> None:
    """Add a directory to the allowed directories for sandbox access.

    Persists to ~/.config/vibedeck/allowed-dirs.json
    """
    _allowed_directories.add(directory)
    _save_allowed_directories(_allowed_directories)


def remove_allowed_directory(directory: str) -> None:
    """Remove a directory from the allowed directories.

    Persists to ~/.config/vibedeck/allowed-dirs.json
    """
    _allowed_directories.discard(directory)
    _save_allowed_directories(_allowed_directories)


def get_allowed_directories() -> list[str]:
    """Get the list of allowed directories for sandbox access."""
    return list(_allowed_directories)


def load_allowed_directories_from_config() -> None:
    """Load allowed directories from config file into memory.

    Should be called on server startup.
    """
    global _allowed_directories
    _allowed_directories = _load_allowed_directories()
    if _allowed_directories:
        logger.info(
            f"Loaded {len(_allowed_directories)} allowed directories from config"
        )


# Summarization configuration


def _reset_summarization_state() -> None:
    """Clear summarization state and stop any existing idle tracker."""
    global \
        _summarizer, \
        _idle_tracker, \
        _summarize_new_sessions, \
        _summarize_after_idle_for, \
        _idle_summary_model, \
        _summary_after_long_running

    if _idle_tracker is not None:
        _idle_tracker.shutdown()

    _summarizer = None
    _idle_tracker = None
    _summarize_new_sessions = True
    _summarize_after_idle_for = None
    _idle_summary_model = "haiku"
    _summary_after_long_running = None

def configure_summarization(
    backend: CodingToolBackend,
    summarize_new_sessions: bool = True,
    summary_log: "Path | None" = None,
    summarize_after_idle_for: int | None = None,
    idle_summary_model: str = "haiku",
    summary_after_long_running: int | None = None,
    summary_prompt: str | None = None,
    summary_prompt_file: "Path | None" = None,
    summary_log_keys: list[str] | None = None,
) -> None:
    """Configure session summarization.

    Args:
        backend: The backend to use for CLI commands.
        summarize_new_sessions: Whether to summarize sessions lacking a summary.
        summary_log: Path to JSONL log file for summaries.
        summarize_after_idle_for: Seconds of idle before re-summarizing.
        idle_summary_model: Model to use for idle summarization (default: haiku).
        summary_after_long_running: Summarize if CLI runs longer than N seconds.
        summary_prompt: Custom prompt template.
        summary_prompt_file: Path to prompt template file.
        summary_log_keys: Keys to include in JSONL log.
    """
    _reset_summarization_state()

    # Create log writer
    log_writer = LogWriter(
        log_path=summary_log,
        log_keys=summary_log_keys,
    )

    # Create summarizer
    _summarizer = Summarizer(
        backend=backend,
        log_writer=log_writer,
        prompt=summary_prompt,
        prompt_file=summary_prompt_file,
        thinking_budget=_thinking_budget,
    )

    # Store settings for later use
    _summarize_new_sessions = summarize_new_sessions
    _summarize_after_idle_for = summarize_after_idle_for
    _idle_summary_model = idle_summary_model
    _summary_after_long_running = summary_after_long_running

    # Create idle tracker if threshold is set
    if summarize_after_idle_for is not None:
        _idle_tracker = IdleTracker(
            idle_threshold_seconds=summarize_after_idle_for,
            summarize_callback=_summarize_session_async,
            get_session_callback=get_session,
        )


def get_summarizer() -> "Summarizer | None":
    """Get the current summarizer instance."""
    return _summarizer


def get_idle_summary_model() -> str:
    """Get the model used for idle summarization."""
    return _idle_summary_model


async def _summarize_session_async(
    session: SessionInfo, model: str | None = None
) -> bool:
    """Async wrapper for summarization.

    Args:
        session: The session to summarize.
        model: Model to use for summarization. If None, uses _idle_summary_model
               (for idle-triggered summarization).

    Returns:
        True if successful, False otherwise.
    """
    if _summarizer is None:
        return False

    # Get the session-specific backend (MultiBackend can't build commands directly)
    session_backend = get_backend_for_session(session.path)
    if not getattr(session_backend, "supports_summarization", lambda: True)():
        logger.info(
            "Skipping summary for %s: backend %s does not support summarization",
            session.session_id,
            session_backend.name,
        )
        return False

    # Create a summarizer with the correct backend for this session
    session_summarizer = Summarizer(
        backend=session_backend,
        log_writer=_summarizer.log_writer,
        prompt=_summarizer.prompt,
        prompt_file=_summarizer.prompt_file,
        thinking_budget=_summarizer.thinking_budget,
    )

    # Use idle model by default (for idle tracker callbacks)
    effective_model = model if model is not None else _idle_summary_model
    result = await session_summarizer.summarize(session, model=effective_model)

    # If summary was successful, broadcast the update and notify idle tracker
    if result.success:
        await _broadcast_session_summary_updated(session.session_id)
        # Mark session as summarized in idle tracker to cancel pending timer
        if _idle_tracker is not None:
            _idle_tracker.mark_session_summarized(session.session_id)

    return result.success


# Backend initialization


def initialize_backend(backend_name: str | None = None, **config) -> CodingToolBackend:
    """Initialize the backend for the server.

    Args:
        backend_name: Name of the backend to use. Defaults to 'claude-code'.
        **config: Backend-specific configuration.

    Returns:
        The initialized backend instance.
    """
    global _backend, _css
    _backend = get_backend(backend_name, **config)
    set_backend(_backend)

    # Load CSS (this is generic, not backend-specific)
    from .rendering import CSS

    _css = CSS

    return _backend


def initialize_multi_backend(**config) -> CodingToolBackend:
    """Initialize a multi-backend that aggregates all available backends.

    Args:
        **config: Backend-specific configuration.

    Returns:
        The initialized multi-backend instance.
    """
    global _backend, _css
    _backend = get_multi_backend(**config)
    set_backend(_backend)

    # Load CSS (this is generic, not backend-specific)
    from .rendering import CSS

    _css = CSS

    return _backend


def get_server_backend() -> CodingToolBackend:
    """Get the current server backend.

    Raises:
        RuntimeError: If backend not initialized.
    """
    if _backend is None:
        raise RuntimeError("Backend not initialized. Call initialize_backend() first.")
    return _backend


def get_renderer_for_session(session_path: Path):
    """Get the appropriate message renderer for a session.

    For MultiBackend, this returns the renderer from the backend that owns
    the session. For single backends, returns that backend's renderer.

    Args:
        session_path: Path to the session file.

    Returns:
        MessageRendererProtocol for the session.
    """
    backend = get_server_backend()

    # Check if this is a MultiBackend with get_renderer_for_session method
    get_renderer = getattr(backend, "get_renderer_for_session", None)
    if get_renderer is not None:
        renderer = get_renderer(session_path)
        if renderer is not None:
            return renderer

    # Fallback to default renderer
    return backend.get_message_renderer()


def get_normalizer_for_session(session_path: Path):
    """Get a normalization function for a session.

    Returns a callable that accepts a raw entry dict and returns
    NormalizedMessage | None. The normalizer is chosen based on
    which backend owns the session.

    Args:
        session_path: Path to the session file.

    Returns:
        A callable (entry: dict) -> NormalizedMessage | None.
    """
    from .backends.shared.normalizer import normalize_message

    backend = get_backend_for_session(session_path)
    key = backend.normalizer_key

    def _normalize(entry: dict):
        return normalize_message(entry, key)

    return _normalize


def get_backend_for_session(session_path: Path) -> CodingToolBackend:
    """Get the appropriate backend for a session.

    For MultiBackend, this returns the backend that owns the session.
    For single backends, returns that backend.

    Args:
        session_path: Path to the session file.

    Returns:
        CodingToolBackend for the session.
    """
    backend = get_server_backend()

    # Check if this is a MultiBackend with get_backend_for_session method
    get_specific = getattr(backend, "get_backend_for_session", None)
    if get_specific is not None:
        specific_backend = get_specific(session_path)
        if specific_backend is not None:
            return specific_backend

    # Fallback to main backend
    return backend


# Broadcasting wrappers (use dependency injection pattern from broadcasting module)


async def _broadcast_session_catchup(info: SessionInfo) -> None:
    """Broadcast session catchup using the renderer (and normalizer if JSON clients connected)."""
    await broadcast_session_catchup(
        info, get_renderer_for_session, get_normalizer_for_session
    )


async def _broadcast_session_summary_updated(session_id: str) -> None:
    """Broadcast session summary update."""
    await broadcast_session_summary_updated(session_id, get_session)


async def _broadcast_session_status(session_id: str) -> None:
    """Broadcast session status update."""
    await broadcast_session_status(session_id, get_session)


async def _broadcast_session_token_usage_updated(session_id: str) -> None:
    """Broadcast session token usage update."""
    await broadcast_session_token_usage_updated(
        session_id, get_session, get_current_backend
    )


# Process management


async def _monitor_attached_process(info: SessionInfo) -> None:
    """Monitor an attached process and clean up when it exits.

    This is called as a background task after attaching a pending process
    to a new session. It waits for the process to complete, then clears
    the process reference and broadcasts the updated status.
    """
    if info.process is None:
        return

    start_time = time.monotonic()
    duration = 0.0

    try:
        await info.process.wait()
        duration = time.monotonic() - start_time
        await process_session_messages(info.session_id)
    except Exception as e:
        logger.error(f"Error monitoring process for {info.session_id}: {e}")
    finally:
        info.process = None

        # Check if we should summarize
        if _summarizer is not None:
            should_summarize = False
            summary_reason = ""

            if _summarize_new_sessions and not info.get_summary_path().exists():
                should_summarize = True
                summary_reason = "new session"
            elif (
                _summary_after_long_running is not None
                and duration >= _summary_after_long_running
            ):
                should_summarize = True
                summary_reason = (
                    f"long-running ({duration:.1f}s >= {_summary_after_long_running}s)"
                )

            if should_summarize:
                session_backend = get_backend_for_session(info.path)
                session_model = session_backend.get_session_model(info.path)
                logger.info(
                    f"Triggering summary for {summary_reason} session {info.session_id} with model {session_model}"
                )
                asyncio.create_task(_summarize_session_async(info, model=session_model))

        await _broadcast_session_status(info.session_id)


def _attach_pending_process(info: SessionInfo) -> bool:
    """Try to attach a pending process to a newly discovered session.

    When a new session is started via /sessions/new, the process is stored
    in _pending_new_session_processes until the session file appears. This
    function checks if there's a pending process for this session's project
    path and attaches it.

    Returns:
        True if a process was attached, False otherwise.
    """
    if not info.project_path:
        return False

    cwd_key = str(Path(info.project_path).resolve())
    proc = _pending_new_session_processes.pop(cwd_key, None)

    if proc is not None:
        info.process = proc
        logger.debug(f"Attached pending process to session {info.session_id}")
        return True

    return False


async def run_cli_for_session(
    session_id: str, message: str, fork: bool = False
) -> None:
    """Send a message to a coding session and track the process.

    Args:
        session_id: The session to send the message to
        message: The message to send
        fork: If True, fork the session to create a new one with conversation history
    """
    from .permissions import parse_permission_denials

    info = get_session(session_id)
    if info is None:
        logger.error(f"Session not found: {session_id}")
        return

    # Get the specific backend for this session
    backend = get_backend_for_session(info.path)

    # Determine if we should use permission detection
    # Only for backends that support it and when not using skip_permissions
    use_permission_detection = (
        not _skip_permissions
        and hasattr(backend, "supports_permission_detection")
        and backend.supports_permission_detection()
    )

    try:
        # Ensure session is indexed (backend-specific)
        backend.ensure_session_indexed(session_id)

        # Build command using session-specific backend
        output_format = "stream-json" if use_permission_detection else None
        add_dirs = get_allowed_directories() or None
        build_cmd = backend.build_fork_command if fork else backend.build_send_command
        build_sig = inspect.signature(build_cmd)
        resume_model = None
        if "model" in build_sig.parameters:
            get_resume_model = getattr(backend, "get_resume_model", None)
            if callable(get_resume_model):
                resume_model = get_resume_model(info.path)

        if fork and resume_model:
            cmd_spec = backend.build_fork_command(
                session_id,
                message,
                _skip_permissions,
                model=resume_model,
                output_format=output_format,
                add_dirs=add_dirs,
            )
        elif fork:
            cmd_spec = backend.build_fork_command(
                session_id, message, _skip_permissions, output_format, add_dirs
            )
        elif resume_model:
            cmd_spec = backend.build_send_command(
                session_id,
                message,
                _skip_permissions,
                model=resume_model,
                output_format=output_format,
                add_dirs=add_dirs,
            )
        else:
            cmd_spec = backend.build_send_command(
                session_id, message, _skip_permissions, output_format, add_dirs
            )

        # Determine working directory from session info
        cwd = (
            info.project_path
            if info.project_path and Path(info.project_path).is_dir()
            else None
        )

        # Get thinking token budget: fixed budget > keyword detection > disabled
        if _thinking_budget is not None:
            # Fixed budget takes precedence
            thinking_env = {"MAX_THINKING_TOKENS": str(_thinking_budget)}
            env = {**os.environ, **thinking_env}
            logger.info(
                f"Sending message to {session_id} with fixed thinking budget "
                f"({_thinking_budget} tokens)"
            )
        elif _enable_thinking:
            # Keyword-based detection
            thinking_level = detect_thinking_level(message)
            thinking_env = {"MAX_THINKING_TOKENS": str(thinking_level.budget_tokens)}
            env = {**os.environ, **thinking_env}
            logger.info(
                f"Sending message to {session_id} with thinking level "
                f"'{thinking_level.name}' ({thinking_level.budget_tokens} tokens)"
            )
        else:
            env = os.environ.copy()
            logger.info(
                f"Sending message to {session_id} (thinking disabled by default)"
            )

        # Track start time for long-running session detection
        start_time = time.monotonic()
        duration = 0.0  # Initialize in case of early exception

        # Capture stdout if using permission detection
        stdout_pipe = (
            asyncio.subprocess.PIPE
            if use_permission_detection
            else asyncio.subprocess.DEVNULL
        )
        # Use PIPE for stdin if we need to pass message content
        stdin_pipe = (
            asyncio.subprocess.PIPE if cmd_spec.stdin else asyncio.subprocess.DEVNULL
        )

        proc = await asyncio.create_subprocess_exec(
            *cmd_spec.args,
            cwd=cwd,
            env=env,
            stdin=stdin_pipe,
            stdout=stdout_pipe,
            stderr=asyncio.subprocess.PIPE,
        )

        # Write message to stdin if provided
        if cmd_spec.stdin:
            proc.stdin.write(cmd_spec.stdin.encode())
            await proc.stdin.drain()
            proc.stdin.close()
            await proc.stdin.wait_closed()

        # Only track process on the original session if not forking
        # (forked session will create its own session file and be picked up by file watcher)
        if not fork:
            info.process = proc
            await _broadcast_session_status(session_id)

        # Wait for completion
        stdout, stderr = await proc.communicate()
        duration = time.monotonic() - start_time

        if proc.returncode != 0:
            logger.error(f"CLI process failed for {session_id}: {stderr.decode()}")

        # Check for permission denials if using permission detection
        if use_permission_detection and stdout:
            denials = parse_permission_denials(stdout.decode())
            if denials:
                logger.info(
                    f"Permission denials detected for {session_id}: "
                    f"{[d['tool_name'] for d in denials]}"
                )
                await broadcast_permission_denied(session_id, denials, message)
                # Don't process queue or summarize - wait for user decision
                if not fork:
                    info.process = None
                    await _broadcast_session_status(session_id)
                return

        if not fork:
            await process_session_messages(session_id)

    except Exception as e:
        logger.error(f"Error running CLI for {session_id}: {e}")

    finally:
        if not fork:
            info.process = None

            # Determine if we should summarize:
            # 1. New session (no summary yet) - always summarize immediately
            # 2. Long-running session (duration > threshold) - summarize to use warm cache
            should_summarize = False
            summary_reason = ""

            if _summarizer is not None:
                if _summarize_new_sessions and not info.get_summary_path().exists():
                    should_summarize = True
                    summary_reason = "new session"
                elif (
                    _summary_after_long_running is not None
                    and duration >= _summary_after_long_running
                ):
                    should_summarize = True
                    summary_reason = f"long-running ({duration:.1f}s >= {_summary_after_long_running}s)"

            if should_summarize:
                # Use the same model that was used for the conversation
                session_backend = get_backend_for_session(info.path)
                session_model = session_backend.get_session_model(info.path)
                logger.info(
                    f"Triggering summary for {summary_reason} session {session_id} with model {session_model}"
                )
                asyncio.create_task(_summarize_session_async(info, model=session_model))

            # Process queue if messages waiting
            if info.message_queue:
                next_message = info.message_queue.pop(0)
                asyncio.create_task(run_cli_for_session(session_id, next_message))

            await _broadcast_session_status(session_id)


# Session message processing


async def process_session_messages(session_id: str) -> int:
    """Read new messages from a session and broadcast to clients."""
    info = get_session(session_id)
    if info is None:
        return 0

    # Get the renderer for this specific session
    renderer = get_renderer_for_session(info.path)
    backend = get_backend_for_session(info.path)

    # Only set up normalizer if JSON clients are connected (lazy normalization)
    normalizer = None
    if has_json_clients():
        normalizer = get_normalizer_for_session(info.path)

    new_entries = info.tailer.read_new_lines()
    logger.debug(
        "process_session_messages backend=%s session=%s path=%s entries=%s running=%s",
        backend.name,
        session_id,
        info.path,
        len(new_entries),
        info.process is not None,
    )
    for entry in new_entries:
        # HTML broadcast
        html = renderer.render_message(entry)
        if html:
            logger.debug(
                "Broadcasting HTML message for %s from payload=%s role=%s",
                session_id,
                entry.get("payload", {}).get("type"),
                entry.get("payload", {}).get("role"),
            )
            await broadcast_message(session_id, html)

        # JSON broadcast (only when clients connected)
        if normalizer is not None:
            try:
                msg = normalizer(entry)
                if msg is not None:
                    await broadcast_json_message(session_id, msg)
            except Exception as e:
                logger.warning(f"Failed to normalize entry for JSON broadcast: {e}")

    # Broadcast updated waiting state and token usage after processing messages
    if new_entries:
        await _broadcast_session_status(session_id)
        await _broadcast_session_token_usage_updated(session_id)
    return len(new_entries)


async def process_session_messages_with_settle(
    session_id: str,
    settle_delay: float = 0.20,
    max_rounds: int = 8,
    idle_rounds_to_stop: int = 2,
) -> None:
    """Process new messages and poll briefly for bursty transcript writes.

    Some CLIs write transcript records in short bursts that do not always
    produce a second filesystem event for the later lines. While a session
    process is still running, poll the file for a short settling window.
    """
    await process_session_messages(session_id)

    info = get_session(session_id)
    if info is None or info.process is None:
        logger.debug(
            "Skipping settle re-read for %s (session missing or no running process)",
            session_id,
        )
        return

    try:
        last_size = info.path.stat().st_size
    except OSError:
        last_size = -1

    idle_rounds = 0
    for round_idx in range(max_rounds):
        await asyncio.sleep(settle_delay)

        info = get_session(session_id)
        if info is None or info.process is None:
            logger.debug(
                "Stopping settle polling for %s at round %s (session missing or process ended)",
                session_id,
                round_idx + 1,
            )
            return

        try:
            current_size = info.path.stat().st_size
        except OSError:
            current_size = -1

        logger.debug(
            "Settle poll round %s/%s for %s (size %s -> %s)",
            round_idx + 1,
            max_rounds,
            session_id,
            last_size,
            current_size,
        )
        new_count = await process_session_messages(session_id)

        if new_count == 0 and current_size == last_size:
            idle_rounds += 1
            if idle_rounds >= idle_rounds_to_stop:
                logger.debug(
                    "Stopping settle polling for %s after %s idle rounds",
                    session_id,
                    idle_rounds,
                )
                return
        else:
            idle_rounds = 0

        last_size = current_size


async def process_session_summary_update(session_id: str) -> None:
    """Reload summary data for a session and broadcast to clients.

    Called when a session's summary file is created or modified.
    """
    logger.debug(f"Processing summary update for session {session_id}")
    info = get_session(session_id)
    if info is None:
        logger.debug(f"Session {session_id} not found, skipping summary update")
        return

    # Reload summary data from file
    summary_path = info.get_summary_path()
    logger.debug(f"Looking for summary at: {summary_path}")
    if info.load_summary():
        logger.info(f"Summary updated for session {session_id}: {info.summary_title}")
        await _broadcast_session_summary_updated(session_id)
    else:
        logger.debug(f"Failed to load summary for session {session_id}")


async def check_for_new_sessions() -> None:
    """Check for new session files and add them.

    Uses the backend's find_recent_sessions to discover new sessions,
    which handles backend-specific file patterns (JSONL for Claude Code,
    JSON for OpenCode).
    """
    backend = get_server_backend()

    # Use backend to find recent sessions (handles pattern differences)
    try:
        recent = backend.find_recent_sessions(
            limit=MAX_SESSIONS, include_subagents=get_include_subagents()
        )
        for f in recent:
            if f not in get_known_session_files():
                async with get_sessions_lock():
                    info, evicted_id = add_session(f)
                    if evicted_id:
                        await broadcast_session_removed(evicted_id)
                    if info:
                        # Check if there's a pending process for this session's project path
                        attached = _attach_pending_process(info)
                        await broadcast_session_added(info)
                        # If we attached a process, broadcast status and start monitoring
                        if attached:
                            await _broadcast_session_status(info.session_id)
                            # Monitor process completion in background
                            asyncio.create_task(_monitor_attached_process(info))
    except Exception as e:
        logger.warning(f"Failed to check for new sessions: {e}")


# Watch loop


def _prune_expired_watch_suppressions(now: float | None = None) -> None:
    """Drop expired temporary watch suppressions."""
    if now is None:
        now = time.monotonic()

    expired = [path for path, expires_at in _suppressed_watch_paths.items() if expires_at <= now]
    for path in expired:
        _suppressed_watch_paths.pop(path, None)


def _suppress_related_db_watch_events(changed_path: Path) -> None:
    """Temporarily ignore follow-up SQLite artifact events caused by our own reads."""
    db_base = changed_path
    if changed_path.name.endswith("-wal") or changed_path.name.endswith("-shm"):
        db_base = changed_path.with_name(changed_path.name.rsplit("-", 1)[0])

    expires_at = time.monotonic() + DB_WATCH_SUPPRESSION_SECONDS
    for suffix in ("", "-wal", "-shm"):
        artifact = db_base.with_name(f"{db_base.name}{suffix}")
        _suppressed_watch_paths[artifact] = expires_at


def _build_watch_filter(backend: CodingToolBackend):
    """Build a watchfiles filter that matches backend watch rules."""

    def watch_filter(change, path_str: str) -> bool:
        del change
        _prune_expired_watch_suppressions()
        path = Path(path_str)
        if path in _suppressed_watch_paths:
            return False
        return backend.should_watch_file(path)

    return watch_filter


def _get_updated_sessions_for_db_change(
    backend: CodingToolBackend,
    changed_path: Path,
    current_time: float | None = None,
    suppress_watch_events: bool = True,
) -> set[str]:
    """Resolve a SQLite watch event into updated OpenCode session IDs."""
    if suppress_watch_events:
        _suppress_related_db_watch_events(changed_path)

    if current_time is None:
        current_time = time.time()

    tracked_sessions = get_sessions()
    if not tracked_sessions:
        return set()

    tracked_ids = list(tracked_sessions.keys())
    synthetic_ids = [sid for sid in tracked_ids if sid.startswith("ses_")]
    if not synthetic_ids:
        return set()

    get_updated = getattr(backend, "get_updated_sessions", None)
    if not get_updated:
        return set()

    updated_ids = get_updated(synthetic_ids, current_time - 5)
    logger.debug("SQLite change detected, %d sessions updated", len(updated_ids))
    return set(updated_ids)


def _backend_has_db_polling(backend: CodingToolBackend) -> bool:
    """Whether the backend stack includes an OpenCode-style SQLite source."""
    backends = [backend]
    get_backends = getattr(backend, "get_backends", None)
    if get_backends:
        backends = get_backends()

    for candidate in backends:
        get_db_path = getattr(candidate, "get_db_path", None)
        get_updated = getattr(candidate, "get_updated_sessions", None)
        if not get_db_path or not get_updated:
            continue
        db_path = get_db_path()
        if db_path and db_path.exists():
            return True
    return False


def _get_watch_directories() -> list[Path]:
    """Get all directories to watch based on the current backend.

    For MultiBackend, returns directories from all backends.
    For single backend, returns just that backend's directory.
    """
    backend = get_server_backend()

    # Check if this is a MultiBackend with get_all_project_dirs method
    if hasattr(backend, "get_all_project_dirs"):
        return backend.get_all_project_dirs()

    # Single backend
    projects_dir = get_projects_dir()
    return [projects_dir] if projects_dir.exists() else []


async def watch_loop() -> None:
    """Background task that watches for file changes.

    Watches the backend's projects directory for file changes. The backend
    determines which files should trigger updates via should_watch_file()
    and how to map changed files to session IDs via get_session_id_from_changed_file().

    For MultiBackend, watches all directories from all backends.
    """
    backend = get_server_backend()
    watch_dirs = _get_watch_directories()

    if not watch_dirs:
        logger.warning("No project directories found to watch")
        return

    # Filter to existing directories only
    watch_dirs = [d for d in watch_dirs if d.exists()]
    if not watch_dirs:
        logger.warning("No existing project directories to watch")
        return

    logger.info(f"Starting watch loop for {len(watch_dirs)} directories: {watch_dirs}")

    try:
        async for changes in watchfiles.awatch(
            *watch_dirs, watch_filter=_build_watch_filter(backend)
        ):
            # Collect sessions to process and whether to check for new sessions
            # This minimizes lock contention by batching operations
            sessions_to_process: set[str] = set()
            sessions_with_summary_updates: set[str] = set()
            need_new_session_check = False
            db_changed_path: Path | None = None

            for change_type, changed_path in changes:
                changed_path = Path(changed_path)

                # Use backend to check if file should be watched
                if not backend.should_watch_file(changed_path):
                    continue

                # Check if this is a SQLite database file change
                is_db_file = getattr(backend, "is_db_file", None)
                if is_db_file and is_db_file(changed_path):
                    if db_changed_path is None:
                        db_changed_path = changed_path
                    need_new_session_check = True
                    continue

                # Get session ID from the changed file path
                # For Claude Code: session ID is the filename (or from summary file)
                # For OpenCode legacy: session ID is extracted from message/part path
                session_id = backend.get_session_id_from_changed_file(changed_path)
                logger.debug(
                    "File change backend=%s type=%s path=%s -> session=%s",
                    backend.name,
                    change_type.name,
                    changed_path,
                    session_id,
                )

                if session_id and get_session(session_id) is not None:
                    # Known session
                    # Check if this is a summary file change
                    is_summary = getattr(backend, "is_summary_file", None)
                    logger.debug(
                        f"is_summary_file method: {is_summary}, "
                        f"file: {changed_path.name}, "
                        f"result: {is_summary(changed_path) if is_summary else 'N/A'}"
                    )
                    if is_summary and is_summary(changed_path):
                        logger.debug(f"Summary file detected for session {session_id}")
                        sessions_with_summary_updates.add(session_id)
                    else:
                        # Regular session file - queue for message processing
                        logger.debug(
                            "Queued session %s for message processing from file event %s",
                            session_id,
                            changed_path,
                        )
                        sessions_to_process.add(session_id)
                else:
                    # Unknown session - might be a new session file
                    logger.debug(
                        "Unknown watched file change for backend=%s path=%s; scheduling new-session scan",
                        backend.name,
                        changed_path,
                    )
                    need_new_session_check = True

            if db_changed_path is not None:
                sessions_to_process.update(
                    _get_updated_sessions_for_db_change(backend, db_changed_path)
                )

            # Check for new sessions (needs lock, but only once per batch)
            if need_new_session_check:
                await check_for_new_sessions()

            # Process messages for known sessions (doesn't need lock)
            for session_id in sessions_to_process:
                await process_session_messages_with_settle(session_id)
                # Notify idle tracker of activity (for re-summarization after idle)
                if _idle_tracker is not None:
                    _idle_tracker.on_session_activity(session_id)

            # Process summary updates for known sessions
            for session_id in sessions_with_summary_updates:
                await process_session_summary_update(session_id)

    except asyncio.CancelledError:
        logger.info("Watch loop cancelled")
        raise
    except Exception as e:
        logger.error(f"Watch loop error: {e}")


async def db_poll_loop() -> None:
    """Poll SQLite-backed backends for updates without watching WAL files."""
    backend = get_server_backend()
    last_new_session_poll = 0.0

    try:
        while True:
            await asyncio.sleep(DB_POLL_INTERVAL_SECONDS)

            current_time = time.time()
            sessions_to_process = _get_updated_sessions_for_db_change(
                backend,
                Path("opencode.db"),
                current_time=current_time,
                suppress_watch_events=False,
            )

            for session_id in sessions_to_process:
                await process_session_messages_with_settle(session_id)
                if _idle_tracker is not None:
                    _idle_tracker.on_session_activity(session_id)

            if (
                current_time - last_new_session_poll
                >= DB_NEW_SESSION_POLL_INTERVAL_SECONDS
            ):
                await check_for_new_sessions()
                last_new_session_poll = current_time
    except asyncio.CancelledError:
        logger.info("DB poll loop cancelled")
        raise
    except Exception as e:
        logger.error(f"DB poll loop error: {e}")


# FastAPI app setup


@asynccontextmanager
async def lifespan(app: FastAPI):
    """Manage server lifecycle - start/stop file watcher."""
    global _watch_task, _db_poll_task

    backend = get_server_backend()

    # Load allowed directories from config file
    load_allowed_directories_from_config()

    # Configure session routes with server dependencies
    configure_session_routes(
        get_server_backend=get_server_backend,
        get_backend_for_session=get_backend_for_session,
        is_send_enabled=is_send_enabled,
        is_fork_enabled=is_fork_enabled,
        is_skip_permissions=is_skip_permissions,
        get_default_send_backend=get_default_send_backend,
        get_allowed_directories=get_allowed_directories,
        add_allowed_directory=add_allowed_directory,
        run_cli_for_session=run_cli_for_session,
        broadcast_session_status=_broadcast_session_status,
        summarize_session_async=_summarize_session_async,
        get_summarizer=get_summarizer,
        get_idle_summary_model=get_idle_summary_model,
        cached_models=_cached_models,
    )

    # Startup: find recent sessions
    recent = backend.find_recent_sessions(
        limit=MAX_SESSIONS, include_subagents=get_include_subagents()
    )
    async with get_sessions_lock():
        for path in recent:
            add_session(path, evict_oldest=False)  # No eviction needed at startup

    if session_count() == 0:
        logger.warning("No session files found")

    # Start watching for changes
    _watch_task = asyncio.create_task(watch_loop())
    if _backend_has_db_polling(backend):
        _db_poll_task = asyncio.create_task(db_poll_loop())

    # Start idle tracker if configured
    if _idle_tracker is not None:
        _idle_tracker.start()
        logger.info("Idle summarization tracker started")

    yield

    # Shutdown
    if _idle_tracker is not None:
        _idle_tracker.shutdown()

    if _watch_task:
        _watch_task.cancel()
        try:
            await _watch_task
        except asyncio.CancelledError:
            pass

    if _db_poll_task:
        _db_poll_task.cancel()
        try:
            await _db_poll_task
        except asyncio.CancelledError:
            pass


app = FastAPI(title="VibeDeck", lifespan=lifespan)

# Include routers
app.include_router(sessions_router)
app.include_router(files_router)
app.include_router(archives_router)
app.include_router(diff_router)
app.include_router(statuses_router)


# Core routes (index, static, SSE events, health)


@app.get("/", response_class=HTMLResponse)
async def index() -> HTMLResponse:
    """Serve the main live transcript page."""
    from .rendering import get_template

    template = get_template("live.html")
    html = template.render(css=_css)
    return HTMLResponse(content=html)


@app.get("/static/js/{filename}")
async def serve_js(filename: str) -> Response:
    """Serve JavaScript modules from the static/js directory."""
    from importlib.resources import files

    # Validate filename (only allow .js files, no path traversal)
    if not filename.endswith(".js") or "/" in filename or "\\" in filename:
        raise HTTPException(status_code=404, detail="Not found")

    try:
        content = (
            files("vibedeck")
            .joinpath("templates", "static", "js", filename)
            .read_text()
        )
        return Response(
            content=content,
            media_type="application/javascript",
            headers={"Cache-Control": "no-cache"},
        )
    except FileNotFoundError:
        raise HTTPException(status_code=404, detail="Not found")


async def event_generator(request: Request) -> AsyncGenerator[dict, None]:
    """Generate SSE events for a client."""
    queue: asyncio.Queue = asyncio.Queue(maxsize=HTML_SSE_QUEUE_MAXSIZE)
    add_client(queue)

    try:
        # Send sessions list (lazy loading: no messages sent here)
        async with get_sessions_lock():
            sessions_data = get_sessions_list()
        yield {
            "event": "sessions",
            "data": json.dumps(
                {"sessions": sessions_data, "maxSessions": MAX_SESSIONS}
            ),
        }

        # Signal catchup complete immediately (messages loaded on-demand via REST API)
        yield {"event": "catchup_complete", "data": "{}"}

        # Stream new events
        ping_interval = 30  # seconds

        while True:
            # Check if client disconnected
            if await request.is_disconnected():
                break

            try:
                # Wait for new event with timeout for ping
                event = await asyncio.wait_for(queue.get(), timeout=ping_interval)
                yield {
                    "event": event["event"],
                    "data": json.dumps(event["data"]),
                }
            except asyncio.TimeoutError:
                # Send ping to keep connection alive
                yield {"event": "ping", "data": "{}"}

    finally:
        remove_client(queue)


@app.get("/events")
async def events(request: Request) -> EventSourceResponse:
    """SSE endpoint for live transcript updates (HTML)."""
    return EventSourceResponse(event_generator(request))


async def json_event_generator(request: Request) -> AsyncGenerator[dict, None]:
    """Generate SSE events for a JSON client.

    Same event types as event_generator() but message events contain
    normalized JSON content blocks instead of HTML.
    """
    queue: asyncio.Queue = asyncio.Queue(maxsize=JSON_SSE_QUEUE_MAXSIZE)
    add_json_client(queue)

    try:
        # Send sessions list (same as HTML stream)
        async with get_sessions_lock():
            sessions_data = get_sessions_list()
        yield {
            "event": "sessions",
            "data": json.dumps(
                {"sessions": sessions_data, "maxSessions": MAX_SESSIONS}
            ),
        }

        # Signal catchup complete
        yield {"event": "catchup_complete", "data": "{}"}

        # Stream new events
        ping_interval = 30  # seconds

        while True:
            if await request.is_disconnected():
                break

            try:
                event = await asyncio.wait_for(queue.get(), timeout=ping_interval)
                yield {
                    "event": event["event"],
                    "data": json.dumps(event["data"]),
                }
            except asyncio.TimeoutError:
                yield {"event": "ping", "data": "{}"}

    finally:
        remove_json_client(queue)


@app.get("/events/json")
async def events_json(request: Request) -> EventSourceResponse:
    """SSE endpoint for live transcript updates (structured JSON)."""
    return EventSourceResponse(json_event_generator(request))


@app.get("/health")
async def health() -> dict:
    """Health check endpoint."""
    return {
        "status": "ok",
        "sessions": session_count(),
        "clients": len(get_clients()),
    }


# Terminal routes


@app.get("/api/terminal/enabled")
async def terminal_enabled() -> dict:
    """Check if terminal feature is enabled."""
    from .terminal import is_terminal_available

    return {
        "enabled": _terminal_enabled and is_terminal_available(),
    }


@app.get("/api/terminal/shells")
async def terminal_shells() -> dict:
    """Get available shells and default shell."""
    from .terminal import terminal_manager

    if not _terminal_enabled:
        raise HTTPException(status_code=403, detail="Terminal feature is disabled")

    return terminal_manager.get_shells()


@app.websocket("/ws/terminal")
async def terminal_websocket(websocket: WebSocket, cwd: str | None = None) -> None:
    """WebSocket endpoint for terminal I/O.

    Query params:
        cwd: Working directory for the terminal (optional)
    """
    from .terminal import is_terminal_available, terminal_manager

    if not _terminal_enabled:
        await websocket.close(code=4003, reason="Terminal feature disabled")
        return

    if not is_terminal_available():
        await websocket.close(code=4003, reason="ptyprocess not installed")
        return

    await terminal_manager.handle_websocket(websocket, cwd, motd_file=_terminal_motd_file)
