#!/usr/bin/env python3
from __future__ import annotations

import argparse
import difflib
import getpass
import hashlib
import json
import os
import re
import sys
import unicodedata
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any


DEFAULT_BASE_URL = ""
DEFAULT_TIMEOUT = 30.0
SCHEMA_VERSION = 1
USER_AGENT = f"outline-edit/{__import__('outline_edit').__version__}"
API_VERSION = "2"
MAX_PAGE_SIZE = 100


class KBError(RuntimeError):
    pass


class APIError(KBError):
    pass


@dataclass
class Config:
    base_url: str
    api_key: str | None
    cache_dir: Path
    timeout: float
    env_file: Path


def xdg_config_home() -> Path:
    raw = os.environ.get("XDG_CONFIG_HOME")
    if raw:
        return Path(raw).expanduser()
    return Path.home() / ".config"


def xdg_state_home() -> Path:
    raw = os.environ.get("XDG_STATE_HOME")
    if raw:
        return Path(raw).expanduser()
    return Path.home() / ".local" / "state"


DEFAULT_CONFIG_DIR = xdg_config_home() / "outline-edit"
DEFAULT_STATE_DIR = xdg_state_home() / "outline-edit"
DEFAULT_ENV_FILE = DEFAULT_CONFIG_DIR / "config.env"


def default_cache_dir(base_url: str) -> Path:
    if base_url:
        parsed = urllib.parse.urlparse(base_url)
        host = parsed.netloc or parsed.path or "default"
    else:
        host = "default"
    return DEFAULT_STATE_DIR / "cache" / slugify(host, "default")


def utc_now() -> str:
    return datetime.now(timezone.utc).replace(microsecond=0).isoformat()


