"""Rulebook / topic / rule service layer — single source of truth used by
both routes/rulebooks.py and mcp/tools/rulebooks.py.

Ownership enforcement: every function takes user_id and scopes through
rulebooks.owner_user_id. Functions return models or model.to_dict() output
depending on the caller's needs (mirroring services/events.py pattern).
"""
from __future__ import annotations

import logging
from typing import Optional

from sqlalchemy import select

from scribe.models import async_session
from scribe.models.rulebook import Rulebook

logger = logging.getLogger(__name__)


# ── Rulebook CRUD ────────────────────────────────────────────────────────

async def create_rulebook(
    user_id: int, title: str, description: str = "",
) -> Rulebook:
    """Create a new rulebook owned by user_id. Returns the persisted model."""
    async with async_session() as session:
        rb = Rulebook(
            owner_user_id=user_id, title=title, description=description or None,
        )
        session.add(rb)
        await session.commit()
        await session.refresh(rb)
        return rb


async def list_rulebooks(user_id: int) -> list[Rulebook]:
    """List rulebooks owned by user_id, ordered by title."""
    async with async_session() as session:
        result = await session.execute(
            select(Rulebook)
            .where(Rulebook.owner_user_id == user_id, Rulebook.deleted_at.is_(None))
            .order_by(Rulebook.title)
        )
        return list(result.scalars().all())


async def get_rulebook(rulebook_id: int, user_id: int) -> Optional[Rulebook]:
    """Get a rulebook by id, scoped to user_id. None if not owned or not found."""
    async with async_session() as session:
        result = await session.execute(
            select(Rulebook).where(
                Rulebook.id == rulebook_id,
                Rulebook.owner_user_id == user_id,
                Rulebook.deleted_at.is_(None),
            )
        )
        return result.scalar_one_or_none()


async def update_rulebook(
    rulebook_id: int, user_id: int, **fields,
) -> Optional[Rulebook]:
    """Partial update. Returns updated rulebook or None if not found."""
    async with async_session() as session:
        result = await session.execute(
            select(Rulebook).where(
                Rulebook.id == rulebook_id,
                Rulebook.owner_user_id == user_id,
            )
        )
        rb = result.scalar_one_or_none()
        if rb is None:
            return None
        allowed = {"title", "description", "always_on"}
        for key, value in fields.items():
            if key in allowed and value is not None:
                setattr(rb, key, value)
        await session.commit()
        await session.refresh(rb)
        return rb


async def delete_rulebook(rulebook_id: int, user_id: int) -> None:
    """Delete a rulebook. Cascade-deletes topics, rules, subscriptions."""
    async with async_session() as session:
        result = await session.execute(
            select(Rulebook).where(
                Rulebook.id == rulebook_id,
                Rulebook.owner_user_id == user_id,
            )
        )
        rb = result.scalar_one_or_none()
        if rb is None:
            return
        await session.delete(rb)
        await session.commit()


async def find_rulebook_by_title(
    user_id: int, title: str,
) -> Optional[Rulebook]:
    """Used by the port script for the dupe-guard. None if not found."""
    async with async_session() as session:
        result = await session.execute(
            select(Rulebook).where(
                Rulebook.owner_user_id == user_id,
                Rulebook.title == title,
                Rulebook.deleted_at.is_(None),
            )
        )
        return result.scalar_one_or_none()


# ── Topic CRUD ──────────────────────────────────────────────────────────

from scribe.models.rulebook import RulebookTopic


async def _assert_rulebook_owned(session, rulebook_id: int, user_id: int) -> None:
    """Raise ValueError if rulebook doesn't exist or isn't owned by user.
    Centralizes ownership check used by all topic/rule operations.
    """
    result = await session.execute(
        select(Rulebook).where(
            Rulebook.id == rulebook_id,
            Rulebook.owner_user_id == user_id,
            Rulebook.deleted_at.is_(None),
        )
    )
    if result.scalar_one_or_none() is None:
        raise ValueError(f"rulebook {rulebook_id} not found")


async def create_topic(
    rulebook_id: int, user_id: int, title: str,
    description: str = "", order_index: int = 0,
) -> RulebookTopic:
    async with async_session() as session:
        await _assert_rulebook_owned(session, rulebook_id, user_id)
        topic = RulebookTopic(
            rulebook_id=rulebook_id,
            title=title,
            description=description or None,
            order_index=order_index,
        )
        session.add(topic)
        await session.commit()
        await session.refresh(topic)
        return topic


