"""FastAPI router for the SwarmWS single-workspace model.

This module provides the REST API endpoints for managing the singleton
SwarmWS workspace, its projects, and filesystem operations (folders,
files, renaming).  It is registered with a prefix in ``main.py``.

Public endpoints:

- ``GET  /workspace``              — Retrieve singleton workspace config
- ``PUT  /workspace``              — Update workspace config (icon, context)
- ``GET  /workspace/tree``         — Return workspace filesystem tree as nested JSON
- ``GET  /workspace/file/committed`` — Return last committed version of a file (git show HEAD:<path>)
- ``GET  /projects``               — List all projects
- ``POST /projects``               — Create a new project
- ``GET  /projects/{project_id}``  — Get project by ID
- ``PUT  /projects/{project_id}``  — Update project metadata
- ``DELETE /projects/{project_id}``— Delete a project
- ``POST /workspace/folders``      — Create a folder inside the workspace
- ``DELETE /workspace/folders``     — Delete a folder or file
- ``PUT  /workspace/rename``       — Rename / move an item

Helper functions:

- ``_should_include``      — Root-level filter: hide infrastructure, pass system items + user dirs
- ``_get_git_status``      — Run ``git status --porcelain`` and return {path: status} dict
- ``_build_tree``          — Recursive tree builder with depth bounding, sorting, and git status
- ``_is_readonly_context_file`` — Check if a path is a readonly system-default context file

Helper models (request bodies):

- ``FolderCreateRequest``  — ``path: str``
- ``FolderDeleteRequest``  — ``path: str``
- ``FolderRenameRequest``  — ``old_path: str``, ``new_path: str``
"""

import base64
import functools
import hashlib
import json
import logging
import asyncio
import mimetypes
import os
import shutil
import subprocess
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Callable, Optional

from fastapi import APIRouter, Header, HTTPException, Query
from fastapi.responses import FileResponse, Response
from pydantic import BaseModel

from core.context_directory_loader import CONTEXT_FILES
from core.swarm_workspace_manager import SYSTEM_MANAGED_FOLDERS, swarm_workspace_manager
from database import db
from utils.diff_parser import parse_unified_diff, format_human_summary
from schemas.workspace_config import (
    TreeNodeResponse,
    WorkspaceConfigResponse,
    WorkspaceConfigUpdate,
)

logger = logging.getLogger(__name__)

# ─── ETag / tree cache ────────────────────────────────────────────────────────
# The frontend polls /workspace/tree every 30s.  Running `git status` +
# recursive iterdir on every poll wastes ~50ms CPU per call — the #1 source
# of idle CPU burn.  This cache short-circuits the work:
#   - _etag_cache stores (etag_value, response_bytes, timestamp)
#   - If <5s have passed since last computation, reuse cached ETag directly
#   - Even when recomputing, git+fs work is offloaded to a thread
_ETAG_CACHE_TTL = 5.0  # seconds — at most 1 real scan per 5s; frontend polls every 30s
_etag_cache: dict[str, tuple[str, bytes, float]] = {}  # key=depth → (etag, body, time)
# Thread safety: _etag_cache is read from the event loop (fast path) and written
# from asyncio.to_thread (slow path).  Python's GIL makes individual dict ops
# atomic, and _invalidate_tree_cache().clear() is also atomic.  The worst case
# is serving one stale response after a mutation — acceptable for a polling cache.


def _invalidate_tree_cache() -> None:
    """Clear the ETag cache after a workspace mutation (create/delete/rename/save).

    Forces the next ``/workspace/tree`` poll to recompute git+fs state.
    """
    _etag_cache.clear()

router = APIRouter(tags=["workspace-api"])

MAX_PREVIEW_SIZE = 50 * 1024 * 1024  # 50 MB


# ─────────────────────────────────────────────────────────────────────────────
# Request body models for folder operations
# ─────────────────────────────────────────────────────────────────────────────

class FolderCreateRequest(BaseModel):
    """Request body for creating a folder."""
    path: str


class FolderDeleteRequest(BaseModel):
    """Request body for deleting a folder or file."""
    path: str


class FolderRenameRequest(BaseModel):
    """Request body for renaming / moving an item."""
    old_path: str
    new_path: str


# ─────────────────────────────────────────────────────────────────────────────
# Helper
# ─────────────────────────────────────────────────────────────────────────────

async def _get_workspace_path() -> str:
    """Return the expanded absolute workspace root path.

    Reads the singleton workspace config from the database and expands
    any path placeholders.

    Returns:
        Expanded absolute path to the workspace root.

    Raises:
        HTTPException: 404 if no workspace config exists.
    """
    config = await db.workspace_config.get_config()
    if config is None:
        raise HTTPException(status_code=404, detail="Workspace not configured")
    return swarm_workspace_manager.expand_path(config["file_path"])

def _validate_relative_path(relative_path: str, workspace_root: str) -> Path:
    """Validate that a relative path resolves within the workspace root.

    Prevents path traversal attacks by resolving the full path and
    verifying it stays under the workspace root.

    Args:
        relative_path: User-supplied relative path.
        workspace_root: Expanded absolute workspace root.

    Returns:
        The resolved absolute Path.

    Raises:
        HTTPException: 400 if path is empty, contains traversal, or escapes root.
    """
    stripped = relative_path.strip("/").replace("\\", "/")
    if not stripped:
        raise HTTPException(status_code=400, detail="Path cannot be empty")

    resolved = (Path(workspace_root) / stripped).resolve()
    root_resolved = Path(workspace_root).resolve()

    if not resolved.is_relative_to(root_resolved):
        raise HTTPException(status_code=400, detail="Path traversal not allowed")

    return resolved



# ─────────────────────────────────────────────────────────────────────────────
# Workspace config endpoints
# ─────────────────────────────────────────────────────────────────────────────

@router.get("/workspace", response_model=WorkspaceConfigResponse)
async def get_workspace():
    """Get the singleton workspace configuration."""
    config = await db.workspace_config.get_config()
    if config is None:
        raise HTTPException(status_code=404, detail="Workspace not configured")

    config["file_path"] = swarm_workspace_manager.expand_path(config["file_path"])
    return WorkspaceConfigResponse(**config)



