//! A repository's tickets: the list and its filters, the search over it,
//! and what may be done to a ticket and by whom.
//!
//! Every address here hangs off a repository's own, because a ticket's
//! number names a ticket *of one repository* - that is what keeps the number
//! short enough to be worth having, and what makes a number that is not
//! there a `404` rather than a guess.

use std::sync::Arc;

use actix_multipart::Multipart;
use actix_web::{HttpResponse, get, post, web};
use futures_util::StreamExt as _;
use sea_orm::EntityTrait;
use serde::Deserialize;
use tokio::sync::RwLock;
use tracing::error;
use uuid::Uuid;

use super::readable_repository;
use crate::auth::{CurrentUser, viewer_name};
use crate::entities::repositories::Model as Repository;
use crate::entities::ticket_labels::Model as Label;
use crate::entities::users;
use crate::tickets::{self, store};
use crate::{AppState, attachments, comments, markdown, state_read, views};

/// What the list page is asked to show.
#[derive(Debug, Default, Deserialize)]
struct TicketsQuery {
    /// The state filter: "open", "closed", or absent for open tickets (the
    /// default).
    state: Option<String>,
    /// The labels to filter by, absent when none are selected.
    labels: Option<String>,
    /// The search box, empty when nothing was typed into it.
    #[serde(default)]
    q: String,
}

/// What an open-or-edit-ticket form posts.
#[derive(Debug)]
struct TicketFields {
    summary: String,
    description: String,
    /// The state of the ticket: "open" or "closed".
    state: String,
    /// The labels that were checked, read in the order the boxes are
    /// written.
    labels: Vec<String>,
}

///
/// `serde_urlencoded` rejects repeated fields (like multiple `labels=...`),
/// but HTML checkboxes send repeated field names. This parser collects all
/// values for each field name, handling both single and repeated fields.
fn parse_ticket_form(body: &str) -> Result<TicketFields, String> {
    let mut summary: Option<String> = None;
    let mut description: Option<String> = None;
    let mut state: Option<String> = None;
    let mut labels: Vec<String> = Vec::new();

    for pair in body.split('&') {
        if pair.is_empty() {
            continue;
        }
        let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
        let key = urlencoding_decode(key)?;
        let value = urlencoding_decode(value)?;

        match key.as_str() {
            "summary" => summary = Some(value),
            "description" => description = Some(value),
            "state" => state = Some(value),
            "labels" => labels.push(value),
            _ => {} // Ignore unknown fields
        }
    }

    Ok(TicketFields {
        summary: summary.ok_or_else(|| "missing summary field".to_string())?,
        description: description.unwrap_or_default(),
        state: state.unwrap_or_else(|| "open".to_string()),
        labels,
    })
}

/// Decodes a URL-encoded string, handling `+` as space.
fn urlencoding_decode(s: &str) -> Result<String, String> {
    let mut result = String::new();
    let mut chars = s.bytes();
    while let Some(b) = chars.next() {
        match b {
            b'+' => result.push(' '),
            b'%' => {
                let hi = chars.next().ok_or("incomplete percent encoding")?;
                let lo = chars.next().ok_or("incomplete percent encoding")?;
                let hex = format!("{}{}", hi as char, lo as char);
                let byte = u8::from_str_radix(&hex, 16)
                    .map_err(|_| "invalid percent encoding".to_string())?;
                result.push(byte as char);
            }
            _ => result.push(b as char),
        }
    }
    Ok(result)
}

/// The address of a repository's tickets, which every link and form here
/// hangs off.
fn tickets_address(owner: &users::Model, repository: &Repository) -> String {
    format!("/~{}/{}/tickets", owner.name, repository.slug)
}

/// The answer for a ticket this repository does not have.
///
/// A number that names nothing is answered the same way as a repository
/// that has no such number at all: a visitor who guessed either has learned
/// nothing about which of the two they got wrong.
fn no_such_ticket(number: &str) -> HttpResponse {
    HttpResponse::NotFound().body(format!("This repository has no ticket {number}"))
}

/// The labels a form checked, as names: trimmed, without the blanks an
/// unchecked set of boxes leaves behind, and without the same label twice.
///
/// Whether this repository has them is the caller's to say - that refusal
/// is about the repository, and this is only about what was asked for.
fn checked_fields(fields: &[String]) -> Vec<String> {
    let mut chosen: Vec<String> = Vec::with_capacity(fields.len());
    for field in fields {
        let name = field.trim();
        if name.is_empty() || chosen.iter().any(|taken| taken == name) {
            continue;
        }
        chosen.push(name.to_string());
    }
    chosen
}

/// The rows the names in `chosen` are of this repository, in the order they
/// were asked for.
///
/// The names have already been checked against the repository's own labels
/// by the caller, so one that is not there is a label deleted between the
/// two - left out rather than refused, since there is nothing left to file
/// the ticket under.
async fn label_ids(
    db: &sea_orm::DatabaseConnection,
    repository_id: i64,
    chosen: &[String],
) -> Result<Vec<i64>, sea_orm::DbErr> {
    let mut ids = Vec::with_capacity(chosen.len());
    for name in chosen {
        if let Some(label) = store::find_label(db, repository_id, name).await? {
            ids.push(label.id);
        }
    }
    Ok(ids)
}

/// One ticket form's own half: where it posts, what it says, and what has
/// already been put in it.
///
/// A form that was refused comes back with what was typed rather than
/// empty: retyping a rejected summary is the least useful thing to ask.
struct FormPage {
    action: String,
    heading: String,
    summary: Option<String>,
    description: Option<String>,
    state: String,
    selected: Vec<String>,
    error: Option<String>,
    submit: &'static str,
}

/// A ticket form, filled with this repository's labels as the checkboxes it
/// offers.
fn ticket_form(
    viewer: Option<&CurrentUser>,
    owner: &users::Model,
    repository: &Repository,
    labels: &[Label],
    page: FormPage,
) -> HttpResponse {
    views::ticket_form(
        viewer_name(viewer),
        &owner.name,
        &views::TicketForm {
            action: page.action,
            heading: page.heading,
            slug: repository.slug.clone(),
            visibility: repository.visibility.label(),
            default_branch: repository.default_branch.clone(),
            summary: page.summary,
            description: page.description,
            state: page.state,
            selected: page.selected,
            labels: labels.iter().map(|label| label.name.clone()).collect(),
            error: page.error,
            submit: page.submit,
        },
    )
}

#[get("/~{user_or_team}/{repository_slug}/tickets")]
#[tracing::instrument(
    skip_all,
    fields(
        owner = tracing::field::Empty,
        repository_slug = tracing::field::Empty,
        state = tracing::field::Empty,
        labels = tracing::field::Empty,
        query = tracing::field::Empty,
    )
)]
async fn get_tickets(
    data: web::Data<Arc<RwLock<AppState>>>,
    viewer: Option<CurrentUser>,
    path: web::Path<(String, String)>,
    query: web::Query<TicketsQuery>,
) -> HttpResponse {
    let (owner_name, repository_slug) = path.into_inner();

    // The path and the ask arrive as pieces, so the span's fields are
    // recorded by hand rather than named in the attribute.
    let span = tracing::Span::current();
    span.record("owner", owner_name.as_str());
    span.record("repository_slug", repository_slug.as_str());
    span.record("state", query.state.as_deref().unwrap_or("open"));
    span.record("labels", query.labels.as_deref().unwrap_or("<none>"));
    span.record("query", query.q.as_str());

    let (owner, repository, _repo_root) =
        match readable_repository(&data, viewer.as_ref(), &owner_name, &repository_slug).await {
            Ok(found) => found,
            Err(response) => return *response,
        };

    list_page(
        &data,
        viewer.as_ref(),
        &owner,
        &repository,
        &query,
        None,
        None,
    )
    .await
}

