"""Tests for tools/bot_mode_probe.py — the Bot Mode teammate-protocol section."""

import textwrap

import pytest

from tools import bot_mode_probe


@pytest.fixture(autouse=True)
def _fresh_cache():
    bot_mode_probe._reset_cache_for_tests()
    yield
    bot_mode_probe._reset_cache_for_tests()


def _make_bot_profile(root, name, *, managed=True, soul=None):
    d = root / "profiles" / name
    d.mkdir(parents=True, exist_ok=True)
    if managed:
        (d / "profile.yaml").write_text(
            textwrap.dedent(
                """\
                ui_meta:
                  hermes-bots:
                    shape: cloud
                    color: '#8b5cf6'
                """
            ),
            encoding="utf-8",
        )
    if soul is not None:
        (d / "SOUL.md").write_text(soul, encoding="utf-8")
    return d


def test_roster_excludes_infra_dirs_and_tombstones(tmp_path):
    """The teammate roster applies the same identity predicate as ``profile list``: bare
    infrastructure dirs (``@sessions``, ``@logs``) and deleted profiles are not teammates (#99392)."""
    from hermes_constants import mark_named_profile_deleted

    home = tmp_path / ".hermes"
    home.mkdir()
    _make_bot_profile(home, "researcher", managed=True)
    for stray in ("sessions", "logs"):
        (home / "profiles" / stray / "cron").mkdir(parents=True)
    ghost = _make_bot_profile(home, "ghost", managed=True)
    mark_named_profile_deleted(ghost)

    assert [name for name, _ in bot_mode_probe._roster(home)] == ["default", "researcher"]
    section = bot_mode_probe.get_bot_mode_protocol_section(home)
    assert "`@researcher`" in section
    assert not any(f"`@{s}`" in section for s in ("sessions", "logs", "ghost", ".deleted"))


def test_roster_excludes_dirs_failing_the_profile_id_regex(tmp_path):
    """#116905: a directory carrying an identity marker but named like anything other than a
    profile id (a parked backup, a dotfile staging dir) is not a teammate. ``profile list``
    hides such dirs via ``_PROFILE_ID_RE``; the roster must agree with that predicate."""
    home = tmp_path / ".hermes"
    home.mkdir()
    _make_bot_profile(home, "researcher", managed=True)
    for stray in ("_backup_removed_20260920", ".staging-area"):
        d = home / "profiles" / stray
        d.mkdir()
        (d / "config.yaml").write_text("model:\n  name: test\n", encoding="utf-8")

    assert [name for name, _ in bot_mode_probe._roster(home)] == ["default", "researcher"]
    section = bot_mode_probe.get_bot_mode_protocol_section(home)
    assert "`@researcher`" in section
    assert not any(f"`@{s}`" in section for s in ("_backup_removed_20260920", ".staging-area"))


def test_silent_when_no_profile_is_bot_managed(tmp_path):
    home = tmp_path / ".hermes"
    home.mkdir()
    _make_bot_profile(home, "researcher", managed=False)
    assert bot_mode_probe.get_bot_mode_protocol_section(home) == ""


def test_emits_for_default_when_any_profile_is_managed(tmp_path):
    home = tmp_path / ".hermes"
    home.mkdir()
    _make_bot_profile(home, "researcher", managed=True)

    section = bot_mode_probe.get_bot_mode_protocol_section(home)
    assert section.startswith("## Messaging other agents")
    # default's callable alias is @hermes, never @default
    assert "@hermes" in section
    assert "@default" not in section
    assert "@researcher" in section
    assert "message_agent" in section


def test_emits_for_named_profile_with_own_handle(tmp_path):
    home = tmp_path / ".hermes"
    home.mkdir()
    profile_dir = _make_bot_profile(home, "coder", managed=True)

    section = bot_mode_probe.get_bot_mode_protocol_section(profile_dir)
    assert "@coder" in section
    # teammate roster excludes self, includes default (as @hermes)
    roster_block = section.split("Your teammates")[1]
    assert "`@hermes`" in roster_block
    assert "`@coder`" not in roster_block


