"""Self-modification: synapse/self_edit.py, plus the ACTION_TOOLS/approval-floor
wiring in tools.py and chat.py that gates it.

Path-boundary tests mirror the sibling-directory-bypass idiom already used for
/icons/image (tests/test_smoke.py) and icons/compositor.py — the same bug class,
fixed the same way, tested the same way.
"""
import asyncio
import json
import shutil
import subprocess
from pathlib import Path

import pytest

from synapse import self_edit
from synapse import tools
from synapse import playbook_manager
from synapse.nexus_config import settings
from synapse.playbooks.store import PlaybookFileStore, PlaybookItem


def _requires_git():
    return pytest.mark.skipif(not shutil.which("git"), reason="git not installed")


@pytest.fixture
def fake_project(tmp_path, monkeypatch):
    """A throwaway project root with the subdirs edit_source must know about."""
    root = tmp_path / "proj"
    (root / "synapse").mkdir(parents=True)
    (root / ".git").mkdir()
    (root / "runtime").mkdir()
    (root / "data").mkdir()
    monkeypatch.setattr(settings, "project_root", root)
    monkeypatch.setattr(settings, "source_checkout", True)
    return root


# ---------------------------------------------------------------------------
# Path boundary
# ---------------------------------------------------------------------------

def test_resolve_source_path_allows_a_file_under_root(fake_project):
    f = fake_project / "synapse" / "foo.py"
    f.write_text("x", encoding="utf-8")
    resolved = self_edit._resolve_source_path("synapse/foo.py")
    assert resolved == f.resolve()


def test_resolve_source_path_denies_sibling_directory_bypass(fake_project, tmp_path):
    # A sibling directory that merely shares a string prefix with the allowed
    # root ("proj-evil" vs "proj") must not pass — the exact bug class fixed
    # today in main.py's /icons/image.
    sibling = tmp_path / "proj-evil"
    sibling.mkdir()
    (sibling / "x.py").write_text("evil", encoding="utf-8")
    with pytest.raises(self_edit.PathError):
        self_edit._resolve_source_path("../proj-evil/x.py")


@pytest.mark.parametrize("subdir", sorted(self_edit._DENYLIST_SUBDIRS))
def test_resolve_source_path_denies_each_denylisted_subdir(fake_project, subdir):
    with pytest.raises(self_edit.PathError):
        self_edit._resolve_source_path(f"{subdir}/whatever.txt")


def test_resolve_source_path_denies_absolute_paths(fake_project):
    with pytest.raises(self_edit.PathError):
        self_edit._resolve_source_path("C:\\Windows\\System32\\evil.py")
    with pytest.raises(self_edit.PathError):
        self_edit._resolve_source_path("/etc/passwd")


# ---------------------------------------------------------------------------
# source_checkout gating
# ---------------------------------------------------------------------------

def test_source_checkout_gating(fake_project, monkeypatch):
    monkeypatch.setattr(settings, "source_checkout", False)
    with pytest.raises(RuntimeError):
        self_edit.source_checkout_required()
    preview = self_edit.preview_source_edit("synapse/foo.py", "x")
    assert preview["ok"] is False
    assert "not a source checkout" in preview["error"]


# ---------------------------------------------------------------------------
# Diff computed from ground truth
# ---------------------------------------------------------------------------

def test_preview_computes_a_real_diff_not_the_models_claim(fake_project):
    f = fake_project / "synapse" / "foo.py"
    f.write_text("print('old')\n", encoding="utf-8")
    preview = self_edit.preview_source_edit("synapse/foo.py", "print('new')\n")
    assert preview["ok"] is True
    assert "-print('old')" in preview["diff"]
    assert "+print('new')" in preview["diff"]


def test_preview_rejects_oversized_content(fake_project):
    huge = "x" * (self_edit.MAX_FILE_CHARS + 1)
    preview = self_edit.preview_source_edit("synapse/foo.py", huge)
    assert preview["ok"] is False


# ---------------------------------------------------------------------------
# Apply + git commit
# ---------------------------------------------------------------------------

