"""Shared filesystem actions for HTTP and WS relays.

All actions take (root_dir, abs_path, req) and return a dict result.
The relay is responsible for path resolution and access control.
"""
import logging

import base64
import json
import os
import re
import subprocess  # nosec B404
import sys as _sys
from collections import deque
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List

# Ensure tools/ is on sys.path so bare imports work from project root (tests)
_tools_dir = os.path.dirname(os.path.abspath(__file__))
if _tools_dir not in _sys.path:
    _sys.path.insert(0, _tools_dir)

# Size limits (prevent memory explosion)
MAX_FILE_SIZE = 50 * 1024 * 1024    # 50 MB for read/write

# Shared utilities (extracted to break circular import with fs_exec)
from fs_common import (
    MAX_EXEC_OUTPUT, _docker_cmd, _translate_path, _to_host_path,
    detect_available_shells, _resolve_shell,
)

# Actions that require write access
WRITE_ACTIONS = frozenset({
    "write_file", "delete_file", "mkdir", "find_replace", "edit",
    "batch_edit", "apply_patch",
    "exec", "exec_stream",
    "edit_notebook",
    "project_init",
    "screen_click", "screen_double_click", "screen_type",
    "screen_key", "screen_move", "screen_scroll",
})

# All available actions — populated at bottom of file after imports
# (referenced here for documentation; actual dict defined below)


def _is_windows_drive_absolute_path(path: str) -> bool:
    raw = str(path or "").replace("\\", "/")
    return len(raw) >= 3 and raw[1] == ":" and raw[2] == "/"


def _is_host_absolute_path(path: str) -> bool:
    raw = str(path or "").replace("\\", "/")
    return raw.startswith("/") or raw.startswith("//") or _is_windows_drive_absolute_path(raw)


def _resolve_tool_path(root_dir: str, raw_path: str, *, allow_host_absolute: bool = False) -> Path:
    root = Path(root_dir).resolve()
    raw = str(raw_path or ".")
    if raw.startswith("/workspace/"):
        raw = raw[len("/workspace/"):]
    elif raw == "/workspace":
        return root
    if _is_host_absolute_path(raw):
        if allow_host_absolute:
            return Path(raw).resolve()
        raise ValueError(f"Path escapes workspace: {raw_path}")
    target = (root / raw).resolve()
    try:
        target.relative_to(root)
    except ValueError as exc:
        raise ValueError(f"Path escapes workspace: {raw_path}") from exc
    return target


def _rel(abs_path: str, root: str) -> str:
    """Convert absolute path back to relative for responses."""
    try:
        return str(Path(abs_path).relative_to(Path(root).resolve())).replace("\\", "/")
    except ValueError:
        return abs_path


def _expand_glob_braces(pattern: str, max_patterns: int = 256) -> List[str]:
    """Expand shell-style glob braces without invoking a shell.

    pathlib glob/rglob supports `**` and character classes but not `{a,b}`.
    PawFlow tools accept patterns like `{core,services}/**/*.py`, so expand
    braces before passing each concrete pattern to pathlib.
    """
    def _split_options(body: str) -> List[str]:
        parts = []
        start = 0
        depth = 0
        for idx, ch in enumerate(body):
            if ch == "{":
                depth += 1
            elif ch == "}" and depth > 0:
                depth -= 1
            elif ch == "," and depth == 0:
                parts.append(body[start:idx])
                start = idx + 1
        parts.append(body[start:])
        return parts

    def _expand_one(value: str) -> List[str]:
        start = value.find("{")
        if start < 0:
            return [value]
        depth = 0
        end = -1
        for idx in range(start, len(value)):
            ch = value[idx]
            if ch == "{":
                depth += 1
            elif ch == "}":
                depth -= 1
                if depth == 0:
                    end = idx
                    break
        if end < 0:
            return [value]
        prefix = value[:start]
        suffix = value[end + 1:]
        expanded = []
        for option in _split_options(value[start + 1:end]):
            for tail in _expand_one(suffix):
                expanded.append(prefix + option + tail)
                if len(expanded) >= max_patterns:
                    return expanded
        return expanded

    return _expand_one(pattern or "*")[:max_patterns]


def action_list_dir(root_dir: str, path: str, req: Dict[str, Any]) -> Any:
    p = Path(path)
    recursive = bool(req.get("recursive", False))
    try:
        max_entries = int(req.get("max_entries") or 0)
    except (TypeError, ValueError):
        max_entries = 0

    entries = []
    iterator = p.rglob("*") if recursive else p.iterdir()
    for entry in sorted(iterator):
        st = entry.stat()
        name = entry.name
        if recursive:
            name = str(entry.relative_to(p)).replace("\\", "/")
        entries.append({
            "name": name,
            "kind": "directory" if entry.is_dir() else "file",
            "size": st.st_size if entry.is_file() else 0,
            "modified": datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).isoformat(),
        })
        if max_entries > 0 and len(entries) >= max_entries:
            break
    return entries


def action_project_context(root_dir: str, path: str, req: Dict[str, Any]) -> Any:
    """Auto-scan project root and build a context summary."""
    root = Path(root_dir)
    context = {"root": root_dir, "files": [], "config_files": {}, "tree": ""}

    # List top-level entries (max 100)
    try:
        entries = sorted(root.iterdir())[:100]
        context["files"] = [
            {"name": e.name, "kind": "dir" if e.is_dir() else "file",
             "size": e.stat().st_size if e.is_file() else 0}
            for e in entries
        ]
    except Exception:
        logging.getLogger(__name__).debug("Ignored exception", exc_info=True)

    # Read key config/context files
    _KEY_FILES = [
        ".pawflow.md", "CLAUDE.md", "README.md", "readme.md",
        "package.json", "pyproject.toml", "Cargo.toml", "go.mod",
        "Makefile", "Dockerfile", "docker-compose.yml",
        ".gitignore", "requirements.txt", "setup.py", "setup.cfg",
        "tsconfig.json", "pom.xml", "build.gradle",
    ]
    for fname in _KEY_FILES:
        fpath = root / fname
        if fpath.is_file():
            try:
                text = fpath.read_text(encoding="utf-8", errors="replace")
                # Cap at 5KB per file for the summary
                context["config_files"][fname] = text[:5000]
            except Exception:
                logging.getLogger(__name__).debug("Ignored exception", exc_info=True)

    # Build a tree (2 levels deep, max 200 entries)
    tree_lines = []
    count = 0
    for entry in sorted(root.iterdir()):
        if entry.name.startswith(".") and entry.name not in (".gitignore", ".pawflow.md"):
            continue
        if count >= 200:
            tree_lines.append("... (more files)")
            break
        prefix = "📁 " if entry.is_dir() else "📄 "
        tree_lines.append(f"{prefix}{entry.name}")
        count += 1
        if entry.is_dir():
            try:
                for sub in sorted(entry.iterdir())[:20]:
                    sub_prefix = "  📁 " if sub.is_dir() else "  📄 "
                    tree_lines.append(f"{sub_prefix}{sub.name}")
                    count += 1
                    if count >= 200:
                        break
                if len(list(entry.iterdir())) > 20:
                    tree_lines.append(f"  ... (+{len(list(entry.iterdir())) - 20} more)")
            except PermissionError:
                pass
    context["tree"] = "\n".join(tree_lines)

    # Detect project type
    types = []
    names = {e.name for e in root.iterdir() if e.is_file()}
    if "package.json" in names: types.append("Node.js")
    if "pyproject.toml" in names or "setup.py" in names or "requirements.txt" in names: types.append("Python")
    if "Cargo.toml" in names: types.append("Rust")
    if "go.mod" in names: types.append("Go")
    if "pom.xml" in names or "build.gradle" in names: types.append("Java")
    if "Makefile" in names: types.append("Make")
    if "Dockerfile" in names: types.append("Docker")
    context["project_types"] = types

    # Git info
    git_dir = root / ".git"
    if git_dir.is_dir():
        context["git"] = True
        try:
            import subprocess  # nosec B404
            br = subprocess.run(["git", "branch", "--show-current"],  # nosec B603, B607
                                cwd=root_dir, capture_output=True, text=True, timeout=10)
            context["git_branch"] = br.stdout.strip()
        except Exception:
            logging.getLogger(__name__).debug("Ignored exception", exc_info=True)

    return context