/// The list page: the labels to filter by, the tickets the filter and the
/// search leave, and who opened each of them.
///
/// It is also the page a refused new label comes back to, with the
/// complaint beside the form that made it - labels are made from the list,
/// so saying no to one belongs there too.
async fn list_page(
    data: &web::Data<Arc<RwLock<AppState>>>,
    viewer: Option<&CurrentUser>,
    owner: &users::Model,
    repository: &Repository,
    query: &TicketsQuery,
    label_name: Option<String>,
    label_error: Option<String>,
) -> HttpResponse {
    let db = { state_read(data).await.db.clone() };
    let state = tickets::parse_state(query.state.as_deref());
    // When state is None (empty parameter), show "all" as the filter
    let state_filter = match state {
        Some(s) => tickets::state_string(s),
        None => "all".to_string(),
    };
    let selected = tickets::selected_labels(query.labels.as_deref());

    // The defaults are made before anything is filtered by them, so the
    // first time a repository's tickets page is opened it filters the same
    // way it will the tenth time.
    let labels = match store::labels(&db, repository.id).await {
        Ok(labels) => labels,
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, "listing a repository's labels failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let listed = match store::list_tickets(&db, repository.id, state, &selected, &query.q).await {
        Ok(listed) => listed,
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, "listing a repository's tickets failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let creator_ids: Vec<i64> = listed
        .iter()
        .filter_map(|ticket| ticket.creator_id)
        .collect();
    let remote_actor_ids: Vec<i64> = listed
        .iter()
        .filter_map(|ticket| ticket.remote_actor_id)
        .collect();
    let creators = match store::creator_authors(&db, &creator_ids, &remote_actor_ids).await {
        Ok(creators) => creators,
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, "looking up who opened tickets failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let ticket_ids: Vec<i64> = listed.iter().map(|ticket| ticket.id).collect();
    let filed = match store::labels_by_ticket(&db, &ticket_ids).await {
        Ok(filed) => filed,
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, "reading what tickets are filed under failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let tickets: Vec<views::TicketRow> = listed
        .into_iter()
        .map(|ticket| {
            let creator = ticket
                .creator_id
                .or(ticket.remote_actor_id)
                .and_then(|id| creators.get(&id))
                .cloned()
                .unwrap_or_else(|| comments::CommentAuthor {
                    name: String::from("someone"),
                    actor_url: None,
                    is_remote: false,
                });
            let labels = filed.get(&ticket.id).cloned().unwrap_or_default();
            views::TicketRow {
                number: ticket.number,
                summary: ticket.summary,
                creator,
                labels,
            }
        })
        .collect();

    views::tickets(
        viewer_name(viewer),
        &owner.name,
        &views::TicketsDetail {
            slug: repository.slug.clone(),
            visibility: repository.visibility.label(),
            default_branch: repository.default_branch.clone(),
            labels: labels.iter().map(|label| label.name.clone()).collect(),
            selected,
            state_filter,
            query: query.q.clone(),
            tickets,
            can_open: viewer.is_some(),
            can_label: viewer.is_some_and(|viewer| viewer.id == repository.owner_id),
            label_name,
            label_error,
        },
    )
}

#[get("/~{user_or_team}/{repository_slug}/tickets/new")]
#[tracing::instrument(
    skip_all,
    fields(owner = tracing::field::Empty, repository_slug = tracing::field::Empty)
)]
async fn get_new_ticket(
    data: web::Data<Arc<RwLock<AppState>>>,
    current: CurrentUser,
    path: web::Path<(String, String)>,
) -> HttpResponse {
    let (owner_name, repository_slug) = path.into_inner();

    let span = tracing::Span::current();
    span.record("owner", owner_name.as_str());
    span.record("repository_slug", repository_slug.as_str());

    let (owner, repository, _repo_root) =
        match readable_repository(&data, Some(&current), &owner_name, &repository_slug).await {
            Ok(found) => found,
            Err(response) => return *response,
        };

    let db = { state_read(&data).await.db.clone() };
    let labels = match store::labels(&db, repository.id).await {
        Ok(labels) => labels,
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, "listing a repository's labels failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // What a ticket is filed under when its opener picked nothing is what
    // the form starts checked, so the boxes say what will happen.
    ticket_form(
        Some(&current),
        &owner,
        &repository,
        &labels,
        FormPage {
            action: tickets_address(&owner, &repository),
            heading: "New ticket".to_string(),
            summary: None,
            description: None,
            state: "open".to_string(),
            selected: vec![],
            error: None,
            submit: "Open",
        },
    )
}

#[post("/~{user_or_team}/{repository_slug}/tickets")]
#[tracing::instrument(
    skip_all,
    fields(
        owner = tracing::field::Empty,
        repository_slug = tracing::field::Empty,
        summary = tracing::field::Empty,
    )
)]
async fn post_ticket(
    data: web::Data<Arc<RwLock<AppState>>>,
    current: CurrentUser,
    path: web::Path<(String, String)>,
    mut payload: Multipart,
) -> HttpResponse {
    let (owner_name, repository_slug) = path.into_inner();

    let span = tracing::Span::current();
    span.record("owner", owner_name.as_str());
    span.record("repository_slug", repository_slug.as_str());

    let (owner, repository, _repo_root) =
        match readable_repository(&data, Some(&current), &owner_name, &repository_slug).await {
            Ok(found) => found,
            Err(response) => return *response,
        };

    let db = { state_read(&data).await.db.clone() };
    let labels = match store::labels(&db, repository.id).await {
        Ok(labels) => labels,
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, "listing a repository's labels failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Parse the multipart form to extract fields and attachments
    let mut summary: Option<String> = None;
    let mut description: Option<String> = None;
    let mut state: Option<String> = None;
    let mut selected_labels: Vec<String> = Vec::new();
    let mut attachments: Vec<(String, String, Vec<u8>)> = Vec::new(); // (filename, content_type, data)

    let max_size = crate::max_attachment_size();

    while let Some(Ok(mut field)) = payload.next().await {
        let field_name = field
            .content_disposition()
            .and_then(|cd| cd.get_name().map(|s| s.to_string()));

        match field_name.as_deref() {
            Some("summary") => {
                let mut bytes = Vec::new();
                while let Some(chunk) = field.next().await {
                    match chunk {
                        Ok(data) => bytes.extend_from_slice(&data),
                        Err(e) => {
                            error!(%e, owner = %owner_name, slug = %repository_slug, "reading summary field failed");
                            return HttpResponse::InternalServerError().finish();
                        }
                    }
                }
                summary = Some(String::from_utf8_lossy(&bytes).to_string());
            }
            Some("description") => {
                let mut bytes = Vec::new();
                while let Some(chunk) = field.next().await {
                    match chunk {
                        Ok(data) => bytes.extend_from_slice(&data),
                        Err(e) => {
                            error!(%e, owner = %owner_name, slug = %repository_slug, "reading description field failed");
                            return HttpResponse::InternalServerError().finish();
                        }
                    }
                }
                description = Some(String::from_utf8_lossy(&bytes).to_string());
            }
            Some("state") => {
                let mut bytes = Vec::new();
                while let Some(chunk) = field.next().await {
                    match chunk {
                        Ok(data) => bytes.extend_from_slice(&data),
                        Err(e) => {
                            error!(%e, owner = %owner_name, slug = %repository_slug, "reading state field failed");
                            return HttpResponse::InternalServerError().finish();
                        }
                    }
                }
                state = Some(String::from_utf8_lossy(&bytes).to_string());
            }
            Some("labels") => {
                let mut bytes = Vec::new();
                while let Some(chunk) = field.next().await {
                    match chunk {
                        Ok(data) => bytes.extend_from_slice(&data),
                        Err(e) => {
                            error!(%e, owner = %owner_name, slug = %repository_slug, "reading labels field failed");
                            return HttpResponse::InternalServerError().finish();
                        }
                    }
                }
                selected_labels.push(String::from_utf8_lossy(&bytes).to_string());
            }
            Some("attachment") => {
                let filename = field
                    .content_disposition()
                    .and_then(|cd| cd.get_filename().map(|s| s.to_string()))
                    .unwrap_or_else(|| "attachment".to_string());
                let content_type = field
                    .content_type()
                    .map(|m| m.to_string())
                    .unwrap_or_else(|| "application/octet-stream".to_string());

                let mut bytes = Vec::new();
                while let Some(chunk) = field.next().await {
                    match chunk {
                        Ok(data) => bytes.extend_from_slice(&data),
                        Err(e) => {
                            error!(%e, owner = %owner_name, slug = %repository_slug, "reading attachment chunk failed");
                            return HttpResponse::InternalServerError().finish();
                        }
                    }
                }

                if bytes.len() > max_size {
                    return HttpResponse::BadRequest().body(format!(
                        "Attachment is too large ({} bytes, maximum is {max_size})",
                        bytes.len()
                    ));
                }

                attachments.push((filename, content_type, bytes));
            }
            _ => {
                // Skip unknown fields
            }
        }
    }

    // Validate attachment count
    let max_count = crate::max_attachments_per_object();
    if attachments.len() > max_count {
        return HttpResponse::BadRequest().body(format!(
            "Too many attachments ({} files, maximum is {max_count})",
            attachments.len()
        ));
    }

    let summary = summary.unwrap_or_default();
    let description = description.unwrap_or_default();
    let state = state.unwrap_or_else(|| "open".to_string());

    span.record("summary", summary.trim().chars().count());

    let refused = |page: FormPage, error: String| {
        let mut page = page;
        page.error = Some(error);
        ticket_form(Some(&current), &owner, &repository, &labels, page)
    };
    let blank = FormPage {
        action: tickets_address(&owner, &repository),
        heading: "New ticket".to_string(),
        summary: Some(summary.clone()),
        description: Some(description.clone()),
        state: state.clone(),
        selected: selected_labels.clone(),
        error: None,
        submit: "Open",
    };

    // Everything is checked before anything is written: a ticket that was
    // not opened must not have taken a number either.
    let summary = match tickets::checked_summary(&summary) {
        Ok(summary) => summary.to_string(),
        Err(error) => return refused(blank, error),
    };

    for name in &selected_labels {
        if !labels.iter().any(|label| label.name == *name) {
            return refused(
                blank,
                format!("`{name}` is not a label of {owner_name}/{repository_slug}"),
            );
        }
    }

    let chosen = selected_labels;

    let ids = match label_ids(&db, repository.id, &chosen).await {
        Ok(ids) => ids,
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, "reading the labels of a new ticket failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // The number is taken here rather than first, so that a refusal above
    // leaves the repository counting nothing but tickets that exist.
    let number = match store::take_ticket_number(&db, repository.id).await {
        Ok(number) => number,
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, "numbering a new ticket failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let state = tickets::parse_state(Some(&state)).unwrap_or(tickets::State::Open);

    let description = (!description.is_empty()).then_some(description.as_str());
    let opened = match store::create_ticket(
        &db,
        repository.id,
        current.id,
        number,
        &summary,
        description,
        state,
    )
    .await
    {
        Ok(opened) => opened,
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, number, "opening a ticket failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Persist any attachments that were uploaded with the ticket
    let state_data = state_read(&data).await;
    let file_store = &state_data.file_store;
    for (filename, content_type, data) in attachments {
        if let Err(e) = attachments::store::create(
            &db,
            file_store,
            attachments::store::Owner::Ticket(opened.id),
            filename,
            content_type,
            data,
        )
        .await
        {
            error!(%e, ticket_id = opened.id, "Failed to create ticket attachment");
        }
    }

    // The ticket is there either way: a fault filing it under its labels
    // costs the labels, and answering with an error page would only invite
    // the same ticket to be opened twice.
    if let Err(error) = store::set_ticket_labels(&db, opened.id, &ids).await {
        error!(%error, owner = %owner.name, slug = %repository.slug, number, "labelling a new ticket failed");
    }

    // Emit federation activities if enabled and repo is public
    let state = state_read(&data).await;
    if crate::activitypub::enabled(&state)
        && repository.visibility == crate::entities::repositories::Visibility::Public
    {
        let urls = crate::activitypub::ApUrls::new(&state.public_url());
        drop(state); // Release lock before DB operations

        // Get user and repo actors
        let user_actor = match crate::activitypub::actor::get_or_create(&db, current.id).await {
            Ok(actor) => actor,
            Err(e) => {
                error!(%e, "failed to get user actor for federation");
                return HttpResponse::SeeOther()
                    .append_header((
                        actix_web::http::header::LOCATION,
                        format!("{}/{number}", tickets_address(&owner, &repository)),
                    ))
                    .finish();
            }
        };

        let repo_actor =
            match crate::activitypub::actor::get_or_create_for_repo(&db, repository.id).await {
                Ok(actor) => actor,
                Err(e) => {
                    error!(%e, "failed to get repo actor for federation");
                    return HttpResponse::SeeOther()
                        .append_header((
                            actix_web::http::header::LOCATION,
                            format!("{}/{number}", tickets_address(&owner, &repository)),
                        ))
                        .finish();
                }
            };

        // Fetch the user entity for the ticket document
        let _user_entity = match crate::entities::users::Entity::find_by_id(current.id)
            .one(&db)
            .await
        {
            Ok(Some(user)) => user,
            Ok(None) => {
                error!("user entity not found for current user");
                return HttpResponse::SeeOther()
                    .append_header((
                        actix_web::http::header::LOCATION,
                        format!("{}/{number}", tickets_address(&owner, &repository)),
                    ))
                    .finish();
            }
            Err(e) => {
                error!(%e, "failed to fetch user entity for federation");
                return HttpResponse::SeeOther()
                    .append_header((
                        actix_web::http::header::LOCATION,
                        format!("{}/{number}", tickets_address(&owner, &repository)),
                    ))
                    .finish();
            }
        };

        let user_actor_url = urls.actor(&current.name);
        let repo_actor_url = urls.repo_actor(&owner.name, &repository.slug);
        let ticket_url = urls.repo_ticket(&owner.name, &repository.slug, number);
        let offer_id = urls.activity(&current.name, Uuid::new_v4());

        // Build ticket document without id (per ForgeFed spec §3.7.1)
        let mut ticket_doc = match crate::activitypub::forge::builders::build_ticket_document(
            &opened,
            &owner.name,
            &repository.slug,
            &user_actor_url,
            &urls,
            &db,
        )
        .await
        {
            Ok(doc) => doc,
            Err(e) => {
                error!(%e, "failed to build ticket document");
                return HttpResponse::InternalServerError().finish();
            }
        };
        ticket_doc.id = None;

        // Build Offer activity
        let offer = crate::activitypub::types::Offer {
            context: crate::activitypub::types::FORGEFED_CONTEXT.to_vec(),
            id: offer_id.clone(),
            kind: "Offer",
            actor: user_actor_url.clone(),
            object: serde_json::to_value(&ticket_doc).expect("ticket doc should serialize"),
            target: repo_actor_url.clone(),
            to: vec![repo_actor_url.clone()],
            cc: Some(vec![urls.repo_followers(&owner.name, &repository.slug)]),
            published: chrono::Utc::now().to_rfc3339(),
        };

        let offer_json = match serde_json::to_string(&offer) {
            Ok(json) => json,
            Err(e) => {
                error!(%e, "failed to serialize Offer activity");
                return HttpResponse::SeeOther()
                    .append_header((
                        actix_web::http::header::LOCATION,
                        format!("{}/{number}", tickets_address(&owner, &repository)),
                    ))
                    .finish();
            }
        };

        // Insert Offer into user's activities (no delivery - tracker is local)
        if let Err(e) = crate::activitypub::forge::activity::publish_activity(
            &db,
            user_actor.id,
            &offer_json,
            &ticket_url,
        )
        .await
        {
            error!(%e, "failed to publish Offer activity");
        }

        // Announce Accept from repo actor to followers
        if let Err(e) = crate::activitypub::forge::activity::announce_accept(
            &db,
            repo_actor.id,
            &repo_actor_url,
            &offer_id,
            &ticket_url,
            None,
        )
        .await
        {
            error!(%e, "failed to announce Accept activity");
        }
    }

    HttpResponse::SeeOther()
        .append_header((
            actix_web::http::header::LOCATION,
            format!("{}/{number}", tickets_address(&owner, &repository)),
        ))
        .finish()
}

#[tracing::instrument(
    skip_all,
    fields(
        owner = tracing::field::Empty,
        repository_slug = tracing::field::Empty,
        number = tracing::field::Empty,
    )
)]
pub async fn get_ticket(
    data: web::Data<Arc<RwLock<AppState>>>,
    viewer: Option<CurrentUser>,
    path: web::Path<(String, String, String)>,
) -> HttpResponse {
    let (owner_name, repository_slug, number) = path.into_inner();

    let span = tracing::Span::current();
    span.record("owner", owner_name.as_str());
    span.record("repository_slug", repository_slug.as_str());
    span.record("number", number.as_str());

    let (owner, repository, _repo_root) =
        match readable_repository(&data, viewer.as_ref(), &owner_name, &repository_slug).await {
            Ok(found) => found,
            Err(response) => return *response,
        };

    let Ok(parsed) = number.parse::<i64>() else {
        return no_such_ticket(&number);
    };

    let db = { state_read(&data).await.db.clone() };
    let ticket = match store::find_ticket(&db, repository.id, parsed).await {
        Ok(Some(ticket)) => ticket,
        Ok(None) => return no_such_ticket(&number),
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, number, "looking up a ticket failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let filed = match store::labels_by_ticket(&db, &[ticket.id]).await {
        Ok(filed) => filed.get(&ticket.id).cloned().unwrap_or_default(),
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, number, "reading a ticket's labels failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let creator = if let Some(creator_id) = ticket.creator_id {
        match store::creator_authors(&db, &[creator_id], &[]).await {
            Ok(authors) => {
                authors
                    .get(&creator_id)
                    .cloned()
                    .unwrap_or_else(|| comments::CommentAuthor {
                        name: String::from("someone"),
                        actor_url: None,
                        is_remote: false,
                    })
            }
            Err(error) => {
                error!(%error, owner = %owner.name, slug = %repository.slug, number, "looking up who opened a ticket failed");
                return HttpResponse::InternalServerError().finish();
            }
        }
    } else if let Some(remote_actor_id) = ticket.remote_actor_id {
        match store::creator_authors(&db, &[], &[remote_actor_id]).await {
            Ok(authors) => {
                authors
                    .get(&remote_actor_id)
                    .cloned()
                    .unwrap_or_else(|| comments::CommentAuthor {
                        name: String::from("someone"),
                        actor_url: None,
                        is_remote: true,
                    })
            }
            Err(error) => {
                error!(%error, owner = %owner.name, slug = %repository.slug, number, "looking up remote ticket creator failed");
                return HttpResponse::InternalServerError().finish();
            }
        }
    } else {
        comments::CommentAuthor {
            name: String::from("someone"),
            actor_url: None,
            is_remote: false,
        }
    };

    let may_change = viewer.as_ref().is_some_and(|viewer| {
        tickets::may_change(repository.owner_id, ticket.creator_id, viewer.id)
    });

    let attachments = match store::list_attachments(&db, ticket.id).await {
        Ok(attachments) => attachments
            .into_iter()
            .map(|a| views::AttachmentView {
                uuid: a.uuid.to_string(),
                filename: a.filename,
                size: a.size,
                content_type: a.content_type,
            })
            .collect(),
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, number, "listing a ticket's attachments failed");
            Vec::new()
        }
    };

    let comments = match comments::store::list_for_ticket(&db, ticket.id).await {
        Ok(comments) => {
            let author_ids: Vec<i64> = comments.iter().filter_map(|c| c.author_id).collect();
            let remote_actor_ids: Vec<i64> =
                comments.iter().filter_map(|c| c.remote_actor_id).collect();
            let authors = match comments::store::comment_authors(
                &db,
                &author_ids,
                &remote_actor_ids,
            )
            .await
            {
                Ok(authors) => authors,
                Err(error) => {
                    error!(%error, owner = %owner.name, slug = %repository.slug, number, "looking up comment authors failed");
                    std::collections::HashMap::new()
                }
            };
            let viewer_id = viewer.as_ref().map(|v| v.id);
            let mut comment_views = Vec::new();
            for c in comments {
                // Fetch attachments for this comment
                let comment_attachments = match attachments::store::list_for(
                    &db,
                    &attachments::store::Owner::Comment(c.id),
                )
                .await
                {
                    Ok(atts) => atts
                        .into_iter()
                        .map(|a| views::CommentAttachment {
                            uuid: a.uuid.to_string(),
                            filename: a.filename,
                            content_type: a.content_type,
                        })
                        .collect(),
                    Err(error) => {
                        error!(%error, comment_id = c.id, "failed to fetch comment attachments");
                        Vec::new()
                    }
                };

                // Look up author by either author_id or remote_actor_id
                let author = c
                    .author_id
                    .and_then(|id| authors.get(&id))
                    .or_else(|| c.remote_actor_id.and_then(|id| authors.get(&id)))
                    .cloned()
                    .unwrap_or_else(|| comments::CommentAuthor {
                        name: String::from("someone"),
                        actor_url: None,
                        is_remote: false,
                    });
                comment_views.push(views::CommentView {
                    base_url: "", /* filled later on in
                                   * [`crate::views::comments::CommentListView`] */
                    uuid: c.uuid.to_string(),
                    author,
                    content: c.content.clone(),
                    content_html: markdown::render(&c.content),
                    created_at: c.created_at,
                    updated_at: c.updated_at,
                    may_change: viewer_id.is_some_and(|vid| {
                        comments::may_change(c.author_id, repository.owner_id, vid)
                    }),
                    attachments: comment_attachments,
                });
            }
            comment_views
        }
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, number, "listing a ticket's comments failed");
            Vec::new()
        }
    };

    let may_comment = viewer.is_some();

    views::ticket(
        viewer_name(viewer.as_ref()),
        &owner.name,
        &views::TicketDetail {
            slug: repository.slug.clone(),
            visibility: repository.visibility.label(),
            default_branch: repository.default_branch.clone(),
            number: ticket.number,
            summary: ticket.summary,
            description_html: markdown::render(ticket.description.as_deref().unwrap_or("")),
            creator,
            opened_at: ticket.created_at.format("%Y-%m-%d %H:%M UTC").to_string(),
            state: tickets::state_string(ticket.state),
            labels: filed,
            may_change,
            attachments,
            comments,
            may_comment,
            comment_error: None,
            comment_draft: None,
            attachment_error: None,
        },
    )
}

