"""Caddie's own routes — the spine (§5.4).

Authorization has two separate questions and they are never interchangeable:
  * PROJECT access — the kit instance decides who may see a project. An
    invisible project is 404, never 403 (a 403 would confirm the id exists).
  * APP capability — `projects.compose` / `dictionary.curate` gate the agency
    surfaces.

The tool-facing router (S2S, HMAC-signed) is deliberately absent: it arrives
with caddie M2 and the contract, and it must never be session-authed.
"""

from fastapi import APIRouter, Body, Depends, File, HTTPException, UploadFile
from fastapi.responses import FileResponse

from app import accounts
from app import bw_accounts as bwa
from app.db import get_session_factory
from app.identity import optional_user
from app.models import (
    Comment, FieldDefinition, Project, ProjectField, ReferenceSection, Stage, Thread,
)
from app.services import activity as activity_svc
from app.services import fields as fields_svc
from app.config import get_settings
from app.services import attachments as att
from app.services import interaction as ix
from app.services import projects as pj

router = APIRouter(prefix="/api", tags=["caddie"])

_STATUS = {"FORBIDDEN": 403, "NOT_FOUND": 404, "EXISTS": 409, "BAD_INPUT": 400,
           "NO_APPROVAL": 409, "NOT_ACTIVE": 409, "NO_SUCH_INSTANCE": 404,
           "UNSUPPORTED_TYPE": 400, "TOO_LARGE": 413, "CONTENT_MISMATCH": 400,
           "SUBJECT_FULL": 409, "NO_SUCH_ATTACHMENT": 404, "NO_SUCH_SUBJECT": 404}


def _raise(exc):
    code = getattr(exc, "code", "BAD_INPUT")
    raise HTTPException(status_code=_STATUS.get(code, 400),
                        detail={"error_code": code, "summary": str(exc)})


def _require(ok: bool, code: str, summary: str):
    if not ok:
        raise HTTPException(status_code=_STATUS.get(code, 400),
                            detail={"error_code": code, "summary": summary})


def _is_agency(user: str) -> bool:
    """Agency = anyone who may compose. Clients never can."""
    return accounts.app_can(user, accounts.PERM_COMPOSE)


def _visible_project(session, user: str, project_id: str) -> Project:
    """A project the caller may see, else 404 — never 403."""
    row = session.get(Project, project_id)
    if row is None or not accounts.has_instance_access(user, project_id):
        raise HTTPException(status_code=404,
                            detail={"error_code": "NOT_FOUND",
                                    "summary": "No such project."})
    return row


# ─────────────────────────────────────────────────────────── client-facing

@router.get("/projects")
def list_projects(user: str | None = Depends(optional_user)) -> dict:
    u = user or ""
    agency = _is_agency(u)
    with get_session_factory()() as session:
        rows = [r for r in session.query(Project).order_by(Project.created_at.desc())
                if accounts.has_instance_access(u, r.id)]
        out = []
        for p in rows:
            stages = pj.stages_of(session, p.id)
            visible = stages if agency else [s for s in stages if s.client_visible]
            out.append({
                "id": p.id, "display_name": p.display_name, "state": p.state,
                "active_stages": [s.title for s in visible if s.status == "active"],
                "done": sum(1 for s in visible if s.status == "done"),
                "total": len([s for s in visible if s.status != "skipped"]),
            })
        return {"projects": out, "is_agency": agency}


@router.get("/projects/{project_id}")
def get_project(project_id: str, user: str | None = Depends(optional_user)) -> dict:
    u = user or ""
    with get_session_factory()() as session:
        p = _visible_project(session, u, project_id)
        agency = _is_agency(u)
        data = pj.render_project(session, p, for_client=not agency, username=u)
        data["is_agency"] = agency
        return data


@router.post("/projects/{project_id}/stages/{stage_id}/approve")
def approve_stage(project_id: str, stage_id: str,
                  user: str | None = Depends(optional_user),
                  body: dict = Body(default={})) -> dict:
    u = user or ""
    with get_session_factory()() as session:
        _visible_project(session, u, project_id)
        stage = session.get(Stage, stage_id)
        if stage is None or stage.project_id != project_id:
            _raise(pj.ProjectError("No such stage.", "NOT_FOUND"))
        m = bwa.member(u) or {}
        display = m.get("display_name") or u
        try:
            pj.approve(session, u, display or u, stage, (body.get("note") or "").strip())
        except pj.ProjectError as exc:
            _raise(exc)
        # the agency is who was waiting on this; an approval is a DECISION
        p = session.get(Project, project_id)
        ix.notify_stage(session, project_id=project_id, stage_id=stage.id,
                        title=_crumb(p, stage), actor=u, verb="approval.given",
                        recipients=ix.agency_users())
        session.commit()
        p = session.get(Project, project_id)
        return {"success": True,
                "project": pj.render_project(session, p, for_client=not _is_agency(u), username=u)}


