"""Plugin packs (#64166): parse/validate, SHA enforcement, install fan-out,
export round-trip, consent-not-bypassed, partial-failure exit codes.

No live network — index resolution and installs are mocked.
"""

from __future__ import annotations

import json
from unittest import mock

import pytest
import hermes_yaml as yaml

from hermes_cli.plugin_packs import (
    PackError,
    ResolvedPackPlugin,
    _sanitized_entry_config as real_sanitized_entry_config,
    cmd_pack_install,
    export_pack,
    install_pack_plugins,
    load_pack,
    parse_pack,
    resolve_pack_plugins,
    validate_config_seed,
)

SHA_A = "a" * 40
SHA_B = "b" * 40


class FakeConsole:
    """Minimal Rich-console stand-in recording printed lines."""

    def __init__(self, answers=None):
        self.lines = []
        self._answers = list(answers or [])

    def print(self, *args, **kwargs):
        self.lines.append(" ".join(str(a) for a in args))

    def input(self, prompt=""):
        if self._answers:
            return self._answers.pop(0)
        return ""

    @property
    def text(self):
        return "\n".join(self.lines)


def _pack_yaml(**overrides) -> str:
    doc = {
        "name": "voice-pack",
        "description": "test pack",
        "author": "hyper",
        "version": "1.0.0",
        "plugins": [
            {"repo": "owner/tts-plugin", "ref": SHA_A},
            {"name": "index-plugin", "ref": SHA_B},
        ],
    }
    doc.update(overrides)
    return yaml.safe_dump(doc)


# ---------------------------------------------------------------------------
# Parse / validate
# ---------------------------------------------------------------------------

def test_parse_pack_happy_path():
    pack = parse_pack(_pack_yaml())
    assert pack.name == "voice-pack"
    assert pack.author == "hyper"
    assert len(pack.plugins) == 2
    assert pack.plugins[0].repo == "owner/tts-plugin"
    assert pack.plugins[0].ref == SHA_A
    assert pack.plugins[1].name == "index-plugin"


def test_parse_pack_accepts_nested_pack_meta_and_source_alias():
    text = yaml.safe_dump(
        {
            "pack": {"name": "nested", "version": "2.0.0"},
            "plugins": [
                {"source": "github:owner/repo", "version": SHA_A, "subdir": "sub/dir"}
            ],
        }
    )
    pack = parse_pack(text)
    assert pack.name == "nested"
    assert pack.version == "2.0.0"
    assert pack.plugins[0].repo == "owner/repo"
    assert pack.plugins[0].subdir == "sub/dir"
    assert pack.plugins[0].install_identifier == "owner/repo/sub/dir"


@pytest.mark.parametrize("bad_ref", ["main", "v1.2.0", "a" * 39, "a" * 41, "", None])
def test_parse_pack_rejects_non_sha_refs_naming_the_entry(bad_ref):
    text = yaml.safe_dump(
        {"name": "p", "plugins": [{"repo": "owner/repo", "ref": bad_ref}]}
    )
    with pytest.raises(PackError) as exc:
        parse_pack(text)
    assert "40-character" in str(exc.value)
    assert "owner/repo" in str(exc.value)


def test_parse_pack_normalizes_ref_to_lowercase():
    text = yaml.safe_dump(
        {"name": "p", "plugins": [{"repo": "o/r", "ref": "A" * 40}]}
    )
    assert parse_pack(text).plugins[0].ref == "a" * 40


@pytest.mark.parametrize(
    "doc,fragment",
    [
        ("[]", "mapping"),
        ("name: p", "plugins"),
        ("name: p\nplugins: []", "plugins"),
        ("plugins:\n  - repo: o/r\n    ref: " + SHA_A, "name"),
        ("name: p\nplugins:\n  - ref: " + SHA_A, "either 'name'"),
        ("name: p\nplugins:\n  - not-a-mapping", "mapping"),
        ("{", "YAML"),
    ],
)
def test_parse_pack_rejects_malformed_documents(doc, fragment):
    with pytest.raises(PackError) as exc:
        parse_pack(doc)
    assert fragment in str(exc.value)


