"""Local file read/edit/write, intercepted from the server-relayed command
channel the same way pet_actions.py intercepts petctl (see server_client.
run_local_command / controller._handle_command). Whatever text the server's
"command" tool sends is just a string this repo is free to interpret before
it ever reaches subprocess — a `filectl` pseudo-command is one such
interpretation, giving the model a way to read/write/edit files on this
machine without constructing a raw shell heredoc, where quoting, `$`,
backticks, and embedded quotes make anything beyond a one-liner failure-prone.

Executing arbitrary commands already works today (that's exactly what
run_local_command/subprocess.run does) — filectl doesn't add that ability,
it only makes the read/write/edit slice of it reliable. It also doesn't
expand what the server can already do to this machine: a relayed shell
command could already overwrite any file the desktop user can write (see the
security notes in CLAUDE.md) — filectl is a safer *path* to the same
capability, not a new capability.

Wire format: `filectl <json>`, where <json> is a single-line, compact JSON
object — critically, ONE LINE. The server's "command" tool marker only
captures the argument up to the next newline (see TOOL_SPECS/
_extract_all_tool_calls in the main repo's ai/agents/default.py — "command"
is not declared multiline), so a marker-delimited multi-line payload (this
module's first design) silently got truncated at the first line no matter
how the prompt worded it. JSON sidesteps that for free: json.dumps() already
encodes embedded newlines as the two characters "\n", not an actual line
break, so arbitrarily multi-line file content still fits on the one physical
line the extractor captures.

    filectl {"op": "list", "path": "<dir>", "pattern": "<glob>", "recursive": <bool>}
    filectl {"op": "read", "path": "<path>", "start": <line>, "end": <line>}
    filectl {"op": "write", "path": "<path>", "content": "<text>"}
    filectl {"op": "edit", "path": "<path>", "old": "<text>", "new": "<text>"}

"pattern"/"recursive" (list) and "start"/"end" (read) are optional. `list`
exists even though a real `ls`/`dir` shell command already works, because
this repo is cross-platform (Windows/macOS/Linux) and the model shouldn't
have to guess which listing command applies on this machine — one glob-based
op covers all three. Must be invoked as the argument to the
ordinary `command` tool marker (e.g. `command: filectl {"op": "write", ...}`)
— see the pet-only paragraph in the main repo's ai/desk_api.py
_system_context for the exact instruction the model is given, including the
"keep it one line" requirement.

Pure parsing (parse) is separated from the filesystem I/O (execute) so the
syntax is unit-testable without touching disk, matching pet_actions.py's
parse/describe split.
"""

from __future__ import annotations

import json
from pathlib import Path
from typing import Optional

_PREFIXES = ("filectl", "file")

# Keeps a runaway read/write/list from blowing up the tool_result relay (and,
# for read/list, from flooding the model's context with a giant response).
_MAX_READ_BYTES = 200_000
_MAX_WRITE_BYTES = 2_000_000
_MAX_LIST_ENTRIES = 500

HELP = (
    'filectl {"op": "list", "path": "<dir>", "pattern": "<glob>", "recursive": <bool>}\n'
    'filectl {"op": "read", "path": "<path>", "start": <line>, "end": <line>}\n'
    'filectl {"op": "write", "path": "<path>", "content": "<text>"}\n'
    'filectl {"op": "edit", "path": "<path>", "old": "<text>", "new": "<text>"}\n'
    "Must be all on one line — this rides the single-line \"command\" marker."
)


class FileOpError(Exception):
    """Bad filectl syntax or a filesystem error — reported back to the
    server as command output, exactly like ActionError in pet_actions.py."""


def is_file_command(command: str) -> bool:
    stripped = (command or "").strip()
    if not stripped:
        return False
    first_word = stripped.split(None, 1)[0]
    return first_word.lower() in _PREFIXES


def parse(command: str) -> Optional[dict]:
    """Parse a `filectl <json>` string into an action dict, or None if this
    isn't a filectl command at all (caller should try the next handler, or
    fall back to a real shell command). Raises FileOpError on a filectl
    command that doesn't make sense."""
    if not is_file_command(command):
        return None
    stripped = command.strip()
    _, _, rest = stripped.partition(" ")
    rest = rest.strip()
    if not rest or rest.lower() in ("help", "-h", "--help"):
        return {"action": "help"}

    try:
        payload = json.loads(rest)
    except json.JSONDecodeError as exc:
        raise FileOpError(f"couldn't parse filectl JSON ({exc}); usage:\n{HELP}") from exc
    if not isinstance(payload, dict):
        raise FileOpError(f"filectl payload must be a JSON object; usage:\n{HELP}")

    op = str(payload.get("op") or "help").lower()

    if op == "help":
        return {"action": "help"}

    if op == "list":
        path = _required_str(payload, "path")
        pattern = payload.get("pattern", "*")
        if not isinstance(pattern, str) or not pattern:
            raise FileOpError('"pattern" must be a non-empty string')
        return {
            "action": "list", "path": path,
            "pattern": pattern, "recursive": bool(payload.get("recursive")),
        }

    if op == "read":
        path = _required_str(payload, "path")
        return {
            "action": "read", "path": path,
            "start": _line_number(payload.get("start"), "start"),
            "end": _line_number(payload.get("end"), "end"),
        }

    if op == "write":
        path = _required_str(payload, "path")
        if payload.get("content") is None:
            raise FileOpError('write needs "content"')
        return {"action": "write", "path": path, "content": str(payload["content"])}

    if op == "edit":
        path = _required_str(payload, "path")
        if payload.get("old") is None or payload.get("new") is None:
            raise FileOpError('edit needs "old" and "new"')
        old, new = str(payload["old"]), str(payload["new"])
        if old == new:
            raise FileOpError("old and new text are identical — nothing to edit")
        return {"action": "edit", "path": path, "old": old, "new": new}

    raise FileOpError(f"unknown filectl op {op!r}; usage:\n{HELP}")


