//! Public ticket adapters (Tauri commands + MCP provider).
//!
//! Public wire names use `ticket`; the application/domain model remains `Issue`.

#![allow(missing_docs)]

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

use async_trait::async_trait;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tauri::State;
use uuid::Uuid;

use application::{
    AppError, AssignIssueAgentInput, AssignTicketToSprintInput, CloseTicketAssistantInput,
    CreateIssueInput, CreateSprintInput, DeleteIssueInput, DeleteSprintInput, LinkIssuesInput,
    ListIssuesInput, ListSprintsInput, OpenProjectInput, OpenTicketAssistantInput,
    ReadIssueCarnetInput, ReadIssueInput, RenameSprintInput, ReorderSprintsInput,
    UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateIssueCarnetInput, UpdateIssueInput,
};
use domain::{
    AgentId, AgentIssueRole, Issue, IssueActor, IssueCarnet, IssueIndexEntry, IssueLink,
    IssueLinkKind, IssueListFilter, IssuePriority, IssueRef, IssueStatus, IssueVersion, ProfileId,
    Project, ProjectId, SprintId, SprintStatus, SprintVersion,
};
use infrastructure::{TicketToolError, TicketToolProvider};

use crate::dto::ErrorDto;
use crate::state::AppState;

/// 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 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 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>,
}

/// Sprint context embedded in MCP 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,
}

/// 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,
}

/// 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 sprint_id: Option<String>,
    pub text: Option<String>,
    pub sort: Option<TicketListSortDto>,
    pub limit: Option<usize>,
    pub cursor: Option<String>,
}

/// 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,
}

#[derive(Clone)]
pub struct AppTicketToolProvider {
    pub create: Arc<application::CreateIssue>,
    pub read: Arc<application::ReadIssue>,
    pub list: Arc<application::ListIssues>,
    pub update: Arc<application::UpdateIssue>,
    pub read_carnet: Arc<application::ReadIssueCarnet>,
    pub update_carnet: Arc<application::UpdateIssueCarnet>,
    pub link: Arc<application::LinkIssues>,
    pub unlink: Arc<application::UnlinkIssues>,
    pub list_sprints: Arc<application::ListSprints>,
}

