use std::cmp::Ordering;
use std::str::FromStr;

use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

use application::{
    BulkDeleteIssuesInput, BulkIssueMutationOutput, BulkUpdateIssuePriorityInput,
    BulkUpdateIssueStatusInput, CreateIssueInput, UpdateIssueInput,
};
use domain::{
    AgentId, AgentIssueRole, Issue, IssueActor, IssueAttachment, IssueAttachmentContent,
    IssueAttachmentId, IssueCarnet, IssueIndexEntry, IssueLink, IssueLinkKind, IssueListFilter,
    IssuePriority, IssueRef, IssueStatus, IssueVersion, ProfileId, Project, SprintId, SprintStatus,
    SprintVersion,
};
use infrastructure::TicketToolError;

use crate::dto::ErrorDto;

/// Public full ticket DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketDto {
    pub id: String,
    pub r#ref: String,
    pub number: u64,
    pub title: String,
    pub description: String,
    pub status: String,
    pub priority: String,
    pub sprint_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub sprint: Option<TicketSprintContextDto>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub carnet: Option<String>,
    pub links: Vec<TicketLinkDto>,
    pub assigned_agent_ids: Vec<String>,
    pub attachments: Vec<TicketAttachmentDto>,
    pub created_by: TicketActorDto,
    pub updated_by: TicketActorDto,
    pub created_at: u64,
    pub updated_at: u64,
    pub version: u64,
}

/// Public ticket summary DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketSummaryDto {
    pub r#ref: String,
    pub path: String,
    pub title: String,
    pub status: String,
    pub priority: String,
    pub sprint_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub sprint: Option<TicketSprintContextDto>,
    pub assigned_agent_ids: Vec<String>,
    pub created_by: TicketActorDto,
    pub updated_at: u64,
}

/// Public list DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketListDto {
    pub items: Vec<TicketSummaryDto>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub next_cursor: Option<String>,
}

/// Public attachment metadata DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketAttachmentDto {
    pub id: String,
    pub filename: String,
    pub mime: String,
    pub size_bytes: u64,
    pub added_by: TicketActorDto,
    pub added_at: u64,
    pub summarized_in_carnet: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub summarized_by: Option<TicketActorDto>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub summarized_at: Option<u64>,
}

/// Raw attachment content DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketAttachmentContentDto {
    pub attachment: TicketAttachmentDto,
    pub content_base64: String,
}

/// Sprint context embedded in ticket read/list responses.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketSprintContextDto {
    pub order: u32,
    pub name: String,
}

/// Public sprint DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SprintDto {
    pub id: String,
    pub order: u32,
    pub name: String,
    pub status: String,
    pub ticket_count: usize,
    pub version: u64,
}

/// Public sprint list DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SprintListDto {
    pub items: Vec<SprintDto>,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenTicketChatRequestDto {
    pub project_id: String,
    pub issue_ref: String,
    pub profile_id: String,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CloseTicketChatRequestDto {
    pub project_id: String,
    pub issue_ref: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketChatDto {
    pub session_id: String,
    pub requester: String,
    pub issue_ref: String,
}

/// Public link DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketLinkDto {
    pub target_ref: String,
    pub kind: String,
}

/// Public actor DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum TicketActorDto {
    User,
    Agent { agent_id: String },
    System,
}

/// Public creator filter DTO.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum TicketCreatorFilterDto {
    User,
    Agent { agent_id: String },
}

/// Carnet read DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketCarnetDto {
    pub r#ref: String,
    pub carnet: String,
    pub version: u64,
}

/// Create request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketCreateRequestDto {
    #[serde(default)]
    pub project_id: String,
    pub title: String,
    pub description: Option<String>,
    pub priority: Option<String>,
    pub status: Option<String>,
    pub assigned_agent_ids: Option<Vec<String>>,
    pub links: Option<Vec<TicketLinkRequestDto>>,
}

/// Read request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketReadRequestDto {
    #[serde(default)]
    pub project_id: String,
    pub r#ref: String,
    pub include_carnet: Option<bool>,
}

/// Delete request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketDeleteRequestDto {
    #[serde(default)]
    pub project_id: String,
    pub r#ref: String,
}

/// Add attachment request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketAttachmentAddRequestDto {
    #[serde(default)]
    pub project_id: String,
    pub r#ref: String,
    pub path: String,
    pub mime: Option<String>,
    pub expected_version: u64,
}

/// Read attachment request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketAttachmentReadRequestDto {
    #[serde(default)]
    pub project_id: String,
    pub r#ref: String,
    pub attachment_id: String,
}

/// Mark attachment summarized request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketAttachmentMarkSummarizedRequestDto {
    #[serde(default)]
    pub project_id: String,
    pub r#ref: String,
    pub attachment_id: String,
    pub expected_version: u64,
}

/// List request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketListRequestDto {
    #[serde(default)]
    pub project_id: String,
    #[serde(default)]
    pub statuses: Vec<String>,
    #[serde(default)]
    pub priorities: Vec<String>,
    pub assigned_agent_id: Option<String>,
    pub created_by: Option<TicketCreatorFilterDto>,
    pub sprint_id: Option<String>,
    pub text: Option<String>,
    pub sort: Option<TicketListSortDto>,
    pub limit: Option<usize>,
    pub cursor: Option<String>,
}

/// Bulk status update request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketBulkUpdateStatusRequestDto {
    #[serde(default)]
    pub project_id: String,
    pub refs: Vec<String>,
    pub status: String,
}

/// Bulk priority update request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketBulkUpdatePriorityRequestDto {
    #[serde(default)]
    pub project_id: String,
    pub refs: Vec<String>,
    pub priority: String,
}

