#!/usr/bin/env python3
"""
模板 registry 加载器 - 单一数据源（ab-api HTTP）。

为什么独立成一个模块：
  template-registry、render-video 等多个 skill 都要读 registry，集中在一处便于：
    - 缓存策略一致（HTTP 拉取 5 分钟 TTL + ETag 复用）
    - 错误降级一致（HTTP 故障时复用上一次磁盘缓存）

单一数据源（设计取舍）：
  registry 只从 ab-api HTTP 接口加载，避免「本地文件 / monorepo 源码 / 数据库」
  多源并存导致的不一致 —— 私有 / 多租户模板只存在于 ab-api，本地源永远不全。

  URL 解析：
    1. 显式 VIDEO_TEMPLATE_REGISTRY_URL（若设置则直接用）
    2. 否则按 CLI 后端约定推导：
         <MM_API_BASE_URL>/remotionTemplate/registry
       MM_API_BASE_URL 默认 https://api.remixmate.ai/api（与 src/http.ts 一致），
       兼容 ab-agent 注入的 MM_BACKEND_API_URL。默认值内置在 CLI 自身，独立安装
       （codex / npm i -g）开箱即用，无需宿主在 spawn 时注入。

  鉴权：只读 PRIV_TOKEN 环境变量。凭证库 / 钥匙串 / 设备授权流由 CLI 的 Node 侧
  唯一鉴权入口负责（src/auth/ensure.ts），并在 spawn 时注入本进程环境。

  monorepo / 显式本地文件等其它来源已**移除**。需要离线 / pinned 数据的维护脚本
  （check_contracts.py / sync_registry.py）直接走 template_paths 读 monorepo，
  那是 monorepo-only 工具，不是运行时数据源。

P1.2 — list/detail split (待后端就绪)：
  100+ 模板时 ``GET /api/template/list`` 应该只返摘要（id / name / aspects /
  styleTags / status / llmHint），``GET /api/template/{id}`` 按需返完整定义。
  目前本模块一次拉整个 registry。后端 endpoint 就绪后只需在 ``_fetch_http``
  下新增 ``_fetch_summary`` / ``_fetch_detail`` 两个函数,``load_registry_data``
  改成「先拉摘要,触发 ``get_template`` 时再补全」,公共 API 不变。
  调用方今天用 ``get_template(id)`` / ``list_templates()`` 已经是按需消费的形态,
  迁移时不需要改任何 caller。
"""

from __future__ import annotations

import hashlib
import json
import os
import sys
import tempfile
import time
import urllib.error
import urllib.request
from functools import lru_cache
from typing import Optional

__all__ = [
    "load_registry_data",
    "load_registry_indexed",
    "get_template",
    "require_visible_template",
    "gated_status",
    "list_templates",
    "invalidate_cache",
    "RegistryAuthError",
    "RegistryUnreachableError",
    "TemplateStatusGatedError",
    "EXIT_NOT_AUTHENTICATED",
    "EXIT_BACKEND_UNREACHABLE",
]

# 与 Node 侧 src/errors.ts 的 EXIT 常量保持一致：宿主据此判断「该引导用户授权」
# 还是「后端故障，等一会儿再试」，而不是把两者都当成一次普通失败。
EXIT_NOT_AUTHENTICATED = 4
EXIT_BACKEND_UNREACHABLE = 5


class RegistryAuthError(RuntimeError):
    """凭证缺失 / 失效 / 被吊销 —— 与「后端不可达」区分开的独立失败模式。

    这两种情况以前被压成同一段「请确认 ab-api 可达…并已设置有效 PRIV_TOKEN」的
    文案，用户无从判断该去登录还是该等后端恢复。
    """


class RegistryUnreachableError(RuntimeError):
    """网络不通 / 超时 / 5xx —— 用户无需重新登录，重试或修配置即可。"""


