#!/usr/bin/env python3
"""CLI wrapper around superpos_agent_core.SuperposClient knowledge methods.

Subcommands mirror the client API:
  search, get, get-by-slug, list, list-by-type,  (read)
  graph, topics, decisions, backlinks
  create, update                                 (bounded write)

Prints JSON to stdout, errors to stderr.  Designed for the LLM to call
via Bash and pipe through ``jq`` for follow-up queries.

Writes are *bounded*: every created/updated entry is stamped with
``metadata.source = "agent_inline"`` so the Curator can audit and decay
inline-authored knowledge.  Org-scope writes remain gated server-side by
the ``knowledge.write_organization`` permission.
"""

from __future__ import annotations

import argparse
import asyncio
import json
import os
import sys
from typing import Any

# `superpos_agent_core` is pip-installed via the container's requirements.txt.
from superpos_agent_core import BaseConfig, KnowledgeClient, SuperposClient

# Typed knowledge page types accepted by the create/update API (server-side
# frontmatter validation is per-type; `entity` additionally requires
# `frontmatter.kind`).
_KNOWLEDGE_TYPES = ("entity", "topic", "trend", "source_page", "log", "procedure")
# AG-2 (PR #34) introduced the name `_KNOWLEDGE_PAGE_TYPES` for the same set;
# main's fix series renamed the canonical constant to `_KNOWLEDGE_TYPES`. Keep
# the AG-2 name working as an alias so both the typed-page help text and the
# ``list-by-type`` validation resolve without further conflict.
_KNOWLEDGE_PAGE_TYPES = frozenset(_KNOWLEDGE_TYPES)

# Typed-shape flags. Presence of any of these (on create/update) routes the
# command through KnowledgeClient.create_page / update_page instead of the
# legacy key/value path. Mirrors the server's dual-shape XOR.
_TYPED_FLAGS = ("type", "slug", "body", "body_file", "frontmatter", "source_ids")

# Typed-shape selectors. On update, ``--id`` directly identifies an existing
# typed page (``--slug`` is already a typed flag above). Like the typed content
# flags, their presence selects the typed path and must not be mixed with the
# legacy key/value flags, so the guard treats them as typed.
_TYPED_SELECTORS = ("id",)

# Shared content flags — meaningful in both shapes, so they don't pick a shape on
# their own. But on *update* the positional ``entry_id`` (a separate argparse
# dest from ``--id``) is a typed selector too, and a positional-id update that
# carries a shared content field AND no legacy flag is unambiguously a typed
# edit: route it to update_page rather than falling through to legacy. When a
# legacy flag is also present, those shared fields are valid legacy content, so
# the legacy flag pins the legacy read-modify-write path (no XOR error). (Pure
# cross-cutting --ttl/--visibility are NOT content fields and must not trigger
# the promotion — they stay on the legacy path so a value is sent.)
_SHARED_CONTENT_FLAGS = ("title", "summary", "tags")

# Legacy key/value-shape flags, deprecated in favour of the typed shape.
_LEGACY_FLAGS = ("key", "value", "content", "confidence")

# Upper bound for slug-on-update resolution. The typed list endpoint returns
# only the newest entries of a type (server-clamped, no pagination, no exact
# slug route), so slug resolution is a bounded best-effort scan. Used both for
# the ``list_by_type`` call and the under-cap/at-cap comparison so they can't
# drift apart.
_SLUG_RESOLVE_LIMIT = 100


def _config_from_env() -> BaseConfig:
    base_url = os.environ.get("SUPERPOS_BASE_URL", "").rstrip("/")
    hive_id = os.environ.get("SUPERPOS_HIVE_ID", "")
    agent_id = os.environ.get("SUPERPOS_AGENT_ID", "")
    token = os.environ.get("SUPERPOS_API_TOKEN", "")
    refresh = os.environ.get("SUPERPOS_REFRESH_TOKEN", "")

    if not (base_url and hive_id and token):
        print(
            "Error: SUPERPOS_BASE_URL, SUPERPOS_HIVE_ID, and "
            "SUPERPOS_API_TOKEN must be set in the environment.",
            file=sys.stderr,
        )
        sys.exit(2)

    return BaseConfig(
        superpos_base_url=base_url,
        superpos_hive_id=hive_id,
        superpos_agent_id=agent_id,
        superpos_api_token=token,
        superpos_refresh_token=refresh,
    )


