"""Tests for MCP rulebook tools — patches the service layer."""
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from tests.helpers import FakeMCP, fake_rule, fake_rulebook, fake_topic


pytestmark = pytest.mark.usefixtures("_bind_user")


@pytest.mark.asyncio
async def test_list_rulebooks_wraps_in_dict():
    rows = [fake_rulebook(id=1, title="t"), fake_rulebook(id=2, title="t")]
    with patch(
        "scribe.mcp.tools.rulebooks.rulebooks_svc.list_rulebooks",
        AsyncMock(return_value=rows),
    ):
        from scribe.mcp.tools.rulebooks import list_rulebooks
        out = await list_rulebooks()
    assert len(out["rulebooks"]) == 2


@pytest.mark.asyncio
async def test_get_rulebook_includes_topics():
    rb = fake_rulebook(id=1, title="t")
    topics = [fake_topic(id=10, title="git"), fake_topic(id=11, title="git")]
    with patch(
        "scribe.mcp.tools.rulebooks.rulebooks_svc.get_rulebook",
        AsyncMock(return_value=rb),
    ), patch(
        "scribe.mcp.tools.rulebooks.rulebooks_svc.list_topics",
        AsyncMock(return_value=topics),
    ):
        from scribe.mcp.tools.rulebooks import get_rulebook
        out = await get_rulebook(rulebook_id=1)
    assert out["id"] == 1
    assert len(out["topics"]) == 2


@pytest.mark.asyncio
async def test_get_rulebook_raises_when_not_found():
    with patch(
        "scribe.mcp.tools.rulebooks.rulebooks_svc.get_rulebook",
        AsyncMock(return_value=None),
    ):
        from scribe.mcp.tools.rulebooks import get_rulebook
        with pytest.raises(ValueError, match="rulebook 999 not found"):
            await get_rulebook(rulebook_id=999)


def _plain_detail():
    """Stub the rule_detail seam these tool tests are not about.

    create/update/get_rule now return through services.rulebooks.rule_detail,
    which reads the rule's areas and edges from the database. These are unit
    tests with no database, and what they assert is that the TOOL forwards the
    right arguments — so the seam is stubbed to the plain record, the same way
    they already stub the create/update calls themselves.
    """
    async def _detail(_uid, rule, _system_ids=None):
        return rule.to_dict()
    return patch("scribe.mcp.tools.rulebooks.rulebooks_svc.rule_detail", _detail)


@pytest.mark.asyncio
async def test_create_rule_passes_required_fields():
    rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
    mock = AsyncMock(return_value=rule)
    with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_rule", mock), _plain_detail():
        from scribe.mcp.tools.rulebooks import create_rule
        await create_rule(
            topic_id=10, title="dev is home", statement="Work directly on dev",
        )
    kwargs = mock.call_args.kwargs
    assert kwargs["user_id"] == 7
    assert kwargs["topic_id"] == 10
    assert kwargs["statement"] == "Work directly on dev"


@pytest.mark.asyncio
async def test_create_rule_blocked_by_duplicate_gate():
    from scribe.services.dedup import DuplicateMatch
    dup = DuplicateMatch(id=47, title="dev is home", similarity=1.0, reason="title")
    create_mock = AsyncMock()
    with patch("scribe.mcp.tools.rulebooks.dedup_svc.find_duplicate_rule",
               AsyncMock(return_value=dup)), \
         patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_rule", create_mock):
        from scribe.mcp.tools.rulebooks import create_rule
        out = await create_rule(topic_id=10, title="dev is home", statement="x")
    assert out["duplicate"] is True
    assert out["existing_id"] == 47
    assert "update_rule" in out["message"]
    create_mock.assert_not_called()


@pytest.mark.asyncio
async def test_create_rule_force_bypasses_duplicate_gate():
    find_mock = AsyncMock()
    with patch("scribe.mcp.tools.rulebooks.dedup_svc.find_duplicate_rule", find_mock), \
         patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_rule",
               AsyncMock(return_value=fake_rule(id=5, title="r", statement="s", topic_id=10))), \
         _plain_detail():
        from scribe.mcp.tools.rulebooks import create_rule
        out = await create_rule(topic_id=10, title="dev is home", statement="x", force=True)
    assert out["id"] == 5
    find_mock.assert_not_called()