#[get("/~{user_or_team}/{repository_slug}/tickets/{number}/edit")]
#[tracing::instrument(
    skip_all,
    fields(
        owner = tracing::field::Empty,
        repository_slug = tracing::field::Empty,
        number = tracing::field::Empty,
    )
)]
async fn get_edit_ticket(
    data: web::Data<Arc<RwLock<AppState>>>,
    current: CurrentUser,
    path: web::Path<(String, String, String)>,
) -> HttpResponse {
    let (owner_name, repository_slug, number) = path.into_inner();

    let span = tracing::Span::current();
    span.record("owner", owner_name.as_str());
    span.record("repository_slug", repository_slug.as_str());
    span.record("number", number.as_str());

    let (owner, repository, _repo_root) =
        match readable_repository(&data, Some(&current), &owner_name, &repository_slug).await {
            Ok(found) => found,
            Err(response) => return *response,
        };

    let Ok(parsed) = number.parse::<i64>() else {
        return no_such_ticket(&number);
    };

    let db = { state_read(&data).await.db.clone() };
    let ticket = match store::find_ticket(&db, repository.id, parsed).await {
        Ok(Some(ticket)) => ticket,
        Ok(None) => return no_such_ticket(&number),
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, number, "looking up a ticket failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Somebody who may not change the ticket is not handed the form to
    // change it with either: the form is the way in, not a lesser one.
    if !tickets::may_change(repository.owner_id, ticket.creator_id, current.id) {
        return HttpResponse::Forbidden().finish();
    }

    let labels = match store::labels(&db, repository.id).await {
        Ok(labels) => labels,
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, "listing a repository's labels failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // What comes back checked is what a save without touching the boxes
    // keeps.
    let selected = match store::labels_by_ticket(&db, &[ticket.id]).await {
        Ok(filed) => filed.get(&ticket.id).cloned().unwrap_or_default(),
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, number, "reading a ticket's labels failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    ticket_form(
        Some(&current),
        &owner,
        &repository,
        &labels,
        FormPage {
            action: format!("{}/tickets/{number}", tickets_address(&owner, &repository)),
            heading: format!("Edit #{number}"),
            summary: Some(ticket.summary),
            description: Some(ticket.description.unwrap_or_default()),
            state: tickets::state_string(ticket.state),
            selected,
            error: None,
            submit: "Save",
        },
    )
}

#[tracing::instrument(
    skip_all,
    fields(
        owner = tracing::field::Empty,
        repository_slug = tracing::field::Empty,
        number = tracing::field::Empty,
        summary = tracing::field::Empty,
    )
)]
pub async fn post_edit_ticket(
    data: web::Data<Arc<RwLock<AppState>>>,
    current: CurrentUser,
    path: web::Path<(String, String, String)>,
    body: String,
) -> HttpResponse {
    let (owner_name, repository_slug, number) = path.into_inner();

    let form = match parse_ticket_form(&body) {
        Ok(f) => f,
        Err(e) => return HttpResponse::BadRequest().body(format!("Invalid form data: {e}")),
    };

    let span = tracing::Span::current();
    span.record("owner", owner_name.as_str());
    span.record("repository_slug", repository_slug.as_str());
    span.record("number", number.as_str());
    span.record("summary", form.summary.trim().chars().count());

    let (owner, repository, _repo_root) =
        match readable_repository(&data, Some(&current), &owner_name, &repository_slug).await {
            Ok(found) => found,
            Err(response) => return *response,
        };

    let Ok(parsed) = number.parse::<i64>() else {
        return no_such_ticket(&number);
    };

    let db = { state_read(&data).await.db.clone() };
    let ticket = match store::find_ticket(&db, repository.id, parsed).await {
        Ok(Some(ticket)) => ticket,
        Ok(None) => return no_such_ticket(&number),
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, number, "looking up a ticket failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    if !tickets::may_change(repository.owner_id, ticket.creator_id, current.id) {
        return HttpResponse::Forbidden().finish();
    }

    let labels = match store::labels(&db, repository.id).await {
        Ok(labels) => labels,
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, "listing a repository's labels failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let refused = |page: FormPage, error: String| {
        let mut page = page;
        page.error = Some(error);
        ticket_form(Some(&current), &owner, &repository, &labels, page)
    };
    let blank = FormPage {
        action: format!("{}/tickets/{number}", tickets_address(&owner, &repository)),
        heading: format!("Edit #{number}"),
        summary: Some(form.summary.clone()),
        description: Some(form.description.clone()),
        state: tickets::state_string(ticket.state),
        selected: checked_fields(&form.labels),
        error: None,
        submit: "Save",
    };

    let summary = match tickets::checked_summary(&form.summary) {
        Ok(summary) => summary.to_string(),
        Err(error) => return refused(blank, error),
    };

    let chosen = checked_fields(&form.labels);
    for name in &chosen {
        if !labels.iter().any(|label| &label.name == name) {
            return refused(
                blank,
                format!("`{name}` is not a label of {owner_name}/{repository_slug}"),
            );
        }
    }

    let ids = match label_ids(&db, repository.id, &chosen).await {
        Ok(ids) => ids,
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, number, "reading a ticket's labels failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let state = tickets::parse_state(Some(&form.state)).unwrap_or(tickets::State::Open);
    let description = (!form.description.is_empty()).then_some(form.description.as_str());

    // Store the old values before updating
    let old_state = ticket.state;
    let old_summary = ticket.summary.clone();
    let old_description = ticket.description.clone();
    let old_resolve_url = ticket.resolve_activity_url.clone();

    // Determine what activities to emit and what resolve_activity_url to set
    let state_changed = old_state != state;
    let summary_changed = old_summary != summary;
    let description_changed = old_description.as_deref() != description;

    // Calculate the new resolve_activity_url value
    let new_resolve_url = if state_changed && state == tickets::State::Closed {
        // Open→Closed: will emit Resolve, need to generate and store the URL
        let state_guard = state_read(&data).await;
        let federation_enabled = crate::activitypub::enabled(&state_guard);
        let is_public = repository.visibility.label() == "public";
        drop(state_guard);

        if federation_enabled && is_public {
            let state_guard = state_read(&data).await;
            let urls = crate::activitypub::ApUrls::new(&state_guard.public_url().to_string());
            Some(urls.activity(&owner.name, Uuid::new_v4()))
        } else {
            None
        }
    } else if state_changed && state == tickets::State::Open {
        // Closed→Open: will emit Undo(Resolve), clear the URL
        None
    } else {
        // No state change or same state: keep existing value
        old_resolve_url.clone()
    };

    if let Err(error) = store::update_ticket(
        &db,
        ticket.id,
        &summary,
        description,
        state,
        new_resolve_url.clone(),
    )
    .await
    {
        error!(%error, owner = %owner.name, slug = %repository.slug, number, "editing a ticket failed");
        return HttpResponse::InternalServerError().finish();
    }

    // An edit is the labels the ticket carries now, which is why nothing
    // checked takes them all away rather than leaving the old ones in
    // place.
    if let Err(error) = store::set_ticket_labels(&db, ticket.id, &ids).await {
        error!(%error, owner = %owner.name, slug = %repository.slug, number, "labelling a ticket failed");
    }

    // Emit federation activities
    let state_guard = state_read(&data).await;
    let federation_enabled = crate::activitypub::enabled(&state_guard);
    let is_public = repository.visibility.label() == "public";
    drop(state_guard);

    if federation_enabled && is_public {
        let state_guard = state_read(&data).await;
        let urls = crate::activitypub::ApUrls::new(&state_guard.public_url().to_string());

        let repo_actor =
            match crate::activitypub::actor::get_or_create_for_repo(&db, repository.id).await {
                Ok(actor) => actor,
                Err(e) => {
                    error!(%e, "failed to get repo actor for federation");
                    return HttpResponse::SeeOther()
                        .append_header((
                            actix_web::http::header::LOCATION,
                            format!("{}/{number}", tickets_address(&owner, &repository)),
                        ))
                        .finish();
                }
            };

        let repo_actor_url = urls.repo_actor(&owner.name, &repository.slug);
        let parsed_number: i64 = number.parse().unwrap_or(0);
        let ticket_url = urls.repo_ticket(&owner.name, &repository.slug, parsed_number);

        // Emit Resolve if ticket was closed
        if state_changed && state == tickets::State::Closed {
            if let Some(resolve_url) = &new_resolve_url {
                let resolve = crate::activitypub::types::Resolve {
                    context: crate::activitypub::types::FORGEFED_CONTEXT.to_vec(),
                    id: resolve_url.clone(),
                    kind: "Resolve",
                    actor: repo_actor_url.clone(),
                    object: ticket_url.clone(),
                    to: vec![repo_actor_url.clone()],
                    cc: Some(vec![urls.repo_followers(&owner.name, &repository.slug)]),
                    published: chrono::Utc::now().to_rfc3339(),
                };

                let resolve_json = match serde_json::to_string(&resolve) {
                    Ok(json) => json,
                    Err(e) => {
                        error!(%e, "failed to serialize Resolve activity");
                        return HttpResponse::SeeOther()
                            .append_header((
                                actix_web::http::header::LOCATION,
                                format!("{}/{number}", tickets_address(&owner, &repository)),
                            ))
                            .finish();
                    }
                };

                if let Err(e) = crate::activitypub::forge::activity::publish_activity(
                    &db,
                    repo_actor.id,
                    &resolve_json,
                    &ticket_url,
                )
                .await
                {
                    error!(%e, "failed to publish Resolve activity");
                }
            }
        }

        // Emit Undo(Resolve) if ticket was reopened
        if state_changed && state == tickets::State::Open {
            if let Some(old_resolve_url) = old_resolve_url {
                let resolve_to_undo = crate::activitypub::types::Resolve {
                    context: crate::activitypub::types::FORGEFED_CONTEXT.to_vec(),
                    id: old_resolve_url,
                    kind: "Resolve",
                    actor: repo_actor_url.clone(),
                    object: ticket_url.clone(),
                    to: vec![repo_actor_url.clone()],
                    cc: Some(vec![urls.repo_followers(&owner.name, &repository.slug)]),
                    published: chrono::Utc::now().to_rfc3339(),
                };

                let undo = crate::activitypub::types::Undo {
                    context: crate::activitypub::types::FORGEFED_CONTEXT.to_vec(),
                    id: urls.activity(&owner.name, Uuid::new_v4()),
                    kind: "Undo",
                    actor: repo_actor_url.clone(),
                    object: resolve_to_undo,
                    to: vec![repo_actor_url.clone()],
                    cc: Some(vec![urls.repo_followers(&owner.name, &repository.slug)]),
                    published: chrono::Utc::now().to_rfc3339(),
                };

                let undo_json = match serde_json::to_string(&undo) {
                    Ok(json) => json,
                    Err(e) => {
                        error!(%e, "failed to serialize Undo activity");
                        return HttpResponse::SeeOther()
                            .append_header((
                                actix_web::http::header::LOCATION,
                                format!("{}/{number}", tickets_address(&owner, &repository)),
                            ))
                            .finish();
                    }
                };

                if let Err(e) = crate::activitypub::forge::activity::publish_activity(
                    &db,
                    repo_actor.id,
                    &undo_json,
                    &ticket_url,
                )
                .await
                {
                    error!(%e, "failed to publish Undo activity");
                }
            }
        }

        // Emit Edit if summary or description changed
        if summary_changed || description_changed {
            let edit = crate::activitypub::types::Edit {
                context: crate::activitypub::types::FORGEFED_CONTEXT.to_vec(),
                id: urls.activity(&owner.name, Uuid::new_v4()),
                kind: "Edit",
                actor: repo_actor_url.clone(),
                object: serde_json::json!(ticket_url.clone()),
                to: vec![repo_actor_url.clone()],
                cc: Some(vec![urls.repo_followers(&owner.name, &repository.slug)]),
                published: chrono::Utc::now().to_rfc3339(),
            };

            let edit_json = match serde_json::to_string(&edit) {
                Ok(json) => json,
                Err(e) => {
                    error!(%e, "failed to serialize Edit activity");
                    return HttpResponse::SeeOther()
                        .append_header((
                            actix_web::http::header::LOCATION,
                            format!("{}/{number}", tickets_address(&owner, &repository)),
                        ))
                        .finish();
                }
            };

            if let Err(e) = crate::activitypub::forge::activity::publish_activity(
                &db,
                repo_actor.id,
                &edit_json,
                &ticket_url,
            )
            .await
            {
                error!(%e, "failed to publish Edit activity");
            }
        }
    }

    HttpResponse::SeeOther()
        .append_header((
            actix_web::http::header::LOCATION,
            format!("{}/{number}", tickets_address(&owner, &repository)),
        ))
        .finish()
}

#[post("/~{user_or_team}/{repository_slug}/tickets/{number}/delete")]
#[tracing::instrument(
    skip_all,
    fields(
        owner = tracing::field::Empty,
        repository_slug = tracing::field::Empty,
        number = tracing::field::Empty,
    )
)]
async fn post_delete_ticket(
    data: web::Data<Arc<RwLock<AppState>>>,
    current: CurrentUser,
    path: web::Path<(String, String, String)>,
) -> HttpResponse {
    let (owner_name, repository_slug, number) = path.into_inner();

    let span = tracing::Span::current();
    span.record("owner", owner_name.as_str());
    span.record("repository_slug", repository_slug.as_str());
    span.record("number", number.as_str());

    let (owner, repository, _repo_root) =
        match readable_repository(&data, Some(&current), &owner_name, &repository_slug).await {
            Ok(found) => found,
            Err(response) => return *response,
        };

    let Ok(parsed) = number.parse::<i64>() else {
        return no_such_ticket(&number);
    };

    let db = { state_read(&data).await.db.clone() };
    let ticket = match store::find_ticket(&db, repository.id, parsed).await {
        Ok(Some(ticket)) => ticket,
        Ok(None) => return no_such_ticket(&number),
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, number, "looking up a ticket failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    if !tickets::may_change(repository.owner_id, ticket.creator_id, current.id) {
        return HttpResponse::Forbidden().finish();
    }

    // Emit Delete activity before deletion
    let state_guard = state_read(&data).await;
    let federation_enabled = crate::activitypub::enabled(&state_guard);
    let is_public = repository.visibility.label() == "public";
    drop(state_guard);

    if federation_enabled && is_public {
        let state_guard = state_read(&data).await;
        let urls = crate::activitypub::ApUrls::new(&state_guard.public_url().to_string());

        let repo_actor =
            match crate::activitypub::actor::get_or_create_for_repo(&db, repository.id).await {
                Ok(actor) => actor,
                Err(e) => {
                    error!(%e, "failed to get repo actor for federation");
                    return HttpResponse::SeeOther()
                        .append_header((
                            actix_web::http::header::LOCATION,
                            tickets_address(&owner, &repository),
                        ))
                        .finish();
                }
            };

        let repo_actor_url = urls.repo_actor(&owner.name, &repository.slug);
        let parsed_number: i64 = number.parse().unwrap_or(0);
        let ticket_url = urls.repo_ticket(&owner.name, &repository.slug, parsed_number);

        let delete = crate::activitypub::types::Delete {
            context: crate::activitypub::types::FORGEFED_CONTEXT.to_vec(),
            id: urls.activity(&owner.name, Uuid::new_v4()),
            kind: "Delete",
            actor: repo_actor_url.clone(),
            object: ticket_url.clone(),
            to: vec![repo_actor_url.clone()],
            cc: Some(vec![urls.repo_followers(&owner.name, &repository.slug)]),
            published: chrono::Utc::now().to_rfc3339(),
        };

        let delete_json = match serde_json::to_string(&delete) {
            Ok(json) => json,
            Err(e) => {
                error!(%e, "failed to serialize Delete activity");
                return HttpResponse::SeeOther()
                    .append_header((
                        actix_web::http::header::LOCATION,
                        tickets_address(&owner, &repository),
                    ))
                    .finish();
            }
        };

        if let Err(e) = crate::activitypub::forge::activity::publish_activity(
            &db,
            repo_actor.id,
            &delete_json,
            &ticket_url,
        )
        .await
        {
            error!(%e, "failed to publish Delete activity");
        }
    }

    // The number is not given out again, so this says nothing about the
    // repository's count: a ticket that is gone is still talked about by
    // its number.
    if let Err(error) = store::delete_ticket(&db, ticket.id).await {
        error!(%error, owner = %owner.name, slug = %repository.slug, number, "deleting a ticket failed");
        return HttpResponse::InternalServerError().finish();
    }

    HttpResponse::SeeOther()
        .append_header((
            actix_web::http::header::LOCATION,
            tickets_address(&owner, &repository),
        ))
        .finish()
}