/// Bulk delete request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketBulkDeleteRequestDto {
    #[serde(default)]
    pub project_id: String,
    pub refs: Vec<String>,
}

/// Bulk operation response.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketBulkResultDto {
    pub items: Vec<TicketBulkResultItemDto>,
}

/// Per-ticket bulk operation result.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketBulkResultItemDto {
    pub r#ref: String,
    pub ok: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub ticket: Option<TicketDto>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<ErrorDto>,
}

/// List sort request.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketListSortDto {
    pub field: TicketListSortFieldDto,
    pub direction: TicketListSortDirectionDto,
}

/// List sort field.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TicketListSortFieldDto {
    Number,
    Priority,
    Status,
    Title,
}

/// List sort direction.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TicketListSortDirectionDto {
    Asc,
    Desc,
}

/// Update request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketUpdateRequestDto {
    #[serde(default)]
    pub project_id: String,
    pub r#ref: String,
    pub title: Option<String>,
    pub description: Option<String>,
    pub status: Option<String>,
    pub priority: Option<String>,
    pub assigned_agent_ids: Option<Vec<String>>,
    pub expected_version: u64,
}

/// Carnet update request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketUpdateCarnetRequestDto {
    #[serde(default)]
    pub project_id: String,
    pub r#ref: String,
    pub carnet: String,
    pub expected_version: u64,
}

/// Link mutation request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketLinkRequestDto {
    pub target_ref: String,
    pub kind: String,
}

/// Link command request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketLinkCommandRequestDto {
    #[serde(default)]
    pub project_id: String,
    pub r#ref: String,
    pub target_ref: String,
    pub kind: String,
    pub expected_version: u64,
}

/// Unlink command request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketUnlinkCommandRequestDto {
    #[serde(default)]
    pub project_id: String,
    pub r#ref: String,
    pub target_ref: String,
    pub kind: Option<String>,
    pub expected_version: u64,
}

/// Assign command request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketAssignRequestDto {
    #[serde(default)]
    pub project_id: String,
    pub r#ref: String,
    pub agent_id: String,
    pub assigned: bool,
    pub expected_version: u64,
}

/// Create sprint request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SprintCreateRequestDto {
    #[serde(default)]
    pub project_id: String,
    pub name: String,
    pub status: Option<String>,
}

/// List sprint request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SprintListRequestDto {
    #[serde(default)]
    pub project_id: String,
}

/// Rename sprint request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SprintRenameRequestDto {
    #[serde(default)]
    pub project_id: String,
    pub sprint_id: String,
    pub name: String,
    pub expected_version: u64,
}

/// Reorder sprint request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SprintReorderRequestDto {
    #[serde(default)]
    pub project_id: String,
    pub ordered_ids: Vec<String>,
}

/// Delete sprint request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SprintDeleteRequestDto {
    #[serde(default)]
    pub project_id: String,
    pub sprint_id: String,
}

/// Assign ticket to sprint request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketSprintAssignRequestDto {
    #[serde(default)]
    pub project_id: String,
    pub r#ref: String,
    pub sprint_id: String,
    pub expected_version: u64,
}

/// Unassign ticket from sprint request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketSprintUnassignRequestDto {
    #[serde(default)]
    pub project_id: String,
    pub r#ref: String,
    pub expected_version: u64,
}

impl TicketDto {
    pub fn from_issue(issue: Issue, carnet: Option<String>) -> Self {
        Self {
            id: issue.id.to_string(),
            r#ref: issue.reference().to_string(),
            number: issue.number.get(),
            title: issue.title,
            description: issue.description.as_str().to_owned(),
            status: status_wire(issue.status).to_owned(),
            priority: priority_wire(issue.priority).to_owned(),
            sprint_id: issue.sprint.map(|id| id.to_string()),
            sprint: None,
            carnet,
            links: issue.links.into_iter().map(TicketLinkDto::from).collect(),
            assigned_agent_ids: issue
                .agent_refs
                .iter()
                .filter(|r| r.role == AgentIssueRole::Assigned)
                .map(|r| r.agent_id.to_string())
                .collect(),
            attachments: issue
                .attachments
                .into_iter()
                .map(TicketAttachmentDto::from)
                .collect(),
            created_by: TicketActorDto::from(issue.created_by),
            updated_by: TicketActorDto::from(issue.updated_by),
            created_at: issue.created_at,
            updated_at: issue.updated_at,
            version: issue.version.get(),
        }
    }

    pub fn from_issue_with_sprints(
        issue: Issue,
        carnet: Option<String>,
        sprints: &[application::SprintListEntry],
    ) -> Self {
        let sprint = issue
            .sprint
            .and_then(|id| ticket_sprint_context(id, sprints));
        let mut dto = Self::from_issue(issue, carnet);
        dto.sprint = sprint;
        dto
    }
}

impl From<IssueIndexEntry> for TicketSummaryDto {
    fn from(row: IssueIndexEntry) -> Self {
        Self {
            r#ref: row.issue_ref.to_string(),
            path: row.path,
            title: row.title,
            status: status_wire(row.status).to_owned(),
            priority: priority_wire(row.priority).to_owned(),
            sprint_id: row.sprint.map(|id| id.to_string()),
            sprint: None,
            assigned_agent_ids: row
                .assigned_agent_ids
                .into_iter()
                .map(|id| id.to_string())
                .collect(),
            created_by: TicketActorDto::from(row.created_by),
            updated_at: row.updated_at,
        }
    }
}

impl TicketSummaryDto {
    fn from_row_with_sprints(
        row: IssueIndexEntry,
        sprints: &[application::SprintListEntry],
    ) -> Self {
        let sprint = row.sprint.and_then(|id| ticket_sprint_context(id, sprints));
        let mut dto = Self::from(row);
        dto.sprint = sprint;
        dto
    }
}

