#!/usr/bin/env python3
"""Grix 中转（虚拟 Key）在**独立 Hermes** profile 上的落地执行端。

与连接器托管的 agent 不同：独立 Hermes 是用户自己跑的 gateway 进程，连接器既不 spawn 它、
也不在 agents.json 里认识它，因此没有"spawn 时注入 env"这一步——只能直接改它 profile 里的
config.yaml。目标 profile **不靠传路径、靠 agent_id 认**：每个接了 Grix 的 profile 都在
``channels.grix.wsUrl`` 里带着自己的 ``agent_id``，用它跟服务端下发的 agent_id 对上号。

**为什么必须用 Hermes 自己的解释器跑**（而不是在连接器里用 Node 解析 YAML）：
配置的读写全交给宿主的 ``hermes_cli.config``，它负责原子写（temp+fsync+replace）、保留原
文件权限与 owner、对照 raw 配置剥离默认值、以及把文件尾那段注释区重新写回去。自己解析回写
YAML 会把注释区丢掉，也会跟宿主的归一化规则漂移。写入语义与用户在聊天里敲 ``/model`` 完全
一致，走的是同一个 ``save_config``。

协议：stdin 收一条 JSON 请求，stdout 回一条 JSON 响应。api_key 绝不进输出。
"""
from __future__ import annotations

import contextlib
import copy
import io
import json
import os
import shutil
import sys
import time
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import parse_qs, urlparse

# 这个脚本躺在连接器的源码树里，而 Python 会把"脚本所在目录"和 cwd 塞进 sys.path 的最前面。
# 宿主 Hermes 内部大量使用顶层 import（utils、gateway 等），一旦连接器目录下有同名文件或目录，
# 它就会解析错位并炸在 import 上。清掉这两个来源——本脚本不从相邻目录 import 任何东西。
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path[:] = [
    entry for entry in sys.path
    if entry and os.path.abspath(entry) != _HERE
]

try:
    from hermes_cli.config import (
        clear_model_endpoint_credentials,
        read_raw_config,
        save_config,
    )
    from hermes_cli.profiles import list_profiles
except Exception as _import_exc:  # noqa: BLE001 - 必须报清楚，不能被下游当成"没有 profile"
    print(json.dumps({
        "ok": False,
        "error_code": "HERMES_IMPORT_FAILED",
        "error_msg": f"cannot import hermes modules ({type(_import_exc).__name__}: {_import_exc}); "
                     "this script must run with hermes' own interpreter",
    }))
    sys.exit(1)

# 连接器写进 Hermes 的固定 provider id。
GRIX_PROVIDER_ID = "grix"
# 记录开中转前的原模型设置，供关闭时精确还原。放 profile 目录，不进 config.yaml。
SIDECAR_NAME = ".grix-relay-state.json"
SNAPSHOT_PREFIX = "config.yaml.grix-relay-snapshot."
# 快照是含凭证的整份配置副本，不能无限堆。每次开中转建新的，只留最近这几个。
SNAPSHOT_KEEP = 3


class RelayError(Exception):
    def __init__(self, code: str, msg: str) -> None:
        super().__init__(msg)
        self.code = code
        self.msg = msg


def _redact(text: str, req: Dict[str, Any]) -> str:
    """把本次请求里的 api_key 从要吐出去的文本里抹掉。

    宿主抛的异常今天恰好不回显输入，但那不是能长期依赖的保证——换个带输入回显的异常，Key 就
    跟着错误信息出去了。真做脱敏，而不是在注释里许诺。
    """
    secret = str(req.get("api_key") or "").strip()
    if secret and secret in text:
        text = text.replace(secret, "***redacted***")
    return text


def _agent_id_of(cfg: Dict[str, Any]) -> str:
    """从 ``channels.grix.wsUrl`` 的 query 里取 agent_id —— profile 与平台 agent 的唯一锚点。"""
    channels = cfg.get("channels")
    if not isinstance(channels, dict):
        return ""
    grix = channels.get(GRIX_PROVIDER_ID)
    if not isinstance(grix, dict):
        return ""
    ws_url = grix.get("wsUrl")
    if not isinstance(ws_url, str) or not ws_url.strip():
        return ""
    try:
        values = parse_qs(urlparse(ws_url).query).get("agent_id") or []
    except Exception:
        return ""
    return str(values[0]).strip() if values else ""


