"""The decision cards, and every comment thread on the site.

Sources of truth: this module, `app/services/discussion.py` (the one write path, the subject
resolver, the permission a subject needs), `app/models/discussion.py`, `docs/CLIENT-SURFACES.md`.

Authorship comes from the session, never the payload: each write stores the EFFECTIVE
account's id in the who-column and its display name as a snapshot (the raw record). The
`item:*` threads belong to the running list on /plan: the reads omit them, and a post, an
edit, a delete or a resolve on one answers 404, unless the caller holds `plan.view` (the same
non-disclosure as the /plan page). A comment is editable by its author under
`client.participate` and by anyone under `discussion.curate`; the same two may remove it
(soft) and resolve or reopen its thread. The legacy routes (`feature-comments`, the card's
`/comments`) still accept the old keys for one release; they write through the same service,
so every comment gets its thread.
"""

from datetime import UTC, datetime

from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import RedirectResponse
from sqlalchemy import func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session, selectinload

from app.db import get_db
from app.models import DiscussionComment, DiscussionItem, FeaturePriority
from app.models.discussion import Thread
from app.models.discussion import ThreadAsk
from app.models.schemas import (
    ArchiveIn,
    AskDoneIn,
    AskEditIn,
    AskIn,
    CommentIn,
    DiscussionCommentIn,
    DiscussionCommentOut,
    DiscussionItemIn,
    DiscussionItemOut,
    FeatureCommentEdit,
    FeatureCommentIn,
    FeatureCommentOut,
    FeaturePriorityIn,
    FeaturePriorityOut,
    FollowupClearIn,
    FollowupIn,
    MoveIn,
    ResolveIn,
    StageIn,
    ThreadCommentOut,
    ThreadOut,
    TopicIn,
)
from app.services import accounts, identity
from app.services import discussion as disc

router = APIRouter(prefix="/api/discussion", tags=["discussion"])
#: The three client pages folded into /discuss's tabs (T8): each old address answers a 301
#: whose Location carries no fragment, so the browser inherits the request's own and a deep
#: link `/todo#t-todo-9#c-88` lands on `/discuss?tab=todo#t-todo-9#c-88`. Included ahead of the
#: shell's catch-all; the policy classes each as a client.view page.
pages_router = APIRouter(include_in_schema=False)
MOVED_PAGES = {"/todo": "/discuss?tab=todo", "/structure": "/discuss?tab=structure", "/quote": "/discuss?tab=quote"}

ITEM_PREFIX = "item:"


def _moved(target: str):
    def page() -> RedirectResponse:
        return RedirectResponse(target, status_code=301, headers={"Cache-Control": "no-store"})
    return page


for _old, _new in MOVED_PAGES.items():
    pages_router.add_api_route(_old, _moved(_new), methods=["GET", "HEAD"])


def _sees_items(request: Request) -> bool:
    return accounts.can(identity.optional_user(request), accounts.PERM_PLAN_VIEW)


def _not_found() -> HTTPException:
    return HTTPException(status_code=404, detail="no such comment")


def _forbidden(summary: str) -> HTTPException:
    return HTTPException(status_code=403, detail={"error_code": "FORBIDDEN", "summary": summary})


def _may_manage(request: Request, author_id: int | None) -> bool:
    """The author under client.participate, anyone under discussion.curate."""
    who = identity.actor(request)
    return author_id == who.id or accounts.can(who.username, accounts.PERM_DISCUSSION_CURATE)


def _visible_comment(db: Session, request: Request, comment_id: int) -> DiscussionComment:
    comment = db.get(DiscussionComment, comment_id)
    if comment is None or comment.deleted_at is not None:
        raise _not_found()
    subject_type, _ = disc.subject_of_legacy(comment.item_id, comment.feature_key)
    if subject_type == "item" and not _sees_items(request):
        raise _not_found()
    return comment


# --- threads (the new shape) ------------------------------------------------------------------

@router.get("/threads", response_model=list[ThreadOut])
def list_threads(request: Request, since: str | None = Query(default=None), db: Session = Depends(get_db)) -> list[dict]:
    """Every thread the caller may see, with its live comments and what each is to the caller
    (unread, needs-you, the read stamp, the two dates): the one read a page joins its own
    subject list onto (the rest are starters) and the side panel lists whole. `since` (ISO)
    keeps the threads that moved on or after it."""
    who = identity.actor(request)
    cutoff = None
    if since:
        try:
            cutoff = datetime.fromisoformat(since)
        except ValueError:
            raise HTTPException(status_code=422, detail={"error_code": "BAD_SINCE", "summary": "since is an ISO date or datetime"})
        if cutoff.tzinfo is None:
            cutoff = cutoff.replace(tzinfo=UTC)
    return disc.threads_for(db, who.username, who.id, since=cutoff)


