# -*- coding: utf-8 -*-
# filename: test_mcp_approval.py
# @Time    : 2026/05/27
# @Author  : JQQ
# @Email   : jqq1716@gmail.com
# @Software: PyCharm
"""
启动期 MCP 批准框单元测试（v0.2.1 #69 Group B，§9.2）/ Boot-time MCP approval-box unit tests。

测试意图 / Test intentions（fake Computer 桩 + 内存 session；XDG 重定向隔离 user；policy monkeypatch 为空）:
- PENDING + TTY ``[y]`` → 写 local ``enabledMcpjsonServers`` + 挂载；**重启幂等**（二次 gate → ENABLED、不再弹）；
- PENDING + 非 TTY（session=None）→ skip+WARN、不挂、不写；``--approve-all-mcp`` → 挂载但**不落盘**；
- ENABLED（user origin trusted）→ 直挂；DISABLED（local disabled）→ 跳过；
- ``ext``（envFile）合回挂载 dict（否则 spawn 丢失）；``resolved.errors`` 呈现 WARN。
"""

from __future__ import annotations

import json
import os
from pathlib import Path
from typing import Any

import pytest

import a2c_smcp.computer.cli.commands.plugin as plugin_cmd
from a2c_smcp.computer.cli.commands.plugin import run_mcp_approval
from a2c_smcp.computer.settings.mcp_config import resolve_mcp_config
from a2c_smcp.computer.settings.scope import user_settings_path, workdir_local_settings_path


@pytest.fixture(autouse=True)
def _no_policy(monkeypatch: pytest.MonkeyPatch) -> None:
    """policy 读取 OS 源不确定 → 测试中固定为空，保证确定性 / pin policy empty for determinism。

    fix-review #4 后 ``resolved_settings`` 移入 ``cli.commands.__init__``（函数内 lazy import policy），
    故 monkeypatch 须打到来源模块 ``settings.policy``（lazy import 会取到打过的属性）。
    """
    import a2c_smcp.computer.settings.policy as policy_mod

    monkeypatch.setattr(policy_mod, "resolve_policy_settings", lambda **_: {})


def _env(tmp_path: Path) -> dict[str, str]:
    return {**os.environ, "XDG_CONFIG_HOME": str(tmp_path / "cfg")}


def _write_json(path: Path, obj: object) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(obj), encoding="utf-8")


def _project_mcp(wd: Path, servers: dict[str, Any]) -> None:
    _write_json(wd / ".tfrobot" / "mcp.json", {"servers": servers, "inputs": []})


def _user_mcp(env: dict[str, str], servers: dict[str, Any]) -> None:
    _write_json(Path(env["XDG_CONFIG_HOME"]) / "a2c" / "mcp.json", {"servers": servers, "inputs": []})


class _Session:
    """内存 PromptSession 桩：按序返回预置答案 / in-memory prompt session returning canned answers。"""

    def __init__(self, answers: list[str]) -> None:
        self._answers = list(answers)
        self.prompts: list[str] = []

    async def prompt_async(self, prompt: str, **_: Any) -> str:
        self.prompts.append(prompt)
        return self._answers.pop(0)


class _FakeComp:
    """Computer 桩：**声明面解析逐字复用生产实现**，只桩掉挂载副作用 / Stub that reuses the real declaration resolve。

    ``resolve_mcp_declarations`` **刻意不自己实现**，而是照 :meth:`Computer.resolve_mcp_declarations` 同款委托
    :func:`resolve_mcp_config`（同样透传 flag/embed 两条 boot 声明式输入）——否则桩会把「flag/embed 层不存在」
    这个前提固化进测试，令 #154/#164 的层序与 origin 条款**在此文件内永远测不到**（Epic #147 记载的 ``_FakeComputer``
    假绿正是此形状）。真实构造路径的契约测试见 ``tests/integration_tests/computer/cli/test_mcp_flag_config.py``（F7）。
    """

    def __init__(
        self,
        home: Path,
        *,
        mcp_flag_config: Path | None = None,
        mcp_servers: list[Any] | None = None,
    ) -> None:
        self.skill_home = home
        self.injected: list[Any] = []
        self.mounted: list[dict[str, Any]] = []
        self.unmounted: list[str] = []
        self.mcp_manager: Any = None  # pre-boot：无活跃集（与 CLI 首次 boot 同形）
        self._mcp_flag_config = mcp_flag_config
        self._mcp_servers = list(mcp_servers or [])

    def resolve_mcp_declarations(self, *, env: Any = None) -> Any:
        return resolve_mcp_config(env=env, flag_config_path=self._mcp_flag_config, embed_servers=self._mcp_servers)

    def add_or_update_input(self, inp: Any) -> None:
        self.injected.append(inp)

    def list_inputs(self) -> tuple[Any, ...]:
        # 累积池：#155 的坍缩前检读它。注意本桩的 add_or_update_input **永不抛**（真 Computer 会），
        # 故「注入失败」类契约在本文件**测不到**——其真实构造路径守卫见
        # tests/integration_tests/computer/cli/test_mcp_flag_config.py（F7）。
        return tuple(self.injected)

    async def amount_server(
        self, cfg: dict[str, Any], *, session: Any = None, plugin: Any = None, marketplace: Any = None,
    ) -> None:
        # #137 ③：run_mcp_approval 走 transient amount_server（boot 读已声明 mcp.json = 投影，不回写）。
        self.mounted.append(cfg)

    async def aunmount_server_by_id(self, bundle_id: str) -> None:
        self.unmounted.append(bundle_id)