def _config_path(home: str) -> str:
    return os.path.join(home, "config.yaml")


def _read_profile_config(home: str) -> Dict[str, Any]:
    """读某个 profile 的原始配置（不合并默认值）。切 HERMES_HOME 即切目标 profile。

    宿主的 ``read_raw_config`` **自己吞掉解析错误并回一个空 dict**，永远不抛。不识别这一点的话，
    一个语法坏掉的 config.yaml 会表现成"这个 profile 没接 Grix"，最终报 PROFILE_NOT_FOUND——
    把人引向完全错误的方向。文件有内容却解析成空 ⇒ 它是坏的，如实抛出去。
    """
    os.environ["HERMES_HOME"] = home
    cfg = read_raw_config() or {}
    if not cfg:
        path = _config_path(home)
        try:
            broken = os.path.exists(path) and os.path.getsize(path) > 0
        except OSError:
            broken = False
        if broken:
            raise RelayError(
                "CONFIG_UNREADABLE",
                f"config.yaml exists but hermes parsed it as empty (likely malformed): {path}",
            )
    return cfg


def _save_profile_config(home: str, cfg: Dict[str, Any]) -> None:
    os.environ["HERMES_HOME"] = home
    save_config(cfg)


def _verify_write(home: str, expect_relay: bool) -> None:
    """回读校验：写入必须真的落盘了。

    宿主的 ``save_config`` 在托管安装下（``is_managed()``）只往 stderr 打一行就 return，**不抛
    异常**——不校验的话，接口会在配置一个字节都没变的情况下报成功。跨进程覆盖（gateway 同时
    在写同一份 config，双方都是 read-modify-write）同样会让我们的写入无声消失。回读是把这两种
    假成功变成可见失败的唯一办法。
    """
    fresh = _read_profile_config(home)
    providers = fresh.get("providers")
    has_provider = isinstance(providers, dict) and GRIX_PROVIDER_ID in providers
    on_grix = _relay_active(fresh)

    if expect_relay and not (has_provider and on_grix):
        raise RelayError(
            "CONFIG_WRITE_VERIFY_FAILED",
            "config.yaml did not take the relay settings after save "
            f"(grix provider present={has_provider}, model points at grix={on_grix}); "
            "the write was silently dropped — hermes may be a managed install, "
            "or its gateway overwrote the file concurrently",
        )
    # 关中转只看"模型还指不指着 grix"。grix provider 本身是**故意留下**的（虚拟 Key 是按
    # agent 绑定的长期凭证，删了下次开就得换新 Key、统计会断），它的存在不代表中转还开着。
    if not expect_relay and on_grix:
        raise RelayError(
            "CONFIG_WRITE_VERIFY_FAILED",
            "config.yaml still points the model at the grix provider after save; "
            "the write was silently dropped — hermes may be a managed install, "
            "or its gateway overwrote the file concurrently",
        )


def _iter_profiles() -> List[Tuple[str, str, bool]]:
    """(name, home, gateway_running)，含 default profile。用宿主的枚举，不自己拼路径。"""
    out: List[Tuple[str, str, bool]] = []
    for info in list_profiles():
        out.append((str(info.name), str(info.path), bool(getattr(info, "gateway_running", False))))
    return out


def _model_section(cfg: Dict[str, Any]) -> Dict[str, Any]:
    raw = cfg.get("model")
    if isinstance(raw, dict):
        return raw
    section: Dict[str, Any] = {}
    # 历史上 model 可能是个裸字符串（模型名），按宿主的语义归一成 {default: <name>}。
    if isinstance(raw, str) and raw.strip():
        section["default"] = raw.strip()
    cfg["model"] = section
    return section


def _relay_active(cfg: Dict[str, Any]) -> bool:
    """这个 profile 当前是不是正开着中转。

    判据是**模型指没指着 grix**，不是"sidecar 在不在"。两者在"用户中途自己 /model 切走后又
    重开中转"这个场景里会分岔：sidecar 还在，但盘上的 model 段已经是用户的新选择了。
    """
    raw = cfg.get("model")
    if not isinstance(raw, dict):
        return False
    return str(raw.get("provider") or "").strip() == GRIX_PROVIDER_ID


