"""
Claude Board Web Server

FastAPI-based server that:
1. Receives hook events from Claude Code
2. Serves a web UI for approving/denying requests
3. Uses WebSocket for real-time updates
4. Provides Unix socket RPC API for GUI tools
5. Future: Coordinates with Bluetooth clients
"""

from __future__ import annotations

import asyncio
import contextlib
import json
import logging
import urllib.parse
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING

from fastapi import (
    APIRouter,
    FastAPI,
    HTTPException,
    Request,
    WebSocket,
    WebSocketDisconnect,
)
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles

from .chat.session import ChatSessionManager
from .config import AppConfig
from .models import DecisionStatus, PermissionRequest
from .state import INTERACTIVE_TOOLS, StateManager

if TYPE_CHECKING:
    from collections.abc import Awaitable, Callable

    from .types import DecisionInput, HookInput, TodoInput, YoloInput

# Set up module logger
logger = logging.getLogger(__name__)

# Web UI static files directory
WEB_DIR = Path(__file__).parent / "web"


class RPCServer:
    """
    Unix socket RPC server for programmatic control.

    Protocol: JSON-RPC 2.0 over Unix domain socket.
    Each message is newline-delimited JSON.

    Available methods:
    - get_state: Get current console state
    - approve: Approve a pending request
    - deny: Deny a pending request
    - set_yolo: Set YOLO mode (global)
    - set_session_yolo: Set YOLO mode for a specific session
    - reset: Reset session
    - get_todos: Get TODO list
    - health: Health check

    Example request:
    {"jsonrpc": "2.0", "method": "get_state", "id": 1}

    Example response:
    {"jsonrpc": "2.0", "result": {...}, "id": 1}
    """

    def __init__(self, socket_path: Path, state_manager: StateManager) -> None:
        self.socket_path = socket_path
        self.state_manager = state_manager
        self.server: asyncio.Server | None = None
        self._handlers: dict[
            str, Callable[[dict[str, object]], Awaitable[dict[str, object]]]
        ] = {}
        self._setup_handlers()

    def _setup_handlers(self) -> None:
        """Register RPC method handlers"""
        self._handlers = {
            "get_state": self._handle_get_state,
            "approve": self._handle_approve,
            "deny": self._handle_deny,
            "set_yolo": self._handle_set_yolo,
            "set_session_yolo": self._handle_set_session_yolo,
            "reset": self._handle_reset,
            "get_todos": self._handle_get_todos,
            "health": self._handle_health,
            "subscribe": self._handle_subscribe,
        }

    async def _handle_get_state(self, _params: dict[str, object]) -> dict[str, object]:
        """Get current console state"""
        return self.state_manager.state.to_dict()

    async def _handle_approve(self, params: dict[str, object]) -> dict[str, object]:
        """Approve a pending request"""
        request_id = params.get("request_id")
        if not request_id or not isinstance(request_id, str):
            raise ValueError("request_id is required")
        success = await self.state_manager.make_decision(
            request_id, DecisionStatus.APPROVED
        )
        return {"success": success}

    async def _handle_deny(self, params: dict[str, object]) -> dict[str, object]:
        """Deny a pending request"""
        request_id = params.get("request_id")
        if not request_id or not isinstance(request_id, str):
            raise ValueError("request_id is required")
        success = await self.state_manager.make_decision(
            request_id, DecisionStatus.DENIED
        )
        return {"success": success}

    async def _handle_set_yolo(self, params: dict[str, object]) -> dict[str, object]:
        """Set YOLO mode"""
        enabled = bool(params.get("enabled", False))
        await self.state_manager.set_yolo_mode(enabled=enabled)
        return {"yolo_mode": enabled}

    async def _handle_set_session_yolo(
        self, params: dict[str, object]
    ) -> dict[str, object]:
        """Set YOLO mode for a specific session"""
        session_id = params.get("session_id")
        if not session_id or not isinstance(session_id, str):
            raise ValueError("session_id is required")
        enabled = bool(params.get("enabled", False))
        success = await self.state_manager.set_session_yolo_mode(
            session_id, enabled=enabled
        )
        return {"success": success, "session_id": session_id, "yolo_mode": enabled}

    async def _handle_reset(self, _params: dict[str, object]) -> dict[str, object]:
        """Reset session"""
        await self.state_manager.reset_session()
        return {"success": True}

    async def _handle_get_todos(self, _params: dict[str, object]) -> dict[str, object]:
        """Get TODO list"""
        return {"todos": [t.to_dict() for t in self.state_manager.state.todos]}

    async def _handle_health(self, _params: dict[str, object]) -> dict[str, object]:
        """Health check"""
        return {
            "status": "ok",
            "version": "0.1.0",
            "yolo_mode": self.state_manager.state.yolo_mode,
            "has_pending": self.state_manager.state.pending_request is not None,
        }

    async def _handle_subscribe(self, _params: dict[str, object]) -> dict[str, object]:
        """Subscribe to state updates.

        Returns current state; client should keep
        connection open.
        """
        return self.state_manager.state.to_dict()

    async def _handle_client(
        self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
    ) -> None:
        """Handle a connected RPC client"""
        _peer = writer.get_extra_info("peername")
        _subscribe_mode = False

        try:
            while True:
                # Read a line (newline-delimited JSON)
                data = await reader.readline()
                if not data:
                    break

                try:
                    request: dict[str, object] = json.loads(data.decode("utf-8"))
                except json.JSONDecodeError as e:
                    response: dict[str, object] = {
                        "jsonrpc": "2.0",
                        "error": {"code": -32700, "message": f"Parse error: {e}"},
                        "id": None,
                    }
                    writer.write((json.dumps(response) + "\n").encode("utf-8"))
                    await writer.drain()
                    continue

                # Validate JSON-RPC request
                request_id = request.get("id")
                method = request.get("method")
                params_raw = request.get("params", {})
                params: dict[str, object] = (
                    params_raw if isinstance(params_raw, dict) else {}
                )

                if not method or not isinstance(method, str):
                    response = {
                        "jsonrpc": "2.0",
                        "error": {
                            "code": -32600,
                            "message": "Invalid request: method is required",
                        },
                        "id": request_id,
                    }
                else:
                    handler = self._handlers.get(method)
                    if not handler:
                        response = {
                            "jsonrpc": "2.0",
                            "error": {
                                "code": -32601,
                                "message": f"Method not found: {method}",
                            },
                            "id": request_id,
                        }
                    else:
                        try:
                            result = await handler(params)
                            response = {
                                "jsonrpc": "2.0",
                                "result": result,
                                "id": request_id,
                            }

                            # If subscribing, enter subscription mode
                            if method == "subscribe":
                                _subscribe_mode = True
                        except (ValueError, KeyError, TypeError) as e:
                            response = {
                                "jsonrpc": "2.0",
                                "error": {"code": -32000, "message": str(e)},
                                "id": request_id,
                            }

                writer.write((json.dumps(response) + "\n").encode("utf-8"))
                await writer.drain()

        except asyncio.CancelledError:
            pass
        except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError):
            pass
        finally:
            writer.close()
            with contextlib.suppress(OSError):
                await writer.wait_closed()

    async def start(self) -> None:
        """Start the RPC server"""
        # Ensure parent directory exists
        self.socket_path.parent.mkdir(parents=True, exist_ok=True)

        # Remove existing socket file
        if self.socket_path.exists():
            self.socket_path.unlink()

        self.server = await asyncio.start_unix_server(
            self._handle_client, path=str(self.socket_path)
        )

        # Set socket permissions (readable/writable by owner only)
        self.socket_path.chmod(0o600)

    async def stop(self) -> None:
        """Stop the RPC server"""
        if self.server:
            self.server.close()
            await self.server.wait_closed()

        # Clean up socket file
        if self.socket_path.exists():
            self.socket_path.unlink()