@router.post("/projects/{project_id}/seen")
def mark_seen(project_id: str, user: str | None = Depends(optional_user)) -> dict:
    u = user or ""
    with get_session_factory()() as session:
        _visible_project(session, u, project_id)
        pj.mark_seen(session, project_id, u)
        session.commit()
        return {"success": True}


@router.get("/projects/{project_id}/activity")
def project_activity(project_id: str, user: str | None = Depends(optional_user)) -> dict:
    u = user or ""
    with get_session_factory()() as session:
        _visible_project(session, u, project_id)
        rows = activity_svc.feed(session, project_id, for_client=not _is_agency(u))
        return {"activity": [
            {"at": a.at.isoformat(), "verb": a.verb, "actor": a.actor,
             "actor_type": a.actor_type, "stage_id": a.stage_id, "payload": a.payload}
            for a in rows]}


# ────────────────────────────────────────────────────────────────── admin

@router.post("/projects", status_code=201)
def create_project(user: str | None = Depends(optional_user),
                   body: dict = Body(...)) -> dict:
    u = user or ""
    _require(_is_agency(u), "FORBIDDEN", "Creating projects needs the compose permission.")
    with get_session_factory()() as session:
        try:
            p = pj.create(session, u,
                          display_name=(body.get("display_name") or ""),
                          template_key=(body.get("template_key") or ""),
                          client_name_hint=(body.get("client_name_hint") or ""),
                          slug=(body.get("slug") or ""))
        except (pj.ProjectError, bwa.AccountsError) as exc:
            _raise(exc)
        session.commit()
        return {"success": True, "id": p.id,
                "project": pj.render_project(session, p, for_client=False, username=u)}


@router.post("/projects/{project_id}/stages", status_code=201)
def create_stage(project_id: str, user: str | None = Depends(optional_user),
                 body: dict = Body(...)) -> dict:
    u = user or ""
    _require(_is_agency(u), "FORBIDDEN", "Composing stages needs the compose permission.")
    with get_session_factory()() as session:
        _visible_project(session, u, project_id)
        try:
            st = pj.add_stage(session, u, project_id,
                              title=(body.get("title") or ""),
                              body_md=(body.get("body_md") or ""),
                              requires_approval=bool(body.get("requires_approval")),
                              client_visible=bool(body.get("client_visible", True)))
        except pj.ProjectError as exc:
            _raise(exc)
        session.commit()
        return {"success": True, "id": st.id}


@router.post("/projects/{project_id}/stages/{stage_id}/status")
def stage_status(project_id: str, stage_id: str,
                 user: str | None = Depends(optional_user),
                 body: dict = Body(...)) -> dict:
    """start | close | skip | reopen — ONE stage, never a cursor. Any number of
    stages may be active at once and each closes on its own (D13)."""
    u = user or ""
    _require(_is_agency(u), "FORBIDDEN", "Moving a stage needs the compose permission.")
    with get_session_factory()() as session:
        _visible_project(session, u, project_id)
        stage = session.get(Stage, stage_id)
        if stage is None or stage.project_id != project_id:
            _raise(pj.ProjectError("No such stage.", "NOT_FOUND"))
        try:
            pj.set_stage_status(session, u, stage, (body.get("status") or ""))
        except pj.ProjectError as exc:
            _raise(exc)
        # a stage becoming active is news the client wants; closing one is too.
        # Nothing else rings — a plan being reshuffled is not an event.
        if stage.status in ("active", "done") and stage.client_visible:
            p = session.get(Project, project_id)
            ix.notify_stage(session, project_id=project_id, stage_id=stage.id,
                            title=_crumb(p, stage), actor=u,
                            verb=f"stage.{stage.status}",
                            recipients=ix.client_users_of(project_id))
        session.commit()
        return {"success": True, "status": stage.status}


# ───────────────────────────────────────────────── the field dictionary

