#!/usr/bin/env bash
# Installs a read-only, token-protected MCP server for /Users/aquaregia/development.
# It binds ONLY to 127.0.0.1:9898. Pair it with an ngrok Cloud Endpoint afterward.
set -euo pipefail

ROOT="/Users/aquaregia/development"
STATE="$HOME/Library/Application Support/hermes-mac-files"
PLIST="$HOME/Library/LaunchAgents/com.aquaregia.hermes-mac-files.plist"
LABEL="com.aquaregia.hermes-mac-files"
UID_NOW="$(id -u)"

if [[ ! -d "$ROOT" ]]; then
  echo "ERROR: Expected development root does not exist: $ROOT" >&2
  exit 1
fi

if ! command -v brew >/dev/null 2>&1; then
  echo "ERROR: Homebrew is required. Install it from https://brew.sh, then rerun this script." >&2
  exit 1
fi

brew install uv ngrok >/dev/null
mkdir -p "$STATE" "$HOME/Library/LaunchAgents"
chmod 700 "$STATE"

if [[ ! -f "$STATE/token" ]]; then
  echo "Create the long random bearer token that Hermes will use to access this MCP."
  echo "Do NOT post it in Discord. Store it in your password manager / private channel."
  read -r -s -p "Bearer token: " TOKEN
  echo
  if [[ ${#TOKEN} -lt 32 ]]; then
    echo "ERROR: Token must be at least 32 characters." >&2
    exit 1
  fi
  printf '%s' "$TOKEN" > "$STATE/token"
  chmod 600 "$STATE/token"
else
  echo "Keeping existing MCP bearer token at $STATE/token"
fi

cat > "$STATE/server.py" <<'PY'
import hmac
import os
from pathlib import Path
from typing import Any

import uvicorn
from fastmcp import FastMCP

ROOT = Path("/Users/aquaregia/development").resolve()
TOKEN = Path.home().joinpath("Library/Application Support/hermes-mac-files/token").read_text().strip()
MAX_READ_BYTES = 1_000_000
MAX_RESULTS = 100

mcp = FastMCP(
    "Aquaregia Mac Development Files",
    instructions=(
        "Read-only access to the approved Mac development directory. "
        "Never assumes permissions outside that root."
    ),
)

def safe_path(relative_path: str = ".", *, must_exist: bool = True) -> Path:
    candidate = (ROOT / relative_path).resolve(strict=False)
    try:
        candidate.relative_to(ROOT)
    except ValueError as exc:
        raise ValueError("Path is outside the approved development root") from exc
    if must_exist and not candidate.exists():
        raise FileNotFoundError(f"Path does not exist: {relative_path}")
    return candidate

def describe(path: Path) -> dict[str, Any]:
    stat = path.stat()
    return {
        "path": str(path.relative_to(ROOT)) if path != ROOT else ".",
        "type": "directory" if path.is_dir() else "file",
        "size_bytes": stat.st_size,
        "modified_epoch": int(stat.st_mtime),
    }

@mcp.tool
async def list_directory(relative_path: str = ".") -> list[dict[str, Any]]:
    """List immediate files and directories below an approved relative path."""
    path = safe_path(relative_path)
    if not path.is_dir():
        raise ValueError("Path is not a directory")
    return [describe(item) for item in sorted(path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))[:MAX_RESULTS]]

@mcp.tool
async def read_text_file(relative_path: str, max_characters: int = 100_000) -> str:
    """Read a UTF-8 text file inside the approved development root. Files over 1 MB are rejected."""
    path = safe_path(relative_path)
    if not path.is_file():
        raise ValueError("Path is not a file")
    if path.stat().st_size > MAX_READ_BYTES:
        raise ValueError("File exceeds the 1 MB read safety limit")
    try:
        text = path.read_text(encoding="utf-8")
    except UnicodeDecodeError as exc:
        raise ValueError("File is not UTF-8 text") from exc
    return text[:max(1, min(max_characters, 100_000))]

@mcp.tool
async def search_text(query: str, relative_path: str = ".") -> list[dict[str, Any]]:
    """Search UTF-8 text files below an approved relative path; returns matching file paths and line snippets."""
    if not query:
        raise ValueError("Query cannot be empty")
    start = safe_path(relative_path)
    if not start.is_dir():
        raise ValueError("Path is not a directory")
    results: list[dict[str, Any]] = []
    for path in start.rglob("*"):
        if len(results) >= MAX_RESULTS:
            break
        if not path.is_file() or path.stat().st_size > MAX_READ_BYTES:
            continue
        resolved = safe_path(str(path.relative_to(ROOT)))
        try:
            for number, line in enumerate(resolved.read_text(encoding="utf-8").splitlines(), 1):
                if query.casefold() in line.casefold():
                    results.append({"path": str(resolved.relative_to(ROOT)), "line": number, "snippet": line[:500]})
                    if len(results) >= MAX_RESULTS:
                        break
        except (UnicodeDecodeError, OSError):
            continue
    return results

@mcp.tool
async def file_metadata(relative_path: str) -> dict[str, Any]:
    """Get metadata for an approved file or directory."""
    return describe(safe_path(relative_path))

def iter_tree(start: Path, max_depth: int):
    """Yield a stable, depth-first read-only tree without following symlinks."""
    def walk(directory: Path, depth: int):
        try:
            children = sorted(directory.iterdir(), key=lambda p: (not p.is_dir(), p.name.casefold()))
        except OSError:
            return
        for raw_child in children:
            relative = str(raw_child.relative_to(ROOT))
            # Resolve first so symlinks that escape the approved root never appear.
            try:
                child = safe_path(relative)
            except (FileNotFoundError, ValueError, OSError):
                continue
            if raw_child.is_symlink():
                yield {"path": relative, "type": "symlink"}
                continue
            try:
                yield describe(child)
            except OSError:
                continue
            if child.is_dir() and depth < max_depth:
                yield from walk(child, depth + 1)
    yield from walk(start, 1)

@mcp.tool
async def list_tree_page(
    relative_path: str = ".", max_depth: int = 5, offset: int = 0, page_size: int = 250
) -> dict[str, Any]:
    """Return a read-only, stable depth-first hierarchy page (up to depth 5). Use next_offset to request every child without a listing cap."""
    if not 0 <= max_depth <= 5:
        raise ValueError("max_depth must be between 0 and 5")
    if offset < 0:
        raise ValueError("offset must be non-negative")
    page_size = max(1, min(page_size, 500))
    start = safe_path(relative_path)
    if not start.is_dir():
        raise ValueError("Path is not a directory")

    entries: list[dict[str, Any]] = []
    skipped = 0
    has_more = False
    for entry in iter_tree(start, max_depth):
        if skipped < offset:
            skipped += 1
            continue
        if len(entries) < page_size:
            entries.append(entry)
            continue
        has_more = True
        break
    return {
        "root": str(start.relative_to(ROOT)) if start != ROOT else ".",
        "max_depth": max_depth,
        "offset": offset,
        "entries": entries,
        "next_offset": offset + len(entries) if has_more else None,
    }

async def secured_app(scope, receive, send):
    if scope["type"] == "http" and scope.get("path", "").startswith("/mcp"):
        headers = dict(scope.get("headers", []))
        supplied = headers.get(b"authorization", b"").decode("utf-8", "ignore")
        expected = f"Bearer {TOKEN}"
        if not hmac.compare_digest(supplied, expected):
            await send({"type": "http.response.start", "status": 401, "headers": [(b"content-type", b"text/plain")]})
            await send({"type": "http.response.body", "body": b"Unauthorized"})
            return
    await app(scope, receive, send)

app = mcp.http_app(path="/mcp", transport="http", stateless_http=True)

if __name__ == "__main__":
    uvicorn.run(secured_app, host="127.0.0.1", port=9898, log_level="warning")
PY

# uv venv exits non-zero when the environment already exists. Reuse it so repeat installs
# update the server safely rather than aborting before LaunchAgent installation.
if [[ ! -x "$STATE/venv/bin/python" ]]; then
  uv venv --python 3.11 "$STATE/venv" >/dev/null 2>&1 || uv venv "$STATE/venv" >/dev/null
fi
uv pip install --python "$STATE/venv/bin/python" "fastmcp==3.4.5" "uvicorn==0.51.0" >/dev/null
chmod 600 "$STATE/server.py"

cat > "$PLIST" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
  <key>Label</key><string>$LABEL</string>
  <key>ProgramArguments</key><array>
    <string>$STATE/venv/bin/python</string><string>$STATE/server.py</string>
  </array>
  <key>WorkingDirectory</key><string>$STATE</string>
  <key>RunAtLoad</key><true/><key>KeepAlive</key><true/>
  <key>StandardOutPath</key><string>$STATE/server.log</string>
  <key>StandardErrorPath</key><string>$STATE/server-error.log</string>
</dict></plist>
EOF
chmod 600 "$PLIST"
launchctl bootout "gui/$UID_NOW/$LABEL" 2>/dev/null || true
launchctl bootstrap "gui/$UID_NOW" "$PLIST"

# FastMCP imports and startup can take longer than a fixed two-second delay on macOS.
for _ in {1..30}; do
  status="$(curl -sS --max-time 2 -o /dev/null -w '%{http_code}' http://127.0.0.1:9898/mcp || true)"
  if [[ "$status" == "401" ]]; then
    echo "SUCCESS: Read-only MCP is running locally at http://127.0.0.1:9898/mcp"
    echo "NEXT: Configure ngrok to forward to 127.0.0.1:9898, then send only the public MCP URL to Hermes."
    exit 0
  fi
  sleep 1
done

echo "ERROR: MCP did not pass its local authorization check within 30 seconds. See $STATE/server-error.log" >&2
exit 1
