"""Server-side terminal — PTY runs on the server, non-whitelisted commands
require admin approval.

Endpoints:
  - WS   /api/v1/terminal/server/ws          — Server PTY WebSocket
  - GET  /api/v1/terminal/approvals          — List pending approvals (admin)
  - POST /api/v1/terminal/approvals/{id}/approve  — Approve a request (admin)
  - POST /api/v1/terminal/approvals/{id}/reject   — Reject a request (admin)
  - GET  /api/v1/terminal/whitelist/global   — List global whitelist (admin)
  - POST /api/v1/terminal/whitelist/global   — Add global whitelist entry (admin)
  - DELETE /api/v1/terminal/whitelist/global/{id}  — Remove global entry (admin)

Security model:
  - Requires TERMINAL_SERVER_USE permission + is_server_terminal_authorized flag
  - Command flow: blocklist → builtin → global whitelist → user whitelist
    → session whitelist → danger check → ADMIN APPROVAL (not user confirmation)
  - Approvals expire after 5 minutes
  - All commands (executed/approved/rejected/blocked) are audit-logged
  - Session isolation: each user gets an independent PTY + working directory
"""

from __future__ import annotations

import asyncio
import json
import logging
import os
import time
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

import aiosqlite
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
from pydantic import BaseModel, Field

from agentkit.server.auth.dependencies import (
    require_permission,
)
from agentkit.server.auth.permissions import Permission, has_permission
from agentkit.server.auth.terminal_security import (
    DECISION_BLOCKED,
    DECISION_CONFIRMED,
    DECISION_EXECUTED,
    DECISION_REJECTED,
    check_command_safety_v2,
    write_audit_log,
)
from agentkit.tools.pty_session import PTYSession

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/terminal", tags=["terminal-server"])

# Approval timeout: 5 minutes
APPROVAL_TIMEOUT_SECONDS = 300

# Maximum concurrent server-terminal sessions
MAX_CONCURRENT_SESSIONS = 20

# Session idle timeout: 30 minutes
SESSION_IDLE_TIMEOUT = 1800


# ── In-memory session store (separate from local terminal sessions) ──


@dataclass
class ServerTerminalSession:
    """Per-user server-terminal session."""

    session_id: str
    user_id: str
    username: str
    pty: PTYSession | None = None
    cwd: str = ""
    created_at: float = field(default_factory=time.time)
    last_activity: float = field(default_factory=time.time)
    whitelist: set[str] = field(default_factory=set)
    history: list[dict[str, Any]] = field(default_factory=list)


# session_id → ServerTerminalSession
_server_sessions: dict[str, ServerTerminalSession] = {}
# user_id → session_id (one session per user for isolation)
_user_sessions: dict[str, str] = {}

# Pending approvals: approval_id → asyncio.Future[bool] (True=approved)
_pending_approvals: dict[str, asyncio.Future[bool]] = {}
# Pending approval ownership: approval_id → session_id (for cancel ownership check)
_approval_owners: dict[str, str] = {}


def _now_iso() -> str:
    return datetime.now(timezone.utc).isoformat()


def _resolve_db(request: Request) -> Path:
    """Resolve the auth DB path from app.state or default."""
    path = getattr(request.app.state, "auth_db_path", None)
    if path:
        return Path(path)
    from agentkit.server.auth.models import DEFAULT_AUTH_DB_PATH

    return DEFAULT_AUTH_DB_PATH


async def _resolve_db_ws(app_state: Any) -> Path:
    """Resolve the auth DB path from app.state (for WebSocket context)."""
    path = getattr(app_state, "auth_db_path", None)
    if path:
        return Path(path)
    from agentkit.server.auth.models import DEFAULT_AUTH_DB_PATH

    return DEFAULT_AUTH_DB_PATH


# ── Approval helpers ──────────────────────────────────────────────────