@router.put("/workspace", response_model=WorkspaceConfigResponse)
async def update_workspace(request: WorkspaceConfigUpdate):
    """Update the singleton workspace configuration (icon, context)."""
    updates: dict = {}
    if request.icon is not None:
        updates["icon"] = request.icon
    if request.context is not None:
        updates["context"] = request.context

    if not updates:
        # Nothing to update — return current config
        config = await db.workspace_config.get_config()
        if config is None:
            raise HTTPException(status_code=404, detail="Workspace not configured")
        config["file_path"] = swarm_workspace_manager.expand_path(config["file_path"])
        return WorkspaceConfigResponse(**config)

    result = await db.workspace_config.update_config(updates)
    if result is None:
        raise HTTPException(status_code=404, detail="Workspace not configured")

    result["file_path"] = swarm_workspace_manager.expand_path(result["file_path"])
    return WorkspaceConfigResponse(**result)


# ─────────────────────────────────────────────────────────────────────────────
# Workspace tree endpoint
# ─────────────────────────────────────────────────────────────────────────────


# Internal directories that exist on disk but should not appear in the
# workspace explorer tree.  These are runtime/system data, not user content.
# .git is excluded because its internals are not useful to browse.
_HIDDEN_DIRS = frozenset({"chats", ".git", "Services"})

# Root-level items that the frontend displays in a separate "System" section.
# These MUST pass through _should_include so the frontend can extract them.
_SYSTEM_ITEMS = frozenset({".context", ".claude", "config.json", "proactive_state.json"})

# Root-level directories that are system/infrastructure — hidden from explorer.
# Not in _SYSTEM_ITEMS (frontend doesn't use them), not user content.
_HIDDEN_ROOT_DIRS = frozenset({
    ".pytest_cache",  # Dev artifact
    "config-backup",  # System backup
    "db-export",      # Database export (system)
    "output",         # Legacy output dir
    "workspace",      # System workspace dir
})


def _should_include(
    name: str, *, is_root: bool = False, is_dir: bool = False
) -> bool:
    """Return True if a file/directory name should appear in the tree.

    Filtering strategy:
    - At root level: show _SYSTEM_ITEMS (frontend extracts to System section),
      show user content directories (Knowledge, Projects, Attachments, etc.),
      hide everything else (infrastructure files, system-only dirs).
    - At all levels: hide directories in ``_HIDDEN_DIRS`` (chats, .git, Services).
    - Below root: show everything (user content lives in subdirectories).
    """
    if name in _HIDDEN_DIRS:
        return False
    if is_root:
        # System items always pass through (frontend needs them)
        if name in _SYSTEM_ITEMS:
            return True
        if not is_dir:
            # All other root-level files are infrastructure artifacts
            return False
        if name in _HIDDEN_ROOT_DIRS:
            return False
    return True


def _get_git_status(workspace_root: Path) -> dict[str, str]:
    """Run ``git status --porcelain -z`` and return a dict of {relative_path: status}.

    Uses ``-z`` for NUL-separated output to avoid quoting of paths with spaces
    or special characters.

    Status values match the GitStatus type on the frontend:
    - 'added', 'modified', 'deleted', 'renamed', 'untracked', 'conflicting'

    Returns an empty dict if the workspace is not a git repo or git fails.
    """
    git_dir = workspace_root / ".git"
    if not git_dir.is_dir():
        return {}

    try:
        # Use -unormal (default) instead of -uall.  -uall recursively
        # enumerates every untracked file in every directory — extremely
        # expensive on large repos (100ms+).  -unormal shows untracked
        # *directories* as a single entry, which is sufficient for the
        # explorer's change indicators and costs ~5ms instead.
        result = subprocess.run(
            ["git", "status", "--porcelain", "-z", "-unormal"],
            cwd=str(workspace_root),
            capture_output=True,
            text=True,
            timeout=5,
        )
        if result.returncode != 0:
            return {}
    except (OSError, subprocess.TimeoutExpired):
        return {}

    status_map: dict[str, str] = {}
    # -z output: entries separated by NUL, renames have two NUL-separated paths
    entries = result.stdout.split("\0")
    i = 0
    while i < len(entries):
        entry = entries[i]
        if len(entry) < 4:
            i += 1
            continue

        xy = entry[:2]
        filepath = entry[3:]

        # Renames: the next NUL-separated entry is the destination path
        if xy[0] == "R" or xy[1] == "R":
            i += 1
            if i < len(entries):
                filepath = entries[i]  # use the destination (new) path

        # Normalize path separators
        filepath = filepath.replace("\\", "/")

        # Map git status codes to our GitStatus enum
        if "U" in xy or (xy[0] == "A" and xy[1] == "A") or (xy[0] == "D" and xy[1] == "D"):
            status_map[filepath] = "conflicting"
        elif xy[0] == "R" or xy[1] == "R":
            status_map[filepath] = "renamed"
        elif xy == "??":
            status_map[filepath] = "untracked"
        elif xy == "!!":
            status_map[filepath] = "ignored"
        elif "D" in xy:
            status_map[filepath] = "deleted"
        elif "A" in xy:
            status_map[filepath] = "added"
        elif "M" in xy or "T" in xy:
            status_map[filepath] = "modified"

        i += 1

    return status_map


