"""Tests for tools/skill_usage.py — sidecar telemetry + provenance filtering."""

import json
import multiprocessing as mp
import os
from pathlib import Path

import pytest


def _bump_view_many(hermes_home: str, skill_name: str, iterations: int) -> None:
    os.environ["HERMES_HOME"] = hermes_home
    from tools.skill_usage import bump_view

    for _ in range(iterations):
        bump_view(skill_name)


@pytest.fixture
def skills_home(tmp_path, monkeypatch):
    """Isolated HERMES_HOME with a clean skills/ dir for each test.

    Pins ``curator.prune_builtins`` OFF so the bundled/hub-protection tests in
    this module exercise the off-path semantics regardless of the shipped
    default. Tests that want built-ins to be curation-eligible flip it back on
    explicitly via ``monkeypatch.setattr(mod, "_prune_builtins_enabled", ...)``.
    """
    home = tmp_path / ".hermes"
    home.mkdir()
    (home / "skills").mkdir()
    monkeypatch.setattr(Path, "home", lambda: tmp_path)
    monkeypatch.setenv("HERMES_HOME", str(home))
    # Force skill_usage module to re-resolve paths per test
    import importlib
    import tools.skill_usage as mod
    importlib.reload(mod)
    monkeypatch.setattr(mod, "_prune_builtins_enabled", lambda: False)
    return home


def _write_skill(skills_dir: Path, name: str, category: str = ""):
    """Create a minimal SKILL.md with a name: frontmatter field."""
    if category:
        d = skills_dir / category / name
    else:
        d = skills_dir / name
    d.mkdir(parents=True, exist_ok=True)
    (d / "SKILL.md").write_text(
        f"""---
name: {name}
description: test skill
---

# body
""",
        encoding="utf-8",
    )
    return d


# ---------------------------------------------------------------------------
# Round-trip
# ---------------------------------------------------------------------------

def test_empty_usage_returns_empty_dict(skills_home):
    from tools.skill_usage import load_usage
    assert load_usage() == {}


def test_save_and_load_roundtrip(skills_home):
    from tools.skill_usage import load_usage, save_usage
    data = {"skill-a": {"use_count": 3, "state": "active"}}
    save_usage(data)
    loaded = load_usage()
    assert loaded["skill-a"]["use_count"] == 3
    assert loaded["skill-a"]["state"] == "active"


def test_save_is_atomic_no_partial_tmp_files(skills_home):
    from tools.skill_usage import save_usage, _usage_file
    save_usage({"x": {"use_count": 1}})
    skills_dir = _usage_file().parent
    # No leftover tempfile
    for p in skills_dir.iterdir():
        assert not p.name.startswith(".usage_"), f"leftover tmp: {p.name}"


def test_get_record_missing_returns_empty_record(skills_home):
    from tools.skill_usage import get_record
    rec = get_record("nonexistent")
    assert rec["use_count"] == 0
    assert rec["view_count"] == 0
    assert rec["state"] == "active"
    assert rec["pinned"] is False
    assert rec["archived_at"] is None


def test_get_record_backfills_missing_keys(skills_home):
    from tools.skill_usage import get_record, save_usage
    save_usage({"legacy": {"use_count": 5}})  # old-format record
    rec = get_record("legacy")
    assert rec["use_count"] == 5
    assert "view_count" in rec  # backfilled
    assert "state" in rec


def test_load_usage_handles_corrupt_file(skills_home):
    from tools.skill_usage import load_usage, _usage_file
    _usage_file().write_text("{ not json }", encoding="utf-8")
    assert load_usage() == {}


# ---------------------------------------------------------------------------
# Counter bumps
# ---------------------------------------------------------------------------

def test_bump_view_increments_and_timestamps(skills_home):
    from tools.skill_usage import bump_view, get_record
    bump_view("my-skill")
    bump_view("my-skill")
    rec = get_record("my-skill")
    assert rec["view_count"] == 2
    assert rec["last_viewed_at"] is not None


def test_bump_use_increments_and_timestamps(skills_home):
    from tools.skill_usage import bump_use, get_record
    bump_use("my-skill")
    rec = get_record("my-skill")
    assert rec["use_count"] == 1
    assert rec["last_used_at"] is not None