def test_roster_lines_carry_roles(tmp_path):
    """Bots must know WHO to message: the roster carries title/description."""
    import textwrap as _tw

    home = tmp_path / ".hermes"
    home.mkdir()
    d = home / "profiles" / "researcher"
    d.mkdir(parents=True)
    (d / "profile.yaml").write_text(
        _tw.dedent(
            """\
            description: Deep research and literature review
            ui_meta:
              hermes-bots:
                title: Research Buddy
            """
        ),
        encoding="utf-8",
    )

    section = bot_mode_probe.get_bot_mode_protocol_section(home)
    assert "`@researcher`" in section
    assert "Research Buddy" in section
    assert "Deep research and literature review" in section


def test_soul_legacy_protocol_no_longer_suppresses_live_section(tmp_path):
    """Plugin-era SOUL append is stripped at load time; the live roster is the only copy."""
    home = tmp_path / ".hermes"
    home.mkdir()
    _make_bot_profile(home, "coder", managed=True)
    (home / "SOUL.md").write_text(
        "# Me\n\n## Messaging other agents\nold plugin text\n", encoding="utf-8"
    )
    assert "`@coder`" in bot_mode_probe.get_bot_mode_protocol_section(home)
    assert bot_mode_probe.strip_legacy_protocol((home / "SOUL.md").read_text()) == "# Me\n"


def test_deterministic_across_calls(tmp_path):
    home = tmp_path / ".hermes"
    home.mkdir()
    _make_bot_profile(home, "researcher", managed=True)
    first = bot_mode_probe.get_bot_mode_protocol_section(home)
    # Even if the filesystem changes, the cached result must be byte-stable
    # for the life of the process (prompt-cache invariant).
    _make_bot_profile(home, "newbot", managed=True)
    second = bot_mode_probe.get_bot_mode_protocol_section(home)
    assert first == second


def test_never_raises_on_garbage(tmp_path, monkeypatch):
    home = tmp_path / ".hermes"
    home.mkdir()
    profiles = home / "profiles" / "bad"
    profiles.mkdir(parents=True)
    (profiles / "profile.yaml").write_text("ui_meta: [unclosed", encoding="utf-8")
    assert isinstance(bot_mode_probe.get_bot_mode_protocol_section(home), str)

    monkeypatch.setattr(bot_mode_probe, "_roster", lambda root: (_ for _ in ()).throw(OSError("boom")))
    bot_mode_probe._reset_cache_for_tests()
    assert bot_mode_probe.get_bot_mode_protocol_section(home) == ""


# ── capability epoch ─────────────────────────────────────────────────────────




def test_fingerprint_changes_on_each_capability_axis(tmp_path):
    home = tmp_path / ".hermes"
    home.mkdir()
    _make_bot_profile(home, "researcher", managed=True)
    base = bot_mode_probe.capability_fingerprint(home)

    # new skill installed
    skill = home / "skills" / "web" / "scraping"
    skill.mkdir(parents=True)
    (skill / "SKILL.md").write_text("---\nname: scraping\n---\n", encoding="utf-8")
    after_skill = bot_mode_probe.capability_fingerprint(home)
    assert after_skill != base

    # toolset pin changed (the key `hermes tools enable/disable` writes, #124211)
    (home / "config.yaml").write_text(
        "platform_toolsets:\n  desktop: [hermes-desktop, computer_use]\n", encoding="utf-8"
    )
    after_tools = bot_mode_probe.capability_fingerprint(home)
    assert after_tools != after_skill

    # global suppression changed
    (home / "config.yaml").write_text(
        "platform_toolsets:\n  desktop: [hermes-desktop, computer_use]\n"
        "agent:\n  disabled_toolsets: [computer_use]\n",
        encoding="utf-8",
    )
    after_disabled = bot_mode_probe.capability_fingerprint(home)
    assert after_disabled != after_tools

    # MCP server added
    (home / "config.yaml").write_text(
        "platform_toolsets:\n  desktop: [hermes-desktop, computer_use]\n"
        "agent:\n  disabled_toolsets: [computer_use]\n"
        "mcp_servers:\n  github:\n    preset: github\n",
        encoding="utf-8",
    )
    after_mcp = bot_mode_probe.capability_fingerprint(home)
    assert after_mcp != after_disabled

    # SOUL edited
    (home / "SOUL.md").write_text("# New identity\n", encoding="utf-8")
    after_soul = bot_mode_probe.capability_fingerprint(home)
    assert after_soul != after_mcp

    # teammate added to the roster
    _make_bot_profile(home, "coder", managed=True)
    assert bot_mode_probe.capability_fingerprint(home) != after_soul