async def _create_approval_request(
    db_path: Path,
    *,
    user_id: str,
    username: str,
    session_id: str,
    command: str,
    reason: str | None,
) -> str:
    """Create a pending approval request in the DB. Returns the approval ID."""
    approval_id = str(uuid.uuid4())
    expires_at = datetime.now(timezone.utc).timestamp() + APPROVAL_TIMEOUT_SECONDS
    expires_iso = datetime.fromtimestamp(expires_at, tz=timezone.utc).isoformat()

    async with aiosqlite.connect(str(db_path)) as db:
        await db.execute(
            """INSERT INTO terminal_approvals
               (id, user_id, username, session_id, command, reason, status,
                created_at, expires_at)
               VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?)""",
            (approval_id, user_id, username, session_id, command[:4096], reason, _now_iso(), expires_iso),
        )
        await db.commit()

    return approval_id


async def _get_approval(db_path: Path, approval_id: str) -> dict[str, Any] | None:
    """Fetch an approval record by ID."""
    async with aiosqlite.connect(str(db_path)) as db:
        db.row_factory = aiosqlite.Row
        cursor = await db.execute(
            "SELECT * FROM terminal_approvals WHERE id = ?",
            (approval_id,),
        )
        row = await cursor.fetchone()
        if row is None:
            return None
        return dict(row)


async def _update_approval_status(
    db_path: Path,
    approval_id: str,
    *,
    status: str,
    reviewer_id: str | None,
    reviewer_username: str | None,
    review_note: str | None = None,
) -> bool:
    """Update an approval's status. Returns True if updated, False if not found
    or already reviewed."""
    async with aiosqlite.connect(str(db_path)) as db:
        cursor = await db.execute(
            "UPDATE terminal_approvals SET status = ?, reviewer_id = ?, "
            "reviewer_username = ?, review_note = ?, reviewed_at = ? "
            "WHERE id = ? AND status = 'pending'",
            (status, reviewer_id, reviewer_username, review_note, _now_iso(), approval_id),
        )
        await db.commit()
        return cursor.rowcount > 0


async def _expire_stale_approvals(db_path: Path) -> int:
    """Mark expired approvals as 'expired'. Returns count of expired records."""
    now_iso = _now_iso()
    async with aiosqlite.connect(str(db_path)) as db:
        cursor = await db.execute(
            "UPDATE terminal_approvals SET status = 'expired', reviewed_at = ? "
            "WHERE status = 'pending' AND expires_at < ?",
            (now_iso, now_iso),
        )
        await db.commit()
        return cursor.rowcount


# ── Schemas ───────────────────────────────────────────────────────────


class ApprovalInfo(BaseModel):
    id: str
    user_id: str
    username: str
    session_id: str
    command: str
    reason: str | None = None
    status: str
    reviewer_id: str | None = None
    reviewer_username: str | None = None
    review_note: str | None = None
    created_at: str
    reviewed_at: str | None = None
    expires_at: str


class ApprovalListResponse(BaseModel):
    approvals: list[ApprovalInfo]
    total: int


class ReviewRequest(BaseModel):
    note: str | None = Field(None, max_length=512)


class GlobalWhitelistEntry(BaseModel):
    id: str
    command_pattern: str
    created_at: str


class GlobalWhitelistListResponse(BaseModel):
    entries: list[GlobalWhitelistEntry]


class AddGlobalWhitelistRequest(BaseModel):
    command_pattern: str = Field(..., min_length=1, max_length=256)


# ── Approval management endpoints (admin only) ────────────────────────


@router.get(
    "/approvals",
    response_model=ApprovalListResponse,
    dependencies=[Depends(require_permission(Permission.TERMINAL_WHITELIST_MANAGE))],
)
async def list_approvals(
    request: Request,
    status: str | None = None,
    limit: int = 100,
    offset: int = 0,
):
    """List terminal approval requests (admin only).

    Filter by status: pending, approved, rejected, expired, cancelled.
    """
    db_path = _resolve_db(request)

    # Expire stale approvals first
    await _expire_stale_approvals(db_path)

    where_sql = " WHERE status = ?" if status else ""
    params: list[Any] = [status] if status else []

    async with aiosqlite.connect(str(db_path)) as db:
        db.row_factory = aiosqlite.Row

        count_cursor = await db.execute(
            f"SELECT COUNT(*) FROM terminal_approvals{where_sql}",
            params,
        )
        total = (await count_cursor.fetchone())[0]

        cursor = await db.execute(
            f"""SELECT * FROM terminal_approvals{where_sql}
                ORDER BY created_at DESC LIMIT ? OFFSET ?""",
            [*params, limit, offset],
        )
        rows = await cursor.fetchall()

    return ApprovalListResponse(
        approvals=[
            ApprovalInfo(
                id=row["id"],
                user_id=row["user_id"],
                username=row["username"],
                session_id=row["session_id"],
                command=row["command"],
                reason=row["reason"],
                status=row["status"],
                reviewer_id=row["reviewer_id"],
                reviewer_username=row["reviewer_username"],
                review_note=row["review_note"],
                created_at=row["created_at"],
                reviewed_at=row["reviewed_at"],
                expires_at=row["expires_at"],
            )
            for row in rows
        ],
        total=total,
    )