@router.post("/threads/{thread_id}/read")
def read_thread(thread_id: int, request: Request, db: Session = Depends(get_db)) -> dict:
    """The caller opened the thread (the panel or the page): their read stamp moves to now and
    their notifications from it leave the unread count. Their own row only; nothing else is
    written, and a thread they may not see is 404 like everywhere else."""
    thread = db.get(Thread, thread_id)
    if thread is None or (thread.subject_type == "item" and not _sees_items(request)):
        raise HTTPException(status_code=404, detail="no such thread")
    out = disc.mark_thread_read(db, thread, identity.actor(request).id)
    db.commit()
    return out


@router.get("/people")
def people(subject_type: str, request: Request, db: Session = Depends(get_db)) -> list[dict]:
    """The mention directory for a subject type (T16): who `@name` may reach there, as
    `{handle, display_name}` rows. The same directory the write path resolves mentions against,
    so what the box offers is exactly what will ring; the owner's subjects add the `claude`
    handle (a session reads it, nobody is notified). The day the pack's Composer takes a
    `directory` prop, this feeds it; until then the composer shows the handles as its hint."""
    if subject_type not in disc.SUBJECT_TYPES:
        raise HTTPException(status_code=422, detail={"error_code": "UNKNOWN_SUBJECT"})
    if subject_type == "item" and not _sees_items(request):
        raise HTTPException(status_code=404, detail="no such subject")
    directory = disc.mention_directory(db, subject_type)
    return [{"handle": handle, "display_name": account.display_name if account is not None else "Claude"}
            for handle, account in sorted(directory.items())]


@router.post("/topics", response_model=ThreadOut, status_code=201)
def start_topic(payload: TopicIn, request: Request, db: Session = Depends(get_db)) -> dict:
    """A thread that belongs to no section (T15): a title and a first comment, from the panel's
    footer. The thread comes back as the list read shapes it, so the panel can show it at once."""
    who = identity.actor(request)
    try:
        thread = disc.start_topic(db, title=payload.title, body=payload.body, actor=who)
    except ValueError:
        raise HTTPException(status_code=422, detail={"error_code": "TITLE_REQUIRED", "summary": "A topic needs a title with a word in it."})
    db.commit()
    out = disc.thread_out(db, thread, who.id)
    assert out is not None
    return out


@router.post("/threads/{thread_id}/unread")
def unread_thread(thread_id: int, request: Request, db: Session = Depends(get_db)) -> dict:
    """The caller wants the thread back as unread: their own read stamp goes."""
    thread = db.get(Thread, thread_id)
    if thread is None or (thread.subject_type == "item" and not _sees_items(request)):
        raise HTTPException(status_code=404, detail="no such thread")
    out = disc.mark_thread_unread(db, thread, identity.actor(request).id)
    db.commit()
    return out


@router.post("/comments", response_model=ThreadCommentOut, status_code=201)
def add_comment(payload: CommentIn, request: Request, db: Session = Depends(get_db)) -> dict:
    if not disc.valid_subject(payload.subject_type, payload.subject_id):
        raise HTTPException(status_code=422, detail={"error_code": "UNKNOWN_SUBJECT"})
    if payload.subject_type == "item" and not _sees_items(request):
        raise _not_found()
    who = identity.actor(request)
    comment = disc.add_comment(db, subject_type=payload.subject_type, subject_id=payload.subject_id,
                               actor=who, body=payload.body, label=payload.label)
    db.commit()
    db.refresh(comment)
    return {**disc.comment_out(comment, who.id), "thread_id": comment.thread_id}


@router.post("/comments/{comment_id}/edit", response_model=ThreadCommentOut)
def edit_comment(comment_id: int, payload: FeatureCommentEdit, request: Request, db: Session = Depends(get_db)) -> dict:
    """Rewrite the words of any thread's comment (a card's included): the author or a curator."""
    comment = _visible_comment(db, request, comment_id)
    if not _may_manage(request, comment.author_id):
        raise _forbidden("You can edit your own comments only.")
    disc.edit_comment(db, comment, body=payload.body)
    db.commit()
    db.refresh(comment)
    return {**disc.comment_out(comment, identity.actor(request).id), "thread_id": comment.thread_id}