impl SprintDto {
    pub fn from_sprint(sprint: domain::Sprint, ticket_count: usize) -> Self {
        Self {
            id: sprint.id.to_string(),
            order: sprint.order.get(),
            name: sprint.name,
            status: sprint_status_wire(sprint.status).to_owned(),
            ticket_count,
            version: sprint.version.get(),
        }
    }
}

impl From<application::SprintListEntry> for SprintDto {
    fn from(entry: application::SprintListEntry) -> Self {
        Self {
            id: entry.sprint.id.to_string(),
            order: entry.sprint.order.get(),
            name: entry.sprint.name,
            status: sprint_status_wire(entry.sprint.status).to_owned(),
            ticket_count: entry.ticket_count,
            version: entry.sprint.version.get(),
        }
    }
}

impl From<Vec<application::SprintListEntry>> for SprintListDto {
    fn from(items: Vec<application::SprintListEntry>) -> Self {
        Self {
            items: items.into_iter().map(SprintDto::from).collect(),
        }
    }
}

impl From<IssueLink> for TicketLinkDto {
    fn from(link: IssueLink) -> Self {
        Self {
            target_ref: link.target.to_string(),
            kind: link_kind_wire(link.kind).to_owned(),
        }
    }
}

impl From<IssueActor> for TicketActorDto {
    fn from(actor: IssueActor) -> Self {
        match actor {
            IssueActor::User => Self::User,
            IssueActor::Agent { agent_id } => Self::Agent {
                agent_id: agent_id.to_string(),
            },
            IssueActor::System => Self::System,
        }
    }
}

impl From<IssueAttachment> for TicketAttachmentDto {
    fn from(attachment: IssueAttachment) -> Self {
        Self {
            id: attachment.id.as_str().to_owned(),
            filename: attachment.filename,
            mime: attachment.mime,
            size_bytes: attachment.size_bytes,
            added_by: TicketActorDto::from(attachment.added_by),
            added_at: attachment.added_at,
            summarized_in_carnet: attachment.summarized_in_carnet,
            summarized_by: attachment.summarized_by.map(TicketActorDto::from),
            summarized_at: attachment.summarized_at,
        }
    }
}

impl From<IssueAttachmentContent> for TicketAttachmentContentDto {
    fn from(content: IssueAttachmentContent) -> Self {
        Self {
            attachment: TicketAttachmentDto::from(content.attachment),
            content_base64: URL_SAFE_NO_PAD.encode(content.bytes),
        }
    }
}

impl TryFrom<TicketCreatorFilterDto> for IssueActor {
    type Error = ErrorDto;

    fn try_from(actor: TicketCreatorFilterDto) -> Result<Self, Self::Error> {
        match actor {
            TicketCreatorFilterDto::User => Ok(Self::User),
            TicketCreatorFilterDto::Agent { agent_id } => Ok(Self::Agent {
                agent_id: parse_agent_id_dto(&agent_id)?,
            }),
        }
    }
}

pub fn parse_attachment_id_dto(raw: &str) -> Result<IssueAttachmentId, ErrorDto> {
    IssueAttachmentId::new(raw.to_owned()).map_err(|err| ErrorDto {
        code: "INVALID".to_owned(),
        message: err.to_string(),
    })
}

impl From<IssueCarnet> for TicketCarnetDto {
    fn from(carnet: IssueCarnet) -> Self {
        Self {
            r#ref: carnet.issue_ref.to_string(),
            carnet: carnet.carnet.as_str().to_owned(),
            version: carnet.version.get(),
        }
    }
}

#[derive(Debug)]
pub struct TicketListPageInput {
    pub filter: IssueListFilter,
    pub sort: Option<TicketListSortDto>,
    pub limit: usize,
    pub cursor: Option<String>,
}

impl TicketListPageInput {
    pub fn from_request(request: TicketListRequestDto) -> Result<Self, ErrorDto> {
        Ok(Self {
            filter: IssueListFilter {
                statuses: parse_statuses_dto(request.statuses)?,
                priorities: parse_priorities_dto(request.priorities)?,
                assigned_agent_id: request
                    .assigned_agent_id
                    .as_deref()
                    .map(parse_agent_id_dto)
                    .transpose()?,
                created_by: request.created_by.map(IssueActor::try_from).transpose()?,
                sprint: request
                    .sprint_id
                    .as_deref()
                    .map(parse_sprint_id_dto)
                    .transpose()?,
                text: request.text,
            },
            sort: request.sort,
            limit: request.limit.unwrap_or(100).clamp(1, 500),
            cursor: request.cursor,
        })
    }
}

pub fn sort_ticket_rows(rows: &mut [IssueIndexEntry], sort: Option<TicketListSortDto>) {
    let Some(sort) = sort else {
        return;
    };

    rows.sort_by(|a, b| {
        let field_order = match sort.field {
            TicketListSortFieldDto::Number => {
                a.issue_ref.number().get().cmp(&b.issue_ref.number().get())
            }
            TicketListSortFieldDto::Priority => {
                priority_rank(a.priority).cmp(&priority_rank(b.priority))
            }
            TicketListSortFieldDto::Status => status_rank(a.status).cmp(&status_rank(b.status)),
            TicketListSortFieldDto::Title => a
                .title
                .to_lowercase()
                .cmp(&b.title.to_lowercase())
                .then_with(|| a.title.cmp(&b.title)),
        };
        let directed = match sort.direction {
            TicketListSortDirectionDto::Asc => field_order,
            TicketListSortDirectionDto::Desc => field_order.reverse(),
        };
        directed.then_with(|| a.issue_ref.number().get().cmp(&b.issue_ref.number().get()))
    });
}