#[post("/~{user_or_team}/{repository_slug}/tickets/{number}/attachments")]
#[tracing::instrument(
    skip_all,
    fields(
        owner = tracing::field::Empty,
        repository_slug = tracing::field::Empty,
        number = tracing::field::Empty,
    )
)]
async fn post_attachment(
    data: web::Data<Arc<RwLock<AppState>>>,
    current: CurrentUser,
    path: web::Path<(String, String, String)>,
    mut payload: Multipart,
) -> HttpResponse {
    let (owner_name, repository_slug, number) = path.into_inner();

    let span = tracing::Span::current();
    span.record("owner", owner_name.as_str());
    span.record("repository_slug", repository_slug.as_str());
    span.record("number", number.as_str());

    let (owner, repository, _repo_root) =
        match readable_repository(&data, Some(&current), &owner_name, &repository_slug).await {
            Ok(found) => found,
            Err(response) => return *response,
        };

    let Ok(parsed) = number.parse::<i64>() else {
        return no_such_ticket(&number);
    };

    let db = { state_read(&data).await.db.clone() };
    let ticket = match store::find_ticket(&db, repository.id, parsed).await {
        Ok(Some(ticket)) => ticket,
        Ok(None) => return no_such_ticket(&number),
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, number, "looking up a ticket failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    if !tickets::may_change(repository.owner_id, ticket.creator_id, current.id) {
        return HttpResponse::Forbidden().finish();
    }

    // Check the current count before accepting a new one.
    let current_count = match store::count_attachments(&db, ticket.id).await {
        Ok(count) => count as usize,
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, number, "counting a ticket's attachments failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let max_count = crate::max_attachments_per_ticket();
    if current_count >= max_count {
        return HttpResponse::BadRequest().body(format!(
            "This ticket already has {current_count} attachments (maximum is {max_count})"
        ));
    }

    let max_size = crate::max_attachment_size();

    // Read the first file field from the multipart payload.
    let mut filename: Option<String> = None;
    let mut content_type: Option<String> = None;
    let mut data_bytes: Option<Vec<u8>> = None;

    while let Some(Ok(mut field)) = payload.next().await {
        let field_name = field
            .content_disposition()
            .and_then(|cd| cd.get_name().map(|s| s.to_string()));

        if field_name.as_deref() != Some("attachment") {
            continue;
        }

        filename = field
            .content_disposition()
            .and_then(|cd| cd.get_filename().map(|s| s.to_string()));

        content_type = field.content_type().map(|m| m.to_string());

        let mut bytes = Vec::new();
        while let Some(chunk) = field.next().await {
            match chunk {
                Ok(data) => bytes.extend_from_slice(&data),
                Err(e) => {
                    error!(%e, owner = %owner_name, slug = %repository_slug, number, "reading attachment chunk failed");
                    return HttpResponse::InternalServerError().finish();
                }
            }
        }

        if bytes.len() > max_size {
            let error_msg = format!(
                "Attachment is too large ({} bytes, maximum is {max_size})",
                bytes.len()
            );
            return ticket_with_comment_error(
                &data,
                Some(&current),
                &owner,
                &repository,
                &ticket,
                None,
                None,
                Some(error_msg),
            )
            .await;
        }

        data_bytes = Some(bytes);
        break;
    }

    let Some(filename) = filename else {
        return ticket_with_comment_error(
            &data,
            Some(&current),
            &owner,
            &repository,
            &ticket,
            None,
            None,
            Some("No attachment was uploaded".to_string()),
        )
        .await;
    };

    let Some(content_type) = content_type else {
        return ticket_with_comment_error(
            &data,
            Some(&current),
            &owner,
            &repository,
            &ticket,
            None,
            None,
            Some("Attachment has no content type".to_string()),
        )
        .await;
    };

    let Some(data_bytes) = data_bytes else {
        return ticket_with_comment_error(
            &data,
            Some(&current),
            &owner,
            &repository,
            &ticket,
            None,
            None,
            Some("No attachment was uploaded".to_string()),
        )
        .await;
    };

    let file_store = { state_read(&data).await.file_store.clone() };
    match store::create_attachment(
        &db,
        &file_store,
        ticket.id,
        &filename,
        &content_type,
        data_bytes,
    )
    .await
    {
        Ok(_) => HttpResponse::SeeOther()
            .append_header((
                actix_web::http::header::LOCATION,
                format!("{}/{number}", tickets_address(&owner, &repository)),
            ))
            .finish(),
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, number, "uploading an attachment failed");
            ticket_with_comment_error(
                &data,
                Some(&current),
                &owner,
                &repository,
                &ticket,
                None,
                None,
                Some("Uploading the attachment failed".to_string()),
            )
            .await
        }
    }
}