@pytest.mark.asyncio
async def test_update_rule_only_sends_non_default_fields():
    rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
    mock = AsyncMock(return_value=rule)
    with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.update_rule", mock), _plain_detail():
        from scribe.mcp.tools.rulebooks import update_rule
        await update_rule(rule_id=1, statement="new statement")
    args, kwargs = mock.call_args
    assert args == (1, 7)
    # `clear` is always forwarded — an empty tuple is "clear nothing", which is
    # a value, not an absent argument. Everything the caller left at its
    # default stays out: that is the property this test pins.
    assert kwargs == {"statement": "new statement", "clear": ()}


@pytest.mark.asyncio
async def test_update_rule_forwards_the_fields_named_for_clearing():
    """Naming a field is the only way to empty it through this door.

    "" means "leave unchanged" here, so a caller has no value that means
    "remove it" — which is what makes an explicit list necessary and what
    stops a partial update from wiping the fields it did not mention.
    """
    rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
    mock = AsyncMock(return_value=rule)
    with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.update_rule", mock), _plain_detail():
        from scribe.mcp.tools.rulebooks import update_rule
        await update_rule(rule_id=1, clear_fields=["verify_with"])
    _args, kwargs = mock.call_args
    assert kwargs == {"clear": ["verify_with"]}


@pytest.mark.asyncio
async def test_update_rule_sends_the_check_fields_when_given():
    rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
    mock = AsyncMock(return_value=rule)
    with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.update_rule", mock), _plain_detail():
        from scribe.mcp.tools.rulebooks import update_rule
        await update_rule(
            rule_id=1,
            verify_with="cat CI-runner/renovate/config.js",
            expires_when="approval is turned off",
        )
    _args, kwargs = mock.call_args
    assert kwargs == {
        "verify_with": "cat CI-runner/renovate/config.js",
        "expires_when": "approval is turned off",
        "clear": (),
    }


@pytest.mark.asyncio
async def test_delete_rule_without_confirmed_returns_warning():
    """delete_rule with confirmed=False returns a preview, not an action."""
    rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
    with patch(
        "scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule",
        AsyncMock(return_value=rule),
    ), patch(
        "scribe.mcp.tools.rulebooks.rulebooks_svc.delete_rule",
        AsyncMock(),
    ) as mock_delete:
        from scribe.mcp.tools.rulebooks import delete_rule
        out = await delete_rule(rule_id=1, confirmed=False)
    assert out.get("confirmed_required") is True
    assert "confirmed=True" in out.get("warning", "")
    assert not mock_delete.called  # service NOT called


@pytest.mark.asyncio
async def test_delete_rule_with_confirmed_soft_deletes():
    rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
    mock_delete = AsyncMock(return_value="batch-1")
    with patch(
        "scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule",
        AsyncMock(return_value=rule),
    ), patch(
        "scribe.mcp.tools.rulebooks.trash_svc.delete",
        mock_delete,
    ):
        from scribe.mcp.tools.rulebooks import delete_rule
        out = await delete_rule(rule_id=1, confirmed=True)
    assert out["deleted"] == 1
    assert out["deleted_batch_id"] == "batch-1"
    assert mock_delete.called


@pytest.mark.asyncio
async def test_subscribe_project_to_rulebook_calls_service():
    mock = AsyncMock()
    with patch(
        "scribe.mcp.tools.rulebooks.rulebooks_svc.subscribe_project", mock,
    ):
        from scribe.mcp.tools.rulebooks import subscribe_project_to_rulebook
        out = await subscribe_project_to_rulebook(project_id=3, rulebook_id=1)
    assert out["subscribed"] is True
    assert mock.called


@pytest.mark.asyncio
async def test_unsubscribe_project_from_rulebook_calls_service():
    mock = AsyncMock()
    with patch(
        "scribe.mcp.tools.rulebooks.rulebooks_svc.unsubscribe_project", mock,
    ):
        from scribe.mcp.tools.rulebooks import unsubscribe_project_from_rulebook
        out = await unsubscribe_project_from_rulebook(project_id=3, rulebook_id=1)
    assert out["subscribed"] is False
    assert mock.called