def test_config_seed_rejects_secret_shaped_keys():
    for key in ("api_key", "MY_TOKEN", "password", "auth_header", "private_key"):
        with pytest.raises(PackError) as exc:
            validate_config_seed("p", {key: "x"})
        assert "secret" in str(exc.value).lower()


def test_config_seed_rejects_capability_and_trust_gate_keys():
    for key in ("granted_capabilities", "capabilities_consent", "allow_tool_override"):
        with pytest.raises(PackError) as exc:
            validate_config_seed("p", {key: True})
        assert "reserved" in str(exc.value)


@pytest.mark.parametrize("seed, fragment", [
    ({"settings": {"api_key": "LEAK"}}, "secret-shaped key 'settings.api_key'"),
    ({"security": {"granted_capabilities": ["tools"]}}, "reserved key 'security.granted_capabilities'"),
    ({"profiles": [{"allow_tool_override": True}]}, "reserved key 'profiles.allow_tool_override'"),
])
def test_config_seed_rejects_forbidden_keys_at_any_depth(seed, fragment):
    """Nesting a secret/consent key under another mapping (or a list) is the same contract
    violation as setting it at the top level (#85050)."""
    with pytest.raises(PackError) as exc:
        validate_config_seed("p", seed)
    assert fragment in str(exc.value)
    assert validate_config_seed("p", {"settings": {"voice": "nova", "tags": ["a"]}}) == {
        "settings": {"voice": "nova", "tags": ["a"]}}


def test_sanitized_entry_config_strips_forbidden_keys_at_any_depth():
    fake_cfg = {"plugins": {"entries": {"tts": {
        "smtp": {"host": "mail.example", "password": "hunter2"},
        "rules": [{"name": "r1", "auth_token": "t"}],
        "voice": "nova",
    }}}}
    with mock.patch("hermes_cli.config.load_config", return_value=fake_cfg):
        assert real_sanitized_entry_config("tts") == {
            "smtp": {"host": "mail.example"}, "rules": [{"name": "r1"}], "voice": "nova"}


def test_parse_pack_validates_config_section():
    text = _pack_yaml(config={"tts-plugin": {"granted_capabilities": ["tools"]}})
    with pytest.raises(PackError):
        parse_pack(text)
    ok = parse_pack(_pack_yaml(config={"tts-plugin": {"voice": "nova"}}))
    assert ok.config["tts-plugin"] == {"voice": "nova"}


def test_load_pack_rejects_insecure_url_schemes(tmp_path):
    with pytest.raises(PackError) as exc:
        load_pack("http://example.com/pack.yaml")
    assert "https" in str(exc.value)


def test_load_pack_reads_local_file(tmp_path):
    f = tmp_path / "hermes-pack.yaml"
    f.write_text(_pack_yaml(), encoding="utf-8")
    assert load_pack(str(f)).name == "voice-pack"


def test_load_pack_missing_file_errors():
    with pytest.raises(PackError) as exc:
        load_pack("/nonexistent/pack.yaml")
    assert "not found" in str(exc.value)


# ---------------------------------------------------------------------------
# Resolution (bare catalog names) — catalog mocked, no network
# ---------------------------------------------------------------------------

def _fake_catalog_entry(name):
    from hermes_cli.plugin_catalog import CatalogCapabilities, PluginCatalogEntry
    return PluginCatalogEntry(
        name=name, repo="https://github.com/idx-owner/idx-repo", sha=SHA_B, description="d", maintainer="idx-owner",
        capabilities=CatalogCapabilities(provides_tools=["tools"]))


def test_resolve_pack_plugins_uses_catalog_for_bare_names():
    pack = parse_pack(_pack_yaml())
    bare_name = pack.plugins[1].name
    fake_entry = _fake_catalog_entry(bare_name)
    with mock.patch(
        "hermes_cli.plugin_catalog.load_catalog_live",
        return_value=[fake_entry],
    ):
        resolved = resolve_pack_plugins(pack)

    assert resolved[0].identifier == "owner/tts-plugin"  # repo entries skip the catalog
    assert resolved[1].identifier == "https://github.com/idx-owner/idx-repo"
    assert resolved[1].index_capabilities == ["tools"]