def _mounted_names(comp: _FakeComp) -> set[str]:
    return {str(m.get("name")) for m in comp.mounted}


def _stdio() -> dict[str, Any]:
    return {"type": "stdio", "server_parameters": {"command": "node"}}


# ── PENDING + TTY [y] → 写 local + 挂载 + 重启幂等 ──────────────────────────────
@pytest.mark.asyncio
async def test_pending_approve_yes_writes_local_and_mounts_then_idempotent(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    wd, home, env = tmp_path / "proj", tmp_path / "home", _env(tmp_path)
    wd.mkdir()
    home.mkdir()
    _project_mcp(wd, {"shared": _stdio()})  # project origin → 非 trusted → PENDING
    monkeypatch.chdir(wd)  # #116：project/local 层锚定进程 cwd

    comp = _FakeComp(home)
    sess = _Session(["y"])
    # run_mcp_approval 读 os.environ 派生 XDG → 用 monkeypatch env 注入
    import unittest.mock as _m

    with _m.patch.dict(os.environ, env, clear=False):
        await run_mcp_approval(comp, sess, approve_all=False, settings_flag_path=None)
    assert "shared" in _mounted_names(comp)  # [y] → 挂载
    local = json.loads(workdir_local_settings_path(wd).read_text(encoding="utf-8"))
    assert local["enabledMcpjsonServers"] == ["shared"]  # 写 local scope（cwd 单根）

    # 重启幂等：二次启动 gate 读到 local enabled → ENABLED 直挂、不再弹（session 无答案也不报错）
    comp2 = _FakeComp(home)
    sess2 = _Session([])
    with _m.patch.dict(os.environ, env, clear=False):
        await run_mcp_approval(comp2, sess2, approve_all=False, settings_flag_path=None)
    assert "shared" in _mounted_names(comp2)
    assert sess2.prompts == []  # 不再弹批准框


# ── PENDING + 非 TTY → skip+WARN（不挂不写）；--approve-all-mcp → 挂载不落盘 ──────
@pytest.mark.asyncio
async def test_pending_non_tty_skips_and_does_not_write(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    wd, home, env = tmp_path / "proj", tmp_path / "home", _env(tmp_path)
    wd.mkdir()
    home.mkdir()
    _project_mcp(wd, {"shared": _stdio()})
    monkeypatch.chdir(wd)
    comp = _FakeComp(home)
    import unittest.mock as _m

    with _m.patch.dict(os.environ, env, clear=False):
        await run_mcp_approval(comp, None, approve_all=False, settings_flag_path=None)  # session=None = 非 TTY
    assert _mounted_names(comp) == set()  # 未挂
    assert not workdir_local_settings_path(wd).exists()  # 未写


@pytest.mark.asyncio
async def test_pending_non_tty_approve_all_mounts_without_persist(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    wd, home, env = tmp_path / "proj", tmp_path / "home", _env(tmp_path)
    wd.mkdir()
    home.mkdir()
    _project_mcp(wd, {"shared": _stdio()})
    monkeypatch.chdir(wd)
    comp = _FakeComp(home)
    import unittest.mock as _m

    with _m.patch.dict(os.environ, env, clear=False):
        await run_mcp_approval(comp, None, approve_all=True, settings_flag_path=None)
    assert "shared" in _mounted_names(comp)  # --approve-all-mcp → 挂载
    assert not workdir_local_settings_path(wd).exists()  # 仅本次、不落盘


# ── ENABLED（user origin trusted）直挂 ─────────────────────────────────────────
@pytest.mark.asyncio
async def test_user_origin_mounts_without_box(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    wd, home, env = tmp_path / "proj", tmp_path / "home", _env(tmp_path)
    wd.mkdir()
    home.mkdir()
    monkeypatch.chdir(wd)  # #116：project/local 锚 cwd——chdir 到空 wd，隔离真实仓库 .tfrobot/（与 PENDING 用例一致）
    _user_mcp(env, {"mine": _stdio()})  # user origin → trusted_origin → ENABLED、免批准
    comp = _FakeComp(home)
    sess = _Session([])  # 不应被调用
    import unittest.mock as _m

    with _m.patch.dict(os.environ, env, clear=False):
        await run_mcp_approval(comp, sess, approve_all=False, settings_flag_path=None)
    assert "mine" in _mounted_names(comp)
    assert sess.prompts == []


# ── ext（envFile）合回挂载 dict（#69 Group B 风险 2）─────────────────────────────
@pytest.mark.asyncio
async def test_envfile_ext_merged_into_mount_dict(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    wd, home, env = tmp_path / "proj", tmp_path / "home", _env(tmp_path)
    wd.mkdir()
    home.mkdir()
    monkeypatch.chdir(wd)  # #116：project/local 锚 cwd——chdir 到空 wd，隔离真实仓库 .tfrobot/（与 PENDING 用例一致）
    envfile = str(wd / ".env")
    _user_mcp(env, {"mine": {**_stdio(), "envFile": envfile}})  # user origin → 直挂
    comp = _FakeComp(home)
    import unittest.mock as _m

    with _m.patch.dict(os.environ, env, clear=False):
        await run_mcp_approval(comp, _Session([]), approve_all=False, settings_flag_path=None)
    mine = next(m for m in comp.mounted if m.get("name") == "mine")
    assert mine.get("envFile") == envfile  # ext 合回，否则 spawn 丢 envFile


# ── resolved.errors 呈现 WARN ──────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_malformed_server_surfaces_warning(
    tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch,
) -> None:
    wd, home, env = tmp_path / "proj", tmp_path / "home", _env(tmp_path)
    wd.mkdir()
    home.mkdir()
    # 畸形 server（缺 type）→ drop + error；valid 同存
    _project_mcp(wd, {"bad": {"server_parameters": {}}, "good": _stdio()})
    monkeypatch.chdir(wd)
    comp = _FakeComp(home)
    import unittest.mock as _m

    with _m.patch.dict(os.environ, env, clear=False):
        await run_mcp_approval(comp, None, approve_all=True, settings_flag_path=None)
    out = capsys.readouterr().out
    assert "mcp.json" in out  # resolved.errors 呈现（被 drop 的 bad 不静默）


# ── #157：project scope 自我批准 —— 拦下 + 响亮失败 ─────────────────────────────
@pytest.mark.asyncio
async def test_project_self_approval_filtered_and_surfaced_at_boot(
    tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch,
) -> None:
    """被 clone 仓库携 project settings.json 自我批准 → **不挂载**（非 TTY 无 approve_all）+ **打印解释**。

    这是 #157 的用户侧终局断言：过滤生效（安全）**且**用户看得懂为何自己的 settings 没生效（协议 §2.1
    「响亮失败」）。只断言过滤、不断言呈现 = 文案写进黑洞的假绿。
    End-to-end: the self-approval is filtered AND the user is told why.
    """
    wd, home, env = tmp_path / "proj", tmp_path / "home", _env(tmp_path)
    wd.mkdir()
    home.mkdir()
    _project_mcp(wd, {"evil": _stdio()})
    _write_json(wd / ".tfrobot" / "settings.json", {"enableAllProjectMcpServers": True})  # ← 入 git
    monkeypatch.chdir(wd)
    comp = _FakeComp(home)
    import unittest.mock as _m

    with _m.patch.dict(os.environ, env, clear=False):
        # session=None（非 TTY）且未 --approve-all-mcp → 若自我批准生效会直挂；被过滤后应 skip+WARN。
        await run_mcp_approval(comp, None, approve_all=False, settings_flag_path=None)

    assert _mounted_names(comp) == set(), "project 自我批准 MUST NOT 让恶意 server 直挂"
    out = capsys.readouterr().out
    assert "enableAllProjectMcpServers" in out  # 越权字段被点名
    assert "settings.local.json" in out  # 且给出可操作去向
