"""The side panel's read (Stream R2b, T12): what a thread is to the viewer, and the read state.

What the rules prevent, each found on the first surface that lacked them: a viewer's own
words counted as unread to them (a list that never emptied); a thread opened in the panel
still ringing on the bell because the notification rows and the read rows disagreed (so the
read write marks both, and needs-you is computed from the notification rows the bell counts,
never stored twice); a running-list thread readable through a side route a client could reach
(404, like every other route on the owner's subjects); a `since` typo returning everything.
In-process on the accounts suite's SQLite. The SQLite timestamps are whole seconds, so a test
that needs "later" moves a row back in time instead of sleeping.
"""

from datetime import UTC, datetime, timedelta

import pytest
from sqlalchemy import select, update

from app.models import AccountLevel, DiscussionComment, Notification
from app.models.discussion import ThreadRead
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 post(c, subject_type: str, subject_id: str, body: str, label: str | None = None) -> dict:
    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()


def threads(c) -> dict[str, dict]:
    return {f"{t['subject_type']}:{t['subject_id']}": t for t in c.get("/api/discussion/threads").json()}


def back_in_time(model, column, **where) -> None:
    """Move a row's timestamp an hour back, so a later write is later by more than a second."""
    with _env.TestSessionLocal() as db:
        q = update(model).values({column: datetime.now(UTC) - timedelta(hours=1)})
        for k, v in where.items():
            q = q.where(getattr(model, k) == v)
        db.execute(q)
        db.commit()


class TestReadState:
    def test_unread_is_someone_elses_comment_after_the_viewers_last_read(self, world):
        c = T.client()
        T.as_user(c, "adam")
        post(c, "structure", "urls", "first word", label="Structure › URL patterns")
        mine = threads(c)["structure:urls"]
        # The author's own words are never unread to them, and the two dates are the one comment.
        assert mine["unread"] is False and mine["needs_you"] is False and mine["reply_count"] == 0
        assert mine["read_at"] is None and mine["followup"] is None
        assert mine["started_at"] == mine["last_activity_at"] and mine["started_at"].endswith("+00:00")

        T.as_user(c, "mark")
        theirs = threads(c)["structure:urls"]
        assert theirs["unread"] is True and theirs["read_at"] is None  # never opened
        r = c.post(f"/api/discussion/threads/{theirs['id']}/read")
        assert r.status_code == 200 and r.json()["thread_id"] == theirs["id"] and r.json()["notifications_read"] == 0
        after = threads(c)["structure:urls"]
        assert after["unread"] is False and after["read_at"] is not None
        with _env.TestSessionLocal() as db:
            assert len(list(db.scalars(select(ThreadRead)))) == 1  # one row per thread and account, moved on open
        c.post(f"/api/discussion/threads/{theirs['id']}/read")
        with _env.TestSessionLocal() as db:
            assert len(list(db.scalars(select(ThreadRead)))) == 1

        # Adam writes again after Mark's read: unread again, with the reply counted and the
        # activity date moved; Mark's own reply changes nothing about that.
        back_in_time(ThreadRead, "read_at", thread_id=theirs["id"])
        T.as_user(c, "adam")
        post(c, "structure", "urls", "second word")
        T.as_user(c, "mark")
        again = threads(c)["structure:urls"]
        assert again["unread"] is True and again["reply_count"] == 1
        assert again["last_activity_at"] >= again["started_at"]
        post(c, "structure", "urls", "my reply")
        assert threads(c)["structure:urls"]["unread"] is True
        c.post(f"/api/discussion/threads/{theirs['id']}/read")
        assert threads(c)["structure:urls"]["unread"] is False

    def test_reading_a_thread_marks_its_notifications_read_so_the_bell_agrees(self, world):
        c = T.client()
        T.as_user(c, "adam")
        post(c, "todo", "9", "over to you @mark", label="To-do › Airport write-ups")
        post(c, "quote", "guides", "and @mark here too")
        T.as_user(c, "mark")
        box = c.get("/api/notifications").json()
        assert box["unread"] == 2 and box["needs_you"] is True
        mine = threads(c)
        assert mine["todo:9"]["needs_you"] is True and mine["quote:guides"]["needs_you"] is True
        r = c.post(f"/api/discussion/threads/{mine['todo:9']['id']}/read")
        assert r.json()["notifications_read"] == 1
        box = c.get("/api/notifications").json()
        assert box["unread"] == 1 and box["needs_you"] is True  # the quote mention is still waiting
        mine = threads(c)
        assert mine["todo:9"]["needs_you"] is False and mine["todo:9"]["unread"] is False
        assert mine["quote:guides"]["needs_you"] is True
        # Rian's reply rows (a curator) are his own: untouched by Mark's read.
        T.as_user(c, "rian")
        assert c.get("/api/notifications").json()["unread"] == 2

    def test_needs_you_leaves_with_the_threads_resolution_like_the_bell(self, world):
        c = T.client()
        T.as_user(c, "adam")
        post(c, "structure", "schema", "a question for @mark")
        tid = threads(c)["structure:schema"]["id"]
        T.as_user(c, "mark")
        assert threads(c)["structure:schema"]["needs_you"] is True
        T.as_user(c, "adam")
        assert c.post(f"/api/discussion/threads/{tid}/resolve", json={"resolved": True}).status_code == 200
        T.as_user(c, "mark")
        t = threads(c)["structure:schema"]
        assert t["resolved"] is True and t["needs_you"] is False
        assert c.get("/api/notifications").json()["needs_you"] is False

    def test_the_running_lists_threads_are_hidden_from_the_read_route_too(self, world):
        c = T.client()
        T.as_user(c, "rian")
        tid = post(c, "item", "decide-x", "the plan")["thread_id"]
        T.as_user(c, "adam")
        assert c.post(f"/api/discussion/threads/{tid}/read").status_code == 404
        assert c.post("/api/discussion/threads/999/read").status_code == 404
        T.as_user(c, "rian")
        assert c.post(f"/api/discussion/threads/{tid}/read").status_code == 200

    def test_since_keeps_the_threads_that_moved_after_it(self, world):
        c = T.client()
        T.as_user(c, "adam")
        old = post(c, "structure", "urls", "an old thread")
        post(c, "quote", "guides", "a fresh one")
        back_in_time(DiscussionComment, "created_at", id=old["id"])
        cutoff = (datetime.now(UTC) - timedelta(minutes=30)).isoformat()
        assert list(threads(c)) == ["structure:urls", "quote:guides"]
        fresh = c.get("/api/discussion/threads", params={"since": cutoff}).json()
        assert [t["subject_id"] for t in fresh] == ["guides"]
        # An edit is activity too.
        assert c.post(f"/api/discussion/comments/{old['id']}/edit", json={"body": "an old thread, reworded"}).status_code == 200
        assert len(c.get("/api/discussion/threads", params={"since": cutoff}).json()) == 2
        r = c.get("/api/discussion/threads", params={"since": "yesterday"})
        assert r.status_code == 422 and r.json()["detail"]["error_code"] == "BAD_SINCE"

    def test_a_read_only_view_as_cannot_move_the_stamp(self, world):
        from tests.conftest import as_session
        c = T.client()
        T.as_user(c, "adam")
        tid = post(c, "structure", "urls", "hello")["thread_id"]
        as_session(c, user="rian", bw_acting_as="mark", bw_acting_mode="readonly")
        r = c.post(f"/api/discussion/threads/{tid}/read")
        assert r.status_code == 403 and r.json()["detail"]["error_code"] == "VIEW_AS_READ_ONLY"
        with _env.TestSessionLocal() as db:
            assert db.scalar(select(ThreadRead)) is None
            assert db.scalar(select(Notification).where(Notification.read.is_(True))) is None