def test_bump_patch_increments_and_timestamps(skills_home):
    from tools.skill_usage import bump_patch, get_record
    bump_patch("my-skill")
    rec = get_record("my-skill")
    assert rec["patch_count"] == 1
    assert rec["last_patched_at"] is not None


def test_bump_on_empty_name_is_noop(skills_home):
    from tools.skill_usage import bump_view, load_usage
    bump_view("")
    assert load_usage() == {}


def test_bumps_do_not_corrupt_other_skills(skills_home):
    from tools.skill_usage import bump_view, bump_use, get_record
    bump_view("skill-a")
    bump_use("skill-b")
    bump_view("skill-a")
    assert get_record("skill-a")["view_count"] == 2
    assert get_record("skill-a")["use_count"] == 0
    assert get_record("skill-b")["use_count"] == 1


def test_concurrent_bump_view_preserves_all_updates(skills_home):
    from tools.skill_usage import get_record

    process_count = 6
    iterations = 25
    ctx = mp.get_context("spawn")
    processes = [
        ctx.Process(
            target=_bump_view_many,
            args=(str(skills_home), "shared-skill", iterations),
        )
        for _ in range(process_count)
    ]

    for process in processes:
        process.start()
    for process in processes:
        process.join(timeout=20)

    for process in processes:
        assert process.exitcode == 0
    assert get_record("shared-skill")["view_count"] == process_count * iterations


# ---------------------------------------------------------------------------
# State transitions
# ---------------------------------------------------------------------------

def test_set_state_active(skills_home):
    from tools.skill_usage import set_state, get_record, STATE_ACTIVE
    set_state("x", STATE_ACTIVE)
    assert get_record("x")["state"] == "active"


def test_set_state_archived_records_timestamp(skills_home):
    from tools.skill_usage import set_state, get_record, STATE_ARCHIVED
    set_state("x", STATE_ARCHIVED)
    rec = get_record("x")
    assert rec["state"] == "archived"
    assert rec["archived_at"] is not None


def test_set_state_invalid_is_noop(skills_home):
    from tools.skill_usage import set_state, get_record
    set_state("x", "bogus")
    # No record created for invalid state
    rec = get_record("x")
    assert rec["state"] == "active"  # default


def test_restoring_from_archive_clears_timestamp(skills_home):
    from tools.skill_usage import set_state, get_record, STATE_ARCHIVED, STATE_ACTIVE
    set_state("x", STATE_ARCHIVED)
    assert get_record("x")["archived_at"] is not None
    set_state("x", STATE_ACTIVE)
    assert get_record("x")["archived_at"] is None


def test_set_pinned(skills_home):
    from tools.skill_usage import set_pinned, get_record
    set_pinned("x", True)
    assert get_record("x")["pinned"] is True
    set_pinned("x", False)
    assert get_record("x")["pinned"] is False


def test_forget_removes_record(skills_home):
    from tools.skill_usage import bump_view, forget, load_usage
    bump_view("x")
    assert "x" in load_usage()
    forget("x")
    assert "x" not in load_usage()


# ---------------------------------------------------------------------------
# Provenance filter — the load-bearing safety check
# ---------------------------------------------------------------------------

def test_agent_created_excludes_bundled(skills_home):
    from tools.skill_usage import list_agent_created_skill_names, mark_agent_created
    skills_dir = skills_home / "skills"
    _write_skill(skills_dir, "bundled-skill", category="github")
    _write_skill(skills_dir, "my-skill")
    mark_agent_created("my-skill")
    # Seed a bundled manifest marking bundled-skill as upstream
    (skills_dir / ".bundled_manifest").write_text(
        "bundled-skill:abc123\n", encoding="utf-8",
    )
    names = list_agent_created_skill_names()
    assert "my-skill" in names
    assert "bundled-skill" not in names


def test_agent_created_excludes_hub_installed(skills_home):
    from tools.skill_usage import list_agent_created_skill_names, mark_agent_created
    skills_dir = skills_home / "skills"
    _write_skill(skills_dir, "hub-skill")
    _write_skill(skills_dir, "my-skill")
    mark_agent_created("my-skill")
    hub_dir = skills_dir / ".hub"
    hub_dir.mkdir()
    (hub_dir / "lock.json").write_text(
        json.dumps({"version": 1, "installed": {"hub-skill": {"source": "taps/main"}}}),
        encoding="utf-8",
    )
    names = list_agent_created_skill_names()
    assert "my-skill" in names
    assert "hub-skill" not in names