#[get("/~{user_or_team}/{repository_slug}/tickets/{number}/attachments/{uuid}")]
#[tracing::instrument(
    skip_all,
    fields(
        owner = tracing::field::Empty,
        repository_slug = tracing::field::Empty,
        number = tracing::field::Empty,
        uuid = tracing::field::Empty,
    )
)]
async fn get_attachment(
    data: web::Data<Arc<RwLock<AppState>>>,
    viewer: Option<CurrentUser>,
    path: web::Path<(String, String, String, String)>,
    query: web::Query<std::collections::HashMap<String, String>>,
) -> HttpResponse {
    let (owner_name, repository_slug, number, uuid_str) = path.into_inner();

    let span = tracing::Span::current();
    span.record("owner", owner_name.as_str());
    span.record("repository_slug", repository_slug.as_str());
    span.record("number", number.as_str());
    span.record("uuid", uuid_str.as_str());

    let (owner, repository, _repo_root) =
        match readable_repository(&data, viewer.as_ref(), &owner_name, &repository_slug).await {
            Ok(found) => found,
            Err(response) => return *response,
        };

    let Ok(uuid) = uuid_str.parse::<Uuid>() else {
        return HttpResponse::NotFound().body("No such attachment");
    };

    let db = { state_read(&data).await.db.clone() };
    let attachment = match store::find_attachment_by_uuid(&db, uuid).await {
        Ok(Some(attachment)) => attachment,
        Ok(None) => return HttpResponse::NotFound().body("No such attachment"),
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, %uuid, "looking up an attachment failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Verify the attachment belongs to a ticket in this repository.
    let ticket_id = match attachment.ticket_id {
        Some(id) => id,
        None => return HttpResponse::NotFound().body("No such attachment"),
    };
    match store::find_ticket(&db, repository.id, ticket_id).await {
        Ok(Some(_)) => {}
        Ok(None) => return HttpResponse::NotFound().body("No such attachment"),
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, %uuid, "looking up a ticket for an attachment failed");
            return HttpResponse::InternalServerError().finish();
        }
    }

    // Serve the file from disk
    let file_store = { state_read(&data).await.file_store.clone() };
    let mut file = match file_store.open(uuid, &attachment.content_type).await {
        Ok(Some(file)) => file,
        Ok(None) => {
            error!(%uuid, "attachment file missing on disk");
            return HttpResponse::NotFound().body("Attachment file missing on disk");
        }
        Err(error) => {
            error!(%error, %uuid, "failed to open attachment file");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Attachments are bounded by MAX_ATTACHMENT_SIZE (2MB default), so reading
    // the whole file is fine. Future work: stream via tokio-util ReaderStream.
    use tokio::io::AsyncReadExt;
    let mut data = Vec::with_capacity(attachment.size as usize);
    if let Err(error) = file.read_to_end(&mut data).await {
        error!(%error, %uuid, "failed to read attachment file");
        return HttpResponse::InternalServerError().finish();
    }

    // Determine Content-Disposition: inline for images (except SVG) when
    // ?inline=1
    let is_image =
        attachment.content_type.starts_with("image/") && attachment.content_type != "image/svg+xml";
    let inline_requested = query.get("inline").map(|s| s.as_str()) == Some("1");

    let disposition = if is_image && inline_requested {
        "inline"
    } else {
        "attachment"
    };

    HttpResponse::Ok()
        .content_type(attachment.content_type)
        .append_header((
            actix_web::http::header::CONTENT_DISPOSITION,
            format!("{}; filename=\"{}\"", disposition, attachment.filename),
        ))
        .body(data)
}

#[post("/~{user_or_team}/{repository_slug}/tickets/{number}/attachments/{uuid}/delete")]
#[tracing::instrument(
    skip_all,
    fields(
        owner = tracing::field::Empty,
        repository_slug = tracing::field::Empty,
        number = tracing::field::Empty,
        uuid = tracing::field::Empty,
    )
)]
async fn post_delete_attachment(
    data: web::Data<Arc<RwLock<AppState>>>,
    current: CurrentUser,
    path: web::Path<(String, String, String, String)>,
) -> HttpResponse {
    let (owner_name, repository_slug, number, uuid_str) = path.into_inner();

    let span = tracing::Span::current();
    span.record("owner", owner_name.as_str());
    span.record("repository_slug", repository_slug.as_str());
    span.record("number", number.as_str());
    span.record("uuid", uuid_str.as_str());

    let (owner, repository, _repo_root) =
        match readable_repository(&data, Some(&current), &owner_name, &repository_slug).await {
            Ok(found) => found,
            Err(response) => return *response,
        };

    let Ok(uuid) = uuid_str.parse::<Uuid>() else {
        return HttpResponse::NotFound().body("No such attachment");
    };

    let db = { state_read(&data).await.db.clone() };
    let attachment = match store::find_attachment_by_uuid(&db, uuid).await {
        Ok(Some(attachment)) => attachment,
        Ok(None) => return HttpResponse::NotFound().body("No such attachment"),
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, %uuid, "looking up an attachment failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let Ok(parsed) = number.parse::<i64>() else {
        return no_such_ticket(&number);
    };

    let ticket = match store::find_ticket(&db, repository.id, parsed).await {
        Ok(Some(ticket)) => ticket,
        Ok(None) => return no_such_ticket(&number),
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, number, "looking up a ticket failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    if attachment.ticket_id != Some(ticket.id) {
        return HttpResponse::NotFound().body("No such attachment");
    }

    if !tickets::may_change(repository.owner_id, ticket.creator_id, current.id) {
        return HttpResponse::Forbidden().finish();
    }

    let file_store = { state_read(&data).await.file_store.clone() };
    if let Err(error) = store::delete_attachment(&db, &file_store, &attachment).await {
        error!(%error, owner = %owner.name, slug = %repository.slug, %uuid, "deleting an attachment failed");
        return HttpResponse::InternalServerError().finish();
    }

    HttpResponse::SeeOther()
        .append_header((
            actix_web::http::header::LOCATION,
            format!("{}/{number}", tickets_address(&owner, &repository)),
        ))
        .finish()
}