@router.post("/comments/{comment_id}/delete", status_code=200)
def remove_comment(comment_id: int, request: Request, db: Session = Depends(get_db)) -> dict:
    """Soft: the row stays, the reader stops seeing it. The author or a curator."""
    comment = _visible_comment(db, request, comment_id)
    if not _may_manage(request, comment.author_id):
        raise _forbidden("You can remove your own comments only.")
    disc.delete_comment(db, comment)
    db.commit()
    return {"ok": True, "id": comment_id}


@router.post("/comments/{comment_id}/move", response_model=ThreadCommentOut)
def move_comment(comment_id: int, payload: MoveIn, request: Request, db: Session = Depends(get_db)) -> dict:
    """Re-file a comment under another subject (T18): curators only (the policy), the words and
    the author untouched, who moved it and from where kept on the comment. A target on the
    running list needs `plan.view` like any other read of it; an unknown target is 422."""
    comment = _visible_comment(db, request, comment_id)
    if not disc.valid_subject(payload.subject_type, payload.subject_id):
        raise HTTPException(status_code=422, detail={"error_code": "UNKNOWN_SUBJECT"})
    if payload.subject_type == "item" and not _sees_items(request):
        raise HTTPException(status_code=404, detail="no such subject")
    who = identity.actor(request)
    disc.move_comment(db, comment, subject_type=payload.subject_type, subject_id=payload.subject_id, actor=who,
                      label=payload.label)
    db.commit()
    db.refresh(comment)
    return {**disc.comment_out(comment, who.id), "thread_id": comment.thread_id}


@router.post("/threads/{thread_id}/resolve", response_model=ThreadOut)
def resolve_thread(thread_id: int, payload: ResolveIn, request: Request, db: Session = Depends(get_db)) -> dict:
    """Resolve or reopen a conversation, with an optional closing word (T17) and an outcome
    (done or later): anyone who took part in it, or a curator (the workflow plan §7). The
    permission check precedes the first write, so a refused caller writes nothing."""
    thread = db.get(Thread, thread_id)
    if thread is None or (thread.subject_type == "item" and not _sees_items(request)):
        raise HTTPException(status_code=404, detail="no such thread")
    who = identity.actor(request)
    if who.id not in disc.participants_of(db, thread) and not accounts.can(who.username, accounts.PERM_DISCUSSION_CURATE):
        raise _forbidden("Only someone who took part in this conversation can resolve it.")
    disc.resolve_thread(db, thread, actor=who, resolved=payload.resolved, comment=payload.comment,
                        outcome=payload.outcome)
    db.commit()
    out = disc.thread_out(db, thread, who.id)
    assert out is not None
    return out


def _visible_thread(db: Session, request: Request, thread_id: int) -> Thread:
    thread = db.get(Thread, thread_id)
    if thread is None or (thread.subject_type == "item" and not _sees_items(request)):
        raise HTTPException(status_code=404, detail="no such thread")
    return thread


@router.post("/threads/{thread_id}/followup", response_model=ThreadOut)
def mark_followup(thread_id: int, payload: FollowupIn, request: Request, db: Session = Depends(get_db)) -> dict:
    """A curator marks the conversation as needing follow-up, with a one-line note (T13); the
    policy admits `discussion.curate` only. The note replaces any earlier mark."""
    thread = _visible_thread(db, request, thread_id)
    who = identity.actor(request)
    try:
        disc.set_followup(db, thread, actor=who, note=payload.note)
    except ValueError:
        raise HTTPException(status_code=422, detail={"error_code": "NOTE_REQUIRED", "summary": "Say what the follow-up is."})
    db.commit()
    out = disc.thread_out(db, thread, who.id)
    assert out is not None
    return out


@router.post("/threads/{thread_id}/followup/clear", response_model=ThreadOut)
def clear_followup(thread_id: int, payload: FollowupClearIn, request: Request, db: Session = Depends(get_db)) -> dict:
    """The follow-up is done: the mark goes; the note, when given, is posted as the curator's
    comment so the people in the thread hear it."""
    thread = _visible_thread(db, request, thread_id)
    who = identity.actor(request)
    disc.clear_followup(db, thread, actor=who, note=payload.note)
    db.commit()
    out = disc.thread_out(db, thread, who.id)
    assert out is not None
    return out