def _print(value: Any) -> None:
    """Print a JSON-serialisable value, indented for human reading."""
    print(json.dumps(value, indent=2, default=str))


def _parse_json_arg(label: str, raw: str | None) -> dict[str, Any] | None:
    """Parse a CLI flag value that should be a JSON object, with a clear error."""
    if raw is None:
        return None
    try:
        parsed = json.loads(raw)
    except json.JSONDecodeError as e:
        print(f"Error: --{label} must be valid JSON ({e})", file=sys.stderr)
        sys.exit(2)
    if not isinstance(parsed, dict):
        print(f"Error: --{label} must be a JSON object, got {type(parsed).__name__}",
              file=sys.stderr)
        sys.exit(2)
    return parsed


def _split_tags(raw: str | None) -> list[str] | None:
    """Parse a comma-separated ``--tags`` flag into a clean list (or None)."""
    if raw is None:
        return None
    return [t.strip() for t in raw.split(",") if t.strip()]


def _has_any_flag(args: argparse.Namespace, names: tuple[str, ...]) -> bool:
    """True if any of the named attributes is set (non-None) on ``args``."""
    return any(getattr(args, n, None) is not None for n in names)


def _resolve_body(args: argparse.Namespace) -> str | None:
    """Resolve the page body from ``--body`` / ``--body-file`` (mutually exclusive)."""
    if args.body is not None and args.body_file is not None:
        print(
            "Error: --body and --body-file are mutually exclusive; pass only one.",
            file=sys.stderr,
        )
        sys.exit(2)
    if args.body_file is not None:
        try:
            with open(args.body_file, encoding="utf-8") as fh:
                return fh.read()
        except OSError as e:
            print(f"Error: could not read --body-file {args.body_file!r}: {e}",
                  file=sys.stderr)
            sys.exit(2)
    return args.body


def _typed_requested(args: argparse.Namespace) -> bool:
    """True if the user supplied any typed-shape flag.

    ``--title``/``--summary``/``--tags`` are shared between both shapes, so
    they don't on their own decide the shape — only the typed-exclusive flags
    in ``_TYPED_FLAGS`` and the typed selectors in ``_TYPED_SELECTORS`` (e.g.
    ``--id`` on update) do.

    The one exception is the positional ``entry_id`` selector on update: it is
    a direct alias for ``--id`` (a typed selector), so a positional-id update
    that carries a shared content flag (``--title``/``--summary``/``--tags``)
    *and no legacy flag* is a typed edit and routes to update_page. But when a
    legacy flag (``--content``/``--value``/etc.) is also present, the shared
    fields are valid legacy content (they assemble into the legacy ``value``),
    so the legacy flag pins the legacy read-modify-write path and the
    positional-id promotion is suppressed — this preserves the main-branch
    behaviour where ``update <id> --content ... --title ...`` is a legacy
    update, not an XOR error. A genuinely typed-exclusive flag (``--body``/
    ``--type``/…) mixed with a legacy flag is still caught by the XOR via the
    first branch above. Cross-cutting ``--ttl``/``--visibility`` are NOT content
    fields, so a bare positional id carrying only those (or only legacy flags)
    is not seen as typed and still resolves via the legacy path.
    """
    if _has_any_flag(args, _TYPED_FLAGS) or _has_any_flag(args, _TYPED_SELECTORS):
        return True
    return (
        getattr(args, "entry_id", None) is not None
        and _has_any_flag(args, _SHARED_CONTENT_FLAGS)
        and not _has_any_flag(args, _LEGACY_FLAGS)
    )


def _legacy_requested(args: argparse.Namespace) -> bool:
    """True if the user supplied any legacy key/value-shape flag."""
    return _has_any_flag(args, _LEGACY_FLAGS)