# ============================================================================
# Module-level route handlers (APIRouter)
# ============================================================================

router = APIRouter()


@router.get("/")
async def root() -> FileResponse:
    """Serve the main web UI"""
    return FileResponse(WEB_DIR / "index.html")


@router.get("/manifest.json")
async def manifest() -> FileResponse:
    """Serve PWA manifest (must be at root scope)"""
    return FileResponse(
        WEB_DIR / "manifest.json",
        media_type="application/manifest+json",
    )


@router.get("/sw.js")
async def service_worker() -> FileResponse:
    """Serve service worker (must be at root scope)"""
    return FileResponse(
        WEB_DIR / "sw.js",
        media_type="application/javascript",
    )


@router.get("/api/state")
async def get_state(request: Request) -> dict[str, object]:
    """Get current console state (including chat_sessions)"""
    state_manager: StateManager = request.app.state.state_manager
    session_manager: ChatSessionManager = request.app.state.session_manager
    state = state_manager.state.to_dict()
    # Include chat_sessions for consistency with WebSocket push
    state["chat_sessions"] = session_manager.list_sessions()
    return state


@router.get("/api/health")
async def health_check(request: Request) -> dict[str, object]:
    """Health check endpoint"""
    connected_clients: list[WebSocket] = request.app.state.connected_clients
    state_manager: StateManager = request.app.state.state_manager
    return {
        "status": "ok",
        "version": "0.1.0",
        "connected_clients": len(connected_clients),
        "ble_connected": state_manager.state.ble_connected,
    }