@router.post("/threads/{thread_id}/asks", response_model=ThreadOut, status_code=201)
def ask_someone(thread_id: int, payload: AskIn, request: Request, db: Session = Depends(get_db)) -> dict:
    """The one hand-off (the workflow plan §2): ask one person, who must be someone this
    conversation can reach, for one thing. Anyone who may write may ask."""
    thread = _visible_thread(db, request, thread_id)
    who = identity.actor(request)
    try:
        disc.ask(db, thread, actor=who, for_username=payload.for_, note=payload.note, kind=payload.kind)
    except ValueError:
        raise HTTPException(status_code=422, detail={"error_code": "NOTE_REQUIRED", "summary": "Say what you are asking for."})
    except LookupError:
        raise HTTPException(status_code=422, detail={"error_code": "NOT_REACHABLE", "summary": "That is not someone this conversation can reach."})
    db.commit()
    out = disc.thread_out(db, thread, who.id)
    assert out is not None
    return out


@router.post("/asks/{ask_id}/done", response_model=ThreadOut)
def ask_done(ask_id: int, payload: AskDoneIn, request: Request, db: Session = Depends(get_db)) -> dict:
    """The ask is done: the person asked, or a curator, says so, with an optional word posted
    as their comment. A thread the caller may not see is 404 like everywhere else."""
    row = db.get(ThreadAsk, ask_id)
    if row is None:
        raise HTTPException(status_code=404, detail="no such ask")
    thread = _visible_thread(db, request, row.thread_id)
    who = identity.actor(request)
    if row.for_id != who.id and not accounts.can(who.username, accounts.PERM_DISCUSSION_CURATE):
        raise _forbidden("This was asked of someone else.")
    disc.finish_ask(db, row, actor=who, word=payload.comment)
    db.commit()
    out = disc.thread_out(db, thread, who.id)
    assert out is not None
    return out


@router.post("/threads/{thread_id}/archive", response_model=ThreadOut)
def archive(thread_id: int, payload: ArchiveIn, request: Request, db: Session = Depends(get_db)) -> dict:
    """Put a conversation away for good, or bring it back: a curator's verb (the policy). An
    open thread is resolved first, so nothing archived is an open issue."""
    thread = _visible_thread(db, request, thread_id)
    who = identity.actor(request)
    disc.archive_thread(db, thread, actor=who, archived=payload.archived)
    db.commit()
    out = disc.thread_out(db, thread, who.id)
    assert out is not None
    return out


@router.post("/threads/{thread_id}/stage", response_model=ThreadOut)
def set_stage(thread_id: int, payload: StageIn, request: Request, db: Session = Depends(get_db)) -> dict:
    """Mark as Later (parked) or Current: anyone who took part, or a curator; a resolved thread
    is reopened first."""
    thread = _visible_thread(db, request, thread_id)
    who = identity.actor(request)
    if who.id not in disc.participants_of(db, thread) and not accounts.can(who.username, accounts.PERM_DISCUSSION_CURATE):
        raise _forbidden("Only someone who took part in this conversation can mark it.")
    disc.set_stage(db, thread, actor=who, stage=payload.stage)
    db.commit()
    out = disc.thread_out(db, thread, who.id)
    assert out is not None
    return out


@router.post("/comments/{comment_id}/flag")
def flag(comment_id: int, request: Request, db: Session = Depends(get_db)) -> dict:
    """A personal flag on a comment: one tap sets it, a second takes it back; nobody is told."""
    comment = _visible_comment(db, request, comment_id)
    out = disc.flag_comment(db, comment, actor=identity.actor(request))
    db.commit()
    return {"ok": True, "id": comment_id, **out}


def _own_ask(db: Session, request: Request, ask_id: int) -> tuple[ThreadAsk, Thread]:
    """An open ask the caller may change: the asker's, or any for a curator."""
    row = db.get(ThreadAsk, ask_id)
    if row is None:
        raise HTTPException(status_code=404, detail="no such ask")
    thread = _visible_thread(db, request, row.thread_id)
    who = identity.actor(request)
    if row.by_id != who.id and not accounts.can(who.username, accounts.PERM_DISCUSSION_CURATE):
        raise _forbidden("This is someone else's request.")
    return row, thread