class TestTopics:
    """A thread that belongs to no section (T15): a title and a first comment from the panel."""

    def test_a_topic_is_a_thread_with_a_slug_a_label_and_a_page(self, world):
        c = T.client()
        T.as_user(c, "adam")
        r = c.post("/api/discussion/topics", json={"title": "  Cruise ports as duty free   locations ", "body": "a growing channel @mark"})
        assert r.status_code == 201, r.text
        t = r.json()
        assert (t["subject_type"], t["subject_id"], t["label"]) == ("topic", "cruise-ports-as-duty-free-locations", "Topic › Cruise ports as duty free locations")
        assert t["anchor"] == "t-topic-cruise-ports-as-duty-free-locations" and t["comments"][0]["mine"] is True
        assert disc.deep_link("topic", t["subject_id"], t["comments"][0]["id"]) == \
            f"https://dfp.test/discuss?tab=review#t-topic-cruise-ports-as-duty-free-locations#c-{t['comments'][0]['id']}"
        # The first comment is a comment like any other: its legacy key, its mention, its thread.
        assert disc.subject_of_legacy(None, "topic:cruise-ports-as-duty-free-locations") == ("topic", "cruise-ports-as-duty-free-locations")
        assert [n.kind for n in _rows("mark")] == ["mention"]
        # Listed with every other thread, and a reply lands on it through the one comment route.
        T.as_user(c, "mark")
        assert threads(c)["topic:cruise-ports-as-duty-free-locations"]["needs_you"] is True
        post(c, "topic", "cruise-ports-as-duty-free-locations", "noted")
        assert threads(c)["topic:cruise-ports-as-duty-free-locations"]["reply_count"] == 1

    def test_the_same_title_twice_is_a_second_topic_never_the_first_ones_conversation(self, world):
        c = T.client()
        T.as_user(c, "adam")
        first = c.post("/api/discussion/topics", json={"title": "Indexing", "body": "one"}).json()
        second = c.post("/api/discussion/topics", json={"title": "indexing!", "body": "two"}).json()
        third = c.post("/api/discussion/topics", json={"title": "Indexing", "body": "three"}).json()
        assert [first["subject_id"], second["subject_id"], third["subject_id"]] == ["indexing", "indexing-2", "indexing-3"]
        assert first["id"] != second["id"] != third["id"]

    def test_a_title_without_a_word_in_it_is_refused_and_a_viewer_cannot_start_one(self, world):
        c = T.client()
        T.as_user(c, "adam")
        r = c.post("/api/discussion/topics", json={"title": "---", "body": "x"})
        assert r.status_code == 422 and r.json()["detail"]["error_code"] == "TITLE_REQUIRED"
        assert c.post("/api/discussion/topics", json={"title": "", "body": "x"}).status_code == 422
        assert disc.topic_slug("Adam's drafts: airport pages") == "adam-s-drafts-airport-pages"
        assert len(disc.topic_slug("x" * 200)) == 60
        # client.view without client.participate reads topics and starts none.
        T.person("viewer", level="viewer", display_name="Viewer")
        with _env.TestSessionLocal() as db:
            level = db.get(AccountLevel, "viewer")
            level.permissions = [accounts.PERM_CLIENT_VIEW]
            db.commit()
        T.as_user(c, "viewer")
        assert c.post("/api/discussion/topics", json={"title": "Mine", "body": "x"}).status_code == 403
        assert c.get("/api/discussion/threads").status_code == 200


def _rows(recipient: str) -> list[Notification]:
    from app.models import Account
    with _env.TestSessionLocal() as db:
        uid = db.scalar(select(Account.id).where(Account.username == recipient))
        return list(db.scalars(select(Notification).where(Notification.recipient_id == uid).order_by(Notification.id)))


class TestPeople:
    """`@` offers names (T16): the directory the composer shows is the one the write path rings."""

    def test_the_directory_is_the_subjects_permission_holders_and_claude_on_the_owners(self, world):
        c = T.client()
        T.as_user(c, "adam")
        r = c.get("/api/discussion/people", params={"subject_type": "structure"})
        assert r.status_code == 200
        assert r.json() == [{"handle": "adam", "display_name": "Adam"}, {"handle": "mark", "display_name": "Mark"},
                            {"handle": "rian", "display_name": "Rian"}]
        assert c.get("/api/discussion/people", params={"subject_type": "topic"}).json() == r.json()
        # The owner's subjects: the plan.view holders plus the claude handle; a client is told nothing.
        assert c.get("/api/discussion/people", params={"subject_type": "item"}).status_code == 404
        T.as_user(c, "rian")
        assert [p["handle"] for p in c.get("/api/discussion/people", params={"subject_type": "item"}).json()] == ["claude", "rian"]
        assert c.get("/api/discussion/people", params={"subject_type": "stage"}).status_code == 422
        assert c.get("/api/discussion/people").status_code == 422

    def test_a_disabled_account_is_not_offered_and_would_not_ring(self, world):
        T.person("olive", level="admin", display_name="Olive", status="disabled")
        c = T.client()
        T.as_user(c, "adam")
        handles = [p["handle"] for p in c.get("/api/discussion/people", params={"subject_type": "quote"}).json()]
        assert "olive" not in handles
        post(c, "quote", "guides", "hello @olive")
        assert _rows("olive") == []