def _sidecar_path(home: str) -> str:
    return os.path.join(home, SIDECAR_NAME)


def _read_sidecar(home: str) -> Optional[Dict[str, Any]]:
    path = _sidecar_path(home)
    if not os.path.exists(path):
        return None
    try:
        with open(path, encoding="utf-8") as handle:
            data = json.load(handle)
        return data if isinstance(data, dict) else None
    except Exception:
        # 记录读坏了就当没有：宁可不还原 model 段，也不能拿瞎猜的值去覆盖用户配置。
        return None


def _write_sidecar(home: str, data: Dict[str, Any]) -> None:
    # 用 os.open 一步带权限创建：先 open(w) 再 chmod 的话，中间有一个窗口文件是 umask 默认权限，
    # 而这里面存着用户自己的 api_key（我们从 model 段抄下来的原值）。
    path = _sidecar_path(home)
    fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
    with os.fdopen(fd, "w", encoding="utf-8") as handle:
        json.dump(data, handle, indent=2, ensure_ascii=False)


def _prune_snapshots(home: str, keep: int = SNAPSHOT_KEEP) -> None:
    """只留最近 keep 个快照——每个都是含凭证的整份配置副本，不能无限堆。"""
    try:
        rows = sorted(
            (name for name in os.listdir(home)
             if name.startswith(SNAPSHOT_PREFIX) and name.endswith(".bak")),
            reverse=True,
        )
        for name in rows[keep:]:
            with contextlib.suppress(OSError):
                os.remove(os.path.join(home, name))
    except OSError:
        pass


def _snapshot(home: str) -> Optional[str]:
    """开中转前存一份整份配置快照，纯逃生舱：不参与自动还原，只给人工兜底。"""
    src = _config_path(home)
    if not os.path.exists(src):
        return None
    # 时间戳带上微秒：只精确到秒的话，同一秒内的两次开启会互相覆盖。
    stamp = time.strftime("%Y%m%d-%H%M%S") + "-%06d" % (int(time.time() * 1_000_000) % 1_000_000)
    dst = os.path.join(home, SNAPSHOT_PREFIX + stamp + ".bak")
    try:
        shutil.copy2(src, dst)
    except Exception:
        return None
    _prune_snapshots(home)
    return os.path.basename(dst)


def _find_profile(agent_id: str) -> Tuple[str, str, bool, Dict[str, Any]]:
    """按 agent_id 定位 profile。匹配不到、或多个 profile 抢同一个 agent_id，都要报错不猜。"""
    agent_id = (agent_id or "").strip()
    if not agent_id:
        raise RelayError("MISSING_AGENT_ID", "agent_id is required")

    matches: List[Tuple[str, str, bool, Dict[str, Any]]] = []
    unreadable: List[str] = []
    for name, home, running in _iter_profiles():
        try:
            cfg = _read_profile_config(home)
        except Exception as exc:  # noqa: BLE001
            # 读不了的 profile 可能正是目标。绝不能静默跳过后报"没找到"——那会让一个坏掉的
            # config.yaml 表现成"这个 agent 不是 hermes"，把人引向完全错误的方向。
            unreadable.append(f"{name} ({type(exc).__name__}: {exc})")
            continue
        if _agent_id_of(cfg) == agent_id:
            matches.append((name, home, running, cfg))

    if not matches:
        detail = f"no hermes profile is bound to agent_id {agent_id}"
        if unreadable:
            detail += f"; {len(unreadable)} profile(s) could not be read: {'; '.join(unreadable)}"
        raise RelayError("PROFILE_NOT_FOUND", detail)
    if len(matches) > 1:
        names = ", ".join(sorted(m[0] for m in matches))
        raise RelayError(
            "AMBIGUOUS_AGENT_ID",
            f"agent_id {agent_id} is claimed by multiple profiles: {names}",
        )
    return matches[0]