def test_register_attaches_every_tool():
    """Every tool in the module reaches the server.

    The count is the guard: a function added to the module but left out of
    register()'s tuple is invisible to callers and raises nothing. The name
    said "sixteen" for ten tools' worth of growth — the number lives in the
    assertion, not the title, so it cannot drift again.
    """
    from scribe.mcp.tools.rulebooks import register
    mcp = FakeMCP()

    register(mcp)
    # 26 through milestone 307, +2 for the staleness sweep (milestone 312),
    # +1 for a rule's edit history (milestone 323).
    assert len(mcp.names) == 29
    # spot-check a few names
    assert "list_rulebooks" in mcp.names
    assert "create_rule" in mcp.names
    assert "subscribe_project_to_rulebook" in mcp.names
    assert "list_always_on_rules" in mcp.names
    # milestone 297: a project's opt-out of a whole always-on rulebook
    assert "exclude_always_on_rulebook" in mcp.names
    assert "include_always_on_rulebook" in mcp.names
    assert "create_project_rule" in mcp.names
    assert "suppress_rule_for_project" in mcp.names
    # milestone 312: the sweep, and the stamp that answers it
    assert "rules_due_for_verification" in mcp.names
    assert "mark_rule_verified" in mcp.names
    # milestone 323: what a rule used to say
    assert "rule_history" in mcp.names
    assert "unsuppress_rule_for_project" in mcp.names
    assert "suppress_topic_for_project" in mcp.names
    assert "unsuppress_topic_for_project" in mcp.names


@pytest.mark.asyncio
async def test_list_always_on_rules_returns_empty_when_no_always_on_rulebooks():
    with patch(
        "scribe.mcp.tools.rulebooks.rulebooks_svc.list_always_on_rules",
        AsyncMock(return_value=[]),
    ):
        from scribe.mcp.tools.rulebooks import list_always_on_rules
        out = await list_always_on_rules()
    # An install with no always-on rulebooks still gets a marker (milestone
    # 323): "no rules" is a STATE, and a payload that omitted the key would
    # make the write path read every session on a fresh install as a change.
    assert out == {"rules": [], "total": 0, "rules_etag": "empty|0"}


@pytest.mark.asyncio
async def test_list_always_on_rules_projects_each_rule():
    rules = [fake_rule(id=100, title="r", statement="s", topic_id=10), fake_rule(id=101, title="r", statement="s", topic_id=10)]
    with patch(
        "scribe.mcp.tools.rulebooks.rulebooks_svc.list_always_on_rules",
        AsyncMock(return_value=rules),
    ):
        from scribe.mcp.tools.rulebooks import list_always_on_rules
        out = await list_always_on_rules()
    assert out["total"] == 2
    assert {r["id"] for r in out["rules"]} == {100, 101}
    assert all("topic_id" in r for r in out["rules"])


@pytest.mark.asyncio
async def test_update_rulebook_forwards_always_on_when_set():
    rb = fake_rulebook(id=1, title="t")
    mock = AsyncMock(return_value=rb)
    with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.update_rulebook", mock):
        from scribe.mcp.tools.rulebooks import update_rulebook
        await update_rulebook(rulebook_id=1, always_on=True)
    kwargs = mock.call_args.kwargs
    assert kwargs.get("always_on") is True
    assert "title" not in kwargs
    assert "description" not in kwargs


@pytest.mark.asyncio
async def test_update_rulebook_omits_always_on_when_none():
    rb = fake_rulebook(id=1, title="t")
    mock = AsyncMock(return_value=rb)
    with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.update_rulebook", mock):
        from scribe.mcp.tools.rulebooks import update_rulebook
        await update_rulebook(rulebook_id=1, title="new title")
    kwargs = mock.call_args.kwargs
    assert "always_on" not in kwargs
    assert kwargs["title"] == "new title"


@pytest.mark.asyncio
async def test_create_project_rule_passes_required_fields():
    rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
    mock = AsyncMock(return_value=rule)
    with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock), _plain_detail():
        from scribe.mcp.tools.rulebooks import create_project_rule
        await create_project_rule(
            project_id=42,
            statement="Always run migrations through alembic, not raw SQL.",
            why="audit trail",
        )
    kwargs = mock.call_args.kwargs
    assert kwargs["user_id"] == 7
    assert kwargs["project_id"] == 42
    assert kwargs["statement"].startswith("Always run migrations")
    assert kwargs["why"] == "audit trail"


@pytest.mark.asyncio
async def test_create_project_rule_derives_title_from_statement():
    rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
    mock = AsyncMock(return_value=rule)
    with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock), _plain_detail():
        from scribe.mcp.tools.rulebooks import create_project_rule
        await create_project_rule(
            project_id=42,
            statement="Avoid auto-generated docstrings. Reviewers find them noise.",
        )
    kwargs = mock.call_args.kwargs
    # Title should be derived from the first sentence, capped at 50 chars
    assert kwargs["title"] == "Avoid auto-generated docstrings"


