"""Path-jailed filesystem primitives over a vault.

Every operation resolves its target and refuses anything that escapes the vault
root (``..``, absolute paths, symlinks pointing out) — the jail the MCP tool
surface relies on (docs/V3-PLAN.md §4 P3). Write primitives only; note-level
operations (frontmatter, todos, capture) layer on top in P3.
"""
import re
import shutil
from pathlib import Path, PurePath

# Containers that are never vault content — default skip set for grep/tree
# (matches vault_integrity.py's EXCLUDE_DIRS).
EXCLUDE_DIRS = {".git", ".obsidian", ".trash", ".claude", ".state", ".alexandria", "node_modules", ".venv"}


class JailError(ValueError):
    """The requested path escapes the vault root."""


def _root(vault) -> Path:
    return Path(vault).resolve()


def _resolve_under(root: Path, rel) -> Path:
    """``rel`` resolved (``..`` + symlinks collapsed) under an already-resolved
    ``root``, refusing anything that escapes it. The single jail implementation."""
    p = (root / rel).resolve() if not Path(rel).is_absolute() else Path(rel).resolve()
    if p != root and root not in p.parents:
        raise JailError(f"{rel!r} escapes the vault root")
    return p


def _jailed(vault, rel) -> Path:
    """Resolve ``rel`` against the vault and refuse escapes (incl. via symlink)."""
    return _resolve_under(_root(vault), rel)


def _within_root(root: Path, p: Path) -> bool:
    """Whether ``p``'s real target (``..`` + symlinks collapsed) stays under
    ``root``. Used to drop glob/tree matches that reach *out* of the vault via a
    symlink — the pattern itself is textually clean, but the match it lands on
    isn't (``root/linkdir/secret.md`` where ``linkdir`` points outside)."""
    try:
        rp = p.resolve()
    except OSError:
        return False
    return rp == root or root in rp.parents


def vault_root(vault) -> Path:
    """The vault's resolved root — hoist this out of a walk loop and pass it to
    :func:`within_vault` so a full-corpus pass resolves the root once, not once
    per note."""
    return _root(vault)


def within_vault(vault, path, root=None) -> bool:
    """Whether ``path``'s REAL target (``..`` + symlinks collapsed) stays inside
    the vault — the guard every corpus walk must apply to every entry it reads.

    ``fs``'s own primitives have always done this (:func:`glob`, :func:`grep`,
    :func:`tree` via :func:`_within_root`), which is why the tools built on them
    hold. The walkers that bypass ``fs`` for a raw ``os.walk``/``rglob`` did not,
    and a symlinked ``.md`` pointing out of the vault was read and returned by
    them (found 2026-07-31, v2.110.0; fixed 2.110.1). Note that neither
    ``os.walk`` (``followlinks=False``) nor ``Path.rglob`` descends a symlinked
    *directory* — a symlinked **file** is the whole vector, and it is an ordinary
    entry in ``filenames`` that ``read_text`` follows.

    ``root`` may be passed pre-resolved (see :func:`vault_root`).
    """
    return _within_root(root if root is not None else _root(vault), Path(path))


def _jailed_child(vault, rel) -> Path:
    """Like :func:`_jailed` but refuses the vault root itself — the guard for
    destructive ops (delete/move) that must never target the whole vault
    (``delete(vault, ".")`` would otherwise ``rmtree`` it)."""
    root = _root(vault)
    p = _resolve_under(root, rel)
    if p == root:
        raise JailError(f"{rel!r} is the vault root — refusing to target it")
    return p


def canonical_rel(vault, rel) -> str:
    """The vault-relative POSIX path ``rel`` actually resolves to — ``..`` and
    symlinks collapsed, the jail enforced (raises :class:`JailError` on escape).

    This is the form *policy* (the config-file check, the ``EXCLUDE_DIRS`` skip)
    must judge, so it never diverges from the filesystem op that ultimately runs
    — which ``.resolve()``s too. Judging the raw textual path instead is the
    traversal-leak bug: ``Areas/Acme/../Beta/x.md`` reads as "Acme" to a textual
    check but lands in "Beta" on disk. Returns ``"."`` for the vault root itself.
    """
    root = _root(vault)
    return _resolve_under(root, rel).relative_to(root).as_posix()


def _skipped(rel_parts) -> bool:
    return any(seg in EXCLUDE_DIRS for seg in rel_parts)


# ---- read ------------------------------------------------------------------

def read_text(vault, rel, start=None, end=None, max_chars=None) -> str:
    """Read a file (optionally a 1-indexed inclusive line range). ``max_chars``
    caps the returned text — agentic gather reads state their budget here
    instead of inheriting the UI-sized server default."""
    text = _jailed(vault, rel).read_text(encoding="utf-8")
    if start is not None or end is not None:
        lines = text.splitlines(keepends=True)
        lo = max(1, start or 1) - 1
        hi = end if end is not None else len(lines)
        text = "".join(lines[lo:hi])
    return text if max_chars is None else text[:max_chars]