def cmd_discover(_req: Dict[str, Any]) -> Dict[str, Any]:
    profiles = []
    unreadable: List[Dict[str, str]] = []
    for name, home, running in _iter_profiles():
        try:
            cfg = _read_profile_config(home)
        except Exception as exc:  # noqa: BLE001 - 报出来，不装作这个 profile 不存在
            unreadable.append({"name": name, "home": home, "error": f"{type(exc).__name__}: {exc}"})
            continue
        model = _model_section(dict(cfg))
        agent_id = _agent_id_of(cfg)
        if not agent_id:
            # 没接 Grix 的 profile（比如纯本地用的）不是我们的目标，直接跳过。
            continue
        profiles.append({
            "name": name,
            "home": home,
            "agent_id": agent_id,
            "gateway_running": running,
            "relay_active": str(model.get("provider") or "").strip() == GRIX_PROVIDER_ID,
            "model_provider": model.get("provider"),
            "model_default": model.get("default"),
        })
    return {"ok": True, "profiles": profiles, "unreadable": unreadable}


def cmd_apply(req: Dict[str, Any]) -> Dict[str, Any]:
    base_url = str(req.get("base_url") or "").strip()
    api_key = str(req.get("api_key") or "").strip()
    model = str(req.get("model") or "").strip()
    if not base_url:
        raise RelayError("MISSING_BASE_URL", "base_url is required to enable Grix relay")
    if not api_key:
        raise RelayError("MISSING_API_KEY", "api_key is required to enable Grix relay")
    if not model:
        # hermes 的 provider 配置里模型名必填，缺了就是"写进去了、聊天必失败"的静默半配置。
        raise RelayError("MISSING_MODEL", "model is required (hermes provider config needs a model name)")

    name, home, running, cfg = _find_profile(req.get("agent_id"))
    model_cfg = _model_section(cfg)

    # 只在**当前没开着中转**时记原值——这时盘上的 model 段按定义就是用户自己的选择。
    #
    # 判据必须是"是否正在中转中"（_relay_active），不能是"sidecar 在不在"：
    #  - 中转中重复下发 → 不记，否则会把"中转中"的 grix 当原值存下来，关了再也回不去；
    #  - 用户中途 /model 切走后再重开 → sidecar 还在，但盘上已是用户的新选择，**必须刷新**，
    #    否则关中转时会拿陈旧的旧值把用户的选择冲掉。
    already_on = _relay_active(cfg)
    sidecar = _read_sidecar(home) if already_on else None
    if not already_on:
        sidecar = {
            # 整段快照，不手抄"我们会改哪些字段"的清单：宿主的 clear_model_endpoint_credentials
            # 哪天多清一个字段，手抄清单就会静默丢掉那个字段的原值。
            "previous_model": copy.deepcopy(model_cfg),
            "snapshot": _snapshot(home),
            "created_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
        }
        _write_sidecar(home, sidecar)

    providers = cfg.get("providers")
    if not isinstance(providers, dict):
        providers = {}
        cfg["providers"] = providers
    providers[GRIX_PROVIDER_ID] = {
        "name": "Grix",
        "api": base_url,
        "api_key": api_key,
        "default_model": model,
        "transport": "openai_chat",
    }

    # model.base_url / api_key 这些内联 endpoint 字段只对 provider=custom 有意义，留着会污染
    # 命名 provider 的解析（宿主自己切换非 custom provider 时也做同样的清理）。
    clear_model_endpoint_credentials(model_cfg, clear_base_url=True)
    model_cfg["provider"] = GRIX_PROVIDER_ID
    model_cfg["default"] = model

    _save_profile_config(home, cfg)
    _verify_write(home, expect_relay=True)
    return {
        "ok": True,
        "profile": name,
        "home": home,
        "model": model,
        "gateway_running": running,
        "restart_required": True,
        "snapshot": (sidecar or {}).get("snapshot"),
    }