async def list_topics(rulebook_id: int, user_id: int) -> list[RulebookTopic]:
    async with async_session() as session:
        await _assert_rulebook_owned(session, rulebook_id, user_id)
        result = await session.execute(
            select(RulebookTopic)
            .where(
                RulebookTopic.rulebook_id == rulebook_id,
                RulebookTopic.deleted_at.is_(None),
            )
            .order_by(RulebookTopic.order_index, RulebookTopic.title)
        )
        return list(result.scalars().all())


async def get_topic(topic_id: int, user_id: int) -> Optional[RulebookTopic]:
    """Get a topic, scoped via the rulebook owner."""
    async with async_session() as session:
        result = await session.execute(
            select(RulebookTopic)
            .join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
            .where(
                RulebookTopic.id == topic_id,
                Rulebook.owner_user_id == user_id,
                RulebookTopic.deleted_at.is_(None),
                Rulebook.deleted_at.is_(None),
            )
        )
        return result.scalar_one_or_none()


async def update_topic(
    topic_id: int, user_id: int, **fields,
) -> Optional[RulebookTopic]:
    async with async_session() as session:
        result = await session.execute(
            select(RulebookTopic)
            .join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
            .where(
                RulebookTopic.id == topic_id,
                Rulebook.owner_user_id == user_id,
            )
        )
        topic = result.scalar_one_or_none()
        if topic is None:
            return None
        allowed = {"title", "description", "order_index"}
        for key, value in fields.items():
            if key in allowed and value is not None:
                setattr(topic, key, value)
        await session.commit()
        await session.refresh(topic)
        return topic


async def delete_topic(topic_id: int, user_id: int) -> None:
    async with async_session() as session:
        result = await session.execute(
            select(RulebookTopic)
            .join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
            .where(
                RulebookTopic.id == topic_id,
                Rulebook.owner_user_id == user_id,
            )
        )
        topic = result.scalar_one_or_none()
        if topic is None:
            return
        await session.delete(topic)
        await session.commit()


# ── Rule CRUD ──────────────────────────────────────────────────────────

from scribe.models.rulebook import Rule


async def _assert_topic_owned(session, topic_id: int, user_id: int) -> None:
    """Raise ValueError if topic doesn't exist or isn't in user's rulebook."""
    result = await session.execute(
        select(RulebookTopic)
        .join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
        .where(
            RulebookTopic.id == topic_id,
            Rulebook.owner_user_id == user_id,
            RulebookTopic.deleted_at.is_(None),
            Rulebook.deleted_at.is_(None),
        )
    )
    if result.scalar_one_or_none() is None:
        raise ValueError(f"topic {topic_id} not found")


async def _assert_project_owned(session, project_id: int, user_id: int) -> None:
    """Raise ValueError if project doesn't exist or isn't owned by user."""
    from scribe.models.project import Project
    result = await session.execute(
        select(Project).where(
            Project.id == project_id,
            Project.user_id == user_id,
            Project.deleted_at.is_(None),
        )
    )
    if result.scalar_one_or_none() is None:
        raise ValueError(f"project {project_id} not found")


async def _assert_rulebook_rule_owned(session, rule_id: int, user_id: int) -> None:
    """Raise ValueError if rule isn't a rulebook rule the user owns.

    Project-scoped rules (Rule.project_id set, topic_id NULL) are NOT
    suppressible — they belong to the project; delete them instead. This
    helper deliberately excludes them.
    """
    from scribe.models.rulebook import Rule
    result = await session.execute(
        select(Rule)
        .join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
        .join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
        .where(
            Rule.id == rule_id,
            Rulebook.owner_user_id == user_id,
            Rule.deleted_at.is_(None),
            RulebookTopic.deleted_at.is_(None),
            Rulebook.deleted_at.is_(None),
        )
    )
    if result.scalar_one_or_none() is None:
        raise ValueError(f"rule {rule_id} not found or not a rulebook rule")