def action_read_file(root_dir: str, path: str, req: Dict[str, Any]) -> Any:
    max_size = req.get("max_size", MAX_FILE_SIZE)
    size = Path(path).stat().st_size
    if size > max_size:
        raise ValueError(f"File too large ({size} bytes, max {max_size}). Use read_file_chunked.")
    content = Path(path).read_bytes()
    return {"content": base64.b64encode(content).decode("ascii"), "size": len(content)}


def action_read_pdf(root_dir: str, path: str, req: Dict[str, Any]) -> Any:
    """Extract text from a PDF file. Requires pypdf or PyPDF2."""
    max_pages = req.get("max_pages", 50)
    p = Path(path)
    if not p.suffix.lower() == ".pdf":
        raise ValueError(f"Not a PDF file: {p.name}")

    # Try pypdf (modern), then PyPDF2 (legacy), then raw fallback
    text_pages = []
    try:
        try:
            from pypdf import PdfReader
        except ImportError:
            from PyPDF2 import PdfReader
        reader = PdfReader(str(p))
        total = len(reader.pages)
        for i, page in enumerate(reader.pages[:max_pages]):
            page_text = page.extract_text() or ""
            text_pages.append({"page": i + 1, "text": page_text})
        return {
            "pages": text_pages,
            "total_pages": total,
            "extracted_pages": min(total, max_pages),
        }
    except ImportError:
        # No PDF library — return base64 raw content
        content = p.read_bytes()
        return {
            "error": "No PDF library installed (pip install pypdf). Returning raw base64.",
            "content": base64.b64encode(content).decode("ascii"),
            "size": len(content),
        }


def action_read_notebook(root_dir: str, path: str, req: Dict[str, Any]) -> Any:
    """Extract content from a Jupyter notebook (.ipynb)."""
    p = Path(path)
    if not p.suffix.lower() == ".ipynb":
        raise ValueError(f"Not a notebook: {p.name}")
    raw = json.loads(p.read_text(encoding="utf-8"))
    cells = raw.get("cells", [])
    metadata = raw.get("metadata", {})
    kernel = metadata.get("kernelspec", {}).get("display_name", "")
    result_cells = []
    for i, cell in enumerate(cells):
        cell_type = cell.get("cell_type", "")
        source = "".join(cell.get("source", []))
        outputs_text = ""
        for out in cell.get("outputs", []):
            if "text" in out:
                outputs_text += "".join(out["text"])
            elif "data" in out:
                # Prefer text/plain, then text/html
                data = out["data"]
                if "text/plain" in data:
                    outputs_text += "".join(data["text/plain"])
                elif "text/html" in data:
                    outputs_text += "[HTML output]"
                elif "image/png" in data:
                    outputs_text += "[Image output]"
            if out.get("ename"):
                outputs_text += f"Error: {out['ename']}: {out.get('evalue', '')}"
        result_cells.append({
            "index": i,
            "type": cell_type,
            "source": source,
            "output": outputs_text,
        })
    return {
        "kernel": kernel,
        "total_cells": len(cells),
        "cells": result_cells,
    }


def action_edit_notebook(root_dir: str, path: str, req: Dict[str, Any]) -> Any:
    """Edit, insert, or delete a cell in a Jupyter notebook."""
    p = Path(path)
    if not p.suffix.lower() == ".ipynb":
        raise ValueError(f"Not a notebook: {p.name}")
    raw = json.loads(p.read_text(encoding="utf-8"))
    cells = raw.get("cells", [])
    operation = req.get("operation", "edit")
    cell_index = req.get("cell_index")
    if cell_index is None:
        raise ValueError("Missing 'cell_index' parameter")

    if operation == "delete":
        if cell_index < 0 or cell_index >= len(cells):
            raise ValueError(f"cell_index {cell_index} out of range (0-{len(cells)-1})")
        deleted = cells.pop(cell_index)
        raw["cells"] = cells
        p.write_text(json.dumps(raw, indent=1, ensure_ascii=False), encoding="utf-8")
        return {"operation": "delete", "cell_index": cell_index, "deleted_type": deleted.get("cell_type", "")}

    new_source = req.get("new_source", "")
    cell_type = req.get("cell_type", "code")

    if operation == "insert":
        if cell_index < 0 or cell_index > len(cells):
            raise ValueError(f"cell_index {cell_index} out of range for insert (0-{len(cells)})")
        new_cell = {
            "cell_type": cell_type,
            "metadata": {},
            "source": new_source.splitlines(True),
            "outputs": [] if cell_type == "code" else [],
        }
        if cell_type == "code":
            new_cell["execution_count"] = None
        cells.insert(cell_index, new_cell)
    elif operation == "edit":
        if cell_index < 0 or cell_index >= len(cells):
            raise ValueError(f"cell_index {cell_index} out of range (0-{len(cells)-1})")
        cells[cell_index]["source"] = new_source.splitlines(True)
        if cell_type:
            cells[cell_index]["cell_type"] = cell_type
    else:
        raise ValueError(f"Unknown operation: {operation}. Use 'edit', 'insert', or 'delete'.")

    raw["cells"] = cells
    p.write_text(json.dumps(raw, indent=1, ensure_ascii=False), encoding="utf-8")
    return {"operation": operation, "cell_index": cell_index, "total_cells": len(cells)}


def action_write_file(root_dir: str, path: str, req: Dict[str, Any]) -> Any:
    content = req.get("content", "")
    raw = base64.b64decode(content) if req.get("base64") else content.encode("utf-8")
    if len(raw) > MAX_FILE_SIZE:
        raise ValueError(f"Content too large ({len(raw)} bytes, max {MAX_FILE_SIZE}). Use write_file_chunked.")
    p = Path(path)
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_bytes(raw)
    return {"written": len(raw), "path": _rel(path, root_dir)}


