#!/usr/bin/env python3
"""Validated client for the ECHO Obsidian Local REST API.

This is the Codex-portable replacement for the original bash echo.py client.
It intentionally requires ECHO_KEY from the environment; no bearer token is
stored in the plugin.
"""

from __future__ import annotations

import argparse
import datetime as dt
import json
import locale
import os
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path


BASE = os.environ.get("ECHO_BASE", "https://echoapi.alwisp.com").rstrip("/")
KEY = os.environ.get("ECHO_KEY", "")
VERIFY = os.environ.get("ECHO_VERIFY", "1") != "0"
LOCK_TTL = int(os.environ.get("ECHO_LOCK_TTL", "900"))


class EchoError(RuntimeError):
    def __init__(self, message: str, code: int = 1) -> None:
        super().__init__(message)
        self.code = code


def require_key() -> None:
    if not KEY:
        raise EchoError("ECHO_KEY is not set; refusing to call the live vault")


def today() -> str:
    return os.environ.get("ECHO_TODAY") or dt.date.today().isoformat()


def now_iso() -> str:
    return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def vault_url(path: str) -> str:
    safe = "/".join(urllib.parse.quote(part) for part in path.split("/"))
    if path.endswith("/") and not safe.endswith("/"):
        safe += "/"
    return f"{BASE}/vault/{safe}"


def request(method: str, url: str, data: bytes | None = None, headers: dict[str, str] | None = None) -> tuple[int, bytes]:
    require_key()
    merged = {"Authorization": f"Bearer {KEY}", **(headers or {})}
    last_status = 0
    last_body = b""
    for attempt in range(2):
        req = urllib.request.Request(url, data=data, method=method, headers=merged)
        try:
            with urllib.request.urlopen(req, timeout=30) as response:
                return response.status, response.read()
        except urllib.error.HTTPError as exc:
            body = exc.read()
            if exc.code >= 500 and attempt == 0:
                time.sleep(1)
                continue
            return exc.code, body
        except Exception as exc:
            last_status, last_body = 0, str(exc).encode()
            if attempt == 0:
                time.sleep(1)
                continue
    return last_status, last_body


def check(status: int, body: bytes, context: str) -> None:
    if status == 404:
        raise EchoError(f"{context}: not found", 44)
    if status == 0:
        raise EchoError(f"{context}: vault unreachable ({body.decode(errors='replace')})", 1)
    if status >= 400:
        raise EchoError(f"{context}: HTTP {status} - {body.decode(errors='replace')}", 1)


def read_body(arg: str | None) -> bytes:
    if arg in (None, "-"):
        raw = sys.stdin.buffer.read()
        try:
            text = raw.decode("utf-8")
        except UnicodeDecodeError:
            text = raw.decode(locale.getpreferredencoding(False) or "cp1252", errors="replace")
        return text.encode("utf-8")
    return Path(arg).read_bytes()


def normalize_patch_body(data: bytes, op: str, target_type: str) -> bytes:
    if target_type != "heading":
        return data
    text = data.decode("utf-8", errors="replace")
    if op == "replace":
        text = text.strip("\r\n")
        if text:
            text = f"\n{text}\n"
        else:
            text = "\n"
    elif text and not text.endswith("\n"):
        text += "\n"
    return text.encode("utf-8")


def normalize_json_scalar(value: str) -> bytes:
    value = value.strip()
    try:
        json.loads(value)
        return value.encode("utf-8")
    except json.JSONDecodeError:
        return json.dumps(value).encode("utf-8")


def cmd_get(path: str) -> int:
    status, body = request("GET", vault_url(path))
    check(status, body, f"get {path}")
    sys.stdout.buffer.write(body)
    return 0


def cmd_map(path: str) -> int:
    status, body = request("GET", vault_url(path), headers={"Accept": "application/vnd.olrapi.document-map+json"})
    check(status, body, f"map {path}")
    sys.stdout.buffer.write(body)
    return 0


def cmd_ls(path: str) -> int:
    if not path.endswith("/"):
        path += "/"
    status, body = request("GET", vault_url(path))
    check(status, body, f"ls {path}")
    sys.stdout.buffer.write(body)
    return 0


def cmd_search(query: list[str]) -> int:
    q = urllib.parse.quote_plus(" ".join(query))
    status, body = request("POST", f"{BASE}/search/simple/?query={q}")
    check(status, body, "search")
    sys.stdout.buffer.write(body)
    return 0