def test_agent_created_excludes_hub_installed_frontmatter_name(skills_home):
    from tools.skill_usage import (
        is_agent_created,
        list_agent_created_skill_names,
        mark_agent_created,
    )

    skills_dir = skills_home / "skills"
    hub_skill = skills_dir / "productivity" / "getnote"
    hub_skill.mkdir(parents=True)
    (hub_skill / "SKILL.md").write_text(
        """---
name: Get笔记
description: test skill
---

# body
""",
        encoding="utf-8",
    )
    _write_skill(skills_dir, "my-skill")
    mark_agent_created("my-skill")
    hub_dir = skills_dir / ".hub"
    hub_dir.mkdir()
    (hub_dir / "lock.json").write_text(
        json.dumps(
            {
                "version": 1,
                "installed": {
                    "getnote": {
                        "source": "taps/main",
                        "install_path": "productivity/getnote",
                    }
                },
            }
        ),
        encoding="utf-8",
    )

    names = list_agent_created_skill_names()
    assert "my-skill" in names
    assert "Get笔记" not in names
    assert is_agent_created("Get笔记") is False
    assert is_agent_created("getnote") is False


def test_is_agent_created(skills_home):
    from tools.skill_usage import is_agent_created
    skills_dir = skills_home / "skills"
    (skills_dir / ".bundled_manifest").write_text("bundled:abc\n", encoding="utf-8")
    hub_dir = skills_dir / ".hub"
    hub_dir.mkdir()
    (hub_dir / "lock.json").write_text(
        json.dumps({"installed": {"hubbed": {}}}), encoding="utf-8",
    )
    assert is_agent_created("my-skill") is True
    assert is_agent_created("bundled") is False
    assert is_agent_created("hubbed") is False


def test_agent_created_skips_archive_and_hub_dirs(skills_home):
    from tools.skill_usage import list_agent_created_skill_names, mark_agent_created
    skills_dir = skills_home / "skills"
    _write_skill(skills_dir, "real-skill")
    mark_agent_created("real-skill")
    # Dot-prefixed dirs must be ignored even if they contain SKILL.md
    archive = skills_dir / ".archive" / "old-skill"
    archive.mkdir(parents=True)
    (archive / "SKILL.md").write_text(
        "---\nname: old-skill\n---\n", encoding="utf-8",
    )
    names = list_agent_created_skill_names()
    assert "real-skill" in names
    assert "old-skill" not in names


# ---------------------------------------------------------------------------
# Archive / restore
# ---------------------------------------------------------------------------

def test_archive_skill_moves_directory(skills_home):
    from tools.skill_usage import archive_skill, get_record
    skills_dir = skills_home / "skills"
    skill_dir = _write_skill(skills_dir, "old-skill")
    assert skill_dir.exists()

    ok, msg = archive_skill("old-skill")
    assert ok, msg
    assert not skill_dir.exists()
    assert (skills_dir / ".archive" / "old-skill" / "SKILL.md").exists()
    assert get_record("old-skill")["state"] == "archived"
    assert get_record("old-skill")["archived_at"] is not None


def test_archive_refuses_bundled_skill(skills_home):
    from tools.skill_usage import archive_skill
    skills_dir = skills_home / "skills"
    _write_skill(skills_dir, "bundled")
    (skills_dir / ".bundled_manifest").write_text("bundled:abc\n", encoding="utf-8")

    ok, msg = archive_skill("bundled")
    assert not ok
    assert "bundled" in msg.lower() or "hub" in msg.lower()


def test_archive_refuses_hub_skill(skills_home):
    from tools.skill_usage import archive_skill
    skills_dir = skills_home / "skills"
    _write_skill(skills_dir, "hub-skill")
    hub_dir = skills_dir / ".hub"
    hub_dir.mkdir()
    (hub_dir / "lock.json").write_text(
        json.dumps({"installed": {"hub-skill": {}}}), encoding="utf-8",
    )

    ok, msg = archive_skill("hub-skill")
    assert not ok


def test_archive_missing_skill_returns_error(skills_home):
    from tools.skill_usage import archive_skill
    ok, msg = archive_skill("nonexistent")
    assert not ok
    assert "not found" in msg.lower()