class TestClosingWord:
    """Resolve and reopen with a closing comment (T17), one write, recorded against the account."""

    def test_the_closing_word_is_the_last_comment_and_the_event_carries_it(self, world):
        c = T.client()
        T.as_user(c, "adam")
        post(c, "structure", "q-single", "one URL per product? @mark")
        tid = threads(c)["structure:q-single"]["id"]
        T.as_user(c, "mark")
        post(c, "structure", "q-single", "yes")
        T.as_user(c, "rian")
        r = c.post(f"/api/discussion/threads/{tid}/resolve", json={"resolved": True, "comment": "We all agree: one URL. Closing."})
        assert r.status_code == 200, r.text
        t = r.json()
        assert t["resolved"] is True and t["resolved_by_id"] == 1
        assert t["comments"][-1]["body"] == "We all agree: one URL. Closing." and t["comments"][-1]["author"] == "Rian"
        assert t["comments"][-1]["mine"] is True and t["reply_count"] == 2
        # The people in it hear it once, as a resolved event carrying the words; the closing
        # word's own reply rows are flagged resolved at birth, so nobody is rung twice.
        for who in ("adam", "mark"):
            rows_ = _rows(who)
            told = [n for n in rows_ if n.kind == "resolved"]
            assert len(told) == 1 and told[0].body == "We all agree: one URL. Closing."
            assert all(n.resolved for n in rows_ if n.source_type == "comment")
        T.as_user(c, "adam")
        box = c.get("/api/notifications").json()
        assert [n["kind"] for n in box["notifications"] if not n["read"] and not n["resolved"]] == ["resolved"]

    def test_reopen_with_a_word_and_resolve_without_one(self, world):
        c = T.client()
        T.as_user(c, "adam")
        post(c, "quote", "guides", "opened")
        tid = threads(c)["quote:guides"]["id"]
        assert c.post(f"/api/discussion/threads/{tid}/resolve", json={"resolved": True, "comment": "   "}).json()["reply_count"] == 0
        r = c.post(f"/api/discussion/threads/{tid}/resolve", json={"resolved": False, "comment": "Not so fast."})
        assert r.status_code == 200 and r.json()["resolved"] is False
        assert r.json()["comments"][-1]["body"] == "Not so fast."
        # The reopening word reaches the curator as an ordinary reply, unflagged.
        rian = [n for n in _rows("rian") if n.source_type == "comment"]
        assert rian and rian[-1].kind == "reply" and rian[-1].resolved is False
        # A second resolve of a resolved thread with a word still keeps the word, states nothing
        # twice: two real resolutions so far (before and after the reopen), no third event.
        c.post(f"/api/discussion/threads/{tid}/resolve", json={"resolved": True})
        again = c.post(f"/api/discussion/threads/{tid}/resolve", json={"resolved": True, "comment": "and again"})
        assert again.json()["resolved"] is True and again.json()["comments"][-1]["body"] == "and again"
        assert len([n for n in _rows("rian") if n.kind == "resolved"]) == 2

    def test_a_refused_resolver_writes_no_word(self, world):
        c = T.client()
        T.as_user(c, "adam")
        post(c, "quote", "guides", "adam's thread")
        tid = threads(c)["quote:guides"]["id"]
        T.as_user(c, "mark")
        r = c.post(f"/api/discussion/threads/{tid}/resolve", json={"resolved": True, "comment": "mine to close?"})
        assert r.status_code == 403
        assert threads(c)["quote:guides"]["reply_count"] == 0


class TestFollowup:
    """The needs-follow-up mark (T13): a curator's one-line note on the thread, who and when,
    listed under its own tab, cleared with a note; not a task system."""

    def test_a_curator_marks_with_a_note_and_the_list_shows_who_and_when(self, world):
        c = T.client()
        T.as_user(c, "adam")
        post(c, "structure", "urls", "the URL question")
        tid = threads(c)["structure:urls"]["id"]
        # A client, even one who may participate, cannot mark; the owner (a curator by absence) can.
        assert c.post(f"/api/discussion/threads/{tid}/followup", json={"note": "check with Mark"}).status_code == 403
        T.as_user(c, "rian")
        r = c.post(f"/api/discussion/threads/{tid}/followup", json={"note": "  check with   Mark  "})
        assert r.status_code == 200, r.text
        assert r.json()["followup"] == {"note": "check with Mark", "by": "Rian", "by_id": 1, "at": r.json()["followup"]["at"]}
        assert r.json()["followup"]["at"].endswith("+00:00")
        assert c.post(f"/api/discussion/threads/{tid}/followup", json={"note": "   "}).status_code == 422
        # A second mark replaces the first; everyone who may see the thread sees the mark.
        c.post(f"/api/discussion/threads/{tid}/followup", json={"note": "ask Adam instead"})
        T.as_user(c, "adam")
        assert threads(c)["structure:urls"]["followup"]["note"] == "ask Adam instead"

    def test_clearing_with_a_note_posts_it_and_without_one_posts_nothing(self, world):
        c = T.client()
        T.as_user(c, "adam")
        post(c, "quote", "guides", "opened")
        tid = threads(c)["quote:guides"]["id"]
        T.as_user(c, "rian")
        c.post(f"/api/discussion/threads/{tid}/followup", json={"note": "send the sizes"})
        r = c.post(f"/api/discussion/threads/{tid}/followup/clear", json={"note": "Sizes sent on Monday."})
        assert r.status_code == 200 and r.json()["followup"] is None
        assert r.json()["comments"][-1]["body"] == "Sizes sent on Monday." and r.json()["comments"][-1]["author"] == "Rian"
        assert [n.kind for n in _rows("adam")][-1] == "reply"  # Adam hears the follow-up landed
        c.post(f"/api/discussion/threads/{tid}/followup", json={"note": "once more"})
        r = c.post(f"/api/discussion/threads/{tid}/followup/clear", json={})
        assert r.json()["followup"] is None and r.json()["reply_count"] == 1
        T.as_user(c, "adam")
        assert c.post(f"/api/discussion/threads/{tid}/followup/clear", json={}).status_code == 403

    def test_the_owners_threads_stay_hidden_and_the_mark_survives_a_resolve(self, world):
        c = T.client()
        T.as_user(c, "rian")
        tid = post(c, "item", "decide-x", "the plan")["thread_id"]
        c.post(f"/api/discussion/threads/{tid}/followup", json={"note": "answer by Friday"})
        c.post(f"/api/discussion/threads/{tid}/resolve", json={"resolved": True})
        assert threads(c)["item:decide-x"]["followup"]["note"] == "answer by Friday"
        # A client is refused by the policy before the handler runs (403, the curator-only class),
        # which tells them nothing about the thread either.
        T.as_user(c, "adam")
        assert c.post(f"/api/discussion/threads/{tid}/followup", json={"note": "peek"}).status_code == 403
        assert c.post(f"/api/discussion/threads/{tid}/followup/clear", json={}).status_code == 403