def _build_tree(
    root: Path,
    workspace_root: Path,
    depth: int,
    git_status: dict[str, str] | None = None,
    subrepo_status_fn: "Callable[[Path], dict[str, str]] | None" = None,
) -> list[dict]:
    """Build a nested tree of workspace entries.

    Walks *root* up to *depth* levels, excluding hidden entries (except
    ``.project.json``).  Directories are sorted before files; both groups
    are sorted alphabetically.

    *subrepo_status_fn* is an optional cached git-status getter that avoids
    duplicate subprocess spawns for symlinked sub-repos across calls.

    Each node is a plain dict matching ``TreeNodeResponse`` fields so it
    can be serialised directly by FastAPI.
    """
    if depth <= 0:
        return []

    try:
        entries = list(root.iterdir())
    except OSError:
        return []

    # Partition into dirs and files, filtering hidden entries
    is_root = root == workspace_root
    dirs: list[Path] = []
    files: list[Path] = []
    for entry in entries:
        entry_is_dir = entry.is_dir()
        if not _should_include(entry.name, is_root=is_root, is_dir=entry_is_dir):
            continue
        if entry_is_dir:
            dirs.append(entry)
        else:
            files.append(entry)

    # Sort: directories first (alphabetically), then files (alphabetically)
    dirs.sort(key=lambda p: p.name.lower())
    files.sort(key=lambda p: p.name.lower())

    result: list[dict] = []

    for d in dirs:
        rel_path = str(d.relative_to(workspace_root)).replace("\\", "/")

        # Detect symlinked directories that are separate git repos.
        # The parent workspace's git status won't cover files inside these —
        # we need to run git status from the symlink target's own repo root.
        child_git_status = git_status
        resolved = d.resolve()
        git_path = resolved / ".git"
        if d.is_symlink() and (git_path.is_dir() or git_path.is_file()):
            # Use cached getter if available to avoid duplicate subprocess spawns
            sub_status = subrepo_status_fn(resolved) if subrepo_status_fn else _get_git_status(resolved)
            # Re-key sub-repo paths relative to the workspace root
            child_git_status = dict(git_status) if git_status else {}
            for sub_path, sub_st in sub_status.items():
                child_git_status[f"{rel_path}/{sub_path}"] = sub_st

        children = _build_tree(d, workspace_root, depth - 1, child_git_status, subrepo_status_fn) if depth > 1 else None

        # Directory git status: check direct match first, then inherit from children
        dir_status = None
        effective_status = child_git_status or git_status
        if effective_status:
            # Check if this directory itself has a git status entry (e.g., symlink flat-path)
            if rel_path in effective_status:
                dir_status = effective_status[rel_path]
            # Also check if any child file has a git status (prefix scan).
            # Note: if children have status, we upgrade to "modified" even if
            # the directory itself had a more specific status (e.g., "untracked").
            # This is intentional — "modified" is the correct aggregate indicator.
            prefix = rel_path + "/"
            for gpath, gstatus in effective_status.items():
                if gpath.startswith(prefix):
                    dir_status = "modified"
                    break

        node: dict = {
            "name": d.name,
            "path": rel_path,
            "type": "directory",
            "children": children,
        }
        if d.is_symlink():
            node["is_symlink"] = True
        if dir_status:
            node["git_status"] = dir_status
        result.append(node)

    for f in files:
        rel_path = str(f.relative_to(workspace_root)).replace("\\", "/")
        node: dict = {
            "name": f.name,
            "path": rel_path,
            "type": "file",
            "children": None,
        }
        if git_status and rel_path in git_status:
            node["git_status"] = git_status[rel_path]
        result.append(node)

    return result


def _collect_subrepo_status(
    entry: Path, rel_prefix: str, items: list[tuple[str, str]]
) -> None:
    """If *entry* is a symlink pointing to a separate git repo, run git status
    on that repo and append results (re-keyed under *rel_prefix*) to *items*.

    Handles both standard repos (.git is a directory) and worktrees (.git is
    a file containing ``gitdir: ...``).
    """
    if entry.is_symlink() and entry.is_dir():
        try:
            resolved = entry.resolve()
            git_path = resolved / ".git"
            if git_path.is_dir() or git_path.is_file():
                sub_status = _get_git_status(resolved)
                for sp, ss in sub_status.items():
                    items.append((f"{rel_prefix}/{sp}", ss))
        except OSError:
            pass


def _collect_subrepo_status_cached(
    entry: Path,
    rel_prefix: str,
    items: list[tuple[str, str]],
    get_status: Callable[[Path], dict[str, str]],
) -> None:
    """Like _collect_subrepo_status but uses a cached git-status getter.

    The *get_status* callable should be a per-invocation cached function
    that deduplicates git status calls across ETag computation and tree building.
    """
    if entry.is_symlink() and entry.is_dir():
        try:
            resolved = entry.resolve()
            git_path = resolved / ".git"
            if git_path.is_dir() or git_path.is_file():
                sub_status = get_status(resolved)
                for sp, ss in sub_status.items():
                    items.append((f"{rel_prefix}/{sp}", ss))
        except OSError:
            pass


def _compute_etag_and_tree_sync(workspace_root: Path, depth: int) -> tuple[str, bytes]:
    """Compute ETag + serialised tree JSON in a worker thread.

    This is the EXPENSIVE function that runs ``git status``, iterdir(),
    and tree building.  By running it in ``asyncio.to_thread`` we keep
    the event loop free for SSE heartbeats and other I/O.

    Uses a per-invocation sub-repo cache so each symlinked project is
    scanned at most once (shared between ETag computation and tree building).
    """
    git_status = _get_git_status(workspace_root)

    # Per-invocation cache: resolved_path → {relative_path: status}
    # Prevents duplicate git status calls for symlinked sub-repos
    # (called once during ETag scan, reused during _build_tree).
    subrepo_cache: dict[str, dict[str, str]] = {}

    def _get_subrepo_status_cached(resolved: Path) -> dict[str, str]:
        """Return git status for a sub-repo, caching by resolved path."""
        key = str(resolved)
        if key not in subrepo_cache:
            subrepo_cache[key] = _get_git_status(resolved)
        return subrepo_cache[key]

    # Collect sub-repo status (symlinked projects)
    all_status_items = list(git_status.items())
    try:
        for entry in workspace_root.iterdir():
            _collect_subrepo_status_cached(entry, entry.name, all_status_items, _get_subrepo_status_cached)
            if entry.is_dir():
                try:
                    for child in entry.iterdir():
                        _collect_subrepo_status_cached(
                            child, f"{entry.name}/{child.name}", all_status_items, _get_subrepo_status_cached,
                        )
                except OSError:
                    pass
    except OSError:
        pass
    git_hash = hashlib.md5(json.dumps(sorted(all_status_items)).encode()).hexdigest()

    # Filesystem fingerprint — scan up to 5 levels deep for the ETag.
    # Must cover the typical project depth (Projects/MyApp/src/components/)
    # so that file adds/deletes at depth 4-5 are detected.  Deeper levels
    # (6-8) are rare and will be caught on the next TTL expiry (5s).
    _FINGERPRINT_DEPTH = min(depth, 5)

    def _fs_fingerprint(root: Path, max_depth: int, *, at_root: bool = False) -> str:
        if max_depth <= 0 or not root.is_dir():
            return ""
        try:
            entries = list(root.iterdir())
        except OSError:
            return ""
        # Apply same filtering as _build_tree so fingerprint only reflects visible items
        names = sorted(
            e.name for e in entries
            if _should_include(e.name, is_root=at_root, is_dir=e.is_dir())
        )
        parts = [",".join(names)]
        for name in names:
            child = root / name
            if child.is_dir() and max_depth > 1:
                parts.append(f"{name}:{_fs_fingerprint(child, max_depth - 1)}")
        return "|".join(parts)

    fs_hash = hashlib.md5(_fs_fingerprint(workspace_root, _FINGERPRINT_DEPTH, at_root=True).encode()).hexdigest()[:8]
    etag = hashlib.md5(f"{git_hash}:{fs_hash}:{depth}".encode()).hexdigest()
    etag_value = f'"{etag}"'

    tree = _build_tree(workspace_root, workspace_root, depth, git_status, _get_subrepo_status_cached)
    body = json.dumps(tree).encode()

    return etag_value, body