def list_dir(vault, rel="."):
    """Sorted ``(name, is_dir)`` pairs for one directory."""
    p = _jailed(vault, rel)
    return sorted((c.name, c.is_dir()) for c in p.iterdir())


def tree(vault, rel=".", depth=2, skip=None):
    """Vault-relative paths (dirs get a trailing ``/``) to ``depth`` levels,
    skipping non-content containers."""
    root = _root(vault)
    base = _jailed(vault, rel)
    skip = EXCLUDE_DIRS if skip is None else skip
    out = []

    def walk(d: Path, level: int):
        for c in sorted(d.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower())):
            rel_c = c.relative_to(root)
            if any(seg in skip for seg in rel_c.parts):
                continue
            if c.is_symlink() and not _within_root(root, c):
                continue  # a symlink leaving the vault — never surface its target
            is_dir = c.is_dir()
            out.append(str(rel_c) + ("/" if is_dir else ""))
            # descend real dirs only: never walk *through* a symlink (an internal
            # one is reachable by its real path; following one risks loops/DoS).
            if is_dir and not c.is_symlink() and level + 1 < depth:
                walk(c, level + 1)

    walk(base, 0)
    return out


def _jailed_glob(vault, pattern):
    """Yield ``(path, rel)`` for pattern matches that stay inside the vault.

    The jail applies to the *pattern* (no ``..``/absolute/``~`` — those would
    root the glob outside) **and** to each match (a symlinked match that resolves
    out of the vault is dropped, via :func:`_within_root`). Non-content
    containers (``EXCLUDE_DIRS``) are skipped. Sorted by path, so callers get a
    stable order. Shared by :func:`glob` and :func:`grep` so a model-controlled
    pattern can never turn either into an out-of-vault read primitive."""
    pp = PurePath(pattern)
    if pp.is_absolute() or ".." in pp.parts or pattern.startswith("~"):
        raise JailError(f"glob pattern {pattern!r} escapes the vault root")
    root = _root(vault)
    for p in sorted(root.glob(pattern)):
        rel = p.relative_to(root)
        if _skipped(rel.parts) or not _within_root(root, p):
            continue
        yield p, rel


def glob(vault, pattern):
    """Sorted vault-relative matches for a glob pattern (rooted at the vault, so
    it cannot escape via ``..``, an absolute/``~`` pattern, or a symlink that
    leaves the vault), skipping non-content containers."""
    return sorted(str(rel) for _, rel in _jailed_glob(vault, pattern))


def iter_paths(vault, pattern):
    """Sorted absolute :class:`Path` matches for a glob pattern, jailed.

    The drop-in for a raw ``vault.glob(pat)`` / ``vault.rglob(pat)`` in any
    walker that goes on to READ what it finds: same matches, minus non-content
    containers and minus anything whose real target leaves the vault. Yields
    absolute paths (where :func:`glob` yields vault-relative strings), because
    that is the shape the engine's walkers already pass around.
    """
    for p, _rel in _jailed_glob(vault, pattern):
        yield p


def grep(vault, pattern, file_glob="**/*.md", ignore_case=False):
    """``(rel_path, line_number, line)`` for every regex match, 1-indexed —
    the ``vault_grep → file:line`` MCP tool's engine. ``file_glob`` is jailed
    exactly like :func:`glob`'s pattern, so it can't reach outside the vault."""
    rx = re.compile(pattern, re.IGNORECASE if ignore_case else 0)
    hits = []
    for p, rel in _jailed_glob(vault, file_glob):
        if not p.is_file():
            continue
        try:
            text = p.read_text(encoding="utf-8", errors="replace")
        except OSError:
            continue
        for i, line in enumerate(text.splitlines(), 1):
            if rx.search(line):
                hits.append((str(rel), i, line))
    return hits


# ---- write (jailed primitives; git ownership lands in P3) -------------------

def write_text(vault, rel, text) -> Path:
    p = _jailed(vault, rel)
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_text(text, encoding="utf-8")
    return p


def append_text(vault, rel, text) -> Path:
    p = _jailed(vault, rel)
    p.parent.mkdir(parents=True, exist_ok=True)
    with open(p, "a", encoding="utf-8") as fh:
        fh.write(text)
    return p


def mkdir(vault, rel) -> Path:
    p = _jailed(vault, rel)
    p.mkdir(parents=True, exist_ok=True)
    return p


def move(vault, src, dst) -> Path:
    s, d = _jailed_child(vault, src), _jailed_child(vault, dst)
    d.parent.mkdir(parents=True, exist_ok=True)
    shutil.move(str(s), str(d))
    return d


def delete(vault, rel, recursive=False):
    p = _jailed_child(vault, rel)
    if p.is_dir():
        if recursive:
            shutil.rmtree(p)
        else:
            p.rmdir()
    else:
        p.unlink()