class TestMove:
    """Re-filing a comment (T18): recorded, the legacy key and the bell's links following."""

    def test_a_curator_moves_a_comment_and_the_record_shows_who_and_from_where(self, world):
        c = T.client()
        T.as_user(c, "adam")
        first = post(c, "structure", "general", "cruise ports are growing @rian", label="Structure › Anything else")
        second = post(c, "structure", "general", "another thought")
        origin = threads(c)["structure:general"]["id"]
        # A client cannot move; the owner (a curator) can.
        assert c.post(f"/api/discussion/comments/{first['id']}/move", json={"subject_type": "topic", "subject_id": "cruise-ports"}).status_code == 403
        T.as_user(c, "rian")
        r = c.post(f"/api/discussion/comments/{first['id']}/move",
                   json={"subject_type": "topic", "subject_id": "cruise-ports", "label": "Topic › Cruise ports"})
        assert r.status_code == 200, r.text
        moved = r.json()
        assert moved["moved_from_thread_id"] == origin and moved["thread_id"] != origin
        with _env.TestSessionLocal() as db:
            row = db.get(DiscussionComment, first["id"])
            assert (row.moved_by_id, row.feature_key, row.item_id) == (1, "topic:cruise-ports", None)
            assert row.moved_at is not None and row.body == "cruise ports are growing @rian" and row.author == "Adam"
            # The mention rian got now points at the topic, on its page.
            n = db.scalar(select(Notification).where(Notification.kind == "mention"))
            assert n.url == f"https://dfp.test/discuss?tab=review#t-topic-cruise-ports#c-{first['id']}"
            assert (n.context_label, n.category) == ("Topic › Cruise ports", "topic")
        after = threads(c)
        assert [x["id"] for x in after["topic:cruise-ports"]["comments"]] == [first["id"]]
        assert [x["id"] for x in after["structure:general"]["comments"]] == [second["id"]]
        assert after["topic:cruise-ports"]["label"] == "Topic › Cruise ports"
        # A second move to the same place changes nothing; an unknown target is refused.
        again = c.post(f"/api/discussion/comments/{first['id']}/move", json={"subject_type": "topic", "subject_id": "cruise-ports"})
        assert again.json()["moved_from_thread_id"] == origin
        assert c.post(f"/api/discussion/comments/{first['id']}/move", json={"subject_type": "stage", "subject_id": "1"}).status_code == 422
        assert c.post("/api/discussion/comments/999/move", json={"subject_type": "todo", "subject_id": "13"}).status_code == 404

    def test_a_thread_emptied_by_a_move_is_a_starter_again(self, world):
        c = T.client()
        T.as_user(c, "adam")
        only = post(c, "feature", "my-airports", "test")
        T.as_user(c, "rian")
        c.post(f"/api/discussion/comments/{only['id']}/move", json={"subject_type": "todo", "subject_id": "13"})
        assert "feature:my-airports" not in threads(c)  # no live comments: not listed, a starter on its page
        with _env.TestSessionLocal() as db:
            assert disc.thread_of(db, "feature", "my-airports") is not None  # the row stays


class TestRethread:
    """The 13 Sep map applied once (T18): idempotent, nothing deleted but the soft delete."""

    def test_check_writes_nothing_and_apply_is_idempotent(self, world):
        from app.services.identity import Actor
        c = T.client()
        T.as_user(c, "adam")
        a = post(c, "structure", "general", "the airport drafts")
        b = post(c, "structure", "p-airport", "the list of nineteen")
        d = post(c, "feature", "my-airports", "test")
        plan = {
            "topics": [{"id": "airport-information-pages", "title": "Airport information pages: Adam's drafts"}],
            "moves": [{"comment": a["id"], "to": "topic:airport-information-pages"},
                      {"comment": b["id"], "to": "topic:airport-information-pages"},
                      {"comment": 999, "to": "todo:13"}, {"comment": a["id"], "to": "nonsense"}],
            "soft_delete": [d["id"], 998],
        }
        with _env.TestSessionLocal() as db:
            actor = Actor(1, "rian", "Rian")
            lines = disc.rethread(db, plan, actor=actor, check=True)  # --check: a walk, no write
            db.commit()  # and a commit proves it: nothing to commit
        assert lines[0] == "topic airport-information-pages: would create (Topic › Airport information pages: Adam's drafts)"
        assert lines[1].endswith("would move from structure:general") and lines[2].endswith("would move from structure:p-airport")
        assert "no such comment" in lines[3] and "not a subject" in lines[4]
        assert lines[5].startswith(f"delete {d['id']}: would soft-delete") and "no such comment" in lines[6]
        with _env.TestSessionLocal() as db:
            assert disc.thread_of(db, "topic", "airport-information-pages") is None  # nothing written
            assert db.get(DiscussionComment, d["id"]).deleted_at is None
            assert db.get(DiscussionComment, a["id"]).moved_at is None
        # --apply, twice: the second run finds everything done.
        with _env.TestSessionLocal() as db:
            disc.rethread(db, plan, actor=actor)
            db.commit()
        with _env.TestSessionLocal() as db:
            again = disc.rethread(db, plan, actor=actor)
            db.commit()
            t = disc.thread_of(db, "topic", "airport-information-pages")
            assert t.label == "Topic › Airport information pages: Adam's drafts"
            assert [x.id for x in disc.live_comments(db, t.id)] == [a["id"], b["id"]]
            assert db.get(DiscussionComment, d["id"]).deleted_at is not None
        assert again[0].endswith("exists (Topic › Airport information pages: Adam's drafts)")
        assert again[1].endswith("already there") and again[2].endswith("already there") and again[5].endswith("already deleted")

    def test_the_real_map_parses_and_names_only_subjects_we_know(self):
        import json, pathlib
        plan = json.loads((pathlib.Path(__file__).resolve().parents[2] / "import" / "rethread-2026-09-13.json").read_text())
        assert {t["id"] for t in plan["topics"]} == {"airport-information-pages", "category-page-drafts", "cruise-ports",
                                                     "dutyfreeawards-platform", "indexing-before-launch"}
        for move in plan["moves"]:
            head, sep, tail = move["to"].partition(":")
            assert sep and disc.valid_subject(head, tail), move
            if head == "topic":
                assert tail in {t["id"] for t in plan["topics"]}, move
        assert plan["soft_delete"] == [213]