#[async_trait]
impl TicketToolProvider for AppTicketToolProvider {
    async fn handle_ticket_tool(
        &self,
        project: &Project,
        requester: &str,
        name: &str,
        arguments: Value,
    ) -> Result<Value, TicketToolError> {
        let actor = actor_from_requester(requester);
        let result = match name {
            "idea_ticket_create" => {
                let req = mcp_create_request(project, arguments)?;
                let issue = self
                    .create
                    .execute(create_input(project.clone(), req, actor).map_err(dto_tool_error)?)
                    .await
                    .map_err(ticket_error)?
                    .issue;
                json!(TicketDto::from_issue(issue, None))
            }
            "idea_ticket_read" => {
                let issue_ref = parse_json_ref(&arguments, "ref")?;
                let include_carnet = arguments
                    .get("includeCarnet")
                    .and_then(Value::as_bool)
                    .unwrap_or(false);
                let issue = self
                    .read
                    .execute(ReadIssueInput {
                        project: project.clone(),
                        issue_ref,
                    })
                    .await
                    .map_err(ticket_error)?
                    .issue;
                let carnet = if include_carnet {
                    Some(read_carnet_body(&*self.read_carnet, project, issue_ref).await?)
                } else {
                    None
                };
                let sprints = self
                    .list_sprints
                    .execute(ListSprintsInput {
                        project: project.clone(),
                    })
                    .await
                    .map_err(ticket_error)?
                    .sprints;
                json!(TicketDto::from_issue_with_sprints(issue, carnet, &sprints))
            }
            "idea_ticket_list" => {
                let req = mcp_list_request(project, arguments)?;
                let mut rows = self
                    .list
                    .execute(ListIssuesInput {
                        project: project.clone(),
                        filter: req.filter,
                    })
                    .await
                    .map_err(ticket_error)?
                    .issues;
                sort_ticket_rows(&mut rows, req.sort);
                let sprints = self
                    .list_sprints
                    .execute(ListSprintsInput {
                        project: project.clone(),
                    })
                    .await
                    .map_err(ticket_error)?
                    .sprints;
                let page = paginate_with_sprints(rows, req.limit, req.cursor, req.sort, &sprints)
                    .map_err(|e| TicketToolError::new("invalid", e.message))?;
                json!(page)
            }
            "idea_sprint_list" => {
                let rows = self
                    .list_sprints
                    .execute(ListSprintsInput {
                        project: project.clone(),
                    })
                    .await
                    .map_err(ticket_error)?
                    .sprints;
                json!(SprintListDto::from(rows))
            }
            "idea_ticket_update" => {
                let req = mcp_update_request(project, arguments)?;
                let issue = self
                    .update
                    .execute(update_input(project.clone(), req, actor).map_err(dto_tool_error)?)
                    .await
                    .map_err(ticket_error)?
                    .issue;
                json!(TicketDto::from_issue(issue, None))
            }
            "idea_ticket_update_status" => {
                let issue = self
                    .update
                    .execute(UpdateIssueInput {
                        project: project.clone(),
                        issue_ref: parse_json_ref(&arguments, "ref")?,
                        expected_version: parse_json_version(&arguments)?,
                        title: None,
                        description: None,
                        status: Some(parse_status(required_str(&arguments, "status")?)?),
                        priority: None,
                        assigned_agent_ids: None,
                        actor,
                    })
                    .await
                    .map_err(ticket_error)?
                    .issue;
                json!(TicketDto::from_issue(issue, None))
            }
            "idea_ticket_update_priority" => {
                let issue = self
                    .update
                    .execute(UpdateIssueInput {
                        project: project.clone(),
                        issue_ref: parse_json_ref(&arguments, "ref")?,
                        expected_version: parse_json_version(&arguments)?,
                        title: None,
                        description: None,
                        status: None,
                        priority: Some(parse_priority(required_str(&arguments, "priority")?)?),
                        assigned_agent_ids: None,
                        actor,
                    })
                    .await
                    .map_err(ticket_error)?
                    .issue;
                json!(TicketDto::from_issue(issue, None))
            }
            "idea_ticket_read_carnet" => {
                let carnet = self
                    .read_carnet
                    .execute(ReadIssueCarnetInput {
                        project: project.clone(),
                        issue_ref: parse_json_ref(&arguments, "ref")?,
                    })
                    .await
                    .map_err(ticket_error)?
                    .carnet;
                json!(TicketCarnetDto::from(carnet))
            }
            "idea_ticket_update_carnet" => {
                let issue_ref = parse_json_ref(&arguments, "ref")?;
                self.update_carnet
                    .execute(UpdateIssueCarnetInput {
                        project: project.clone(),
                        issue_ref,
                        expected_version: parse_json_version(&arguments)?,
                        carnet: required_str(&arguments, "carnet")?.to_owned(),
                        actor,
                    })
                    .await
                    .map_err(ticket_error)?;
                let issue = self
                    .read
                    .execute(ReadIssueInput {
                        project: project.clone(),
                        issue_ref,
                    })
                    .await
                    .map_err(ticket_error)?
                    .issue;
                json!(TicketDto::from_issue(
                    issue,
                    Some(read_carnet_body(&*self.read_carnet, project, issue_ref).await?)
                ))
            }
            "idea_ticket_link" => {
                let issue = self
                    .link
                    .execute(LinkIssuesInput {
                        project: project.clone(),
                        issue_ref: parse_json_ref(&arguments, "ref")?,
                        target: parse_json_ref(&arguments, "targetRef")?,
                        kind: parse_link_kind(required_str(&arguments, "kind")?)?,
                        expected_version: parse_json_version(&arguments)?,
                        actor,
                    })
                    .await
                    .map_err(ticket_error)?
                    .issue;
                json!(TicketDto::from_issue(issue, None))
            }
            "idea_ticket_unlink" => {
                let kind = arguments
                    .get("kind")
                    .and_then(Value::as_str)
                    .map(parse_link_kind)
                    .transpose()?;
                let issue = self
                    .unlink
                    .execute(UnlinkIssuesInput {
                        project: project.clone(),
                        issue_ref: parse_json_ref(&arguments, "ref")?,
                        target: parse_json_ref(&arguments, "targetRef")?,
                        kind,
                        expected_version: parse_json_version(&arguments)?,
                        actor,
                    })
                    .await
                    .map_err(ticket_error)?
                    .issue;
                json!(TicketDto::from_issue(issue, None))
            }
            _ => {
                return Err(TicketToolError::new(
                    "unknownTool",
                    format!("unknown tool: {name}"),
                ))
            }
        };
        Ok(result)
    }
}