def action_delete_file(root_dir: str, path: str, req: Dict[str, Any]) -> Any:
    p = Path(path)
    if p.is_dir():
        import shutil
        shutil.rmtree(p)
    else:
        p.unlink()
    return {"deleted": _rel(path, root_dir)}


def action_mkdir(root_dir: str, path: str, req: Dict[str, Any]) -> Any:
    Path(path).mkdir(parents=True, exist_ok=True)
    return {"created": _rel(path, root_dir)}


def action_stat(root_dir: str, path: str, req: Dict[str, Any]) -> Any:
    p = Path(path)
    st = p.stat()
    return {
        "name": p.name,
        "kind": "directory" if p.is_dir() else "file",
        "size": st.st_size,
        "modified": datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).isoformat(),
        "created": datetime.fromtimestamp(st.st_ctime, tz=timezone.utc).isoformat(),
    }


def action_exists(root_dir: str, path: str, req: Dict[str, Any]) -> Any:
    return {"exists": Path(path).exists()}


def action_search(root_dir: str, path: str, req: Dict[str, Any]) -> Any:
    pattern = req.get("pattern", "*")
    recursive = req.get("recursive", True)
    try:
        limit = int(req.get("limit", 500) or 500)
    except (TypeError, ValueError):
        limit = 500
    if limit <= 0:
        limit = 500
    limit = min(limit, 5000)
    p = Path(path)
    patterns = _expand_glob_braces(pattern)
    matches = []
    seen = set()
    if recursive:
        iterator = (m for pat in patterns for m in p.rglob(pat))
    else:
        iterator = (m for pat in patterns for m in p.glob(pat))
    for m in iterator:
        rel = str(m.relative_to(p)).replace("\\", "/")
        if rel in seen:
            continue
        seen.add(rel)
        matches.append(rel)
        if len(matches) >= limit:
            break
    return matches[:limit]


_GREP_SKIP_DIRS = {
    "__pycache__", ".git", ".hg", ".svn", "node_modules",
    ".venv", "venv", ".tox", ".mypy_cache", ".pytest_cache",
    ".ruff_cache", "dist", "build", ".next", ".cache",
}
_GREP_SKIP_EXT = {
    ".pyc", ".pyo", ".pyd", ".so", ".dylib", ".dll", ".exe",
    ".bin", ".o", ".a", ".class", ".jar", ".war",
    ".png", ".jpg", ".jpeg", ".gif", ".ico", ".webp", ".pdf",
    ".zip", ".tar", ".gz", ".bz2", ".7z", ".rar",
    ".mp3", ".mp4", ".avi", ".mov", ".wav", ".ogg",
    ".woff", ".woff2", ".ttf", ".eot",
    ".coverage", ".lock",
}


def _grep_is_binary(fpath: Path) -> bool:
    """Sniff the first 4KB for NUL bytes — ripgrep's binary detection rule."""
    try:
        with open(fpath, "rb") as f:
            chunk = f.read(4096)
        return b"\x00" in chunk
    except OSError:
        return True


def _grep_has_glob_magic(path: str) -> bool:
    return any(ch in path for ch in ("*", "?", "["))


def _grep_split_glob_path(path: str) -> tuple[str, str]:
    if not path or not _grep_has_glob_magic(path):
        return path, ""
    p = Path(path)
    if p.exists():
        return path, ""

    normalized = path.replace("\\", "/")
    parts = normalized.split("/")
    split_at = None
    for idx, part in enumerate(parts):
        if _grep_has_glob_magic(part):
            split_at = idx
            break
    if split_at is None:
        return path, ""

    base_parts = parts[:split_at]
    glob_parts = parts[split_at:]
    base = "/".join(base_parts)
    if not base:
        base = "/" if normalized.startswith("/") else "."
    return base, "/".join(glob_parts)


def _grep_candidate_paths(path: str) -> list[Path]:
    p = Path(path)
    if p.exists():
        return [p]
    parts = [Path(part) for part in str(path).split() if part]
    if len(parts) > 1 and all(part.exists() for part in parts):
        return parts
    return [p]


def _grep_effective_globs(glob_pattern: Any, recursive: bool) -> list[str]:
    """Normalize include/glob filters.

    Accept comma/space-separated strings and lists. Basename globs are
    recursive by default, matching the MCP grep contract.
    """
    if not glob_pattern:
        return []
    if isinstance(glob_pattern, (list, tuple, set)):
        raw_parts = [str(g).strip() for g in glob_pattern]
    else:
        raw_parts = _split_glob_parts(str(glob_pattern))
    globs = []
    for part in raw_parts:
        if not part:
            continue
        for expanded in _expand_glob_braces(part):
            normalized = expanded.replace("\\", "/")
            if recursive and "/" not in normalized and not normalized.startswith("**/"):
                normalized = f"**/{normalized}"
            globs.append(normalized)
    return globs


def _split_glob_parts(value: str) -> list[str]:
    parts = []
    start = 0
    depth = 0
    for idx, ch in enumerate(value):
        if ch == "{":
            depth += 1
        elif ch == "}" and depth > 0:
            depth -= 1
        elif depth == 0 and (ch == "," or ch.isspace()):
            part = value[start:idx].strip()
            if part:
                parts.append(part)
            start = idx + 1
    tail = value[start:].strip()
    if tail:
        parts.append(tail)
    return parts