class TestLegacyMentions:
    """The mention that never rang (T14). What it cost: Adam's "@Rian" of 13 Sep 21:20 was
    linked to its thread by the backfill and notified nobody; rian's inbox stayed empty until
    a row was emitted by hand on staging. The backfill emits under the write path's own dedupe
    key, so that hand-emitted row and a second run both count for nothing."""

    def _legacy(self, db, key: str, author: str, author_id: int | None, body: str, at):
        from datetime import timedelta
        c = DiscussionComment(feature_key=key, author=author, author_id=author_id, body=body, created_at=at,
                              updated_at=at + timedelta(0))
        db.add(c)
        db.flush()
        return c.id

    def test_the_threads_backfill_rings_what_the_link_up_missed_once(self, world):
        T0 = datetime(2026, 9, 13, 21, 20, tzinfo=UTC)
        with _env.TestSessionLocal() as db:
            c221 = self._legacy(db, "structure:general", "Adam", 2, "@Rian the dutyfreeawards.com site", T0)
            self._legacy(db, "structure:general", "Adam", 2, "@adam naming myself, @nobody, @claude", T0)
            self._legacy(db, "item:decide-x", "rian", 1, "@adam @mark what about this", T0)
            db.commit()
        with _env.TestSessionLocal() as db:
            out = disc.backfill_threads(db)
        assert out == "threads: 2 thread(s) created, 3 comment(s) linked; legacy_mentions: 1 mention(s) rung for 1 comment(s)"
        rows_ = _rows("rian")
        assert len(rows_) == 1 and rows_[0].kind == "mention" and rows_[0].dedupe_key == f"mention:{c221}:rian"
        assert rows_[0].created_at.replace(tzinfo=UTC) == T0 and rows_[0].resolved is False
        assert rows_[0].url == f"https://dfp.test/discuss?tab=structure#t-structure-general#c-{c221}"
        assert _rows("adam") == [] and _rows("mark") == []  # a plan thread never reaches a client; nobody rings themselves
        with _env.TestSessionLocal() as db:
            assert disc.backfill_threads(db) == "threads: 0 thread(s) created, 0 comment(s) linked; legacy_mentions: 0 mention(s) rung for 0 comment(s)"
            assert disc.backfill_legacy_mentions(db) == "legacy_mentions: 0 mention(s) rung for 0 comment(s)"
        assert len(_rows("rian")) == 1
        T.as_user(T.client(), "rian")
        c = T.client()
        T.as_user(c, "rian")
        assert c.get("/api/notifications").json()["needs_you"] is True

    def test_a_hand_emitted_row_and_a_resolved_thread_are_respected(self, world):
        T0 = datetime(2026, 9, 13, 21, 20, tzinfo=UTC)
        c = T.client()
        T.as_user(c, "adam")
        opened = post(c, "quote", "guides", "no name yet")
        tid = opened["thread_id"]
        with _env.TestSessionLocal() as db:
            # A legacy comment already on a thread (as after 0.47.0's link-up), and the hand-emitted row.
            t = db.get(disc.Thread, tid)
            late = DiscussionComment(thread_id=tid, feature_key="quote:guides", author="Adam", author_id=2,
                                     body="@rian and @mark please", created_at=T0, updated_at=T0)
            db.add(late)
            db.flush()
            db.add(Notification(recipient_id=1, actor_id=2, kind="mention", category="quote", context_label="by hand",
                                body="@rian and @mark please", url="x", source_type="comment", source_id=str(late.id),
                                dedupe_key=f"mention:{late.id}:rian"))
            t.resolved = True
            db.commit()
            assert disc.backfill_legacy_mentions(db) == "legacy_mentions: 1 mention(s) rung for 1 comment(s)"
        assert [n.context_label for n in _rows("rian") if n.kind == "mention"] == ["by hand"]  # kept, not doubled
        mark = [n for n in _rows("mark") if n.kind == "mention"]
        assert len(mark) == 1 and mark[0].resolved is True  # filed behind Show resolved from birth
        T.as_user(c, "mark")
        assert c.get("/api/notifications").json()["needs_you"] is False