@pytest.mark.asyncio
async def test_create_project_rule_uses_explicit_title_when_given():
    rule = fake_rule(id=100, title="r", statement="s", topic_id=10)
    mock = AsyncMock(return_value=rule)
    with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock), _plain_detail():
        from scribe.mcp.tools.rulebooks import create_project_rule
        await create_project_rule(
            project_id=42,
            statement="anything",
            title="no auto-docstrings",
        )
    kwargs = mock.call_args.kwargs
    assert kwargs["title"] == "no auto-docstrings"


@pytest.mark.asyncio
async def test_suppress_rule_for_project_passes_through():
    mock = AsyncMock(return_value=None)
    with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.suppress_rule_for_project", mock):
        from scribe.mcp.tools.rulebooks import suppress_rule_for_project
        out = await suppress_rule_for_project(project_id=3, rule_id=17)
    kwargs = mock.call_args.kwargs
    assert kwargs == {"project_id": 3, "rule_id": 17, "user_id": 7}
    assert out == {"project_id": 3, "rule_id": 17, "suppressed": True}


@pytest.mark.asyncio
async def test_unsuppress_rule_for_project_passes_through():
    mock = AsyncMock(return_value=None)
    with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.unsuppress_rule_for_project", mock):
        from scribe.mcp.tools.rulebooks import unsuppress_rule_for_project
        out = await unsuppress_rule_for_project(project_id=3, rule_id=17)
    kwargs = mock.call_args.kwargs
    assert kwargs == {"project_id": 3, "rule_id": 17, "user_id": 7}
    assert out == {"project_id": 3, "rule_id": 17, "suppressed": False}


@pytest.mark.asyncio
async def test_suppress_topic_for_project_passes_through():
    mock = AsyncMock(return_value=None)
    with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.suppress_topic_for_project", mock):
        from scribe.mcp.tools.rulebooks import suppress_topic_for_project
        out = await suppress_topic_for_project(project_id=3, topic_id=22)
    kwargs = mock.call_args.kwargs
    assert kwargs == {"project_id": 3, "topic_id": 22, "user_id": 7}
    assert out == {"project_id": 3, "topic_id": 22, "suppressed": True}


@pytest.mark.asyncio
async def test_unsuppress_topic_for_project_passes_through():
    mock = AsyncMock(return_value=None)
    with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.unsuppress_topic_for_project", mock):
        from scribe.mcp.tools.rulebooks import unsuppress_topic_for_project
        out = await unsuppress_topic_for_project(project_id=3, topic_id=22)
    kwargs = mock.call_args.kwargs
    assert kwargs == {"project_id": 3, "topic_id": 22, "user_id": 7}
    assert out == {"project_id": 3, "topic_id": 22, "suppressed": False}


# ── Typed edges between rules (milestone 307) ───────────────────────────

@pytest.mark.asyncio
async def test_relate_rules_forwards_the_kind_and_the_why():
    """The edge exists so a shape stops being merged into one row. The `note`
    travels with it for the same reason a rule carries `why`: whoever later
    decides whether the edge still holds needs the reasoning."""
    relation = MagicMock()
    relation.to_dict.return_value = {"id": 9, "kind": "co_surfaces"}
    mock = AsyncMock(return_value=relation)
    with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.add_rule_relation", mock):
        from scribe.mcp.tools.rulebooks import relate_rules
        out = await relate_rules(
            from_rule_id=46, to_rule_id=144, kind="co_surfaces",
            note="a stale channel tag and an unparseable version both read as "
                 "no update available",
        )
    assert out["id"] == 9
    args = mock.call_args.args
    assert args[0] == 7 and args[1] == 46 and args[2] == 144
    assert args[3] == "co_surfaces"
    assert "no update available" in args[4]


@pytest.mark.asyncio
async def test_relate_rules_raises_when_either_end_is_not_yours():
    """The service returns None when it cannot see both rules — a one-sided
    edge would surface a rule the caller has no business reading."""
    with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.add_rule_relation",
               AsyncMock(return_value=None)):
        from scribe.mcp.tools.rulebooks import relate_rules
        with pytest.raises(ValueError, match="not found"):
            await relate_rules(from_rule_id=1, to_rule_id=2, kind="overrides")


@pytest.mark.asyncio
async def test_unrelate_rules_raises_when_the_edge_is_gone():
    with patch("scribe.mcp.tools.rulebooks.rulebooks_svc.remove_rule_relation",
               AsyncMock(return_value=False)):
        from scribe.mcp.tools.rulebooks import unrelate_rules
        with pytest.raises(ValueError, match="not found"):
            await unrelate_rules(relation_id=99)


# ── rule_history (milestone 323 step 3) ────────────────────────────────