async def create_rule(
    topic_id: int, user_id: int, title: str, statement: str,
    why: str = "", how_to_apply: str = "", order_index: int = 0,
) -> Rule:
    async with async_session() as session:
        await _assert_topic_owned(session, topic_id, user_id)
        rule = Rule(
            topic_id=topic_id,
            title=title,
            statement=statement,
            why=why or None,
            how_to_apply=how_to_apply or None,
            order_index=order_index,
        )
        session.add(rule)
        await session.commit()
        await session.refresh(rule)
        return rule


async def create_project_rule(
    project_id: int, user_id: int, title: str, statement: str,
    why: str = "", how_to_apply: str = "", order_index: int = 0,
) -> Rule:
    """Create a rule scoped to a single project (no rulebook ceremony).

    Project-scoped rules apply only to the named project; they don't
    propagate via rulebook subscriptions. Topic_id is left NULL — the
    CHECK constraint enforces exactly-one of (topic_id, project_id).
    """
    async with async_session() as session:
        await _assert_project_owned(session, project_id, user_id)
        rule = Rule(
            project_id=project_id,
            title=title,
            statement=statement,
            why=why or None,
            how_to_apply=how_to_apply or None,
            order_index=order_index,
        )
        session.add(rule)
        await session.commit()
        await session.refresh(rule)
        return rule


async def list_rules(
    user_id: int,
    rulebook_id: int | None = None,
    topic_id: int | None = None,
    project_id: int | None = None,
) -> list[Rule]:
    """List rules filtered by any of the three IDs. All filters are ownership-scoped.

    When project_id is set, the result includes both rulebook rules reached via
    project_rulebook_subscriptions AND project-scoped rules (Rule.project_id).
    When rulebook_id or topic_id is set, project-scoped rules are excluded by
    construction (they have neither). With no filter, only rulebook rules are
    returned — adding all of a user's project-scoped rules unprompted would
    surprise existing callers.
    """
    from scribe.models.rulebook import project_rulebook_subscriptions

    async with async_session() as session:
        stmt = (
            select(Rule)
            .join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
            .join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
            .where(
                Rulebook.owner_user_id == user_id,
                Rule.deleted_at.is_(None),
                RulebookTopic.deleted_at.is_(None),
                Rulebook.deleted_at.is_(None),
            )
        )
        if topic_id:
            stmt = stmt.where(Rule.topic_id == topic_id)
        if rulebook_id:
            stmt = stmt.where(RulebookTopic.rulebook_id == rulebook_id)
        if project_id:
            stmt = (
                stmt.join(
                    project_rulebook_subscriptions,
                    project_rulebook_subscriptions.c.rulebook_id == Rulebook.id,
                )
                .where(project_rulebook_subscriptions.c.project_id == project_id)
            )
        stmt = stmt.order_by(
            Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title,
        )
        result = await session.execute(stmt)
        rulebook_rules = list(result.scalars().all())

        if not project_id:
            return rulebook_rules

        # Project-scoped rules (topic_id IS NULL, project_id matches).
        # Verifies ownership by joining Project on user_id.
        from scribe.models.project import Project
        proj_stmt = (
            select(Rule)
            .join(Project, Rule.project_id == Project.id)
            .where(
                Project.user_id == user_id,
                Rule.project_id == project_id,
                Rule.deleted_at.is_(None),
                Project.deleted_at.is_(None),
            )
            .order_by(Rule.order_index, Rule.title)
        )
        proj_result = await session.execute(proj_stmt)
        return rulebook_rules + list(proj_result.scalars().all())


def _excluded_rulebook_ids_q(project_id: int):
    """Subquery: the always-on rulebooks this project opted out of at
    inception (milestone 297) — used by every rule-resolution path so an
    exclusion is total, not just cosmetic."""
    from scribe.models.rulebook import project_rulebook_exclusions

    return select(project_rulebook_exclusions.c.rulebook_id).where(
        project_rulebook_exclusions.c.project_id == project_id
    )