#[tauri::command]
pub async fn ticket_create(
    request: TicketCreateRequestDto,
    state: State<'_, AppState>,
) -> Result<TicketDto, ErrorDto> {
    let project = resolve_project(&state, &request.project_id).await?;
    let issue = state
        .create_issue
        .execute(create_input(project, request, IssueActor::User)?)
        .await
        .map_err(ErrorDto::from)?
        .issue;
    Ok(TicketDto::from_issue(issue, None))
}

#[tauri::command]
pub async fn ticket_read(
    request: TicketReadRequestDto,
    state: State<'_, AppState>,
) -> Result<TicketDto, ErrorDto> {
    let project = resolve_project(&state, &request.project_id).await?;
    let issue_ref = parse_ref_dto(&request.r#ref)?;
    let issue = state
        .read_issue
        .execute(ReadIssueInput {
            project: project.clone(),
            issue_ref,
        })
        .await
        .map_err(ErrorDto::from)?
        .issue;
    let carnet = if request.include_carnet.unwrap_or(false) {
        Some(
            state
                .read_issue_carnet
                .execute(ReadIssueCarnetInput { project, issue_ref })
                .await
                .map_err(ErrorDto::from)?
                .carnet
                .carnet
                .as_str()
                .to_owned(),
        )
    } else {
        None
    };
    Ok(TicketDto::from_issue(issue, carnet))
}

#[tauri::command]
pub async fn ticket_delete(
    request: TicketDeleteRequestDto,
    state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
    let project = resolve_project(&state, &request.project_id).await?;
    state
        .delete_issue
        .execute(DeleteIssueInput {
            project,
            issue_ref: parse_ref_dto(&request.r#ref)?,
        })
        .await
        .map(|_| ())
        .map_err(ErrorDto::from)
}

#[tauri::command]
pub async fn open_ticket_chat(
    request: OpenTicketChatRequestDto,
    state: State<'_, AppState>,
) -> Result<TicketChatDto, ErrorDto> {
    let project = resolve_project(&state, &request.project_id).await?;
    let output = state
        .open_ticket_assistant
        .execute(OpenTicketAssistantInput {
            project,
            issue_ref: parse_ref_dto(&request.issue_ref)?,
            profile_id: parse_profile_id_dto(&request.profile_id)?,
        })
        .await
        .map_err(ErrorDto::from)?;
    Ok(TicketChatDto {
        session_id: output.session_id.to_string(),
        requester: output.requester,
        issue_ref: output.issue_ref.to_string(),
    })
}

#[tauri::command]
pub async fn close_ticket_chat(
    request: CloseTicketChatRequestDto,
    state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
    let _project = resolve_project(&state, &request.project_id).await?;
    state
        .close_ticket_assistant
        .execute(CloseTicketAssistantInput {
            issue_ref: parse_ref_dto(&request.issue_ref)?,
        })
        .await
        .map_err(ErrorDto::from)
}

#[tauri::command]
pub async fn ticket_list(
    request: TicketListRequestDto,
    state: State<'_, AppState>,
) -> Result<TicketListDto, ErrorDto> {
    let project = resolve_project(&state, &request.project_id).await?;
    let page = TicketListPageInput::from_request(request)?;
    let mut rows = state
        .list_issues
        .execute(ListIssuesInput {
            project,
            filter: page.filter,
        })
        .await
        .map_err(ErrorDto::from)?
        .issues;
    sort_ticket_rows(&mut rows, page.sort);
    paginate(rows, page.limit, page.cursor, page.sort)
}

#[tauri::command]
pub async fn sprint_create(
    request: SprintCreateRequestDto,
    state: State<'_, AppState>,
) -> Result<SprintDto, ErrorDto> {
    let project = resolve_project(&state, &request.project_id).await?;
    let sprint = state
        .create_sprint
        .execute(CreateSprintInput {
            project,
            name: request.name,
            status: request
                .status
                .as_deref()
                .map(parse_sprint_status_dto)
                .transpose()?,
            actor: IssueActor::User,
        })
        .await
        .map_err(ErrorDto::from)?
        .sprint;
    Ok(SprintDto::from_sprint(sprint, 0))
}

#[tauri::command]
pub async fn sprint_list(
    request: SprintListRequestDto,
    state: State<'_, AppState>,
) -> Result<SprintListDto, ErrorDto> {
    let project = resolve_project(&state, &request.project_id).await?;
    let rows = state
        .list_sprints
        .execute(ListSprintsInput { project })
        .await
        .map_err(ErrorDto::from)?
        .sprints;
    Ok(SprintListDto::from(rows))
}

#[tauri::command]
pub async fn sprint_rename(
    request: SprintRenameRequestDto,
    state: State<'_, AppState>,
) -> Result<SprintDto, ErrorDto> {
    let project = resolve_project(&state, &request.project_id).await?;
    let sprint = state
        .rename_sprint
        .execute(RenameSprintInput {
            project,
            sprint_id: parse_sprint_id_dto(&request.sprint_id)?,
            expected_version: sprint_version_dto(request.expected_version)?,
            name: request.name,
            actor: IssueActor::User,
        })
        .await
        .map_err(ErrorDto::from)?
        .sprint;
    Ok(SprintDto::from_sprint(sprint, 0))
}

#[tauri::command]
pub async fn sprint_reorder(
    request: SprintReorderRequestDto,
    state: State<'_, AppState>,
) -> Result<SprintListDto, ErrorDto> {
    let project = resolve_project(&state, &request.project_id).await?;
    state
        .reorder_sprints
        .execute(ReorderSprintsInput {
            project: project.clone(),
            ordered_ids: request
                .ordered_ids
                .iter()
                .map(|id| parse_sprint_id_dto(id))
                .collect::<Result<Vec<_>, _>>()?,
            actor: IssueActor::User,
        })
        .await
        .map_err(ErrorDto::from)?;
    sprint_list(
        SprintListRequestDto {
            project_id: project.id.to_string(),
        },
        state,
    )
    .await
}

#[tauri::command]
pub async fn sprint_delete(
    request: SprintDeleteRequestDto,
    state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
    let project = resolve_project(&state, &request.project_id).await?;
    state
        .delete_sprint
        .execute(DeleteSprintInput {
            project,
            sprint_id: parse_sprint_id_dto(&request.sprint_id)?,
            actor: IssueActor::User,
        })
        .await
        .map_err(ErrorDto::from)
}

#[tauri::command]
pub async fn ticket_assign_sprint(
    request: TicketSprintAssignRequestDto,
    state: State<'_, AppState>,
) -> Result<TicketDto, ErrorDto> {
    let project = resolve_project(&state, &request.project_id).await?;
    let issue = state
        .assign_ticket_to_sprint
        .execute(AssignTicketToSprintInput {
            project,
            issue_ref: parse_ref_dto(&request.r#ref)?,
            sprint_id: parse_sprint_id_dto(&request.sprint_id)?,
            expected_version: version_dto(request.expected_version)?,
            actor: IssueActor::User,
        })
        .await
        .map_err(ErrorDto::from)?
        .issue;
    Ok(TicketDto::from_issue(issue, None))
}

#[tauri::command]
pub async fn ticket_unassign_sprint(
    request: TicketSprintUnassignRequestDto,
    state: State<'_, AppState>,
) -> Result<TicketDto, ErrorDto> {
    let project = resolve_project(&state, &request.project_id).await?;
    let issue = state
        .unassign_ticket_from_sprint
        .execute(UnassignTicketFromSprintInput {
            project,
            issue_ref: parse_ref_dto(&request.r#ref)?,
            expected_version: version_dto(request.expected_version)?,
            actor: IssueActor::User,
        })
        .await
        .map_err(ErrorDto::from)?
        .issue;
    Ok(TicketDto::from_issue(issue, None))
}

#[tauri::command]
pub async fn ticket_update(
    request: TicketUpdateRequestDto,
    state: State<'_, AppState>,
) -> Result<TicketDto, ErrorDto> {
    let project = resolve_project(&state, &request.project_id).await?;
    let issue = state
        .update_issue
        .execute(update_input(project, request, IssueActor::User)?)
        .await
        .map_err(ErrorDto::from)?
        .issue;
    Ok(TicketDto::from_issue(issue, None))
}

#[tauri::command]
pub async fn ticket_read_carnet(
    request: TicketReadRequestDto,
    state: State<'_, AppState>,
) -> Result<TicketCarnetDto, ErrorDto> {
    let project = resolve_project(&state, &request.project_id).await?;
    state
        .read_issue_carnet
        .execute(ReadIssueCarnetInput {
            project,
            issue_ref: parse_ref_dto(&request.r#ref)?,
        })
        .await
        .map(|out| TicketCarnetDto::from(out.carnet))
        .map_err(ErrorDto::from)
}

#[tauri::command]
pub async fn ticket_update_carnet(
    request: TicketUpdateCarnetRequestDto,
    state: State<'_, AppState>,
) -> Result<TicketDto, ErrorDto> {
    let project = resolve_project(&state, &request.project_id).await?;
    let issue_ref = parse_ref_dto(&request.r#ref)?;
    state
        .update_issue_carnet
        .execute(UpdateIssueCarnetInput {
            project: project.clone(),
            issue_ref,
            expected_version: version_dto(request.expected_version)?,
            carnet: request.carnet,
            actor: IssueActor::User,
        })
        .await
        .map_err(ErrorDto::from)?;
    let issue = state
        .read_issue
        .execute(ReadIssueInput { project, issue_ref })
        .await
        .map_err(ErrorDto::from)?
        .issue;
    Ok(TicketDto::from_issue(issue, None))
}

#[tauri::command]
pub async fn ticket_link(
    request: TicketLinkCommandRequestDto,
    state: State<'_, AppState>,
) -> Result<TicketDto, ErrorDto> {
    let project = resolve_project(&state, &request.project_id).await?;
    let issue = state
        .link_issues
        .execute(LinkIssuesInput {
            project,
            issue_ref: parse_ref_dto(&request.r#ref)?,
            target: parse_ref_dto(&request.target_ref)?,
            kind: parse_link_kind_dto(&request.kind)?,
            expected_version: version_dto(request.expected_version)?,
            actor: IssueActor::User,
        })
        .await
        .map_err(ErrorDto::from)?
        .issue;
    Ok(TicketDto::from_issue(issue, None))
}

#[tauri::command]
pub async fn ticket_unlink(
    request: TicketUnlinkCommandRequestDto,
    state: State<'_, AppState>,
) -> Result<TicketDto, ErrorDto> {
    let project = resolve_project(&state, &request.project_id).await?;
    let issue = state
        .unlink_issues
        .execute(UnlinkIssuesInput {
            project,
            issue_ref: parse_ref_dto(&request.r#ref)?,
            target: parse_ref_dto(&request.target_ref)?,
            kind: request
                .kind
                .as_deref()
                .map(parse_link_kind_dto)
                .transpose()?,
            expected_version: version_dto(request.expected_version)?,
            actor: IssueActor::User,
        })
        .await
        .map_err(ErrorDto::from)?
        .issue;
    Ok(TicketDto::from_issue(issue, None))
}

#[tauri::command]
pub async fn ticket_assign(
    request: TicketAssignRequestDto,
    state: State<'_, AppState>,
) -> Result<TicketDto, ErrorDto> {
    let project = resolve_project(&state, &request.project_id).await?;
    let agent_id = parse_agent_id_dto(&request.agent_id)?;
    let issue = state
        .assign_issue_agent
        .execute(AssignIssueAgentInput {
            project,
            issue_ref: parse_ref_dto(&request.r#ref)?,
            agent_id,
            assigned: request.assigned,
            expected_version: version_dto(request.expected_version)?,
            actor: IssueActor::User,
        })
        .await
        .map_err(ErrorDto::from)?
        .issue;
    Ok(TicketDto::from_issue(issue, None))
}

impl TicketDto {
    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(),
            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(),
        }
    }

    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(),
            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 {
    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<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)]
struct TicketListPageInput {
    filter: IssueListFilter,
    sort: Option<TicketListSortDto>,
    limit: usize,
    cursor: Option<String>,
}

impl TicketListPageInput {
    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()?,
                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,
        })
    }
}

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 },
}

async fn resolve_project(state: &AppState, project_id: &str) -> Result<Project, ErrorDto> {
    let id = ProjectId::from_uuid(
        Uuid::parse_str(project_id)
            .map_err(|e| ErrorDto::invalid(format!("invalid project id: {e}")))?,
    );
    state
        .open_project
        .execute(OpenProjectInput { project_id: id })
        .await
        .map(|out| out.project)
        .map_err(ErrorDto::from)
}

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,
    })
}

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,
    })
}

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 paginate(
    rows: Vec<IssueIndexEntry>,
    limit: usize,
    cursor: Option<String>,
    sort: Option<TicketListSortDto>,
) -> Result<TicketListDto, ErrorDto> {
    paginate_rows(rows, limit, cursor, sort, TicketSummaryDto::from)
}

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(),
        })
}