def cmd_put(path: str, file_arg: str | None) -> int:
    data = read_body(file_arg)
    status, body = request("PUT", vault_url(path), data=data, headers={"Content-Type": "text/markdown"})
    check(status, body, f"put {path}")
    if VERIFY:
        status, body = request("GET", vault_url(path))
        if status != 200:
            raise EchoError(f"put {path}: write did not verify (GET returned {status})")
    print(f"ok: PUT {path}")
    return 0


def cmd_post(path: str, file_arg: str | None) -> int:
    data = read_body(file_arg)
    status, body = request("POST", vault_url(path), data=data, headers={"Content-Type": "text/markdown"})
    check(status, body, f"post {path}")
    print(f"ok: POST {path}")
    return 0


def cmd_append(path: str, line: str) -> int:
    status, body = request("GET", vault_url(path))
    if status == 200 and line.encode() in body:
        print(f"skip: line already present in {path}")
        return 0
    if status not in (200, 404):
        check(status, body, f"append(read) {path}")
    status, body = request("POST", vault_url(path), data=f"{line}\n".encode(), headers={"Content-Type": "text/markdown"})
    check(status, body, f"append {path}")
    print(f"ok: APPEND {path}")
    return 0


def cmd_patch(path: str, op: str, target_type: str, target: str, file_arg: str | None) -> int:
    if op not in {"append", "prepend", "replace"}:
        raise EchoError("patch: op must be append, prepend, or replace", 2)
    if target_type not in {"heading", "frontmatter", "block"}:
        raise EchoError("patch: target-type must be heading, frontmatter, or block", 2)
    data = normalize_patch_body(read_body(file_arg), op, target_type)
    status, body = request(
        "PATCH",
        vault_url(path),
        data=data,
        headers={
            "Operation": op,
            "Target-Type": target_type,
            "Target": target,
            "Content-Type": "application/json" if target_type == "frontmatter" else "text/markdown",
        },
    )
    check(status, body, f"patch {path} ({target})")
    print(f"ok: PATCH {op} {target_type} '{target}' -> {path}")
    return 0


def cmd_fm(path: str, field: str, value: str) -> int:
    return cmd_patch(path, "replace", "frontmatter", field, temp_file(normalize_json_scalar(value)))


def temp_file(data: bytes) -> str:
    fh = tempfile.NamedTemporaryFile(delete=False)
    try:
        fh.write(data)
        fh.close()
        return fh.name
    finally:
        try:
            fh.close()
        except Exception:
            pass


def cmd_delete(path: str) -> int:
    status, body = request("DELETE", vault_url(path))
    check(status, body, f"delete {path}")
    print(f"ok: DELETE {path}")
    return 0


def parse_lock_time(value: str) -> int:
    try:
        return int(dt.datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=dt.timezone.utc).timestamp())
    except Exception:
        return 0


def cmd_lock(owner: str) -> int:
    path = "_agent/locks/vault.lock"
    status, body = request("GET", vault_url(path))
    if status == 200 and body.strip():
        current = body.decode(errors="replace").strip()
        held_owner, _, held_iso = current.partition(" @ ")
        now_epoch = int(time.time())
        if held_owner != owner and now_epoch - parse_lock_time(held_iso) < LOCK_TTL:
            print(f"lock held by '{held_owner}' since {held_iso} (fresh)", file=sys.stderr)
            return 75
    status, body = request("PUT", vault_url(path), data=f"{owner} @ {now_iso()}\n".encode(), headers={"Content-Type": "text/markdown"})
    check(status, body, "lock")
    print(f"ok: locked by {owner}")
    return 0


def cmd_unlock(owner: str) -> int:
    path = "_agent/locks/vault.lock"
    status, body = request("GET", vault_url(path))
    if status == 200:
        held_owner = body.decode(errors="replace").split(" @ ", 1)[0]
        if held_owner != owner:
            print(f"lock owned by '{held_owner}', not '{owner}' - not releasing", file=sys.stderr)
            return 75
    status, body = request("DELETE", vault_url(path))
    if status != 404:
        check(status, body, "unlock")
    print("ok: unlocked")
    return 0


