"""Notifications on the Interaction Standard (R2 task T4, and T10's guarantee).

Each rule here was earned by the review app this standard corrected: mentions parsed from the
STORED body so a crafted payload cannot page anyone; never the author of their own action;
every emit deduped so a double-click cannot ring twice; resolved propagating so resolved work
stops nagging; a decision as news, never "needs you". DFP's own rule on top: an `@` of someone
who cannot see the subject is dropped silently, so a thread on the running list (`plan.view`,
the owner's) never produces a row for a client, and `@claude` there notifies nobody at all (a
session reads it); anywhere else `@claude` is dropped. In-process on the accounts suite's SQLite.
"""

import pytest
from sqlalchemy import select

from app.models import Account, AccountLevel, DiscussionItem, Notification
from app.services import accounts
from app.services import discussion as disc
from tests import _accounts as T
from tests.kit import _env


@pytest.fixture
def world(monkeypatch):
    T.fresh(monkeypatch)
    T.person("rian", display_name="Rian")
    with _env.TestSessionLocal() as db:
        db.add(AccountLevel(name="admin", permissions=list(accounts.SEED_LEVELS["admin"]["permissions"]), assignable=[]))
        db.commit()
    T.person("adam", level="admin", display_name="Adam")
    T.person("mark", level="admin", display_name="Mark")
    monkeypatch.setattr(disc.settings, "public_base_url", "https://dfp.test")
    yield


def rows(recipient: str | None = None) -> list[Notification]:
    with _env.TestSessionLocal() as db:
        q = select(Notification).order_by(Notification.id)
        if recipient:
            uid = db.scalar(select(Account.id).where(Account.username == recipient))
            q = q.where(Notification.recipient_id == uid)
        return list(db.scalars(q))


def post(c, subject_type: str, subject_id: str, body: str, label: str | None = None):
    r = c.post("/api/discussion/comments", json={"subject_type": subject_type, "subject_id": subject_id, "body": body, "label": label})
    assert r.status_code == 201, r.text
    return r.json()


class TestMentionsAndReplies:
    def test_a_mention_reaches_the_named_person_and_a_reply_reaches_the_rest(self, world):
        c = T.client()
        T.as_user(c, "adam")
        first = post(c, "structure", "urls", "what do you think @mark?", label="Structure › URL patterns")
        # mark: a mention; rian (a curator by absence): a reply; adam: nothing, it was his action.
        assert [(n.kind, n.recipient_id) for n in rows()] == [("mention", 3), ("reply", 1)]
        m = rows("mark")[0]
        assert m.context_label == "Structure › URL patterns" and m.category == "structure"
        assert m.url == f"https://dfp.test/discuss?tab=structure#t-structure-urls#c-{first['id']}"
        assert m.body == "what do you think @mark?" and m.dedupe_key == f"mention:{first['id']}:mark"
        assert rows("adam") == []
        # mark answers: adam (a participant) and rian (a curator) hear a reply; mark hears nothing.
        T.as_user(c, "mark")
        second = post(c, "structure", "urls", "I think yes")
        assert sorted((n.kind, n.recipient_id) for n in rows() if n.source_id == str(second["id"])) == [("reply", 1), ("reply", 2)]

    def test_an_unknown_name_and_a_self_mention_are_dropped(self, world):
        c = T.client()
        T.as_user(c, "adam")
        post(c, "quote", "guides", "@nobody @adam hello")
        assert [n.kind for n in rows()] == ["reply"]  # only the curator's reply row

    def test_the_inbox_is_deduped_read_is_personal_and_needs_you_is_a_mention(self, world):
        c = T.client()
        T.as_user(c, "adam")
        cid = post(c, "todo", "9", "over to you @mark")["id"]
        with _env.TestSessionLocal() as db:
            t = disc.thread_of(db, "todo", "9")
            comment = db.get(disc.DiscussionComment, cid)
            people = disc.people_for(db, "todo")
            disc._notify_comment(db, comment, t, people)  # a replay: nothing doubles
            db.commit()
        assert len(rows("mark")) == 1
        T.as_user(c, "mark")
        box = c.get("/api/notifications").json()
        assert box["unread"] == 1 and box["needs_you"] is True
        assert box["notifications"][0]["actor"] == "Adam" and box["notifications"][0]["kind"] == "mention"
        assert c.post("/api/notifications/read", json={"ids": [box["notifications"][0]["id"]]}).json()["read"] == 1
        box = c.get("/api/notifications").json()
        assert box["unread"] == 0 and box["needs_you"] is False and box["notifications"][0]["read"] is True
        T.as_user(c, "rian")
        assert c.get("/api/notifications").json()["unread"] == 1  # rian's reply row is his own

    def test_an_edit_rings_only_a_newly_named_person(self, world):
        c = T.client()
        T.as_user(c, "adam")
        cid = post(c, "structure", "map", "first draft")["id"]
        before = len(rows())
        assert c.post(f"/api/discussion/comments/{cid}/edit", json={"body": "first draft, and @mark please look"}).status_code == 200
        after = rows()
        assert len(after) == before + 1 and after[-1].kind == "mention" and after[-1].recipient_id == 3