def test_restore_skill_moves_back(skills_home):
    from tools.skill_usage import archive_skill, restore_skill, get_record
    skills_dir = skills_home / "skills"
    _write_skill(skills_dir, "temp-skill")
    archive_skill("temp-skill")
    assert not (skills_dir / "temp-skill").exists()

    ok, msg = restore_skill("temp-skill")
    assert ok, msg
    assert (skills_dir / "temp-skill" / "SKILL.md").exists()
    assert get_record("temp-skill")["state"] == "active"


def test_restore_skill_finds_nested_archive_subdir(skills_home):
    """Skills archived under nested category subdirs (e.g.
    .archive/<category>/<skill>/) — left behind by older archive layouts or
    external imports — must still be restorable by name."""
    from tools.skill_usage import restore_skill, get_record
    skills_dir = skills_home / "skills"
    nested = skills_dir / ".archive" / "openclaw-imports" / "nested-skill"
    nested.mkdir(parents=True)
    (nested / "SKILL.md").write_text(
        "---\nname: nested-skill\ndescription: x\n---\n", encoding="utf-8",
    )

    ok, msg = restore_skill("nested-skill")
    assert ok, msg
    assert (skills_dir / "nested-skill" / "SKILL.md").exists()
    assert not nested.exists()
    assert get_record("nested-skill")["state"] == "active"


def test_restore_skill_finds_nested_timestamped_prefix(skills_home):
    """Prefix-match path (timestamped dupes) must also descend into nested
    archive subdirs, not just .archive/ top-level."""
    from tools.skill_usage import restore_skill
    skills_dir = skills_home / "skills"
    nested = skills_dir / ".archive" / "imports" / "dup-skill-20260101000000"
    nested.mkdir(parents=True)
    (nested / "SKILL.md").write_text(
        "---\nname: dup-skill\ndescription: x\n---\n", encoding="utf-8",
    )

    ok, msg = restore_skill("dup-skill")
    assert ok, msg
    assert (skills_dir / "dup-skill" / "SKILL.md").exists()


def test_archive_collision_gets_suffix(skills_home):
    from tools.skill_usage import archive_skill
    skills_dir = skills_home / "skills"
    _write_skill(skills_dir, "dup")
    archive_skill("dup")
    _write_skill(skills_dir, "dup")  # recreate
    ok, msg = archive_skill("dup")
    assert ok
    # Two entries under .archive/ — second should have a timestamp suffix
    archived = sorted(p.name for p in (skills_dir / ".archive").iterdir() if p.is_dir())
    assert "dup" in archived
    assert any(n.startswith("dup-") and n != "dup" for n in archived)


# ---------------------------------------------------------------------------
# Reporting
# ---------------------------------------------------------------------------

def test_agent_created_report_includes_marked_skills_with_defaults(skills_home):
    from tools.skill_usage import agent_created_report, bump_view, mark_agent_created
    skills_dir = skills_home / "skills"
    _write_skill(skills_dir, "a")
    _write_skill(skills_dir, "b")
    mark_agent_created("a")
    mark_agent_created("b")
    bump_view("a")
    rows = agent_created_report()
    by_name = {r["name"]: r for r in rows}
    assert "a" in by_name and "b" in by_name
    assert by_name["a"]["view_count"] == 1
    # b has only the provenance marker — activity fields still default.
    assert by_name["b"]["view_count"] == 0
    assert by_name["b"]["state"] == "active"


def test_manual_skill_with_usage_is_not_curator_managed(skills_home):
    from tools.skill_usage import agent_created_report, bump_view, list_agent_created_skill_names
    skills_dir = skills_home / "skills"
    _write_skill(skills_dir, "manual-skill")

    bump_view("manual-skill")

    assert "manual-skill" not in list_agent_created_skill_names()
    assert "manual-skill" not in {r["name"] for r in agent_created_report()}