@router.get("/workspace/tree")
async def get_workspace_tree(
    depth: int = Query(default=8, ge=1, le=10),
    if_none_match: Optional[str] = Header(default=None),
) -> list[dict]:
    """Return the SwarmWS filesystem tree as nested JSON.

    Supports conditional requests via ETag / If-None-Match.
    Returns 304 Not Modified when the workspace tree has not changed.

    Walks the workspace root directory up to ``depth`` levels.
    Each node includes:

    - name: str (display name)
    - path: str (relative to workspace root)
    - type: ``"file"`` | ``"directory"``
    - children: list[node] (for directories, if expanded)

    All files are user-manageable — no lock badges or system-managed
    restrictions.

    Performance: git+fs scan is offloaded to a thread pool and cached
    for 5s to match the 30s frontend poll interval. Sub-repo git status
    calls are deduplicated via per-invocation cache.

    Requirements: 10.1, 11.5, 15.1
    """
    expanded_path = await _get_workspace_path()
    workspace_root = Path(expanded_path)

    if not workspace_root.is_dir():
        raise HTTPException(
            status_code=500,
            detail="Workspace root directory does not exist",
        )

    cache_key = str(depth)
    now = time.monotonic()

    # Fast path: serve from cache if fresh (< 2s old)
    cached = _etag_cache.get(cache_key)
    if cached:
        cached_etag, cached_body, cached_time = cached
        if now - cached_time < _ETAG_CACHE_TTL:
            if if_none_match and if_none_match.strip() == cached_etag:
                return Response(status_code=304, headers={"ETag": cached_etag})
            return Response(
                content=cached_body,
                media_type="application/json",
                headers={"ETag": cached_etag},
            )

    # Slow path: offload git+fs scan to thread pool (frees event loop)
    etag_value, body = await asyncio.to_thread(
        _compute_etag_and_tree_sync, workspace_root, depth,
    )

    # Update cache
    _etag_cache[cache_key] = (etag_value, body, time.monotonic())

    if if_none_match and if_none_match.strip() == etag_value:
        return Response(status_code=304, headers={"ETag": etag_value})

    return Response(
        content=body,
        media_type="application/json",
        headers={"ETag": etag_value},
    )


# ─────────────────────────────────────────────────────────────────────────────
# Workspace file content endpoint
# ─────────────────────────────────────────────────────────────────────────────


def _is_readonly_context_file(relative_path: str) -> bool:
    """Check if a file path corresponds to a readonly system-default context file.

    Only applies to files in the ``.context/`` directory.  Returns ``True``
    when the file matches a ``ContextFileSpec`` with ``user_customized=False``
    (system default → readonly).  Returns ``False`` for all other files,
    including user-customized context files and non-context files.

    Falls back to ``False`` on any error (permissive default per Req 9.4).
    """
    try:
        normalized = relative_path.replace("\\", "/")
        if not normalized.startswith(".context/"):
            return False
        filename = normalized.split("/")[-1]
        for spec in CONTEXT_FILES:
            if spec.filename == filename and not spec.user_customized:
                return True
        return False
    except Exception:
        return False


def _is_path_under(child: Path, parent: Path) -> bool:
    """Return True if *child* is equal to or a descendant of *parent*.

    Uses ``Path.parts`` comparison instead of ``str().startswith()`` to avoid
    prefix-collision attacks (e.g., ``/workspace-evil`` matching ``/workspace``).
    Both paths should be resolved before calling.
    """
    child_parts = child.resolve().parts
    parent_parts = parent.resolve().parts
    return child_parts[: len(parent_parts)] == parent_parts


def _resolve_file_path(
    path: str, workspace_root: Path
) -> tuple[Path, bool]:
    """Resolve a file path to an absolute target with external flag.

    Handles both absolute paths (``/Users/.../foo.py``) and workspace-relative
    paths (``Knowledge/Notes/foo.md``).

    Args:
        path: Absolute or workspace-relative file path.
        workspace_root: Resolved workspace root directory.

    Returns:
        ``(target, is_external)`` — *target* is the resolved absolute path,
        *is_external* is True when the file lives outside the workspace
        (either an absolute path pointing elsewhere, or a symlink traversal).

    Raises:
        HTTPException 400: For relative paths with ``..`` traversal that
            don't resolve through a trusted workspace symlink.
    """
    normalized = os.path.normpath(path)

    # ── Absolute path — allow only under user's home directory ──
    if os.path.isabs(normalized):
        target = Path(normalized).resolve()
        home = Path.home().resolve()
        if not _is_path_under(target, home):
            raise HTTPException(
                status_code=400,
                detail=f"Absolute path must be under user home directory: {path}",
            )
        is_external = not _is_path_under(target, workspace_root)
        return target, is_external

    # ── Relative path — apply traversal guard ──
    if ".." in path.split("/"):
        raise HTTPException(status_code=400, detail=f"Path traversal not allowed: {path}")

    target = (workspace_root / path).resolve()
    is_external = not _is_path_under(target, workspace_root)

    # Relative path that escapes the workspace must go through a symlink
    if is_external and not _is_symlink_traversal(workspace_root, path):
        raise HTTPException(status_code=400, detail=f"Path outside workspace: {path}")

    return target, is_external


