#!/usr/bin/env python3
"""
template-registry CLI — list available video templates.

Single source of truth: registry loading, caching and stable/beta status
gating all live in ``registry_loader.py`` (the same module ``render-video``
and ``gen-script`` import in-process). This entry point only adds the CLI
surface — flag parsing, tag/aspect/language filtering, and table/JSON
rendering — that used to be re-implemented in TypeScript
(``src/handlers/template-bind.ts``). Deleting that handler removes the
TS↔Python duplication; this is now the only place the list verb is realized.

The DSL → TemplateBinding logic stays in ``match_template.py`` and is invoked
in-process by ``render-video``; it is intentionally NOT exposed as a CLI verb.

List vs. detail (why ``--json-output`` no longer dumps everything):
  A registry row is 4–17 KB of JSON (customPayloadSchema + slotMapping +
  variants dominate), so ``--list-templates --json-output`` over a real
  registry produced a six-figure-character blob. Every caller of this CLI is
  an LLM tool call, and that blob overflowed the tool-result budget outright —
  the agent got an "exceeds maximum allowed tokens" error instead of the
  template definition it asked for, i.e. the mode that existed *to* read a
  template's contract was the one mode that could never be read.

  So the list verb emits **summaries** (identity + how to choose: name,
  description, aspects, language, status, tags, variant ids, the capability
  keys that drive authoring, a truncated llmHint) and ``--template-id <id>``
  emits the **full definition** of the one
  template the agent picked. That is the shape ``registry_loader``'s P1.2 note
  already anticipated, realized CLI-side so it works before the backend
  list/detail endpoints exist. ``--full`` still dumps whole definitions for
  programmatic callers, guarded by a size check so it can't silently recreate
  the overflow.

Flags:
  --list-templates                 (accepted; listing is the default verb)
  --template-id <id>               print the FULL definition of this template
                                   (repeatable); this is the detail verb
  --full                           in list mode, emit full definitions instead
                                   of summaries (size-guarded; prefer
                                   --template-id)
  --list-examples                  list *.dsl.json / *.binding.json reference
                                   examples grouped by templateId (consumed
                                   by downstream agents that want to read a
                                   template's reference shape before producing
                                   new DSL)
  --filter-tag <substr>            keep templates whose styleTags match
  --filter-aspect <ratio>          keep templates supporting this aspect ratio
  --filter-language <zh|en|...>    keep templates tagged with this content lang
                                   (templates with no contentLanguage are kept)
  --include-beta                   also show beta templates (else stable-only)
  --json-output                    emit {"templates": [...]} or {"examples": [...]}
                                   instead of a table
"""

from __future__ import annotations

import argparse
import json
import os
import sys
from pathlib import Path

# When run as `python3 <skillDir>/scripts/list_templates.py`, this script's own
# directory is sys.path[0], so the sibling shared modules import directly.
from registry_loader import (  # noqa: E402
    EXIT_BACKEND_UNREACHABLE,
    EXIT_NOT_AUTHENTICATED,
    RegistryAuthError,
    RegistryUnreachableError,
    get_template,
    list_templates as load_visible_templates,
)

# Summary llmHint budget: enough to tell templates apart when choosing, far
# short of the multi-KB authoring contract (read that with --template-id).
_SUMMARY_HINT_CHARS = 200

# Upper bound for a multi-template full dump (--full). ~60 K chars ≈ 20 K
# tokens: still large, but inside a tool-result budget, and the message it
# fails with names the flag that replaces it.
_MAX_FULL_DUMP_CHARS = 60_000


def _matches(tpl: dict, tag: str | None, aspect: str | None, language: str | None) -> bool:
    if tag:
        needle = tag.lower()
        tags = [str(t).lower() for t in (tpl.get("styleTags") or [])]
        if not any(needle in t or t in needle for t in tags):
            return False
    if aspect:
        want = aspect.lower()
        ratios = [str(r).lower() for r in (tpl.get("supportedAspectRatios") or [])]
        if want not in ratios:
            return False
    if language:
        # Unknown language (absent/empty) is never excluded by a language filter.
        langs = [str(l).lower() for l in (tpl.get("contentLanguage") or [])]
        if langs and language.lower() not in langs:
            return False
    return True


def _status_of(tpl: dict) -> str:
    raw = tpl.get("status")
    if isinstance(raw, str) and raw.strip():
        return raw.strip().lower()
    return "stable"