class TestOutcome:
    """Resolve with an outcome (the workflow plan §2): done, or later; the closing word is the
    record; anyone who took part may resolve."""

    def test_later_is_a_resolution_with_its_own_outcome_and_the_word_is_the_record(self, world):
        c = T.client()
        T.as_user(c, "adam")
        post(c, "topic", "cruise-ports", "cruise ports are growing", label="Topic › Cruise ports")
        tid = threads(c)["topic:cruise-ports"]["id"]
        T.as_user(c, "rian")
        r = c.post(f"/api/discussion/threads/{tid}/resolve", json={"resolved": True, "outcome": "later", "comment": "After the soft launch."})
        assert r.status_code == 200, r.text
        t = r.json()
        assert (t["resolved"], t["outcome"], t["resolved_by"], t["closing_word"]) == (True, "later", "Rian", "After the soft launch.")
        told = [n for n in _rows("adam") if n.kind == "resolved"]
        assert len(told) == 1 and told[0].body == "After the soft launch."
        # A later word on a resolved thread becomes its record; reopening clears both.
        c.post(f"/api/discussion/threads/{tid}/resolve", json={"resolved": True, "outcome": "later", "comment": "Or the release after."})
        assert threads(c)["topic:cruise-ports"]["closing_word"] == "Or the release after."
        r = c.post(f"/api/discussion/threads/{tid}/resolve", json={"resolved": False})
        assert (r.json()["outcome"], r.json()["closing_word"], r.json()["resolved_by"]) == (None, None, None)
        assert c.post(f"/api/discussion/threads/{tid}/resolve", json={"resolved": True, "outcome": "never"}).status_code == 422
        r = c.post(f"/api/discussion/threads/{tid}/resolve", json={"resolved": True})
        assert r.json()["outcome"] is None and r.json()["closing_word"] is None
        assert [n.body for n in _rows("adam") if n.kind == "resolved"][-1] == "Resolved."

    def test_anyone_who_took_part_may_resolve_and_a_bystander_may_not(self, world):
        c = T.client()
        T.as_user(c, "adam")
        post(c, "quote", "guides", "adam opens")
        tid = threads(c)["quote:guides"]["id"]
        T.as_user(c, "mark")
        assert c.post(f"/api/discussion/threads/{tid}/resolve", json={"resolved": True}).status_code == 403
        post(c, "quote", "guides", "mark joins")
        assert c.post(f"/api/discussion/threads/{tid}/resolve", json={"resolved": True}).status_code == 200
        assert c.post(f"/api/discussion/threads/{tid}/resolve", json={"resolved": False}).status_code == 200


class TestReadSeed:
    def test_the_seed_marks_everything_read_for_everyone_once(self, world):
        c = T.client()
        T.as_user(c, "adam")
        post(c, "structure", "urls", "one")
        post(c, "quote", "guides", "two")
        T.as_user(c, "mark")
        assert all(t["unread"] for t in threads(c).values())
        with _env.TestSessionLocal() as db:
            assert disc.backfill_thread_reads_seed(db) == "thread_reads_seed: 6 row(s) inserted for 3 account(s) over 2 thread(s)"
            assert disc.backfill_thread_reads_seed(db) == "thread_reads_seed: 0 row(s) inserted for 3 account(s) over 2 thread(s)"
        assert not any(t["unread"] for t in threads(c).values())
        # New activity after the seed is unread again (only that thread's stamp is moved back).
        back_in_time(ThreadRead, "read_at", account_id=3, thread_id=threads(c)["structure:urls"]["id"])
        T.as_user(c, "adam")
        post(c, "structure", "urls", "three")
        T.as_user(c, "mark")
        assert threads(c)["structure:urls"]["unread"] is True and threads(c)["quote:guides"]["unread"] is False


class TestAsks:
    """The one hand-off (the workflow plan §2): for one person, a line saying what; needs-you for
    them, done by them or a curator, the asker told as news."""

    def test_an_ask_rings_the_person_lists_under_needs_you_and_done_tells_the_asker(self, world):
        c = T.client()
        T.as_user(c, "adam")
        post(c, "topic", "whiskey-example", "here is a category example", label="Topic › Category page drafts")
        tid = threads(c)["topic:whiskey-example"]["id"]
        r = c.post(f"/api/discussion/threads/{tid}/asks", json={"for": "mark", "note": "  your view on   this one "})
        assert r.status_code == 201, r.text
        ask = r.json()["asks"][0]
        assert (ask["for"], ask["note"], ask["by"], ask["done_at"], ask["mine"]) == ("Mark", "your view on this one", "Adam", None, False)
        turn = [n for n in _rows("mark") if n.kind == "turn"]
        assert len(turn) == 1 and turn[0].body == "your view on this one" and turn[0].source_type == "ask"
        T.as_user(c, "mark")
        box = c.get("/api/notifications").json()
        assert box["needs_you"] is True
        mine = threads(c)["topic:whiskey-example"]
        assert mine["needs_you"] is True and mine["asks"][0]["mine"] is True
        # Rian (a curator, not asked) sees the ask but it is not his to need.
        T.as_user(c, "rian")
        assert threads(c)["topic:whiskey-example"]["needs_you"] is False
        # Mark does it with a word: his comment lands, the turn row leaves his count, Adam hears news.
        T.as_user(c, "mark")
        r = c.post(f"/api/discussion/asks/{ask['id']}/done", json={"comment": "Looks right; shorten the intro."})
        assert r.status_code == 200
        t = r.json()
        assert t["asks"][0]["done_by"] == "Mark" and t["asks"][0]["done_at"] is not None
        assert t["comments"][-1]["body"] == "Looks right; shorten the intro." and t["needs_you"] is False
        assert c.get("/api/notifications").json()["needs_you"] is False
        news = [n for n in _rows("adam") if n.kind == "status"]
        assert len(news) == 1 and news[0].body.startswith("Done: your view on this one")
        # Done twice changes nothing; a bystander cannot do someone else's ask; a curator can.
        assert c.post(f"/api/discussion/asks/{ask['id']}/done", json={}).status_code == 200
        assert len([n for n in _rows("adam") if n.kind == "status"]) == 1
        T.as_user(c, "adam")
        second = c.post(f"/api/discussion/threads/{tid}/asks", json={"for": "mark", "note": "and the whisky one"}).json()["asks"]
        open_ask = next(a for a in second if a["done_at"] is None)
        assert c.post(f"/api/discussion/asks/{open_ask['id']}/done", json={}).status_code == 403
        T.as_user(c, "rian")
        assert c.post(f"/api/discussion/asks/{open_ask['id']}/done", json={}).status_code == 200

    def test_an_ask_reaches_only_someone_the_conversation_can_reach(self, world):
        c = T.client()
        T.as_user(c, "rian")
        tid = post(c, "item", "decide-x", "the plan")["thread_id"]
        r = c.post(f"/api/discussion/threads/{tid}/asks", json={"for": "adam", "note": "confirm"})
        assert r.status_code == 422 and r.json()["detail"]["error_code"] == "NOT_REACHABLE"
        assert c.post(f"/api/discussion/threads/{tid}/asks", json={"for": "rian", "note": "build it"}).status_code == 201  # oneself
        assert c.post(f"/api/discussion/threads/{tid}/asks", json={"for": "rian", "note": "  "}).status_code == 422
        assert _rows("rian") == []  # never rung by one's own ask
        T.as_user(c, "adam")
        assert c.post(f"/api/discussion/threads/{tid}/asks", json={"for": "rian", "note": "peek"}).status_code == 404

    def test_reading_the_thread_clears_the_turn_row_but_not_the_ask(self, world):
        c = T.client()
        T.as_user(c, "adam")
        tid = post(c, "quote", "guides", "opened")["thread_id"]
        c.post(f"/api/discussion/threads/{tid}/asks", json={"for": "mark", "note": "confirm"})
        T.as_user(c, "mark")
        c.post(f"/api/discussion/threads/{tid}/read")
        assert c.get("/api/notifications").json()["unread"] == 0
        assert threads(c)["quote:guides"]["needs_you"] is True  # the ask stands until it is done