@functools.lru_cache(maxsize=64)
def _find_git_root_cached(parent_dir: str) -> Optional[str]:
    """Cached git root lookup by parent directory string.

    Caching by *str* (not Path) so the LRU key is hashable.
    Returns the git root as a string, or None.
    """
    try:
        result = subprocess.run(
            ["git", "rev-parse", "--show-toplevel"],
            cwd=parent_dir,
            capture_output=True,
            text=True,
            timeout=5,
        )
        if result.returncode == 0:
            return result.stdout.strip()
    except (OSError, subprocess.TimeoutExpired):
        pass
    return None


def _find_git_root(file_path: Path) -> Optional[Path]:
    """Find the git repository root containing the given file.

    Uses an LRU cache keyed on parent directory to avoid repeated
    ``git rev-parse`` subprocess calls for files in the same repo.
    """
    parent = file_path.parent if file_path.is_file() else file_path
    if not parent.is_dir():
        return None
    root_str = _find_git_root_cached(str(parent))
    return Path(root_str) if root_str else None


def _is_symlink_traversal(workspace_root: Path, relative_path: str) -> bool:
    """Return True if *relative_path* reaches outside the workspace through a
    symlink that itself lives inside the workspace (e.g., Projects/SwarmAI/...).

    **Security model — write-through-symlinks:**
    This function intentionally allows reads/writes to files outside the
    workspace IF reached through a trusted symlink (e.g.,
    ``Projects/SwarmAI → ~/Desktop/SwarmAI-Workspace/swarmai``).  This is a
    deliberate security surface expansion required for the project-linking
    feature (``s_project-manager``).  The trust boundary is:

    1. The symlink itself must live inside the workspace (not injected from outside).
    2. The final resolved target must be a descendant of the symlink's resolved
       target — i.e., you can't use the symlink to escape *above* the linked
       directory via ``..`` segments that survive after resolution.
    3. Only the first symlink hop is trusted; nested symlinks inside the target
       are not given additional escape privileges.
    """
    parts = Path(relative_path).parts
    ws_resolved = workspace_root.resolve()
    for i in range(1, len(parts)):
        ancestor = workspace_root / Path(*parts[:i])
        if ancestor.is_symlink():
            # The symlink itself must be inside the workspace
            symlink_parent = ancestor.parent.resolve()
            if not _is_path_under(symlink_parent, ws_resolved):
                return False
            # The final target must be under the symlink's resolved root
            symlink_target = ancestor.resolve()
            full_target = (workspace_root / relative_path).resolve()
            if not _is_path_under(full_target, symlink_target):
                return False
            return True
    return False


@router.get("/workspace/file")
async def get_workspace_file(
    path: str = Query(..., description="Absolute or workspace-relative file path"),
):
    """Read a file's content by path.

    Accepts both absolute paths (``/Users/.../foo.py``) and workspace-relative
    paths (``Knowledge/Notes/foo.md``).  Absolute paths allow the file editor
    to open any file on the user's machine — essential for opening source
    files referenced in chat messages.

    Returns ``{ "content": "...", "encoding": "utf-8" }`` for text files.
    Returns ``{ "content": "<base64>", "encoding": "base64", ... }`` for binary files.
    Returns 404 if the file does not exist.
    Returns 400 if a relative path attempts directory traversal.
    Returns 413 if the file exceeds 50 MB.
    """
    expanded_path = await _get_workspace_path()
    workspace_root = Path(expanded_path)
    target, is_external = _resolve_file_path(path, workspace_root)

    if not target.is_file():
        raise HTTPException(status_code=404, detail=f"File not found: {path}")

    # Check file size BEFORE reading (prevents loading huge files into memory)
    file_size = target.stat().st_size
    if file_size > MAX_PREVIEW_SIZE:
        raise HTTPException(
            status_code=413,
            detail=f"File too large to preview ({file_size // (1024 * 1024)} MB). Maximum is 50 MB.",
        )

    try:
        content = target.read_text(encoding="utf-8")
    except UnicodeDecodeError:
        # Binary fallback: base64 encode
        logger.info("Binary file fallback for %s (size=%d, not valid UTF-8)", path, file_size)
        raw = target.read_bytes()
        mime_type, _ = mimetypes.guess_type(target.name)
        if mime_type is None:
            mime_type = "application/octet-stream"
        return {
            "content": base64.b64encode(raw).decode("ascii"),
            "path": path,
            "name": target.name,
            "encoding": "base64",
            "mime_type": mime_type,
            "size": file_size,
        }
    except OSError as exc:
        raise HTTPException(status_code=500, detail=f"Failed to read file: {exc}")

    # Projected skill files and context files are always readonly (workspace-internal only)
    is_readonly = False
    if not is_external:
        path_parts = Path(path).parts
        is_skill_file = len(path_parts) >= 2 and path_parts[0] == ".claude" and path_parts[1] == "skills"
        is_readonly = _is_readonly_context_file(path) or is_skill_file

    return {
        "content": content,
        "path": path,
        "name": target.name,
        "readonly": is_readonly,
        "encoding": "utf-8",
    }