@router.post("/api/hook")
async def handle_hook(request: Request, hook_input: HookInput) -> dict[str, object]:
    """
    Handle incoming hook events from Claude Code.

    For PreToolUse/PermissionRequest hooks, this blocks until a decision
    is made or timeout occurs.
    """
    state_manager: StateManager = request.app.state.state_manager
    connected_clients: list[WebSocket] = request.app.state.connected_clients
    config: AppConfig = request.app.state.config
    event = hook_input.hook_event_name

    # Get project path from hook input
    project_path = hook_input.project_path or hook_input.cwd or ""

    if event in ["PreToolUse", "PermissionRequest"]:
        # Interactive tools (AskUserQuestion, ExitPlanMode, etc.) need user
        # input directly in Claude Code's TUI — never route through approval flow
        if hook_input.tool_name in INTERACTIVE_TOOLS:
            if event == "PreToolUse":
                return {
                    "hookSpecificOutput": {
                        "hookEventName": "PreToolUse",
                        "permissionDecision": "ask",
                        "permissionDecisionReason": (
                            "Interactive tool requires TUI"
                            f" input: {hook_input.tool_name}"
                        ),
                    }
                }
            # PermissionRequest doesn't support "ask", exit with error
            # to let Claude Code handle it natively
            raise HTTPException(
                status_code=422,
                detail=(
                    f"Interactive tool {hook_input.tool_name}"
                    " should be handled by Claude Code TUI"
                ),
            )

        # Auto-register session if not already registered
        # This handles the case where permission hook fires before SessionStart hook
        if (
            hook_input.session_id
            and project_path
            and hook_input.session_id not in state_manager.state.claude_sessions
        ):
            await state_manager.register_claude_session(
                session_id=hook_input.session_id,
                project_path=project_path,
                is_external=True,
            )

        # Create a permission request
        perm_request = PermissionRequest(
            tool_name=hook_input.tool_name,
            tool_input=dict(hook_input.tool_input),
            tool_use_id=hook_input.tool_use_id,
            session_id=hook_input.session_id,
            project_path=project_path,
        )

        # Add to state and get a future for the decision
        decision_future = await state_manager.add_permission_request(perm_request)

        try:
            # Wait for decision with timeout
            decision = await asyncio.wait_for(
                decision_future, timeout=config.server.hook_timeout
            )
        except asyncio.TimeoutError:
            decision = DecisionStatus.TIMEOUT
            await state_manager.make_decision(
                perm_request.id, DecisionStatus.TIMEOUT
            )

        # Return the decision in hook-compatible format
        if event == "PreToolUse":
            # PreToolUse format
            if decision == DecisionStatus.APPROVED:
                return {
                    "hookSpecificOutput": {
                        "hookEventName": "PreToolUse",
                        "permissionDecision": "allow",
                        "permissionDecisionReason": "Approved by Claude Board",
                    }
                }
            return {
                "hookSpecificOutput": {
                    "hookEventName": "PreToolUse",
                    "permissionDecision": "deny",
                    "permissionDecisionReason": (
                        "Denied by Claude Board"
                        f" ({decision.value})"
                    ),
                }
            }
        # PermissionRequest format
        if decision == DecisionStatus.APPROVED:
            return {
                "hookSpecificOutput": {
                    "hookEventName": "PermissionRequest",
                    "decision": {"behavior": "allow"},
                }
            }
        return {
            "hookSpecificOutput": {
                "hookEventName": "PermissionRequest",
                "decision": {
                    "behavior": "deny",
                    "message": (
                        "Denied by Claude Board"
                        f" ({decision.value})"
                    ),
                },
            }
        }

    if event == "Notification":
        # Just broadcast the notification, don't block
        return {"status": "ok"}

    if event == "Stop":
        # Claude finished responding to ONE message (fires after each response)
        # This does NOT mean the session ended - user can send more messages
        logger.info(
            "Stop event received for session %s "
            "(Claude finished responding, session still active)",
            hook_input.session_id,
        )

        # Broadcast stop notification to all WebSocket clients
        stop_message = json.dumps(
            {
                "type": "stop_notification",
                "session_id": hook_input.session_id,
                "project_path": project_path,
                "timestamp": datetime.now(tz=timezone.utc).isoformat(),
            }
        )
        disconnected: list[WebSocket] = []
        for client in connected_clients:
            try:
                await client.send_text(stop_message)
            except (OSError, WebSocketDisconnect):
                disconnected.append(client)
        for client in disconnected:
            if client in connected_clients:
                connected_clients.remove(client)

        return {"status": "ok"}

    if event == "SessionEnd":
        # Session ended (more reliable - includes Ctrl+C, /clear, logout, etc.)
        await state_manager.handle_session_stop(
            session_id=hook_input.session_id,
            project_path=project_path,
        )
        return {"status": "ok"}

    return {"status": "ok"}