def action_grep(root_dir: str, path: str, req: Dict[str, Any]) -> Any:
    regex = req.get("regex", "")
    recursive = req.get("recursive", True)
    glob_pattern = req.get("glob", "") or req.get("include", "")
    limit = int(req.get("limit") or req.get("head_limit") or 200)
    context_both = int(req.get("context", 0) or 0)
    context_before = int(req.get("context_before") or req.get("-B") or context_both or 0)
    context_after = int(req.get("context_after") or req.get("-A") or context_both or 0)
    if not regex:
        raise ValueError("Missing 'regex' parameter")
    if not glob_pattern:
        path, glob_pattern = _grep_split_glob_path(path)
    glob_patterns = _grep_effective_globs(glob_pattern, recursive)
    # Suppress Python 3.12+ FutureWarning for user-supplied regex quirks
    # (e.g. `[[..]]` patterns flagged as possible nested sets). The regex
    # still compiles correctly — we just don't want the warning to pollute
    # the relay log on every grep call.
    import warnings as _w
    with _w.catch_warnings():
        _w.simplefilter("ignore", FutureWarning)
        compiled = re.compile(regex, re.IGNORECASE)
    paths = _grep_candidate_paths(path)
    multiple_roots = len(paths) > 1
    results = []

    def _scan_file(fpath: Path, display_name: str) -> bool:
        if fpath.suffix.lower() in _GREP_SKIP_EXT:
            return False
        if _grep_is_binary(fpath):
            return False
        before = deque(maxlen=max(0, context_before))
        pending_after: List[Dict[str, Any]] = []
        try:
            handle = fpath.open("r", encoding="utf-8", errors="replace")
        except Exception:
            return False
        with handle:
            for i, raw_line in enumerate(handle, 1):
                line = raw_line.rstrip("\r\n")
                clipped = line[:500]
                if pending_after:
                    done = []
                    for item in pending_after:
                        item["after"].append({"line_number": i, "line": clipped})
                        item["_remaining_after"] -= 1
                        if item["_remaining_after"] <= 0:
                            done.append(item)
                    for item in done:
                        pending_after.remove(item)
                if len(results) < limit and compiled.search(line):
                    row = {
                        "path": display_name,
                        "line_number": i,
                        "line": clipped,
                    }
                    if context_before:
                        row["before"] = list(before)
                    if context_after:
                        row["after"] = []
                        row["_remaining_after"] = context_after
                        pending_after.append(row)
                    results.append(row)
                if context_before:
                    before.append({"line_number": i, "line": clipped})
                if len(results) >= limit and not pending_after:
                    break
        for item in results:
            item.pop("_remaining_after", None)
        return len(results) >= limit

    scan_patterns = glob_patterns or ["**/*" if recursive else "*"]
    seen = set()
    for p in paths:
        if p.is_file():
            display = str(p).replace("\\", "/") if multiple_roots else p.name
            if _scan_file(p, display):
                break
            continue
        for scan_pattern in scan_patterns:
            for fpath in p.glob(scan_pattern):
                if fpath in seen:
                    continue
                seen.add(fpath)
                if not fpath.is_file():
                    continue
                # Skip any path whose parents include an ignored dir.
                if any(part in _GREP_SKIP_DIRS for part in fpath.parts):
                    continue
                rel = str(fpath.relative_to(p)).replace("\\", "/")
                root_label = str(p).replace("\\", "/")
                display = f"{root_label}/{rel}" if multiple_roots else rel
                if _scan_file(fpath, display):
                    break
            if len(results) >= limit:
                break
        if len(results) >= limit:
            break
    return results


def action_find_replace(root_dir: str, path: str, req: Dict[str, Any]) -> Any:
    pattern = req.get("pattern", "")
    replacement = req.get("replacement", "")
    flags = re.MULTILINE if bool(req.get("multiline", False)) else 0
    if not pattern:
        raise ValueError("Missing 'pattern' parameter")
    compiled = re.compile(pattern, flags)
    p = Path(path)
    text = p.read_text(encoding="utf-8", errors="replace")
    new_text, count = compiled.subn(replacement, text)
    if count > 0:
        p.write_text(new_text, encoding="utf-8")
    return {"replacements": count, "path": _rel(path, root_dir)}


def _line_key(line: str) -> str:
    """Normalize one line for safe whitespace-only edit matching."""
    return line.expandtabs(4).rstrip()


def _window_start(lines: List[str], index: int) -> int:
    return sum(len(line) for line in lines[:index])


def _find_window_matches(text: str, old_string: str, *, fuzzy: bool = False,
                         fuzzy_threshold: float = 0.92) -> List[Dict[str, Any]]:
    """Find exact, whitespace-safe, or explicit fuzzy candidates.

    Exact string matching remains authoritative. Whitespace-safe matching is
    limited to line-ending, trailing-whitespace, and tab/space indentation
    drift, where applying the actual file substring is deterministic. General
    fuzzy matching only runs when requested by the caller.
    """
    matches: List[Dict[str, Any]] = []
    start = 0
    while True:
        pos = text.find(old_string, start)
        if pos < 0:
            break
        matches.append({
            "kind": "exact",
            "start": pos,
            "end": pos + len(old_string),
            "actual": old_string,
            "score": 1.0,
        })
        start = pos + max(1, len(old_string))
    if matches:
        return matches

    old_lines = old_string.splitlines(True)
    text_lines = text.splitlines(True)
    if not old_lines or not text_lines:
        return []
    old_key = [_line_key(line.rstrip("\r\n")) for line in old_lines]
    span = len(old_lines)

    for i in range(0, len(text_lines) - span + 1):
        actual = "".join(text_lines[i:i + span])
        key = [_line_key(line.rstrip("\r\n")) for line in text_lines[i:i + span]]
        if key == old_key:
            start_pos = _window_start(text_lines, i)
            matches.append({
                "kind": "whitespace",
                "start": start_pos,
                "end": start_pos + len(actual),
                "actual": actual,
                "score": 1.0,
            })
    if matches or not fuzzy:
        return matches

    best: List[Dict[str, Any]] = []
    import difflib
    for span_len in {span, max(1, span - 1), span + 1}:
        if span_len > len(text_lines):
            continue
        for i in range(0, len(text_lines) - span_len + 1):
            actual = "".join(text_lines[i:i + span_len])
            score = difflib.SequenceMatcher(None, old_string, actual).ratio()
            if score >= fuzzy_threshold:
                start_pos = _window_start(text_lines, i)
                best.append({
                    "kind": "fuzzy",
                    "start": start_pos,
                    "end": start_pos + len(actual),
                    "actual": actual,
                    "score": score,
                })
    if not best:
        return []
    best.sort(key=lambda item: item["score"], reverse=True)
    top_score = best[0]["score"]
    return [item for item in best if top_score - item["score"] < 0.02]


def _apply_replacements(text: str, matches: List[Dict[str, Any]],
                        new_string: str, replace_all: bool) -> str:
    selected = matches if replace_all else matches[:1]
    out = []
    pos = 0
    for match in sorted(selected, key=lambda item: item["start"]):
        out.append(text[pos:match["start"]])
        out.append(new_string)
        pos = match["end"]
    out.append(text[pos:])
    return "".join(out)