@router.get("/workspace/file/resolve")
async def resolve_workspace_file(
    path: str = Query(..., description="Partial or relative file path to resolve", max_length=1024),
):
    """Resolve a partial file path to a workspace-relative path.

    Used by clickable file links in chat messages. The agent often outputs
    paths relative to the source codebase (e.g., ``backend/routers/foo.py``)
    rather than the workspace root. This endpoint searches:

    1. Direct: ``{wsRoot}/{path}`` (already workspace-relative)
    2. Under each project symlink: ``Projects/*/{path}``

    Returns ``{ "resolved_path": "Projects/SwarmAI/backend/routers/foo.py" }``
    on success, or 404 if not found anywhere.
    """
    expanded_path = await _get_workspace_path()
    workspace_root = Path(expanded_path)

    # --- Stage 0: Absolute path handling ---
    # The agent often outputs absolute paths like /Users/.../swarmai/backend/foo.py.
    # First try to convert to workspace-relative via project symlinks.
    # If no match but the file exists, return the absolute path as-is so
    # the file editor can open any file on the host.
    normalized = os.path.normpath(path)
    if os.path.isabs(normalized):
        abs_path = Path(normalized).resolve()

        # Try to convert to a workspace-relative path via project symlinks
        projects_dir = workspace_root / "Projects"
        if projects_dir.is_dir():
            for project in sorted(projects_dir.iterdir()):
                if not project.is_dir():
                    continue
                try:
                    symlink_target = project.resolve()
                    rel = abs_path.relative_to(symlink_target)
                    # Convert to workspace-relative and restart resolution
                    path = f"Projects/{project.name}/{rel}"
                    normalized = os.path.normpath(path)
                    break
                except ValueError:
                    continue
            else:
                # No project match — return absolute path as-is if file exists
                if abs_path.is_file():
                    return {"resolved_path": str(abs_path)}
                raise HTTPException(status_code=404, detail=f"File not found: {path}")
        else:
            # No Projects/ dir — return absolute path as-is if file exists
            if abs_path.is_file():
                return {"resolved_path": str(abs_path)}
            raise HTTPException(status_code=404, detail=f"File not found: {path}")

    # Reject ".." traversal after normalization (relative paths only)
    if normalized.startswith(".."):
        raise HTTPException(status_code=400, detail=f"Path traversal not allowed: {path}")

    # --- Stage 1: Direct lookup (already workspace-relative) ---
    direct = (workspace_root / path).resolve()
    if direct.is_file() and (_is_path_under(direct, workspace_root) or _is_symlink_traversal(workspace_root, path)):
        return {"resolved_path": path}

    # --- Stage 2: Try under each project in Projects/{name}/{path} ---
    projects_dir = workspace_root / "Projects"
    if projects_dir.is_dir():
        for project in sorted(projects_dir.iterdir()):
            if not project.is_dir():
                continue
            candidate_rel = f"Projects/{project.name}/{path}"
            candidate = (workspace_root / candidate_rel).resolve()
            if candidate.is_file():
                if _is_path_under(candidate, workspace_root) or _is_symlink_traversal(workspace_root, candidate_rel):
                    return {"resolved_path": candidate_rel}

    # --- Stage 3: Bare filename → recursive search in Projects/ ---
    # For paths like "fileClassification.ts" with no directory separators,
    # search recursively within project directories (depth-limited for perf).
    if "/" not in path and "\\" not in path and projects_dir.is_dir():
        _MAX_DEPTH = 8
        for project in sorted(projects_dir.iterdir()):
            if not project.is_dir():
                continue
            project_resolved = project.resolve()
            for match in project_resolved.rglob(path):
                if not match.is_file():
                    continue
                # Depth check: count path components from project root
                try:
                    rel_to_project = match.relative_to(project_resolved)
                except ValueError:
                    continue
                if len(rel_to_project.parts) > _MAX_DEPTH:
                    continue
                candidate_rel = f"Projects/{project.name}/{rel_to_project}"
                return {"resolved_path": candidate_rel}

    raise HTTPException(status_code=404, detail=f"Could not resolve file: {path}")


@router.get("/workspace/file/meta")
async def get_workspace_file_meta(
    path: str = Query(..., description="Absolute or workspace-relative file path"),
):
    """Return lightweight file metadata (size, mime type) without reading content.

    Used by the BinaryPreviewModal unsupported mode to display file size
    and type information without fetching potentially large binary files.
    """
    expanded_path = await _get_workspace_path()
    workspace_root = Path(expanded_path)
    target, _is_external = _resolve_file_path(path, workspace_root)

    if not target.is_file():
        raise HTTPException(status_code=404, detail=f"File not found: {path}")

    try:
        stat = target.stat()
    except OSError as exc:
        raise HTTPException(status_code=500, detail=f"Cannot stat file: {exc}")

    mime_type, _ = mimetypes.guess_type(target.name)
    if mime_type is None:
        mime_type = "application/octet-stream"

    return {
        "name": target.name,
        "path": path,
        "size": stat.st_size,
        "mime_type": mime_type,
    }


@router.get("/workspace/file/raw")
async def get_workspace_file_raw(
    path: str = Query(..., description="Absolute or workspace-relative file path"),
):
    """Serve a file as raw binary with proper Content-Type.

    Used by the markdown preview to render local images directly via
    ``<img src="http://localhost:{port}/api/workspace/file/raw?path=...">``.
    Accepts both absolute and workspace-relative paths.
    """
    expanded_path = await _get_workspace_path()
    workspace_root = Path(expanded_path)
    target, _is_ext = _resolve_file_path(path, workspace_root)

    if not target.is_file():
        raise HTTPException(status_code=404, detail=f"File not found: {path}")

    # Limit to 50 MB
    file_size = target.stat().st_size
    if file_size > MAX_PREVIEW_SIZE:
        raise HTTPException(status_code=413, detail="File too large")

    mime_type, _ = mimetypes.guess_type(target.name)
    return FileResponse(target, media_type=mime_type or "application/octet-stream")


@router.get("/workspace/file/diff")
async def get_workspace_file_diff(
    path: str = Query(..., description="Absolute or workspace-relative file path"),
):
    """Return a structured diff summary of uncommitted changes for a file.

    Used by the file editor panel's auto-diff feature (L2) to inject an
    edit summary into the chat input after saving. Finds the containing git
    repo automatically — works for both workspace files and external source files.

    Returns ``{"path": ..., "hunks": [...], "summary": "...", "raw_diff": "..."}``.
    """
    expanded_path = await _get_workspace_path()
    workspace_root = Path(expanded_path)
    target, _is_ext = _resolve_file_path(path, workspace_root)

    if not target.is_file():
        raise HTTPException(status_code=404, detail=f"File not found: {path}")

    # Find the git repo containing this file (workspace or external)
    git_root = _find_git_root(target)
    if git_root is None:
        return {"path": path, "hunks": [], "summary": "", "raw_diff": ""}

    # Compute path relative to the git root for the diff command
    try:
        git_relative = str(target.resolve().relative_to(git_root))
    except ValueError:
        return {"path": path, "hunks": [], "summary": "", "raw_diff": ""}

    try:
        result = subprocess.run(
            ["git", "diff", "--unified=3", "--", git_relative],
            cwd=str(git_root),
            capture_output=True,
            text=True,
            timeout=5,
        )
    except (OSError, subprocess.TimeoutExpired):
        return {"path": path, "hunks": [], "summary": "", "raw_diff": ""}

    raw_diff = result.stdout or ""

    hunks = parse_unified_diff(raw_diff)

    # Read current file content for section-aware summary
    try:
        file_content = target.read_text(encoding="utf-8")
    except (OSError, UnicodeDecodeError):
        file_content = ""

    summary = format_human_summary(hunks, file_content)

    # Serialize hunks to dicts for JSON response
    hunk_dicts = [
        {
            "old_start": h.old_start,
            "old_count": h.old_count,
            "new_start": h.new_start,
            "new_count": h.new_count,
            "added_lines": h.added_lines,
            "removed_lines": h.removed_lines,
        }
        for h in hunks
    ]

    return {"path": path, "hunks": hunk_dicts, "summary": summary, "raw_diff": raw_diff}