class TemplateStatusGatedError(RuntimeError):
    """模板存在，但它的 ``status`` 不在当前进程的可见集合里（见 ``_allowed_statuses``）。

    与「id 不存在」分开的独立失败模式：前者要改 id，后者要开门控开关
    （``ENABLE_BETA_TEMPLATES=1``）或把模板转正。压成同一个 ``None`` 返回值的后果见
    ``require_visible_template`` 的文档。
    """

# 默认缓存 TTL（秒）。HTTP 拉取后该时间内不再重新请求。
_DEFAULT_TTL_SECONDS = 300

# HTTP 请求超时（秒）。registry 体积小，超时设短一些避免阻塞。
_DEFAULT_HTTP_TIMEOUT = 5

# 缓存目录：默认 /tmp，CI/容器中也能写。
_CACHE_DIR = os.environ.get(
    "VIDEO_TEMPLATE_REGISTRY_CACHE_DIR",
    os.path.join(tempfile.gettempdir(), "ab-template-registry"),
)


# CLI 默认后端 base，与 remixmate-cli/src/http.ts 的 DEFAULT_API_BASE_URL 保持一致。
_DEFAULT_API_BASE_URL = "https://api.remixmate.ai/api"


def _default_registry_url() -> str:
    """未显式配置 VIDEO_TEMPLATE_REGISTRY_URL 时，推导默认 ab-api registry endpoint。

    默认值内置在 CLI 自身（而非依赖 ab-agent 等宿主在 spawn 时注入），这样
    ``remixmate-cli`` 独立安装（codex / ``npm i -g``）也能开箱即用。

    base 解析顺序与 CLI / ab-agent 对齐：
      MM_API_BASE_URL（CLI ``src/http.ts`` 约定）
        → MM_BACKEND_API_URL（ab-agent 约定，向后兼容）
        → https://api.remixmate.ai/api（生产默认，零配置）
    路径段固定为 ``/remotionTemplate/registry``（POST，返回 ``{code,msg,data}``）。
    """
    base = (
        os.environ.get("MM_API_BASE_URL", "").strip()
        or os.environ.get("MM_BACKEND_API_URL", "").strip()
        or _DEFAULT_API_BASE_URL
    ).rstrip("/")
    return f"{base}/remotionTemplate/registry"


def _cache_path_for(url: str) -> str:
    """根据 URL 生成本地缓存文件路径，URL 不同则缓存隔离。"""
    digest = hashlib.sha256(url.encode("utf-8")).hexdigest()[:16]
    return os.path.join(_CACHE_DIR, f"registry-{digest}.json")


def _read_cached(cache_file: str) -> Optional[dict]:
    """读取缓存元数据 + 内容；任何异常都返回 None。"""
    if not os.path.exists(cache_file):
        return None
    try:
        with open(cache_file, "r", encoding="utf-8") as f:
            return json.load(f)
    except (OSError, json.JSONDecodeError):
        return None


def _write_cached(cache_file: str, payload: dict) -> None:
    """原子化写入缓存（先写临时文件再 rename，避免半写文件）。"""
    os.makedirs(os.path.dirname(cache_file), exist_ok=True)
    tmp_fd, tmp_path = tempfile.mkstemp(dir=os.path.dirname(cache_file))
    try:
        with os.fdopen(tmp_fd, "w", encoding="utf-8") as f:
            json.dump(payload, f, ensure_ascii=False)
        os.replace(tmp_path, cache_file)
    except Exception:
        try:
            os.unlink(tmp_path)
        except OSError:
            pass
        raise


# ── PrivToken 解析 ────────────────────────────────────────────────────────
#
# 只读 PRIV_TOKEN 环境变量 —— 凭证库 / 系统钥匙串 / 设备授权流全部由 Node 侧的
# 唯一鉴权入口（src/auth/ensure.ts）负责，并在 spawn 时把解析结果注入本进程的
# 环境变量（src/runner.ts）。
#
# 这里曾经自己读 ~/.config/remixmate/credentials.json，带来两个问题：一是与 Node
# 侧的解析逻辑各写一份、只有本 skill 享受得到；二是「只有一条凭证就直接用」的兜底
# 会把某个后端（如本地开发环境）的 token 发往另一个后端（如生产），属于跨源泄露。


