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

#![allow(missing_docs)]

use std::sync::Arc;

use async_trait::async_trait;
use serde_json::{json, Value};
use tauri::State;

use application::{
    AddIssueAttachmentInput, AppError, AssignIssueAgentInput, AssignTicketToSprintInput,
    CloseTicketAssistantInput, CreateSprintInput, DeleteIssueInput, DeleteSprintInput,
    LinkIssuesInput, ListIssuesInput, ListSprintsInput, MarkIssueAttachmentSummarizedInput,
    OpenProjectInput, OpenTicketAssistantInput, ReadIssueAttachmentInput, ReadIssueCarnetInput,
    ReadIssueInput, RenameSprintInput, ReorderSprintsInput, UnassignTicketFromSprintInput,
    UnlinkIssuesInput, UpdateIssueCarnetInput, UpdateIssueInput,
};
use domain::{IssueActor, IssueRef, Project};
use infrastructure::{TicketToolError, TicketToolProvider};

use crate::dto::*;
use crate::state::AppState;

#[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 bulk_update_status: Arc<application::BulkUpdateIssueStatus>,
    pub bulk_update_priority: Arc<application::BulkUpdateIssuePriority>,
    pub bulk_delete: Arc<application::BulkDeleteIssues>,
    pub read_carnet: Arc<application::ReadIssueCarnet>,
    pub update_carnet: Arc<application::UpdateIssueCarnet>,
    pub add_attachment: Arc<application::AddIssueAttachment>,
    pub read_attachment: Arc<application::ReadIssueAttachment>,
    pub mark_attachment_summarized: Arc<application::MarkIssueAttachmentSummarized>,
    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 fallback_carnet = issue.carnet.as_str().to_owned();
                let carnet = if include_carnet {
                    Some(
                        read_ticket_carnet_body_best_effort(
                            &*self.read_carnet,
                            project,
                            issue_ref,
                            fallback_carnet,
                        )
                        .await
                        .map_err(ticket_error)?,
                    )
                } 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_bulk_update_status" => {
                let req = mcp_bulk_update_status_request(project, arguments)?;
                let out = self
                    .bulk_update_status
                    .execute(
                        bulk_update_status_input(project.clone(), req, actor)
                            .map_err(dto_tool_error)?,
                    )
                    .await;
                json!(TicketBulkResultDto::from(out))
            }
            "idea_ticket_bulk_update_priority" => {
                let req = mcp_bulk_update_priority_request(project, arguments)?;
                let out = self
                    .bulk_update_priority
                    .execute(
                        bulk_update_priority_input(project.clone(), req, actor)
                            .map_err(dto_tool_error)?,
                    )
                    .await;
                json!(TicketBulkResultDto::from(out))
            }
            "idea_ticket_bulk_delete" => {
                let req = mcp_bulk_delete_request(project, arguments)?;
                let out = self
                    .bulk_delete
                    .execute(bulk_delete_input(project.clone(), req).map_err(dto_tool_error)?)
                    .await;
                json!(TicketBulkResultDto::from(out))
            }
            "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_attachment_add" => {
                let req = mcp_attachment_add_request(project, arguments)?;
                let issue = self
                    .add_attachment
                    .execute(AddIssueAttachmentInput {
                        project: project.clone(),
                        issue_ref: parse_ref_dto(&req.r#ref).map_err(dto_tool_error)?,
                        path: req.path,
                        mime: req.mime,
                        expected_version: domain::IssueVersion::new(req.expected_version)
                            .map_err(|e| TicketToolError::new("invalid", e.to_string()))?,
                        actor,
                    })
                    .await
                    .map_err(ticket_error)?
                    .issue;
                json!(TicketDto::from_issue(issue, None))
            }
            "idea_ticket_attachment_read" => {
                let req = mcp_attachment_read_request(project, arguments)?;
                let content = self
                    .read_attachment
                    .execute(ReadIssueAttachmentInput {
                        project: project.clone(),
                        issue_ref: parse_ref_dto(&req.r#ref).map_err(dto_tool_error)?,
                        attachment_id: parse_attachment_id_dto(&req.attachment_id)
                            .map_err(dto_tool_error)?,
                    })
                    .await
                    .map_err(ticket_error)?
                    .content;
                json!(TicketAttachmentContentDto::from(content))
            }
            "idea_ticket_attachment_mark_summarized" => {
                let req = mcp_attachment_mark_summarized_request(project, arguments)?;
                let issue = self
                    .mark_attachment_summarized
                    .execute(MarkIssueAttachmentSummarizedInput {
                        project: project.clone(),
                        issue_ref: parse_ref_dto(&req.r#ref).map_err(dto_tool_error)?,
                        attachment_id: parse_attachment_id_dto(&req.attachment_id)
                            .map_err(dto_tool_error)?,
                        expected_version: domain::IssueVersion::new(req.expected_version)
                            .map_err(|e| TicketToolError::new("invalid", e.to_string()))?,
                        actor,
                    })
                    .await
                    .map_err(ticket_error)?
                    .issue;
                json!(TicketDto::from_issue(issue, None))
            }
            "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 fallback_carnet = issue.carnet.as_str().to_owned();
    let carnet = if request.include_carnet.unwrap_or(false) {
        Some(
            read_ticket_carnet_body_best_effort(
                &state.read_issue_carnet,
                &project,
                issue_ref,
                fallback_carnet,
            )
            .await
            .map_err(ErrorDto::from)?,
        )
    } else {
        None
    };
    Ok(TicketDto::from_issue(issue, carnet))
}

#[tauri::command]
pub async fn ticket_attachment_add(
    request: TicketAttachmentAddRequestDto,
    state: State<'_, AppState>,
) -> Result<TicketDto, ErrorDto> {
    let project = resolve_project(&state, &request.project_id).await?;
    let issue = state
        .add_issue_attachment
        .execute(AddIssueAttachmentInput {
            project,
            issue_ref: parse_ref_dto(&request.r#ref)?,
            path: request.path,
            mime: request.mime,
            expected_version: domain::IssueVersion::new(request.expected_version).map_err(|e| {
                ErrorDto {
                    code: "INVALID".to_owned(),
                    message: e.to_string(),
                }
            })?,
            actor: IssueActor::User,
        })
        .await
        .map_err(ErrorDto::from)?
        .issue;
    Ok(TicketDto::from_issue(issue, None))
}

#[tauri::command]
pub async fn ticket_attachment_read(
    request: TicketAttachmentReadRequestDto,
    state: State<'_, AppState>,
) -> Result<TicketAttachmentContentDto, ErrorDto> {
    let project = resolve_project(&state, &request.project_id).await?;
    let content = state
        .read_issue_attachment
        .execute(ReadIssueAttachmentInput {
            project,
            issue_ref: parse_ref_dto(&request.r#ref)?,
            attachment_id: parse_attachment_id_dto(&request.attachment_id)?,
        })
        .await
        .map_err(ErrorDto::from)?
        .content;
    Ok(TicketAttachmentContentDto::from(content))
}

#[tauri::command]
pub async fn ticket_attachment_mark_summarized(
    request: TicketAttachmentMarkSummarizedRequestDto,
    state: State<'_, AppState>,
) -> Result<TicketDto, ErrorDto> {
    let project = resolve_project(&state, &request.project_id).await?;
    let issue = state
        .mark_issue_attachment_summarized
        .execute(MarkIssueAttachmentSummarizedInput {
            project,
            issue_ref: parse_ref_dto(&request.r#ref)?,
            attachment_id: parse_attachment_id_dto(&request.attachment_id)?,
            expected_version: domain::IssueVersion::new(request.expected_version).map_err(|e| {
                ErrorDto {
                    code: "INVALID".to_owned(),
                    message: e.to_string(),
                }
            })?,
            actor: IssueActor::User,
        })
        .await
        .map_err(ErrorDto::from)?
        .issue;
    Ok(TicketDto::from_issue(issue, None))
}

#[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 ticket_bulk_update_status(
    request: TicketBulkUpdateStatusRequestDto,
    state: State<'_, AppState>,
) -> Result<TicketBulkResultDto, ErrorDto> {
    let project = resolve_project(&state, &request.project_id).await?;
    let out = state
        .bulk_update_issue_status
        .execute(bulk_update_status_input(
            project,
            request,
            IssueActor::User,
        )?)
        .await;
    Ok(TicketBulkResultDto::from(out))
}

#[tauri::command]
pub async fn ticket_bulk_update_priority(
    request: TicketBulkUpdatePriorityRequestDto,
    state: State<'_, AppState>,
) -> Result<TicketBulkResultDto, ErrorDto> {
    let project = resolve_project(&state, &request.project_id).await?;
    let out = state
        .bulk_update_issue_priority
        .execute(bulk_update_priority_input(
            project,
            request,
            IssueActor::User,
        )?)
        .await;
    Ok(TicketBulkResultDto::from(out))
}

#[tauri::command]
pub async fn ticket_bulk_delete(
    request: TicketBulkDeleteRequestDto,
    state: State<'_, AppState>,
) -> Result<TicketBulkResultDto, ErrorDto> {
    let project = resolve_project(&state, &request.project_id).await?;
    let out = state
        .bulk_delete_issues
        .execute(bulk_delete_input(project, request)?)
        .await;
    Ok(TicketBulkResultDto::from(out))
}

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

async fn resolve_project(state: &AppState, project_id: &str) -> Result<Project, ErrorDto> {
    let id = parse_project_id(project_id)?;
    state
        .open_project
        .execute(OpenProjectInput { project_id: id })
        .await
        .map(|out| out.project)
        .map_err(ErrorDto::from)
}

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

async fn read_ticket_carnet_body_best_effort(
    read_carnet: &application::ReadIssueCarnet,
    project: &Project,
    issue_ref: IssueRef,
    fallback_carnet: String,
) -> Result<String, AppError> {
    match read_carnet
        .execute(ReadIssueCarnetInput {
            project: project.clone(),
            issue_ref,
        })
        .await
    {
        Ok(out) => Ok(out.carnet.carnet.as_str().to_owned()),
        Err(AppError::NotFound(_)) => Ok(fallback_carnet),
        Err(err) => Err(err),
    }
}

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 mcp_bulk_update_status_request(
    project: &Project,
    arguments: Value,
) -> Result<TicketBulkUpdateStatusRequestDto, TicketToolError> {
    let mut req: TicketBulkUpdateStatusRequestDto = 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_bulk_update_priority_request(
    project: &Project,
    arguments: Value,
) -> Result<TicketBulkUpdatePriorityRequestDto, TicketToolError> {
    let mut req: TicketBulkUpdatePriorityRequestDto = 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_bulk_delete_request(
    project: &Project,
    arguments: Value,
) -> Result<TicketBulkDeleteRequestDto, TicketToolError> {
    let mut req: TicketBulkDeleteRequestDto = 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_attachment_add_request(
    project: &Project,
    arguments: Value,
) -> Result<TicketAttachmentAddRequestDto, TicketToolError> {
    let mut req: TicketAttachmentAddRequestDto = 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_attachment_read_request(
    project: &Project,
    arguments: Value,
) -> Result<TicketAttachmentReadRequestDto, TicketToolError> {
    let mut req: TicketAttachmentReadRequestDto = 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_attachment_mark_summarized_request(
    project: &Project,
    arguments: Value,
) -> Result<TicketAttachmentMarkSummarizedRequestDto, TicketToolError> {
    let mut req: TicketAttachmentMarkSummarizedRequestDto = serde_json::from_value(arguments)
        .map_err(|e| TicketToolError::new("invalid", e.to_string()))?;
    req.project_id = project.id.to_string();
    Ok(req)
}

fn parse_json_ref(arguments: &Value, key: &str) -> Result<IssueRef, TicketToolError> {
    required_str(arguments, key).and_then(|raw| parse_ref_dto(raw).map_err(dto_tool_error))
}

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

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