def _diagnose_edit_mismatch(old_string: str, text: str, filename: str) -> str:
    """Build an actionable error message explaining WHY old_string doesn't match.

    Emits several hints that cover the common causes agents hit repeatedly:
    CRLF vs LF, trailing whitespace, tab/space indentation, and a best-effort
    longest-prefix match pointing at the exact divergence position. The goal
    is to replace 5 useless retries with a single corrective read.
    """
    hints = []

    # CRLF vs LF mismatch
    if '\r\n' in text and '\r\n' not in old_string:
        if old_string.replace('\n', '\r\n') in text:
            hints.append(
                "File uses CRLF line endings; your old_string uses LF. "
                "Re-send old_string with \\r\\n between lines.")
    elif '\r\n' in old_string and '\r\n' not in text:
        if old_string.replace('\r\n', '\n') in text:
            hints.append(
                "File uses LF line endings; your old_string has CRLF. "
                "Strip the \\r from line endings in old_string.")

    # Trailing whitespace mismatch (either direction) — only emit if no
    # more specific hint already covers it. CRLF and tab/space mismatches
    # also make rstripped content match, but their hints are more actionable.
    _specific_hint = bool(hints)
    old_rstripped = '\n'.join(l.rstrip() for l in old_string.split('\n'))
    text_rstripped = '\n'.join(l.rstrip() for l in text.split('\n'))
    if not _specific_hint and old_rstripped in text_rstripped:
        hints.append(
            "Content matches after rstripping each line — trailing whitespace "
            "differs between your old_string and the file. Re-read the target "
            "lines and copy them verbatim (cat -A or repr() to see exact bytes).")

    # Tabs vs spaces
    if '\t' in text and '\t' not in old_string:
        # Guess the indent width that turns spaces into tabs
        for _w in (4, 2, 8):
            _swapped = old_string.replace(' ' * _w, '\t')
            if _swapped in text:
                hints.append(
                    f"File uses tabs for indentation; your old_string uses "
                    f"{_w}-space indent. Convert runs of {_w} spaces to tabs.")
                break
    elif '\t' in old_string and '\t' not in text:
        for _w in (4, 2, 8):
            _swapped = old_string.replace('\t', ' ' * _w)
            if _swapped in text:
                hints.append(
                    f"File uses spaces for indentation ({_w}-wide); your "
                    f"old_string has tabs. Replace each \\t with {_w} spaces.")
                break

    # Longest-prefix match — where does old_string start diverging?
    _first_line = old_string.split('\n', 1)[0]
    if len(_first_line) >= 8:
        best_prefix = 0
        best_pos = -1
        _pos = 0
        while True:
            _pos = text.find(_first_line, _pos)
            if _pos < 0:
                break
            _mlen = 0
            _stop = min(len(old_string), len(text) - _pos)
            while _mlen < _stop and text[_pos + _mlen] == old_string[_mlen]:
                _mlen += 1
            if _mlen > best_prefix:
                best_prefix = _mlen
                best_pos = _pos
            _pos += 1
        if best_pos >= 0 and best_prefix >= len(_first_line):
            _line_num = text[:best_pos].count('\n') + 1
            _diverge_line = old_string[:best_prefix].count('\n') + 1
            _old_tail = old_string[best_prefix:best_prefix + 60].replace('\n', '\\n')
            _file_tail = text[best_pos + best_prefix:best_pos + best_prefix + 60].replace('\n', '\\n')
            hints.append(
                f"Partial match starts at file line {_line_num}, "
                f"diverges on line {_diverge_line} of old_string "
                f"(after {best_prefix} chars). "
                f"You sent: {_old_tail!r} | File has: {_file_tail!r}")

    if not hints:
        hints.append(
            "No similar content found anywhere in the file. "
            "Re-read the exact lines you want to edit before retrying.")

    return (f"old_string not found in {filename}.\n  - "
            + "\n  - ".join(hints)
            + "\n\nDo NOT retry with the same old_string. "
            "Read the file at the expected line range and copy the exact bytes.")


def action_edit(root_dir: str, path: str, req: Dict[str, Any]) -> Any:
    """Edit a file, either by exact string replacement or by line range.

    Two mutually-exclusive modes (matches EditHandler's JSON schema):
      - string-based: req has `old_string` + `new_string` (+ `replace_all`)
      - line-based:   req has `start_line` + `end_line` + `new_string`
                      (1-based, inclusive end)

    Previously only string-based was implemented; a line-based request
    (no `old_string` in the payload) crashed with
      "Missing 'old_string' parameter"
    even though EditHandler advertises the line-based API and routes to
    it. The line-based branch originally lived in the pawflow_relay.py
    dispatcher copy, which had become dead code and was removed — but the
    fs_actions copy (the one actually reached by the relay) never got it.
    """
    if "old_string" not in req and "old" in req:
        req["old_string"] = req.get("old", "")
    if "new_string" not in req and "new" in req:
        req["new_string"] = req.get("new", "")
    if "old_string" not in req and "old_str" in req:
        req["old_string"] = req.get("old_str", "")
    if "new_string" not in req and "new_str" in req:
        req["new_string"] = req.get("new_str", "")
    new_string = req.get("new_string", "")
    start_line = int(req.get("start_line", 0) or 0)
    end_line = int(req.get("end_line", 0) or 0)
    p = Path(path)

    if start_line > 0 and end_line > 0:
        text = p.read_text(encoding="utf-8")
        lines = text.split("\n")
        if start_line > len(lines) or end_line < start_line:
            raise ValueError(
                f"Invalid line range {start_line}-{end_line} for file "
                f"{p.name} ({len(lines)} lines)")
        s = max(0, start_line - 1)
        e = min(len(lines), end_line)
        removed = lines[s:e]
        new_lines = new_string.split("\n")
        lines[s:e] = new_lines
        p.write_text("\n".join(lines), encoding="utf-8")
        return {
            "lines_replaced": f"{start_line}-{end_line}",
            "lines_removed": len(removed),
            "lines_inserted": len(new_lines),
            "path": _rel(path, root_dir),
        }

    old_string = req.get("old_string", "")
    replace_all = req.get("replace_all", False)
    fuzzy = bool(req.get("fuzzy", False))
    fuzzy_threshold = float(req.get("fuzzy_threshold", 0.92) or 0.92)
    if not old_string:
        raise ValueError(
            "Missing 'old_string' parameter (or provide start_line/end_line "
            "for a line-based edit)")
    text = p.read_text(encoding="utf-8")
    matches = _find_window_matches(
        text, old_string, fuzzy=fuzzy, fuzzy_threshold=fuzzy_threshold)
    count = len(matches)
    if count == 0:
        raise ValueError(_diagnose_edit_mismatch(old_string, text, p.name))
    if count > 1 and not replace_all:
        kinds = ", ".join(sorted({m["kind"] for m in matches}))
        raise ValueError(
            f"old_string found {count} times ({kinds}; use replace_all=true)")
    if replace_all and any(m["kind"] == "fuzzy" for m in matches):
        raise ValueError("fuzzy replace_all is not supported; make the match exact or use one edit per occurrence")

    # Build diff context (±3 lines around the first replacement)
    lines = text.splitlines(True)
    diff_lines = []
    first_match = matches[0]
    matched_old = first_match["actual"]
    old_lines = matched_old.splitlines(True)
    new_lines = new_string.splitlines(True)
    # Find line number of first occurrence
    pos = first_match["start"]
    line_num = text[:pos].count("\n") + 1 if pos >= 0 else 0
    ctx_start = max(0, line_num - 4)
    ctx_end = min(len(lines), line_num + len(old_lines) + 3)
    for i in range(ctx_start, min(ctx_end, len(lines))):
        in_old = line_num - 1 <= i < line_num - 1 + len(old_lines)
        diff_lines.append({"line": i + 1, "text": lines[i].rstrip("\n\r"),
                           "type": "remove" if in_old else "context"})
    for j, nl in enumerate(new_lines):
        diff_lines.append({"line": line_num + j, "text": nl.rstrip("\n\r"),
                           "type": "add"})

    # Apply replacement
    new_text = _apply_replacements(text, matches, new_string, bool(replace_all))
    p.write_text(new_text, encoding="utf-8")
    return {
        "replacements": count if replace_all else 1,
        "path": _rel(path, root_dir),
        "diff": diff_lines,
        "line": line_num,
        "match_type": first_match["kind"],
        "similarity": round(float(first_match.get("score", 1.0)), 4),
    }