#[post("/~{user_or_team}/{repository_slug}/tickets/{number}/comments")]
#[tracing::instrument(
    skip_all,
    fields(
        owner = tracing::field::Empty,
        repository_slug = tracing::field::Empty,
        number = tracing::field::Empty,
    )
)]
pub async fn post_comment(
    data: web::Data<Arc<RwLock<AppState>>>,
    current: CurrentUser,
    path: web::Path<(String, String, String)>,
    mut payload: Multipart,
) -> HttpResponse {
    let (owner_name, repository_slug, number) = path.into_inner();

    let span = tracing::Span::current();
    span.record("owner", owner_name.as_str());
    span.record("repository_slug", repository_slug.as_str());
    span.record("number", number.as_str());

    let (owner, repository, _repo_root) =
        match readable_repository(&data, Some(&current), &owner_name, &repository_slug).await {
            Ok(found) => found,
            Err(response) => return *response,
        };

    let Ok(parsed) = number.parse::<i64>() else {
        return no_such_ticket(&number);
    };

    let db = { state_read(&data).await.db.clone() };
    let ticket = match store::find_ticket(&db, repository.id, parsed).await {
        Ok(Some(ticket)) => ticket,
        Ok(None) => return no_such_ticket(&number),
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, number, "looking up a ticket failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Parse the multipart form to extract content and attachments
    let mut content: Option<String> = None;
    let mut attachments: Vec<(String, String, Vec<u8>)> = Vec::new(); // (filename, content_type, data)

    let max_size = crate::max_attachment_size();

    while let Some(Ok(mut field)) = payload.next().await {
        let field_name = field
            .content_disposition()
            .and_then(|cd| cd.get_name().map(|s| s.to_string()));

        match field_name.as_deref() {
            Some("content") => {
                let mut bytes = Vec::new();
                while let Some(chunk) = field.next().await {
                    match chunk {
                        Ok(data) => bytes.extend_from_slice(&data),
                        Err(e) => {
                            error!(%e, owner = %owner_name, slug = %repository_slug, number, "reading comment field failed");
                            return HttpResponse::InternalServerError().finish();
                        }
                    }
                }
                content = Some(String::from_utf8_lossy(&bytes).to_string());
            }
            Some("attachment") => {
                let filename = field
                    .content_disposition()
                    .and_then(|cd| cd.get_filename().map(|s| s.to_string()))
                    .unwrap_or_else(|| "attachment".to_string());
                let content_type = field
                    .content_type()
                    .map(|m| m.to_string())
                    .unwrap_or_else(|| "application/octet-stream".to_string());

                let mut bytes = Vec::new();
                while let Some(chunk) = field.next().await {
                    match chunk {
                        Ok(data) => bytes.extend_from_slice(&data),
                        Err(e) => {
                            error!(%e, owner = %owner_name, slug = %repository_slug, number, "reading attachment chunk failed");
                            return HttpResponse::InternalServerError().finish();
                        }
                    }
                }

                if bytes.len() > max_size {
                    return HttpResponse::BadRequest().body(format!(
                        "Attachment is too large ({} bytes, maximum is {max_size})",
                        bytes.len()
                    ));
                }

                attachments.push((filename, content_type, bytes));
            }
            _ => {
                // Skip unknown fields
            }
        }
    }

    // Validate attachment count
    let max_count = crate::max_attachments_per_object();
    if attachments.len() > max_count {
        return HttpResponse::BadRequest().body(format!(
            "Too many attachments ({} files, maximum is {max_count})",
            attachments.len()
        ));
    }

    let content = content.unwrap_or_default();
    let content = match comments::checked_content(&content) {
        Ok(c) => c,
        Err(error) => {
            // Re-render the ticket page with the error
            return ticket_with_comment_error(
                &data,
                Some(&current),
                &owner,
                &repository,
                &ticket,
                Some(error),
                Some(content),
                None,
            )
            .await;
        }
    };

    match comments::store::create_on_ticket(&db, current.id, ticket.id, &content).await {
        Ok(comment) => {
            // Persist any attachments that were uploaded with the comment
            let state = state_read(&data).await;
            let file_store = &state.file_store;
            for (filename, content_type, data) in attachments {
                if let Err(e) = attachments::store::create(
                    &db,
                    file_store,
                    attachments::store::Owner::Comment(comment.id),
                    filename,
                    content_type,
                    data,
                )
                .await
                {
                    error!(%e, comment_id = comment.id, "Failed to create comment attachment");
                }
            }

            // Emit Create activity for federation
            if crate::activitypub::enabled(&state) {
                let urls = crate::activitypub::ApUrls::new(state.public_url());

                // Get or create the repo actor
                match crate::activitypub::actor::get_or_create_for_repo(&db, repository.id).await {
                    Ok(repo_actor) => {
                        let user_actor_url = urls.actor(&owner.name);
                        let repo_actor_url = urls.repo_actor(&owner.name, &repository.slug);

                        // Build the Note document
                        let note_doc =
                            match crate::activitypub::forge::builders::build_comment_note_document(
                                &comment,
                                &owner.name,
                                &repository.slug,
                                ticket.number,
                                true, // is_ticket
                                &owner,
                                &urls,
                                &state.db,
                            )
                            .await
                            {
                                Ok(doc) => doc,
                                Err(e) => {
                                    error!(%e, "failed to build comment note document");
                                    return HttpResponse::InternalServerError().finish();
                                }
                            };

                        // Build the Create activity
                        let activity_id =
                            format!("{}/activities/{}", repo_actor_url, uuid::Uuid::new_v4());
                        let create_activity = crate::activitypub::types::Create {
                            context: crate::activitypub::types::FORGEFED_CONTEXT.to_vec(),
                            id: activity_id,
                            kind: "Create",
                            actor: user_actor_url.clone(),
                            object: note_doc,
                            published: chrono::Utc::now().to_rfc3339(),
                            to: vec![repo_actor_url.clone()],
                            cc: vec![urls.repo_followers(&owner.name, &repository.slug)],
                        };

                        let create_json =
                            serde_json::to_string(&create_activity).unwrap_or_default();

                        // Publish with user as signer, repo followers as
                        // delivery target
                        let comment_url = urls.repo_ticket_comment(
                            &owner.name,
                            &repository.slug,
                            ticket.number,
                            comment.uuid,
                        );
                        if let Err(e) = crate::activitypub::forge::activity::publish_activity_with_separate_signer(
                            &db,
                            current.id,           // signer = user actor
                            repo_actor.id,        // fan-out to repo followers
                            &create_json,
                            &comment_url,
                        ).await {
                            tracing::error!(%e, "failed to publish Create comment activity");
                        }
                    }
                    Err(e) => {
                        tracing::error!(%e, "failed to get repo actor for comment emission");
                    }
                }
            }

            HttpResponse::SeeOther()
                .append_header((
                    actix_web::http::header::LOCATION,
                    format!("{}/{number}", tickets_address(&owner, &repository)),
                ))
                .finish()
        }
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, number, "creating a comment failed");
            HttpResponse::InternalServerError().finish()
        }
    }
}