def _guard_shape_xor(args: argparse.Namespace) -> bool:
    """Decide between typed and legacy shapes, mirroring the server XOR.

    Returns True for the typed shape, False for legacy. Fails fast (exit 2)
    if the user mixes typed and legacy flags, so the CLI never sends a mixed
    payload the server would reject with a 422 on ``shape``.
    """
    typed = _typed_requested(args)
    legacy = _legacy_requested(args)
    if typed and legacy:
        print(
            "Error: cannot mix typed flags (--type/--slug/--body/--body-file/"
            "--frontmatter/--source-ids/--id) with legacy flags (--key/--value/"
            "--content/--confidence). Use one shape. See SKILL.md.",
            file=sys.stderr,
        )
        sys.exit(2)
    return typed


def _deprecation_notice() -> None:
    print(
        "⚠ legacy key/value knowledge is deprecated; use --type/--slug/"
        "--body. See SKILL.md.",
        file=sys.stderr,
    )


def _build_value(args: argparse.Namespace) -> dict[str, Any]:
    """Assemble the canonical knowledge ``value`` dict from CLI flags.

    ``--value`` (raw JSON) is the escape hatch / base; the structured flags
    override individual fields on top of it.  Provenance metadata is always
    stamped so inline writes are auditable.
    """
    value: dict[str, Any] = _parse_json_arg("value", args.value) or {}

    if args.title:
        value["title"] = args.title
    if args.summary:
        value["summary"] = args.summary
    if args.content:
        value["content"] = args.content
    if args.tags:
        value["tags"] = [t.strip() for t in args.tags.split(",") if t.strip()]
    if args.confidence:
        value["confidence"] = args.confidence

    meta = value.get("metadata")
    if not isinstance(meta, dict):
        meta = {}
    meta["source"] = "agent_inline"
    meta["auto_generated"] = True
    value["metadata"] = meta
    return value


def _default_scope(explicit: str | None) -> str:
    """Resolve the write scope: explicit flag → fillin default env → ``hive``."""
    return explicit or os.environ.get("SUPERPOS_KNOWLEDGE_FILLIN_SCOPE", "hive")


async def _resolve_slug_to_id(
    kclient: KnowledgeClient, *, type_: str | None, slug: str,
) -> str:
    """Resolve a typed page ``slug`` to its entry id for update-by-slug.

    The update endpoint (PUT /knowledge/{entry}) is id-only — there is no
    slug or PATCH route — so we list pages of the given type (newest first)
    and match on the entry's ``slug`` field. ``--type`` is required alongside
    ``--slug`` because the typed list endpoint is keyed by type.

    This is a bounded best-effort scan: the list endpoint returns only the
    newest ``_SLUG_RESOLVE_LIMIT`` entries (server-clamped, no pagination), so
    an entry older than that can't be resolved by slug — use ``--id`` instead.
    """
    if type_ is None:
        print(
            "Error: --type is required with --slug for update (slug is "
            "resolved to an id via the typed list endpoint).",
            file=sys.stderr,
        )
        sys.exit(2)
    pages = await kclient.list_by_type(type_, limit=_SLUG_RESOLVE_LIMIT)
    matches = [
        p for p in pages
        if isinstance(p, dict) and p.get("slug") == slug
    ]
    if not matches:
        if len(pages) >= _SLUG_RESOLVE_LIMIT:
            # The result set was truncated to the newest entries, so the page
            # may exist but is unreachable by slug. Be honest about the bound.
            print(
                f"Error: no {type_!r} page with slug {slug!r} found among the "
                f"{_SLUG_RESOLVE_LIMIT} most recent entries. Older entries "
                "can't be resolved by slug (the server returns only the newest "
                f"{_SLUG_RESOLVE_LIMIT} with no pagination); update it by --id "
                "instead.",
                file=sys.stderr,
            )
        else:
            # The whole type was scanned, so the slug genuinely doesn't exist.
            print(
                f"Error: no {type_} page found with slug {slug!r}.",
                file=sys.stderr,
            )
        sys.exit(2)
    if len(matches) > 1:
        ids = ", ".join(str(p.get("id")) for p in matches)
        print(
            f"Error: {len(matches)} {type_} pages match slug {slug!r} "
            f"(ids: {ids}); refusing to guess. Update by --id instead.",
            file=sys.stderr,
        )
        sys.exit(2)
    entry_id = matches[0].get("id")
    if not entry_id:
        print(
            f"Error: resolved {type_} page for slug {slug!r} has no id.",
            file=sys.stderr,
        )
        sys.exit(2)
    return str(entry_id)