@router.post("/api/approve")
async def approve_request(
    request: Request, decision_input: DecisionInput
) -> dict[str, str]:
    """Approve a pending permission request"""
    state_manager: StateManager = request.app.state.state_manager
    success = await state_manager.make_decision(
        decision_input.request_id, DecisionStatus.APPROVED
    )
    if not success:
        raise HTTPException(
            status_code=404, detail="Request not found or already decided"
        )
    return {"status": "approved"}


@router.post("/api/deny")
async def deny_request(
    request: Request, decision_input: DecisionInput
) -> dict[str, str]:
    """Deny a pending permission request"""
    state_manager: StateManager = request.app.state.state_manager
    success = await state_manager.make_decision(
        decision_input.request_id, DecisionStatus.DENIED
    )
    if not success:
        raise HTTPException(
            status_code=404, detail="Request not found or already decided"
        )
    return {"status": "denied"}


@router.post("/api/yolo")
async def toggle_yolo(
    request: Request, yolo_input: YoloInput
) -> dict[str, object]:
    """Toggle YOLO (auto-approve) mode"""
    state_manager: StateManager = request.app.state.state_manager
    await state_manager.set_yolo_mode(enabled=yolo_input.enabled)
    return {"status": "ok", "yolo_mode": yolo_input.enabled}


@router.post("/api/reset")
async def reset_session(request: Request) -> dict[str, str]:
    """Reset the session state"""
    state_manager: StateManager = request.app.state.state_manager
    await state_manager.reset_session()
    return {"status": "ok"}


@router.post("/api/todos")
async def update_todos(
    request: Request, todo_input: TodoInput
) -> dict[str, object]:
    """Update the TODO list for a specific session"""
    state_manager: StateManager = request.app.state.state_manager
    # Auto-register session if session_id is provided but not registered yet
    # This handles the case where TODO hook fires before SessionStart hook
    if (
        todo_input.session_id
        and todo_input.project_path
        and todo_input.session_id not in state_manager.state.claude_sessions
    ):
        await state_manager.register_claude_session(
            session_id=todo_input.session_id,
            project_path=todo_input.project_path,
            # Assume external (no SessionStart received)
            is_external=True,
        )

    todos_list: list[dict[str, object]] = [
        {"content": t.content, "status": t.status, "activeForm": t.activeForm}
        for t in todo_input.todos
    ]
    # Pass session_id for per-session TODO storage
    await state_manager.update_todos(
        todos_list,
        project_path=todo_input.project_path,
        session_id=todo_input.session_id,
    )
    return {"status": "ok", "count": len(todo_input.todos)}