@router.get("/workspace/file/committed")
async def get_workspace_file_committed(
    path: str = Query(..., description="Absolute or workspace-relative file path"),
):
    """Return the last committed version of a file via ``git show HEAD:<path>``.

    Finds the containing git repo automatically — works for both workspace
    files and external source files.

    Returns ``{"content": "<committed text>"}`` for tracked files.
    Returns ``{"content": ""}`` for untracked files (no committed version).
    Returns 400 for binary files or relative path traversal attempts.
    Returns 404 if the file doesn't exist on disk.
    """
    expanded_path = await _get_workspace_path()
    workspace_root = Path(expanded_path)
    target, _is_ext = _resolve_file_path(path, workspace_root)

    if not target.is_file():
        raise HTTPException(status_code=404, detail=f"File not found: {path}")

    # Find the git repo containing this file
    git_root = _find_git_root(target)
    if git_root is None:
        return {"content": ""}

    # Compute path relative to the git root
    try:
        git_relative = str(target.resolve().relative_to(git_root))
    except ValueError:
        return {"content": ""}

    try:
        result = subprocess.run(
            ["git", "show", f"HEAD:{git_relative}"],
            cwd=str(git_root),
            capture_output=True,
            timeout=10,
        )
    except (OSError, subprocess.TimeoutExpired):
        return {"content": ""}

    if result.returncode != 0:
        # File is untracked or not in HEAD — return empty string
        return {"content": ""}

    # Decode manually to catch binary files
    try:
        content = result.stdout.decode("utf-8")
    except UnicodeDecodeError:
        raise HTTPException(status_code=400, detail="File is not valid UTF-8 text (binary file)")

    return {"content": content}


@router.put("/workspace/file")
async def put_workspace_file(
    path: str = Query(..., description="Absolute or workspace-relative file path"),
    body: dict = None,
):
    """Write text content to a file by path.

    Accepts both absolute paths and workspace-relative paths so the file
    editor can save any file the user has open.
    Expects ``{ "content": "<utf-8 text>" }`` in the request body.
    """
    if body is None or "content" not in body:
        raise HTTPException(status_code=400, detail="Request body must include 'content'")

    expanded_path = await _get_workspace_path()
    workspace_root = Path(expanded_path)
    target, is_external = _resolve_file_path(path, workspace_root)

    # Readonly guards only apply to workspace-internal paths
    if not is_external:
        if path.startswith(".claude/skills/") or path.startswith(".claude\\skills\\"):
            raise HTTPException(status_code=403, detail="Skill files are read-only")
        if _is_readonly_context_file(path):
            raise HTTPException(status_code=403, detail="System-default context files are read-only")

    try:
        target.parent.mkdir(parents=True, exist_ok=True)
        target.write_text(body["content"], encoding="utf-8")
    except OSError as exc:
        raise HTTPException(status_code=500, detail=f"Failed to write file: {exc}")

    # Only invalidate tree cache for workspace-internal writes
    if not is_external:
        _invalidate_tree_cache()
    return {"success": True, "path": path}


# ─────────────────────────────────────────────────────────────────────────────
# Project endpoints — REMOVED
# Legacy project CRUD endpoints have been extracted to the dedicated
# ``routers/projects.py`` router (registered separately in main.py).
# ─────────────────────────────────────────────────────────────────────────────


# ─────────────────────────────────────────────────────────────────────────────
# Folder / file operations
# ─────────────────────────────────────────────────────────────────────────────


@router.post("/workspace/file")
async def create_file(request: FolderCreateRequest):
    """Create an empty file inside the workspace.

    Creates parent directories as needed.  Returns HTTP 409 if the file
    already exists to prevent accidental overwrites.
    Returns HTTP 403 if the target is inside a system-managed directory.
    """
    expanded_path = await _get_workspace_path()
    target = _validate_relative_path(request.path, expanded_path)

    # Note: We intentionally allow file creation inside system-managed folders
    # (Knowledge/, Projects/, etc.).  SYSTEM_MANAGED_FOLDERS protects the
    # folder *structure* from being deleted/renamed, not from having files
    # added to it — that's the whole point of these directories.
    rel_path = request.path.replace("\\", "/").strip("/")

    if target.exists():
        raise HTTPException(status_code=409, detail="File already exists")

    # Reject creation of system-default context files (readonly, overwritten on startup)
    if _is_readonly_context_file(rel_path):
        raise HTTPException(status_code=403, detail="System-default context files are read-only")

    # Validate depth
    is_valid, error_msg = swarm_workspace_manager.validate_depth(request.path)
    if not is_valid:
        raise HTTPException(status_code=400, detail=error_msg)

    target.parent.mkdir(parents=True, exist_ok=True)
    target.touch()

    _invalidate_tree_cache()
    logger.info("Created file: %s", request.path)
    return {"path": request.path}


@router.post("/workspace/folders")
async def create_folder(request: FolderCreateRequest):
    """Create a folder inside the workspace.

    Increments project_files_version for context cache invalidation
    when the path is under a project directory (Requirement 34.2).
    """
    expanded_path = await _get_workspace_path()
    target = _validate_relative_path(request.path, expanded_path)

    # Validate depth
    is_valid, error_msg = swarm_workspace_manager.validate_depth(request.path)
    if not is_valid:
        raise HTTPException(status_code=400, detail=error_msg)

    target.mkdir(parents=True, exist_ok=True)

    # Increment project_files_version for context cache invalidation (Req 34.2)

    _invalidate_tree_cache()
    logger.info("Created folder: %s", request.path)
    return {"path": request.path}