def _resolve_priv_token(_url: str) -> str:
    """PrivToken 解析：只认由 CLI 注入 / 宿主设置的 PRIV_TOKEN 环境变量。"""
    return os.environ.get("PRIV_TOKEN", "").strip()


def _fetch_http(url: str, timeout: int) -> dict:
    """执行 HTTP 请求，自动加 X-Priv-Token / If-None-Match 头。

    支持两种 endpoint 形态：
      - 老形态（@ab-templates/metadata 静态文件）：GET 返回纯 registry JSON
      - ab-api 形态：POST 返回 {code, msg, data: <registry>} 包装

    通过环境变量 VIDEO_TEMPLATE_REGISTRY_HTTP_METHOD 切换，默认 POST 适配 ab-api。
    """
    cache_file = _cache_path_for(url)
    cached = _read_cached(cache_file)

    method = os.environ.get("VIDEO_TEMPLATE_REGISTRY_HTTP_METHOD", "POST").upper()

    headers = {"Accept": "application/json"}
    if method == "POST":
        headers["Content-Type"] = "application/json"
    if token := _resolve_priv_token(url):
        headers["X-Priv-Token"] = token
    if cached and (etag := cached.get("etag")):
        headers["If-None-Match"] = etag

    body = b"{}" if method == "POST" else None
    req = urllib.request.Request(url, data=body, headers=headers, method=method)

    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            raw = resp.read().decode("utf-8")
            etag = resp.headers.get("ETag", "")
            parsed = json.loads(raw)

            # ab-api 风格：{code, msg, data} → 取 data；其他场景视作直接 registry
            if isinstance(parsed, dict) and "data" in parsed and "code" in parsed:
                code = parsed.get("code")
                if str(code) in ("401", "403"):
                    # ab-api 用 HTTP 200 + code=401 表达鉴权失败，所以这里也要判。
                    raise RegistryAuthError(str(parsed.get("msg") or f"code={code}"))
                if code not in (0, "0"):
                    raise RuntimeError(
                        f"registry HTTP 业务失败: code={code} msg={parsed.get('msg')}"
                    )
                data = parsed["data"]
            else:
                data = parsed

            _write_cached(
                cache_file,
                {"fetchedAt": time.time(), "etag": etag, "data": data},
            )
            return data
    except urllib.error.HTTPError as e:
        # 304 Not Modified — 复用缓存内容
        if e.code == 304 and cached:
            cached["fetchedAt"] = time.time()
            _write_cached(cache_file, cached)
            return cached["data"]
        if e.code in (401, 403):
            raise RegistryAuthError(f"HTTP {e.code}") from e
        raise


def _load_from_url(url: str, ttl: int, timeout: int) -> dict:
    """先看缓存是否在 TTL 内；超期则远程拉取。失败时降级到磁盘缓存（同一 URL
    上一份成功响应），无缓存则抛错 —— 不再回退本地文件 / monorepo，保证单一数据源。
    """
    cache_file = _cache_path_for(url)
    cached = _read_cached(cache_file)
    now = time.time()

    if cached and (now - cached.get("fetchedAt", 0)) < ttl:
        return cached["data"]

    try:
        return _fetch_http(url, timeout)
    except RegistryAuthError as exc:
        # 鉴权失败不降级到缓存：缓存可能属于另一个账号，静默返回它会把「凭证已失效」
        # 掩盖成一份看似正常、实则不属于当前用户的模板列表。
        raise RegistryAuthError(
            f"凭证无效或已过期（{exc}）。运行 remixmate login 重新授权，"
            f"或设置有效的 PRIV_TOKEN。当前后端：{url}"
        ) from exc
    except Exception as exc:
        # HTTP 故障时降级：有磁盘缓存就继续用（同一数据源的容错，不是第二个源）
        if cached:
            print(
                f"⚠️  registry HTTP 拉取失败，降级使用本地缓存: {exc}",
                file=sys.stderr,
            )
            return cached["data"]
        raise RegistryUnreachableError(
            f"registry 后端不可达且无可用缓存: {url} ({exc})。"
            f" 请确认 ab-api 可达、VIDEO_TEMPLATE_REGISTRY_URL / MM_API_BASE_URL 配置正确。"
        ) from exc