def _required_str(payload: dict, key: str) -> str:
    value = payload.get(key)
    if not isinstance(value, str) or not value.strip():
        raise FileOpError(f'filectl needs a non-empty "{key}"')
    return value


def _line_number(value, label: str) -> Optional[int]:
    if value is None:
        return None
    if isinstance(value, bool) or not isinstance(value, int):
        raise FileOpError(f"{label} must be an integer line number, got {value!r}")
    return value


def describe(action: dict) -> str:
    """Text handed back to the server before execute() runs — mirrors
    pet_actions.describe, used only for the log line."""
    kind = action.get("action")
    if kind == "help":
        return "[filectl] help"
    return f"[filectl] {kind} {action.get('path', '')}"


def execute(action: dict) -> str:
    """Actually perform a parsed filectl action, returning the text to send
    back to the server as the command's output. Raises FileOpError on any
    filesystem problem, same as a bad-syntax parse error."""
    kind = action.get("action")
    if kind == "help":
        return HELP
    if kind == "list":
        return _do_list(action)
    if kind == "read":
        return _do_read(action)
    if kind == "write":
        return _do_write(action)
    if kind == "edit":
        return _do_edit(action)
    return "[filectl] ok"


def _resolve(path_str: str) -> Path:
    return Path(path_str).expanduser()


def _do_list(action: dict) -> str:
    path = _resolve(action["path"])
    if not path.is_dir():
        raise FileOpError(f"no such directory: {path}")
    pattern = action["pattern"]
    glob = path.rglob if action["recursive"] else path.glob
    try:
        entries = sorted(glob(pattern), key=lambda p: str(p).lower())
    except OSError as exc:
        raise FileOpError(f"couldn't list {path}: {exc}") from exc
    if not entries:
        return f"[no entries matching {pattern!r} in {path}]"
    truncated = len(entries) > _MAX_LIST_ENTRIES
    lines = []
    for entry in entries[:_MAX_LIST_ENTRIES]:
        rel = entry.relative_to(path)
        if entry.is_dir():
            lines.append(f"{rel}/")
            continue
        try:
            size = entry.stat().st_size
        except OSError:
            size = -1
        lines.append(f"{rel}\t{size}B")
    if truncated:
        lines.append(f"... truncated at {_MAX_LIST_ENTRIES} entries (of {len(entries)}) — narrow \"pattern\"")
    return "\n".join(lines)


def _do_read(action: dict) -> str:
    path = _resolve(action["path"])
    if not path.is_file():
        raise FileOpError(f"no such file: {path}")
    try:
        data = path.read_bytes()
    except OSError as exc:
        raise FileOpError(f"couldn't read {path}: {exc}") from exc
    if len(data) > _MAX_READ_BYTES:
        raise FileOpError(
            f"{path} is {len(data)} bytes, over the {_MAX_READ_BYTES}-byte filectl read limit — "
            'pass "start"/"end" to read a slice instead'
        )
    try:
        text = data.decode("utf-8")
    except UnicodeDecodeError as exc:
        raise FileOpError(f"{path} isn't valid UTF-8 text: {exc}") from exc
    lines = text.splitlines()
    start = max(1, action.get("start") or 1)
    end = min(len(lines), action.get("end") or len(lines))
    if not lines:
        return "[empty file]"
    return "\n".join(f"{i:>6}\t{lines[i - 1]}" for i in range(start, end + 1))


def _do_write(action: dict) -> str:
    path = _resolve(action["path"])
    content = action["content"]
    if len(content.encode("utf-8")) > _MAX_WRITE_BYTES:
        raise FileOpError(f"content is over the {_MAX_WRITE_BYTES}-byte filectl write limit")
    try:
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(content, encoding="utf-8")
    except OSError as exc:
        raise FileOpError(f"couldn't write {path}: {exc}") from exc
    return f"[filectl] wrote {len(content)} chars to {path}"


def _do_edit(action: dict) -> str:
    path = _resolve(action["path"])
    old, new = action["old"], action["new"]
    if not path.is_file():
        raise FileOpError(f"no such file: {path}")
    try:
        text = path.read_text(encoding="utf-8")
    except OSError as exc:
        raise FileOpError(f"couldn't read {path}: {exc}") from exc
    count = text.count(old)
    if count == 0:
        raise FileOpError(f"didn't find that text in {path} — nothing changed")
    if count > 1:
        raise FileOpError(
            f"that text appears {count} times in {path} — filectl edit needs a unique match; "
            "include more surrounding context"
        )
    try:
        path.write_text(text.replace(old, new, 1), encoding="utf-8")
    except OSError as exc:
        raise FileOpError(f"couldn't write {path}: {exc}") from exc
    return f"[filectl] edited {path}"
