"""Tests for the community plugin index (#64181).

Covers: index parsing, fuzzy search, cache TTL + fallback chain
(remote → cache → seed), `hermes plugins search --json`, and install-time
name resolution (unique / ambiguous / passthrough of owner/repo).
No live network — every remote fetch is mocked.
"""

from __future__ import annotations

import json
import time
from pathlib import Path

import pytest

from hermes_cli import plugin_index
from hermes_cli.plugin_index import (
    PluginIndexEntry,
    _parse_entries,
    load_index,
    resolve_name,
    search_index,
)


def _entry(name, repo="owner/repo", **kw):
    return PluginIndexEntry(name=name, repo=repo, **kw)


def _index_doc(entries):
    return {"schema_version": 1, "plugins": entries}


SAMPLE = _index_doc(
    [
        {
            "name": "hermes-media-studio",
            "description": "Generative media workspace plugin.",
            "author": "NousResearch",
            "tags": ["media", "image-gen"],
            "repo": "NousResearch/hermes-media-studio",
            "ref": "e" * 40,
        },
        {
            "name": "hermes-telegram-business",
            "description": "Telegram secretary bot with owner approval.",
            "author": "NousResearch",
            "tags": ["telegram", "gateway"],
            "repo": "NousResearch/hermes-telegram-business",
            "ref": "f" * 40,
            "capabilities": ["platform"],
        },
        {
            "name": "plugin-llm-example",
            "description": "Reference plugin for structured LLM access.",
            "author": "NousResearch",
            "tags": ["example", "llm"],
            "repo": "NousResearch/hermes-example-plugins",
            "subdir": "plugin-llm-example",
            "ref": "a" * 40,
            "capabilities": ["commands", "llm"],
        },
    ]
)


@pytest.fixture()
def hermes_home(tmp_path, monkeypatch):
    monkeypatch.setenv("HERMES_HOME", str(tmp_path))
    monkeypatch.setattr(plugin_index, "get_hermes_home", lambda: tmp_path)
    return tmp_path


def _write_cache(home: Path, doc, *, age_seconds: float = 0) -> Path:
    cache = home / "cache" / "plugin_index.json"
    cache.parent.mkdir(parents=True, exist_ok=True)
    cache.write_text(json.dumps(doc), encoding="utf-8")
    if age_seconds:
        stamp = time.time() - age_seconds
        import os

        os.utime(cache, (stamp, stamp))
    return cache


# ---------------------------------------------------------------------------
# Parsing
# ---------------------------------------------------------------------------


class TestParsing:
    def test_parses_object_form(self):
        entries = _parse_entries(SAMPLE)
        assert [e.name for e in entries] == [
            "hermes-media-studio",
            "hermes-telegram-business",
            "plugin-llm-example",
        ]
        assert entries[2].subdir == "plugin-llm-example"
        assert entries[2].install_identifier == (
            "NousResearch/hermes-example-plugins/plugin-llm-example"
        )
        assert entries[0].install_identifier == "NousResearch/hermes-media-studio"

    def test_parses_bare_list_form(self):
        entries = _parse_entries(SAMPLE["plugins"])
        assert len(entries) == 3

    def test_skips_malformed_entries(self):
        doc = _index_doc(
            [
                {"name": "good", "repo": "o/r"},
                {"name": "", "repo": "o/r"},           # empty name
                {"name": "norepo"},                     # missing repo
                {"name": "badrepo", "repo": "not-a-repo"},  # no slash
                {"name": "deep", "repo": "a/b/c"},      # too many slashes
                "not-a-dict",
            ]
        )
        entries = _parse_entries(doc)
        assert [e.name for e in entries] == ["good"]

    def test_rejects_non_container(self):
        with pytest.raises(ValueError):
            _parse_entries("nope")

    def test_bundled_seed_parses(self):
        raw = json.loads(plugin_index.SEED_INDEX_PATH.read_text(encoding="utf-8"))
        entries = _parse_entries(raw)
        assert len(entries) >= 3
        for e in entries:
            assert e.repo.count("/") == 1
            assert e.ref, f"seed entry {e.name} must pin a ref"
            assert len(e.ref) == 40, f"seed entry {e.name} must pin a commit SHA"


# ---------------------------------------------------------------------------
# Search
# ---------------------------------------------------------------------------