@router.delete("/workspace/folders", status_code=204)
async def delete_folder(request: FolderDeleteRequest):
    """Delete a folder or file inside the workspace.

    Returns HTTP 403 if the target is a system-managed directory
    (Requirement 12.9).
    """
    expanded_path = await _get_workspace_path()
    target = _validate_relative_path(request.path, expanded_path)

    # Reject delete on system-managed folders (Req 12.9)
    rel_path = request.path.replace("\\", "/").strip("/")
    if rel_path in SYSTEM_MANAGED_FOLDERS:
        raise HTTPException(
            status_code=403,
            detail=f"Cannot delete/rename system-managed directory: {rel_path}",
        )

    if not target.exists():
        raise HTTPException(status_code=404, detail="Path not found")

    if target.is_dir():
        shutil.rmtree(target)
    else:
        target.unlink()

    _invalidate_tree_cache()
    logger.info("Deleted: %s", request.path)
    return Response(status_code=204)




@router.post("/workspace/trash")
async def trash_item(request: FolderDeleteRequest):
    """Move a file or folder to the macOS Trash (recoverable via Finder).

    Never falls back to permanent delete — if trashing fails, the error is
    surfaced to the user so they can decide what to do.

    **Symlink handling:** If the target path is a symlink, only the link
    itself is removed (``os.unlink``).  The real target directory is
    preserved.  This prevents accidental data loss when trashing linked
    project folders (e.g., ``Projects/SwarmAI → ~/real/repo``).

    Returns HTTP 403 if the target is a system-managed directory.
    Returns HTTP 500 if trashing fails (filesystem error, permissions, etc.).
    """
    expanded_path = await _get_workspace_path()
    workspace_root = Path(expanded_path)

    # Build the unresolved path BEFORE _validate_relative_path (which resolves
    # symlinks).  We need the unresolved path to detect symlinks and to pass
    # the correct filesystem entry to shutil.move / unlink.
    stripped = request.path.replace("\\", "/").strip("/")
    unresolved_path = workspace_root / stripped

    target = _validate_relative_path(request.path, expanded_path)

    # Reject trash on system-managed folders
    rel_path = request.path.replace("\\", "/").strip("/")
    if rel_path in SYSTEM_MANAGED_FOLDERS:
        raise HTTPException(
            status_code=403,
            detail=f"Cannot delete system-managed directory: {rel_path}",
        )

    if not target.exists() and not unresolved_path.is_symlink():
        raise HTTPException(status_code=404, detail="Path not found")

    # Symlink guard: if the path is a symlink, remove the link itself — never
    # trash the real target directory.  This prevents accidental data loss when
    # trashing linked project folders (e.g., Projects/SwarmAI → ~/real/repo).
    # os.unlink removes the symlink without touching the target.
    if unresolved_path.is_symlink():
        try:
            await asyncio.to_thread(os.unlink, str(unresolved_path))
        except OSError as exc:
            raise HTTPException(
                status_code=500,
                detail=f"Failed to remove symlink: {exc}",
            )
        logger.info("Removed symlink (target preserved): %s", request.path)
        return {"path": request.path, "trashed": True, "was_symlink": True}

    # Move to macOS Trash via direct filesystem operation (recoverable).
    # Uses shutil.move to ~/.Trash/ — no osascript, no Apple Events, no TCC
    # popup. Uses a UUID suffix to guarantee uniqueness (avoids TOCTOU race
    # between exists() check and shutil.move in the old counter approach).
    trash_dir = Path.home() / ".Trash"
    trash_dir.mkdir(exist_ok=True)  # May not exist on network home dirs
    dest = trash_dir / target.name

    if dest.exists():
        # UUID suffix guarantees no collision without a TOCTOU window
        import uuid
        unique_id = uuid.uuid4().hex[:8]
        stem = target.stem
        suffix = target.suffix
        dest = trash_dir / f"{stem} {unique_id}{suffix}"

    try:
        await asyncio.to_thread(shutil.move, str(target), str(dest))
    except OSError as exc:
        logger.error("Trash failed for %s: %s", request.path, exc)
        raise HTTPException(
            status_code=500,
            detail=f"Failed to trash: {exc}",
        )

    _invalidate_tree_cache()
    logger.info("Trashed (recoverable): %s", request.path)
    return {"path": request.path, "trashed": True}


@router.put("/workspace/rename")
async def rename_item(request: FolderRenameRequest):
    """Rename or move an item inside the workspace.

    Increments project_files_version for context cache invalidation
    when project files are renamed or moved (Requirement 34.2).

    Returns HTTP 403 if the source or destination is a system-managed
    directory (Requirement 12.9).
    """
    expanded_path = await _get_workspace_path()

    # Reject rename on system-managed folders (Req 12.9)
    normalized_old = request.old_path.replace("\\", "/").strip("/")
    if normalized_old in SYSTEM_MANAGED_FOLDERS:
        raise HTTPException(
            status_code=403,
            detail=f"Cannot delete/rename system-managed directory: {normalized_old}",
        )

    # Note: We intentionally do NOT block moves INTO system-managed folders.
    # SYSTEM_MANAGED_FOLDERS protects the folders themselves from being
    # renamed/deleted (checked above for old_path).  Users should be able
    # to move files into Knowledge/, Knowledge/Designs/, Projects/, etc.
    normalized_new = request.new_path.replace("\\", "/").strip("/")

    old_target = _validate_relative_path(request.old_path, expanded_path)
    new_target = _validate_relative_path(request.new_path, expanded_path)

    if not old_target.exists():
        raise HTTPException(status_code=404, detail="Source path not found")

    if new_target.exists():
        raise HTTPException(status_code=409, detail=f"Destination already exists: {request.new_path}")

    # If the destination is a directory path, validate depth
    if new_target.suffix == "" or old_target.is_dir():
        is_valid, error_msg = swarm_workspace_manager.validate_depth(request.new_path)
        if not is_valid:
            raise HTTPException(status_code=400, detail=error_msg)

    new_target.parent.mkdir(parents=True, exist_ok=True)
    old_target.rename(new_target)

    # Increment project_files_version for context cache invalidation (Req 34.2)

    _invalidate_tree_cache()
    logger.info("Renamed '%s' → '%s'", request.old_path, request.new_path)
    return {"old_path": request.old_path, "new_path": request.new_path}