@router.post(
    "/approvals/{approval_id}/approve",
    response_model=ApprovalInfo,
    dependencies=[Depends(require_permission(Permission.TERMINAL_WHITELIST_MANAGE))],
)
async def approve_request(
    approval_id: str,
    request: Request,
    payload: ReviewRequest | None = None,
):
    """Approve a pending terminal command request (admin only)."""
    db_path = _resolve_db(request)
    user = getattr(request.state, "current_user", None) or {}

    # Expire stale approvals
    await _expire_stale_approvals(db_path)

    note = payload.note if payload else None
    updated = await _update_approval_status(
        db_path,
        approval_id,
        status="approved",
        reviewer_id=user.get("user_id"),
        reviewer_username=user.get("username"),
        review_note=note,
    )

    if not updated:
        raise HTTPException(
            status_code=404,
            detail="Approval not found or already reviewed",
        )

    # Notify the waiting WebSocket
    future = _pending_approvals.get(approval_id)
    if future and not future.done():
        future.set_result(True)
    _pending_approvals.pop(approval_id, None)
    _approval_owners.pop(approval_id, None)

    approval = await _get_approval(db_path, approval_id)
    return ApprovalInfo(**approval)


@router.post(
    "/approvals/{approval_id}/reject",
    response_model=ApprovalInfo,
    dependencies=[Depends(require_permission(Permission.TERMINAL_WHITELIST_MANAGE))],
)
async def reject_request(
    approval_id: str,
    request: Request,
    payload: ReviewRequest | None = None,
):
    """Reject a pending terminal command request (admin only)."""
    db_path = _resolve_db(request)
    user = getattr(request.state, "current_user", None) or {}

    await _expire_stale_approvals(db_path)

    note = payload.note if payload else None
    updated = await _update_approval_status(
        db_path,
        approval_id,
        status="rejected",
        reviewer_id=user.get("user_id"),
        reviewer_username=user.get("username"),
        review_note=note,
    )

    if not updated:
        raise HTTPException(
            status_code=404,
            detail="Approval not found or already reviewed",
        )

    # Notify the waiting WebSocket
    future = _pending_approvals.get(approval_id)
    if future and not future.done():
        future.set_result(False)
    _pending_approvals.pop(approval_id, None)
    _approval_owners.pop(approval_id, None)

    approval = await _get_approval(db_path, approval_id)
    return ApprovalInfo(**approval)


# ── Global whitelist endpoints (admin only) ───────────────────────────


@router.get(
    "/whitelist/global",
    response_model=GlobalWhitelistListResponse,
    dependencies=[Depends(require_permission(Permission.TERMINAL_WHITELIST_MANAGE))],
)
async def list_global_whitelist(request: Request):
    """List the global (admin-managed) terminal whitelist."""
    db_path = _resolve_db(request)

    async with aiosqlite.connect(str(db_path)) as db:
        db.row_factory = aiosqlite.Row
        cursor = await db.execute(
            "SELECT id, command_pattern, created_at "
            "FROM terminal_whitelist_user WHERE scope = 'global' "
            "ORDER BY created_at DESC"
        )
        rows = await cursor.fetchall()

    return GlobalWhitelistListResponse(
        entries=[
            GlobalWhitelistEntry(
                id=row["id"],
                command_pattern=row["command_pattern"],
                created_at=row["created_at"],
            )
            for row in rows
        ]
    )