@router.get("/admin/field-definitions")
def search_fields(q: str = "", user: str | None = Depends(optional_user)) -> dict:
    """The route that makes reuse the default: every hit carries its usage
    count, and results are ordered by usage."""
    u = user or ""
    _require(_is_agency(u), "FORBIDDEN", "The dictionary is an agency surface.")
    with get_session_factory()() as session:
        # The type vocabulary rides along: a picker that hardcodes its own list
        # drifts silently from what create() accepts.
        return {"definitions": fields_svc.search(session, q),
                "types": sorted(fields_svc.FIELD_TYPES)}


@router.post("/admin/field-definitions", status_code=201)
def create_field(user: str | None = Depends(optional_user),
                 body: dict = Body(...)) -> dict:
    """Creating is the SECONDARY action. The response carries near-matches so
    the UI can warn before a near-duplicate lands."""
    u = user or ""
    _require(accounts.app_can(u, accounts.PERM_CURATE) or accounts.is_owner(u),
             "FORBIDDEN", "Creating a field definition needs the curate permission.")
    label = (body.get("label") or "").strip()
    with get_session_factory()() as session:
        near = fields_svc.near_matches(session, label)
        if near and not body.get("confirm"):
            return {"success": False, "needs_confirm": True, "near_matches": near}
        try:
            d = fields_svc.create(session, u, label=label,
                                  type=(body.get("type") or "text"),
                                  options=body.get("options"),
                                  help=(body.get("help") or ""),
                                  default_client_visible=bool(body.get("default_client_visible")))
        except fields_svc.FieldError as exc:
            _raise(exc)
        session.commit()
        return {"success": True, "key": d.key, "label": d.label, "type": d.type}


@router.post("/admin/field-definitions/{key}/merge")
def merge_fields(key: str, user: str | None = Depends(optional_user),
                 body: dict = Body(...)) -> dict:
    """Owner-only. The release valve: values repoint onto the survivor, the
    loser is archived with `merged_into` set, nothing is lost."""
    u = user or ""
    _require(accounts.is_owner(u), "FORBIDDEN", "Merging definitions is owner-only.")
    with get_session_factory()() as session:
        try:
            res = fields_svc.merge(session, u, loser_key=key,
                                   winner_key=(body.get("into") or ""))
        except fields_svc.FieldError as exc:
            _raise(exc)
        session.commit()
        return {"success": True, **res}


@router.put("/projects/{project_id}/fields/{key}")
def set_field(project_id: str, key: str, user: str | None = Depends(optional_user),
              body: dict = Body(...)) -> dict:
    u = user or ""
    _require(_is_agency(u), "FORBIDDEN", "Project context is an agency surface.")
    with get_session_factory()() as session:
        _visible_project(session, u, project_id)
        defn = session.get(FieldDefinition, key)
        if defn is None or defn.archived:
            _raise(fields_svc.FieldError("No such field definition.", "NOT_FOUND"))
        row = session.query(ProjectField).filter_by(
            project_id=project_id, definition_key=key).one_or_none()
        visible = body.get("client_visible")
        if row is None:
            row = ProjectField(project_id=project_id, definition_key=key,
                               client_visible=(defn.default_client_visible
                                               if visible is None else bool(visible)))
            session.add(row)
        elif visible is not None:
            row.client_visible = bool(visible)
        row.value = (body.get("value") or "").strip()
        row.updated_by = u
        session.flush()
        activity_svc.record(session, project_id, "field.set", actor=u,
                            payload={"key": key}, client_visible=row.client_visible)
        session.commit()
        return {"success": True}


@router.delete("/projects/{project_id}/fields/{key}")
def clear_field(project_id: str, key: str, user: str | None = Depends(optional_user)) -> dict:
    u = user or ""
    _require(_is_agency(u), "FORBIDDEN", "Project context is an agency surface.")
    with get_session_factory()() as session:
        _visible_project(session, u, project_id)
        row = session.query(ProjectField).filter_by(
            project_id=project_id, definition_key=key).one_or_none()
        if row is not None:
            session.delete(row)
            session.commit()
        return {"success": True}


# ────────────────────────────────────────────────────────────── reference

@router.post("/projects/{project_id}/reference", status_code=201)
def add_reference(project_id: str, user: str | None = Depends(optional_user),
                  body: dict = Body(...)) -> dict:
    """`client_visible` defaults FALSE — this is exactly where a staging
    password gets pasted at 11pm."""
    u = user or ""
    _require(_is_agency(u), "FORBIDDEN", "Reference sections are an agency surface.")
    with get_session_factory()() as session:
        _visible_project(session, u, project_id)
        n = session.query(ReferenceSection).filter_by(project_id=project_id).count()
        row = ReferenceSection(project_id=project_id,
                               title=(body.get("title") or "Reference").strip(),
                               body_md=(body.get("body_md") or ""),
                               links=body.get("links"),
                               client_visible=bool(body.get("client_visible", False)),
                               position=n, updated_by=u)
        session.add(row)
        session.commit()
        return {"success": True, "id": row.id, "warnings": _secret_lint(row.body_md)}


