"""Session management service."""

import asyncio
import logging
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any

from ieum.approval_policy import (
    APPROVAL_POLICY_MANUAL,
    APPROVAL_POLICY_PLAN_ONLY,
    normalize_approval_policy,
)
from ieum.sessions import (
    generate_session_id,
    generate_thread_id,
    get_web_checkpointer,
    get_web_store,
    init_web_sessions_db,
    save_web_session,
    load_web_sessions,
    delete_web_session,
    update_web_session_activity,
    append_web_audit_event,
    list_web_audit_events,
)

if TYPE_CHECKING:
    from langgraph.checkpoint.base import BaseCheckpointSaver
    from langgraph.pregel import Pregel
    from langgraph.store.base import BaseStore

logger = logging.getLogger(__name__)


@dataclass
class Session:
    """Represents an active agent session."""

    id: str
    agent_id: str
    working_directory: str
    model_name: str | None
    auto_approve: bool
    approval_policy: str = "manual"
    timeout_minutes: int | None = None
    max_concurrent_sessions: int | None = None
    rate_limit_max_requests: int | None = None
    rate_limit_window_seconds: int | None = None
    user_id: str = "anonymous"
    workspace_id: str = "default"
    created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
    last_activity: datetime = field(default_factory=lambda: datetime.now(UTC))
    status: str = "active"

    # Internal state (not serialized to API)
    agent: Any = field(default=None, repr=False)  # Pregel
    backend: Any = field(default=None, repr=False)
    checkpointer: Any = field(default=None, repr=False)  # BaseCheckpointSaver
    store: Any = field(default=None, repr=False)  # BaseStore
    thread_id: str = field(default_factory=generate_thread_id)

    # Session-scoped credentials (avoid mutating os.environ)
    _api_key: str | None = field(default=None, repr=False)
    _base_url: str | None = field(default=None, repr=False)
    _github_pat: str | None = field(default=None, repr=False)
    _github_host: str | None = field(default=None, repr=False)

    # WebSocket connections
    _connections: set = field(default_factory=set, repr=False)

    # Plan mode state (not persisted)
    plan_mode: bool = field(default=False, repr=False)
    current_plan: str | None = field(default=None, repr=False)
    _plan_original_task: str | None = field(default=None, repr=False)
    _saved_approval_policy: str | None = field(default=None, repr=False)

    # Cost tracking (accumulated across messages, not persisted)
    session_cost: float = field(default=0.0, repr=False)

    # Git auto-commit state (not persisted)
    auto_commit: bool = field(default=False, repr=False)

    # Verify loop state (not persisted)
    verify_enabled: bool = field(default=False, repr=False)
    verify_config: Any = field(default=None, repr=False)  # VerifyConfig

    # Checkpoint restore state (not persisted)
    _restore_checkpoint_id: str | None = field(default=None, repr=False)

    def touch(self) -> None:
        """Update last activity timestamp."""
        self.last_activity = datetime.now(UTC)

    # --- Three-mode cycling: manual → auto → plan → manual ---

    def get_current_mode(self) -> str:
        """Return current mode label: 'manual', 'auto', or 'plan'."""
        if self.plan_mode:
            return "plan"
        if self.auto_approve:
            return "auto"
        return "manual"

    def cycle_mode(self) -> str:
        """Cycle to next mode. Returns the new mode label."""
        current = self.get_current_mode()
        if current == "manual":
            self.auto_approve = True
            return "auto"
        if current == "auto":
            self.auto_approve = False
            self.enter_plan_mode()
            return "plan"
        # plan → manual
        self.exit_plan_mode()
        return "manual"

    def enter_plan_mode(self) -> None:
        """Enter plan mode: backup policy, switch to plan_only."""
        self._saved_approval_policy = self.approval_policy
        self.approval_policy = APPROVAL_POLICY_PLAN_ONLY
        self.plan_mode = True
        self.current_plan = None

    def exit_plan_mode(self) -> None:
        """Exit plan mode: restore previous approval policy."""
        self.plan_mode = False
        if self._saved_approval_policy is not None:
            self.approval_policy = self._saved_approval_policy
            self._saved_approval_policy = None
        else:
            self.approval_policy = APPROVAL_POLICY_MANUAL