fn priority_rank(priority: IssuePriority) -> u8 {
    match priority {
        IssuePriority::Low => 0,
        IssuePriority::Medium => 1,
        IssuePriority::High => 2,
        IssuePriority::Critical => 3,
    }
}

fn status_rank(status: IssueStatus) -> u8 {
    match status {
        IssueStatus::Open => 0,
        IssueStatus::InProgress => 1,
        IssueStatus::Qa => 2,
        IssueStatus::Closed => 3,
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct TicketCursorToken {
    v: u8,
    sort: Option<TicketListSortDto>,
    anchor: TicketCursorAnchor,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct TicketCursorAnchor {
    number: u64,
    sort_key: TicketCursorSortKey,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind", content = "value")]
enum TicketCursorSortKey {
    Number(u64),
    Priority(u8),
    Status(u8),
    Title { lower: String, raw: String },
}

pub fn create_input(
    project: Project,
    request: TicketCreateRequestDto,
    actor: IssueActor,
) -> Result<CreateIssueInput, ErrorDto> {
    Ok(CreateIssueInput {
        project,
        title: request.title,
        description: request.description.unwrap_or_default(),
        priority: request
            .priority
            .as_deref()
            .map(parse_priority_dto)
            .transpose()?
            .unwrap_or(IssuePriority::Medium),
        status: request
            .status
            .as_deref()
            .map(parse_status_dto)
            .transpose()?
            .unwrap_or(IssueStatus::Open),
        links: request
            .links
            .unwrap_or_default()
            .into_iter()
            .map(parse_link_request)
            .collect::<Result<Vec<_>, _>>()?,
        assigned_agent_ids: request
            .assigned_agent_ids
            .unwrap_or_default()
            .iter()
            .map(|id| parse_agent_id_dto(id))
            .collect::<Result<Vec<_>, _>>()?,
        actor,
    })
}

pub fn update_input(
    project: Project,
    request: TicketUpdateRequestDto,
    actor: IssueActor,
) -> Result<UpdateIssueInput, ErrorDto> {
    Ok(UpdateIssueInput {
        project,
        issue_ref: parse_ref_dto(&request.r#ref)?,
        expected_version: version_dto(request.expected_version)?,
        title: request.title,
        description: request.description,
        status: request
            .status
            .as_deref()
            .map(parse_status_dto)
            .transpose()?,
        priority: request
            .priority
            .as_deref()
            .map(parse_priority_dto)
            .transpose()?,
        assigned_agent_ids: request
            .assigned_agent_ids
            .map(|ids| ids.iter().map(|id| parse_agent_id_dto(id)).collect())
            .transpose()?,
        actor,
    })
}

pub fn bulk_update_status_input(
    project: Project,
    request: TicketBulkUpdateStatusRequestDto,
    actor: IssueActor,
) -> Result<BulkUpdateIssueStatusInput, ErrorDto> {
    Ok(BulkUpdateIssueStatusInput {
        project,
        issue_refs: parse_bulk_refs(request.refs)?,
        status: parse_status_dto(&request.status)?,
        actor,
    })
}

pub fn bulk_update_priority_input(
    project: Project,
    request: TicketBulkUpdatePriorityRequestDto,
    actor: IssueActor,
) -> Result<BulkUpdateIssuePriorityInput, ErrorDto> {
    Ok(BulkUpdateIssuePriorityInput {
        project,
        issue_refs: parse_bulk_refs(request.refs)?,
        priority: parse_priority_dto(&request.priority)?,
        actor,
    })
}

pub fn bulk_delete_input(
    project: Project,
    request: TicketBulkDeleteRequestDto,
) -> Result<BulkDeleteIssuesInput, ErrorDto> {
    Ok(BulkDeleteIssuesInput {
        project,
        issue_refs: parse_bulk_refs(request.refs)?,
    })
}

impl From<BulkIssueMutationOutput> for TicketBulkResultDto {
    fn from(output: BulkIssueMutationOutput) -> Self {
        Self {
            items: output
                .items
                .into_iter()
                .map(|item| TicketBulkResultItemDto {
                    r#ref: item.issue_ref.to_string(),
                    ok: item.error.is_none(),
                    ticket: item.issue.map(|issue| TicketDto::from_issue(issue, None)),
                    error: item.error.map(ErrorDto::from),
                })
                .collect(),
        }
    }
}

fn parse_link_request(link: TicketLinkRequestDto) -> Result<IssueLink, ErrorDto> {
    Ok(IssueLink {
        target: parse_ref_dto(&link.target_ref)?,
        kind: parse_link_kind_dto(&link.kind)?,
    })
}

fn parse_bulk_refs(raw: Vec<String>) -> Result<Vec<IssueRef>, ErrorDto> {
    if raw.is_empty() {
        return Err(ErrorDto::invalid("refs must not be empty"));
    }
    let mut refs = Vec::with_capacity(raw.len());
    let mut seen = std::collections::HashSet::with_capacity(raw.len());
    for item in raw {
        let issue_ref = parse_ref_dto(&item)?;
        if !seen.insert(issue_ref) {
            return Err(ErrorDto::invalid(format!(
                "duplicate ticket ref: {issue_ref}"
            )));
        }
        refs.push(issue_ref);
    }
    Ok(refs)
}

pub fn paginate(
    rows: Vec<IssueIndexEntry>,
    limit: usize,
    cursor: Option<String>,
    sort: Option<TicketListSortDto>,
) -> Result<TicketListDto, ErrorDto> {
    paginate_rows(rows, limit, cursor, sort, TicketSummaryDto::from)
}

pub fn paginate_with_sprints(
    rows: Vec<IssueIndexEntry>,
    limit: usize,
    cursor: Option<String>,
    sort: Option<TicketListSortDto>,
    sprints: &[application::SprintListEntry],
) -> Result<TicketListDto, ErrorDto> {
    paginate_rows(rows, limit, cursor, sort, |row| {
        TicketSummaryDto::from_row_with_sprints(row, sprints)
    })
}

fn paginate_rows(
    rows: Vec<IssueIndexEntry>,
    limit: usize,
    cursor: Option<String>,
    sort: Option<TicketListSortDto>,
    map_row: impl Fn(IssueIndexEntry) -> TicketSummaryDto,
) -> Result<TicketListDto, ErrorDto> {
    let start = cursor_start(&rows, cursor.as_deref(), sort)?;
    let total = rows.len();
    let end = total.min(start.saturating_add(limit));
    let next_cursor = if end < total && end > start {
        Some(encode_ticket_cursor(&rows[end - 1], sort)?)
    } else {
        None
    };
    Ok(TicketListDto {
        items: rows
            .into_iter()
            .skip(start)
            .take(limit)
            .map(map_row)
            .collect(),
        next_cursor,
    })
}

fn cursor_start(
    rows: &[IssueIndexEntry],
    cursor: Option<&str>,
    sort: Option<TicketListSortDto>,
) -> Result<usize, ErrorDto> {
    let Some(raw) = cursor else {
        return Ok(0);
    };
    let token = decode_ticket_cursor(raw)?;
    if token.sort != sort {
        return Err(ErrorDto::invalid("Invalid cursor: sort mismatch"));
    }
    Ok(rows
        .iter()
        .position(|row| compare_row_to_anchor(row, &token.anchor, sort) == Ordering::Greater)
        .unwrap_or(rows.len()))
}

fn encode_ticket_cursor(
    row: &IssueIndexEntry,
    sort: Option<TicketListSortDto>,
) -> Result<String, ErrorDto> {
    let token = TicketCursorToken {
        v: 1,
        sort,
        anchor: row_cursor_anchor(row, sort),
    };
    let json = serde_json::to_vec(&token)
        .map_err(|err| ErrorDto::invalid(format!("Invalid cursor: {err}")))?;
    Ok(format!("v1.{}", URL_SAFE_NO_PAD.encode(json)))
}

fn decode_ticket_cursor(raw: &str) -> Result<TicketCursorToken, ErrorDto> {
    let encoded = raw
        .strip_prefix("v1.")
        .ok_or_else(|| ErrorDto::invalid("Invalid cursor: unknown version"))?;
    let bytes = URL_SAFE_NO_PAD
        .decode(encoded)
        .map_err(|err| ErrorDto::invalid(format!("Invalid cursor: {err}")))?;
    let token: TicketCursorToken = serde_json::from_slice(&bytes)
        .map_err(|err| ErrorDto::invalid(format!("Invalid cursor: {err}")))?;
    if token.v != 1 {
        return Err(ErrorDto::invalid("Invalid cursor: unknown version"));
    }
    Ok(token)
}

fn row_cursor_anchor(row: &IssueIndexEntry, sort: Option<TicketListSortDto>) -> TicketCursorAnchor {
    TicketCursorAnchor {
        number: row.issue_ref.number().get(),
        sort_key: row_sort_key(row, sort),
    }
}

fn row_sort_key(row: &IssueIndexEntry, sort: Option<TicketListSortDto>) -> TicketCursorSortKey {
    match sort.map(|sort| sort.field) {
        None | Some(TicketListSortFieldDto::Number) => {
            TicketCursorSortKey::Number(row.issue_ref.number().get())
        }
        Some(TicketListSortFieldDto::Priority) => {
            TicketCursorSortKey::Priority(priority_rank(row.priority))
        }
        Some(TicketListSortFieldDto::Status) => {
            TicketCursorSortKey::Status(status_rank(row.status))
        }
        Some(TicketListSortFieldDto::Title) => TicketCursorSortKey::Title {
            lower: row.title.to_lowercase(),
            raw: row.title.clone(),
        },
    }
}

fn compare_row_to_anchor(
    row: &IssueIndexEntry,
    anchor: &TicketCursorAnchor,
    sort: Option<TicketListSortDto>,
) -> Ordering {
    let row_key = row_sort_key(row, sort);
    let field_order = compare_sort_key(&row_key, &anchor.sort_key);
    let directed = match sort.map(|sort| sort.direction) {
        Some(TicketListSortDirectionDto::Desc) => field_order.reverse(),
        None | Some(TicketListSortDirectionDto::Asc) => field_order,
    };
    directed.then_with(|| row.issue_ref.number().get().cmp(&anchor.number))
}

fn compare_sort_key(a: &TicketCursorSortKey, b: &TicketCursorSortKey) -> Ordering {
    match (a, b) {
        (TicketCursorSortKey::Number(a), TicketCursorSortKey::Number(b)) => a.cmp(b),
        (TicketCursorSortKey::Priority(a), TicketCursorSortKey::Priority(b))
        | (TicketCursorSortKey::Status(a), TicketCursorSortKey::Status(b)) => a.cmp(b),
        (
            TicketCursorSortKey::Title { lower: al, raw: ar },
            TicketCursorSortKey::Title { lower: bl, raw: br },
        ) => al.cmp(bl).then_with(|| ar.cmp(br)),
        _ => Ordering::Equal,
    }
}

fn ticket_sprint_context(
    sprint_id: SprintId,
    sprints: &[application::SprintListEntry],
) -> Option<TicketSprintContextDto> {
    sprints
        .iter()
        .find(|entry| entry.sprint.id == sprint_id)
        .map(|entry| TicketSprintContextDto {
            order: entry.sprint.order.get(),
            name: entry.sprint.name.clone(),
        })
}

pub fn actor_from_requester(requester: &str) -> IssueActor {
    Uuid::parse_str(requester)
        .ok()
        .map(|uuid| IssueActor::Agent {
            agent_id: AgentId::from_uuid(uuid),
        })
        .unwrap_or(IssueActor::User)
}

pub fn parse_ref_dto(raw: &str) -> Result<IssueRef, ErrorDto> {
    IssueRef::from_str(raw).map_err(|e| ErrorDto::invalid(e.to_string()))
}

pub fn parse_agent_id_dto(raw: &str) -> Result<AgentId, ErrorDto> {
    Uuid::parse_str(raw)
        .map(AgentId::from_uuid)
        .map_err(|e| ErrorDto::invalid(format!("invalid agent id: {e}")))
}

pub fn parse_profile_id_dto(raw: &str) -> Result<ProfileId, ErrorDto> {
    Uuid::parse_str(raw)
        .map(ProfileId::from_uuid)
        .map_err(|e| ErrorDto::invalid(format!("invalid profile id: {e}")))
}

pub fn parse_sprint_id_dto(raw: &str) -> Result<SprintId, ErrorDto> {
    Uuid::parse_str(raw)
        .map(SprintId::from_uuid)
        .map_err(|e| ErrorDto::invalid(format!("invalid sprint id: {e}")))
}

pub fn version_dto(raw: u64) -> Result<IssueVersion, ErrorDto> {
    IssueVersion::new(raw).map_err(|e| ErrorDto::invalid(e.to_string()))
}

pub fn sprint_version_dto(raw: u64) -> Result<SprintVersion, ErrorDto> {
    SprintVersion::new(raw).map_err(|e| ErrorDto::invalid(e.to_string()))
}

pub fn parse_status_dto(raw: &str) -> Result<IssueStatus, ErrorDto> {
    parse_status(raw).map_err(|e| ErrorDto::invalid(e.message))
}

pub fn parse_priority_dto(raw: &str) -> Result<IssuePriority, ErrorDto> {
    parse_priority(raw).map_err(|e| ErrorDto::invalid(e.message))
}

fn parse_statuses_dto(raw: Vec<String>) -> Result<Vec<IssueStatus>, ErrorDto> {
    let mut out = Vec::new();
    for item in raw {
        let status = parse_status_dto(&item)?;
        if !out.contains(&status) {
            out.push(status);
        }
    }
    Ok(out)
}

fn parse_priorities_dto(raw: Vec<String>) -> Result<Vec<IssuePriority>, ErrorDto> {
    let mut out = Vec::new();
    for item in raw {
        let priority = parse_priority_dto(&item)?;
        if !out.contains(&priority) {
            out.push(priority);
        }
    }
    Ok(out)
}

pub fn parse_link_kind_dto(raw: &str) -> Result<IssueLinkKind, ErrorDto> {
    parse_link_kind(raw).map_err(|e| ErrorDto::invalid(e.message))
}

pub fn parse_sprint_status_dto(raw: &str) -> Result<SprintStatus, ErrorDto> {
    match raw {
        "planned" => Ok(SprintStatus::Planned),
        "active" => Ok(SprintStatus::Active),
        "done" => Ok(SprintStatus::Done),
        _ => Err(ErrorDto::invalid(format!("invalid sprint status: {raw}"))),
    }
}

pub fn parse_status(raw: &str) -> Result<IssueStatus, TicketToolError> {
    match raw {
        "open" => Ok(IssueStatus::Open),
        "inProgress" => Ok(IssueStatus::InProgress),
        "QA" => Ok(IssueStatus::Qa),
        "closed" => Ok(IssueStatus::Closed),
        _ => Err(TicketToolError::new(
            "invalid",
            format!("invalid ticket status: {raw}"),
        )),
    }
}

pub fn parse_priority(raw: &str) -> Result<IssuePriority, TicketToolError> {
    match raw {
        "low" => Ok(IssuePriority::Low),
        "medium" => Ok(IssuePriority::Medium),
        "high" => Ok(IssuePriority::High),
        "critical" => Ok(IssuePriority::Critical),
        _ => Err(TicketToolError::new(
            "invalid",
            format!("invalid ticket priority: {raw}"),
        )),
    }
}

pub fn parse_link_kind(raw: &str) -> Result<IssueLinkKind, TicketToolError> {
    match raw {
        "relatesTo" => Ok(IssueLinkKind::RelatesTo),
        "blocks" => Ok(IssueLinkKind::Blocks),
        "blockedBy" => Ok(IssueLinkKind::BlockedBy),
        "duplicates" => Ok(IssueLinkKind::Duplicates),
        "dependsOn" => Ok(IssueLinkKind::DependsOn),
        _ => Err(TicketToolError::new(
            "invalid",
            format!("invalid ticket link kind: {raw}"),
        )),
    }
}

fn status_wire(status: IssueStatus) -> &'static str {
    match status {
        IssueStatus::Open => "open",
        IssueStatus::InProgress => "inProgress",
        IssueStatus::Qa => "QA",
        IssueStatus::Closed => "closed",
    }
}

fn priority_wire(priority: IssuePriority) -> &'static str {
    match priority {
        IssuePriority::Low => "low",
        IssuePriority::Medium => "medium",
        IssuePriority::High => "high",
        IssuePriority::Critical => "critical",
    }
}

fn link_kind_wire(kind: IssueLinkKind) -> &'static str {
    match kind {
        IssueLinkKind::RelatesTo => "relatesTo",
        IssueLinkKind::Blocks => "blocks",
        IssueLinkKind::BlockedBy => "blockedBy",
        IssueLinkKind::Duplicates => "duplicates",
        IssueLinkKind::DependsOn => "dependsOn",
    }
}

fn sprint_status_wire(status: SprintStatus) -> &'static str {
    match status {
        SprintStatus::Planned => "planned",
        SprintStatus::Active => "active",
        SprintStatus::Done => "done",
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use domain::IssueNumber;

    #[test]
    fn ticket_list_request_deduplicates_multi_select_filters() {
        let page = TicketListPageInput::from_request(TicketListRequestDto {
            project_id: String::new(),
            statuses: vec!["open".into(), "closed".into(), "open".into()],
            priorities: vec!["high".into(), "low".into(), "high".into()],
            assigned_agent_id: None,
            created_by: None,
            sprint_id: None,
            text: None,
            sort: None,
            limit: None,
            cursor: None,
        })
        .unwrap();

        assert_eq!(
            page.filter.statuses,
            vec![IssueStatus::Open, IssueStatus::Closed]
        );
        assert_eq!(
            page.filter.priorities,
            vec![IssuePriority::High, IssuePriority::Low]
        );
    }

    #[test]
    fn ticket_list_request_rejects_invalid_multi_select_token() {
        let err = TicketListPageInput::from_request(TicketListRequestDto {
            project_id: String::new(),
            statuses: vec!["open".into(), "bad".into()],
            priorities: Vec::new(),
            assigned_agent_id: None,
            created_by: None,
            sprint_id: None,
            text: None,
            sort: None,
            limit: None,
            cursor: None,
        })
        .unwrap_err();

        assert_eq!(err.code, "INVALID");
        assert!(err.message.contains("invalid ticket status: bad"));
    }

    #[test]
    fn ticket_list_request_parses_creator_filter() {
        let agent_id = AgentId::new_random();
        let page = TicketListPageInput::from_request(TicketListRequestDto {
            project_id: String::new(),
            statuses: Vec::new(),
            priorities: Vec::new(),
            assigned_agent_id: None,
            created_by: Some(TicketCreatorFilterDto::Agent {
                agent_id: agent_id.to_string(),
            }),
            sprint_id: None,
            text: None,
            sort: None,
            limit: None,
            cursor: None,
        })
        .unwrap();

        assert_eq!(page.filter.created_by, Some(IssueActor::Agent { agent_id }));
    }

    #[test]
    fn ticket_list_pagination_preserves_multi_filter_request_shape() {
        let page = TicketListPageInput::from_request(TicketListRequestDto {
            project_id: String::new(),
            statuses: vec!["open".into(), "QA".into()],
            priorities: vec!["high".into()],
            assigned_agent_id: None,
            created_by: None,
            sprint_id: None,
            text: None,
            sort: None,
            limit: Some(1),
            cursor: None,
        })
        .unwrap();
        let rows = vec![
            issue_row(1, IssueStatus::Open, IssuePriority::High, "Alpha"),
            issue_row(2, IssueStatus::Qa, IssuePriority::High, "Beta"),
            issue_row(3, IssueStatus::Qa, IssuePriority::High, "Gamma"),
        ];

        assert_eq!(
            page.filter.statuses,
            vec![IssueStatus::Open, IssueStatus::Qa]
        );
        assert_eq!(page.filter.priorities, vec![IssuePriority::High]);
        let out = paginate(rows, page.limit, page.cursor, page.sort).unwrap();
        assert_eq!(out.items.len(), 1);
        assert_eq!(out.items[0].r#ref, "#1");
        assert!(out
            .next_cursor
            .as_deref()
            .is_some_and(|cursor| cursor.starts_with("v1.")));
    }

    #[test]
    fn ticket_summary_exposes_created_by() {
        let agent_id = AgentId::new_random();
        let mut row = issue_row(1, IssueStatus::Open, IssuePriority::High, "Alpha");
        row.created_by = IssueActor::Agent { agent_id };

        let dto = TicketSummaryDto::from(row);

        assert!(matches!(
            dto.created_by,
            TicketActorDto::Agent { agent_id: id } if id == agent_id.to_string()
        ));
    }

    #[test]
    fn bulk_input_rejects_empty_refs() {
        let err = bulk_update_status_input(
            project_for_tests(),
            TicketBulkUpdateStatusRequestDto {
                project_id: String::new(),
                refs: Vec::new(),
                status: "closed".into(),
            },
            IssueActor::User,
        )
        .unwrap_err();

        assert_eq!(err.code, "INVALID");
        assert!(err.message.contains("refs must not be empty"));
    }

    #[test]
    fn bulk_input_rejects_duplicate_refs() {
        let err = bulk_update_status_input(
            project_for_tests(),
            TicketBulkUpdateStatusRequestDto {
                project_id: String::new(),
                refs: vec!["#1".into(), "#1".into()],
                status: "closed".into(),
            },
            IssueActor::User,
        )
        .unwrap_err();

        assert_eq!(err.code, "INVALID");
        assert!(err.message.contains("duplicate ticket ref: #1"));
    }

    #[test]
    fn bulk_delete_input_maps_refs_in_order() {
        let input = bulk_delete_input(
            project_for_tests(),
            TicketBulkDeleteRequestDto {
                project_id: String::new(),
                refs: vec!["#2".into(), "#1".into()],
            },
        )
        .unwrap();

        assert_eq!(
            input
                .issue_refs
                .iter()
                .map(ToString::to_string)
                .collect::<Vec<_>>(),
            vec!["#2", "#1"]
        );
    }

    #[test]
    fn actor_from_requester_maps_agent_uuid_else_user() {
        let agent_id = AgentId::new_random();

        assert_eq!(
            actor_from_requester(&agent_id.to_string()),
            IssueActor::Agent { agent_id }
        );
        assert_eq!(actor_from_requester("mcp"), IssueActor::User);
    }

    #[test]
    fn ticket_list_sort_priority_is_semantic_with_number_tie_breaker() {
        let page = TicketListPageInput::from_request(TicketListRequestDto {
            project_id: String::new(),
            statuses: Vec::new(),
            priorities: Vec::new(),
            assigned_agent_id: None,
            created_by: None,
            sprint_id: None,
            text: None,
            sort: Some(TicketListSortDto {
                field: TicketListSortFieldDto::Priority,
                direction: TicketListSortDirectionDto::Desc,
            }),
            limit: None,
            cursor: None,
        })
        .unwrap();
        let mut rows = vec![
            issue_row(4, IssueStatus::Open, IssuePriority::High, "Delta"),
            issue_row(2, IssueStatus::Closed, IssuePriority::Critical, "Beta"),
            issue_row(3, IssueStatus::Qa, IssuePriority::High, "Gamma"),
            issue_row(1, IssueStatus::Open, IssuePriority::Low, "Alpha"),
        ];

        sort_ticket_rows(&mut rows, page.sort);
        let out = paginate(rows, page.limit, page.cursor, page.sort).unwrap();

        assert_eq!(
            out.items
                .into_iter()
                .map(|item| item.r#ref)
                .collect::<Vec<_>>(),
            vec!["#2", "#3", "#4", "#1"]
        );
    }

    #[test]
    fn ticket_list_cursor_is_anchor_based_when_items_are_inserted_or_removed_before_anchor() {
        let rows = vec![
            issue_row(10, IssueStatus::Open, IssuePriority::High, "Alpha"),
            issue_row(20, IssueStatus::Open, IssuePriority::High, "Beta"),
            issue_row(30, IssueStatus::Open, IssuePriority::High, "Gamma"),
            issue_row(40, IssueStatus::Open, IssuePriority::High, "Delta"),
        ];
        let first = paginate(rows, 2, None, None).unwrap();
        let cursor = first.next_cursor.clone().expect("next cursor");
        assert_eq!(refs(&first), vec!["#10", "#20"]);

        let with_insert_before_anchor = vec![
            issue_row(10, IssueStatus::Open, IssuePriority::High, "Alpha"),
            issue_row(15, IssueStatus::Open, IssuePriority::High, "Inserted"),
            issue_row(20, IssueStatus::Open, IssuePriority::High, "Beta"),
            issue_row(30, IssueStatus::Open, IssuePriority::High, "Gamma"),
            issue_row(40, IssueStatus::Open, IssuePriority::High, "Delta"),
        ];
        let second = paginate(with_insert_before_anchor, 2, Some(cursor.clone()), None).unwrap();
        assert_eq!(refs(&second), vec!["#30", "#40"]);

        let with_removed_before_anchor = vec![
            issue_row(20, IssueStatus::Open, IssuePriority::High, "Beta"),
            issue_row(30, IssueStatus::Open, IssuePriority::High, "Gamma"),
            issue_row(40, IssueStatus::Open, IssuePriority::High, "Delta"),
        ];
        let second = paginate(with_removed_before_anchor, 2, Some(cursor.clone()), None).unwrap();
        assert_eq!(refs(&second), vec!["#30", "#40"]);

        let with_removed_anchor = vec![
            issue_row(10, IssueStatus::Open, IssuePriority::High, "Alpha"),
            issue_row(30, IssueStatus::Open, IssuePriority::High, "Gamma"),
            issue_row(40, IssueStatus::Open, IssuePriority::High, "Delta"),
        ];
        let second = paginate(with_removed_anchor, 2, Some(cursor), None).unwrap();
        assert_eq!(refs(&second), vec!["#30", "#40"]);
    }

    #[test]
    fn ticket_list_cursor_rejects_legacy_or_invalid_tokens() {
        let rows = vec![issue_row(
            1,
            IssueStatus::Open,
            IssuePriority::High,
            "Alpha",
        )];

        for cursor in ["2", "v1.not-base64", "v2.abc"] {
            let err = paginate(rows.clone(), 1, Some(cursor.to_owned()), None).unwrap_err();
            assert_eq!(err.code, "INVALID");
            assert!(err.message.contains("Invalid cursor"));
        }
    }

    #[test]
    fn ticket_list_cursor_rejects_sort_mismatch() {
        let mut rows = vec![
            issue_row(1, IssueStatus::Open, IssuePriority::Low, "Alpha"),
            issue_row(2, IssueStatus::Open, IssuePriority::Critical, "Beta"),
            issue_row(3, IssueStatus::Open, IssuePriority::High, "Gamma"),
        ];
        let priority_sort = Some(TicketListSortDto {
            field: TicketListSortFieldDto::Priority,
            direction: TicketListSortDirectionDto::Desc,
        });
        sort_ticket_rows(&mut rows, priority_sort);
        let first = paginate(rows.clone(), 1, None, priority_sort).unwrap();
        let cursor = first.next_cursor.expect("next cursor");

        let err = paginate(rows, 1, Some(cursor), None).unwrap_err();

        assert_eq!(err.code, "INVALID");
        assert!(err.message.contains("sort mismatch"));
    }

    fn issue_row(
        number: u64,
        status: IssueStatus,
        priority: IssuePriority,
        title: &str,
    ) -> IssueIndexEntry {
        IssueIndexEntry {
            issue_ref: IssueRef::from(IssueNumber::new(number).unwrap()),
            path: number.to_string(),
            title: title.to_owned(),
            status,
            priority,
            sprint: None,
            assigned_agent_ids: Vec::new(),
            created_by: IssueActor::User,
            updated_at: number,
        }
    }

    fn refs(out: &TicketListDto) -> Vec<String> {
        out.items.iter().map(|item| item.r#ref.clone()).collect()
    }

    fn project_for_tests() -> Project {
        Project::new(
            domain::ProjectId::new_random(),
            "IdeA",
            domain::ProjectPath::new("/tmp/idea").unwrap(),
            domain::RemoteRef::Local,
            0,
        )
        .unwrap()
    }
}