def extract_heading(markdown: str, heading: str) -> str:
    lines = markdown.splitlines()
    out: list[str] = []
    capture = False
    marker = f"## {heading}"
    for line in lines:
        if line.strip() == marker:
            capture = True
            continue
        if capture and line.startswith("## "):
            break
        if capture:
            out.append(line)
    return "\n".join(out).strip()


def cmd_scope(subcommand: str, text: str | None = None) -> int:
    path = "_agent/context/current-context.md"
    status, body = request("GET", vault_url(path))
    check(status, body, f"scope {subcommand}")
    current = body.decode(errors="replace")
    if subcommand == "show":
        scope = extract_heading(current, "Scope")
        print("-- Active scope --")
        print(scope)
        scope_updated = ""
        for line in current.splitlines():
            if line.startswith("scope_updated:"):
                scope_updated = line.split(":", 1)[1].strip().strip('"')
                break
        print(f"scope_updated: {scope_updated or '<missing - drift cannot be detected; run scope set or repair>'}")
        return 0
    if subcommand != "set":
        raise EchoError("scope: use show or set", 2)
    if not text:
        raise EchoError("scope set needs the new scope text", 2)
    prior = extract_heading(current, "Scope").replace("\n", " ").strip()[:140] or "(prior scope)"
    history_line = f"- {today()}: {prior}\n"
    cmd_patch(path, "prepend", "heading", "Current Context::Scope History", temp_file(history_line.encode()))
    cmd_patch(path, "replace", "heading", "Current Context::Scope", temp_file(f"{text}\n".encode()))
    cmd_patch(path, "replace", "frontmatter", "scope_updated", temp_file(json.dumps(today()).encode()))
    print(f"ok: scope switched (prior archived to Scope History; scope_updated={today()})")
    return 0


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="Validated ECHO vault client")
    sub = parser.add_subparsers(dest="cmd", required=True)
    sub.add_parser("get").add_argument("path")
    sub.add_parser("map").add_argument("path")
    sub.add_parser("ls").add_argument("path")
    sub.add_parser("search").add_argument("query", nargs="+")
    p = sub.add_parser("put"); p.add_argument("path"); p.add_argument("file", nargs="?")
    p = sub.add_parser("post"); p.add_argument("path"); p.add_argument("file", nargs="?")
    p = sub.add_parser("append"); p.add_argument("path"); p.add_argument("line")
    p = sub.add_parser("patch"); p.add_argument("path"); p.add_argument("op"); p.add_argument("target_type"); p.add_argument("target"); p.add_argument("file", nargs="?")
    p = sub.add_parser("fm"); p.add_argument("path"); p.add_argument("field"); p.add_argument("value")
    p = sub.add_parser("bump"); p.add_argument("path"); p.add_argument("date", nargs="?")
    sub.add_parser("delete").add_argument("path")
    sub.add_parser("lock").add_argument("owner")
    sub.add_parser("unlock").add_argument("owner")
    p = sub.add_parser("scope"); p.add_argument("subcommand", nargs="?", default="show", choices=["show", "set"]); p.add_argument("text", nargs="?")
    return parser


def main(argv: list[str] | None = None) -> int:
    args = build_parser().parse_args(argv)
    try:
        if args.cmd == "get":
            return cmd_get(args.path)
        if args.cmd == "map":
            return cmd_map(args.path)
        if args.cmd == "ls":
            return cmd_ls(args.path)
        if args.cmd == "search":
            return cmd_search(args.query)
        if args.cmd == "put":
            return cmd_put(args.path, args.file)
        if args.cmd == "post":
            return cmd_post(args.path, args.file)
        if args.cmd == "append":
            return cmd_append(args.path, args.line)
        if args.cmd == "patch":
            return cmd_patch(args.path, args.op, args.target_type, args.target, args.file)
        if args.cmd == "fm":
            return cmd_fm(args.path, args.field, args.value)
        if args.cmd == "bump":
            return cmd_fm(args.path, "updated", json.dumps(args.date or today()))
        if args.cmd == "delete":
            return cmd_delete(args.path)
        if args.cmd == "lock":
            return cmd_lock(args.owner)
        if args.cmd == "unlock":
            return cmd_unlock(args.owner)
        if args.cmd == "scope":
            return cmd_scope(args.subcommand, args.text)
    except EchoError as exc:
        print(f"echo.py: {exc}", file=sys.stderr)
        return exc.code
    return 2


if __name__ == "__main__":
    raise SystemExit(main())