def test_fingerprint_changes_when_model_vision_override_flips(tmp_path):
    """A Bot Chat prompt must rebuild when model.supports_vision flips."""
    home = tmp_path / ".hermes"
    home.mkdir()
    _make_bot_profile(home, "researcher", managed=True)
    config = home / "config.yaml"

    config.write_text("model:\n  supports_vision: true\n", encoding="utf-8")
    vision_enabled = bot_mode_probe.capability_fingerprint(home)
    stamped = "system stuff\n\n" + bot_mode_probe.epoch_line(home)
    assert vision_enabled == bot_mode_probe.capability_fingerprint(home)
    assert not bot_mode_probe.stored_prompt_capability_stale(stamped, home)

    config.write_text("model:\n  supports_vision: false\n", encoding="utf-8")
    vision_disabled = bot_mode_probe.capability_fingerprint(home)
    assert vision_disabled != vision_enabled
    assert bot_mode_probe.stored_prompt_capability_stale(stamped, home)

    restamped = "system stuff\n\n" + bot_mode_probe.epoch_line(home)
    assert not bot_mode_probe.stored_prompt_capability_stale(restamped, home)
    config.write_text("model:\n  supports_vision: true\n", encoding="utf-8")
    assert bot_mode_probe.capability_fingerprint(home) != vision_disabled


def test_vision_override_spellings_share_one_fingerprint(tmp_path):
    """YAML boolean tokens that image routing treats as the same override share an epoch."""
    home = tmp_path / ".hermes"
    home.mkdir()
    _make_bot_profile(home, "researcher", managed=True)
    config = home / "config.yaml"

    config.write_text("model:\n  supports_vision: true\n", encoding="utf-8")
    enabled = bot_mode_probe.capability_fingerprint(home)
    config.write_text("model:\n  supports_vision: yes\n", encoding="utf-8")
    assert bot_mode_probe.capability_fingerprint(home) == enabled
    config.write_text("model:\n  supports_vision: false\n", encoding="utf-8")
    assert bot_mode_probe.capability_fingerprint(home) != enabled


def test_fingerprint_changes_when_model_context_length_override_changes(tmp_path):
    """context_length truncates context files in the rebuilt prompt, so it is part of the epoch."""
    home = tmp_path / ".hermes"
    home.mkdir()
    _make_bot_profile(home, "researcher", managed=True)
    config = home / "config.yaml"

    config.write_text("model:\n  context_length: 32768\n", encoding="utf-8")
    narrow = bot_mode_probe.capability_fingerprint(home)
    config.write_text("model:\n  context_length: 131072\n", encoding="utf-8")
    assert bot_mode_probe.capability_fingerprint(home) != narrow
    config.write_text("model:\n  context_length: 32768\n", encoding="utf-8")
    assert bot_mode_probe.capability_fingerprint(home) == narrow