def file_sha256(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as fh:
        for chunk in iter(lambda: fh.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()


def slugify(value: str, fallback: str) -> str:
    normalized = unicodedata.normalize("NFKD", value)
    ascii_value = normalized.encode("ascii", "ignore").decode("ascii")
    slug = re.sub(r"[^a-zA-Z0-9]+", "-", ascii_value).strip("-").lower()
    return slug or fallback


def read_env_file(path: Path) -> dict[str, str]:
    values: dict[str, str] = {}
    if not path.exists():
        return values

    for raw_line in path.read_text(encoding="utf-8").splitlines():
        line = raw_line.strip()
        if not line or line.startswith("#"):
            continue
        if line.startswith("export "):
            line = line[len("export ") :].strip()
        if "=" not in line:
            raise KBError(f"Invalid env line in {path}: {raw_line!r}")
        key, value = line.split("=", 1)
        key = key.strip()
        value = value.strip().strip("'").strip('"')
        values[key] = value
    return values


def parse_timeout(raw: float | str | None) -> float:
    if raw is None:
        return DEFAULT_TIMEOUT
    try:
        timeout = float(raw)
    except (TypeError, ValueError) as exc:
        raise KBError(f"Invalid timeout value: {raw!r}") from exc
    if timeout <= 0:
        raise KBError(f"Timeout must be positive, got {timeout!r}")
    return timeout


def render_config_template(
    *,
    base_url: str | None = None,
    api_key: str | None = None,
) -> str:
    resolved_base_url = (base_url or "https://your-outline.example.com").rstrip("/")
    resolved_api_key = api_key or "..."
    lines = [
        "# outline-edit configuration",
        "# Generated by `outline-edit init`.",
        "",
        f"OUTLINE_CLI_BASE_URL={resolved_base_url}",
        f"OUTLINE_CLI_API_KEY={resolved_api_key}",
        "",
        "# Optional overrides",
        "# OUTLINE_CLI_CACHE_DIR=/path/to/custom/cache",
        f"# OUTLINE_CLI_TIMEOUT={int(DEFAULT_TIMEOUT)}",
        "",
    ]
    return "\n".join(lines)


def missing_config_error(config: Config) -> KBError:
    missing = []
    if not config.base_url:
        missing.append("OUTLINE_CLI_BASE_URL")
    if not config.api_key:
        missing.append("OUTLINE_CLI_API_KEY")
    missing_fields = ", ".join(missing)
    return KBError(
        "Missing Outline configuration"
        f" ({missing_fields}).\n\n"
        f"Create {config.env_file} with:\n\n"
        "OUTLINE_CLI_BASE_URL=https://your-outline.example.com\n"
        "OUTLINE_CLI_API_KEY=...\n\n"
        "Then run:\n"
        "  outline-edit auth\n\n"
        "Or generate a starter config with:\n"
        "  outline-edit init\n"
        "  outline-edit init --interactive"
    )


def prompt_for_value(label: str, *, default: str | None = None, secret: bool = False) -> str:
    while True:
        suffix = f" [{default}]" if default else ""
        prompt = f"{label}{suffix}: "
        raw = getpass.getpass(prompt) if secret else input(prompt)
        value = raw.strip() or (default or "")
        if value:
            return value
        print("Value required.", file=sys.stderr)


def build_config(args: argparse.Namespace) -> Config:
    file_values = read_env_file(args.env_file) if args.env_file else {}
    env = {**file_values, **os.environ}

    base_url = (args.base_url or env.get("OUTLINE_CLI_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
    api_key = args.api_key or env.get("OUTLINE_CLI_API_KEY")
    cache_dir_raw = args.cache_dir or env.get("OUTLINE_CLI_CACHE_DIR")
    cache_dir = Path(cache_dir_raw).expanduser() if cache_dir_raw else default_cache_dir(base_url)
    timeout = parse_timeout(args.timeout if args.timeout is not None else env.get("OUTLINE_CLI_TIMEOUT"))

    return Config(
        base_url=base_url,
        api_key=api_key,
        cache_dir=cache_dir,
        timeout=timeout,
        env_file=args.env_file,
    )


class OutlineClient:
    def __init__(self, config: Config):
        if not config.base_url or not config.api_key:
            raise missing_config_error(config)
        self.base_url = config.base_url
        self.api_key = config.api_key
        self.timeout = config.timeout

    def post_json(
        self,
        endpoint: str,
        payload: dict[str, Any] | None = None,
        *,
        accept: str = "application/json",
    ) -> Any:
        body = json.dumps(payload or {}).encode("utf-8")
        request = urllib.request.Request(
            f"{self.base_url}/api/{endpoint}",
            data=body,
            method="POST",
            headers={
                "Authorization": f"Bearer {self.api_key}",
                "Accept": accept,
                "Content-Type": "application/json; charset=utf-8",
                "User-Agent": USER_AGENT,
                "x-api-version": API_VERSION,
            },
        )

        try:
            with urllib.request.urlopen(request, timeout=self.timeout) as response:
                content_type = response.headers.get("Content-Type", "")
                raw = response.read()
        except urllib.error.HTTPError as exc:
            detail = exc.read().decode("utf-8", errors="replace").strip()
            raise APIError(f"{endpoint} failed with HTTP {exc.code}: {detail}") from exc
        except urllib.error.URLError as exc:
            raise APIError(f"{endpoint} failed: {exc.reason}") from exc

        if "application/json" in content_type:
            return json.loads(raw.decode("utf-8"))
        return raw.decode("utf-8")

    def paginate(
        self,
        endpoint: str,
        payload: dict[str, Any] | None = None,
        *,
        limit: int | None = None,
        page_size: int = MAX_PAGE_SIZE,
    ) -> list[dict[str, Any]]:
        if page_size < 1 or page_size > MAX_PAGE_SIZE:
            raise KBError(f"page_size must be between 1 and {MAX_PAGE_SIZE}")

        results: list[dict[str, Any]] = []
        offset = 0

        while True:
            remaining = None if limit is None else max(limit - len(results), 0)
            if remaining == 0:
                break

            current_limit = page_size if remaining is None else min(page_size, remaining)
            request_body = dict(payload or {})
            request_body.update({"offset": offset, "limit": current_limit})
            response = self.post_json(endpoint, request_body)
            batch = response.get("data", [])
            if not isinstance(batch, list):
                raise KBError(f"{endpoint} returned an unexpected payload")

            results.extend(batch)
            if not batch:
                break

            pagination = response.get("pagination") or {}
            total = pagination.get("total")
            offset += len(batch)

            if total is not None and offset >= int(total):
                break
            if len(batch) < current_limit:
                break

        return results


def default_index(base_url: str) -> dict[str, Any]:
    return {
        "schemaVersion": SCHEMA_VERSION,
        "meta": {
            "baseUrl": base_url,
            "updatedAt": None,
        },
        "collections": {},
        "documents": {},
    }


def load_index(cache_dir: Path, base_url: str) -> dict[str, Any]:
    index_path = cache_dir / "index.json"
    if not index_path.exists():
        return default_index(base_url)

    data = json.loads(index_path.read_text(encoding="utf-8"))
    if data.get("schemaVersion") != SCHEMA_VERSION:
        raise KBError(
            f"Unsupported index schema version in {index_path}: {data.get('schemaVersion')}"
        )
    data.setdefault("meta", {})
    data.setdefault("collections", {})
    data.setdefault("documents", {})
    data["meta"]["baseUrl"] = base_url
    return data


def save_index(cache_dir: Path, index: dict[str, Any]) -> None:
    cache_dir.mkdir(parents=True, exist_ok=True)
    index["meta"]["updatedAt"] = utc_now()
    index_path = cache_dir / "index.json"
    index_path.write_text(json.dumps(index, indent=2, sort_keys=True) + "\n", encoding="utf-8")


def slim_user(user: Any) -> dict[str, str] | None:
    if not isinstance(user, dict):
        return None
    return {"id": str(user.get("id") or ""), "name": str(user.get("name") or "")}


def full_url(base_url: str, url_path: str | None) -> str | None:
    if not url_path:
        return None
    if url_path.startswith("http://") or url_path.startswith("https://"):
        return url_path
    return f"{base_url}{url_path}"


def snapshot_path(cache_dir: Path, doc_id: str) -> Path:
    return cache_dir / ".snapshots" / f"{doc_id}.md"


def write_snapshot(cache_dir: Path, doc_id: str, text: str) -> None:
    path = snapshot_path(cache_dir, doc_id)
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(text.rstrip() + "\n", encoding="utf-8")


def read_text_file(path: Path) -> str:
    return path.read_text(encoding="utf-8")


def ensure_clean_snapshot(entry: dict[str, Any], cache_dir: Path) -> bool:
    doc_id = entry.get("id")
    if not doc_id:
        return False
    state = current_state(entry, cache_dir)
    if state["missing"] or state["modified"]:
        return False
    snap = snapshot_path(cache_dir, doc_id)
    if snap.exists():
        return True
    path = state["path"]
    if path is None:
        return False
    write_snapshot(cache_dir, doc_id, read_text_file(path))
    return True


def generate_relative_path(doc_id: str, title: str, collection_name: str | None) -> str:
    collection_slug = slugify(collection_name or "unfiled", "unfiled")
    title_slug = slugify(title, "document")
    filename = f"{title_slug}--{doc_id[:8]}.md"
    return str(Path("collections") / collection_slug / filename)


def current_state(entry: dict[str, Any], cache_dir: Path) -> dict[str, Any]:
    relative_path = entry.get("path")
    sha256 = entry.get("sha256")
    absolute_path = cache_dir / relative_path if relative_path else None

    missing = absolute_path is None or not absolute_path.exists()
    current_hash = None if missing else file_sha256(absolute_path)
    modified = not missing and sha256 is not None and current_hash != sha256
    stale = entry.get("contentRevision") != entry.get("revision")

    return {
        "path": absolute_path,
        "missing": missing,
        "modified": modified,
        "stale": stale,
        "currentHash": current_hash,
    }


def lifecycle_state(entry: dict[str, Any]) -> str:
    if entry.get("deletedAt"):
        return "deleted"
    if entry.get("archivedAt"):
        return "archived"
    if entry.get("publishedAt"):
        return "published"
    return "draft"


def update_collections(
    index: dict[str, Any], collections: list[dict[str, Any]]
) -> dict[str, dict[str, Any]]:
    lookup: dict[str, dict[str, Any]] = {}
    for collection in collections:
        collection_id = collection["id"]
        record = {
            "id": collection_id,
            "name": collection.get("name"),
            "description": collection.get("description"),
            "icon": collection.get("icon"),
            "color": collection.get("color"),
        }
        index["collections"][collection_id] = record
        lookup[collection_id] = record
    return lookup


def match_collection(
    collections: list[dict[str, Any]], selector: str
) -> dict[str, Any]:
    lowered = selector.casefold()
    exact = [
        collection
        for collection in collections
        if collection["id"] == selector or str(collection.get("name", "")).casefold() == lowered
    ]
    if len(exact) == 1:
        return exact[0]
    if len(exact) > 1:
        raise KBError(f"Collection selector {selector!r} is ambiguous")

    fuzzy = [
        collection
        for collection in collections
        if selector in collection["id"] or lowered in str(collection.get("name", "")).casefold()
    ]
    if len(fuzzy) == 1:
        return fuzzy[0]
    if not fuzzy:
        raise KBError(f"No collection matches {selector!r}")
    raise KBError(
        "Collection selector is ambiguous: "
        + ", ".join(f"{item['name']} ({item['id']})" for item in fuzzy[:5])
    )


def fetch_all_collections(client: OutlineClient) -> list[dict[str, Any]]:
    return client.paginate("collections.list")


def get_collection_values(index: dict[str, Any]) -> list[dict[str, Any]]:
    return list(index.get("collections", {}).values())


def resolve_collection_selector(
    index: dict[str, Any], client: OutlineClient, selector: str
) -> dict[str, Any]:
    collections = get_collection_values(index)
    try:
        return match_collection(collections, selector)
    except KBError:
        remote = fetch_all_collections(client)
        update_collections(index, remote)
        return match_collection(remote, selector)


def fetch_documents(
    client: OutlineClient,
    *,
    query: str | None,
    collection_id: str | None,
    document_id: str | None,
    limit: int | None,
    page_size: int,
    status_filter: list[str] | None,
) -> list[dict[str, Any]]:
    if document_id:
        response = client.post_json("documents.info", {"id": document_id})
        document = (response.get("data") or {}).get("document")
        if not document:
            raise KBError(f"documents.info returned no document for {document_id}")
        return [document]

    payload: dict[str, Any] = {}
    if collection_id:
        payload["collectionId"] = collection_id
    if status_filter:
        payload["statusFilter"] = status_filter

    if query:
        payload["query"] = query
        return client.paginate(
            "documents.search_titles",
            payload,
            limit=limit,
            page_size=page_size,
        )

    return client.paginate(
        "documents.list",
        payload,
        limit=limit,
        page_size=page_size,
    )


def export_document_markdown(client: OutlineClient, document_id: str) -> str:
    response = client.post_json("documents.export", {"id": document_id})
    data = response.get("data")
    if not isinstance(data, str):
        raise KBError(f"documents.export returned an unexpected payload for {document_id}")
    return data


def export_revision_markdown(client: OutlineClient, revision_id: str) -> str:
    response = client.post_json("revisions.export", {"id": revision_id})
    data = response.get("data")
    if not isinstance(data, str):
        raise KBError(f"revisions.export returned an unexpected payload for {revision_id}")
    return data


def upsert_document(
    index: dict[str, Any],
    cache_dir: Path,
    base_url: str,
    document: dict[str, Any],
    collection_lookup: dict[str, dict[str, Any]],
    *,
    metadata_only: bool,
    force: bool,
) -> str:
    doc_id = document["id"]
    title = document.get("title") or "Untitled"
    collection_id = document.get("collectionId")
    collection_name = None
    if collection_id:
        collection_name = (collection_lookup.get(collection_id) or {}).get("name")

    existing = index["documents"].get(doc_id, {})
    relative_path = existing.get("path") or generate_relative_path(doc_id, title, collection_name)
    state = current_state(existing, cache_dir) if existing else {
        "path": cache_dir / relative_path,
        "missing": True,
        "modified": False,
        "stale": False,
        "currentHash": None,
    }

    now = utc_now()
    entry = {
        "id": doc_id,
        "title": title,
        "url": full_url(base_url, document.get("url")),
        "urlPath": document.get("url"),
        "urlId": document.get("urlId"),
        "path": relative_path,
        "collectionId": collection_id,
        "collectionName": collection_name,
        "parentDocumentId": document.get("parentDocumentId"),
        "revision": document.get("revision"),
        "contentRevision": existing.get("contentRevision"),
        "createdAt": document.get("createdAt"),
        "updatedAt": document.get("updatedAt"),
        "publishedAt": document.get("publishedAt"),
        "archivedAt": document.get("archivedAt"),
        "deletedAt": document.get("deletedAt"),
        "createdBy": slim_user(document.get("createdBy")),
        "updatedBy": slim_user(document.get("updatedBy")),
        "collaboratorIds": document.get("collaboratorIds") or [],
        "sha256": existing.get("sha256"),
        "metadataSyncedAt": now,
        "contentSyncedAt": existing.get("contentSyncedAt"),
        "lastPullMode": "metadata-only" if metadata_only else "content",
    }

    if metadata_only:
        index["documents"][doc_id] = entry
        return "metadata_only"

    if state["modified"] and not force:
        return "skipped_modified"

    text = document.get("text")
    if not isinstance(text, str):
        raise KBError(
            f"Document {doc_id} did not include markdown text. Check API version handling."
        )

    absolute_path = cache_dir / relative_path
    absolute_path.parent.mkdir(parents=True, exist_ok=True)
    absolute_path.write_text(text.rstrip() + "\n", encoding="utf-8")
    write_snapshot(cache_dir, doc_id, text)
    sha256 = file_sha256(absolute_path)
    entry["sha256"] = sha256
    entry["contentRevision"] = document.get("revision")
    entry["contentSyncedAt"] = now
    index["documents"][doc_id] = entry

    return "updated_existing" if existing else "created"


def resolve_documents_from_index(
    index: dict[str, Any],
    selector: str | None = None,
    *,
    collection: str | None = None,
    modified_only: bool = False,
    stale_only: bool = False,
    missing_only: bool = False,
    cache_dir: Path,
) -> list[dict[str, Any]]:
    documents = list(index["documents"].values())
    if collection:
        lowered = collection.casefold()
        documents = [
            document
            for document in documents
            if lowered in str(document.get("collectionName") or "").casefold()
            or collection in str(document.get("collectionId") or "")
        ]

    if selector:
        lowered = selector.casefold()
        documents = [
            document
            for document in documents
            if selector in document.get("id", "")
            or lowered in str(document.get("title") or "").casefold()
            or lowered in str(document.get("path") or "").casefold()
        ]

    filtered: list[dict[str, Any]] = []
    for document in documents:
        state = current_state(document, cache_dir)
        if modified_only and not state["modified"]:
            continue
        if stale_only and not state["stale"]:
            continue
        if missing_only and not state["missing"]:
            continue
        merged = dict(document)
        merged.update(
            {
                "absolutePath": str(state["path"]) if state["path"] else None,
                "missing": state["missing"],
                "modified": state["modified"],
                "stale": state["stale"],
                "lifecycleState": lifecycle_state(document),
            }
        )
        filtered.append(merged)

    filtered.sort(
        key=lambda item: (
            str(item.get("collectionName") or ""),
            str(item.get("title") or ""),
            str(item.get("id") or ""),
        )
    )
    return filtered


def resolve_single_document(
    index: dict[str, Any], selector: str, *, cache_dir: Path
) -> dict[str, Any]:
    matches = resolve_documents_from_index(index, selector, cache_dir=cache_dir)
    if not matches:
        raise KBError(f"No cached document matches {selector!r}")
    if len(matches) > 1:
        sample = ", ".join(f"{item['title']} ({item['id']})" for item in matches[:5])
        raise KBError(f"Document selector is ambiguous: {sample}")
    return matches[0]


def parse_status_filter(raw: str | None) -> list[str] | None:
    if raw is None:
        return None
    values = [value.strip() for value in raw.split(",") if value.strip()]
    allowed = {"published", "draft", "archived"}
    invalid = [value for value in values if value not in allowed]
    if invalid:
        raise KBError(f"Invalid status values: {', '.join(invalid)}")
    return values or None


def parse_csv(raw: str | None) -> list[str] | None:
    if raw is None:
        return None
    values = [value.strip() for value in raw.split(",") if value.strip()]
    return values or None


def build_unified_diff(
    before: str,
    after: str,
    *,
    fromfile: str,
    tofile: str,
) -> str:
    return "".join(
        difflib.unified_diff(
            before.splitlines(keepends=True),
            after.splitlines(keepends=True),
            fromfile=fromfile,
            tofile=tofile,
        )
    )


def search_index(
    index: dict[str, Any],
    cache_dir: Path,
    query: str,
    *,
    collection: str | None = None,
    title_only: bool = False,
    limit: int | None = None,
) -> list[dict[str, Any]]:
    needle = query.casefold()
    documents = resolve_documents_from_index(
        index,
        collection=collection,
        cache_dir=cache_dir,
    )

    results: list[dict[str, Any]] = []
    for document in documents:
        title = str(document.get("title") or "")
        collection_name = str(document.get("collectionName") or "")
        relative_path = str(document.get("path") or "")

        title_hit = needle in title.casefold()
        collection_hit = needle in collection_name.casefold()
        path_hit = needle in relative_path.casefold()
        line_hits: list[dict[str, Any]] = []

        if not title_only and not document.get("missing"):
            absolute_path = Path(document["absolutePath"])
            for idx, line in enumerate(read_text_file(absolute_path).splitlines(), start=1):
                if needle in line.casefold():
                    line_hits.append({"line": idx, "text": line})
                    if len(line_hits) >= 3:
                        break

        if not (title_hit or collection_hit or path_hit or line_hits):
            continue

        score = 0
        if title_hit:
            score += 4
        if collection_hit:
            score += 2
        if path_hit:
            score += 1
        score += len(line_hits)

        results.append(
            {
                **document,
                "score": score,
                "titleHit": title_hit,
                "collectionHit": collection_hit,
                "pathHit": path_hit,
                "lineHits": line_hits,
            }
        )

    results.sort(
        key=lambda item: (
            -int(item["score"]),
            str(item.get("title") or "").casefold(),
            str(item.get("id") or ""),
        )
    )
    if limit is not None:
        results = results[:limit]
    return results


def update_cached_document_from_remote(
    index: dict[str, Any],
    config: Config,
    document: dict[str, Any],
    *,
    metadata_only: bool,
    force: bool,
) -> dict[str, Any]:
    collection_lookup = index.get("collections", {})
    upsert_document(
        index,
        config.cache_dir,
        config.base_url,
        document,
        collection_lookup,
        metadata_only=metadata_only,
        force=force,
    )
    return index["documents"][document["id"]]


def mark_content_in_sync(
    entry: dict[str, Any], *, update_timestamp: bool = True
) -> dict[str, Any]:
    entry["contentRevision"] = entry.get("revision")
    if update_timestamp:
        entry["contentSyncedAt"] = utc_now()
    return entry


def push_single_document(
    index: dict[str, Any],
    config: Config,
    client: OutlineClient,
    entry: dict[str, Any],
    *,
    publish: bool = False,
    publish_collection_id: str | None = None,
) -> dict[str, Any]:
    state = current_state(entry, config.cache_dir)
    path = state["path"]

    if state["missing"] or path is None:
        return {
            "status": "skipped",
            "reason": "missing",
            "id": entry["id"],
            "title": entry["title"],
        }

    local_text = read_text_file(path)
    remote_response = client.post_json("documents.info", {"id": entry["id"]})
    remote_document = (remote_response.get("data") or {}).get("document")
    if not remote_document:
        raise KBError(f"documents.info returned no document for {entry['id']}")

    update_cached_document_from_remote(
        index,
        config,
        remote_document,
        metadata_only=True,
        force=False,
    )

    if remote_document.get("revision") != entry.get("contentRevision"):
        return {
            "status": "conflict",
            "id": entry["id"],
            "title": entry["title"],
            "localBaseRevision": entry.get("contentRevision"),
            "remoteRevision": remote_document.get("revision"),
            "remoteUpdatedAt": remote_document.get("updatedAt"),
            "remoteUpdatedBy": slim_user(remote_document.get("updatedBy")),
            "url": full_url(config.base_url, remote_document.get("url")),
        }

    if not state["modified"] and not publish:
        return {
            "status": "skipped",
            "reason": "unmodified",
            "id": entry["id"],
            "title": entry["title"],
        }

    payload: dict[str, Any] = {
        "id": entry["id"],
        "text": local_text,
        "editMode": "replace",
    }
    if publish:
        payload["publish"] = True
        if publish_collection_id:
            payload["collectionId"] = publish_collection_id

    update_response = client.post_json("documents.update", payload)
    updated_document = update_response.get("data")
    if not isinstance(updated_document, dict):
        raise KBError(f"documents.update returned an unexpected payload for {entry['id']}")

    pushed_entry = update_cached_document_from_remote(
        index,
        config,
        updated_document,
        metadata_only=False,
        force=True,
    )
    return {
        "status": "updated",
        "id": pushed_entry["id"],
        "title": pushed_entry["title"],
        "revision": pushed_entry.get("revision"),
        "url": pushed_entry.get("url"),
        "path": str(config.cache_dir / pushed_entry["path"]),
        "publishedAt": pushed_entry.get("publishedAt"),
    }


def print_json(data: Any) -> None:
    print(json.dumps(data, indent=2, sort_keys=True))


def add_runtime_args(
    parser: argparse.ArgumentParser,
    *,
    include_json: bool,
    suppress_defaults: bool = False,
) -> None:
    default = argparse.SUPPRESS if suppress_defaults else None
    env_file_default = argparse.SUPPRESS if suppress_defaults else DEFAULT_ENV_FILE
    json_default = argparse.SUPPRESS if suppress_defaults else False
    parser.add_argument(
        "--base-url",
        default=default,
        help="Outline base URL. Defaults to OUTLINE_CLI_BASE_URL.",
    )
    parser.add_argument(
        "--api-key",
        default=default,
        help="Outline API key. Defaults to OUTLINE_CLI_API_KEY.",
    )
    parser.add_argument(
        "--cache-dir",
        default=default,
        help="Cache directory. Defaults to OUTLINE_CLI_CACHE_DIR or an XDG state path scoped by Outline host.",
    )
    parser.add_argument(
        "--env-file",
        type=Path,
        default=env_file_default,
        help="Optional env file to preload. Defaults to the XDG config path for outline-edit.",
    )
    parser.add_argument("--timeout", type=float, default=default, help="HTTP timeout in seconds")
    if include_json:
        parser.add_argument(
            "--json",
            action="store_true",
            default=json_default,
            help="Emit machine-readable JSON",
        )


def command_init(args: argparse.Namespace, config: Config) -> int:
    env_file = config.env_file
    existed = env_file.exists()
    if existed and not args.force:
        raise KBError(f"Config file already exists: {env_file}. Use --force to overwrite it.")

    base_url = config.base_url or None
    api_key = config.api_key or None
    if args.interactive:
        if not sys.stdin.isatty() or not sys.stdout.isatty():
            raise KBError("--interactive requires a TTY")
        base_url = prompt_for_value("Outline base URL", default=base_url).rstrip("/")
        api_key = prompt_for_value("Outline API key", default=api_key, secret=True)

    env_file.parent.mkdir(parents=True, exist_ok=True)
    env_file.write_text(
        render_config_template(base_url=base_url, api_key=api_key),
        encoding="utf-8",
    )
    try:
        env_file.chmod(0o600)
    except OSError:
        pass

    payload = {
        "status": "updated" if existed else "created",
        "envFile": str(env_file),
        "interactive": args.interactive,
        "hasBaseUrl": bool(base_url),
        "hasApiKey": bool(api_key),
    }
    if args.json:
        print_json(payload)
        return 0

    action = "Updated" if existed else "Created"
    print(f"{action} config file: {env_file}")
    if args.interactive:
        print("Saved provided Outline credentials.")
    else:
        print("Wrote a starter template. Fill in the required values if needed.")
    print("Next step: outline-edit auth")
    return 0


def command_auth(args: argparse.Namespace, config: Config) -> int:
    client = OutlineClient(config)
    response = client.post_json("auth.info")
    data = response.get("data", {})
    user = data.get("user", {})
    team = data.get("team", {})

    if args.json:
        print_json(
            {
                "baseUrl": config.base_url,
                "user": user,
                "team": team,
            }
        )
        return 0

    print(f"base_url: {config.base_url}")
    print(f"user: {user.get('name')} ({user.get('email')})")
    print(f"team: {team.get('name')} ({team.get('id')})")
    return 0


def command_pull(args: argparse.Namespace, config: Config) -> int:
    client = OutlineClient(config)
    index = load_index(config.cache_dir, config.base_url)
    collections = fetch_all_collections(client)
    collection_lookup = update_collections(index, collections)

    collection_id = None
    resolved_collection = None
    if args.collection:
        resolved_collection = match_collection(collections, args.collection)
        collection_id = resolved_collection["id"]

    documents = fetch_documents(
        client,
        query=args.query,
        collection_id=collection_id,
        document_id=args.document_id,
        limit=args.limit,
        page_size=args.page_size,
        status_filter=parse_status_filter(args.status),
    )

    stats = {
        "fetched": len(documents),
        "created": 0,
        "updated_existing": 0,
        "metadata_only": 0,
        "skipped_modified": 0,
    }

    touched: list[dict[str, Any]] = []
    for document in documents:
        result = upsert_document(
            index,
            config.cache_dir,
            config.base_url,
            document,
            collection_lookup,
            metadata_only=args.metadata_only,
            force=args.force,
        )
        stats[result] = stats.get(result, 0) + 1
        if result != "skipped_modified":
            touched.append(index["documents"].get(document["id"], {"id": document["id"]}))

    save_index(config.cache_dir, index)

    payload = {
        "baseUrl": config.base_url,
        "cacheDir": str(config.cache_dir),
        "collection": resolved_collection,
        "query": args.query,
        "documentId": args.document_id,
        "metadataOnly": args.metadata_only,
        "force": args.force,
        "stats": stats,
        "documents": touched,
    }

    if args.json:
        print_json(payload)
        return 0

    print(f"cache_dir: {config.cache_dir}")
    if resolved_collection:
        print(f"collection: {resolved_collection['name']} ({resolved_collection['id']})")
    if args.query:
        print(f"query: {args.query}")
    if args.document_id:
        print(f"document_id: {args.document_id}")
    print(
        "results:"
        f" fetched={stats['fetched']}"
        f" created={stats['created']}"
        f" updated={stats['updated_existing']}"
        f" metadata_only={stats['metadata_only']}"
        f" skipped_modified={stats['skipped_modified']}"
    )
    return 0


def command_status(args: argparse.Namespace, config: Config) -> int:
    index = load_index(config.cache_dir, config.base_url)
    documents = resolve_documents_from_index(
        index,
        args.selector,
        collection=args.collection,
        modified_only=args.modified,
        stale_only=args.stale,
        missing_only=args.missing,
        cache_dir=config.cache_dir,
    )

    summary = {
        "total": len(index["documents"]),
        "shown": len(documents),
        "modified": sum(1 for document in documents if document["modified"]),
        "stale": sum(1 for document in documents if document["stale"]),
        "missing": sum(1 for document in documents if document["missing"]),
    }

    payload = {
        "cacheDir": str(config.cache_dir),
        "summary": summary,
        "documents": documents,
    }

    if args.json:
        print_json(payload)
        return 0

    print(f"cache_dir: {config.cache_dir}")
    print(
        "summary:"
        f" total={summary['total']}"
        f" shown={summary['shown']}"
        f" modified={summary['modified']}"
        f" stale={summary['stale']}"
        f" missing={summary['missing']}"
    )
    for document in documents:
        flags = []
        if document["modified"]:
            flags.append("modified")
        if document["stale"]:
            flags.append("stale")
        if document["missing"]:
            flags.append("missing")
        suffix = f" [{' '.join(flags)}]" if flags else ""
        print(
            f"{document['id'][:8]}  {document.get('collectionName') or '-'} / {document.get('title')} ({document['lifecycleState']}){suffix}"
        )
        print(f"  path: {document.get('absolutePath')}")
    return 0


def command_list(args: argparse.Namespace, config: Config) -> int:
    index = load_index(config.cache_dir, config.base_url)
    documents = resolve_documents_from_index(
        index,
        args.selector,
        collection=args.collection,
        cache_dir=config.cache_dir,
    )
    if args.limit is not None:
        documents = documents[: args.limit]

    if args.json:
        print_json({"documents": documents})
        return 0

    for document in documents:
        flags = []
        if document["modified"]:
            flags.append("modified")
        if document["stale"]:
            flags.append("stale")
        if document["missing"]:
            flags.append("missing")
        suffix = f" [{' '.join(flags)}]" if flags else ""
        print(
            f"{document['id'][:8]}  {document.get('collectionName') or '-'} / {document.get('title')} ({document['lifecycleState']}){suffix}"
        )
        print(
            f"  rev: {document.get('contentRevision')} -> {document.get('revision')}  updated: {document.get('updatedAt')}"
        )
        print(f"  path: {document.get('absolutePath')}")
    return 0


def command_read(args: argparse.Namespace, config: Config) -> int:
    index = load_index(config.cache_dir, config.base_url)
    document = resolve_single_document(index, args.selector, cache_dir=config.cache_dir)
    path = Path(document["absolutePath"])
    if not path.exists():
        raise KBError(f"Cached file is missing: {path}")

    if args.path:
        print(path)
        return 0

    content = path.read_text(encoding="utf-8")
    if args.json:
        print_json(
            {
                "id": document["id"],
                "title": document["title"],
                "path": str(path),
                "content": content,
            }
        )
        return 0

    sys.stdout.write(content)
    if not content.endswith("\n"):
        sys.stdout.write("\n")
    return 0


def command_search(args: argparse.Namespace, config: Config) -> int:
    index = load_index(config.cache_dir, config.base_url)
    results = search_index(
        index,
        config.cache_dir,
        args.query,
        collection=args.collection,
        title_only=args.title_only,
        limit=args.limit,
    )

    if args.json:
        print_json({"query": args.query, "results": results})
        return 0

    for result in results:
        flags = []
        if result["titleHit"]:
            flags.append("title")
        if result["collectionHit"]:
            flags.append("collection")
        if result["pathHit"]:
            flags.append("path")
        if result["lineHits"]:
            flags.append("content")
        print(
            f"{result['id'][:8]}  {result.get('collectionName') or '-'} / {result.get('title')} [{', '.join(flags)}]"
        )
        print(f"  path: {result.get('absolutePath')}")
        for hit in result["lineHits"]:
            print(f"  L{hit['line']}: {hit['text']}")
    return 0


def command_diff(args: argparse.Namespace, config: Config) -> int:
    index = load_index(config.cache_dir, config.base_url)
    document = resolve_single_document(index, args.selector, cache_dir=config.cache_dir)
    absolute_path = Path(document["absolutePath"])
    if not absolute_path.exists():
        raise KBError(f"Cached file is missing: {absolute_path}")

    current_text = read_text_file(absolute_path)
    basis = "snapshot"
    snap = snapshot_path(config.cache_dir, document["id"])

    if snap.exists():
        baseline_text = read_text_file(snap)
        baseline_name = f"{document['title']} (snapshot)"
    else:
        if ensure_clean_snapshot(document, config.cache_dir):
            baseline_text = read_text_file(snapshot_path(config.cache_dir, document["id"]))
            baseline_name = f"{document['title']} (snapshot)"
        else:
            client = OutlineClient(config)
            baseline_text = export_document_markdown(client, document["id"])
            baseline_name = f"{document['title']} (remote)"
            basis = "remote"

    diff_text = build_unified_diff(
        baseline_text,
        current_text,
        fromfile=baseline_name,
        tofile=f"{document['title']} (local)",
    )

    if args.json:
        print_json(
            {
                "id": document["id"],
                "title": document["title"],
                "basis": basis,
                "path": str(absolute_path),
                "diff": diff_text,
            }
        )
        return 0

    if not diff_text:
        print(f"No diff for {document['title']} ({basis} basis).")
        return 0

    sys.stdout.write(diff_text)
    if not diff_text.endswith("\n"):
        sys.stdout.write("\n")
    return 0


def command_push(args: argparse.Namespace, config: Config) -> int:
    index = load_index(config.cache_dir, config.base_url)
    if args.selector:
        targets = [resolve_single_document(index, args.selector, cache_dir=config.cache_dir)]
    else:
        targets = resolve_documents_from_index(
            index,
            collection=args.collection,
            modified_only=True,
            cache_dir=config.cache_dir,
        )

    if not targets:
        payload = {"pushed": [], "conflicts": [], "skipped": [], "updated": 0}
        if args.json:
            print_json(payload)
        else:
            print("No locally modified cached documents to push.")
        return 0

    client = OutlineClient(config)
    pushed: list[dict[str, Any]] = []
    conflicts: list[dict[str, Any]] = []
    skipped: list[dict[str, Any]] = []

    for document in targets:
        entry = index["documents"].get(document["id"], document)
        if entry.get("contentRevision") is None:
            skipped.append(
                {
                    "id": entry["id"],
                    "title": entry["title"],
                    "reason": "no_local_base",
                }
            )
            continue

        result = push_single_document(index, config, client, entry)
        if result["status"] == "updated":
            pushed.append(result)
        elif result["status"] == "conflict":
            conflicts.append(result)
        else:
            skipped.append(result)

    save_index(config.cache_dir, index)

    payload = {
        "updated": len(pushed),
        "pushed": pushed,
        "conflicts": conflicts,
        "skipped": skipped,
    }

    if args.json:
        print_json(payload)
        return 0

    print(
        f"push results: updated={len(pushed)} conflicts={len(conflicts)} skipped={len(skipped)}"
    )
    for item in pushed:
        print(f"updated: {item['title']} -> rev {item['revision']}")
        print(f"  url: {item['url']}")
    for item in conflicts:
        who = (item.get("remoteUpdatedBy") or {}).get("name")
        print(
            f"conflict: {item['title']} local_base={item['localBaseRevision']} remote={item['remoteRevision']}"
        )
        print(f"  remote_updated: {item['remoteUpdatedAt']} by {who}")
    for item in skipped:
        print(f"skipped: {item['title']} ({item['reason']})")
    return 0


def command_create(args: argparse.Namespace, config: Config) -> int:
    client = OutlineClient(config)
    index = load_index(config.cache_dir, config.base_url)

    payload: dict[str, Any] = {
        "title": args.title,
        "publish": args.publish,
    }
    if args.text is not None:
        payload["text"] = args.text
    if args.file is not None:
        payload["text"] = read_text_file(Path(args.file))
    if args.template_id:
        payload["templateId"] = args.template_id
    if args.full_width:
        payload["fullWidth"] = True

    if args.parent:
        parent = resolve_single_document(index, args.parent, cache_dir=config.cache_dir)
        payload["parentDocumentId"] = parent["id"]
    elif args.collection:
        collection = resolve_collection_selector(index, client, args.collection)
        payload["collectionId"] = collection["id"]

    response = client.post_json("documents.create", payload)
    created = response.get("data")
    if not isinstance(created, dict):
        raise KBError("documents.create returned an unexpected payload")

    entry = update_cached_document_from_remote(
        index,
        config,
        created,
        metadata_only=False,
        force=True,
    )
    save_index(config.cache_dir, index)

    output = {
        "id": entry["id"],
        "title": entry["title"],
        "url": entry.get("url"),
        "path": str(config.cache_dir / entry["path"]),
        "publishedAt": entry.get("publishedAt"),
        "collectionId": entry.get("collectionId"),
        "collectionName": entry.get("collectionName"),
    }

    if args.json:
        print_json(output)
        return 0

    state = "published" if entry.get("publishedAt") else "draft"
    print(f"created {state}: {entry['title']}")
    print(f"  url: {entry.get('url')}")
    print(f"  path: {config.cache_dir / entry['path']}")
    return 0


def command_publish(args: argparse.Namespace, config: Config) -> int:
    index = load_index(config.cache_dir, config.base_url)
    entry = resolve_single_document(index, args.selector, cache_dir=config.cache_dir)
    client = OutlineClient(config)

    publish_collection_id = None
    if args.collection:
        collection = resolve_collection_selector(index, client, args.collection)
        publish_collection_id = collection["id"]

    result = push_single_document(
        index,
        config,
        client,
        entry,
        publish=True,
        publish_collection_id=publish_collection_id,
    )
    save_index(config.cache_dir, index)

    if args.json:
        print_json(result)
        return 0

    if result["status"] == "updated":
        print(f"published: {result['title']}")
        print(f"  url: {result.get('url')}")
        print(f"  publishedAt: {result.get('publishedAt')}")
        return 0

    if result["status"] == "conflict":
        who = (result.get("remoteUpdatedBy") or {}).get("name")
        print(
            f"conflict: {result['title']} local_base={result['localBaseRevision']} remote={result['remoteRevision']}"
        )
        print(f"  remote_updated: {result['remoteUpdatedAt']} by {who}")
        return 1

    print(f"skipped: {result['title']} ({result['reason']})")
    return 0


def command_archive(args: argparse.Namespace, config: Config) -> int:
    index = load_index(config.cache_dir, config.base_url)
    entry = resolve_single_document(index, args.selector, cache_dir=config.cache_dir)
    state = current_state(entry, config.cache_dir)
    client = OutlineClient(config)

    response = client.post_json("documents.archive", {"id": entry["id"]})
    archived = response.get("data")
    if not isinstance(archived, dict):
        raise KBError("documents.archive returned an unexpected payload")

    refreshed = update_cached_document_from_remote(
        index,
        config,
        archived,
        metadata_only=not state["missing"],
        force=state["missing"],
    )
    if not state["missing"]:
        refreshed = mark_content_in_sync(refreshed, update_timestamp=not state["modified"])
    save_index(config.cache_dir, index)

    output = {
        "status": "archived",
        "id": refreshed["id"],
        "title": refreshed["title"],
        "url": refreshed.get("url"),
        "archivedAt": refreshed.get("archivedAt"),
        "path": str(config.cache_dir / refreshed["path"]),
        "lifecycleState": lifecycle_state(refreshed),
    }

    if args.json:
        print_json(output)
        return 0

    print(f"archived: {refreshed['title']}")
    print(f"  url: {refreshed.get('url')}")
    print(f"  archivedAt: {refreshed.get('archivedAt')}")
    return 0


def command_restore(args: argparse.Namespace, config: Config) -> int:
    index = load_index(config.cache_dir, config.base_url)
    entry = resolve_single_document(index, args.selector, cache_dir=config.cache_dir)
    state = current_state(entry, config.cache_dir)
    client = OutlineClient(config)

    if args.revision_id and state["modified"] and not args.force:
        raise KBError(
            "Local cached file has unsynced changes; use push, revert the file, or rerun restore with --force."
        )

    payload: dict[str, Any] = {"id": entry["id"]}
    if args.collection:
        collection = resolve_collection_selector(index, client, args.collection)
        payload["collectionId"] = collection["id"]
    if args.revision_id:
        payload["revisionId"] = args.revision_id

    response = client.post_json("documents.restore", payload)
    restored = response.get("data")
    if not isinstance(restored, dict):
        raise KBError("documents.restore returned an unexpected payload")

    refresh_content = bool(args.revision_id or state["missing"])
    refreshed = update_cached_document_from_remote(
        index,
        config,
        restored,
        metadata_only=not refresh_content,
        force=bool(args.force or state["missing"]),
    )
    if not refresh_content:
        refreshed = mark_content_in_sync(refreshed, update_timestamp=not state["modified"])
    save_index(config.cache_dir, index)

    output = {
        "status": "restored",
        "id": refreshed["id"],
        "title": refreshed["title"],
        "url": refreshed.get("url"),
        "revision": refreshed.get("revision"),
        "publishedAt": refreshed.get("publishedAt"),
        "archivedAt": refreshed.get("archivedAt"),
        "deletedAt": refreshed.get("deletedAt"),
        "path": str(config.cache_dir / refreshed["path"]),
        "lifecycleState": lifecycle_state(refreshed),
        "revisionId": args.revision_id,
    }

    if args.json:
        print_json(output)
        return 0

    print(f"restored: {refreshed['title']}")
    print(f"  url: {refreshed.get('url')}")
    print(f"  state: {lifecycle_state(refreshed)}")
    if args.revision_id:
        print(f"  revision: {args.revision_id}")
    return 0


def command_delete(args: argparse.Namespace, config: Config) -> int:
    index = load_index(config.cache_dir, config.base_url)
    entry = resolve_single_document(index, args.selector, cache_dir=config.cache_dir)
    client = OutlineClient(config)

    response = client.post_json(
        "documents.delete",
        {"id": entry["id"], "permanent": args.permanent},
    )
    if not response.get("success"):
        raise KBError("documents.delete returned an unexpected payload")

    now = utc_now()
    output = {
        "status": "permanent_deleted" if args.permanent else "deleted",
        "id": entry["id"],
        "title": entry["title"],
        "url": entry.get("url"),
        "path": str(config.cache_dir / entry["path"]),
        "deletedAt": now,
        "permanent": args.permanent,
    }

    if args.permanent:
        index["documents"].pop(entry["id"], None)
    else:
        deleted_entry = dict(index["documents"].get(entry["id"], entry))
        deleted_entry["deletedAt"] = now
        deleted_entry["metadataSyncedAt"] = now
        index["documents"][entry["id"]] = deleted_entry

    save_index(config.cache_dir, index)

    if args.json:
        print_json(output)
        return 0

    action = "permanently deleted" if args.permanent else "deleted"
    print(f"{action}: {entry['title']}")
    print(f"  url: {entry.get('url')}")
    print(f"  cached_path: {config.cache_dir / entry['path']}")
    return 0


def resolve_revdiff_source(
    client: OutlineClient,
    document: dict[str, Any],
    selector: str,
    recent_revisions: list[dict[str, Any]],
) -> dict[str, Any]:
    lowered = selector.casefold()
    if lowered in {"current", "head"}:
        return {
            "id": None,
            "label": "current",
            "text": export_document_markdown(client, document["id"]),
        }
    if lowered == "latest":
        if not recent_revisions:
            raise KBError(f"No revisions found for {document['title']}")
        revision = recent_revisions[0]
        return {
            "id": revision["id"],
            "label": f"latest:{revision['id']}",
            "text": export_revision_markdown(client, revision["id"]),
        }
    if lowered == "previous":
        if len(recent_revisions) < 2:
            raise KBError(f"No previous revision found for {document['title']}")
        revision = recent_revisions[1]
        return {
            "id": revision["id"],
            "label": f"previous:{revision['id']}",
            "text": export_revision_markdown(client, revision["id"]),
        }
    return {
        "id": selector,
        "label": selector,
        "text": export_revision_markdown(client, selector),
    }


def command_revdiff(args: argparse.Namespace, config: Config) -> int:
    index = load_index(config.cache_dir, config.base_url)
    document = resolve_single_document(index, args.selector, cache_dir=config.cache_dir)
    client = OutlineClient(config)

    needs_recent = any(
        value.casefold() in {"latest", "previous"} for value in (args.from_revision, args.to_revision)
    )
    recent_revisions: list[dict[str, Any]] = []
    if needs_recent:
        recent_revisions = client.paginate(
            "revisions.list",
            {
                "documentId": document["id"],
                "sort": "createdAt",
                "direction": "DESC",
            },
            limit=2,
            page_size=2,
        )

    before = resolve_revdiff_source(client, document, args.from_revision, recent_revisions)
    after = resolve_revdiff_source(client, document, args.to_revision, recent_revisions)
    diff_text = build_unified_diff(
        before["text"],
        after["text"],
        fromfile=f"{document['title']} ({before['label']})",
        tofile=f"{document['title']} ({after['label']})",
    )

    output = {
        "document": {
            "id": document["id"],
            "title": document["title"],
            "url": document.get("url"),
        },
        "from": {
            "selector": args.from_revision,
            "resolvedId": before["id"],
            "label": before["label"],
        },
        "to": {
            "selector": args.to_revision,
            "resolvedId": after["id"],
            "label": after["label"],
        },
        "diff": diff_text,
    }

    if args.json:
        print_json(output)
        return 0

    if not diff_text:
        print(
            f"No revision diff for {document['title']} ({before['label']} -> {after['label']})."
        )
        return 0

    sys.stdout.write(diff_text)
    if not diff_text.endswith("\n"):
        sys.stdout.write("\n")
    return 0


def command_history(args: argparse.Namespace, config: Config) -> int:
    index = load_index(config.cache_dir, config.base_url)
    document = resolve_single_document(index, args.selector, cache_dir=config.cache_dir)
    client = OutlineClient(config)
    revisions = client.paginate(
        "revisions.list",
        {
            "documentId": document["id"],
            "sort": args.sort,
            "direction": args.direction,
        },
        limit=args.limit,
        page_size=min(args.limit or MAX_PAGE_SIZE, MAX_PAGE_SIZE),
    )

    if args.json:
        print_json(
            {
                "document": {
                    "id": document["id"],
                    "title": document["title"],
                    "url": document.get("url"),
                },
                "revisions": revisions,
            }
        )
        return 0

    print(f"{document['title']} revisions:")
    for revision in revisions:
        author = (revision.get("createdBy") or {}).get("name")
        label = f" [{revision['name']}]" if revision.get("name") else ""
        print(f"{revision['id']}  {revision.get('createdAt')}  {author}{label}")
    return 0


def command_log(args: argparse.Namespace, config: Config) -> int:
    index = load_index(config.cache_dir, config.base_url)
    payload: dict[str, Any] = {
        "sort": args.sort,
        "direction": args.direction,
        "auditLog": args.audit,
    }

    if args.selector:
        document = resolve_single_document(index, args.selector, cache_dir=config.cache_dir)
        payload["documentId"] = document["id"]

    if args.collection:
        collection = match_collection(list(index.get("collections", {}).values()), args.collection)
        payload["collectionId"] = collection["id"]

    if args.actor_id:
        payload["actorId"] = args.actor_id

    events = parse_csv(args.events)
    if events:
        payload["events"] = events

    client = OutlineClient(config)
    items = client.paginate(
        "events.list",
        payload,
        limit=args.limit,
        page_size=min(args.limit or MAX_PAGE_SIZE, MAX_PAGE_SIZE),
    )

    if args.json:
        print_json({"events": items})
        return 0

    for event in items:
        actor = (event.get("actor") or {}).get("name")
        print(f"{event.get('createdAt')}  {event.get('name')}  {actor}")
        if event.get("documentId"):
            print(f"  documentId: {event.get('documentId')}")
        if event.get("collectionId"):
            print(f"  collectionId: {event.get('collectionId')}")
    return 0


def command_skill(args: argparse.Namespace, config: Config) -> int:
    skill_file = Path(__file__).with_name("skill.md")
    if not skill_file.exists():
        raise KBError(f"Skill file not found: {skill_file}")
    sys.stdout.write(skill_file.read_text(encoding="utf-8"))
    return 0


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="outline-edit",
        description="Sync Outline documents into a local cache for efficient CLI and agent workflows."
    )
    add_runtime_args(parser, include_json=True)

    subparsers = parser.add_subparsers(dest="command", required=True)

    init_parser = subparsers.add_parser("init", help="Create a starter config file")
    add_runtime_args(init_parser, include_json=True, suppress_defaults=True)
    init_parser.add_argument(
        "--interactive",
        action="store_true",
        help="Prompt for the base URL and API key before writing the config file",
    )
    init_parser.add_argument(
        "--force",
        action="store_true",
        help="Overwrite an existing config file",
    )
    init_parser.set_defaults(func=command_init)

    auth_parser = subparsers.add_parser("auth", help="Validate the API key and show the current user/team")
    add_runtime_args(auth_parser, include_json=True, suppress_defaults=True)
    auth_parser.set_defaults(func=command_auth)

    pull_parser = subparsers.add_parser("pull", help="Fetch documents into the local cache")
    add_runtime_args(pull_parser, include_json=True, suppress_defaults=True)
    selector_group = pull_parser.add_mutually_exclusive_group(required=True)
    selector_group.add_argument("--all", action="store_true", help="Pull all accessible documents")
    selector_group.add_argument("--collection", help="Pull documents for a collection name or id")
    selector_group.add_argument("--query", help="Pull documents matching a title query")
    selector_group.add_argument("--document-id", help="Pull a single document by id")
    pull_parser.add_argument(
        "--status",
        help="Optional status filter: published,draft,archived",
    )
    pull_parser.add_argument(
        "--metadata-only",
        action="store_true",
        help="Refresh index metadata without writing markdown files",
    )
    pull_parser.add_argument(
        "--force",
        action="store_true",
        help="Overwrite locally modified cached files during a content pull",
    )
    pull_parser.add_argument(
        "--limit",
        type=int,
        help="Maximum number of documents to fetch",
    )
    pull_parser.add_argument(
        "--page-size",
        type=int,
        default=MAX_PAGE_SIZE,
        help=f"Page size for API pagination, max {MAX_PAGE_SIZE}",
    )
    pull_parser.set_defaults(func=command_pull)

    status_parser = subparsers.add_parser("status", help="Show local cache state")
    add_runtime_args(status_parser, include_json=True, suppress_defaults=True)
    status_parser.add_argument("selector", nargs="?", help="Optional title/id/path filter")
    status_parser.add_argument("--collection", help="Filter by collection name or id")
    status_parser.add_argument("--modified", action="store_true", help="Show only locally modified docs")
    status_parser.add_argument("--stale", action="store_true", help="Show only stale docs")
    status_parser.add_argument("--missing", action="store_true", help="Show only missing docs")
    status_parser.set_defaults(func=command_status)

    list_parser = subparsers.add_parser("list", help="List cached documents")
    add_runtime_args(list_parser, include_json=True, suppress_defaults=True)
    list_parser.add_argument("selector", nargs="?", help="Optional title/id/path filter")
    list_parser.add_argument("--collection", help="Filter by collection name or id")
    list_parser.add_argument("--limit", type=int, help="Limit the number of listed documents")
    list_parser.set_defaults(func=command_list)

    read_parser = subparsers.add_parser("read", help="Print a cached document")
    add_runtime_args(read_parser, include_json=True, suppress_defaults=True)
    read_parser.add_argument("selector", help="Document id prefix, title, or cached path fragment")
    read_parser.add_argument("--path", action="store_true", help="Print the resolved cached path only")
    read_parser.set_defaults(func=command_read)

    create_parser = subparsers.add_parser("create", help="Create a document through the raw Outline API")
    add_runtime_args(create_parser, include_json=True, suppress_defaults=True)
    create_parser.add_argument("--title", required=True, help="Document title")
    create_parser.add_argument("--text", help="Inline markdown body")
    create_parser.add_argument("--file", help="Read markdown body from a local file")
    create_parser.add_argument("--collection", help="Collection name or id")
    create_parser.add_argument("--parent", help="Optional cached parent document selector")
    create_parser.add_argument("--template-id", help="Optional template document id")
    create_parser.add_argument("--publish", action="store_true", help="Create the document as published")
    create_parser.add_argument("--full-width", action="store_true", help="Create the document in full-width mode")
    create_parser.set_defaults(func=command_create)

    search_parser = subparsers.add_parser("search", help="Search the local cache")
    add_runtime_args(search_parser, include_json=True, suppress_defaults=True)
    search_parser.add_argument("query", help="Case-insensitive substring query")
    search_parser.add_argument("--collection", help="Filter by collection name or id")
    search_parser.add_argument(
        "--title-only",
        action="store_true",
        help="Search only title/collection/path metadata",
    )
    search_parser.add_argument("--limit", type=int, default=20, help="Maximum number of results to show")
    search_parser.set_defaults(func=command_search)

    diff_parser = subparsers.add_parser(
        "diff",
        help="Diff a cached document against its last synced snapshot",
    )
    add_runtime_args(diff_parser, include_json=True, suppress_defaults=True)
    diff_parser.add_argument("selector", help="Document id prefix, title, or cached path fragment")
    diff_parser.set_defaults(func=command_diff)

    push_parser = subparsers.add_parser(
        "push",
        help="Push locally modified cached documents back to Outline",
    )
    add_runtime_args(push_parser, include_json=True, suppress_defaults=True)
    push_parser.add_argument("selector", nargs="?", help="Optional single document selector")
    push_parser.add_argument(
        "--collection",
        help="Filter modified documents by collection when pushing many",
    )
    push_parser.set_defaults(func=command_push)

    publish_parser = subparsers.add_parser("publish", help="Publish a cached draft document")
    add_runtime_args(publish_parser, include_json=True, suppress_defaults=True)
    publish_parser.add_argument("selector", help="Document id prefix, title, or cached path fragment")
    publish_parser.add_argument(
        "--collection",
        help="Collection name or id to use when publishing a draft without a collection",
    )
    publish_parser.set_defaults(func=command_publish)

    archive_parser = subparsers.add_parser("archive", help="Archive a cached document remotely")
    add_runtime_args(archive_parser, include_json=True, suppress_defaults=True)
    archive_parser.add_argument("selector", help="Document id prefix, title, or cached path fragment")
    archive_parser.set_defaults(func=command_archive)

    restore_parser = subparsers.add_parser(
        "restore",
        help="Restore an archived or deleted document, or restore a document to a specific revision",
    )
    add_runtime_args(restore_parser, include_json=True, suppress_defaults=True)
    restore_parser.add_argument("selector", help="Document id prefix, title, or cached path fragment")
    restore_parser.add_argument("--collection", help="Optional destination collection name or id")
    restore_parser.add_argument("--revision-id", help="Optional full revision id to restore document content from")
    restore_parser.add_argument(
        "--force",
        action="store_true",
        help="Overwrite a locally modified cached file when restoring from a revision",
    )
    restore_parser.set_defaults(func=command_restore)

    delete_parser = subparsers.add_parser("delete", help="Delete a cached document remotely")
    add_runtime_args(delete_parser, include_json=True, suppress_defaults=True)
    delete_parser.add_argument("selector", help="Document id prefix, title, or cached path fragment")
    delete_parser.add_argument(
        "--permanent",
        action="store_true",
        help="Permanently delete the document instead of soft-deleting it",
    )
    delete_parser.set_defaults(func=command_delete)

    history_parser = subparsers.add_parser("history", help="Show revision history for a cached document")
    add_runtime_args(history_parser, include_json=True, suppress_defaults=True)
    history_parser.add_argument("selector", help="Document id prefix, title, or cached path fragment")
    history_parser.add_argument("--limit", type=int, default=20, help="Maximum revisions to show")
    history_parser.add_argument("--sort", default="createdAt", help="Revision sort field")
    history_parser.add_argument("--direction", default="DESC", help="Revision sort direction")
    history_parser.set_defaults(func=command_history)

    revdiff_parser = subparsers.add_parser(
        "revdiff",
        help="Diff two remote document revisions without using the local cached file",
    )
    add_runtime_args(revdiff_parser, include_json=True, suppress_defaults=True)
    revdiff_parser.add_argument("selector", help="Document id prefix, title, or cached path fragment")
    revdiff_parser.add_argument(
        "from_revision",
        help="Full revision id, or one of: latest, previous, current",
    )
    revdiff_parser.add_argument(
        "to_revision",
        help="Full revision id, or one of: latest, previous, current",
    )
    revdiff_parser.set_defaults(func=command_revdiff)

    log_parser = subparsers.add_parser("log", help="Show Outline activity events")
    add_runtime_args(log_parser, include_json=True, suppress_defaults=True)
    log_parser.add_argument("selector", nargs="?", help="Optional document selector")
    log_parser.add_argument("--collection", help="Optional collection name or id filter")
    log_parser.add_argument("--actor-id", help="Optional actor user id filter")
    log_parser.add_argument("--events", help="Comma-separated event names to include")
    log_parser.add_argument("--audit", action="store_true", help="Request audit log events")
    log_parser.add_argument("--limit", type=int, default=20, help="Maximum events to show")
    log_parser.add_argument("--sort", default="createdAt", help="Event sort field")
    log_parser.add_argument("--direction", default="DESC", help="Event sort direction")
    log_parser.set_defaults(func=command_log)

    skill_parser = subparsers.add_parser(
        "skill",
        help="Print the agent skill definition for this tool",
    )
    skill_parser.set_defaults(func=command_skill)

    return parser


def main() -> int:
    parser = build_parser()
    args = parser.parse_args()

    try:
        config = build_config(args)
        return args.func(args, config)
    except (KBError, APIError) as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 1


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