def action_batch_edit(root_dir: str, path: str, req: Dict[str, Any]) -> Any:
    """Apply multiple edits atomically across files."""
    edits = req.get("edits", [])
    if not edits:
        raise ValueError("Missing 'edits' parameter (list of {path, old_string, new_string})")

    allow_host_absolute = bool(req.get("local", False))
    root = Path(root_dir).resolve()

    # Phase 1: Read files once.
    file_contents = {}
    path_labels = {}
    for i, edit in enumerate(edits):
        fpath = edit.get("path", "")
        if not fpath:
            raise ValueError(f"Edit {i}: missing 'path'")
        abs_path = str(_resolve_tool_path(
            root_dir, fpath, allow_host_absolute=allow_host_absolute))
        path_labels[abs_path] = _rel(abs_path, str(root))
        old_string = edit.get("old_string", "")
        if not old_string:
            raise ValueError(f"Edit {i}: missing 'old_string'")
        if abs_path not in file_contents:
            p = Path(abs_path)
            if not p.is_file():
                raise ValueError(f"Edit {i}: file not found: {fpath}")
            file_contents[abs_path] = p.read_text(encoding="utf-8")

    # Phase 2: Validate and apply in memory, sequentially. This keeps the
    # operation atomic while allowing multiple edits in the same file where a
    # later edit targets content produced or shifted by an earlier edit.
    working = dict(file_contents)
    details = []
    total_replacements = 0
    for i, edit in enumerate(edits):
        fpath = edit.get("path", "")
        abs_path = str(_resolve_tool_path(
            root_dir, fpath, allow_host_absolute=allow_host_absolute))
        text = working[abs_path]
        old_string = edit.get("old_string", "")
        replace_all = bool(edit.get("replace_all", req.get("replace_all", False)))
        fuzzy = bool(edit.get("fuzzy", req.get("fuzzy", False)))
        fuzzy_threshold = float(edit.get(
            "fuzzy_threshold", req.get("fuzzy_threshold", 0.92)) or 0.92)
        matches = _find_window_matches(
            text, old_string, fuzzy=fuzzy, fuzzy_threshold=fuzzy_threshold)
        count = len(matches)
        if count == 0:
            raise ValueError(
                f"Edit {i}: " + _diagnose_edit_mismatch(
                    old_string, text, path_labels.get(abs_path, fpath)))
        if count > 1 and not replace_all:
            kinds = ", ".join(sorted({m["kind"] for m in matches}))
            raise ValueError(
                f"Edit {i}: old_string found {count} times in {fpath} "
                f"({kinds}; use replace_all=true)")
        if replace_all and any(m["kind"] == "fuzzy" for m in matches):
            raise ValueError(
                f"Edit {i}: fuzzy replace_all is not supported in {fpath}; "
                "make the match exact or use one edit per occurrence")
        replacements = count if replace_all else 1
        first = matches[0]
        working[abs_path] = _apply_replacements(
            text, matches, edit.get("new_string", ""), replace_all)
        total_replacements += replacements
        details.append({
            "index": i,
            "path": path_labels.get(abs_path, fpath),
            "replacements": replacements,
            "match_type": first["kind"],
            "similarity": round(float(first.get("score", 1.0)), 4),
            "line": text[:first["start"]].count("\n") + 1,
        })

    # Phase 3: Write all files
    for abs_path, content in working.items():
        Path(abs_path).write_text(content, encoding="utf-8")

    modified = sorted(set(path_labels.get(ap, _rel(ap, str(root))) for ap in working))
    return {
        "edits_applied": len(edits),
        "total_replacements": total_replacements,
        "files_modified": modified,
        "files_modified_count": len(modified),
        "details": details,
    }


def _patch_target(root_dir: str, raw_path: str, *, allow_host_absolute: bool = False):
    root = Path(root_dir).resolve()
    name = (raw_path or "").strip()
    if not name:
        raise ValueError("Patch target path is empty")
    if name.startswith("a/") or name.startswith("b/"):
        name = name[2:]
    target = _resolve_tool_path(
        root_dir, name, allow_host_absolute=allow_host_absolute).resolve()
    try:
        rel = target.relative_to(root).as_posix()
    except ValueError as exc:
        if allow_host_absolute and _is_host_absolute_path(name):
            return target, str(target)
        raise ValueError(f"Patch target escapes workspace: {raw_path}") from exc
    return target, rel


def _find_patch_sequence(lines, seq, start=0):
    if not seq:
        return start
    end = len(lines) - len(seq) + 1
    for idx in range(max(0, start), max(0, end)):
        if lines[idx:idx + len(seq)] == seq:
            return idx
    for idx in range(0, max(0, end)):
        if lines[idx:idx + len(seq)] == seq:
            return idx
    return -1


def _apply_patch_hunks(content: str, hunks, rel: str):
    content_lines = content.splitlines(True)
    cursor = 0
    applied = 0
    for hunk in hunks:
        old_seq = []
        new_seq = []
        for line in hunk:
            if not line:
                continue
            prefix = line[:1]
            body = line[1:]
            if prefix in (" ", "-"):
                old_seq.append(body)
            if prefix in (" ", "+"):
                new_seq.append(body)
        if not old_seq and not new_seq:
            continue
        pos = _find_patch_sequence(content_lines, old_seq, cursor)
        if pos < 0:
            preview = "".join(old_seq[:5]).strip().replace("\n", "\\n")
            raise ValueError(f"Patch context not found in {rel}: {preview[:160]}")
        content_lines[pos:pos + len(old_seq)] = new_seq
        cursor = pos + len(new_seq)
        applied += 1
    return "".join(content_lines), applied