def _fake_version(**over):
    """A RuleVersion-shaped stand-in. A real model instance rather than a
    MagicMock, because the tool calls `to_dict` and a mock would hand back
    another mock instead of failing."""
    from scribe.models.rule_version import RuleVersion
    from datetime import datetime, timezone

    defaults = {
        "id": 5, "rule_id": 100, "user_id": 1,
        "title": "The runner has no bash", "statement": "Use sh.",
        "why": "the image ships no bash", "how_to_apply": None,
        "when_to_apply": None, "tier": "always_on",
        "verify_with": "read the workflow's shell setting",
        "expires_when": None,
        "created_at": datetime(2026, 8, 29, tzinfo=timezone.utc),
    }
    return RuleVersion(**{**defaults, **over})


@pytest.mark.asyncio
async def test_rule_history_lists_without_the_heavy_text():
    """The listing answers "when, and by whom". A rule's statement runs to
    thousands of characters, so a history carrying every field would cost
    more to read than the answer is worth."""
    with patch(
        "scribe.mcp.tools.rulebooks.rulebooks_svc.list_rule_versions",
        AsyncMock(return_value=[_fake_version(), _fake_version(id=4)]),
    ), patch(
        "scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule",
        AsyncMock(return_value=fake_rule(
            id=100, title="The runner has no bash", statement="s", topic_id=10,
        )),
    ):
        from scribe.mcp.tools.rulebooks import rule_history
        out = await rule_history(rule_id=100)

    assert out["total"] == 2
    assert out["versions"][0]["title"] == "The runner has no bash"
    assert "statement" not in out["versions"][0]
    assert "why" not in out["versions"][0]


@pytest.mark.asyncio
async def test_rule_history_opens_one_version_in_full():
    """Passing a version id switches from the index to the text — which is
    the whole reason the listing can afford to omit it."""
    with patch(
        "scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule_version",
        AsyncMock(return_value=_fake_version()),
    ):
        from scribe.mcp.tools.rulebooks import rule_history
        out = await rule_history(rule_id=100, version_id=5)

    assert out["statement"] == "Use sh."
    assert out["verify_with"] == "read the workflow's shell setting"


@pytest.mark.asyncio
async def test_rule_history_says_an_empty_history_is_ordinary():
    """Most rules have never been reworded, and nothing was written before
    milestone 323. Without this line an empty list reads as a lost history or
    a broken tool."""
    with patch(
        "scribe.mcp.tools.rulebooks.rulebooks_svc.list_rule_versions",
        AsyncMock(return_value=[]),
    ), patch(
        "scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule",
        AsyncMock(return_value=fake_rule(
            id=100, title="The runner has no bash", statement="s", topic_id=10,
        )),
    ):
        from scribe.mcp.tools.rulebooks import rule_history
        out = await rule_history(rule_id=100)

    assert out["total"] == 0
    assert "never been reworded" in out["note"]


@pytest.mark.asyncio
async def test_rule_history_raises_when_the_rule_is_not_yours():
    """None from the service means "not readable", and the tool must not
    turn that into an empty history — which would read as "this rule has no
    past" rather than "this is not your rule"."""
    with patch(
        "scribe.mcp.tools.rulebooks.rulebooks_svc.list_rule_versions",
        AsyncMock(return_value=None),
    ):
        from scribe.mcp.tools.rulebooks import rule_history
        with pytest.raises(ValueError, match="rule 100 not found"):
            await rule_history(rule_id=100)


@pytest.mark.asyncio
async def test_rule_history_raises_for_a_version_on_another_rule():
    with patch(
        "scribe.mcp.tools.rulebooks.rulebooks_svc.get_rule_version",
        AsyncMock(return_value=None),
    ):
        from scribe.mcp.tools.rulebooks import rule_history
        with pytest.raises(ValueError, match="version 5 not found"):
            await rule_history(rule_id=100, version_id=5)


def test_rule_history_docstring_says_what_a_version_HOLDS():
    """The one thing a reader gets wrong unaided: an entry is the text the
    edit REPLACED, not the text it introduced. Read the other way, every
    diff comes out backwards — so the docstring has to say it, and this is
    the guard against a later tidy-up dropping the line."""
    from scribe.mcp.tools.rulebooks import rule_history

    doc = rule_history.__doc__ or ""
    assert "REPLACED" in doc
    assert "no restore" in doc.lower(), (
        "the docstring no longer explains that a rule version cannot be "
        "restored. A caller who assumes a revert exists will look for one "
        "and, not finding it, is likely to hand-copy the old text back with "
        "no record of why."
    )