#[tracing::instrument(
    skip_all,
    fields(
        owner = tracing::field::Empty,
        repository_slug = tracing::field::Empty,
        number = tracing::field::Empty,
        uuid = tracing::field::Empty,
    )
)]
pub async fn post_edit_comment(
    data: web::Data<Arc<RwLock<AppState>>>,
    current: CurrentUser,
    path: web::Path<(String, String, String, String)>,
    body: String,
) -> HttpResponse {
    let (owner_name, repository_slug, number, uuid_str) = path.into_inner();

    let span = tracing::Span::current();
    span.record("owner", owner_name.as_str());
    span.record("repository_slug", repository_slug.as_str());
    span.record("number", number.as_str());
    span.record("uuid", uuid_str.as_str());

    let (owner, repository, _repo_root) =
        match readable_repository(&data, Some(&current), &owner_name, &repository_slug).await {
            Ok(found) => found,
            Err(response) => return *response,
        };

    let Ok(uuid) = uuid_str.parse::<Uuid>() else {
        return HttpResponse::NotFound().body("No such comment");
    };

    let db = { state_read(&data).await.db.clone() };
    let comment = match comments::store::find_by_uuid(&db, uuid).await {
        Ok(Some(comment)) => comment,
        Ok(None) => return HttpResponse::NotFound().body("No such comment"),
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, %uuid, "looking up a comment failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let Ok(parsed) = number.parse::<i64>() else {
        return no_such_ticket(&number);
    };

    let ticket = match store::find_ticket(&db, repository.id, parsed).await {
        Ok(Some(ticket)) => ticket,
        Ok(None) => return no_such_ticket(&number),
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, number, "looking up a ticket failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    if comment.ticket_id != Some(ticket.id) {
        return HttpResponse::NotFound().body("No such comment");
    }

    if !comments::may_change(comment.author_id, repository.owner_id, current.id) {
        return HttpResponse::Forbidden().finish();
    }

    // Parse the form body to extract content
    let mut content: Option<String> = None;
    for pair in body.split('&') {
        if pair.is_empty() {
            continue;
        }
        let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
        if key == "content" {
            content = Some(urlencoding_decode(value).unwrap_or_default());
        }
    }

    let content = content.unwrap_or_default();
    let content = match comments::checked_content(&content) {
        Ok(c) => c,
        Err(error) => {
            // Re-render the ticket page with the error
            return ticket_with_comment_error(
                &data,
                Some(&current),
                &owner,
                &repository,
                &ticket,
                Some(error),
                Some(content),
                None,
            )
            .await;
        }
    };

    match comments::store::update(&db, comment.id, content).await {
        Ok(()) => {
            // Re-fetch the updated comment for federation
            let updated_comment = match comments::store::find_by_uuid(&db, uuid).await {
                Ok(Some(comment)) => comment,
                Ok(None) => {
                    tracing::error!("comment disappeared after update");
                    return HttpResponse::InternalServerError().finish();
                }
                Err(error) => {
                    tracing::error!(%error, "failed to re-fetch comment after update");
                    return HttpResponse::InternalServerError().finish();
                }
            };

            // Emit Edit activity for federation
            let state = state_read(&data).await;
            if crate::activitypub::enabled(&state) {
                let urls = crate::activitypub::ApUrls::new(state.public_url());

                // Get or create the repo actor
                match crate::activitypub::actor::get_or_create_for_repo(&db, repository.id).await {
                    Ok(repo_actor) => {
                        let user_actor_url = urls.actor(&owner.name);
                        let repo_actor_url = urls.repo_actor(&owner.name, &repository.slug);

                        // Build the updated Note document
                        let note_doc =
                            match crate::activitypub::forge::builders::build_comment_note_document(
                                &updated_comment,
                                &owner.name,
                                &repository.slug,
                                ticket.number,
                                true, // is_ticket
                                &owner,
                                &urls,
                                &state.db,
                            )
                            .await
                            {
                                Ok(doc) => doc,
                                Err(e) => {
                                    error!(%e, "failed to build comment note document");
                                    return HttpResponse::InternalServerError().finish();
                                }
                            };
                        let note_doc_id = note_doc.id.clone();

                        // Build the Edit activity
                        let activity_id =
                            format!("{}/activities/{}", repo_actor_url, uuid::Uuid::new_v4());
                        let edit_activity = crate::activitypub::types::Edit {
                            context: crate::activitypub::types::FORGEFED_CONTEXT.to_vec(),
                            id: activity_id,
                            kind: "Update",
                            actor: user_actor_url.clone(),
                            object: serde_json::to_value(&note_doc).unwrap_or_default(),
                            published: chrono::Utc::now().to_rfc3339(),
                            to: vec![repo_actor_url.clone()],
                            cc: Some(vec![urls.repo_followers(&owner.name, &repository.slug)]),
                        };

                        let edit_json = serde_json::to_string(&edit_activity).unwrap_or_default();

                        // Publish with user as signer, repo followers as
                        // delivery target
                        if let Err(e) = crate::activitypub::forge::activity::publish_activity_with_separate_signer(
                            &db,
                            current.id,           // signer = user actor
                            repo_actor.id,        // fan-out to repo followers
                            &edit_json,
                            &note_doc_id,
                        ).await {
                            tracing::error!(%e, "failed to publish Edit comment activity");
                        }
                    }
                    Err(e) => {
                        tracing::error!(%e, "failed to get repo actor for comment edit emission");
                    }
                }
            }

            HttpResponse::SeeOther()
                .append_header((
                    actix_web::http::header::LOCATION,
                    format!("{}/{number}", tickets_address(&owner, &repository)),
                ))
                .finish()
        }
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, %uuid, "editing a comment failed");
            HttpResponse::InternalServerError().finish()
        }
    }
}

#[post("/~{user_or_team}/{repository_slug}/tickets/{number}/comments/{uuid}/delete")]
#[tracing::instrument(
    skip_all,
    fields(
        owner = tracing::field::Empty,
        repository_slug = tracing::field::Empty,
        number = tracing::field::Empty,
        uuid = tracing::field::Empty,
    )
)]
pub async fn post_delete_comment(
    data: web::Data<Arc<RwLock<AppState>>>,
    current: CurrentUser,
    path: web::Path<(String, String, String, String)>,
) -> HttpResponse {
    let (owner_name, repository_slug, number, uuid_str) = path.into_inner();

    let span = tracing::Span::current();
    span.record("owner", owner_name.as_str());
    span.record("repository_slug", repository_slug.as_str());
    span.record("number", number.as_str());
    span.record("uuid", uuid_str.as_str());

    let (owner, repository, _repo_root) =
        match readable_repository(&data, Some(&current), &owner_name, &repository_slug).await {
            Ok(found) => found,
            Err(response) => return *response,
        };

    let Ok(uuid) = uuid_str.parse::<Uuid>() else {
        return HttpResponse::NotFound().body("No such comment");
    };

    let db = { state_read(&data).await.db.clone() };
    let comment = match comments::store::find_by_uuid(&db, uuid).await {
        Ok(Some(comment)) => comment,
        Ok(None) => return HttpResponse::NotFound().body("No such comment"),
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, %uuid, "looking up a comment failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let Ok(parsed) = number.parse::<i64>() else {
        return no_such_ticket(&number);
    };

    let ticket = match store::find_ticket(&db, repository.id, parsed).await {
        Ok(Some(ticket)) => ticket,
        Ok(None) => return no_such_ticket(&number),
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, number, "looking up a ticket failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    if comment.ticket_id != Some(ticket.id) {
        return HttpResponse::NotFound().body("No such comment");
    }

    if !comments::may_change(comment.author_id, repository.owner_id, current.id) {
        return HttpResponse::Forbidden().finish();
    }

    // Emit Delete activity before deleting
    let state = state_read(&data).await;
    if crate::activitypub::enabled(&state) {
        let urls = crate::activitypub::ApUrls::new(state.public_url());

        // Get or create the repo actor
        match crate::activitypub::actor::get_or_create_for_repo(&db, repository.id).await {
            Ok(repo_actor) => {
                let user_actor_url = urls.actor(&owner.name);
                let repo_actor_url = urls.repo_actor(&owner.name, &repository.slug);

                // Build the comment URL
                let comment_url = urls.repo_ticket_comment(
                    &owner.name,
                    &repository.slug,
                    ticket.number,
                    comment.uuid,
                );

                // Build the Delete activity
                let activity_id = format!("{}/activities/{}", repo_actor_url, uuid::Uuid::new_v4());
                let delete_activity = crate::activitypub::types::Delete {
                    context: crate::activitypub::types::FORGEFED_CONTEXT.to_vec(),
                    id: activity_id,
                    kind: "Delete",
                    actor: user_actor_url.clone(),
                    object: comment_url.clone(),
                    published: chrono::Utc::now().to_rfc3339(),
                    to: vec![repo_actor_url.clone()],
                    cc: Some(vec![urls.repo_followers(&owner.name, &repository.slug)]),
                };

                let delete_json = serde_json::to_string(&delete_activity).unwrap_or_default();

                // Publish with user as signer, repo followers as delivery
                // target
                if let Err(e) =
                    crate::activitypub::forge::activity::publish_activity_with_separate_signer(
                        &db,
                        current.id,    // signer = user actor
                        repo_actor.id, // fan-out to repo followers
                        &delete_json,
                        &comment_url,
                    )
                    .await
                {
                    tracing::error!(%e, "failed to publish Delete comment activity");
                }
            }
            Err(e) => {
                tracing::error!(%e, "failed to get repo actor for comment delete emission");
            }
        }
    }

    match comments::store::delete(&db, comment.id).await {
        Ok(_) => HttpResponse::SeeOther()
            .append_header((
                actix_web::http::header::LOCATION,
                format!("{}/{number}", tickets_address(&owner, &repository)),
            ))
            .finish(),
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, %uuid, "deleting a comment failed");
            HttpResponse::InternalServerError().finish()
        }
    }
}