@router.post("/api/todos/task")
async def handle_task_operation(
    request: Request, data: dict[str, object]
) -> dict[str, str]:
    """
    Handle incremental task operations from TaskCreate/TaskUpdate hooks.

    For TaskCreate: {action, subject, description,
        activeForm, session_id, project_path}
    For TaskUpdate: {action, taskId, status?,
        subject?, ..., session_id, project_path}
    """
    state_manager: StateManager = request.app.state.state_manager
    action = str(data.get("action", ""))
    session_id = str(data.get("session_id", ""))
    project_path = str(data.get("project_path", ""))

    # Auto-register session if needed
    if (
        session_id
        and project_path
        and session_id not in state_manager.state.claude_sessions
    ):
        await state_manager.register_claude_session(
            session_id=session_id,
            project_path=project_path,
            is_external=True,
        )

    if action == "create":
        await state_manager.add_todo(
            subject=str(data.get("subject", "")),
            description=str(data.get("description", "")),
            active_form=str(data.get("activeForm", "")),
            project_path=project_path,
            session_id=session_id,
        )
    elif action == "update":
        await state_manager.update_todo(
            task_id=str(data.get("taskId", "")),
            status=str(data.get("status", "")) or None,
            subject=str(data.get("subject", "")) or None,
            active_form=str(data.get("activeForm", "")) or None,
            project_path=project_path,
            session_id=session_id,
        )

    return {"status": "ok"}


# === Multi-project API ===


@router.get("/api/projects")
async def list_projects(request: Request) -> dict[str, object]:
    """List all projects with their states"""
    state_manager: StateManager = request.app.state.state_manager
    return {
        "projects": state_manager.state.get_project_list(),
        "active_project": state_manager.state.active_project,
    }


@router.get("/api/projects/{project_path:path}")
async def get_project_state(
    request: Request, project_path: str
) -> dict[str, object]:
    """Get state for a specific project"""
    state_manager: StateManager = request.app.state.state_manager
    decoded_path = urllib.parse.unquote(project_path)
    project_state = state_manager.get_project_state(decoded_path)
    return project_state.to_dict()


@router.post("/api/projects/active")
async def set_active_project(
    request: Request, data: dict[str, str | None]
) -> dict[str, object]:
    """Set the active project for UI display"""
    state_manager: StateManager = request.app.state.state_manager
    project_path = data.get("project_path")
    await state_manager.set_active_project(project_path)
    return {
        "status": "ok",
        "active_project": state_manager.state.active_project,
    }


@router.post("/api/session/notify")
async def session_notify(
    request: Request, data: dict[str, str | None]
) -> dict[str, str | None]:
    """
    Receive session lifecycle notifications from hooks.

    Called by session_start_hook.py when a new Claude Code session starts.
    The hook may pass a chat_session_id (from CLAUDE_BOARD_SESSION_ID env var)
    if this Claude session was started by claude-board chat.
    """
    state_manager: StateManager = request.app.state.state_manager
    session_manager: ChatSessionManager = request.app.state.session_manager

    event = data.get("event", "unknown")
    session_id = data.get("session_id", "unknown")
    project_path = data.get("project_path", "")
    chat_session_id = data.get(
        "chat_session_id"
    )  # From CLAUDE_BOARD_SESSION_ID env

    if event == "session_start":
        # Check if this session was started by claude-board chat
        is_external = True
        chat_session = None

        if chat_session_id:
            # Try to link to the chat session with retry
            # The session may still be registering due to race condition
            for attempt in range(3):
                chat_session = session_manager.get_session(chat_session_id)
                if chat_session:
                    break
                # Exponential backoff: 0.1s, 0.2s, 0.4s
                await asyncio.sleep(0.1 * (2**attempt))

            if chat_session:
                # Update the chat session's claude_session_id
                chat_session.claude_session_id = session_id
                is_external = False  # Not external, it's managed by us
            else:
                # Check stub sessions (from disk after server restart)
                stub = session_manager.get_stub_session(chat_session_id)
                if stub:
                    # Found stub - mark as linked but external (PTY unavailable)
                    is_external = False

                # Fallback: try to find by project path and timing
                if is_external and project_path:
                    fallback = session_manager.find_recent_session_by_project(
                        project_path, within_seconds=30
                    )
                    if fallback:
                        fallback.claude_session_id = session_id
                        chat_session_id = fallback.session_id
                        is_external = False

        # Register the Claude session (creates tab immediately)
        if session_id and project_path:
            await state_manager.register_claude_session(
                session_id=session_id,
                project_path=project_path,
                chat_session_id=chat_session_id,
                is_external=is_external,
            )
        elif project_path:
            # Fallback: just set active project
            await state_manager.set_active_project(project_path)

    return {"status": "ok", "event": event, "session_id": session_id}