class SessionService:
    """Manages agent sessions."""

    _instance: "SessionService | None" = None

    def __new__(cls, *args, **kwargs) -> "SessionService":
        """Singleton pattern to ensure single instance."""
        if cls._instance is None:
            cls._instance = super().__new__(cls)
            cls._instance._initialized = False
        return cls._instance

    def __init__(
        self,
        max_sessions: int = 100,
        session_timeout_minutes: int = 60,
    ) -> None:
        """Initialize the session service.

        Args:
            max_sessions: Maximum number of concurrent sessions.
            session_timeout_minutes: Session timeout in minutes.
        """
        if self._initialized:
            return

        self._sessions: dict[str, Session] = {}
        self._max_sessions = max_sessions
        self._session_timeout_minutes = session_timeout_minutes
        self._lock = asyncio.Lock()
        self._agent_init_lock = asyncio.Lock()  # Protects global settings during agent init
        self._checkpointer_ctx = None
        self._store_ctx = None
        self._checkpointer: Any = None  # BaseCheckpointSaver
        self._store: Any = None  # BaseStore
        self._db_initialized = False
        self._ws_manager: Any = None  # WebSocketManager (set externally)
        self._initialized = True

    async def _ensure_db_initialized(self) -> None:
        """Ensure web sessions database is initialized."""
        if self._db_initialized:
            return
        await init_web_sessions_db()
        self._db_initialized = True

    async def _ensure_persistence(self) -> tuple[Any, Any]:
        """Ensure checkpointer and store are initialized (web-specific)."""
        if self._checkpointer is None:
            self._checkpointer_ctx = get_web_checkpointer()
            self._checkpointer = await self._checkpointer_ctx.__aenter__()

        if self._store is None:
            self._store_ctx = get_web_store()
            self._store = await self._store_ctx.__aenter__()

        return self._checkpointer, self._store

    async def _persist_session(self, session: Session) -> None:
        """Persist full session state to SQLite."""
        await save_web_session(
            session_id=session.id,
            thread_id=session.thread_id,
            agent_id=session.agent_id,
            working_directory=session.working_directory,
            model_name=session.model_name,
            auto_approve=session.auto_approve,
            approval_policy=session.approval_policy,
            timeout_minutes=session.timeout_minutes,
            max_concurrent_sessions=session.max_concurrent_sessions,
            rate_limit_max_requests=session.rate_limit_max_requests,
            rate_limit_window_seconds=session.rate_limit_window_seconds,
            created_at=session.created_at.isoformat(),
            last_activity=session.last_activity.isoformat(),
            status=session.status,
            user_id=session.user_id,
            workspace_id=session.workspace_id,
        )

    async def load_persisted_sessions(self) -> None:
        """Load sessions from SQLite database on startup."""
        await self._ensure_db_initialized()
        persisted = await load_web_sessions()

        checkpointer, store = await self._ensure_persistence()

        for data in persisted:
            # Recreate session object from persisted data
            session = Session(
                id=data["id"],
                agent_id=data["agent_id"],
                working_directory=data["working_directory"],
                model_name=data["model_name"],
                auto_approve=data["auto_approve"],
                approval_policy=normalize_approval_policy(data.get("approval_policy")),
                timeout_minutes=data.get("timeout_minutes"),
                max_concurrent_sessions=data.get("max_concurrent_sessions"),
                rate_limit_max_requests=data.get("rate_limit_max_requests"),
                rate_limit_window_seconds=data.get("rate_limit_window_seconds"),
                user_id=data.get("user_id", "anonymous"),
                workspace_id=data.get("workspace_id", "default"),
                created_at=datetime.fromisoformat(data["created_at"]).replace(tzinfo=UTC),
                last_activity=datetime.fromisoformat(data["last_activity"]).replace(tzinfo=UTC),
                status=data["status"],
                thread_id=data["thread_id"],
                checkpointer=checkpointer,
                store=store,
            )
            self._sessions[session.id] = session

    async def create_session(
        self,
        working_directory: str = ".",
        agent_id: str = "agent",
        model_name: str | None = None,
        auto_approve: bool = False,
        approval_policy: str = "manual",
        timeout_minutes: int | None = None,
        max_concurrent_sessions: int | None = None,
        rate_limit_max_requests: int | None = None,
        rate_limit_window_seconds: int | None = None,
        user_id: str = "anonymous",
        workspace_id: str = "default",
    ) -> Session:
        """Create a new agent session.

        Args:
            working_directory: Working directory for the session.
            agent_id: Agent identifier.
            model_name: Model to use.
            auto_approve: Auto-approve tool calls.

        Returns:
            Created session.

        Raises:
            RuntimeError: If maximum sessions reached.
        """
        await self._ensure_db_initialized()

        async with self._lock:
            if len(self._sessions) >= self._max_sessions:
                msg = f"Maximum sessions ({self._max_sessions}) reached"
                raise RuntimeError(msg)

            if max_concurrent_sessions is not None and max_concurrent_sessions > 0:
                active_for_scope = sum(
                    1
                    for existing in self._sessions.values()
                    if (
                        existing.user_id == user_id
                        and existing.workspace_id == workspace_id
                        and existing.status == "active"
                    )
                )
                if active_for_scope >= max_concurrent_sessions:
                    msg = (
                        "Maximum concurrent sessions reached for this user/workspace "
                        f"({max_concurrent_sessions})"
                    )
                    raise RuntimeError(msg)

            # Resolve working directory
            cwd = Path(working_directory).resolve()
            if not cwd.exists():
                msg = f"Working directory does not exist: {cwd}"
                raise ValueError(msg)

            # Generate session ID with high entropy (256-bit, URL-safe)
            session_id = generate_session_id()

            # Ensure persistence is initialized
            checkpointer, store = await self._ensure_persistence()

            now = datetime.now(UTC)

            # Create session (agent will be created on first use)
            session = Session(
                id=session_id,
                agent_id=agent_id,
                working_directory=str(cwd),
                model_name=model_name,
                auto_approve=auto_approve,
                approval_policy=normalize_approval_policy(approval_policy),
                timeout_minutes=timeout_minutes,
                max_concurrent_sessions=max_concurrent_sessions,
                rate_limit_max_requests=rate_limit_max_requests,
                rate_limit_window_seconds=rate_limit_window_seconds,
                user_id=user_id,
                workspace_id=workspace_id,
                checkpointer=checkpointer,
                store=store,
                created_at=now,
                last_activity=now,
            )

            self._sessions[session_id] = session

            # Persist to SQLite
            await self._persist_session(session)

            return session

    async def initialize_agent(self, session: Session) -> None:
        """Initialize the agent for a session.

        Creates the agent without mutating global process state (os.chdir,
        os.environ).  Instead, API keys and base URLs stored on the session
        object are injected into the ``settings`` module temporarily during
        model creation, and the working directory is passed explicitly to
        ``create_cli_agent``.

        A lock is held for the entire duration of settings mutation and model
        creation to prevent concurrent sessions from cross-contaminating
        credentials via the global ``settings`` singleton.

        Args:
            session: Session to initialize agent for.
        """
        if session.agent is not None:
            return  # Already initialized

        async with self._agent_init_lock:
            # Re-check after acquiring lock (another coroutine may have initialized)
            if session.agent is not None:
                return

            # Import here to avoid circular imports
            from ieum.agent import create_cli_agent
            from ieum.config import create_model, settings
            from ieum.tools import fetch_url, http_request, python_repl, ripgrep, web_search

            # --- Temporarily inject session-scoped credentials into settings ---
            # We snapshot the original values so we can restore them after model
            # creation.  This avoids mutating os.environ which is process-global
            # and not safe under concurrent sessions.
            original_settings_snapshot: dict[str, Any] = {}

            if session.model_name:
                model_spec = session.model_name
                base_url = session._base_url

                # Extract base_url from model_name if present (provider/model@base_url)
                if '@' in model_spec:
                    model_spec, base_url = model_spec.rsplit('@', 1)

                # Extract provider
                if '/' in model_spec:
                    provider = model_spec.split('/')[0].lower()

                    # Inject base URL into settings for local providers
                    if base_url:
                        url_attr_map = {
                            'ollama': 'ollama_base_url',
                            'lmstudio': 'lmstudio_base_url',
                            'vllm': 'vllm_base_url',
                        }
                        attr = url_attr_map.get(provider)
                        if attr:
                            original_settings_snapshot[attr] = getattr(settings, attr)
                            setattr(settings, attr, base_url)

                    # Inject API key into settings for cloud providers
                    if session._api_key:
                        key_attr_map = {
                            'openai': 'openai_api_key',
                            'anthropic': 'anthropic_api_key',
                            'google': 'google_api_key',
                            'groq': 'groq_api_key',
                            'mistral': 'mistral_api_key',
                            'cohere': 'cohere_api_key',
                            'nvidia': 'nvidia_api_key',
                            'openrouter': 'openrouter_api_key',
                            'litellm': 'litellm_api_key',
                            'deepseek': 'deepseek_api_key',
                        }
                        attr = key_attr_map.get(provider)
                        if attr:
                            original_settings_snapshot[attr] = getattr(settings, attr)
                            setattr(settings, attr, session._api_key)

            try:
                # Create model (without @base_url suffix)
                model_name = session.model_name
                if model_name and '@' in model_name:
                    model_name = model_name.rsplit('@', 1)[0]
                model = create_model(model_name)

                # Create tools
                tools = [http_request, fetch_url, ripgrep, python_repl]
                if settings.has_tavily:
                    tools.append(web_search)

                # Create agent -- pass working_directory explicitly instead of
                # relying on os.chdir.
                agent, backend = create_cli_agent(
                    model=model,
                    assistant_id=session.agent_id,
                    tools=tools,
                    auto_approve=session.auto_approve,
                    enable_memory=True,
                    enable_skills=True,
                    checkpointer=session.checkpointer,
                    store=session.store,
                    client_environment="WEB",
                    working_directory=session.working_directory,
                )

                session.agent = agent
                session.backend = backend
            finally:
                # Restore original settings values
                for attr, original_value in original_settings_snapshot.items():
                    setattr(settings, attr, original_value)

    def get_session(self, session_id: str, user_id: str | None = None) -> Session | None:
        """Get a session by ID.

        Args:
            session_id: Session ID.

        Returns:
            Session or None if not found / not authorized.
        """
        session = self._sessions.get(session_id)
        if session is None:
            return None
        if user_id is not None and session.user_id != user_id:
            return None
        return session

    def list_sessions(self, user_id: str | None = None) -> list[Session]:
        """List all active sessions.

        Returns:
            List of active sessions.
        """
        if user_id is None:
            return list(self._sessions.values())
        return [s for s in self._sessions.values() if s.user_id == user_id]

    async def delete_session(self, session_id: str, user_id: str | None = None) -> bool:
        """Delete a session.

        Args:
            session_id: Session ID to delete.

        Returns:
            True if deleted, False if not found.
        """
        async with self._lock:
            session = self.get_session(session_id, user_id=user_id)
            if session is None:
                return False

            self._sessions.pop(session_id, None)
            session.status = "closed"
            # Delete from SQLite
            await delete_web_session(session_id)
            # Note: Agent cleanup is handled by garbage collection

        # Clean up WebSocket manager session-specific data (rate windows, history, sequences)
        if self._ws_manager is not None:
            try:
                await self._ws_manager.remove_session(session_id)
            except Exception:
                logger.debug(
                    "Failed to clean up WebSocket data for session %s",
                    session_id,
                    exc_info=True,
                )

        return True

    async def touch_session(self, session_id: str) -> None:
        """Update session last activity and persist to SQLite."""
        session = self._sessions.get(session_id)
        if session:
            session.touch()
            await update_web_session_activity(session_id)

    async def update_session_approval_policy(
        self,
        session_id: str,
        approval_policy: str,
        *,
        user_id: str | None = None,
    ) -> Session | None:
        """Update approval policy for an existing session."""
        session = self.get_session(session_id, user_id=user_id)
        if session is None:
            return None
        session.approval_policy = normalize_approval_policy(approval_policy)
        await self._persist_session(session)
        return session

    async def append_audit_event(
        self,
        *,
        session_id: str,
        user_id: str,
        workspace_id: str,
        event_type: str,
        payload: dict[str, Any],
    ) -> None:
        """Append one audit event for a session."""
        await append_web_audit_event(
            session_id=session_id,
            user_id=user_id,
            workspace_id=workspace_id,
            event_type=event_type,
            payload=payload,
        )

    async def list_audit_events(
        self,
        *,
        session_id: str,
        user_id: str,
        since_id: int | None = None,
        limit: int = 200,
    ) -> list[dict[str, Any]]:
        """List audit events for a user-owned session."""
        session = self.get_session(session_id, user_id=user_id)
        if session is None:
            return []
        return await list_web_audit_events(
            session_id=session_id,
            user_id=user_id,
            since_id=since_id,
            limit=limit,
        )

    async def cleanup_expired_sessions_loop(self) -> None:
        """Background task to clean up expired sessions."""
        while True:
            await asyncio.sleep(60)  # Check every minute
            await self._cleanup_expired_sessions()

    async def _cleanup_expired_sessions(self) -> None:
        """Clean up expired sessions."""
        async with self._lock:
            now = datetime.now(UTC)
            expired = []
            for session_id, session in self._sessions.items():
                age_minutes = (now - session.last_activity).total_seconds() / 60
                timeout_minutes = session.timeout_minutes or self._session_timeout_minutes
                if age_minutes > timeout_minutes:
                    expired.append(session_id)

            for session_id in expired:
                session = self._sessions.pop(session_id, None)
                if session:
                    session.status = "expired"
                    await self._persist_session(session)

    async def close_all(self) -> None:
        """Close all sessions and cleanup resources."""
        async with self._lock:
            for session in self._sessions.values():
                session.status = "closed"
            self._sessions.clear()

        # Close persistence contexts
        if self._checkpointer_ctx:
            await self._checkpointer_ctx.__aexit__(None, None, None)
        if self._store_ctx:
            await self._store_ctx.__aexit__(None, None, None)

    async def get_file_tree(
        self,
        session_id: str,
        path: str = "/",
        depth: int = 2,
        show_hidden: bool = False,
    ) -> list[dict]:
        """Get file tree for a session.

        Args:
            session_id: Session ID.
            path: Root path.
            depth: Maximum depth.
            show_hidden: Whether to show hidden files.

        Returns:
            List of file nodes.

        Raises:
            PermissionError: If the resolved path escapes the working directory.
        """
        session = self.get_session(session_id)
        if session is None:
            msg = f"Session {session_id} not found"
            raise ValueError(msg)

        # Resolve path relative to working directory
        path = path.replace("\\", "/")
        if path.startswith("/"):
            path = path[1:]
        root = Path(session.working_directory) / path

        # Security: Path traversal defense -- resolve symlinks and verify
        # that the resolved path is within the working directory.
        base_path = Path(session.working_directory).resolve()
        resolved_root = root.resolve()
        try:
            resolved_root.relative_to(base_path)
        except ValueError:
            msg = f"Access denied: path traversal detected for {path}"
            raise PermissionError(msg) from None

        if not resolved_root.exists():
            return []

        return self._build_tree(resolved_root, depth, base_path, show_hidden)

    def _build_tree(self, path: Path, depth: int, base_path: Path, show_hidden: bool = False) -> list[dict]:
        """Build file tree recursively.

        Args:
            path: Current path.
            depth: Remaining depth.
            base_path: Base working directory for relative paths.
            show_hidden: Whether to show hidden files.

        Returns:
            List of file nodes.
        """
        if depth < 0 or not path.exists():
            return []

        directories = []
        files = []

        try:
            for item in path.iterdir():
                # Skip hidden files and common ignore patterns (unless show_hidden is True)
                if not show_hidden:
                    if item.name.startswith(".") or item.name in {
                        "node_modules",
                        "__pycache__",
                        ".git",
                        "venv",
                        ".venv",
                    }:
                        continue

                # Use forward slashes for paths (cross-platform compatibility)
                # Path relative to working directory
                relative_path = str(item.relative_to(base_path)).replace("\\", "/")
                node = {
                    "name": item.name,
                    "path": "/" + relative_path,
                    "type": "directory" if item.is_dir() else "file",
                }

                if item.is_dir():
                    if depth > 0:
                        node["children"] = self._build_tree(item, depth - 1, base_path, show_hidden)
                    directories.append(node)
                else:
                    try:
                        node["size"] = item.stat().st_size
                    except OSError:
                        node["size"] = None
                    files.append(node)
        except PermissionError:
            pass

        # VS Code default sort: folders first, then files, each alphabetically (case-insensitive)
        directories.sort(key=lambda x: x["name"].lower())
        files.sort(key=lambda x: x["name"].lower())

        return directories + files

    async def get_file_content(
        self,
        session_id: str,
        path: str,
    ) -> tuple[str, int]:
        """Get file content.

        Args:
            session_id: Session ID.
            path: File path.

        Returns:
            Tuple of (content, size).

        Raises:
            PermissionError: If the resolved path escapes the working directory.
        """
        session = self.get_session(session_id)
        if session is None:
            msg = f"Session {session_id} not found"
            raise ValueError(msg)

        # Resolve path relative to working directory
        # Normalize path separators (handle both forward and backslashes)
        path = path.replace("\\", "/")
        if path.startswith("/"):
            path = path[1:]
        file_path = Path(session.working_directory) / path

        # Security: Path traversal defense -- resolve symlinks and verify
        # that the resolved path is within the working directory.
        base_path = Path(session.working_directory).resolve()
        resolved_path = file_path.resolve()
        try:
            resolved_path.relative_to(base_path)
        except ValueError:
            msg = f"Access denied: {path}"
            raise PermissionError(msg) from None

        if not resolved_path.exists():
            msg = f"File not found: {path}"
            raise FileNotFoundError(msg)

        if not resolved_path.is_file():
            msg = f"Not a file: {path}"
            raise ValueError(msg)

        try:
            content = resolved_path.read_text(encoding="utf-8")
            size = resolved_path.stat().st_size
            return content, size
        except UnicodeDecodeError:
            msg = f"Cannot read binary file: {path}"
            raise ValueError(msg) from None

    async def save_file_content(
        self,
        session_id: str,
        path: str,
        content: str,
        overwrite: bool = True,
    ) -> None:
        """Save content to a file.

        Args:
            session_id: Session ID.
            path: File path.
            content: Content to save.
            overwrite: If False, raise FileExistsError if file exists.
        """
        session = self.get_session(session_id)
        if session is None:
            msg = f"Session {session_id} not found"
            raise ValueError(msg)

        # Resolve path relative to working directory
        # Normalize path separators (handle both forward and backslashes)
        path = path.replace("\\", "/")
        if path.startswith("/"):
            path = path[1:]
        file_path = Path(session.working_directory) / path

        # Security check: ensure path is within working directory
        try:
            file_path.resolve().relative_to(Path(session.working_directory).resolve())
        except ValueError:
            msg = f"Access denied: {path}"
            raise PermissionError(msg) from None

        # Check if file exists and overwrite is False
        if not overwrite and file_path.exists():
            msg = f"File already exists: {path}"
            raise FileExistsError(msg)

        # Create parent directories if needed
        file_path.parent.mkdir(parents=True, exist_ok=True)

        # Write content
        file_path.write_text(content, encoding="utf-8")

    async def create_folder(
        self,
        session_id: str,
        path: str,
        overwrite: bool = True,
    ) -> None:
        """Create a new folder.

        Args:
            session_id: Session ID.
            path: Folder path.
            overwrite: If False, raise FileExistsError if folder exists.
        """
        session = self.get_session(session_id)
        if session is None:
            msg = f"Session {session_id} not found"
            raise ValueError(msg)

        # Resolve path relative to working directory
        path = path.replace("\\", "/")
        if path.startswith("/"):
            path = path[1:]
        folder_path = Path(session.working_directory) / path

        # Security check: ensure path is within working directory
        try:
            folder_path.resolve().relative_to(Path(session.working_directory).resolve())
        except ValueError:
            msg = f"Access denied: {path}"
            raise PermissionError(msg) from None

        # Check if folder exists and overwrite is False
        if not overwrite and folder_path.exists():
            msg = f"Folder already exists: {path}"
            raise FileExistsError(msg)

        # Create folder
        folder_path.mkdir(parents=True, exist_ok=True)

    async def rename_file(
        self,
        session_id: str,
        old_path: str,
        new_path: str,
    ) -> None:
        """Rename a file or folder.

        Args:
            session_id: Session ID.
            old_path: Current file path.
            new_path: New file path.
        """
        session = self.get_session(session_id)
        if session is None:
            msg = f"Session {session_id} not found"
            raise ValueError(msg)

        # Resolve paths relative to working directory
        old_path = old_path.replace("\\", "/")
        new_path = new_path.replace("\\", "/")
        if old_path.startswith("/"):
            old_path = old_path[1:]
        if new_path.startswith("/"):
            new_path = new_path[1:]

        old_file_path = Path(session.working_directory) / old_path
        new_file_path = Path(session.working_directory) / new_path

        # Security check: ensure paths are within working directory
        try:
            old_file_path.resolve().relative_to(Path(session.working_directory).resolve())
            new_file_path.resolve().relative_to(Path(session.working_directory).resolve())
        except ValueError:
            msg = f"Access denied"
            raise PermissionError(msg) from None

        if not old_file_path.exists():
            msg = f"File not found: {old_path}"
            raise FileNotFoundError(msg)

        # Rename
        old_file_path.rename(new_file_path)

    async def delete_file(
        self,
        session_id: str,
        path: str,
    ) -> None:
        """Delete a file or folder.

        Args:
            session_id: Session ID.
            path: File or folder path.
        """
        import shutil

        session = self.get_session(session_id)
        if session is None:
            msg = f"Session {session_id} not found"
            raise ValueError(msg)

        # Resolve path relative to working directory
        path = path.replace("\\", "/")
        if path.startswith("/"):
            path = path[1:]
        file_path = Path(session.working_directory) / path

        # Security check: ensure path is within working directory
        try:
            file_path.resolve().relative_to(Path(session.working_directory).resolve())
        except ValueError:
            msg = f"Access denied: {path}"
            raise PermissionError(msg) from None

        if not file_path.exists():
            msg = f"File not found: {path}"
            raise FileNotFoundError(msg)

        # Delete
        if file_path.is_dir():
            shutil.rmtree(file_path)
        else:
            file_path.unlink()