async def _run_typed_create(kclient: KnowledgeClient, args: argparse.Namespace) -> None:
    """Create a typed wiki page via KnowledgeClient.create_page."""
    if not args.type:
        print("Error: --type is required for a typed create.", file=sys.stderr)
        sys.exit(2)
    if not args.slug:
        print("Error: --slug is required for a typed create.", file=sys.stderr)
        sys.exit(2)
    body = _resolve_body(args)
    if body is None:
        print(
            "Error: a typed create needs a body (--body or --body-file).",
            file=sys.stderr,
        )
        sys.exit(2)
    frontmatter = _parse_json_arg("frontmatter", args.frontmatter)
    # Fail fast on the one server invariant worth pre-checking: entity pages
    # require frontmatter.kind. (Other types are validated server-side.)
    if args.type == "entity" and not (frontmatter or {}).get("kind"):
        print(
            "Error: --type entity requires frontmatter.kind, e.g. "
            "--frontmatter '{\"kind\": \"service\"}'.",
            file=sys.stderr,
        )
        sys.exit(2)
    _print(await kclient.create_page(
        type=args.type,
        slug=args.slug,
        body=body,
        frontmatter=frontmatter,
        source_ids=_split_tags(args.source_ids),
        title=args.title,
        summary=args.summary,
        tags=_split_tags(args.tags),
        scope=_default_scope(args.scope),
        visibility=args.visibility or "public",
        ttl=args.ttl,
    ))