@_requires_git()
def test_apply_source_edit_writes_and_commits(fake_project):
    subprocess.run(["git", "init"], cwd=fake_project, check=True, capture_output=True)
    subprocess.run(["git", "config", "user.email", "test@test"], cwd=fake_project, check=True, capture_output=True)
    subprocess.run(["git", "config", "user.name", "test"], cwd=fake_project, check=True, capture_output=True)

    result = self_edit.apply_source_edit("synapse/foo.py", "print('applied')\n", "add foo")
    assert result["ok"] is True
    assert (fake_project / "synapse" / "foo.py").read_text(encoding="utf-8") == "print('applied')\n"
    assert result["commit"] is not None

    log = subprocess.run(["git", "log", "--oneline"], cwd=fake_project, check=True,
                          capture_output=True, text=True)
    assert "self-edit: add foo" in log.stdout


def test_apply_source_edit_degrades_to_no_commit_when_git_fails(fake_project, monkeypatch):
    # No `git init` here — fake_project/.git exists as a plain directory, not a
    # real repo, so git commands fail. The write must still succeed.
    result = self_edit.apply_source_edit("synapse/foo.py", "print('ok')\n", "x")
    assert result["ok"] is True
    assert result["commit"] is None
    assert (fake_project / "synapse" / "foo.py").read_text(encoding="utf-8") == "print('ok')\n"


# ---------------------------------------------------------------------------
# Playbook merge semantics
# ---------------------------------------------------------------------------

@pytest.fixture
def fake_playbooks(tmp_path, monkeypatch):
    store = PlaybookFileStore(tmp_path / "playbooks")
    monkeypatch.setattr(playbook_manager, "playbook_store", store)
    monkeypatch.setattr(self_edit, "playbook_store", store)
    return store


def test_edit_playbook_merge_preserves_omitted_fields(fake_playbooks):
    fake_playbooks.add_playbook(PlaybookItem(
        id="p1", title="Title", goal="Goal", instructions="Do X",
        tags=["a"], tools=["remember"], model="m1", order=0,
    ))
    result = playbook_manager.persist_playbook({"id": "p1", "instructions": "Do Y"}, merge=True)
    assert result["instructions"] == "Do Y"
    assert result["title"] == "Title"
    assert result["goal"] == "Goal"
    assert result["tags"] == ["a"]
    assert result["tools"] == ["remember"]
    assert result["model"] == "m1"


def test_edit_playbook_requires_full_fields_for_new(fake_playbooks):
    with pytest.raises(ValueError):
        playbook_manager.persist_playbook({"instructions": "only this"}, merge=True)


def test_edit_playbook_create_appends_at_tail_not_main(fake_playbooks):
    fake_playbooks.add_playbook(PlaybookItem(id="p1", title="A", goal="g", instructions="i", order=0))
    result = playbook_manager.persist_playbook(
        {"title": "B", "goal": "g2", "instructions": "i2"}, merge=True,
    )
    assert result["order"] != 0
    main = fake_playbooks.all_playbooks()[0]
    assert main.id == "p1"


def test_make_main_reassigns_order_zero_without_corrupting_the_rest(fake_playbooks):
    fake_playbooks.add_playbook(PlaybookItem(id="p1", title="A", goal="g", instructions="i", order=0))
    fake_playbooks.add_playbook(PlaybookItem(id="p2", title="B", goal="g", instructions="i", order=1))
    playbook_manager.make_main("p2")
    all_pb = fake_playbooks.all_playbooks()
    assert all_pb[0].id == "p2"
    assert {p.id for p in all_pb} == {"p1", "p2"}


def test_preview_playbook_edit_flags_becomes_main(fake_playbooks):
    fake_playbooks.add_playbook(PlaybookItem(id="p1", title="A", goal="g", instructions="i", order=0))
    fake_playbooks.add_playbook(PlaybookItem(id="p2", title="B", goal="g", instructions="i", order=1))
    preview = self_edit.preview_playbook_edit({"id": "p2", "make_active": True})
    assert preview["ok"] is True
    assert preview["becomes_main_playbook"] is True


# ---------------------------------------------------------------------------
# Settings edit
# ---------------------------------------------------------------------------

def test_edit_settings_ignores_unknown_keys():
    preview = self_edit.preview_settings_edit({"changes": {"model": "llama3.1:8b", "not_a_real_key": 1}})
    assert preview["ok"] is True
    assert "model" in preview["applied"]
    assert "not_a_real_key" in preview["ignored_unknown"]