async def excluded_always_on_rulebooks(user_id: int, project_id: int) -> list[dict]:
    """[{id, title}] of the always-on rulebooks excluded for ``project_id``
    (owner-scoped). Empty for an undecided or inherit-all project."""
    from scribe.models.rulebook import project_rulebook_exclusions

    if not project_id:
        return []
    async with async_session() as session:
        rows = (
            await session.execute(
                select(Rulebook.id, Rulebook.title)
                .join(project_rulebook_exclusions,
                      project_rulebook_exclusions.c.rulebook_id == Rulebook.id)
                .where(
                    project_rulebook_exclusions.c.project_id == project_id,
                    Rulebook.owner_user_id == user_id,
                    Rulebook.deleted_at.is_(None),
                )
                .order_by(Rulebook.title)
            )
        ).all()
    return [{"id": rid, "title": title} for rid, title in rows]


async def list_always_on_rules(
    user_id: int, limit: int = 100, project_id: int = 0,
) -> list[Rule]:
    """Return all rules from rulebooks flagged always_on for the user.

    Called by the MCP tool of the same name at session start to load the
    standing rules that apply regardless of which project (if any) is in
    scope. Ordering matches list_rules so results are stable across calls.

    ``project_id`` (milestone 297): inside a project that excluded specific
    always-on rulebooks at inception, those rulebooks' rules are NOT
    returned — the project decided not to inherit them. 0 = the user-wide
    set, which is what a session sees before a project is in scope.
    """
    async with async_session() as session:
        q = (
            select(Rule)
            .join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
            .join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
            .where(
                Rulebook.owner_user_id == user_id,
                Rulebook.always_on.is_(True),
                Rule.deleted_at.is_(None),
                RulebookTopic.deleted_at.is_(None),
                Rulebook.deleted_at.is_(None),
            )
        )
        if project_id:
            q = q.where(Rulebook.id.notin_(_excluded_rulebook_ids_q(project_id)))
        result = await session.execute(
            q.order_by(
                Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title,
            ).limit(limit)
        )
        return list(result.scalars().all())


async def _fetch_owned_rule(session, rule_id: int, user_id: int) -> Optional[Rule]:
    """Fetch a rule by id, scoped to user owning either its rulebook
    (via topic) or its project (via project_id). Honors soft-delete.
    Returns None when not found or not owned.
    """
    from scribe.models.project import Project

    # Path A — rulebook rule.
    rulebook_rule = (await session.execute(
        select(Rule)
        .join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
        .join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
        .where(
            Rule.id == rule_id,
            Rulebook.owner_user_id == user_id,
            Rule.deleted_at.is_(None),
            RulebookTopic.deleted_at.is_(None),
            Rulebook.deleted_at.is_(None),
        )
    )).scalar_one_or_none()
    if rulebook_rule is not None:
        return rulebook_rule

    # Path B — project-scoped rule.
    project_rule = (await session.execute(
        select(Rule)
        .join(Project, Rule.project_id == Project.id)
        .where(
            Rule.id == rule_id,
            Project.user_id == user_id,
            Rule.deleted_at.is_(None),
            Project.deleted_at.is_(None),
        )
    )).scalar_one_or_none()
    return project_rule


async def get_rule(rule_id: int, user_id: int) -> Optional[Rule]:
    async with async_session() as session:
        return await _fetch_owned_rule(session, rule_id, user_id)


async def update_rule(rule_id: int, user_id: int, **fields) -> Optional[Rule]:
    async with async_session() as session:
        rule = await _fetch_owned_rule(session, rule_id, user_id)
        if rule is None:
            return None
        allowed = {"title", "statement", "why", "how_to_apply", "order_index"}
        for key, value in fields.items():
            if key in allowed and value is not None:
                setattr(rule, key, value)
        await session.commit()
        await session.refresh(rule)
        return rule


async def delete_rule(rule_id: int, user_id: int) -> None:
    async with async_session() as session:
        rule = await _fetch_owned_rule(session, rule_id, user_id)
        if rule is None:
            return
        await session.delete(rule)
        await session.commit()


# ── Subscriptions + get_applicable_rules ───────────────────────────────

from sqlalchemy import insert, delete as sql_delete
from sqlalchemy.exc import IntegrityError


async def subscribe_project(
    project_id: int, rulebook_id: int, user_id: int,
) -> None:
    """Add a subscription. Idempotent — duplicates raise; we swallow."""
    from scribe.models.rulebook import project_rulebook_subscriptions

    async with async_session() as session:
        await _assert_project_owned(session, project_id, user_id)
        await _assert_rulebook_owned(session, rulebook_id, user_id)
        # ON CONFLICT DO NOTHING via try/except to keep dialect-agnostic.
        try:
            await session.execute(
                insert(project_rulebook_subscriptions).values(
                    project_id=project_id, rulebook_id=rulebook_id,
                )
            )
            await session.commit()
        except Exception:
            await session.rollback()  # PK collision = already subscribed; fine.