def load_registry_data(
    *,
    ttl_seconds: int = _DEFAULT_TTL_SECONDS,
    http_timeout: int = _DEFAULT_HTTP_TIMEOUT,
) -> dict:
    """加载 registry 完整文档（含 version / generatedAt / templates）。

    单一数据源：ab-api HTTP。URL 取显式 ``VIDEO_TEMPLATE_REGISTRY_URL``，
    未设置则按 ``MM_API_BASE_URL`` 推导（见 ``_default_registry_url``）。
    带磁盘缓存（TTL + ETag/304），返回 ``data.get("templates", [])`` 形状的 dict。
    """
    # 这两行是实现细节（endpoint 从哪来），对终端用户没有意义，却出现在每一次
    # 成功调用的 stderr 上。默认收起，排障时用 REMIXMATE_DEBUG=1 打开。
    debug = os.environ.get("REMIXMATE_DEBUG", "").strip() == "1"
    url = os.environ.get("VIDEO_TEMPLATE_REGISTRY_URL", "").strip()
    if url:
        if debug:
            print(f"[registry_loader] using HTTP registry: {url}", file=sys.stderr)
    else:
        url = _default_registry_url()
        if debug:
            print(
                f"[registry_loader] VIDEO_TEMPLATE_REGISTRY_URL unset; using derived"
                f" default URL: {url}",
                file=sys.stderr,
            )
    return _load_from_url(url, ttl=ttl_seconds, timeout=http_timeout)


# ════════════════════════════════════════════════════════════════════════════
# Indexed views — O(1) template-id lookup + status / beta gating
#
# `load_registry_data` returns the raw registry document; consumers that
# repeatedly look up templates by id (validator, render_video, gen-script)
# used to do `for tpl in registry.get("templates", []): if tpl["templateId"]
# == X: ...` — O(N) per call. With 100+ templates that becomes 100 scans for
# every validate() invocation. The helpers below materialize the list into a
# dict[templateId → tpl] once per process and serve everyone from there.
#
# `status` / `version` are P2.2 fields on template.json. When templates
# don't declare them yet, we treat them as stable + version "0.0.0" — pure
# additive behavior, no regressions on legacy templates.
# ════════════════════════════════════════════════════════════════════════════

_DEFAULT_STATUS = "stable"
_BETA_GATE_ENV = "ENABLE_BETA_TEMPLATES"


def _truthy(value: str) -> bool:
    """统一解析布尔型环境变量：1 / true / yes / on（大小写不敏感）。"""
    return value.strip().lower() in ("1", "true", "yes", "on")


def _truthy_env(name: str) -> bool:
    return _truthy(os.environ.get(name, ""))


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


def _allowed_statuses() -> set[str]:
    """Which template statuses the current process is allowed to see.

    Default: only `stable`. Set `ENABLE_BETA_TEMPLATES=1` (or any truthy
    value) to also include `beta`. `deprecated` is always hidden — callers
    that explicitly need it can call ``load_registry_data`` directly.
    """
    allowed = {"stable"}
    if _truthy_env(_BETA_GATE_ENV):
        allowed.add("beta")
    return allowed


@lru_cache(maxsize=4)
def _indexed_view(allowed_statuses_key: Optional[tuple[str, ...]]) -> dict[str, dict]:
    """Build (and memoize) ``{templateId: tpl}`` keyed by the allowed-status set.

    The key is a sorted tuple so equal sets map to the same cache entry. The
    HTTP/file load underneath has its own TTL — this layer only avoids
    re-iterating the list across the typical 10-100 lookups per render run.
    """
    raw = load_registry_data()
    allow_filter = allowed_statuses_key is not None
    allowed = set(allowed_statuses_key) if allowed_statuses_key else set()
    out: dict[str, dict] = {}
    for tpl in raw.get("templates", []) or []:
        tid = tpl.get("templateId")
        if not isinstance(tid, str) or not tid:
            continue
        if allow_filter and _resolve_status(tpl) not in allowed:
            continue
        out[tid] = tpl
    return out