@router.put("/projects/{project_id}/reference/{ref_id}")
def edit_reference(project_id: str, ref_id: str, user: str | None = Depends(optional_user),
                   body: dict = Body(...)) -> dict:
    """Edit in place. Visibility is part of the edit, because the common case
    is writing it internal first and publishing it once it's right."""
    u = user or ""
    _require(_is_agency(u), "FORBIDDEN", "Reference sections are an agency surface.")
    with get_session_factory()() as session:
        _visible_project(session, u, project_id)
        row = session.get(ReferenceSection, ref_id)
        if row is None or row.project_id != project_id:
            _raise(pj.ProjectError("No such reference section.", "NOT_FOUND"))
        if "title" in body:
            row.title = (body.get("title") or "Reference").strip()
        if "body_md" in body:
            row.body_md = body.get("body_md") or ""
        if "client_visible" in body:
            row.client_visible = bool(body.get("client_visible"))
        row.updated_by = u
        session.commit()
        return {"success": True, "warnings": _secret_lint(row.body_md)}


@router.delete("/projects/{project_id}/reference/{ref_id}")
def delete_reference(project_id: str, ref_id: str,
                     user: str | None = Depends(optional_user)) -> dict:
    u = user or ""
    _require(_is_agency(u), "FORBIDDEN", "Reference sections are an agency surface.")
    with get_session_factory()() as session:
        _visible_project(session, u, project_id)
        row = session.get(ReferenceSection, ref_id)
        if row is None or row.project_id != project_id:
            _raise(pj.ProjectError("No such reference section.", "NOT_FOUND"))
        session.delete(row)
        session.commit()
        return {"success": True}


def _secret_lint(text: str) -> list[str]:
    """Warn, never block. A human pasting a password into a reference section
    is a mistake to catch, not an action to forbid."""
    import re
    hits = []
    if re.search(r"(?i)\b(password|passwd|pwd|api[_ -]?key|secret|token)\b\s*[:=]", text or ""):
        hits.append("This looks like it contains a credential — "
                    "reference sections are not a password store.")
    return hits


# ─────────────────────────────── the Interaction Standard: comments + bell
# Subjects (04 §4): the PROJECT itself and each visible STAGE. Labels are
# breadcrumbs in caddie's own nouns — "{project} › {stage}" — never a type word.

def _crumb(project: Project, stage: Stage | None = None) -> str:
    return f"{project.display_name} › {stage.title}" if stage else project.display_name


def _visible_stage(session, user: str, project_id: str, stage_id: str) -> Stage:
    stage = session.get(Stage, stage_id)
    if stage is None or stage.project_id != project_id:
        _raise(pj.ProjectError("No such stage.", "NOT_FOUND"))
    if not _is_agency(user) and not stage.client_visible:
        _raise(pj.ProjectError("No such stage.", "NOT_FOUND"))
    return stage


def _subject(session, user: str, project_id: str, subject_type: str,
             subject_id: str) -> dict:
    """A subject the caller may see (else 404 — never 403), with its label."""
    p = _visible_project(session, user, project_id)
    if subject_type == "project":
        if subject_id != project_id:
            _raise(pj.ProjectError("No such place.", "NO_SUCH_SUBJECT"))
        return {"subject_type": "project", "subject_id": project_id,
                "label": _crumb(p), "category": "project"}
    if subject_type == "stage":
        st = _visible_stage(session, user, project_id, subject_id)
        return {"subject_type": "stage", "subject_id": st.id,
                "label": _crumb(p, st), "category": "stage"}
    _raise(pj.ProjectError("No such place.", "NO_SUCH_SUBJECT"))


def _visible_thread(session, user: str, thread_id: str) -> tuple[Thread, dict]:
    t = session.get(Thread, thread_id)
    if t is None or not t.project_id:
        _raise(pj.ProjectError("No such conversation.", "NOT_FOUND"))
    try:
        subj = _subject(session, user, t.project_id, t.subject_type, t.subject_id)
    except HTTPException:
        _raise(pj.ProjectError("No such conversation.", "NOT_FOUND"))
    return t, subj