async def unsubscribe_project(
    project_id: int, rulebook_id: int, user_id: int,
) -> None:
    from scribe.models.rulebook import project_rulebook_subscriptions

    async with async_session() as session:
        await _assert_project_owned(session, project_id, user_id)
        await _assert_rulebook_owned(session, rulebook_id, user_id)
        await session.execute(
            sql_delete(project_rulebook_subscriptions).where(
                project_rulebook_subscriptions.c.project_id == project_id,
                project_rulebook_subscriptions.c.rulebook_id == rulebook_id,
            )
        )
        await session.commit()


# ── Suppressions — project-level mute of rulebook rules / topics ────────

async def suppress_rule_for_project(
    project_id: int, rule_id: int, user_id: int,
) -> None:
    """Mute one rulebook rule for one project. Idempotent."""
    from scribe.models.rulebook import project_rule_suppressions

    async with async_session() as session:
        await _assert_project_owned(session, project_id, user_id)
        await _assert_rulebook_rule_owned(session, rule_id, user_id)
        try:
            await session.execute(
                insert(project_rule_suppressions).values(
                    project_id=project_id, rule_id=rule_id,
                )
            )
            await session.commit()
        except Exception:
            await session.rollback()  # PK collision = already suppressed; fine.


async def unsuppress_rule_for_project(
    project_id: int, rule_id: int, user_id: int,
) -> None:
    """Unmute one rulebook rule for one project. Idempotent."""
    from scribe.models.rulebook import project_rule_suppressions

    async with async_session() as session:
        await _assert_project_owned(session, project_id, user_id)
        await session.execute(
            sql_delete(project_rule_suppressions).where(
                project_rule_suppressions.c.project_id == project_id,
                project_rule_suppressions.c.rule_id == rule_id,
            )
        )
        await session.commit()


async def exclude_always_on_rulebook_for_project(
    project_id: int, rulebook_id: int, user_id: int,
) -> None:
    """Opt one project out of a whole ALWAYS-ON rulebook (milestone 297).
    Owner-only on both sides; the rulebook must be always_on — a subscribed
    rulebook is left by unsubscribing, not excluding. Idempotent."""
    from scribe.models.rulebook import project_rulebook_exclusions

    async with async_session() as session:
        await _assert_project_owned(session, project_id, user_id)
        await _assert_rulebook_owned(session, rulebook_id, user_id)
        rb = await session.get(Rulebook, rulebook_id)
        if rb is None or not rb.always_on:
            raise ValueError(
                f"rulebook {rulebook_id} is not always-on — it binds only by "
                "subscription; unsubscribe_project_from_rulebook instead"
            )
        try:
            await session.execute(
                insert(project_rulebook_exclusions).values(
                    project_id=project_id, rulebook_id=rulebook_id,
                )
            )
            await session.commit()
        except IntegrityError:
            await session.rollback()  # already excluded — idempotent


async def include_always_on_rulebook_for_project(
    project_id: int, rulebook_id: int, user_id: int,
) -> None:
    """Undo exclude_always_on_rulebook_for_project. Idempotent."""
    from scribe.models.rulebook import project_rulebook_exclusions

    async with async_session() as session:
        await _assert_project_owned(session, project_id, user_id)
        await session.execute(
            sql_delete(project_rulebook_exclusions).where(
                project_rulebook_exclusions.c.project_id == project_id,
                project_rulebook_exclusions.c.rulebook_id == rulebook_id,
            )
        )
        await session.commit()


async def suppress_topic_for_project(
    project_id: int, topic_id: int, user_id: int,
) -> None:
    """Mute every rule under one topic for one project. Idempotent."""
    from scribe.models.rulebook import project_topic_suppressions

    async with async_session() as session:
        await _assert_project_owned(session, project_id, user_id)
        await _assert_topic_owned(session, topic_id, user_id)
        try:
            await session.execute(
                insert(project_topic_suppressions).values(
                    project_id=project_id, topic_id=topic_id,
                )
            )
            await session.commit()
        except Exception:
            await session.rollback()