@router.post("/api/session/active")
async def set_active_session(
    request: Request, data: dict[str, str]
) -> dict[str, str | None]:
    """Set the active Claude session for UI"""
    state_manager: StateManager = request.app.state.state_manager
    session_id = data.get("session_id")
    await state_manager.set_active_session(session_id)
    return {"status": "ok", "active_session_id": session_id}


@router.delete("/api/session/{session_id}")
async def remove_session(
    request: Request, session_id: str
) -> dict[str, str]:
    """Remove an ended Claude session from tracking"""
    state_manager: StateManager = request.app.state.state_manager
    success = await state_manager.remove_claude_session(session_id)
    if not success:
        raise HTTPException(
            status_code=400, detail="Session not found or not ended yet"
        )
    return {"status": "ok"}


@router.post("/api/session/{session_id}/yolo")
async def toggle_session_yolo(
    request: Request, session_id: str, data: dict[str, bool]
) -> dict[str, object]:
    """Toggle YOLO mode for a specific Claude session"""
    state_manager: StateManager = request.app.state.state_manager
    enabled = data.get("enabled", False)
    success = await state_manager.set_session_yolo_mode(session_id, enabled=enabled)
    if not success:
        raise HTTPException(status_code=404, detail="Session not found")
    return {"status": "ok", "session_id": session_id, "yolo_mode": enabled}


# === Session API ===


@router.get("/api/sessions")
async def list_sessions(request: Request) -> dict[str, object]:
    """List all chat sessions"""
    session_manager: ChatSessionManager = request.app.state.session_manager
    # Clean up dead sessions first
    session_manager.cleanup_dead_sessions()
    return {"sessions": session_manager.list_sessions()}


@router.post("/api/sessions")
async def create_session(
    request: Request, data: dict[str, object]
) -> dict[str, object]:
    """
    Create a new chat session.

    This endpoint allows external processes (like claude-board chat CLI)
    to create sessions that are managed by the server process.

    Request body:
    - name: Optional friendly name for the session
    - project_path: Project directory (defaults to server's cwd)
    - resume: Resume option (true for picker, string for specific session ID)
    - claude_args: Additional arguments to pass to claude CLI
    - rows: Initial terminal rows (defaults to 24)
    - cols: Initial terminal columns (defaults to 80)
    """
    session_manager: ChatSessionManager = request.app.state.session_manager
    name = data.get("name")
    project_path = data.get("project_path", str(Path.cwd()))
    resume = data.get("resume")
    claude_args = data.get("claude_args")
    rows = data.get("rows")
    cols = data.get("cols")

    # Convert resume to proper type
    resume_option: str | bool | None = None
    if resume is True:
        resume_option = True
    elif isinstance(resume, str) and resume:
        resume_option = resume

    try:
        session = await session_manager.create_session(
            name=str(name) if name else None,
            project_path=str(project_path),
            resume=resume_option,
            claude_args=(
                [str(a) for a in claude_args]
                if isinstance(claude_args, list)
                else None
            ),
            initial_rows=(
                int(rows) if isinstance(rows, (int, float, str)) else None
            ),
            initial_cols=(
                int(cols) if isinstance(cols, (int, float, str)) else None
            ),
        )
        return {"status": "ok", "session": session.to_dict()}
    except (OSError, ValueError, RuntimeError) as e:
        raise HTTPException(status_code=500, detail=str(e)) from e


@router.get("/api/sessions/{session_id}")
async def get_session(
    request: Request, session_id: str
) -> dict[str, object]:
    """Get a specific session"""
    session_manager: ChatSessionManager = request.app.state.session_manager
    session = session_manager.get_session(session_id)
    if not session:
        raise HTTPException(status_code=404, detail="Session not found")
    return session.to_dict()


@router.post("/api/sessions/{session_id}/prompt")
async def send_session_prompt(
    request: Request, session_id: str, data: dict[str, str]
) -> dict[str, str]:
    """Send a prompt to a session"""
    session_manager: ChatSessionManager = request.app.state.session_manager
    session = session_manager.get_session(session_id)
    if not session:
        raise HTTPException(status_code=404, detail="Session not found")

    if not session.is_alive():
        raise HTTPException(
            status_code=400, detail="Session is not running"
        )

    prompt = data.get("prompt", "")
    if not prompt:
        raise HTTPException(status_code=400, detail="Prompt is required")

    await session.send_prompt(prompt, source="web")
    return {"status": "ok"}