class TestGotIt:
    def test_got_it_is_a_toggle_that_completes_the_persons_open_asks(self, world):
        c = T.client()
        T.as_user(c, "rian")
        cid = post(c, "topic", "dutyfreeawards", "It sits on the new platform, not WordPress.", label="Topic › dutyfreeawards")["id"]
        tid = threads(c)["topic:dutyfreeawards"]["id"]
        c.post(f"/api/discussion/threads/{tid}/asks", json={"for": "adam", "note": "confirm you have seen this"})
        T.as_user(c, "adam")
        assert threads(c)["topic:dutyfreeawards"]["needs_you"] is True
        r = c.post(f"/api/discussion/comments/{cid}/ack")
        assert r.status_code == 200 and r.json() == {"ok": True, "id": cid, "acked": True, "asks_done": 1}
        t = threads(c)["topic:dutyfreeawards"]
        assert t["comments"][0]["acks"] == ["Adam"] and t["comments"][0]["acked"] is True
        assert t["needs_you"] is False and t["asks"][0]["done_by"] == "Adam"
        assert [n.kind for n in _rows("rian")][-1] == "status"  # rian hears the ask is done, as news
        # Mark's view of the same comment; a second tap takes Adam's back, the ask stays done.
        T.as_user(c, "mark")
        assert threads(c)["topic:dutyfreeawards"]["comments"][0]["acked"] is False
        T.as_user(c, "adam")
        assert c.post(f"/api/discussion/comments/{cid}/ack").json()["acked"] is False
        t = threads(c)["topic:dutyfreeawards"]
        assert t["comments"][0]["acks"] == [] and t["asks"][0]["done_at"] is not None


class TestArchive:
    def test_archive_is_a_curators_verb_that_resolves_first_and_keeps_the_record(self, world):
        c = T.client()
        T.as_user(c, "adam")
        post(c, "structure", "urls", "the URL question")
        tid = threads(c)["structure:urls"]["id"]
        assert c.post(f"/api/discussion/threads/{tid}/archive", json={"archived": True}).status_code == 403
        T.as_user(c, "rian")
        c.post(f"/api/discussion/threads/{tid}/resolve", json={"resolved": True, "comment": "Airport first, then the code."})
        r = c.post(f"/api/discussion/threads/{tid}/archive", json={"archived": True})
        assert r.status_code == 200
        t = r.json()
        assert t["archived"] is True and t["resolved"] is True and t["closing_word"] == "Airport first, then the code."
        # Still read by everyone (the panel hides it unless asked), still repliable, and back again on request.
        T.as_user(c, "adam")
        assert threads(c)["structure:urls"]["archived"] is True
        post(c, "structure", "urls", "one more thought")
        T.as_user(c, "rian")
        assert c.post(f"/api/discussion/threads/{tid}/archive", json={"archived": False}).json()["archived"] is False
        # Archiving an open thread resolves it first: nothing archived is an open issue.
        tid2 = post(c, "quote", "guides", "opened")["thread_id"]
        t2 = c.post(f"/api/discussion/threads/{tid2}/archive", json={"archived": True}).json()
        assert t2["archived"] is True and t2["resolved"] is True and t2["outcome"] is None


class TestFlagsStagesDecisions:
    """Rian's card design of 14 Sep: a personal flag on a comment, Later as a mark on an open
    thread, and a decision as a mark on a resolution."""

    def test_a_flag_is_personal_and_lifts_the_thread_into_the_viewers_flagged_view(self, world):
        c = T.client()
        T.as_user(c, "adam")
        cid = post(c, "structure", "urls", "the URL question")["id"]
        T.as_user(c, "mark")
        r = c.post(f"/api/discussion/comments/{cid}/flag")
        assert r.status_code == 200 and r.json()["flagged"] is True
        t = threads(c)["structure:urls"]
        assert t["flagged"] is True and t["comments"][0]["flagged"] is True
        T.as_user(c, "adam")
        assert threads(c)["structure:urls"]["flagged"] is False  # nobody else sees Mark's flag
        assert _rows("adam") == [] or all(n.kind != "status" for n in _rows("adam"))  # nobody is told
        T.as_user(c, "mark")
        assert c.post(f"/api/discussion/comments/{cid}/flag").json()["flagged"] is False
        assert threads(c)["structure:urls"]["flagged"] is False

    def test_later_is_a_mark_on_an_open_thread_and_a_decision_a_mark_on_a_resolution(self, world):
        c = T.client()
        T.as_user(c, "adam")
        post(c, "topic", "cruise-ports", "cruise ports", label="Topic › Cruise ports")
        tid = threads(c)["topic:cruise-ports"]["id"]
        r = c.post(f"/api/discussion/threads/{tid}/stage", json={"stage": "later"})
        assert r.status_code == 200 and r.json()["later"] is True and r.json()["resolved"] is False
        assert c.post(f"/api/discussion/threads/{tid}/stage", json={"stage": "current"}).json()["later"] is False
        assert c.post(f"/api/discussion/threads/{tid}/stage", json={"stage": "someday"}).status_code == 422
        T.as_user(c, "mark")
        assert c.post(f"/api/discussion/threads/{tid}/stage", json={"stage": "later"}).status_code == 403  # not in it
        T.as_user(c, "rian")
        r = c.post(f"/api/discussion/threads/{tid}/resolve", json={"resolved": True, "outcome": "decision", "comment": "After launch, as a port set."})
        t = r.json()
        assert (t["resolved"], t["decision"], t["later"], t["closing_word"]) == (True, True, False, "After launch, as a port set.")
        assert [n.body for n in _rows("adam") if n.kind == "resolved"][-1] == "After launch, as a port set."
        # A plain resolve carries no mark; a reopen clears the decision; Later on a resolved thread reopens it.
        assert c.post(f"/api/discussion/threads/{tid}/resolve", json={"resolved": True}).json()["decision"] is True  # already resolved: unchanged
        assert c.post(f"/api/discussion/threads/{tid}/resolve", json={"resolved": False}).json()["decision"] is False
        c.post(f"/api/discussion/threads/{tid}/resolve", json={"resolved": True})
        assert threads(c)["topic:cruise-ports"]["decision"] is False
        t = c.post(f"/api/discussion/threads/{tid}/stage", json={"stage": "later"}).json()
        assert (t["resolved"], t["later"]) == (False, True)