class TestSearch:
    entries = _parse_entries(SAMPLE)

    def test_exact_name_ranks_first(self):
        results = search_index(self.entries, "hermes-media-studio")
        assert results[0].name == "hermes-media-studio"

    def test_matches_tags(self):
        results = search_index(self.entries, "telegram")
        assert results and results[0].name == "hermes-telegram-business"

    def test_matches_description(self):
        results = search_index(self.entries, "secretary")
        assert [e.name for e in results] == ["hermes-telegram-business"]

    def test_fuzzy_typo_tolerance(self):
        results = search_index(self.entries, "hermes-media-studo")
        assert results and results[0].name == "hermes-media-studio"

    def test_no_match(self):
        assert search_index(self.entries, "zzzzqqqq") == []

    def test_empty_term_browses_all_sorted(self):
        results = search_index(self.entries, "")
        assert [e.name for e in results] == sorted(e.name for e in self.entries)

    def test_capability_filter(self):
        results = search_index(self.entries, "", capability="platform")
        assert [e.name for e in results] == ["hermes-telegram-business"]

    def test_capability_filter_with_term(self):
        results = search_index(self.entries, "llm", capability="commands")
        assert [e.name for e in results] == ["plugin-llm-example"]


# ---------------------------------------------------------------------------
# Fallback chain: remote → cache → seed
# ---------------------------------------------------------------------------


class TestLoadIndex:
    def test_fresh_cache_wins_without_network(self, hermes_home, monkeypatch):
        _write_cache(hermes_home, SAMPLE)

        def boom():  # pragma: no cover - must not be called
            raise AssertionError("network hit despite fresh cache")

        monkeypatch.setattr(plugin_index, "_fetch_remote", boom)
        entries, source = load_index()
        assert source == "cache"
        assert len(entries) == 3

    def test_expired_cache_triggers_remote(self, hermes_home, monkeypatch):
        _write_cache(hermes_home, SAMPLE, age_seconds=plugin_index.INDEX_CACHE_TTL + 60)
        remote_doc = _index_doc([{"name": "fresh-plugin", "repo": "o/r", "ref": "b" * 40}])
        monkeypatch.setattr(
            plugin_index, "_fetch_remote", lambda: _parse_entries(remote_doc)
        )
        entries, source = load_index()
        assert source == "remote"
        assert [e.name for e in entries] == ["fresh-plugin"]

    def test_remote_failure_falls_back_to_stale_cache(self, hermes_home, monkeypatch):
        _write_cache(hermes_home, SAMPLE, age_seconds=plugin_index.INDEX_CACHE_TTL + 60)
        monkeypatch.setattr(plugin_index, "_fetch_remote", lambda: None)
        entries, source = load_index()
        assert source == "cache"
        assert len(entries) == 3

    def test_no_cache_no_remote_falls_back_to_seed(self, hermes_home, monkeypatch):
        monkeypatch.setattr(plugin_index, "_fetch_remote", lambda: None)
        entries, source = load_index()
        assert source == "seed"
        assert len(entries) >= 3

    def test_refresh_bypasses_fresh_cache(self, hermes_home, monkeypatch):
        _write_cache(hermes_home, SAMPLE)
        remote_doc = _index_doc([{"name": "newer", "repo": "o/r", "ref": "c" * 40}])
        monkeypatch.setattr(
            plugin_index, "_fetch_remote", lambda: _parse_entries(remote_doc)
        )
        entries, source = load_index(refresh=True)
        assert source == "remote"
        assert [e.name for e in entries] == ["newer"]

    def test_offline_skips_network(self, hermes_home, monkeypatch):
        def boom():  # pragma: no cover
            raise AssertionError("network hit in offline mode")

        monkeypatch.setattr(plugin_index, "_fetch_remote", boom)
        entries, source = load_index(offline=True)
        assert source == "seed"

    def test_corrupt_cache_ignored(self, hermes_home, monkeypatch):
        cache = hermes_home / "cache" / "plugin_index.json"
        cache.parent.mkdir(parents=True, exist_ok=True)
        cache.write_text("{not json", encoding="utf-8")
        monkeypatch.setattr(plugin_index, "_fetch_remote", lambda: None)
        entries, source = load_index()
        assert source == "seed"

    def test_remote_fetch_writes_cache(self, hermes_home, monkeypatch):
        payload = json.dumps(SAMPLE)

        class FakeResponse:
            text = payload

            def raise_for_status(self):
                return None

        import httpx

        monkeypatch.setattr(httpx, "get", lambda *a, **k: FakeResponse())
        entries, source = load_index()
        assert source == "remote"
        cache = hermes_home / "cache" / "plugin_index.json"
        assert cache.is_file()
        assert json.loads(cache.read_text(encoding="utf-8")) == SAMPLE

    def test_index_url_config_override(self, monkeypatch):
        monkeypatch.setattr(
            plugin_index,
            "get_index_url",
            plugin_index.get_index_url,  # keep real fn, patch config below
        )
        from hermes_cli import config as config_mod

        monkeypatch.setattr(
            config_mod,
            "load_config_readonly",
            lambda: {"plugins": {"index_url": "https://example.com/custom.json"}},
        )
        assert plugin_index.get_index_url() == "https://example.com/custom.json"