class TestResolvedAndDecision:
    def test_resolving_flags_the_threads_rows_and_tells_the_people_in_it(self, world):
        c = T.client()
        T.as_user(c, "adam")
        post(c, "structure", "schema", "a question for @mark")
        tid = c.get("/api/discussion/threads").json()[0]["id"]
        r = c.post(f"/api/discussion/threads/{tid}/resolve", json={"resolved": True})
        assert r.status_code == 200
        flagged = [n for n in rows() if n.source_type == "comment"]
        assert flagged and all(n.resolved for n in flagged)
        # The people IN the thread hear it: rian (a curator), never adam (the actor). mark was only
        # named, so his mention is flagged (it leaves his count) and no event follows it.
        told = [n for n in rows() if n.kind == "resolved"]
        assert [n.recipient_id for n in told] == [1]
        T.as_user(c, "mark")
        box = c.get("/api/notifications").json()
        assert box["unread"] == 0 and box["needs_you"] is False
        assert box["notifications"][0]["resolved"] is True  # still there behind "Show resolved"
        T.as_user(c, "rian")
        assert [n["kind"] for n in c.get("/api/notifications").json()["notifications"] if not n["read"] and not n["resolved"]] == ["resolved"]
        # Reopen: the mention is back in the count.
        T.as_user(c, "adam")
        c.post(f"/api/discussion/threads/{tid}/resolve", json={"resolved": False})
        T.as_user(c, "mark")
        assert c.get("/api/notifications").json()["needs_you"] is True

    def test_resolving_a_decision_card_is_a_decision_event(self, world):
        c = T.client()
        with _env.TestSessionLocal() as db:
            db.add(DiscussionItem(id=5, title="Awards rules"))
            db.commit()
        T.as_user(c, "adam")
        assert c.post("/api/discussion/5/comments", json={"body": "my view"}).status_code == 201
        T.as_user(c, "rian")
        assert c.post("/api/discussion/5/resolved", params={"resolved": "true"}).status_code == 200
        decided = [n for n in rows() if n.kind == "decision"]
        assert [n.recipient_id for n in decided] == [2] and decided[0].context_label == "Awards rules"
        assert decided[0].url == "https://dfp.test/discuss#t-decision-5"
        # A second resolve of an already resolved card emits nothing.
        c.post("/api/discussion/5/resolved", params={"resolved": "true"})
        assert len([n for n in rows() if n.kind == "decision"]) == 1


class TestTheOwnersSubjects:
    """T10's guarantee, pinned here: a plan thread never reaches a client."""

    def test_a_plan_thread_mentioning_a_client_produces_no_row_for_them(self, world):
        c = T.client()
        T.as_user(c, "rian")
        post(c, "item", "decide-x", "@adam @mark what about this, and @claude note it")
        assert rows() == []  # adam and mark cannot see the subject; claude is not an account

    def test_claude_on_a_client_subject_is_dropped_silently(self, world):
        c = T.client()
        T.as_user(c, "adam")
        post(c, "quote", "guides", "@claude please fix this")
        assert [n.kind for n in rows()] == ["reply"]  # the curator's reply, nothing for a handle
        with _env.TestSessionLocal() as db:
            assert "claude" not in disc.mention_directory(db, "quote")
            assert "claude" in disc.mention_directory(db, "item")

    def test_the_claude_inbox_lists_the_owners_subjects_only_and_honours_since(self, world):
        c = T.client()
        T.as_user(c, "rian")
        post(c, "item", "decide-x", "@claude please split this task", label="Running list › A question")
        post(c, "item", "issue-y", "no handle here")
        T.as_user(c, "adam")
        post(c, "quote", "guides", "@claude do this")  # a client subject: dropped, never in the inbox
        with _env.TestSessionLocal() as db:
            rows = disc.claude_inbox(db)
            assert [(r["subject_type"], r["subject_id"], r["author"]) for r in rows] == [("item", "decide-x", "Rian")]
            assert rows[0]["label"] == "Running list › A question" and rows[0]["url"].startswith("https://dfp.test/issues#t-item-decide-x#c-")
            from datetime import UTC, datetime, timedelta
            assert disc.claude_inbox(db, since=datetime.now(UTC) + timedelta(days=1)) == []
            assert len(disc.claude_inbox(db, since=datetime.now(UTC) - timedelta(days=1))) == 1

    def test_recording_a_running_list_decision_notifies_no_client(self, world, monkeypatch):
        from app.services import items as svc
        monkeypatch.setattr(svc, "load_items", lambda: [{"id": "decide-x", "kind": "decide", "title": "A question"}])
        c = T.client()
        T.as_user(c, "rian")
        r = c.post("/api/items/decide-x/decide", json={"text": "yes"})
        assert r.status_code == 200, r.text
        assert rows() == []  # the subject is the owner's and rian is the actor