async fn read_carnet_body(
    read_carnet: &application::ReadIssueCarnet,
    project: &Project,
    issue_ref: IssueRef,
) -> Result<String, TicketToolError> {
    read_carnet
        .execute(ReadIssueCarnetInput {
            project: project.clone(),
            issue_ref,
        })
        .await
        .map(|out| out.carnet.carnet.as_str().to_owned())
        .map_err(ticket_error)
}

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::System)
}

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

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}")))
}

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}")))
}

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}")))
}

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

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

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

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)
}

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

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}"))),
    }
}

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}"),
        )),
    }
}

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}"),
        )),
    }
}

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",
    }
}

fn ticket_error(err: AppError) -> TicketToolError {
    let message = err.to_string();
    let code = if message.contains("issue version conflict") {
        "versionConflict"
    } else {
        match err.code() {
            "NOT_FOUND" => "notFound",
            "INVALID" => "invalid",
            "STORE" => "store",
            "FILESYSTEM" => "filesystem",
            _ => "internal",
        }
    };
    TicketToolError::new(code, message)
}

fn dto_tool_error(err: ErrorDto) -> TicketToolError {
    let code = match err.code.as_str() {
        "NOT_FOUND" => "notFound",
        "INVALID" => "invalid",
        "STORE" => "store",
        "FILESYSTEM" => "filesystem",
        _ => "internal",
    };
    TicketToolError::new(code, err.message)
}