def test_agent_created_report_excludes_bundled_and_hub(skills_home):
    from tools.skill_usage import agent_created_report, mark_agent_created
    skills_dir = skills_home / "skills"
    _write_skill(skills_dir, "mine")
    _write_skill(skills_dir, "bundled")
    _write_skill(skills_dir, "hubbed")
    mark_agent_created("mine")
    (skills_dir / ".bundled_manifest").write_text("bundled:abc\n", encoding="utf-8")
    hub = skills_dir / ".hub"
    hub.mkdir()
    (hub / "lock.json").write_text(
        json.dumps({"installed": {"hubbed": {}}}), encoding="utf-8",
    )
    names = {r["name"] for r in agent_created_report()}
    assert "mine" in names
    assert "bundled" not in names
    assert "hubbed" not in names


def test_agent_created_report_derives_activity_from_view_and_patch(skills_home, monkeypatch):
    import tools.skill_usage as skill_usage

    skills_dir = skills_home / "skills"
    _write_skill(skills_dir, "mine")
    timestamps = iter([
        "2026-04-30T10:00:00+00:00",
        "2026-04-30T11:00:00+00:00",
        "2026-04-30T12:00:00+00:00",
        "2026-04-30T13:00:00+00:00",
    ])
    monkeypatch.setattr(skill_usage, "_now_iso", lambda: next(timestamps))

    skill_usage.mark_agent_created("mine")
    skill_usage.bump_view("mine")
    skill_usage.bump_patch("mine")

    row = next(r for r in skill_usage.agent_created_report() if r["name"] == "mine")
    assert row["activity_count"] == 2
    assert row["last_activity_at"] == "2026-04-30T12:00:00+00:00"


# ---------------------------------------------------------------------------
# Telemetry vs curation — usage is tracked for ALL skills; curation is not
# ---------------------------------------------------------------------------

def test_bump_view_tracks_bundled_skill(skills_home):
    """Telemetry IS recorded for bundled skills (observability), but the record
    must NOT make the skill a curation candidate by itself."""
    from tools.skill_usage import (
        bump_view, load_usage, list_agent_created_skill_names,
    )
    skills_dir = skills_home / "skills"
    _write_skill(skills_dir, "ship-bundled")
    (skills_dir / ".bundled_manifest").write_text(
        "ship-bundled:abc\n", encoding="utf-8",
    )

    bump_view("ship-bundled")
    rec = load_usage().get("ship-bundled")
    assert isinstance(rec, dict), "bundled skill telemetry should be recorded"
    assert rec["view_count"] == 1
    # Pruning is off by default in this fixture → not a curation candidate.
    assert "ship-bundled" not in list_agent_created_skill_names()


def test_bump_patch_tracks_hub_skill(skills_home):
    from tools.skill_usage import (
        bump_patch, load_usage, list_agent_created_skill_names,
    )
    skills_dir = skills_home / "skills"
    _write_skill(skills_dir, "from-hub")
    hub = skills_dir / ".hub"
    hub.mkdir()
    (hub / "lock.json").write_text(
        json.dumps({"installed": {"from-hub": {}}}), encoding="utf-8",
    )

    bump_patch("from-hub")
    rec = load_usage().get("from-hub")
    assert isinstance(rec, dict), "hub skill telemetry should be recorded"
    assert rec["patch_count"] == 1
    # Hub skills are NEVER curation candidates regardless of any flag.
    assert "from-hub" not in list_agent_created_skill_names()


def test_bump_use_tracks_hub_skill(skills_home):
    from tools.skill_usage import bump_use, load_usage
    skills_dir = skills_home / "skills"
    _write_skill(skills_dir, "from-hub")
    hub = skills_dir / ".hub"
    hub.mkdir()
    (hub / "lock.json").write_text(
        json.dumps({"installed": {"from-hub": {}}}), encoding="utf-8",
    )

    bump_use("from-hub")
    rec = load_usage().get("from-hub")
    assert isinstance(rec, dict)
    assert rec["use_count"] == 1


def test_set_state_no_op_for_bundled_skill(skills_home):
    """State transitions on bundled skills must not land in the sidecar."""
    from tools.skill_usage import set_state, load_usage, STATE_ARCHIVED
    skills_dir = skills_home / "skills"
    (skills_dir / ".bundled_manifest").write_text(
        "locked:abc\n", encoding="utf-8",
    )
    set_state("locked", STATE_ARCHIVED)
    assert "locked" not in load_usage()