def test_edit_settings_flags_policy_and_system_prompt_changes():
    preview = self_edit.preview_settings_edit({"changes": {"action_tool_policy": "allow"}})
    assert preview["policy_change"] is True
    preview2 = self_edit.preview_settings_edit({"changes": {"system_prompt": "be nice"}})
    assert preview2["system_prompt_change"] is True


# ---------------------------------------------------------------------------
# Approval floor + payload enrichment (chat.py wiring)
# ---------------------------------------------------------------------------

class _EditManager:
    """Returns one edit_settings tool_call, then plain content."""
    def __init__(self, args):
        self.n = 0
        self.args = args

    async def chat(self, **_):
        self.n += 1
        if self.n == 1:
            return {"role": "assistant",
                    "tool_calls": [{"function": {"name": "edit_settings", "arguments": self.args}}]}
        return {"role": "assistant", "content": "done"}


def _drive(policy, decision, args, monkeypatch, preview_stub=None):
    from synapse import chat as chatmod

    async def fake_dispatch(name, call_args):
        return json.dumps({"ok": True})
    monkeypatch.setattr(tools, "dispatch", fake_dispatch)

    if preview_stub is not None:
        monkeypatch.setattr(self_edit, "preview_for", lambda name, a: preview_stub)

    async def run():
        messages = [{"role": "user", "content": "change a setting"}]
        schemas = tools.schemas_for(["edit_settings"])
        gen = chatmod._run_tool_loop(_EditManager(args), messages, "m", schemas, None, None,
                                     conversation_id="conv2", policy=policy)
        statuses = []
        approve_payload = None
        async for s in gen:
            statuses.append(s)
            if s.startswith("__approve__"):
                approve_payload = json.loads(s[len("__approve__"):])
                w = chatmod.pending_approvals["conv2"]
                w["decisions"] = {"edit_settings": decision}
                w["event"].set()
        return statuses, approve_payload

    return asyncio.run(run())


def test_edit_settings_requires_approval_even_when_policy_is_allow(monkeypatch):
    statuses, payload = _drive("allow", True, {"changes": {"model": "x"}}, monkeypatch)
    assert any(s.startswith("__approve__") for s in statuses)
    assert payload is not None


def test_approve_payload_carries_the_computed_preview(monkeypatch):
    stub = {"ok": True, "applied": {"model": {"before": "a", "after": "x"}}}
    statuses, payload = _drive("ask", True, {"changes": {"model": "x"}}, monkeypatch, preview_stub=stub)
    assert payload["actions"][0]["preview"] == stub


def test_approve_payload_degrades_gracefully_when_preview_raises(monkeypatch):
    def boom(name, args):
        raise RuntimeError("preview exploded")
    monkeypatch.setattr(self_edit, "preview_for", boom)
    statuses, payload = _drive("ask", True, {"changes": {"model": "x"}}, monkeypatch)
    assert payload is not None
    assert payload["actions"][0]["preview"]["ok"] is False


def test_action_tools_include_self_edit_and_are_gated_by_consent():
    allow = ["edit_playbook", "edit_settings", "edit_source"]
    on = [s["function"]["name"] for s in tools.schemas_for(allow, allow_actions=True)]
    off = [s["function"]["name"] for s in tools.schemas_for(allow, allow_actions=False)]
    assert set(on) == set(allow)
    assert off == []
    for name in allow:
        assert tools.is_action(name)
        assert name in self_edit.ALWAYS_ASK_TOOLS


# ---------------------------------------------------------------------------
# End-to-end: dispatch("edit_source", ...) through the real preview/apply path
# ---------------------------------------------------------------------------

@_requires_git()
def test_dispatch_edit_source_end_to_end(fake_project):
    subprocess.run(["git", "init"], cwd=fake_project, check=True, capture_output=True)
    subprocess.run(["git", "config", "user.email", "test@test"], cwd=fake_project, check=True, capture_output=True)
    subprocess.run(["git", "config", "user.name", "test"], cwd=fake_project, check=True, capture_output=True)

    out = asyncio.run(tools.dispatch("edit_source", {
        "path": "synapse/foo.py", "new_content": "print('e2e')\n", "summary": "e2e test",
    }))
    payload = json.loads(out)
    assert payload["ok"] is True
    assert payload["commit"] is not None
    assert "nexus-edit" in payload["fence"]
    assert (fake_project / "synapse" / "foo.py").read_text(encoding="utf-8") == "print('e2e')\n"