fn mcp_create_request(
    project: &Project,
    arguments: Value,
) -> Result<TicketCreateRequestDto, TicketToolError> {
    let mut req: TicketCreateRequestDto = serde_json::from_value(arguments)
        .map_err(|e| TicketToolError::new("invalid", e.to_string()))?;
    req.project_id = project.id.to_string();
    Ok(req)
}

fn mcp_update_request(
    project: &Project,
    arguments: Value,
) -> Result<TicketUpdateRequestDto, TicketToolError> {
    let mut req: TicketUpdateRequestDto = serde_json::from_value(arguments)
        .map_err(|e| TicketToolError::new("invalid", e.to_string()))?;
    req.project_id = project.id.to_string();
    Ok(req)
}

fn mcp_list_request(
    project: &Project,
    arguments: Value,
) -> Result<TicketListPageInput, TicketToolError> {
    let mut req: TicketListRequestDto = serde_json::from_value(arguments)
        .map_err(|e| TicketToolError::new("invalid", e.to_string()))?;
    req.project_id = project.id.to_string();
    TicketListPageInput::from_request(req).map_err(|e| TicketToolError::new("invalid", e.message))
}

fn parse_json_ref(arguments: &Value, key: &str) -> Result<IssueRef, TicketToolError> {
    required_str(arguments, key).and_then(|raw| {
        IssueRef::from_str(raw).map_err(|e| TicketToolError::new("invalid", e.to_string()))
    })
}

fn parse_json_version(arguments: &Value) -> Result<IssueVersion, TicketToolError> {
    let version = arguments
        .get("expectedVersion")
        .and_then(Value::as_u64)
        .ok_or_else(|| TicketToolError::new("invalid", "missing expectedVersion"))?;
    IssueVersion::new(version).map_err(|e| TicketToolError::new("invalid", e.to_string()))
}

fn required_str<'a>(arguments: &'a Value, key: &str) -> Result<&'a str, TicketToolError> {
    arguments
        .get(key)
        .and_then(Value::as_str)
        .ok_or_else(|| TicketToolError::new("invalid", format!("missing {key}")))
}

#[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,
            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,
            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_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,
            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_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,
            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(),
            updated_at: number,
        }
    }

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