@router.post("/asks/{ask_id}/edit", response_model=ThreadOut)
def ask_edit(ask_id: int, payload: AskEditIn, request: Request, db: Session = Depends(get_db)) -> dict:
    """Rewrite an ask's words or kind: the asker or a curator."""
    row, thread = _own_ask(db, request, ask_id)
    try:
        disc.edit_ask(db, row, note=payload.note, kind=None if payload.kind == "other" else payload.kind,
                      keep_kind=payload.kind is None)
    except ValueError:
        raise HTTPException(status_code=422, detail={"error_code": "NOTE_REQUIRED", "summary": "Say what you are asking for."})
    db.commit()
    out = disc.thread_out(db, thread, identity.actor(request).id)
    assert out is not None
    return out


@router.post("/asks/{ask_id}/delete", response_model=ThreadOut)
def ask_delete(ask_id: int, request: Request, db: Session = Depends(get_db)) -> dict:
    """Withdraw an ask: the asker or a curator. The row goes; nothing is kept."""
    row, thread = _own_ask(db, request, ask_id)
    disc.delete_ask(db, row)
    db.commit()
    out = disc.thread_out(db, thread, identity.actor(request).id)
    assert out is not None
    return out


@router.post("/comments/{comment_id}/ack")
def acknowledge(comment_id: int, request: Request, db: Session = Depends(get_db)) -> dict:
    """"Got it": seen and agreed, one tap; a second tap takes it back. Completes the caller's
    open asks on the thread."""
    comment = _visible_comment(db, request, comment_id)
    out = disc.acknowledge(db, comment, actor=identity.actor(request))
    db.commit()
    return {"ok": True, "id": comment_id, **out}


# --- the legacy comment routes (one release) ------------------------------------------------

@router.get("/feature-comments", response_model=list[FeatureCommentOut])
def list_feature_comments(request: Request, db: Session = Depends(get_db)) -> list[DiscussionComment]:
    """Every live comment left on a keyed thread, oldest first; the running list's `item:*`
    threads only for a caller who may see /plan."""
    query = select(DiscussionComment).where(
        DiscussionComment.feature_key.isnot(None), DiscussionComment.deleted_at.is_(None))
    if not _sees_items(request):
        query = query.where(DiscussionComment.feature_key.notlike(ITEM_PREFIX + "%"))
    return list(db.scalars(query.order_by(DiscussionComment.created_at, DiscussionComment.id)))


@router.post("/feature-comments", response_model=FeatureCommentOut, status_code=201)
def add_feature_comment(
    payload: FeatureCommentIn, request: Request, db: Session = Depends(get_db)
) -> DiscussionComment:
    if payload.feature_key.startswith(ITEM_PREFIX) and not _sees_items(request):
        raise _not_found()
    subject_type, subject_id = disc.subject_of_legacy(None, payload.feature_key)
    comment = disc.add_comment(db, subject_type=subject_type, subject_id=subject_id,
                               actor=identity.actor(request), body=payload.body)
    db.commit()
    db.refresh(comment)
    return comment


@router.post("/feature-comments/{comment_id}", response_model=FeatureCommentOut)
def edit_feature_comment(
    comment_id: int, payload: FeatureCommentEdit, request: Request, db: Session = Depends(get_db)
) -> DiscussionComment:
    """Rewrite the body of a comment: one's own under client.participate, anyone's under
    discussion.curate. The author is never editable and `edited_at` is stamped, so an edited
    comment says so."""
    comment = _visible_comment(db, request, comment_id)
    if comment.feature_key is None:
        raise _not_found()  # a card's comment is not a keyed thread's; the legacy route keeps its shape
    if not _may_manage(request, comment.author_id):
        raise _forbidden("You can edit your own comments only.")
    disc.edit_comment(db, comment, body=payload.body)
    db.commit()
    db.refresh(comment)
    return comment


# --- the feature ranking ----------------------------------------------------------------------

@router.get("/feature-priorities", response_model=list[FeaturePriorityOut])
def list_feature_priorities(db: Session = Depends(get_db)) -> list[FeaturePriority]:
    """The client's current demo-scope ranking, one row per feature."""
    return list(db.scalars(select(FeaturePriority).order_by(FeaturePriority.feature_key)))