def test_restore_refuses_to_shadow_bundled_skill(skills_home):
    """If a bundled skill now occupies the name, refuse to restore."""
    from tools.skill_usage import archive_skill, restore_skill
    skills_dir = skills_home / "skills"
    _write_skill(skills_dir, "shared-name")
    archive_skill("shared-name")

    # Now a bundled skill appears with the same name
    (skills_dir / ".bundled_manifest").write_text(
        "shared-name:abc\n", encoding="utf-8",
    )
    _write_skill(skills_dir, "shared-name")  # bundled install landed

    ok, msg = restore_skill("shared-name")
    assert not ok
    assert "bundled" in msg.lower() or "shadow" in msg.lower()


def test_end_to_end_telemetry_tracked_but_lifecycle_refused(skills_home):
    """The combined guarantee under decoupled telemetry/curation:

    - Usage telemetry (view/use/patch) IS recorded for bundled & hub skills.
    - Lifecycle mutations (set_state, set_pinned, archive) are REFUSED for them
      (with pruning off, the fixture default), so no state/pinned/archived flag
      lands and the directories stay on disk.
    """
    from tools.skill_usage import (
        bump_view, bump_use, bump_patch, set_state, set_pinned,
        archive_skill, load_usage, STATE_ACTIVE, STATE_STALE, STATE_ARCHIVED,
    )
    skills_dir = skills_home / "skills"
    _write_skill(skills_dir, "bundled-one")
    _write_skill(skills_dir, "hub-one")
    _write_skill(skills_dir, "mine")

    (skills_dir / ".bundled_manifest").write_text(
        "bundled-one:abc\n", encoding="utf-8",
    )
    hub = skills_dir / ".hub"
    hub.mkdir()
    (hub / "lock.json").write_text(
        json.dumps({"installed": {"hub-one": {}}}), encoding="utf-8",
    )

    for name in ("bundled-one", "hub-one"):
        bump_view(name)
        bump_use(name)
        bump_patch(name)
        set_state(name, STATE_STALE)
        set_state(name, STATE_ARCHIVED)
        set_pinned(name, True)
        ok, _msg = archive_skill(name)
        assert not ok, f"archive_skill(\"{name}\") should refuse"

    data = load_usage()
    # Telemetry landed for both.
    for name in ("bundled-one", "hub-one"):
        assert name in data, f"{name} telemetry should be recorded"
        assert data[name]["view_count"] == 1
        assert data[name]["use_count"] == 1
        assert data[name]["patch_count"] == 1
        # But lifecycle mutators were refused — state stays the default, never
        # archived/stale/pinned, and created_by is never agent.
        assert data[name]["state"] == STATE_ACTIVE
        assert data[name]["archived_at"] is None
        assert data[name]["pinned"] is False
        assert data[name].get("created_by") != "agent"

    # Directories must still be in place on disk.
    assert (skills_dir / "bundled-one" / "SKILL.md").exists()
    assert (skills_dir / "hub-one" / "SKILL.md").exists()

    # The agent-created skill can still be mutated normally.
    bump_view("mine")
    assert load_usage()["mine"]["view_count"] == 1


def test_usage_report_covers_all_provenance(skills_home):
    """usage_report() surfaces every skill with provenance, unlike the
    curator-scoped agent_created_report()."""
    from tools.skill_usage import (
        bump_use, usage_report, mark_agent_created,
    )
    skills_dir = skills_home / "skills"
    _write_skill(skills_dir, "bundled-one")
    _write_skill(skills_dir, "hub-one")
    _write_skill(skills_dir, "mine")
    (skills_dir / ".bundled_manifest").write_text("bundled-one:abc\n", encoding="utf-8")
    hub = skills_dir / ".hub"
    hub.mkdir()
    (hub / "lock.json").write_text(
        json.dumps({"installed": {"hub-one": {}}}), encoding="utf-8",
    )
    mark_agent_created("mine")
    for n in ("bundled-one", "hub-one", "mine"):
        bump_use(n)

    rows = {r["name"]: r for r in usage_report()}
    assert set(rows) == {"bundled-one", "hub-one", "mine"}
    assert rows["bundled-one"]["provenance"] == "bundled"
    assert rows["hub-one"]["provenance"] == "hub"
    assert rows["mine"]["provenance"] == "agent"
    # All carry real usage now.
    for n in rows:
        assert rows[n]["use_count"] == 1
        assert rows[n]["_persisted"] is True
