"""Tests for skill fuzzy patching via tools.fuzzy_match."""

import json
import os
import stat

import pytest

from tools.skill_manager_tool import (
    _create_skill,
    _edit_skill,
    _patch_skill,
    _write_file,
    skill_manage,
)


SKILL_CONTENT = """\
---
name: test-skill
description: A test skill for unit testing.
---

# Test Skill

Step 1: Do the thing.
Step 2: Do another thing.
Step 3: Final step.
"""


# ---------------------------------------------------------------------------
# Fuzzy patching
# ---------------------------------------------------------------------------


class TestFuzzyPatchSkill:
    @pytest.fixture(autouse=True)
    def setup_skills(self, tmp_path, monkeypatch):
        skills_dir = tmp_path / "skills"
        skills_dir.mkdir()
        monkeypatch.setattr("tools.skill_manager_tool.SKILLS_DIR", skills_dir)
        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
        self.skills_dir = skills_dir

    def test_exact_match_still_works(self):
        _create_skill("test-skill", SKILL_CONTENT)
        result = _patch_skill("test-skill", "Step 1: Do the thing.", "Step 1: Done!")
        assert result["success"] is True
        content = (self.skills_dir / "test-skill" / "SKILL.md").read_text()
        assert "Step 1: Done!" in content

    def test_whitespace_trimmed_match(self):
        """Patch with extra leading whitespace should still find the target."""
        skill = """\
---
name: ws-skill
description: Whitespace test
---

# Commands

    def hello():
        print("hi")
"""
        _create_skill("ws-skill", skill)
        # Agent sends patch with no leading whitespace (common LLM behaviour)
        result = _patch_skill("ws-skill", "def hello():\n    print(\"hi\")", "def hello():\n    print(\"hello world\")")
        assert result["success"] is True
        content = (self.skills_dir / "ws-skill" / "SKILL.md").read_text()
        assert 'print("hello world")' in content


    def test_multiple_matches_blocked_without_replace_all(self):
        """Multiple fuzzy matches should return an error without replace_all."""
        skill = """\
---
name: dup-skill
description: Duplicate test
---

# Steps

word word word
"""
        _create_skill("dup-skill", skill)
        result = _patch_skill("dup-skill", "word", "replaced")
        assert result["success"] is False
        assert "match" in result["error"].lower()


    def test_skill_manage_patch_uses_fuzzy(self):
        """The dispatcher should route to the fuzzy-matching patch."""
        _create_skill("test-skill", SKILL_CONTENT)
        raw = skill_manage(
            action="patch",
            name="test-skill",
            old_string="  Step 1: Do the thing.",  # extra leading space
            new_string="Step 1: Updated.",
        )
        result = json.loads(raw)
        # Should succeed via line-trimmed or indentation-flexible matching
        assert result["success"] is True

    @pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits")
    def test_created_skill_is_group_readable(self):
        """New instructional skills use the public-document mode 0644."""
        _create_skill("mode-skill", SKILL_CONTENT)
        mode = stat.S_IMODE((self.skills_dir / "mode-skill" / "SKILL.md").stat().st_mode)
        assert mode == 0o644

    def test_create_rollback_removes_skill_when_scan_blocks(self, monkeypatch):
        """Blocked skill creation removes the newly created skill directory."""
        monkeypatch.setattr(
            "tools.skill_manager_tool._security_scan_skill",
            lambda _skill_dir: "blocked",
        )

        result = _create_skill("blocked-skill", SKILL_CONTENT)

        assert result["success"] is False
        assert not (self.skills_dir / "blocked-skill").exists()

    @pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits")
    def test_new_documents_are_exactly_0644_under_restrictive_umask(self):
        """New skill documents override a restrictive process umask."""
        old_umask = os.umask(0o077)
        try:
            create_result = _create_skill("umask-skill", SKILL_CONTENT)
            write_result = _write_file(
                "umask-skill", "references/example.md", "# Reference\n"
            )
        finally:
            os.umask(old_umask)

        assert create_result["success"] is True
        assert write_result["success"] is True
        skill_md = self.skills_dir / "umask-skill" / "SKILL.md"
        reference = self.skills_dir / "umask-skill" / "references/example.md"
        assert stat.S_IMODE(skill_md.stat().st_mode) == 0o644
        assert stat.S_IMODE(reference.stat().st_mode) == 0o644

    @pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits")
    def test_explicit_skill_md_patch_preserves_existing_mode(self):
        """Explicit SKILL.md patch paths preserve the existing document mode."""
        _create_skill("explicit-skill", SKILL_CONTENT)
        skill_md = self.skills_dir / "explicit-skill" / "SKILL.md"
        skill_md.chmod(0o660)

        result = _patch_skill(
            "explicit-skill",
            "Step 1: Do the thing.",
            "Step 1: Done!",
            file_path="SKILL.md",
        )

        assert result["success"] is True
        assert "Step 1: Done!" in skill_md.read_text(encoding="utf-8")
        assert stat.S_IMODE(skill_md.stat().st_mode) == 0o660

    @pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits")
    @pytest.mark.parametrize("mode", [0o600, 0o660])
    def test_edit_preserves_existing_mode(self, mode):
        """Full skill edits must preserve private and shared document modes."""
        _create_skill("mode-skill", SKILL_CONTENT)
        skill_md = self.skills_dir / "mode-skill" / "SKILL.md"
        skill_md.chmod(mode)
        replacement = SKILL_CONTENT.replace("Step 1: Do the thing.", "Step 1: Done!")

        result = _edit_skill("mode-skill", replacement)

        assert result["success"] is True
        assert stat.S_IMODE(skill_md.stat().st_mode) == mode

    @pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits")
    @pytest.mark.parametrize("mode", [0o600, 0o660])
    def test_patched_skill_preserves_existing_mode(self, mode):
        """Atomic patching must preserve both private and shared modes."""
        _create_skill("mode-skill", SKILL_CONTENT)
        skill_md = self.skills_dir / "mode-skill" / "SKILL.md"
        skill_md.chmod(mode)

        result = _patch_skill("mode-skill", "Step 1: Do the thing.", "Step 1: Done!")

        assert result["success"] is True
        assert stat.S_IMODE(skill_md.stat().st_mode) == mode

    @pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits")
    def test_supporting_file_write_uses_group_readable_mode(self):
        """New reference files should follow the same document mode."""
        _create_skill("mode-skill", SKILL_CONTENT)

        result = _write_file(
            "mode-skill",
            "references/example.md",
            "# Reference\n",
        )

        assert result["success"] is True
        reference = self.skills_dir / "mode-skill" / "references/example.md"
        assert stat.S_IMODE(reference.stat().st_mode) == 0o644

    @pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits")
    @pytest.mark.parametrize("mode", [0o600, 0o660])
    def test_supporting_file_write_preserves_existing_mode(self, mode):
        """Overwriting a reference preserves its existing private or shared mode."""
        _create_skill("mode-skill", SKILL_CONTENT)
        reference = self.skills_dir / "mode-skill" / "references/example.md"
        reference.parent.mkdir()
        reference.write_text("old\n", encoding="utf-8")
        reference.chmod(mode)

        result = _write_file("mode-skill", "references/example.md", "new\n")

        assert result["success"] is True
        assert stat.S_IMODE(reference.stat().st_mode) == mode

    @pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits")
    @pytest.mark.parametrize("mode", [0o600, 0o660])
    def test_supporting_file_patch_preserves_existing_mode(self, mode):
        """Patching a reference preserves its existing private or shared mode."""
        _create_skill("mode-skill", SKILL_CONTENT)
        reference = self.skills_dir / "mode-skill" / "references/example.md"
        reference.parent.mkdir()
        reference.write_text("old\n", encoding="utf-8")
        reference.chmod(mode)

        result = _patch_skill(
            "mode-skill",
            "old",
            "new",
            file_path="references/example.md",
        )

        assert result["success"] is True
        assert reference.read_text(encoding="utf-8") == "new\n"
        assert stat.S_IMODE(reference.stat().st_mode) == mode

    @pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits")
    def test_supporting_file_patch_rollback_preserves_mode_when_scan_blocks(
        self, monkeypatch
    ):
        """Blocked reference patches restore content and the original mode."""
        _create_skill("rollback-skill", SKILL_CONTENT)
        reference = self.skills_dir / "rollback-skill" / "references/example.md"
        reference.parent.mkdir()
        reference.write_text("original\n", encoding="utf-8")
        reference.chmod(0o660)
        monkeypatch.setattr(
            "tools.skill_manager_tool._security_scan_skill",
            lambda _skill_dir: "blocked",
        )

        result = _patch_skill(
            "rollback-skill",
            "original",
            "blocked",
            file_path="references/example.md",
        )

        assert result["success"] is False
        assert reference.read_text(encoding="utf-8") == "original\n"
        assert stat.S_IMODE(reference.stat().st_mode) == 0o660

    @pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits")
    def test_edit_rollback_preserves_existing_mode_when_scan_blocks(self, monkeypatch):
        """Blocked full edits restore both content and the original mode."""
        _create_skill("rollback-skill", SKILL_CONTENT)
        skill_md = self.skills_dir / "rollback-skill" / "SKILL.md"
        skill_md.chmod(0o660)
        replacement = SKILL_CONTENT.replace("Step 1: Do the thing.", "blocked edit")
        monkeypatch.setattr(
            "tools.skill_manager_tool._security_scan_skill",
            lambda _skill_dir: "blocked",
        )

        result = _edit_skill("rollback-skill", replacement)

        assert result["success"] is False
        assert skill_md.read_text(encoding="utf-8") == SKILL_CONTENT
        assert stat.S_IMODE(skill_md.stat().st_mode) == 0o660

    @pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits")
    def test_patch_rollback_preserves_existing_mode_when_scan_blocks(self, monkeypatch):
        """Blocked patches restore both content and the original mode."""
        _create_skill("rollback-skill", SKILL_CONTENT)
        skill_md = self.skills_dir / "rollback-skill" / "SKILL.md"
        skill_md.chmod(0o600)
        monkeypatch.setattr(
            "tools.skill_manager_tool._security_scan_skill",
            lambda _skill_dir: "blocked",
        )

        result = _patch_skill(
            "rollback-skill", "Step 1: Do the thing.", "blocked patch"
        )

        assert result["success"] is False
        assert skill_md.read_text(encoding="utf-8") == SKILL_CONTENT
        assert stat.S_IMODE(skill_md.stat().st_mode) == 0o600

    @pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits")
    def test_supporting_file_rollback_preserves_existing_mode_when_scan_blocks(
        self, monkeypatch
    ):
        """Blocked supporting-file overwrites restore content and mode."""
        _create_skill("rollback-skill", SKILL_CONTENT)
        reference = self.skills_dir / "rollback-skill" / "references/example.md"
        reference.parent.mkdir()
        reference.write_text("original\n", encoding="utf-8")
        reference.chmod(0o660)
        monkeypatch.setattr(
            "tools.skill_manager_tool._security_scan_skill",
            lambda _skill_dir: "blocked",
        )

        result = _write_file("rollback-skill", "references/example.md", "blocked\n")

        assert result["success"] is False
        assert reference.read_text(encoding="utf-8") == "original\n"
        assert stat.S_IMODE(reference.stat().st_mode) == 0o660

    def test_new_supporting_file_rollback_removes_file_when_scan_blocks(self, monkeypatch):
        """Blocked supporting-file creates remove the newly written file."""
        _create_skill("rollback-skill", SKILL_CONTENT)
        reference = self.skills_dir / "rollback-skill" / "references/example.md"
        monkeypatch.setattr(
            "tools.skill_manager_tool._security_scan_skill",
            lambda _skill_dir: "blocked",
        )

        result = _write_file("rollback-skill", "references/example.md", "blocked\n")

        assert result["success"] is False
        assert not reference.exists()