@router.post("/feature-priorities", response_model=FeaturePriorityOut)
def set_feature_priority(
    payload: FeaturePriorityIn, request: Request, db: Session = Depends(get_db)
) -> FeaturePriority:
    """Upsert: the latest choice per feature wins; the client can change his mind.

    ON CONFLICT so two simultaneous first choices for one feature (a fast
    double-tap, or two viewers) cannot race a SELECT-then-INSERT into a 500.
    """
    who = identity.actor(request)
    stmt = (
        pg_insert(FeaturePriority)
        .values(
            feature_key=payload.feature_key,
            priority=payload.priority,
            author=who.display_name,
            author_id=who.id,
        )
        .on_conflict_do_update(
            index_elements=[FeaturePriority.feature_key],
            set_={"priority": payload.priority, "author": who.display_name, "author_id": who.id,
                  "updated_at": func.now()},
        )
    )
    db.execute(stmt)
    db.commit()
    row = db.scalar(
        select(FeaturePriority).where(FeaturePriority.feature_key == payload.feature_key)
    )
    assert row is not None
    return row


# --- the decision cards -----------------------------------------------------------------------

@router.get("", response_model=list[DiscussionItemOut])
def list_items(db: Session = Depends(get_db)) -> list[DiscussionItem]:
    items = list(
        db.scalars(
            select(DiscussionItem)
            .options(selectinload(DiscussionItem.comments))
            .order_by(
                DiscussionItem.resolved, DiscussionItem.sort_order, DiscussionItem.id
            )
        )
    )
    for item in items:
        item.comments = [c for c in item.comments if c.deleted_at is None]
    return items


@router.post("", response_model=DiscussionItemOut, status_code=201)
def add_item(payload: DiscussionItemIn, db: Session = Depends(get_db)) -> DiscussionItem:
    title = payload.title.strip()
    if not title:
        raise HTTPException(status_code=422, detail={"error_code": "TITLE_REQUIRED"})
    item = DiscussionItem(title=title, note=(payload.note or "").strip() or None)
    db.add(item)
    db.commit()
    return item


@router.post("/{item_id}", response_model=DiscussionItemOut)
def update_item(
    item_id: int, payload: DiscussionItemIn, db: Session = Depends(get_db)
) -> DiscussionItem:
    """Reword an item. The list must track reality, and reality moves."""
    item = db.get(DiscussionItem, item_id)
    if item is None:
        raise HTTPException(status_code=404, detail={"error_code": "ITEM_NOT_FOUND"})
    title = payload.title.strip()
    if not title:
        raise HTTPException(status_code=422, detail={"error_code": "TITLE_REQUIRED"})
    item.title = title
    item.note = (payload.note or "").strip() or None
    if payload.theme is not None:
        item.theme = payload.theme.strip() or None
    if payload.needs_decision is not None:
        item.needs_decision = payload.needs_decision
    if payload.sort_order is not None:
        item.sort_order = payload.sort_order
    db.commit()
    return item


@router.post("/{item_id}/resolved", response_model=DiscussionItemOut)
def set_resolved(item_id: int, resolved: bool, request: Request, db: Session = Depends(get_db)) -> DiscussionItem:
    """Resolving a card is a DECISION: the people in its thread hear it as news."""
    item = db.get(DiscussionItem, item_id)
    if item is None:
        raise HTTPException(status_code=404, detail={"error_code": "ITEM_NOT_FOUND"})
    was = item.resolved
    item.resolved = resolved
    if resolved and not was:
        disc.emit_decision(db, subject_type="decision", subject_id=str(item.id), actor=identity.actor(request),
                           body=f"Resolved: {item.title}", label=item.title)
    db.commit()
    return item


@router.post("/{item_id}/comments", response_model=DiscussionCommentOut, status_code=201)
def add_item_comment(
    item_id: int, payload: DiscussionCommentIn, request: Request, db: Session = Depends(get_db)
) -> DiscussionComment:
    """The client's side of the conversation on a card, under the signed-in account's name."""
    item = db.get(DiscussionItem, item_id)
    if item is None:
        raise HTTPException(status_code=404, detail={"error_code": "ITEM_NOT_FOUND"})
    body = payload.body.strip()[:2000]
    if not body:
        raise HTTPException(status_code=422, detail={"error_code": "BODY_REQUIRED"})
    comment = disc.add_comment(db, subject_type="decision", subject_id=str(item.id),
                               actor=identity.actor(request), body=body, label=item.title)
    db.commit()
    db.refresh(comment)
    return comment