async def _run_typed_update(kclient: KnowledgeClient, args: argparse.Namespace) -> None:
    """Update a typed wiki page via KnowledgeClient.update_page.

    Accepts a positional ``entry_id`` or ``--id`` (both direct, interchangeable
    aliases — the positional form is the backwards-compatible spelling that
    worked before ``--id`` existed), or ``--slug`` (resolved to an id first,
    since PUT is id-only).
    """
    # The positional ``entry_id`` is an alias for ``--id``: both identify the
    # page directly. They must agree if both are given, and neither can be
    # combined with ``--slug`` (two different ways to point at the same page).
    direct_id = args.id or args.entry_id
    if args.id and args.entry_id and args.id != args.entry_id:
        print(
            "Error: positional entry_id and --id both given but differ; "
            "pass only one.",
            file=sys.stderr,
        )
        sys.exit(2)
    if direct_id and args.slug:
        print(
            "Error: pass either an entry id (positional or --id) or "
            "--type/--slug to identify the page, not both.",
            file=sys.stderr,
        )
        sys.exit(2)
    # --type is immutable on update: a page's type is fixed once created, so
    # re-typing is a migration, not an edit. The only legitimate use of --type
    # here is alongside --slug, where it selects the type to resolve the slug to
    # an id. Provided without --slug it's either a retype attempt (combined with
    # an entry id) or has nothing to resolve — reject before any API call.
    if args.type is not None and not args.slug:
        print(
            "Error: --type cannot be changed on an update — a page's type is "
            "immutable (re-typing is a migration, not an edit). --type is only "
            "accepted alongside --slug to resolve the page id.",
            file=sys.stderr,
        )
        sys.exit(2)
    body = _resolve_body(args)
    frontmatter = _parse_json_arg("frontmatter", args.frontmatter)
    tags = _split_tags(args.tags)
    source_ids = _split_tags(args.source_ids)
    # A typed update must carry at least one content field. --id/--slug/--type
    # are selectors/routing, not content, and the server's UpdateKnowledgeRequest
    # treats `visibility` and `ttl` as cross-cutting fields its writable-shape
    # guard EXCLUDES — so a payload carrying only selectors and/or cross-cutting
    # fields (or nothing at all, e.g. `update --id X`) reduces to an empty/
    # content-less body that fails server-side validation ("value required") or
    # no-ops. Reject before any API call (including the slug→id resolution): a
    # content field (body/frontmatter/title/summary/tags/source_ids) is required.
    has_content = any(
        v is not None for v in (
            body, frontmatter, args.title, args.summary, tags, source_ids,
        )
    )
    if not has_content:
        if args.visibility is not None or args.ttl is not None:
            # Cross-cutting flag(s) present but no content to hang them on.
            print(
                "Error: --visibility/--ttl cannot be the only updated field(s) "
                "on a typed update; they must accompany at least one content "
                "field (--body/--body-file/--frontmatter/--title/--summary/"
                "--tags/--source-ids).",
                file=sys.stderr,
            )
        else:
            # Nothing to update at all — a bare selector (e.g. `update --id X`)
            # would otherwise send an empty {} body and report success.
            print(
                "Error: a typed update needs at least one content field to "
                "change (--body/--body-file/--frontmatter/--title/--summary/"
                "--tags/--source-ids).",
                file=sys.stderr,
            )
        sys.exit(2)
    if direct_id:
        entry_id = direct_id
    elif args.slug:
        entry_id = await _resolve_slug_to_id(kclient, type_=args.type, slug=args.slug)
    else:
        print(
            "Error: typed update needs an entry id (positional or --id) or "
            "--slug to identify the page.",
            file=sys.stderr,
        )
        sys.exit(2)
    _print(await kclient.update_page(
        entry_id,
        body=body,
        frontmatter=frontmatter,
        title=args.title,
        summary=args.summary,
        tags=tags,
        source_ids=source_ids,
        visibility=args.visibility,
        ttl=args.ttl,
    ))