def cmd_clear(req: Dict[str, Any]) -> Dict[str, Any]:
    name, home, running, cfg = _find_profile(req.get("agent_id"))
    model_cfg = _model_section(cfg)
    sidecar = _read_sidecar(home)
    snapshot = (sidecar or {}).get("snapshot")

    # **grix provider 连同虚拟 Key 原样留着，不删。**
    #
    # 虚拟 Key 是按 agent 绑定的长期凭证：服务端只在签发那一刻给一次明文，之后再要只能
    # resend——而 resend 会签一把新的并吊销旧的。关中转就把本地这份删掉的话，下次开中转
    # 就只能换新 Key，于是每开关一轮换一把，按 Key 统计的用量被切成互不相连的碎片。
    # 关中转要的是"不再走中转"，把模型指回用户原来的供应商就够了；留下的 grix provider
    # 没有任何东西指向它，不影响推理，下次开中转直接指回来即可。
    providers = cfg.get("providers")
    provider_kept = isinstance(providers, dict) and GRIX_PROVIDER_ID in providers

    # 中转开着的时候用户可能自己 /model 切走了——那是他的新选择，无脑把旧值写回去会把它冲掉。
    # 只有确认模型还指着 grix 时才动 model 段。
    still_on_grix = _relay_active(cfg)
    previous = (sidecar or {}).get("previous_model")
    restored = False
    model_cleared = False

    if still_on_grix:
        if isinstance(previous, dict):
            cfg["model"] = copy.deepcopy(previous)
            restored = True
        else:
            # sidecar 丢了或读坏了。**绝不能把 model.provider=grix 留在盘上**——它会指向一个
            # 刚被我们摘掉的 provider，配置直接是坏的，比还原和不还原都糟。清掉指向让 hermes
            # 回落到它自己的默认，并在响应里如实说明我们没能还原成用户原来的模型。
            model_cfg.pop("provider", None)
            model_cfg.pop("default", None)
            model_cleared = True

    if restored or model_cleared:
        _save_profile_config(home, cfg)
        _verify_write(home, expect_relay=False)

    sidecar_path = _sidecar_path(home)
    if os.path.exists(sidecar_path):
        with contextlib.suppress(Exception):
            os.remove(sidecar_path)

    result = {
        "ok": True,
        "profile": name,
        "home": home,
        # 中转 provider（连同虚拟 Key）留在配置里，供下次开中转直接复用——见上面为什么不删。
        "provider_kept": provider_kept,
        "restored_model": restored,
        # 用户中途自己换过模型：model 段是他自己的选择，我们没动。
        "user_switched_away": bool(not still_on_grix),
        "gateway_running": running,
        "restart_required": bool(restored or model_cleared),
    }
    if snapshot:
        # 逃生舱得告诉人它在哪，否则等于没有。
        result["snapshot"] = snapshot
    if model_cleared:
        result["model_cleared"] = True
        result["warning"] = (
            "relay state file was missing or unreadable: the grix provider was removed and the "
            "model selection was cleared (hermes will fall back to its default). The previous "
            "model settings could not be restored"
            + (f"; a pre-relay snapshot is kept at {snapshot}" if snapshot else "")
        )
    return result


COMMANDS = {"discover": cmd_discover, "apply": cmd_apply, "clear": cmd_clear}


def main() -> int:
    try:
        raw = sys.stdin.read()
        req = json.loads(raw) if raw.strip() else {}
    except Exception as exc:
        print(json.dumps({"ok": False, "error_code": "BAD_REQUEST", "error_msg": str(exc)}))
        return 1

    command = str(req.get("command") or "").strip()
    handler = COMMANDS.get(command)
    if handler is None:
        print(json.dumps({
            "ok": False,
            "error_code": "BAD_REQUEST",
            "error_msg": f"unknown command: {command or '(empty)'}",
        }))
        return 1

    # 宿主的配置层会往 stdout 打提示（managed 配置等），那会污染我们的 JSON 协议。
    # 业务执行期间把 stdout 折到 stderr，只有最终响应写回真正的 stdout。
    real_stdout = sys.stdout
    try:
        with contextlib.redirect_stdout(io.StringIO()) as captured:
            result = handler(req)
        noise = captured.getvalue().strip()
        if noise:
            print(noise, file=sys.stderr)
    except RelayError as exc:
        print(json.dumps({
            "ok": False,
            "error_code": exc.code,
            "error_msg": _redact(exc.msg, req),
        }), file=real_stdout)
        return 1
    except Exception as exc:  # noqa: BLE001 - 兜底
        print(json.dumps({
            "ok": False,
            "error_code": "CONFIG_WRITE_FAILED",
            "error_msg": _redact(f"{type(exc).__name__}: {exc}", req),
        }), file=real_stdout)
        return 1

    print(json.dumps(result, ensure_ascii=False), file=real_stdout)
    return 0


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