def _may_manage(user: str, t: Thread, session) -> bool:
    """Resolve / move: the agency, or whoever opened the conversation."""
    if _is_agency(user):
        return True
    first = ix.live_comments(session, t.id)
    return bool(first) and first[0].author == user


@router.get("/projects/{project_id}/discussion")
def project_discussion(project_id: str, user: str | None = Depends(optional_user)) -> dict:
    """ONE read for the whole work surface (04 §4): the project as a subject,
    then every stage the caller may see, each with its conversation or null."""
    u = user or ""
    with get_session_factory()() as session:
        p = _visible_project(session, u, project_id)
        agency = _is_agency(u)
        subjects = [{"subject_type": "project", "subject_id": p.id,
                     "label": _crumb(p), "category": "project", "title": p.display_name}]
        for st in pj.stages_of(session, p.id):
            if not agency and (not st.client_visible or st.status == "skipped"):
                continue
            subjects.append({"subject_type": "stage", "subject_id": st.id,
                             "label": _crumb(p, st), "category": "stage",
                             "title": st.title})
        out = ix.discussion_of(session, subjects)
        out["me"] = u
        out["is_agency"] = agency
        return out


@router.get("/projects/{project_id}/stages/{stage_id}/comments")
def stage_comments(project_id: str, stage_id: str,
                   user: str | None = Depends(optional_user)) -> dict:
    u = user or ""
    with get_session_factory()() as session:
        _visible_project(session, u, project_id)
        _visible_stage(session, u, project_id, stage_id)
        rows = ix.comments_of(session, subject_type="stage", subject_id=stage_id)
        return {"comments": [ix.comment_out(c) for c in rows]}


def _post_comment(session, u: str, subj: dict, project_id: str, body: dict) -> dict:
    text = (body.get("body") or "").strip()
    if not text:
        _raise(pj.ProjectError("Say something first.", "BAD_INPUT"))
    c = ix.add_comment(session, project_id=project_id,
                       subject_type=subj["subject_type"], subject_id=subj["subject_id"],
                       context_label=subj["label"], category=subj["category"],
                       author=u, author_kind=("agency" if _is_agency(u) else "client"),
                       body=text)
    session.commit()
    return {"success": True, "id": c.id, "thread_id": c.thread_id}


@router.post("/projects/{project_id}/stages/{stage_id}/comments", status_code=201)
def add_stage_comment(project_id: str, stage_id: str,
                      user: str | None = Depends(optional_user),
                      body: dict = Body(...)) -> dict:
    u = user or ""
    with get_session_factory()() as session:
        subj = _subject(session, u, project_id, "stage", stage_id)
        return _post_comment(session, u, subj, project_id, body)


@router.post("/projects/{project_id}/comments", status_code=201)
def add_project_comment(project_id: str, user: str | None = Depends(optional_user),
                        body: dict = Body(...)) -> dict:
    """The project itself is a subject: the place for anything not about one
    stage (04 §4 — anything a person can look at is a subject)."""
    u = user or ""
    with get_session_factory()() as session:
        subj = _subject(session, u, project_id, "project", project_id)
        return _post_comment(session, u, subj, project_id, body)


@router.post("/threads/{thread_id}/resolve")
def resolve_thread(thread_id: str, user: str | None = Depends(optional_user),
                   body: dict = Body(...)) -> dict:
    u = user or ""
    with get_session_factory()() as session:
        t, subj = _visible_thread(session, u, thread_id)
        _require(_may_manage(u, t, session), "FORBIDDEN",
                 "Only the conversation's author or the agency can resolve it.")
        ix.resolve_thread(session, t, actor=u, resolved=bool(body.get("resolved", True)),
                          project_id=t.project_id, context_label=subj["label"],
                          category=subj["category"],
                          actor_kind=("agency" if _is_agency(u) else "client"))
        session.commit()
        return {"success": True, "resolved": t.resolved}


@router.post("/threads/{thread_id}/move")
def move_thread(thread_id: str, user: str | None = Depends(optional_user),
                body: dict = Body(...)) -> dict:
    """Re-file a conversation under another subject of the same project. The
    target must be a place the caller can see; an unknown one is 404."""
    u = user or ""
    with get_session_factory()() as session:
        t, _ = _visible_thread(session, u, thread_id)
        _require(_may_manage(u, t, session), "FORBIDDEN",
                 "Only the conversation's author or the agency can move it.")
        target = _subject(session, u, t.project_id,
                          str(body.get("subject_type") or ""), str(body.get("subject_id") or ""))
        moved = ix.move_thread(session, t, subject_type=target["subject_type"],
                               subject_id=target["subject_id"])
        session.commit()
        return {"success": True, "thread_id": moved.id,
                "subject_type": moved.subject_type, "subject_id": moved.subject_id}