def test_resolve_pack_plugins_carries_catalog_miss_as_error():
    pack = parse_pack(
        yaml.safe_dump(
            {"name": "p", "plugins": [{"name": "ghost", "ref": SHA_A}]}
        )
    )
    with mock.patch(
        "hermes_cli.plugin_catalog.load_catalog_live", return_value=[]
    ):
        resolved = resolve_pack_plugins(pack)
    assert resolved[0].identifier is None
    assert "not found" in resolved[0].resolve_error


# ---------------------------------------------------------------------------
# Install fan-out — installer mocked
# ---------------------------------------------------------------------------

def _resolved(pack):
    return [
        ResolvedPackPlugin(entry=e, identifier=e.install_identifier)
        for e in pack.plugins
    ]


def _fanout_patches(install_side_effect, consent_mock=None):
    """Patch the plugins_cmd seams install_pack_plugins pulls in."""
    patches = {
        "_install_plugin_core": mock.MagicMock(side_effect=install_side_effect),
        "_prompt_plugin_env_vars": mock.MagicMock(),
        "_set_plugin_enabled": mock.MagicMock(),
        "_run_capability_consent": consent_mock or mock.MagicMock(return_value=True),
        "_declared_capabilities_from_manifest": mock.MagicMock(
            side_effect=lambda manifest, name: manifest.get("capabilities", [])
        ),
    }
    return patches


def test_install_fan_out_passes_pinned_refs_to_installer(tmp_path):
    pack = parse_pack(
        yaml.safe_dump(
            {
                "name": "p",
                "plugins": [
                    {"repo": "o/a", "ref": SHA_A},
                    {"repo": "o/b", "ref": SHA_B},
                ],
            }
        )
    )
    installer = mock.MagicMock(
        side_effect=[
            (tmp_path / "a", {"name": "a"}, "a"),
            (tmp_path / "b", {"name": "b"}, "b"),
        ]
    )
    patches = _fanout_patches(None)
    patches["_install_plugin_core"] = installer
    with mock.patch.multiple("hermes_cli.plugins_cmd", **patches):
        results = install_pack_plugins(pack, _resolved(pack), FakeConsole())

    assert [r.ok for r in results] == [True, True]
    assert installer.call_args_list == [
        mock.call("o/a", force=False, ref=SHA_A),
        mock.call("o/b", force=False, ref=SHA_B),
    ]


def test_install_fan_out_invokes_capability_consent_per_plugin(tmp_path):
    """Consent is NOT bypassed: the standard per-plugin consent function
    runs once for every installed plugin that declares capabilities."""
    pack = parse_pack(
        yaml.safe_dump(
            {
                "name": "p",
                "plugins": [
                    {"repo": "o/a", "ref": SHA_A},
                    {"repo": "o/b", "ref": SHA_B},
                ],
            }
        )
    )
    consent = mock.MagicMock(return_value=True)
    patches = _fanout_patches(
        [
            (tmp_path / "a", {"name": "a", "capabilities": ["tools"]}, "a"),
            (tmp_path / "b", {"name": "b", "capabilities": ["platform"]}, "b"),
        ],
        consent_mock=consent,
    )
    with mock.patch.multiple("hermes_cli.plugins_cmd", **patches):
        install_pack_plugins(pack, _resolved(pack), FakeConsole())

    assert consent.call_count == 2
    called_ids = [c.args[1] for c in consent.call_args_list]
    assert called_ids == ["a", "b"]
    called_caps = [c.args[2] for c in consent.call_args_list]
    assert called_caps == [["tools"], ["platform"]]


def test_install_fan_out_continues_past_failures_and_reports():
    from hermes_cli.plugins_cmd import PluginOperationError

    pack = parse_pack(
        yaml.safe_dump(
            {
                "name": "p",
                "plugins": [
                    {"repo": "o/bad", "ref": SHA_A},
                    {"repo": "o/good", "ref": SHA_B},
                ],
            }
        )
    )

    def installer(identifier, *, force, ref):
        if "bad" in identifier:
            raise PluginOperationError("clone exploded")
        return (mock.MagicMock(), {"name": "good"}, "good")

    patches = _fanout_patches(installer)
    console = FakeConsole()
    with mock.patch.multiple("hermes_cli.plugins_cmd", **patches):
        results = install_pack_plugins(pack, _resolved(pack), console)

    assert [r.ok for r in results] == [False, True]
    assert "clone exploded" in results[0].error
    assert results[1].installed_name == "good"