# ---------------------------------------------------------------------------
# Name resolution
# ---------------------------------------------------------------------------


class TestResolveName:
    entries = _parse_entries(SAMPLE)

    def test_exact_unique(self):
        entry, candidates = resolve_name(self.entries, "hermes-media-studio")
        assert entry is not None and entry.repo == "NousResearch/hermes-media-studio"

    def test_case_insensitive(self):
        entry, _ = resolve_name(self.entries, "Hermes-Media-Studio")
        assert entry is not None

    def test_unique_partial(self):
        entry, _ = resolve_name(self.entries, "telegram")
        assert entry is not None and entry.name == "hermes-telegram-business"

    def test_ambiguous_partial(self):
        entry, candidates = resolve_name(self.entries, "hermes")
        assert entry is None
        assert len(candidates) == 2

    def test_unknown(self):
        entry, candidates = resolve_name(self.entries, "nonexistent-thing")
        assert entry is None and candidates == []


# ---------------------------------------------------------------------------
# Install wiring
# ---------------------------------------------------------------------------


class TestInstallResolution:
    def test_bare_name_detection(self):
        from hermes_cli.plugins_cmd import _looks_like_bare_index_name

        assert _looks_like_bare_index_name("hermes-media-studio")
        assert not _looks_like_bare_index_name("owner/repo")
        assert not _looks_like_bare_index_name("https://github.com/o/r.git")
        assert not _looks_like_bare_index_name("git@github.com:o/r.git")
        assert not _looks_like_bare_index_name("ssh://git@github.com/o/r.git")
        assert not _looks_like_bare_index_name("file:///tmp/x")

    def test_install_resolves_name_and_pins_ref(self, hermes_home, monkeypatch):
        from hermes_cli import plugins_cmd

        monkeypatch.setattr(
            plugin_index, "load_index", lambda **kw: (_parse_entries(SAMPLE), "seed")
        )
        captured = {}

        def fake_core(identifier, *, force, ref=None, scan_decision_cb=None):
            captured["identifier"] = identifier
            captured["ref"] = ref
            raise plugins_cmd.PluginOperationError("stop here")

        monkeypatch.setattr(plugins_cmd, "_install_plugin_core", fake_core)
        with pytest.raises(SystemExit):
            plugins_cmd.cmd_install("hermes-media-studio", enable=False)
        assert captured["identifier"] == "NousResearch/hermes-media-studio"
        assert captured["ref"] == "e" * 40

    def test_install_explicit_ref_beats_index_pin(self, hermes_home, monkeypatch):
        from hermes_cli import plugins_cmd

        monkeypatch.setattr(
            plugin_index, "load_index", lambda **kw: (_parse_entries(SAMPLE), "seed")
        )
        captured = {}

        def fake_core(identifier, *, force, ref=None, scan_decision_cb=None):
            captured["ref"] = ref
            raise plugins_cmd.PluginOperationError("stop here")

        monkeypatch.setattr(plugins_cmd, "_install_plugin_core", fake_core)
        with pytest.raises(SystemExit):
            plugins_cmd.cmd_install("hermes-media-studio", enable=False, ref="d" * 40)
        assert captured["ref"] == "d" * 40

    def test_install_ambiguous_name_lists_candidates_and_exits(
        self, hermes_home, monkeypatch, capsys
    ):
        from hermes_cli import plugins_cmd

        monkeypatch.setattr(
            plugin_index, "load_index", lambda **kw: (_parse_entries(SAMPLE), "seed")
        )
        called = []
        monkeypatch.setattr(
            plugins_cmd,
            "_install_plugin_core",
            lambda *a, **k: called.append(1),
        )
        with pytest.raises(SystemExit) as exc:
            plugins_cmd.cmd_install("hermes", enable=False)
        assert exc.value.code == 1
        assert not called
        out = capsys.readouterr().out
        assert "ambiguous" in out
        assert "hermes-media-studio" in out
        assert "hermes-telegram-business" in out

    def test_install_unknown_name_exits(self, hermes_home, monkeypatch, capsys):
        from hermes_cli import plugins_cmd

        monkeypatch.setattr(
            plugin_index, "load_index", lambda **kw: (_parse_entries(SAMPLE), "seed")
        )
        with pytest.raises(SystemExit) as exc:
            plugins_cmd.cmd_install("totally-unknown", enable=False)
        assert exc.value.code == 1
        assert "not found" in capsys.readouterr().out

    def test_owner_repo_passthrough_skips_index(self, hermes_home, monkeypatch):
        """Explicit owner/repo installs never consult the index."""
        from hermes_cli import plugins_cmd

        def boom(**kw):  # pragma: no cover
            raise AssertionError("index consulted for owner/repo identifier")

        monkeypatch.setattr(plugin_index, "load_index", boom)
        captured = {}

        def fake_core(identifier, *, force, ref=None, scan_decision_cb=None):
            captured["identifier"] = identifier
            captured["ref"] = ref
            raise plugins_cmd.PluginOperationError("stop here")

        monkeypatch.setattr(plugins_cmd, "_install_plugin_core", fake_core)
        with pytest.raises(SystemExit):
            plugins_cmd.cmd_install("someowner/somerepo", enable=False)
        assert captured["identifier"] == "someowner/somerepo"
        assert captured["ref"] is None