async def unsuppress_topic_for_project(
    project_id: int, topic_id: int, user_id: int,
) -> None:
    """Unmute a topic for one project. Idempotent."""
    from scribe.models.rulebook import project_topic_suppressions

    async with async_session() as session:
        await _assert_project_owned(session, project_id, user_id)
        await session.execute(
            sql_delete(project_topic_suppressions).where(
                project_topic_suppressions.c.project_id == project_id,
                project_topic_suppressions.c.topic_id == topic_id,
            )
        )
        await session.commit()


async def get_applicable_rules(
    project_id: int, user_id: int, limit: int = 50,
) -> dict:
    """Return rules applicable to a project — both via rulebook subscriptions
    and project-scoped rules (Rule.project_id matches), with suppressed rules
    and suppressed topics filtered out.

    Shape:
        {
          "rules": [{id, title, statement,
                     topic_id, topic_title,
                     rulebook_id, rulebook_title}, ...],
          "project_rules": [{id, title, statement}, ...],
          "suppressed_rules": [{id, title,
                                topic_id, topic_title,
                                rulebook_id, rulebook_title}, ...],
          "suppressed_topics": [{id, title,
                                 rulebook_id, rulebook_title}, ...],
          "truncated": bool,
          "subscribed_rulebooks": [{id, title}, ...]
        }

    `rules` is the subscription-derived set with project-level suppressions
    applied. `project_rules` is the project-scoped set (never suppressed —
    delete instead). `suppressed_rules` / `suppressed_topics` carry the
    titles + rulebook context callers need to display what was filtered
    without round-tripping for names.
    """
    from scribe.models.rulebook import (
        project_rulebook_subscriptions,
        project_rule_suppressions,
        project_topic_suppressions,
    )

    async with async_session() as session:
        # Subscribed rulebooks for the project (ownership-scoped).
        sub_q = (
            select(Rulebook.id, Rulebook.title)
            .join(
                project_rulebook_subscriptions,
                project_rulebook_subscriptions.c.rulebook_id == Rulebook.id,
            )
            .where(
                project_rulebook_subscriptions.c.project_id == project_id,
                Rulebook.owner_user_id == user_id,
                Rulebook.deleted_at.is_(None),
            )
            .order_by(Rulebook.title)
        )
        sub_rows = (await session.execute(sub_q)).all()
        subscribed_rulebooks = [
            {"id": rb_id, "title": rb_title} for rb_id, rb_title in sub_rows
        ]

        # Suppressed rules — joined to topic + rulebook so callers can render
        # context without a follow-up lookup. Ownership-scoped via rulebook.
        suppressed_rules_q = (
            select(
                Rule.id, Rule.title,
                RulebookTopic.id.label("topic_id"),
                RulebookTopic.title.label("topic_title"),
                Rulebook.id.label("rulebook_id"),
                Rulebook.title.label("rulebook_title"),
            )
            .join(project_rule_suppressions, project_rule_suppressions.c.rule_id == Rule.id)
            .join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
            .join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
            .where(
                project_rule_suppressions.c.project_id == project_id,
                Rulebook.owner_user_id == user_id,
            )
            .order_by(Rulebook.title, RulebookTopic.title, Rule.title)
        )
        suppressed_rule_rows = (await session.execute(suppressed_rules_q)).all()
        suppressed_rules = [
            {"id": rid, "title": rt, "topic_id": ti, "topic_title": tt,
             "rulebook_id": rbi, "rulebook_title": rbt}
            for rid, rt, ti, tt, rbi, rbt in suppressed_rule_rows
        ]
        suppressed_rule_ids = [r["id"] for r in suppressed_rules]

        # Suppressed topics — joined to rulebook for context.
        suppressed_topics_q = (
            select(
                RulebookTopic.id, RulebookTopic.title,
                Rulebook.id.label("rulebook_id"),
                Rulebook.title.label("rulebook_title"),
            )
            .join(project_topic_suppressions, project_topic_suppressions.c.topic_id == RulebookTopic.id)
            .join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
            .where(
                project_topic_suppressions.c.project_id == project_id,
                Rulebook.owner_user_id == user_id,
            )
            .order_by(Rulebook.title, RulebookTopic.title)
        )
        suppressed_topic_rows = (await session.execute(suppressed_topics_q)).all()
        suppressed_topics = [
            {"id": tid, "title": tt, "rulebook_id": rbi, "rulebook_title": rbt}
            for tid, tt, rbi, rbt in suppressed_topic_rows
        ]
        suppressed_topic_ids = [t["id"] for t in suppressed_topics]

        # Applicable rules (limit + 1 so we can detect truncation). Filter
        # in SQL so truncation reflects the post-suppression count, not the
        # raw subscription count.
        rules_q = (
            select(
                Rule.id, Rule.title, Rule.statement,
                RulebookTopic.id.label("topic_id"),
                RulebookTopic.title.label("topic_title"),
                Rulebook.id.label("rulebook_id"),
                Rulebook.title.label("rulebook_title"),
            )
            .join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
            .join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
            .join(
                project_rulebook_subscriptions,
                project_rulebook_subscriptions.c.rulebook_id == Rulebook.id,
            )
            .where(
                project_rulebook_subscriptions.c.project_id == project_id,
                Rulebook.owner_user_id == user_id,
                Rule.deleted_at.is_(None),
                RulebookTopic.deleted_at.is_(None),
                Rulebook.deleted_at.is_(None),
                # An inception exclusion is total (milestone 297): a rulebook the
                # project opted out of contributes nothing, subscribed or not.
                Rulebook.id.notin_(_excluded_rulebook_ids_q(project_id)),
            )
            .order_by(
                Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title,
            )
            .limit(limit + 1)
        )
        if suppressed_rule_ids:
            rules_q = rules_q.where(Rule.id.notin_(suppressed_rule_ids))
        if suppressed_topic_ids:
            rules_q = rules_q.where(Rule.topic_id.notin_(suppressed_topic_ids))
        rule_rows = (await session.execute(rules_q)).all()
        truncated = len(rule_rows) > limit
        rules = [
            {
                "id": rid, "title": rtitle, "statement": stmt,
                "topic_id": ti, "topic_title": tt,
                "rulebook_id": rbi, "rulebook_title": rbt,
            }
            for rid, rtitle, stmt, ti, tt, rbi, rbt in rule_rows[:limit]
        ]

        # Project-scoped rules — verifies ownership via Project.user_id.
        from scribe.models.project import Project
        proj_rules_q = (
            select(Rule.id, Rule.title, Rule.statement)
            .join(Project, Rule.project_id == Project.id)
            .where(
                Project.user_id == user_id,
                Rule.project_id == project_id,
                Rule.deleted_at.is_(None),
                Project.deleted_at.is_(None),
            )
            .order_by(Rule.order_index, Rule.title)
        )
        proj_rule_rows = (await session.execute(proj_rules_q)).all()
        project_rules = [
            {"id": rid, "title": rtitle, "statement": stmt}
            for rid, rtitle, stmt in proj_rule_rows
        ]

    return {
        "rules": rules,
        "project_rules": project_rules,
        "suppressed_rules": suppressed_rules,
        "suppressed_topics": suppressed_topics,
        "truncated": truncated,
        "subscribed_rulebooks": subscribed_rulebooks,
        "excluded_always_on": await excluded_always_on_rulebooks(user_id, project_id),
    }


def rules_payload(applicable: dict) -> dict:
    """The caller-facing shape of a get_applicable_rules() result.

    Every surface that hands rules to an agent (enter_project, get_project,
    get_milestone, get_task for legacy plans, start_planning) carries the
    same seven keys under the same names — so a reader learns them once. One
    place renames `rules` → `applicable_rules` and `truncated` →
    `applicable_rules_truncated`; the tools merge this into their payloads.
    `excluded_always_on` (milestone 297) names the always-on rulebooks this
    project decided NOT to inherit, so the departure is visible wherever the
    rules are.
    """
    return {
        "applicable_rules": applicable["rules"],
        "applicable_rules_truncated": applicable["truncated"],
        "subscribed_rulebooks": applicable["subscribed_rulebooks"],
        "project_rules": applicable.get("project_rules", []),
        "suppressed_rules": applicable.get("suppressed_rules", []),
        "suppressed_topics": applicable.get("suppressed_topics", []),
        "excluded_always_on": applicable.get("excluded_always_on", []),
    }