def test_pack_install_exits_nonzero_on_partial_failure(tmp_path, monkeypatch):
    pack_file = tmp_path / "pack.yaml"
    pack_file.write_text(
        yaml.safe_dump(
            {
                "name": "p",
                "plugins": [
                    {"repo": "o/bad", "ref": SHA_A},
                    {"repo": "o/good", "ref": SHA_B},
                ],
            }
        ),
        encoding="utf-8",
    )
    from hermes_cli.plugins_cmd import PluginOperationError

    def installer(identifier, *, force, ref):
        if "bad" in identifier:
            raise PluginOperationError("boom")
        return (mock.MagicMock(), {"name": "good"}, "good")

    fake_console = FakeConsole(answers=["y"])
    patches = _fanout_patches(installer)
    monkeypatch.setattr("sys.stdin", mock.MagicMock(isatty=lambda: True))
    monkeypatch.setattr("sys.stdout", mock.MagicMock(isatty=lambda: True))
    with mock.patch.multiple("hermes_cli.plugins_cmd", **patches), mock.patch(
        "rich.console.Console", return_value=fake_console
    ):
        with pytest.raises(SystemExit) as exc:
            cmd_pack_install(str(pack_file))
    assert exc.value.code == 1


def test_pack_install_refuses_noninteractive_sessions(tmp_path, monkeypatch):
    pack_file = tmp_path / "pack.yaml"
    pack_file.write_text(_pack_yaml(), encoding="utf-8")
    fake_console = FakeConsole()
    monkeypatch.setattr("sys.stdin", mock.MagicMock(isatty=lambda: False))
    monkeypatch.setattr("sys.stdout", mock.MagicMock(isatty=lambda: False))
    installer = mock.MagicMock()
    with mock.patch(
        "rich.console.Console", return_value=fake_console
    ), mock.patch(
        "hermes_cli.plugin_packs.resolve_pack_plugins",
        side_effect=lambda pack: _resolved(pack),
    ), mock.patch("hermes_cli.plugins_cmd._install_plugin_core", installer):
        with pytest.raises(SystemExit) as exc:
            cmd_pack_install(str(pack_file))
    assert exc.value.code == 1
    installer.assert_not_called()
    assert "interactive" in fake_console.text


def test_pack_install_aborts_cleanly_on_decline(tmp_path, monkeypatch):
    pack_file = tmp_path / "pack.yaml"
    pack_file.write_text(_pack_yaml(), encoding="utf-8")
    fake_console = FakeConsole(answers=["n"])
    monkeypatch.setattr("sys.stdin", mock.MagicMock(isatty=lambda: True))
    monkeypatch.setattr("sys.stdout", mock.MagicMock(isatty=lambda: True))
    installer = mock.MagicMock()
    with mock.patch(
        "rich.console.Console", return_value=fake_console
    ), mock.patch(
        "hermes_cli.plugin_packs.resolve_pack_plugins",
        side_effect=lambda pack: _resolved(pack),
    ), mock.patch("hermes_cli.plugins_cmd._install_plugin_core", installer):
        with pytest.raises(SystemExit):
            cmd_pack_install(str(pack_file))
    installer.assert_not_called()


# ---------------------------------------------------------------------------
# Export round-trip
# ---------------------------------------------------------------------------