# ---------------------------------------------------------------------------
# CLI search command
# ---------------------------------------------------------------------------


class TestCmdSearch:
    def test_json_output(self, hermes_home, monkeypatch, capsys):
        from hermes_cli import plugins_cmd

        monkeypatch.setattr(
            plugin_index, "load_index", lambda **kw: (_parse_entries(SAMPLE), "seed")
        )
        plugins_cmd.cmd_search("telegram", json_output=True)
        payload = json.loads(capsys.readouterr().out)
        assert payload["source"] == "seed"
        assert payload["query"] == "telegram"
        assert payload["results"][0]["name"] == "hermes-telegram-business"
        assert payload["results"][0]["repo"] == "NousResearch/hermes-telegram-business"
        assert payload["results"][0]["ref"] == "f" * 40
        assert "audited" in payload["note"]

    def test_table_output_includes_security_footer(
        self, hermes_home, monkeypatch, capsys
    ):
        from hermes_cli import plugins_cmd

        monkeypatch.setattr(
            plugin_index, "load_index", lambda **kw: (_parse_entries(SAMPLE), "seed")
        )
        plugins_cmd.cmd_search("media")
        out = capsys.readouterr().out
        assert "hermes-media-studio" in out
        assert "audited" in out

    def test_no_results_message(self, hermes_home, monkeypatch, capsys):
        from hermes_cli import plugins_cmd

        monkeypatch.setattr(
            plugin_index, "load_index", lambda **kw: (_parse_entries(SAMPLE), "seed")
        )
        plugins_cmd.cmd_search("zzzznope")
        assert "No plugins matched" in capsys.readouterr().out

    def test_parser_accepts_search(self):
        import argparse

        from hermes_cli.subcommands.plugins import build_plugins_parser

        parser = argparse.ArgumentParser()
        sub = parser.add_subparsers(dest="command")
        build_plugins_parser(sub, cmd_plugins=lambda args: None)
        args = parser.parse_args(
            ["plugins", "search", "media", "--json", "--capability", "tools", "--refresh"]
        )
        assert args.plugins_action == "search"
        assert args.term == "media"
        assert args.json is True
        assert args.capability == "tools"
        assert args.refresh is True

    def test_dispatch_routes_search(self, hermes_home, monkeypatch):
        from hermes_cli import plugins_cmd

        captured = {}

        def fake_search(term, *, json_output, capability, refresh):
            captured.update(
                term=term, json_output=json_output, capability=capability, refresh=refresh
            )

        monkeypatch.setattr(plugins_cmd, "cmd_search", fake_search)
        import argparse

        args = argparse.Namespace(
            plugins_action="search", term="llm", json=True, capability=None, refresh=False
        )
        plugins_cmd.plugins_command(args)
        assert captured == {
            "term": "llm",
            "json_output": True,
            "capability": None,
            "refresh": False,
        }