def _parse_openai_patch_sections(patch: str):
    raw_lines = patch.splitlines(True)
    stripped = [ln.rstrip("\r\n") for ln in raw_lines]
    if not stripped or stripped[0] != "*** Begin Patch":
        raise ValueError("OpenAI patch must start with '*** Begin Patch'")
    sections = []
    i = 1
    while i < len(raw_lines):
        marker = stripped[i]
        if marker == "*** End Patch":
            return sections
        if marker.startswith("*** Add File: "):
            path = marker[len("*** Add File: "):].strip()
            i += 1
            body = []
            while i < len(raw_lines) and not stripped[i].startswith("*** "):
                if raw_lines[i].startswith("+"):
                    body.append(raw_lines[i][1:])
                elif raw_lines[i].strip():
                    raise ValueError(f"Invalid Add File line for {path}: {stripped[i]}")
                i += 1
            sections.append(("add", path, body, None))
            continue
        if marker.startswith("*** Delete File: "):
            sections.append(("delete", marker[len("*** Delete File: "):].strip(), [], None))
            i += 1
            continue
        if marker.startswith("*** Update File: "):
            path = marker[len("*** Update File: "):].strip()
            i += 1
            hunks = []
            current = []
            move_to = None
            while i < len(raw_lines) and not stripped[i].startswith("*** Update File: ") and not stripped[i].startswith("*** Add File: ") and not stripped[i].startswith("*** Delete File: ") and stripped[i] != "*** End Patch":
                line = raw_lines[i]
                sm = stripped[i]
                if sm.startswith("*** Move to: "):
                    move_to = sm[len("*** Move to: "):].strip()
                elif sm == "*** End of File":
                    pass
                elif line.startswith("@@"):
                    if current:
                        hunks.append(current)
                    current = []
                elif line.startswith((" ", "+", "-")):
                    if current is None:
                        current = []
                    current.append(line)
                elif sm.startswith("\\ No newline"):
                    pass
                elif sm.strip():
                    raise ValueError(f"Invalid Update File line for {path}: {sm}")
                i += 1
            if current:
                hunks.append(current)
            sections.append(("update", path, hunks, move_to))
            continue
        if marker.strip():
            raise ValueError(f"Unsupported patch marker: {marker}")
        i += 1
    raise ValueError("OpenAI patch missing '*** End Patch'")


def _apply_openai_patch(root_dir: str, patch: str, *,
                        allow_host_absolute: bool = False) -> Dict[str, Any]:
    sections = _parse_openai_patch_sections(patch)
    if not sections:
        raise ValueError("Patch did not contain any applicable hunks")
    files_modified = []
    hunks_applied = 0
    for action, raw_path, payload, move_to in sections:
        target, rel = _patch_target(
            root_dir, raw_path, allow_host_absolute=allow_host_absolute)
        if action == "add":
            if target.exists():
                raise ValueError(f"Add File target already exists: {rel}")
            target.parent.mkdir(parents=True, exist_ok=True)
            target.write_text("".join(payload), encoding="utf-8")
            files_modified.append(rel)
            hunks_applied += 1
        elif action == "delete":
            if not target.exists():
                raise ValueError(f"Delete File target does not exist: {rel}")
            target.unlink()
            files_modified.append(rel)
            hunks_applied += 1
        elif action == "update":
            if not target.exists():
                raise ValueError(f"Update File target does not exist: {rel}")
            content = target.read_text(encoding="utf-8")
            new_content, applied = _apply_patch_hunks(content, payload, rel)
            if applied == 0 and not move_to:
                raise ValueError(f"Patch did not contain applicable hunks for {rel}")
            dest = target
            dest_rel = rel
            if move_to:
                dest, dest_rel = _patch_target(
                    root_dir, move_to, allow_host_absolute=allow_host_absolute)
                dest.parent.mkdir(parents=True, exist_ok=True)
                if dest != target:
                    target.unlink()
            dest.write_text(new_content, encoding="utf-8")
            files_modified.append(dest_rel)
            hunks_applied += applied or 1
    if not files_modified or hunks_applied <= 0:
        raise ValueError("Patch did not modify any files")
    return {
        "method": "openai_apply_patch",
        "files_modified": files_modified,
        "hunks_applied": hunks_applied,
        "applied": True,
    }


def action_apply_patch(root_dir: str, path: str, req: Dict[str, Any]) -> Any:
    """Apply a unified diff patch or Codex/OpenAI *** Begin Patch block."""
    patch = req.get("patch", "")
    if not patch:
        raise ValueError("Missing 'patch' parameter")

    allow_host_absolute = bool(req.get("local", False))

    if patch.lstrip().startswith("*** Begin Patch"):
        return _apply_openai_patch(
            root_dir, patch.lstrip(), allow_host_absolute=allow_host_absolute)

    # Try git apply first for real unified diffs.
    try:
        result = subprocess.run(  # nosec B603, B607
            ["git", "apply", "--stat", "-"],
            input=patch, cwd=root_dir,
            capture_output=True, text=True, timeout=10,
        )
        if result.returncode == 0:
            stat_output = result.stdout.strip()
            result = subprocess.run(  # nosec B603, B607
                ["git", "apply", "-"],
                input=patch, cwd=root_dir,
                capture_output=True, text=True,
            )
            if result.returncode == 0:
                if not stat_output:
                    raise ValueError("Patch did not contain any applicable hunks")
                return {"method": "git_apply", "stats": stat_output, "applied": True}
            raise ValueError(f"git apply failed: {result.stderr}")
    except FileNotFoundError:
        pass
    except subprocess.TimeoutExpired:
        raise ValueError("Patch application timed out")

    # Manual fallback: simple unified diff parser.
    files_modified = []
    current_file = None
    current_rel = ""
    current_content = None
    hunks_applied = 0

    lines = patch.splitlines(True)
    i = 0
    while i < len(lines):
        line = lines[i]
        if line.startswith("+++ b/") or line.startswith("+++ "):
            if current_file and current_content is not None:
                Path(current_file).write_text(current_content, encoding="utf-8")
            fname = line[6:].strip() if line.startswith("+++ b/") else line[4:].strip()
            if fname == "/dev/null":
                current_file = None
                current_content = None
                i += 1
                continue
            target, current_rel = _patch_target(
                root_dir, fname, allow_host_absolute=allow_host_absolute)
            current_file = str(target)
            current_content = target.read_text(encoding="utf-8") if target.is_file() else ""
            files_modified.append(current_rel)
            i += 1
            continue
        if line.startswith("--- "):
            i += 1
            continue
        if line.startswith("@@") and current_content is not None:
            m = re.match(r"@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@", line)
            if not m:
                i += 1
                continue
            orig_start = int(m.group(1)) - 1
            content_lines = current_content.splitlines(True)
            while len(content_lines) <= orig_start:
                content_lines.append("")
            j = orig_start
            i += 1
            applied_this_hunk = False
            while i < len(lines):
                dl = lines[i]
                if dl.startswith(("@@", "diff ", "--- ", "+++ ")):
                    break
                if dl.startswith("-"):
                    if j < len(content_lines):
                        content_lines.pop(j)
                    applied_this_hunk = True
                elif dl.startswith("+"):
                    content_lines.insert(j, dl[1:])
                    j += 1
                    applied_this_hunk = True
                else:
                    j += 1
                i += 1
            current_content = "".join(content_lines)
            if applied_this_hunk:
                hunks_applied += 1
            continue
        i += 1

    if current_file and current_content is not None:
        Path(current_file).write_text(current_content, encoding="utf-8")

    if not files_modified or hunks_applied <= 0:
        raise ValueError("Patch did not contain any applicable unified diff hunks")
    return {"method": "manual_unified", "files_modified": files_modified,
            "hunks_applied": hunks_applied, "applied": True}