async def _run(args: argparse.Namespace) -> int:
    config = _config_from_env()
    client = SuperposClient(config)
    try:
        if args.cmd == "search":
            try:
                out = await client.search_knowledge(
                    q=args.query,
                    scope=args.scope,
                    semantic=args.semantic,
                    limit=args.limit,
                )
            except ValueError as e:
                print(f"Error: {e}", file=sys.stderr)
                return 2
            _print(out)

        elif args.cmd == "get":
            _print(await client.get_knowledge(args.entry_id))

        elif args.cmd == "get-by-slug":
            try:
                out = await client.get_knowledge_by_slug(args.slug)
            except ValueError as e:
                print(f"Error: {e}", file=sys.stderr)
                return 2
            _print(out)

        elif args.cmd == "list":
            _print(await client.list_knowledge(
                key=args.key,
                scope=args.scope,
                tags=args.tags,
                stale_days=args.stale_days,
                sort=args.sort,
                limit=args.limit,
            ))

        elif args.cmd == "list-by-type":
            if args.type not in _KNOWLEDGE_PAGE_TYPES:
                print(
                    f"Error: --type {args.type!r} is not a known page type. "
                    f"Valid types: {', '.join(sorted(_KNOWLEDGE_PAGE_TYPES))}.",
                    file=sys.stderr,
                )
                return 2
            _print(await client.list_knowledge_by_type(
                args.type, limit=args.limit,
            ))

        elif args.cmd == "graph":
            _print(await client.get_knowledge_graph(
                args.entry_id,
                depth=args.depth,
                max_nodes=args.max_nodes,
                link_types=args.link_types,
            ))

        elif args.cmd == "topics":
            _print(await client.knowledge_topics())

        elif args.cmd == "decisions":
            _print(await client.knowledge_decisions())

        elif args.cmd == "backlinks":
            _print(await client.list_knowledge_backlinks(args.entry_id))

        elif args.cmd == "create":
            if _guard_shape_xor(args):
                await _run_typed_create(KnowledgeClient(client), args)
                return 0
            # Legacy key/value path (deprecated).
            _deprecation_notice()
            if not args.key:
                print("Error: --key is required for a legacy create.",
                      file=sys.stderr)
                return 2
            if not args.content:
                print("Error: --content is required for a legacy create.",
                      file=sys.stderr)
                return 2
            _print(await client.create_knowledge(
                key=args.key,
                value=_build_value(args),
                scope=_default_scope(args.scope),
                visibility=args.visibility,
                ttl=args.ttl,
            ))

        elif args.cmd == "update":
            # _guard_shape_xor treats --id/--slug as typed selectors, so the
            # typed path is taken (and legacy-flag mixing is rejected) here
            # rather than bypassing the guard on selector presence.
            if _guard_shape_xor(args):
                await _run_typed_update(KnowledgeClient(client), args)
                return 0
            # Legacy key/value path (deprecated).
            _deprecation_notice()
            if not args.entry_id:
                print(
                    "Error: legacy update needs a positional entry_id "
                    "(or use --id/--slug for a typed update).",
                    file=sys.stderr,
                )
                return 2
            has_individual_flags = any(
                getattr(args, f, None) is not None
                for f in ("title", "summary", "content", "tags", "confidence")
            )
            if args.value and not has_individual_flags:
                # Full replacement: --value without individual field flags
                final = _build_value(args)
            else:
                # Partial update: read-modify-write merge
                existing = await client.get_knowledge(args.entry_id)
                existing_value = existing.get("value", {}) if isinstance(existing, dict) else {}
                if not isinstance(existing_value, dict):
                    existing_value = {}
                partial = _build_value(args)
                final = {**existing_value, **partial}
                existing_meta = existing_value.get("metadata", {})
                if isinstance(existing_meta, dict) and isinstance(partial.get("metadata"), dict):
                    final["metadata"] = {**existing_meta, **partial["metadata"]}
            _print(await client.update_knowledge(
                args.entry_id,
                value=final,
                visibility=args.visibility,
                ttl=args.ttl,
            ))

        else:
            print(f"Unknown subcommand: {args.cmd}", file=sys.stderr)
            return 2

        return 0
    finally:
        await client.close()


def _add_value_flags(p: argparse.ArgumentParser) -> None:
    """Shared flags for create/update, covering both shapes.

    ``--content`` is no longer ``required`` at the argparse level: the handler
    enforces it only for the legacy path, since a typed create uses
    ``--body``/``--body-file`` instead. Flags fall into three groups:

    - Typed shape: ``--type``, ``--slug``, ``--body``, ``--body-file``,
      ``--frontmatter``, ``--source-ids`` → KnowledgeClient.create_page /
      update_page.
    - Legacy shape (deprecated): ``--key`` (create only), ``--content``,
      ``--value``, ``--confidence``.
    - Shared: ``--title``, ``--summary``, ``--tags``, ``--visibility``,
      ``--ttl`` (applies to both typed and legacy shapes), ``--scope``.
    """
    # ── typed shape ──
    p.add_argument(
        "--type", choices=list(_KNOWLEDGE_TYPES),
        help="Typed page type (entity, topic, trend, source_page, log, "
             "procedure). Routes through the typed wiki API.",
    )
    p.add_argument(
        "--slug",
        help="Stable typed-page slug (regex ^[A-Za-z0-9:_\\-.]+$). Required "
             "with --type on create. On update it is resolved to an id as a "
             "best-effort scan over the newest 100 entries of the type "
             "(server cap, no pagination); for older entries use --id, which "
             "is exact and unbounded.",
    )
    p.add_argument("--body", help="Typed page body (Markdown).")
    p.add_argument(
        "--body-file", dest="body_file",
        help="Read the typed page body from a file (mutually exclusive with "
             "--body).",
    )
    p.add_argument(
        "--frontmatter",
        help="Typed page frontmatter as a JSON object string. "
             "(--type entity requires frontmatter.kind.)",
    )
    p.add_argument(
        "--source-ids", dest="source_ids",
        help="Comma-separated source entry ULIDs / agent IDs to attach as "
             "citations (curator audit trail). Optional and not auto-stamped: "
             "source_ids is sent only when you pass this flag (auto-stamping "
             "SUPERPOS_AGENT_ID would 403 per proposal §6.8). An explicit empty "
             "value (--source-ids \"\") clears existing citations on update.",
    )
    # ── legacy shape (deprecated) ──
    p.add_argument(
        "--content",
        help="[legacy] Entry body. Prefer --body. "
             "Shape: 'Rule: …  Why: …  How to apply: …'",
    )
    p.add_argument(
        "--confidence", choices=["high", "medium", "low"],
        help="[legacy] Confidence level for this entry",
    )
    p.add_argument(
        "--value",
        help="[legacy] Raw JSON object for the full value (escape hatch; "
             "structured flags override fields on top of it)",
    )
    # ── shared ──
    p.add_argument("--title", help="Short headline for the entry")
    p.add_argument("--summary", help="One-line summary (shown in search results)")
    p.add_argument("--tags", help="Comma-separated tags")
    p.add_argument(
        "--visibility", choices=["public", "private"],
        help="Override visibility (defaults server-side by scope)",
    )
    p.add_argument(
        "--ttl",
        help="ISO8601 expiry timestamp (entry auto-expires after); applies "
             "to both typed and legacy shapes",
    )