def _truncate(text: object, limit: int) -> str | None:
    if not isinstance(text, str) or not text.strip():
        return None
    text = text.strip()
    return text if len(text) <= limit else text[: limit - 3] + "..."


def _summarize(tpl: dict) -> dict:
    """The "choose a template" view: identity + selection criteria only.

    Deliberately excludes the authoring contract (llmHint in full,
    customPayloadSchema, slideSchemas, slotMapping, compositions, variants' style bodies) —
    that is what ``--template-id`` returns, one template at a time.
    """
    variants = tpl.get("variants")
    # Only the scalar capability keys that change how a caller *drives* the
    # template (narration vs typewriter caption, who decides duration). The
    # rest of `capabilities` — payloadDefaults above all — is bulk that scales
    # with the registry, so it stays in the detail view.
    caps = tpl.get("capabilities") if isinstance(tpl.get("capabilities"), dict) else {}
    caps_summary = {
        k: caps[k]
        for k in ("payloadStyle", "needsNarration", "durationStrategy", "narrationDriver")
        if k in caps
    }
    summary = {
        "templateId": tpl.get("templateId"),
        "name": tpl.get("name"),
        "description": tpl.get("description"),
        "status": _status_of(tpl),
        "supportedAspectRatios": tpl.get("supportedAspectRatios") or [],
        "contentLanguage": tpl.get("contentLanguage") or [],
        "styleTags": tpl.get("styleTags") or [],
        "variantIds": sorted(variants.keys()) if isinstance(variants, dict) else [],
        "capabilities": caps_summary,
        "llmHintPreview": _truncate(tpl.get("llmHint"), _SUMMARY_HINT_CHARS),
    }
    hint = tpl.get("llmHint")
    if isinstance(hint, str) and len(hint.strip()) > _SUMMARY_HINT_CHARS:
        summary["llmHintTruncated"] = True
    return summary


def _detail_hint(template_id: str = "<templateId>") -> str:
    return (
        f"Summaries only. Run --template-id {template_id} --json-output for one "
        "template's full definition (llmHint / customPayloadSchema / slideSchemas / "
        "slotMapping / variants) — that is the per-template authoring contract."
    )


# ----- Examples discovery (--list-examples) ---------------------------------
#
# Examples live under <SkillDir>/video_dsl/schema/examples/ as `*.dsl.json` and
# `*.binding.json` files. Naming convention (per gen-script SKILL.md) is
# `<templateId>.dsl.json` / `<templateId>.binding.json`, occasionally with
# variant suffixes (`<templateId>.<variant>.dsl.json`).
#
# We do not parse the JSON content here (downstream agents read the raw files
# they care about); we just enumerate paths so the agent doesn't have to guess
# the directory layout. When the directory does not exist (e.g. inside a slim
# package distribution), we return an empty list — non-fatal.

# Resolve the examples directory relative to this script.
#   <skill>/scripts/list_templates.py  →  <skill>/video_dsl/schema/examples/
_EXAMPLES_DIR = Path(__file__).resolve().parent.parent / "video_dsl" / "schema" / "examples"


def _collect_examples() -> list[dict]:
    if not _EXAMPLES_DIR.is_dir():
        return []
    by_template: dict[str, dict] = {}
    for entry in sorted(_EXAMPLES_DIR.iterdir()):
        if not entry.is_file():
            continue
        name = entry.name
        # Recognise *.dsl.json / *.binding.json
        if name.endswith(".dsl.json"):
            kind = "dsl"
            base = name[: -len(".dsl.json")]
        elif name.endswith(".binding.json"):
            kind = "binding"
            base = name[: -len(".binding.json")]
        else:
            continue
        # Variant: 'foo.bar.dsl.json' → templateId='foo', variant='bar'
        if "." in base:
            template_id, variant = base.split(".", 1)
        else:
            template_id, variant = base, None
        bucket = by_template.setdefault(template_id, {"templateId": template_id, "files": []})
        bucket["files"].append({
            "kind": kind,
            "variant": variant,
            "path": str(entry.relative_to(_EXAMPLES_DIR.parent.parent.parent)),  # relative to skill dir
            "absolutePath": str(entry),
            "sizeBytes": entry.stat().st_size,
        })
    return list(by_template.values())