@router.post("/api/sessions/{session_id}/input")
async def send_session_input(
    request: Request, session_id: str, data: dict[str, str]
) -> dict[str, str]:
    """Send raw input to a session"""
    session_manager: ChatSessionManager = request.app.state.session_manager
    session = session_manager.get_session(session_id)
    if not session:
        raise HTTPException(status_code=404, detail="Session not found")

    if not session.is_alive():
        raise HTTPException(
            status_code=400, detail="Session is not running"
        )

    input_data = data.get("data", "")
    if not input_data:
        raise HTTPException(status_code=400, detail="Data is required")

    await session.send_input(input_data.encode("utf-8"), source="web")
    return {"status": "ok"}


@router.delete("/api/sessions/{session_id}")
async def stop_session(
    request: Request, session_id: str
) -> dict[str, str]:
    """Stop a session"""
    session_manager: ChatSessionManager = request.app.state.session_manager
    success = await session_manager.stop_session(session_id)
    if not success:
        raise HTTPException(status_code=404, detail="Session not found")
    return {"status": "ok"}


# === WebSocket Endpoints ===


@router.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket) -> None:
    """WebSocket endpoint for real-time updates"""
    connected_clients: list[WebSocket] = websocket.app.state.connected_clients
    state_manager: StateManager = websocket.app.state.state_manager

    await websocket.accept()
    connected_clients.append(websocket)
    await state_manager.update_web_client_count(len(connected_clients))

    try:
        # Send initial state
        await websocket.send_text(
            json.dumps(
                {
                    "type": "state_update",
                    "data": state_manager.state.to_dict(),
                    "timestamp": datetime.now(tz=timezone.utc).isoformat(),
                }
            )
        )

        while True:
            # Keep connection alive, handle any client messages
            _data = await websocket.receive_text()
            # Could handle client commands here if needed
    except WebSocketDisconnect:
        pass
    except OSError as e:
        # Handle other connection errors (e.g., connection reset)
        logger.debug("WebSocket error: %s", e)
    finally:
        if websocket in connected_clients:
            connected_clients.remove(websocket)
        await state_manager.update_web_client_count(len(connected_clients))


@router.websocket("/ws/sessions/{session_id}/terminal")
async def session_terminal_websocket(
    websocket: WebSocket, session_id: str
) -> None:
    """
    Binary WebSocket endpoint for terminal I/O.

    Both CLI and WebUI use this endpoint. Raw bytes are transmitted directly
    without JSON wrapping for optimal performance.

    Query parameters:
    - resize=true: Client can resize the PTY (only first client with this flag)
    - resize=false (default): Client cannot resize, read-only for PTY size

    Protocol:
    - Output: Server sends raw bytes from PTY
    - Input: Client sends raw bytes, forwarded to PTY
    - On connect: Server sends buffered history as raw bytes
    """
    session_manager: ChatSessionManager = websocket.app.state.session_manager
    session = session_manager.get_session(session_id)
    if not session:
        await websocket.close(code=4004, reason="Session not found")
        return

    await websocket.accept()

    # Determine resize permission from query params
    query_params = dict(websocket.query_params)
    can_resize = query_params.get("resize", "false").lower() == "true"

    # Callback to send binary output to this websocket
    async def output_callback(data: bytes) -> None:
        try:
            await websocket.send_bytes(data)
        except WebSocketDisconnect:
            raise  # Re-raise to be handled by outer try/except
        except OSError as e:
            logger.warning(
                "Error sending bytes to terminal WebSocket: %s", e
            )

    try:
        # Attach callback and get history
        history = session.attach_binary_callback(
            output_callback, can_resize=can_resize
        )

        # Send PTY size as JSON text message first
        # (for non-resize clients to set terminal size)
        await websocket.send_text(
            json.dumps(
                {
                    "type": "pty_size",
                    "rows": session.term_rows,
                    "cols": session.term_cols,
                }
            )
        )

        # Send history immediately as raw bytes
        if history:
            await websocket.send_bytes(history)

        # Handle incoming data (binary or text)
        # Use low-level receive() to handle both binary and text messages
        while True:
            message = await websocket.receive()
            msg_type = message.get("type", "")

            if msg_type == "websocket.disconnect":
                break

            # Handle binary data (terminal input from CLI)
            if "bytes" in message and message["bytes"] is not None:
                await session.send_input(message["bytes"], source="ws")
            # Handle text data (control messages like resize, or text input)
            elif "text" in message and message["text"] is not None:
                text = message["text"]
                # Try to parse as JSON control message
                try:
                    ctrl_data = json.loads(text)
                    ctrl_type = ctrl_data.get("type", "")

                    if ctrl_type == "resize" and can_resize:
                        rows = ctrl_data.get("rows", 24)
                        cols = ctrl_data.get("cols", 80)
                        session.resize(
                            rows, cols, requester=output_callback
                        )
                except json.JSONDecodeError:
                    # Not JSON, treat as text input
                    await session.send_input(
                        text.encode("utf-8"), source="ws"
                    )

    except WebSocketDisconnect:
        logger.debug("Terminal WebSocket disconnected normally")
    except OSError as e:
        logger.warning(
            "Terminal WebSocket error: %s", e, exc_info=True
        )

    finally:
        session.detach_binary_callback(output_callback)