def test_stored_prompt_staleness(tmp_path):
    home = tmp_path / ".hermes"
    home.mkdir()
    _make_bot_profile(home, "researcher", managed=True)

    stamped = "system stuff\n\n" + bot_mode_probe.epoch_line(home)
    # unchanged surface → not stale (cache preserved)
    assert not bot_mode_probe.stored_prompt_capability_stale(stamped, home)

    # capability change → stale exactly once
    skill = home / "skills" / "new-skill"
    skill.mkdir(parents=True)
    (skill / "SKILL.md").write_text("---\nname: new-skill\n---\n", encoding="utf-8")
    assert bot_mode_probe.stored_prompt_capability_stale(stamped, home)
    restamped = "system stuff\n\n" + bot_mode_probe.epoch_line(home)
    assert not bot_mode_probe.stored_prompt_capability_stale(restamped, home)

    # prompts without a stamp (every non-Bot-Chat session) are never stale
    assert not bot_mode_probe.stored_prompt_capability_stale("ordinary prompt", home)
    assert not bot_mode_probe.stored_prompt_capability_stale("", home)


def test_legacy_bot_chat_upgrade(tmp_path):
    home = tmp_path / ".hermes"
    home.mkdir()
    _make_bot_profile(home, "researcher", managed=True)

    legacy = "old prompt with no protocol and no stamp"
    # legacy Bot Chat on a managed install → upgrade once
    assert bot_mode_probe.stored_bot_chat_prompt_needs_upgrade(legacy, home)

    # a rebuilt prompt (stamped) never re-fires
    upgraded = legacy + "\n\n" + bot_mode_probe.get_bot_mode_protocol_section(home) + "\n\n" + bot_mode_probe.epoch_line(home)
    assert not bot_mode_probe.stored_bot_chat_prompt_needs_upgrade(upgraded, home)

    # SOUL-era prompt (frozen roster rode in from SOUL.md, no stamp) → upgrade once
    assert bot_mode_probe.stored_bot_chat_prompt_needs_upgrade(
        "prompt containing\n## Messaging other agents\nfrom SOUL", home
    )

    # unmanaged install → probe silent → never upgrades
    bot_mode_probe._reset_cache_for_tests()
    home2 = tmp_path / ".hermes2"
    home2.mkdir()
    assert not bot_mode_probe.stored_bot_chat_prompt_needs_upgrade(legacy, home2)


# ── peer gateways (cross-machine DMs) ────────────────────────────────────────




def test_peer_paragraph_lists_registered_peers(tmp_path):
    home = tmp_path / ".hermes"
    home.mkdir()
    _make_bot_profile(home, "researcher", managed=True)
    (home / "config.yaml").write_text(
        textwrap.dedent(
            """\
            bot_peers:
              spark:
                url: http://spark.lan:8377
              homelab:
                url: http://homelab.lan:8377
            """
        ),
        encoding="utf-8",
    )

    section = bot_mode_probe.get_bot_mode_protocol_section(home)
    assert "`homelab`" in section and "`spark`" in section


def test_fingerprint_changes_when_a_peer_is_registered(tmp_path):
    home = tmp_path / ".hermes"
    home.mkdir()
    _make_bot_profile(home, "researcher", managed=True)

    before = bot_mode_probe.capability_fingerprint(home)
    (home / "config.yaml").write_text(
        "bot_peers:\n  spark:\n    url: http://spark.lan:8377\n",
        encoding="utf-8",
    )
    after = bot_mode_probe.capability_fingerprint(home)
    assert before != after


def test_roster_resolves_default_to_root_home_over_stray_directory(tmp_path):
    home = tmp_path / ".hermes"
    home.mkdir()
    _make_bot_profile(home, "researcher", managed=True)
    # A stray profiles/default/ directory must not shadow the reserved root home.
    stray = home / "profiles" / "default"
    stray.mkdir()
    (stray / "state.db").write_bytes(b"")

    roster = bot_mode_probe._roster(home)
    homes = dict(roster)
    assert homes["default"] == home
    assert homes["researcher"] == home / "profiles" / "researcher"
    names = [name for name, _ in roster]
    assert names.count("default") == 1