def main() -> None:
    ap = argparse.ArgumentParser(
        description="List available video templates from the registry.",
    )
    ap.add_argument("--list-templates", action="store_true",
                    help="List every available template (summaries; the default verb).")
    ap.add_argument("--template-id", action="append", dest="template_ids", metavar="ID",
                    help="Print this template's FULL definition (repeatable).")
    ap.add_argument("--full", action="store_true",
                    help="List mode: emit full definitions instead of summaries "
                         "(size-guarded; prefer --template-id).")
    ap.add_argument("--list-examples", action="store_true",
                    help="List reference examples (*.dsl.json / *.binding.json) grouped by templateId.")
    ap.add_argument("--filter-tag", help="Keep templates whose styleTags match this substring.")
    ap.add_argument("--filter-aspect", help="Keep templates supporting this aspect ratio (e.g. 9:16).")
    ap.add_argument("--filter-language", help="Keep templates tagged with this content language (zh|en|...).")
    ap.add_argument("--include-beta", action="store_true", help="Also show beta templates (default: stable only).")
    ap.add_argument("--json-output", action="store_true", help='Emit JSON ({"templates":[...]} or {"examples":[...]}) instead of a table.')
    args = ap.parse_args()

    # --include-beta flips registry_loader's documented status gate via env, so
    # the CLI and in-process callers share one gating implementation.
    if args.include_beta:
        os.environ["ENABLE_BETA_TEMPLATES"] = "1"

    # ----- examples mode -----------------------------------------------------
    if args.list_examples:
        examples = _collect_examples()
        if args.json_output:
            print(json.dumps({"examples": examples}, ensure_ascii=False))
            return
        if not examples:
            # 这**不是**异常状态,而是常态:该目录从未进过仓库,也就从未随包发布过。
            #
            # 旧文案("This package may have shipped without reference examples;
            # check the cli source repo or upgrade @remixmate/cli")把常态说成了事故,
            # 而 gen-script 的 SKILL.md 又把「先读这里的参考 DSL」列为强制第一步 ——
            # 于是线上 agent 每次都在这里撞空,然后退回 gen_script 的骨架照抄,连模板
            # 自己声明的版式都不去看。升级 CLI 修不了它,因为没有哪一版带过这些文件。
            #
            # 真正随包到达调用方的逐模板契约是 registry 里的 template.json,所以这里
            # 直接把人指过去,而不是让它以为"该模板不受支持"。
            print(
                f"ℹ️  No local reference DSLs under {_EXAMPLES_DIR} — that directory is "
                "optional and is not part of the published package.\n"
                "   This does NOT mean the template is unsupported. The per-template "
                "contract that does ship is the registry's template.json:\n"
                "     • llmHint            — how this template's on-screen text and layouts must be authored\n"
                "     • customPayloadSchema — every template-specific field, incl. the legal slideId values\n"
                "     • slideSchemas        — for multi-layout templates: what templateData each slideId eats\n"
                "     • slotMapping        — propExtractors / requiredProps / optionalProps\n"
                "   Read it with `--template-id <id> --json-output` (one template's full "
                "definition). Run `--list-templates` first if you need the ids;\n"
                "   that view is summaries only — pointing it at the whole registry "
                "returns more JSON than a tool result can carry.",
            )
            return
        print(f"\n{'Template ID':<28} Files")
        print(f"{'-' * 28} {'-' * 60}")
        for bucket in examples:
            tid = str(bucket["templateId"])[:28].ljust(28)
            files = bucket["files"]
            for i, f in enumerate(files):
                marker = tid if i == 0 else " " * 28
                kind = f["kind"]
                variant = f"@{f['variant']}" if f.get("variant") else ""
                print(f"{marker} [{kind}{variant}] {f['path']} ({f['sizeBytes']}B)")
        print(f"\n{sum(len(b['files']) for b in examples)} example file(s) across {len(examples)} template(s)")
        return

    try:
        templates = load_visible_templates()
    except RegistryAuthError as exc:
        # 退出码 4 = 需要授权（见 registry_loader.EXIT_NOT_AUTHENTICATED），宿主
        # 据此引导用户重新登录，而不是把它当成一次普通失败。
        print(f"❌ {exc}", file=sys.stderr)
        sys.exit(EXIT_NOT_AUTHENTICATED)
    except RegistryUnreachableError as exc:
        # 退出码 5 = 后端不可达；重试或修配置即可，不需要重新登录。
        print(f"❌ {exc}", file=sys.stderr)
        sys.exit(EXIT_BACKEND_UNREACHABLE)
    except RuntimeError as exc:
        print(f"❌ {exc}", file=sys.stderr)
        sys.exit(1)

    # ----- detail mode (--template-id) ---------------------------------------
    #
    # Explicit ids win over --filter-*: the caller already chose, and silently
    # returning nothing because a stale filter excluded the pick would read as
    # "template does not exist".
    if args.template_ids:
        by_id = {t.get("templateId"): t for t in templates}
        picked: list[dict] = []
        missing: list[str] = []
        for tid in args.template_ids:
            tpl = by_id.get(tid)
            if tpl is None:
                missing.append(tid)
            else:
                picked.append(tpl)
        if missing:
            for tid in missing:
                # Distinguish "gated out by status" from "does not exist" —
                # otherwise a beta template reads as a typo and the caller
                # retries the id instead of passing --include-beta.
                gated = get_template(tid, include_all_statuses=True)
                if gated is not None:
                    print(
                        f"❌ Template '{tid}' exists but its status is "
                        f"'{_status_of(gated)}' — pass --include-beta to read it.",
                        file=sys.stderr,
                    )
                else:
                    print(f"❌ Unknown template id: '{tid}'", file=sys.stderr)
            known = ", ".join(sorted(str(t.get("templateId")) for t in templates))
            print(f"   Available ids: {known}", file=sys.stderr)
            sys.exit(1)
        if args.json_output:
            print(json.dumps({"templates": picked}, ensure_ascii=False))
        else:
            # Pretty-printed rather than tabular: the whole point of detail mode
            # is the nested contract (customPayloadSchema / slotMapping), which
            # a table cannot show.
            print(json.dumps({"templates": picked}, ensure_ascii=False, indent=2))
        return

    visible = [
        t for t in templates
        if _matches(t, args.filter_tag, args.filter_aspect, args.filter_language)
    ]

    if args.json_output:
        if args.full:
            payload = json.dumps({"templates": visible}, ensure_ascii=False)
            if len(payload) > _MAX_FULL_DUMP_CHARS and len(visible) > 1:
                print(
                    f"❌ Full definitions for {len(visible)} template(s) are "
                    f"{len(payload):,} characters — past the {_MAX_FULL_DUMP_CHARS:,}-char "
                    "cap, and past what a tool result can carry.\n"
                    "   Read one template at a time with `--template-id <id> --json-output`, "
                    "or narrow with --filter-tag / --filter-aspect / --filter-language.",
                    file=sys.stderr,
                )
                sys.exit(1)
            print(payload)
            return
        print(json.dumps(
            {
                "templates": [_summarize(t) for t in visible],
                "detailHint": _detail_hint(),
            },
            ensure_ascii=False,
        ))
        return

    if not visible:
        print(
            "⚠️  No templates match the current filters "
            "(check --include-beta or relax --filter-*).",
        )
        return

    header = (
        f"\n{'Template ID':<22} {'Name':<18} {'Aspect':<12} "
        f"{'Lang':<8} {'Status':<8} Tags / LLM hint"
    )
    print(header)
    print(
        f"{'-' * 22} {'-' * 18} {'-' * 12} {'-' * 8} {'-' * 8} {'-' * 60}"
    )
    for tpl in visible:
        tid = str(tpl.get("templateId", ""))[:22].ljust(22)
        name = str(tpl.get("name", ""))[:18].ljust(18)
        ratios = ",".join(tpl.get("supportedAspectRatios") or [])[:12].ljust(12)
        lang = (",".join(tpl.get("contentLanguage") or []) or "-")[:8].ljust(8)
        status = _status_of(tpl).ljust(8)
        tags = ", ".join((tpl.get("styleTags") or [])[:5])
        print(f"{tid} {name} {ratios} {lang} {status} {tags}")
        hint = _truncate(tpl.get("llmHint"), _SUMMARY_HINT_CHARS)
        if hint:
            print(f"{' ' * 22}   ↳ {hint}")

    print(f"\n{len(visible)} template(s) shown")
    first_id = str(visible[0].get("templateId", "<templateId>"))
    print(
        f"Full definition of one template: --template-id {first_id} --json-output "
        "(llmHint / customPayloadSchema / slideSchemas / slotMapping / variants)",
    )


if __name__ == "__main__":
    main()