def _seed_install_state(home, monkeypatch, *, metadata, config=None, plugins=()):
    plugins_dir = home / "plugins"
    plugins_dir.mkdir(parents=True, exist_ok=True)
    for name in plugins:
        (plugins_dir / name).mkdir(exist_ok=True)
    (plugins_dir / ".install-metadata.json").write_text(
        json.dumps(metadata), encoding="utf-8"
    )
    monkeypatch.setattr(
        "hermes_cli.plugins_cmd._read_install_metadata", lambda: metadata
    )
    monkeypatch.setattr("hermes_cli.plugins_cmd._plugins_dir", lambda: plugins_dir)
    cfg = config or {}
    monkeypatch.setattr(
        "hermes_cli.plugins_cmd._get_enabled_set",
        lambda: set((cfg.get("plugins") or {}).get("enabled") or []),
    )
    monkeypatch.setattr("hermes_cli.plugin_packs._sanitized_entry_config",
                        lambda pid: ((cfg.get("plugins") or {}).get("entries") or {}).get(pid, {}))


def test_export_round_trips_through_parse(tmp_path, monkeypatch):
    metadata = {
        "tts": {
            "pinned": True,
            "revision": SHA_A,
            "source": "https://github.com/owner/tts-plugin.git",
        },
        "relay": {
            "pinned": True,
            "revision": SHA_B,
            "source": "https://github.com/owner/mono.git#plugins/relay",
        },
    }
    config = {
        "plugins": {
            "enabled": ["tts", "relay"],
            "entries": {"tts": {"voice": "nova"}},
        }
    }
    _seed_install_state(
        tmp_path, monkeypatch, metadata=metadata, config=config,
        plugins=("tts", "relay"),
    )

    text, warnings = export_pack(pack_name="exported")
    assert warnings == []
    pack = parse_pack(text)
    assert pack.name == "exported"
    by_repo = {p.repo: p for p in pack.plugins}
    assert by_repo["owner/tts-plugin"].ref == SHA_A
    assert by_repo["owner/mono"].subdir == "plugins/relay"
    assert by_repo["owner/mono"].ref == SHA_B
    assert pack.config["tts"] == {"voice": "nova"}


def test_export_warns_on_local_only_plugins(tmp_path, monkeypatch):
    _seed_install_state(
        tmp_path, monkeypatch, metadata={}, plugins=("local-hack",)
    )
    text, warnings = export_pack()
    assert any("local-hack" in w for w in warnings)
    assert "# WARNING" in text and "local-hack" in text
    # A pack with zero installable plugins won't parse back as installable.
    with pytest.raises(PackError):
        parse_pack(text)


def test_export_strips_secret_and_capability_config_keys(tmp_path, monkeypatch):
    metadata = {
        "tts": {
            "pinned": True,
            "revision": SHA_A,
            "source": "https://github.com/owner/tts.git",
        }
    }
    _seed_install_state(
        tmp_path, monkeypatch, metadata=metadata, plugins=("tts",)
    )
    # Use the real sanitizer against a fake loaded config.
    monkeypatch.setattr(
        "hermes_cli.plugin_packs._sanitized_entry_config",
        real_sanitized_entry_config,
    )
    fake_cfg = {
        "plugins": {
            "entries": {
                "tts": {
                    "voice": "nova",
                    "api_key": "sk-super-secret",
                    "granted_capabilities": ["tools"],
                    "allow_tool_override": True,
                }
            }
        }
    }
    with mock.patch("hermes_cli.config.load_config", return_value=fake_cfg):
        text, _warnings = export_pack()
    assert "sk-super-secret" not in text
    assert "api_key" not in text
    assert "granted_capabilities" not in text
    assert "allow_tool_override" not in text
    assert "voice: nova" in text


def test_export_enabled_only_filters(tmp_path, monkeypatch):
    metadata = {
        "on": {"pinned": True, "revision": SHA_A,
               "source": "https://github.com/o/on.git"},
        "off": {"pinned": True, "revision": SHA_B,
                "source": "https://github.com/o/off.git"},
    }
    config = {"plugins": {"enabled": ["on"]}}
    _seed_install_state(
        tmp_path, monkeypatch, metadata=metadata, config=config,
        plugins=("on", "off"),
    )
    text, _ = export_pack(enabled_only=True)
    pack = parse_pack(text)
    assert [p.repo for p in pack.plugins] == ["o/on"]


# ---------------------------------------------------------------------------
# Parser wiring
# ---------------------------------------------------------------------------