def _own_comment(session, user: str, comment_id: str) -> tuple[Comment, Thread, dict]:
    c = session.get(Comment, comment_id)
    if c is None or c.deleted_at is not None:
        _raise(pj.ProjectError("No such comment.", "NOT_FOUND"))
    t, subj = _visible_thread(session, user, c.thread_id)
    return c, t, subj


@router.put("/comments/{comment_id}")
def edit_comment(comment_id: str, user: str | None = Depends(optional_user),
                 body: dict = Body(...)) -> dict:
    u = user or ""
    with get_session_factory()() as session:
        c, t, subj = _own_comment(session, u, comment_id)
        _require(c.author == u, "FORBIDDEN", "Only the author can edit a comment.")
        try:
            ix.edit_comment(session, c, project_id=t.project_id, context_label=subj["label"],
                            category=subj["category"], body=(body.get("body") or ""))
        except ValueError:
            _raise(pj.ProjectError("Say something first.", "BAD_INPUT"))
        session.commit()
        return {"success": True, "comment": ix.comment_out(c)}


@router.delete("/comments/{comment_id}")
def delete_comment(comment_id: str, user: str | None = Depends(optional_user)) -> dict:
    u = user or ""
    with get_session_factory()() as session:
        c, _, _ = _own_comment(session, u, comment_id)
        _require(c.author == u or _is_agency(u), "FORBIDDEN",
                 "Only the author or the agency can remove a comment.")
        ix.delete_comment(session, c)
        session.commit()
        return {"success": True}


@router.get("/notifications")
def notifications(user: str | None = Depends(optional_user)) -> dict:
    """The ONE bell. Local-only today; the same shape the inbox service will
    serve, so the joint gate repoints rather than migrates."""
    u = user or ""
    with get_session_factory()() as session:
        return ix.inbox(session, u)


@router.post("/notifications/read")
def read_notifications(user: str | None = Depends(optional_user),
                       body: dict = Body(default={})) -> dict:
    u = user or ""
    with get_session_factory()() as session:
        n = ix.mark_read(session, u, body.get("ids"))
        session.commit()
        return {"success": True, "read": n}


# ─────────────────────────────────────── attachments (04 addendum)
# Access is NOT per-file: every read re-checks the OWNING SUBJECT's visibility
# (a stage, or the project itself), so permissions can never drift from the
# project's own model. The subject id is the folder; a file's URL is never a
# capability.

def _subject_for_attachment(session, user: str, subject_id: str) -> None:
    stage = session.get(Stage, subject_id)
    if stage is not None:
        _visible_project(session, user, stage.project_id)
        _visible_stage(session, user, stage.project_id, subject_id)
        return
    if session.get(Project, subject_id) is not None:
        _visible_project(session, user, subject_id)
        return
    raise HTTPException(status_code=404,
                        detail={"error_code": "NOT_FOUND", "summary": "No such place."})


@router.post("/attachments/{subject_id}", status_code=201)
def upload_attachment(subject_id: str, user: str | None = Depends(optional_user),
                      file: UploadFile = File(...)) -> dict:
    """One file for this subject's conversation. The response's `markdown` is
    what the composer appends on Send, so a comment carries its files."""
    u = user or ""
    with get_session_factory()() as session:
        _subject_for_attachment(session, u, subject_id)
    data = file.file.read(att.MAX_BYTES + 1)
    try:
        saved = att.save(get_settings().data_dir / "uploads", subject_id,
                         file.filename or "", data)
    except att.AttachmentError as exc:
        _raise(exc)
    return {"success": True, "attachment": saved}


@router.get("/attachments/{subject_id}/{name}")
def get_attachment(subject_id: str, name: str,
                   user: str | None = Depends(optional_user)) -> FileResponse:
    u = user or ""
    with get_session_factory()() as session:
        _subject_for_attachment(session, u, subject_id)
    try:
        path, ctype = att.resolve(get_settings().data_dir / "uploads", subject_id, name)
    except att.AttachmentError as exc:
        _raise(exc)
    return FileResponse(path, media_type=ctype, headers={
        "X-Content-Type-Options": "nosniff",
        "Content-Disposition": f'inline; filename="{name}"',
        "Cache-Control": "private, max-age=3600",
    })