def load_registry_indexed(
    *,
    ttl_seconds: int = _DEFAULT_TTL_SECONDS,
    http_timeout: int = _DEFAULT_HTTP_TIMEOUT,
    include_all_statuses: bool = False,
) -> dict[str, dict]:
    """Return registry as ``{templateId: template_dict}``.

    Filters by the process-wide status gate unless ``include_all_statuses``
    is set (used by maintenance tooling like ``check_contracts``). Templates
    missing ``status`` are treated as ``stable`` for backwards compatibility.

    ``ttl_seconds`` / ``http_timeout`` are accepted for API symmetry with
    ``load_registry_data`` but the memoization layer below is per-process
    rather than time-based; call ``invalidate_cache()`` to force a reread.
    """
    if include_all_statuses:
        return _indexed_view(None)
    return _indexed_view(tuple(sorted(_allowed_statuses())))


def get_template(template_id: str, *, include_all_statuses: bool = False) -> Optional[dict]:
    """Return a single template dict by id, or None when missing/filtered out."""
    if not template_id:
        return None
    return load_registry_indexed(include_all_statuses=include_all_statuses).get(template_id)


def gated_status(template_id: str) -> Optional[str]:
    """模板存在、但被状态门控挡在可见集合外时返回它的真实 status；否则 None。

    ``get_template`` 用 ``None`` 同时表达「registry 里没有这个 id」和「有,但被
    门控挡了」两件完全不同的事。前者是调用方拼错了 id,后者是运行时配置问题
    （模板确实在线,只是当前进程没开 beta）—— 两者的处置方式不同,却在一个返回
    值里混成一样。这个函数把后者单独识别出来。
    """
    if not template_id:
        return None
    if get_template(template_id) is not None:
        return None
    tpl = get_template(template_id, include_all_statuses=True)
    return _resolve_status(tpl) if tpl is not None else None


def require_visible_template(template_id: str) -> Optional[dict]:
    """与 ``get_template`` 相同,但模板**被状态门控挡住**时抛异常而不是返回 None。

    为什么需要它：``get_template`` 返回 None 之后,调用方普遍的写法是「拿不到就用
    默认值继续」—— 对"这个 id 不存在"是合理的容错,对"模板在线但本进程看不到"却是
    灾难性的静默降级:模板的 llmHint / customPayloadSchema / capabilities 全部蒸发,
    脚本按通用骨架生成、payload 契约整段跳过,最后烧掉渲染积分换一条废片,而日志里
    一个字都没有。

    返回 None 只剩一个含义：registry 里没有这个 templateId。
    """
    tpl = get_template(template_id)
    if tpl is not None:
        return tpl
    status = gated_status(template_id)
    if status is None:
        return None
    raise TemplateStatusGatedError(
        f"模板 '{template_id}' 存在,但状态是 '{status}',当前进程的可见集合只有 "
        f"{sorted(_allowed_statuses())}。设置 {_BETA_GATE_ENV}=1 放行 beta,"
        f"或把该模板转为 stable —— 继续往下走只会拿不到它的契约并产出通用结果。"
    )


def list_templates(*, include_all_statuses: bool = False) -> list[dict]:
    """Return the visible templates as a list (status-filtered)."""
    return list(load_registry_indexed(include_all_statuses=include_all_statuses).values())


def invalidate_cache() -> None:
    """Drop the indexed-view memoization. Mostly for tests / hot reload.

    Note: the on-disk HTTP cache is independent and respects its own TTL;
    this only resets the in-process memoization built by ``lru_cache``.
    """
    _indexed_view.cache_clear()