@router.post(
    "/whitelist/global",
    response_model=GlobalWhitelistEntry,
    status_code=201,
    dependencies=[Depends(require_permission(Permission.TERMINAL_WHITELIST_MANAGE))],
)
async def add_global_whitelist(payload: AddGlobalWhitelistRequest, request: Request):
    """Add a command pattern to the global whitelist (admin only)."""
    db_path = _resolve_db(request)
    user = getattr(request.state, "current_user", None) or {}
    entry_id = str(uuid.uuid4())

    async with aiosqlite.connect(str(db_path)) as db:
        # Check for duplicate
        cursor = await db.execute(
            "SELECT id FROM terminal_whitelist_user "
            "WHERE command_pattern = ? AND scope = 'global'",
            (payload.command_pattern,),
        )
        existing = await cursor.fetchone()
        if existing:
            raise HTTPException(status_code=409, detail="Pattern already exists in global whitelist")

        await db.execute(
            """INSERT INTO terminal_whitelist_user
               (id, user_id, command_pattern, scope, created_at, created_by)
               VALUES (?, ?, ?, 'global', ?, ?)""",
            (entry_id, user.get("user_id") or "system", payload.command_pattern, _now_iso(),
             user.get("user_id")),
        )
        await db.commit()

        db.row_factory = aiosqlite.Row
        cursor = await db.execute(
            "SELECT id, command_pattern, created_at "
            "FROM terminal_whitelist_user WHERE id = ?",
            (entry_id,),
        )
        row = await cursor.fetchone()

    return GlobalWhitelistEntry(
        id=row["id"],
        command_pattern=row["command_pattern"],
        created_at=row["created_at"],
    )


@router.delete(
    "/whitelist/global/{entry_id}",
    status_code=204,
    dependencies=[Depends(require_permission(Permission.TERMINAL_WHITELIST_MANAGE))],
)
async def delete_global_whitelist(entry_id: str, request: Request):
    """Remove a pattern from the global whitelist (admin only)."""
    db_path = _resolve_db(request)

    async with aiosqlite.connect(str(db_path)) as db:
        cursor = await db.execute(
            "DELETE FROM terminal_whitelist_user WHERE id = ? AND scope = 'global'",
            (entry_id,),
        )
        await db.commit()

        if cursor.rowcount == 0:
            raise HTTPException(status_code=404, detail="Global whitelist entry not found")


# ── Server-terminal WebSocket ─────────────────────────────────────────