#[get(
    "/~{user_or_team}/{repository_slug}/tickets/{number}/comments/{comment_uuid}/attachments/{uuid}"
)]
#[tracing::instrument(
    skip_all,
    fields(
        owner = tracing::field::Empty,
        repository_slug = tracing::field::Empty,
        number = tracing::field::Empty,
        comment_uuid = tracing::field::Empty,
        uuid = tracing::field::Empty,
    )
)]
pub async fn get_comment_attachment(
    data: web::Data<Arc<RwLock<AppState>>>,
    viewer: Option<CurrentUser>,
    path: web::Path<(String, String, String, String, String)>,
    query: web::Query<std::collections::HashMap<String, String>>,
) -> HttpResponse {
    let (owner_name, repository_slug, number, comment_uuid_str, uuid_str) = path.into_inner();

    let span = tracing::Span::current();
    span.record("owner", owner_name.as_str());
    span.record("repository_slug", repository_slug.as_str());
    span.record("number", number.as_str());
    span.record("comment_uuid", comment_uuid_str.as_str());
    span.record("uuid", uuid_str.as_str());

    let (owner, repository, _repo_root) =
        match readable_repository(&data, viewer.as_ref(), &owner_name, &repository_slug).await {
            Ok(found) => found,
            Err(response) => return *response,
        };

    let Ok(comment_uuid) = comment_uuid_str.parse::<Uuid>() else {
        return HttpResponse::NotFound().body("No such comment");
    };

    let Ok(uuid) = uuid_str.parse::<Uuid>() else {
        return HttpResponse::NotFound().body("No such attachment");
    };

    let db = { state_read(&data).await.db.clone() };
    let comment = match comments::store::find_by_uuid(&db, comment_uuid).await {
        Ok(Some(comment)) => comment,
        Ok(None) => return HttpResponse::NotFound().body("No such comment"),
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, %comment_uuid, "looking up a comment failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let Ok(parsed) = number.parse::<i64>() else {
        return no_such_ticket(&number);
    };

    let ticket = match store::find_ticket(&db, repository.id, parsed).await {
        Ok(Some(ticket)) => ticket,
        Ok(None) => return no_such_ticket(&number),
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, number, "looking up a ticket failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Verify the comment belongs to this ticket
    if comment.ticket_id != Some(ticket.id) {
        return HttpResponse::NotFound().body("No such comment");
    }

    // Find the attachment and verify it belongs to this comment
    let attachment = match attachments::store::find_by_uuid(&db, uuid).await {
        Ok(Some(attachment)) => attachment,
        Ok(None) => return HttpResponse::NotFound().body("No such attachment"),
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, %uuid, "looking up an attachment failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Verify the attachment belongs to this comment
    if attachment.comment_id != Some(comment.id) {
        return HttpResponse::NotFound().body("No such attachment");
    }

    // Serve the file from disk
    let file_store = { state_read(&data).await.file_store.clone() };
    let mut file = match file_store.open(uuid, &attachment.content_type).await {
        Ok(Some(file)) => file,
        Ok(None) => {
            error!(%uuid, "attachment file missing on disk");
            return HttpResponse::NotFound().body("Attachment file missing on disk");
        }
        Err(error) => {
            error!(%error, %uuid, "failed to open attachment file");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let mut response = HttpResponse::Ok();
    response.content_type(attachment.content_type.clone());

    // Determine Content-Disposition: inline for images (except SVG) when
    // ?inline=1
    let is_image =
        attachment.content_type.starts_with("image/") && attachment.content_type != "image/svg+xml";
    let inline_requested = query.get("inline").map(|s| s.as_str()) == Some("1");

    let disposition = if is_image && inline_requested {
        "inline"
    } else {
        "attachment"
    };

    response.insert_header((
        actix_web::http::header::CONTENT_DISPOSITION,
        format!("{}; filename=\"{}\"", disposition, attachment.filename),
    ));

    let mut body = Vec::new();
    if let Err(error) = tokio::io::AsyncReadExt::read_to_end(&mut file, &mut body).await {
        error!(%error, %uuid, "reading attachment file failed");
        return HttpResponse::InternalServerError().finish();
    }

    response.body(body)
}

#[post(
    "/~{user_or_team}/{repository_slug}/tickets/{number}/comments/{comment_uuid}/attachments/{uuid}/delete"
)]
#[tracing::instrument(
    skip_all,
    fields(
        owner = tracing::field::Empty,
        repository_slug = tracing::field::Empty,
        number = tracing::field::Empty,
        comment_uuid = tracing::field::Empty,
        uuid = tracing::field::Empty,
    )
)]
pub async fn post_delete_comment_attachment(
    data: web::Data<Arc<RwLock<AppState>>>,
    current: CurrentUser,
    path: web::Path<(String, String, String, String, String)>,
) -> HttpResponse {
    let (owner_name, repository_slug, number, comment_uuid_str, uuid_str) = path.into_inner();

    let span = tracing::Span::current();
    span.record("owner", owner_name.as_str());
    span.record("repository_slug", repository_slug.as_str());
    span.record("number", number.as_str());
    span.record("comment_uuid", comment_uuid_str.as_str());
    span.record("uuid", uuid_str.as_str());

    let (owner, repository, _repo_root) =
        match readable_repository(&data, Some(&current), &owner_name, &repository_slug).await {
            Ok(found) => found,
            Err(response) => return *response,
        };

    let Ok(comment_uuid) = comment_uuid_str.parse::<Uuid>() else {
        return HttpResponse::NotFound().body("No such comment");
    };

    let Ok(uuid) = uuid_str.parse::<Uuid>() else {
        return HttpResponse::NotFound().body("No such attachment");
    };

    let db = { state_read(&data).await.db.clone() };
    let comment = match comments::store::find_by_uuid(&db, comment_uuid).await {
        Ok(Some(comment)) => comment,
        Ok(None) => return HttpResponse::NotFound().body("No such comment"),
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, %comment_uuid, "looking up a comment failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let Ok(parsed) = number.parse::<i64>() else {
        return no_such_ticket(&number);
    };

    let ticket = match store::find_ticket(&db, repository.id, parsed).await {
        Ok(Some(ticket)) => ticket,
        Ok(None) => return no_such_ticket(&number),
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, number, "looking up a ticket failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Verify the comment belongs to this ticket
    if comment.ticket_id != Some(ticket.id) {
        return HttpResponse::NotFound().body("No such comment");
    }

    // Check permissions: only the comment author or repo owner can delete
    // attachments
    if !comments::may_change(comment.author_id, repository.owner_id, current.id) {
        return HttpResponse::Forbidden().finish();
    }

    // Find the attachment and verify it belongs to this comment
    let attachment = match attachments::store::find_by_uuid(&db, uuid).await {
        Ok(Some(attachment)) => attachment,
        Ok(None) => return HttpResponse::NotFound().body("No such attachment"),
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, %uuid, "looking up an attachment failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    // Verify the attachment belongs to this comment
    if attachment.comment_id != Some(comment.id) {
        return HttpResponse::NotFound().body("No such attachment");
    }

    // Delete the attachment
    let file_store = { state_read(&data).await.file_store.clone() };
    if let Err(error) = attachments::store::delete(&db, &file_store, &attachment).await {
        error!(%error, owner = %owner.name, slug = %repository.slug, %uuid, "deleting an attachment failed");
        return HttpResponse::InternalServerError().finish();
    }

    HttpResponse::SeeOther()
        .append_header((
            actix_web::http::header::LOCATION,
            format!("{}/{number}", tickets_address(&owner, &repository)),
        ))
        .finish()
}

/// Re-renders the ticket page with a comment error and draft preserved
async fn ticket_with_comment_error(
    data: &web::Data<Arc<RwLock<AppState>>>,
    viewer: Option<&CurrentUser>,
    owner: &users::Model,
    repository: &Repository,
    ticket: &crate::entities::tickets::Model,
    error: Option<String>,
    draft: Option<String>,
    attachment_error: Option<String>,
) -> HttpResponse {
    let db = { state_read(data).await.db.clone() };

    let filed = match store::labels_by_ticket(&db, &[ticket.id]).await {
        Ok(filed) => filed.get(&ticket.id).cloned().unwrap_or_default(),
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, number = ticket.number, "reading a ticket's labels failed");
            return HttpResponse::InternalServerError().finish();
        }
    };

    let creator = if let Some(creator_id) = ticket.creator_id {
        match store::creator_authors(&db, &[creator_id], &[]).await {
            Ok(creators) => creators.get(&creator_id).cloned().unwrap_or_else(|| {
                crate::comments::CommentAuthor {
                    name: String::from("someone"),
                    actor_url: None,
                    is_remote: false,
                }
            }),
            Err(error) => {
                error!(%error, owner = %owner.name, slug = %repository.slug, number = ticket.number, "looking up who created a ticket failed");
                return HttpResponse::InternalServerError().finish();
            }
        }
    } else if let Some(remote_actor_id) = ticket.remote_actor_id {
        match store::creator_authors(&db, &[], &[remote_actor_id]).await {
            Ok(creators) => creators.get(&remote_actor_id).cloned().unwrap_or_else(|| {
                crate::comments::CommentAuthor {
                    name: String::from("someone"),
                    actor_url: None,
                    is_remote: true,
                }
            }),
            Err(error) => {
                error!(%error, owner = %owner.name, slug = %repository.slug, number = ticket.number, "looking up remote ticket creator failed");
                return HttpResponse::InternalServerError().finish();
            }
        }
    } else {
        crate::comments::CommentAuthor {
            name: String::from("someone"),
            actor_url: None,
            is_remote: false,
        }
    };

    let may_change = viewer.is_some_and(|viewer| {
        tickets::may_change(repository.owner_id, ticket.creator_id, viewer.id)
    });

    let attachments = match store::list_attachments(&db, ticket.id).await {
        Ok(attachments) => attachments
            .into_iter()
            .map(|a| views::AttachmentView {
                uuid: a.uuid.to_string(),
                filename: a.filename,
                size: a.size,
                content_type: a.content_type,
            })
            .collect(),
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, number = ticket.number, "listing a ticket's attachments failed");
            Vec::new()
        }
    };

    let comments = match comments::store::list_for_ticket(&db, ticket.id).await {
        Ok(comments) => {
            let author_ids: Vec<i64> = comments.iter().filter_map(|c| c.author_id).collect();
            let remote_actor_ids: Vec<i64> =
                comments.iter().filter_map(|c| c.remote_actor_id).collect();
            let authors = match comments::store::comment_authors(
                &db,
                &author_ids,
                &remote_actor_ids,
            )
            .await
            {
                Ok(authors) => authors,
                Err(error) => {
                    error!(%error, owner = %owner.name, slug = %repository.slug, number = ticket.number, "looking up comment authors failed");
                    std::collections::HashMap::new()
                }
            };
            let viewer_id = viewer.map(|v| v.id);
            let mut comment_views = Vec::new();
            for c in comments {
                // Fetch attachments for this comment
                let comment_attachments = match attachments::store::list_for(
                    &db,
                    &attachments::store::Owner::Comment(c.id),
                )
                .await
                {
                    Ok(atts) => atts
                        .into_iter()
                        .map(|a| views::CommentAttachment {
                            uuid: a.uuid.to_string(),
                            filename: a.filename,
                            content_type: a.content_type,
                        })
                        .collect(),
                    Err(error) => {
                        error!(%error, comment_id = c.id, "failed to fetch comment attachments");
                        Vec::new()
                    }
                };

                let author = c
                    .author_id
                    .and_then(|id| authors.get(&id))
                    .or_else(|| c.remote_actor_id.and_then(|id| authors.get(&id)))
                    .cloned()
                    .unwrap_or_else(|| comments::CommentAuthor {
                        name: String::from("someone"),
                        actor_url: None,
                        is_remote: false,
                    });
                comment_views.push(views::CommentView {
                    base_url: "", /* filled later on in
                                   * [`crate::views::comments::CommentListView`] */
                    uuid: c.uuid.to_string(),
                    author,
                    content: c.content.clone(),
                    content_html: markdown::render(&c.content),
                    created_at: c.created_at,
                    updated_at: c.updated_at,
                    may_change: viewer_id.is_some_and(|vid| {
                        comments::may_change(c.author_id, repository.owner_id, vid)
                    }),
                    attachments: comment_attachments,
                });
            }
            comment_views
        }
        Err(error) => {
            error!(%error, owner = %owner.name, slug = %repository.slug, number = ticket.number, "listing a ticket's comments failed");
            Vec::new()
        }
    };

    let may_comment = viewer.is_some();

    views::ticket(
        viewer_name(viewer),
        &owner.name,
        &views::TicketDetail {
            slug: repository.slug.clone(),
            visibility: repository.visibility.label(),
            default_branch: repository.default_branch.clone(),
            number: ticket.number,
            summary: ticket.summary.clone(),
            description_html: markdown::render(ticket.description.as_deref().unwrap_or("")),
            creator,
            opened_at: ticket.created_at.format("%Y-%m-%d %H:%M UTC").to_string(),
            state: tickets::state_string(ticket.state),
            labels: filed,
            may_change,
            attachments,
            comments,
            may_comment,
            comment_error: error,
            comment_draft: draft,
            attachment_error,
        },
    )
}