def _build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="superpos-knowledge",
        description=(
            "Read and write the Superpos knowledge store: FTS/semantic search, "
            "list, get, graph traversal, topic & decision indexes, plus bounded "
            "create/update for recording lasting decisions."
        ),
    )
    sub = parser.add_subparsers(dest="cmd", required=True)

    # search
    p_search = sub.add_parser("search", help="Full-text or semantic search")
    p_search.add_argument("query", nargs="?", help="Search query string")
    p_search.add_argument("--scope", help="Restrict to a scope (hive, apiary, agent:<id>)")
    p_search.add_argument(
        "--semantic", action="store_true",
        help="Use pgvector cosine-similarity (embedding-backed) instead of Postgres FTS",
    )
    p_search.add_argument("--limit", type=int, default=10, help="Top-N results (default 10)")

    # get
    p_get = sub.add_parser("get", help="Fetch a single entry by ID")
    p_get.add_argument("entry_id", help="Knowledge entry ULID")

    # get-by-slug
    p_get_slug = sub.add_parser(
        "get-by-slug", help="Fetch a single entry by slug (typed-page lookup)",
    )
    p_get_slug.add_argument(
        "slug", help="Entry slug, e.g. 'proposal-knowledge-wiki'",
    )

    # list
    p_list = sub.add_parser("list", help="Browse entries with filters")
    p_list.add_argument("--key", help="Key pattern (* wildcard)")
    p_list.add_argument("--scope", help="Restrict to a scope")
    p_list.add_argument("--tags", help="Comma-separated tags (all must match)")
    p_list.add_argument("--stale-days", type=int, help="Entries not read in N days")
    p_list.add_argument(
        "--sort",
        choices=["least-read"],
        help="Sort order — currently only least-read is supported server-side",
    )
    p_list.add_argument("--limit", type=int, default=50, help="Default 50, server cap 100")

    # list-by-type
    p_list_type = sub.add_parser(
        "list-by-type",
        help="List entries filtered by type (typed-page browse)",
    )
    p_list_type.add_argument(
        "type",
        help=(
            "Page type: "
            f"{', '.join(sorted(_KNOWLEDGE_PAGE_TYPES))}"
        ),
    )
    p_list_type.add_argument(
        "--limit", type=int, default=50, help="Max entries (default 50, server cap 100)",
    )

    # graph
    p_graph = sub.add_parser("graph", help="BFS link traversal from an entry")
    p_graph.add_argument("entry_id", help="Knowledge entry ULID to start from")
    p_graph.add_argument("--depth", type=int, default=2, help="Hops to traverse (server clamps 1-5)")
    p_graph.add_argument(
        "--max-nodes", type=int, default=50,
        help="Max nodes returned (server clamps 1-200)",
    )
    p_graph.add_argument(
        "--link-types",
        help="Comma-separated link-type allowlist (e.g. 'decides,depends_on')",
    )

    # topics / decisions
    sub.add_parser("topics", help="Convenience index of topic clusters in this hive")
    sub.add_parser("decisions", help="Convenience index of recorded decisions")

    # backlinks — typed-page hygiene
    p_backlinks = sub.add_parser(
        "backlinks", help="Show entries that link to this entry (by ULID)",
    )
    p_backlinks.add_argument(
        "entry_id",
        help=(
            "Target entry ULID to find backlinks for. "
            "Resolve a slug to a ULID first via `search <slug> --limit 1 | jq '.[0].id'`."
        ),
    )

    # ── Typed-page flags (create & update) ──────────────────────────
    def _add_typed_flags(p: argparse.ArgumentParser, *, slug_required: bool) -> None:
        p.add_argument(
            "--type",
            help=(
                "Page type for the typed create shape (TASK-297). "
                "Mutually exclusive with --key. One of: "
                f"{', '.join(sorted(_KNOWLEDGE_PAGE_TYPES))}."
            ),
        )
        p.add_argument(
            "--slug",
            required=slug_required,
            help="Stable slug for the typed page (e.g. 'proposal-knowledge-wiki'). "
                 "Required when --type is set on create.",
        )
        p.add_argument("--body", help="Markdown body of the page (typed shape).")
        p.add_argument(
            "--body-file",
            help="Path to a file containing the markdown body (mutually exclusive with --body).",
        )
        p.add_argument(
            "--frontmatter",
            help="JSON object string for the page frontmatter (e.g. "
                 "'{\"summary\": \"...\"}').",
        )
        p.add_argument(
            "--source-ids",
            help="Comma-separated source entry ULIDs / agent IDs (curator audit trail). "
                 "Defaults to SUPERPOS_AGENT_ID when set.",
        )

    # create
    p_create = sub.add_parser(
        "create",
        help="Record a new knowledge entry — typed (--type/--slug/--body) "
             "or legacy (--key/--content, deprecated)",
    )
    p_create.add_argument(
        "--key",
        help="[legacy] Stable key, e.g. 'decisions:eventbus-unification'. "
             "Use --type/--slug for typed pages.",
    )
    p_create.add_argument(
        "--scope",
        help="Scope (default: SUPERPOS_KNOWLEDGE_FILLIN_SCOPE env or 'hive'). "
             "Org scope needs the knowledge.write_organization permission.",
    )
    _add_value_flags(p_create)

    # update
    p_update = sub.add_parser(
        "update",
        help="Update an existing entry — typed (--id or --type+--slug) "
             "or legacy (positional entry_id, deprecated)",
    )
    p_update.add_argument(
        "entry_id", nargs="?",
        help="Knowledge entry ULID to update. On a typed update (e.g. with "
             "--body) it identifies the page, exactly like --id; on a legacy "
             "key/value update it is the entry to modify. Alternatively use "
             "--type+--slug to resolve a typed page by slug.",
    )
    p_update.add_argument(
        "--id", dest="id",
        help="Typed page entry id to update directly (PUT is id-only); a "
             "named alias for the positional entry_id. Exact and unbounded — "
             "unlike --type+--slug, which resolves only over the newest 100 "
             "entries of the type.",
    )
    p_update.add_argument(
        "--scope",
        help="[unused on update] Scope is immutable after create.",
    )
    _add_value_flags(p_update)

    return parser


def _normalise_sort(value: str | None) -> str | None:
    """Map the user-friendly ``--sort least-read`` to the server's
    ``least_read`` parameter (the underscore variant the controller
    expects)."""
    if value == "least-read":
        return "least_read"
    return value


def main(argv: list[str] | None = None) -> int:
    args = _build_parser().parse_args(argv)
    args.sort = _normalise_sort(getattr(args, "sort", None))
    return asyncio.run(_run(args))


if __name__ == "__main__":
    sys.exit(main())