def action_project_init(root_dir: str, path: str, req: Dict[str, Any]) -> Any:
    """Generate a .pawflow.md project description from auto-scan."""
    target = Path(root_dir) / ".pawflow.md"
    if target.exists() and not req.get("force", False):
        raise ValueError(".pawflow.md already exists. Use force=true to overwrite.")

    # Use project_context to gather info
    ctx = action_project_context(root_dir, root_dir, {})

    lines = ["# Project Context\n"]

    if ctx.get("project_types"):
        lines.append(f"**Type:** {', '.join(ctx['project_types'])}\n")

    if ctx.get("git_branch"):
        lines.append(f"**Git branch:** {ctx['git_branch']}\n")

    lines.append("\n## Structure\n")
    if ctx.get("tree"):
        lines.append(f"```\n{ctx['tree'][:3000]}\n```\n")

    # Include key config snippets
    for fname in ("README.md", "readme.md"):
        if fname in ctx.get("config_files", {}):
            excerpt = ctx["config_files"][fname][:1500]
            lines.append(f"\n## README (excerpt)\n\n{excerpt}\n")
            break

    lines.append("\n## Instructions\n\n")
    lines.append("<!-- Add project-specific instructions for the AI agent here -->\n")
    lines.append("<!-- Example: coding conventions, architecture notes, test commands -->\n")

    content = "\n".join(lines)
    target.write_text(content, encoding="utf-8")
    return {"path": ".pawflow.md", "size": len(content)}


# ── Chunked read/write (for large files) ─────────────────────────

CHUNK_SIZE = 1024 * 1024  # 1 MB per chunk


def action_read_file_chunked(root_dir: str, path: str, req: Dict[str, Any]) -> Any:
    """Read a file in chunks. Returns first chunk + metadata.

    The caller must handle {"type": "chunked", "total_size": N, "chunk_size": M,
    "total_chunks": K, "chunk_index": 0, "data": base64, "path": relpath}.
    Subsequent chunks are requested with action=read_chunk, index=N.
    """
    chunk_size = req.get("chunk_size", CHUNK_SIZE)
    p = Path(path)
    total_size = p.stat().st_size
    total_chunks = (total_size + chunk_size - 1) // chunk_size
    # Read first chunk
    with open(path, "rb") as f:
        data = f.read(chunk_size)
    return {
        "type": "chunked",
        "total_size": total_size,
        "chunk_size": chunk_size,
        "total_chunks": total_chunks,
        "chunk_index": 0,
        "data": base64.b64encode(data).decode("ascii"),
        "path": _rel(path, root_dir),
    }


def action_read_chunk(root_dir: str, path: str, req: Dict[str, Any]) -> Any:
    """Read a specific chunk of a file by index."""
    chunk_size = req.get("chunk_size", CHUNK_SIZE)
    index = req.get("index", 0)
    offset = index * chunk_size
    with open(path, "rb") as f:
        f.seek(offset)
        data = f.read(chunk_size)
    return {
        "chunk_index": index,
        "data": base64.b64encode(data).decode("ascii"),
        "size": len(data),
        "done": len(data) < chunk_size,
    }


def action_write_file_chunked(root_dir: str, path: str, req: Dict[str, Any]) -> Any:
    """Write a chunk to a file. First chunk creates/truncates, subsequent append.

    req: {"chunk_index": 0, "data": base64, "done": false}
    Last chunk has "done": true.
    """
    chunk_data = base64.b64decode(req.get("data", ""))
    chunk_index = req.get("index", 0)
    done = req.get("done", False)
    p = Path(path)
    p.parent.mkdir(parents=True, exist_ok=True)
    mode = "wb" if chunk_index == 0 else "ab"
    with open(path, mode) as f:
        f.write(chunk_data)
    result = {"chunk_index": chunk_index, "written": len(chunk_data)}
    if done:
        result["total_written"] = p.stat().st_size
        result["path"] = _rel(path, root_dir)
    return result


# ── Action registry ──────────────────────────────────────────────
# Import split-out modules
from fs_exec import action_exec, action_exec_stream
from fs_screen import (
    action_screen_screenshot, action_screen_click, action_screen_double_click,
    action_screen_triple_click, action_screen_right_click,
    action_screen_type, action_screen_key, action_screen_move,
    action_screen_scroll, action_screen_mouse_position, action_screen_drag,
    action_screen_screenshot_region, action_screen_size, action_screen_wait,
    action_screen_open_app,
    action_screen_clipboard_read, action_screen_clipboard_write,
    action_screen_window_list, action_screen_window_focus,
    action_screen_window_close, action_screen_window_resize,
    action_screen_window_minimize, action_screen_window_maximize,
    action_screen_ocr, action_screen_locate,
)
from fs_mcp import (
    action_mcp_start, action_mcp_discover, action_mcp_call,
    action_mcp_stop, action_mcp_list,
)
from fs_http import action_http_fetch

ACTIONS = {
    "list_dir": action_list_dir,
    "project_context": action_project_context,
    "read_file": action_read_file,
    "read_pdf": action_read_pdf,
    "read_notebook": action_read_notebook,
    "write_file": action_write_file,
    "delete_file": action_delete_file,
    "mkdir": action_mkdir,
    "stat": action_stat,
    "exists": action_exists,
    "search": action_search,
    "grep": action_grep,
    "find_replace": action_find_replace,
    "edit": action_edit,
    "batch_edit": action_batch_edit,
    "apply_patch": action_apply_patch,
    "exec": action_exec,
    "exec_stream": action_exec_stream,
    "http_fetch": action_http_fetch,
    "read_file_chunked": action_read_file_chunked,
    "read_chunk": action_read_chunk,
    "write_file_chunked": action_write_file_chunked,
    "project_init": action_project_init,
    "edit_notebook": action_edit_notebook,
    "screen_screenshot": action_screen_screenshot,
    "screen_screenshot_region": action_screen_screenshot_region,
    "screen_click": action_screen_click,
    "screen_double_click": action_screen_double_click,
    "screen_triple_click": action_screen_triple_click,
    "screen_right_click": action_screen_right_click,
    "screen_type": action_screen_type,
    "screen_key": action_screen_key,
    "screen_move": action_screen_move,
    "screen_drag": action_screen_drag,
    "screen_scroll": action_screen_scroll,
    "screen_cursor_position": action_screen_mouse_position,
    "screen_size": action_screen_size,
    "screen_wait": action_screen_wait,
    "screen_open_app": action_screen_open_app,
    "screen_clipboard_read": action_screen_clipboard_read,
    "screen_clipboard_write": action_screen_clipboard_write,
    "screen_window_list": action_screen_window_list,
    "screen_window_focus": action_screen_window_focus,
    "screen_window_close": action_screen_window_close,
    "screen_window_resize": action_screen_window_resize,
    "screen_window_minimize": action_screen_window_minimize,
    "screen_window_maximize": action_screen_window_maximize,
    "screen_ocr": action_screen_ocr,
    "screen_locate": action_screen_locate,
    "mcp_start": action_mcp_start,
    "mcp_call": action_mcp_call,
    "mcp_discover": action_mcp_discover,
    "mcp_stop": action_mcp_stop,
    "mcp_list": action_mcp_list,
}