# ============================================================================
# App factory
# ============================================================================


def create_app(config: AppConfig | None = None) -> FastAPI:
    """
    Create and configure the FastAPI application.

    Args:
        config: Application configuration. Uses defaults if not provided.

    Returns:
        Configured FastAPI application.
    """
    if config is None:
        config = AppConfig.load()

    app = FastAPI(
        title="Claude Board",
        description="Permission approval console for Claude Code",
        version="0.1.0",
    )

    # State manager
    state_manager = StateManager(yolo_default=config.yolo_mode_default)

    # Session manager for chat sessions
    session_manager = ChatSessionManager()
    # Load existing session metadata from disk (for matching after server restart)
    loaded_count = session_manager.load_sessions()
    if loaded_count > 0:
        logging.getLogger(__name__).info(
            "Loaded %d session stubs from disk", loaded_count
        )

    # Connected WebSocket clients list
    connected_clients: list[WebSocket] = []

    # Store shared state on app.state for module-level route handlers
    app.state.config = config
    app.state.state_manager = state_manager
    app.state.session_manager = session_manager
    app.state.connected_clients = connected_clients

    # === WebSocket broadcast callback ===

    async def broadcast_state(state: dict[str, object]) -> None:
        """Broadcast state to all connected WebSocket clients"""
        clients: list[WebSocket] = app.state.connected_clients
        if not clients:
            return

        # Inject chat_sessions into state for WebSocket push
        # This avoids the need for separate /api/sessions polling
        sm: ChatSessionManager = app.state.session_manager
        state["chat_sessions"] = sm.list_sessions()

        message = json.dumps(
            {
                "type": "state_update",
                "data": state,
                "timestamp": datetime.now(tz=timezone.utc).isoformat(),
            }
        )

        # Send to all clients, remove disconnected ones
        disconnected: list[WebSocket] = []
        for client in clients:
            try:
                await client.send_text(message)
            except (OSError, WebSocketDisconnect):
                disconnected.append(client)

        for client in disconnected:
            if client in clients:
                clients.remove(client)

    # Set up state manager callback
    state_manager.set_broadcast_callback(broadcast_state)

    # === Register routes from module-level APIRouter ===

    app.include_router(router)

    # === Static Files ===

    # Mount static files for CSS and JS
    if WEB_DIR.exists():
        app.mount("/static", StaticFiles(directory=WEB_DIR), name="static")

    return app


def run_server(
    host: str = "0.0.0.0",
    port: int = 8765,
    config: AppConfig | None = None,
    socket_path: Path | None = None,
) -> None:
    """
    Run the server (blocking).

    Args:
        host: Host to bind to.
        port: Port to listen on.
        config: Application configuration.
        socket_path: Path for Unix socket RPC API (optional).
    """
    import uvicorn  # noqa: PLC0415

    if config is None:
        config = AppConfig.load()
        config.server.host = host
        config.server.port = port

    app = create_app(config)

    # If socket_path is provided, run with RPC server
    if socket_path:

        async def run_with_rpc() -> None:
            # Create RPC server
            rpc_server = RPCServer(socket_path, app.state.state_manager)
            await rpc_server.start()

            # Create uvicorn config
            uvi_config = uvicorn.Config(
                app,
                host=config.server.host,
                port=config.server.port,
                log_level="info",
            )
            server = uvicorn.Server(uvi_config)

            try:
                await server.serve()
            finally:
                await rpc_server.stop()

        asyncio.run(run_with_rpc())
    else:
        uvicorn.run(app, host=config.server.host, port=config.server.port)