class TestAskKinds:
    """A typed ask (rian, 14 Sep): the person asked gets a button that is the action, and the
    ask completes when they do it; nobody else's action completes it."""

    def test_reply_resolve_and_later_asks_complete_on_the_receivers_own_action(self, world):
        c = T.client()
        T.as_user(c, "rian")
        post(c, "topic", "cruise-ports", "cruise ports", label="Topic › Cruise ports")
        tid = threads(c)["topic:cruise-ports"]["id"]
        r = c.post(f"/api/discussion/threads/{tid}/asks", json={"for": "adam", "kind": "reply"})
        assert r.status_code == 201, r.text
        assert r.json()["asks"][0]["kind"] == "reply" and r.json()["asks"][0]["note"] == "Please reply"
        c.post(f"/api/discussion/threads/{tid}/asks", json={"for": "adam", "kind": "later", "note": "confirm the deferral"})
        c.post(f"/api/discussion/threads/{tid}/asks", json={"for": "adam", "kind": "resolve"})
        assert c.post(f"/api/discussion/threads/{tid}/asks", json={"for": "adam", "kind": "dance"}).status_code == 422
        assert c.post(f"/api/discussion/threads/{tid}/asks", json={"for": "adam"}).status_code == 422  # words needed without a kind
        # Mark's reply completes nothing of Adam's.
        T.as_user(c, "mark")
        post(c, "topic", "cruise-ports", "a view from mark")
        T.as_user(c, "adam")
        open_kinds = lambda: sorted(a["kind"] for a in threads(c)["topic:cruise-ports"]["asks"] if not a["done_at"])
        assert open_kinds() == ["later", "reply", "resolve"]
        # Adam replies: the reply ask is done, the others stand.
        post(c, "topic", "cruise-ports", "here is my reply")
        assert open_kinds() == ["later", "resolve"]
        # Adam marks Later: the later ask is done.
        c.post(f"/api/discussion/threads/{tid}/stage", json={"stage": "later"})
        assert open_kinds() == ["resolve"]
        # Adam resolves: the resolve ask is done; rian hears each as news.
        c.post(f"/api/discussion/threads/{tid}/resolve", json={"resolved": True, "comment": "Agreed, after launch."})
        assert open_kinds() == []
        assert len([n for n in _rows("rian") if n.kind == "status"]) == 3
        assert threads(c)["topic:cruise-ports"]["needs_you"] is False


class TestUnreadAgain:
    def test_the_circle_takes_a_thread_back_to_unread_for_the_viewer_only(self, world):
        c = T.client()
        T.as_user(c, "adam")
        post(c, "structure", "urls", "the URL question")
        tid = threads(c)["structure:urls"]["id"]
        T.as_user(c, "mark")
        c.post(f"/api/discussion/threads/{tid}/read")
        assert threads(c)["structure:urls"]["unread"] is False
        r = c.post(f"/api/discussion/threads/{tid}/unread")
        assert r.status_code == 200 and r.json() == {"thread_id": tid, "read_at": None}
        assert threads(c)["structure:urls"]["unread"] is True
        assert c.post(f"/api/discussion/threads/{tid}/unread").status_code == 200  # twice is fine
        T.as_user(c, "adam")
        assert threads(c)["structure:urls"]["unread"] is False  # his own words, and his stamp untouched
        T.as_user(c, "rian")
        tid2 = post(c, "item", "decide-x", "the plan")["thread_id"]
        T.as_user(c, "adam")
        assert c.post(f"/api/discussion/threads/{tid2}/unread").status_code == 404


class TestAskEditDelete:
    def test_the_asker_or_a_curator_rewrites_or_withdraws_an_ask(self, world):
        c = T.client()
        T.as_user(c, "adam")
        post(c, "quote", "guides", "opened")
        tid = threads(c)["quote:guides"]["id"]
        ask = c.post(f"/api/discussion/threads/{tid}/asks", json={"for": "mark", "note": "your view"}).json()["asks"][0]
        # Mark (the receiver, not the asker) may neither rewrite nor withdraw it.
        T.as_user(c, "mark")
        assert c.post(f"/api/discussion/asks/{ask['id']}/edit", json={"note": "no"}).status_code == 403
        assert c.post(f"/api/discussion/asks/{ask['id']}/delete").status_code == 403
        # Adam rewrites the words, then the kind: the turn row's preview follows.
        T.as_user(c, "adam")
        r = c.post(f"/api/discussion/asks/{ask['id']}/edit", json={"note": "your view on the whisky one"})
        assert r.status_code == 200 and r.json()["asks"][0]["note"] == "your view on the whisky one"
        assert [n.body for n in _rows("mark") if n.kind == "turn"][-1] == "your view on the whisky one"
        r = c.post(f"/api/discussion/asks/{ask['id']}/edit", json={"kind": "reply", "note": ""})
        assert (r.json()["asks"][0]["kind"], r.json()["asks"][0]["note"]) == ("reply", "Please reply")
        assert c.post(f"/api/discussion/asks/{ask['id']}/edit", json={"kind": "other", "note": ""}).status_code == 422
        # Withdrawn: the row goes and Mark's count drops; a curator could have done it too.
        T.as_user(c, "mark")
        assert c.get("/api/notifications").json()["needs_you"] is True
        T.as_user(c, "adam")
        assert c.post(f"/api/discussion/asks/{ask['id']}/delete").json()["asks"] == []
        T.as_user(c, "mark")
        assert c.get("/api/notifications").json()["needs_you"] is False
        assert c.post(f"/api/discussion/asks/{ask['id']}/delete").status_code == 404