@router.websocket("/server/ws")
async def server_terminal_websocket(websocket: WebSocket) -> None:
    """WebSocket endpoint for server-side terminal.

    The PTY runs on the server. Non-whitelisted dangerous commands
    require admin approval (not just user confirmation).

    Client → Server messages:
        {"type": "execute", "command": "..."}         — Execute a command
        {"type": "cancel_approval", "approval_id": "..."} — Cancel pending approval
        {"type": "input", "data": "..."}              — Send input to running PTY
        {"type": "ping"}                              — Heartbeat

    Server → Client messages:
        {"type": "connected", "session_id": "..."}    — Connection confirmed
        {"type": "output", "data": "..."}             — Terminal output chunk
        {"type": "result", "command": "...", ...}     — Command completed
        {"type": "approval_required", "approval_id": "...", "command": "...", "reason": "..."}
                                                       — Need admin approval
        {"type": "approval_approved", "approval_id": "..."}  — Admin approved
        {"type": "approval_rejected", "approval_id": "...", "reason": "..."}  — Admin rejected
        {"type": "approval_expired", "approval_id": "..."}   — Approval timed out
        {"type": "error", "message": "..."}           — Error occurred
        {"type": "pong"}                              — Heartbeat response
    """
    await websocket.accept()

    # ── Authentication ────────────────────────────────────────────
    jwt_secret: str | None = getattr(websocket.app.state, "jwt_secret", None)

    user_payload: dict[str, Any] | None = None
    auth_ok = False

    # JWT via ?token=
    jwt_token = websocket.query_params.get("token")
    if jwt_token and jwt_secret:
        try:
            from agentkit.server.auth.jwt_utils import verify_token

            payload = verify_token(jwt_token, jwt_secret)
            if payload.get("type") == "access":
                user_payload = {
                    "user_id": payload.get("sub"),
                    "username": payload.get("username"),
                    "role": payload.get("role"),
                }
                # Check TERMINAL_SERVER_USE permission
                if has_permission(user_payload, Permission.TERMINAL_SERVER_USE):
                    auth_ok = True
                else:
                    await websocket.send_json({
                        "type": "error",
                        "message": "Permission denied: TERMINAL_SERVER_USE required",
                    })
                    await websocket.close(code=4003, reason="Permission denied")
                    return
        except Exception:
            pass

    if not auth_ok:
        # Dev mode — only if explicitly allowed
        allow_dev = getattr(websocket.app.state, "allow_dev_terminal", False)
        if not jwt_secret and allow_dev:
            user_payload = {"user_id": "dev", "username": "dev", "role": "admin", "dev_mode": True}
            auth_ok = True
        else:
            await websocket.send_json({
                "type": "error",
                "message": "Authentication required for server terminal",
            })
            await websocket.close(code=4001, reason="Authentication required")
            return

    # ── Check is_server_terminal_authorized flag (skip in dev mode) ──
    if not user_payload.get("dev_mode"):
        user_id = user_payload.get("user_id")
        if user_id:
            db_path = await _resolve_db_ws(websocket.app.state)
            try:
                async with aiosqlite.connect(str(db_path)) as db:
                    cursor = await db.execute(
                        "SELECT is_server_terminal_authorized FROM users WHERE id = ?",
                        (user_id,),
                    )
                    row = await cursor.fetchone()
                    if row is None or not bool(row[0]):
                        await websocket.send_json({
                            "type": "error",
                            "message": "Server terminal access not authorized for this account",
                        })
                        await websocket.close(code=4003, reason="Not authorized")
                        return
            except Exception as e:
                logger.warning(f"Failed to check server terminal authorization: {e}")
                # If DB check fails, deny access
                await websocket.send_json({
                    "type": "error",
                    "message": "Failed to verify server terminal authorization",
                })
                await websocket.close(code=500, reason="Authorization check failed")
                return

    # ── Session management ────────────────────────────────────────
    user_id = user_payload["user_id"]
    username = user_payload.get("username", "unknown")

    # Check concurrent session limit
    if len(_server_sessions) >= MAX_CONCURRENT_SESSIONS and user_id not in _user_sessions:
        await websocket.send_json({
            "type": "error",
            "message": f"Maximum concurrent sessions ({MAX_CONCURRENT_SESSIONS}) reached",
        })
        await websocket.close(code=503, reason="Server busy")
        return

    # Reuse existing session for this user, or create a new one
    session_id = _user_sessions.get(user_id)
    if session_id and session_id in _server_sessions:
        state = _server_sessions[session_id]
    else:
        session_id = str(uuid.uuid4())
        state = ServerTerminalSession(
            session_id=session_id,
            user_id=user_id,
            username=username,
            cwd=os.getcwd(),
        )
        _server_sessions[session_id] = state
        _user_sessions[user_id] = session_id

    state.last_activity = time.time()

    active_pty: PTYSession | None = None

    try:
        await websocket.send_json({
            "type": "connected",
            "session_id": state.session_id,
            "terminal_mode": "server",
        })

        while True:
            try:
                raw = await asyncio.wait_for(
                    websocket.receive_text(),
                    timeout=SESSION_IDLE_TIMEOUT,
                )
            except asyncio.TimeoutError:
                await websocket.send_json({"type": "pong"})
                continue

            state.last_activity = time.time()

            try:
                msg = json.loads(raw)
            except json.JSONDecodeError:
                continue

            msg_type = msg.get("type")

            if msg_type == "execute":
                command = msg.get("command", "").strip()
                if not command:
                    continue

                db_path = await _resolve_db_ws(websocket.app.state)

                # Three-layer whitelist check (with global whitelist layer)
                decision = await check_command_safety_v2(
                    command,
                    session_id=state.session_id,
                    session_whitelist=state.whitelist,
                    user_id=user_id if not user_payload.get("dev_mode") else None,
                    db_path=db_path,
                )

                # Blocklist: reject outright
                if decision.decision == DECISION_BLOCKED:
                    await write_audit_log(
                        db_path,
                        user_id=user_id,
                        username=username,
                        session_id=state.session_id,
                        command=command,
                        decision=DECISION_BLOCKED,
                        reason=decision.reason,
                        cwd=state.cwd,
                        terminal_mode="server",
                    )
                    await websocket.send_json({
                        "type": "result",
                        "command": command,
                        "exit_code": 126,
                        "output": f"命令被黑名单拒绝: {decision.reason or ''}",
                        "cwd": state.cwd,
                        "duration_ms": 0,
                    })
                    continue

                # Dangerous command: require admin approval
                if not decision.safe:
                    approval_id = await _create_approval_request(
                        db_path,
                        user_id=user_id,
                        username=username,
                        session_id=state.session_id,
                        command=command,
                        reason=decision.reason,
                    )

                    await websocket.send_json({
                        "type": "approval_required",
                        "approval_id": approval_id,
                        "command": command,
                        "reason": decision.reason or "需要管理员审批",
                        "expires_in": APPROVAL_TIMEOUT_SECONDS,
                    })

                    # Register a future for the approval response
                    loop = asyncio.get_running_loop()
                    approval_future: asyncio.Future[bool] = loop.create_future()
                    _pending_approvals[approval_id] = approval_future
                    _approval_owners[approval_id] = state.session_id

                    # Wait for admin response or timeout
                    try:
                        approved = await asyncio.wait_for(
                            approval_future,
                            timeout=APPROVAL_TIMEOUT_SECONDS,
                        )
                    except asyncio.TimeoutError:
                        approved = False
                        # Mark as expired in DB
                        await _update_approval_status(
                            db_path,
                            approval_id,
                            status="expired",
                            reviewer_id=None,
                            reviewer_username=None,
                        )
                        await websocket.send_json({
                            "type": "approval_expired",
                            "approval_id": approval_id,
                            "command": command,
                        })
                        await write_audit_log(
                            db_path,
                            user_id=user_id,
                            username=username,
                            session_id=state.session_id,
                            command=command,
                            decision="expired",
                            reason="Approval timed out",
                            cwd=state.cwd,
                            terminal_mode="server",
                        )
                        continue
                    finally:
                        _pending_approvals.pop(approval_id, None)
                        _approval_owners.pop(approval_id, None)

                    if not approved:
                        await websocket.send_json({
                            "type": "approval_rejected",
                            "approval_id": approval_id,
                            "command": command,
                            "reason": "管理员拒绝",
                        })
                        await write_audit_log(
                            db_path,
                            user_id=user_id,
                            username=username,
                            session_id=state.session_id,
                            command=command,
                            decision=DECISION_REJECTED,
                            reason="Admin rejected",
                            cwd=state.cwd,
                            terminal_mode="server",
                        )
                        continue

                    # Approved!
                    await websocket.send_json({
                        "type": "approval_approved",
                        "approval_id": approval_id,
                        "command": command,
                    })

                    # Audit: approved
                    await write_audit_log(
                        db_path,
                        user_id=user_id,
                        username=username,
                        session_id=state.session_id,
                        command=command,
                        decision=DECISION_CONFIRMED,
                        reason=f"Admin approved (approval_id={approval_id})",
                        cwd=state.cwd,
                        terminal_mode="server",
                    )

                # Execute command with streaming output
                start_time = time.monotonic()
                try:
                    pty = PTYSession()
                    await pty.start()
                    active_pty = pty

                    run_task = asyncio.create_task(
                        pty.run_command(
                            command,
                            timeout=msg.get("timeout", 30.0),
                            cwd=state.cwd or None,
                        )
                    )

                    # Stream output
                    while not run_task.done():
                        try:
                            chunk = await asyncio.wait_for(
                                pty.read_output(timeout=0.5), timeout=1.0
                            )
                            if chunk:
                                await websocket.send_json({
                                    "type": "output",
                                    "data": chunk,
                                })
                        except asyncio.TimeoutError:
                            continue
                        except Exception:
                            break

                    result = await run_task
                    await pty.close()
                    active_pty = None

                    duration_ms = int((time.monotonic() - start_time) * 1000)

                    # Update cwd if command was cd — parse directly instead
                    # of spawning a new PTY (which doesn't inherit the cd).
                    if command.strip().startswith("cd "):
                        try:
                            cd_arg = command.strip()[3:].strip()
                            # Handle cd with no args → home directory
                            if not cd_arg or cd_arg == "~":
                                state.cwd = os.path.expanduser("~")
                            elif cd_arg.startswith("~/"):
                                state.cwd = os.path.expanduser(cd_arg)
                            elif os.path.isabs(cd_arg):
                                state.cwd = os.path.normpath(cd_arg)
                            else:
                                base = state.cwd or os.path.expanduser("~")
                                state.cwd = os.path.normpath(os.path.join(base, cd_arg))
                        except Exception:
                            pass  # cd parsing failed — keep old cwd

                    # Record history
                    state.history.append({
                        "command": command,
                        "exit_code": result.exit_code,
                        "output": result.output[:5000],
                        "cwd": state.cwd,
                        "timestamp": time.time(),
                        "duration_ms": duration_ms,
                    })

                    # Audit: executed (if whitelisted, not already audited above)
                    if decision.safe:
                        await write_audit_log(
                            db_path,
                            user_id=user_id,
                            username=username,
                            session_id=state.session_id,
                            command=command,
                            decision=DECISION_EXECUTED,
                            reason=f"matched_layer={decision.matched_layer}",
                            cwd=state.cwd,
                            exit_code=result.exit_code,
                            terminal_mode="server",
                        )

                    await websocket.send_json({
                        "type": "result",
                        "command": command,
                        "exit_code": result.exit_code,
                        "output": result.output,
                        "cwd": state.cwd,
                        "duration_ms": duration_ms,
                    })

                except asyncio.TimeoutError:
                    duration_ms = int((time.monotonic() - start_time) * 1000)
                    await websocket.send_json({
                        "type": "result",
                        "command": command,
                        "exit_code": -1,
                        "output": "命令执行超时",
                        "cwd": state.cwd,
                        "duration_ms": duration_ms,
                    })
                except Exception as e:
                    duration_ms = int((time.monotonic() - start_time) * 1000)
                    await websocket.send_json({
                        "type": "error",
                        "message": str(e)[:200],
                    })
                finally:
                    if active_pty and active_pty.is_running:
                        try:
                            await active_pty.close()
                        except Exception:
                            pass
                        active_pty = None

            elif msg_type == "cancel_approval":
                approval_id = msg.get("approval_id")
                if approval_id and approval_id in _pending_approvals:
                    # Verify ownership: only the user who requested the
                    # approval can cancel it.
                    approval_state = _approval_owners.get(approval_id)
                    if approval_state is not None and approval_state != state.session_id:
                        await websocket.send_json({
                            "type": "error",
                            "message": "Cannot cancel another user's approval",
                        })
                        continue
                    fut = _pending_approvals[approval_id]
                    if not fut.done():
                        fut.set_result(False)
                    _pending_approvals.pop(approval_id, None)
                    _approval_owners.pop(approval_id, None)
                    db_path = await _resolve_db_ws(websocket.app.state)
                    await _update_approval_status(
                        db_path,
                        approval_id,
                        status="cancelled",
                        reviewer_id=user_id,
                        reviewer_username=username,
                    )

            elif msg_type == "input":
                if active_pty and active_pty.is_running:
                    await active_pty.send(msg.get("data", ""))

            elif msg_type == "ping":
                await websocket.send_json({"type": "pong"})

    except WebSocketDisconnect:
        logger.debug(f"Server terminal WebSocket disconnected for session {state.session_id}")
    except Exception as e:
        logger.error(f"Server terminal WebSocket error for session {state.session_id}: {e}")
        try:
            await websocket.send_json({"type": "error", "message": str(e)[:200]})
        except Exception:
            pass
    finally:
        if active_pty and active_pty.is_running:
            try:
                await active_pty.close()
            except Exception:
                pass
        # Clean up session from in-memory stores to prevent leaks
        _server_sessions.pop(state.session_id, None)
        _user_sessions.pop(state.user_id, None)
        # Cancel any pending approvals owned by this session
        for ap_id in list(_approval_owners.keys()):
            if _approval_owners.get(ap_id) == state.session_id:
                fut = _pending_approvals.get(ap_id)
                if fut is not None and not fut.done():
                    try:
                        fut.set_result(False)
                    except asyncio.InvalidStateError:
                        pass
                _pending_approvals.pop(ap_id, None)
                _approval_owners.pop(ap_id, None)
