//! Data Transfer Objects crossing the IPC boundary.
//!
//! Convention (frozen here for L1, see L1-ipc-bridge.md "points d'attention"):
//! **all IPC payloads are `camelCase`** via `#[serde(rename_all = "camelCase")]`.
//! Rust uses `snake_case` fields; serde renames them on the wire so the
//! TypeScript side sees idiomatic camelCase. This matches the persisted-domain
//! JSON convention already used in the domain (`agents.json` etc.).

use serde::{Deserialize, Serialize};
use serde_json::Value;

use application::{
    AgentBackgroundTaskState, AgentTicketState, AppError, AppExitWorkGuardDetail,
    AppExitWorkGuardState, AttachLiveAgentOutput, BackgroundTaskKindLabel,
    ConversationPreviewStatus, ConversationTurnWorkPreview, ConversationWorkSummary,
    CreateProjectInput, CreateProjectOutput, GitGraphOutput, HealthInput, HealthReport, LayoutKind,
    ListProjectsOutput, LiveSessionKind, LiveSessionSnapshot, OpenProjectOutput, ProjectWorkState,
    StopLiveAgentOutput, TicketWorkSource, TicketWorkStatus, TurnPage, TurnSource, TurnView,
};
use domain::{
    AgentBusyState, PageCursor, PageDirection, Project, ProjectId, ProjectSystemPermissions,
    ResolvedAgentSystemPermissions, SystemPermissionSet, TurnRole,
};

pub use crate::ticket_dto::*;

// ---------------------------------------------------------------------------
// Plugins (#43)
// ---------------------------------------------------------------------------

/// Plugin contribution summary DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginContributionSummaryDto {
    /// Top-level menus count.
    pub top_level_menus: usize,
    /// Menu items count.
    pub menu_items: usize,
    /// Layouts count.
    pub layouts: usize,
    /// MCP servers count.
    pub mcp_servers: usize,
}

impl From<application::PluginContributionSummary> for PluginContributionSummaryDto {
    fn from(value: application::PluginContributionSummary) -> Self {
        Self {
            top_level_menus: value.top_level_menus,
            menu_items: value.menu_items,
            layouts: value.layouts,
            mcp_servers: value.mcp_servers,
        }
    }
}

/// Admin plugin DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginAdminDto {
    /// Plugin id.
    pub id: String,
    /// Display name.
    pub display_name: String,
    /// Publisher.
    pub publisher: Option<String>,
    /// Version.
    pub version: String,
    /// Description.
    pub description: Option<String>,
    /// Icon URL.
    pub icon_url: Option<String>,
    /// Source kind.
    pub source_kind: String,
    /// Source label.
    pub source_label: Option<String>,
    /// Lifecycle state.
    pub lifecycle_state: domain::PluginLifecycleState,
    /// Enabled flag.
    pub enabled: bool,
    /// Pending enable state.
    pub pending_enable_state: Option<bool>,
    /// Pending uninstall flag.
    pub pending_uninstall: bool,
    /// Restart required flag.
    pub restart_required: bool,
    /// Trust level.
    pub trust_level: domain::PluginTrustLevel,
    /// Contribution summary.
    pub contribution_summary: PluginContributionSummaryDto,
    /// Optional error.
    pub error: Option<String>,
}

impl From<application::PluginAdmin> for PluginAdminDto {
    fn from(value: application::PluginAdmin) -> Self {
        Self {
            id: value.id,
            display_name: value.display_name,
            publisher: value.publisher,
            version: value.version,
            description: value.description,
            icon_url: value.icon_url,
            source_kind: value.source_kind,
            source_label: value.source_label,
            lifecycle_state: value.lifecycle_state,
            enabled: value.enabled,
            pending_enable_state: value.pending_enable_state,
            pending_uninstall: value.pending_uninstall,
            restart_required: value.restart_required,
            trust_level: value.trust_level,
            contribution_summary: value.contribution_summary.into(),
            error: value.error,
        }
    }
}

/// Review request DTO.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReviewPluginPackageDto {
    /// Source kind: `archive` or `directory`.
    pub source_kind: String,
    /// Local source path.
    pub path: String,
}

/// Plugin review DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginReviewDto {
    /// Manifest id.
    pub id: String,
    /// Display name.
    pub display_name: String,
    /// Publisher.
    pub publisher: Option<String>,
    /// Version.
    pub version: String,
    /// Description.
    pub description: Option<String>,
    /// Source kind.
    pub source_kind: String,
    /// Source label.
    pub source_label: Option<String>,
    /// Content hash.
    pub content_hash: String,
    /// Trust level.
    pub trust_level: domain::PluginTrustLevel,
    /// Summary.
    pub contribution_summary: PluginContributionSummaryDto,
    /// Manifest contributions.
    pub contributes: domain::PluginContributionSet,
}

impl From<application::PluginReview> for PluginReviewDto {
    fn from(value: application::PluginReview) -> Self {
        Self {
            id: value.manifest.id.as_str().to_owned(),
            display_name: value.manifest.display_name,
            publisher: value.manifest.publisher,
            version: value.manifest.version.as_str().to_owned(),
            description: value.manifest.description,
            source_kind: value.source.kind().to_owned(),
            source_label: Some(value.source.label().to_owned()),
            content_hash: value.content_hash,
            trust_level: value.trust_level,
            contribution_summary: value.contribution_summary.into(),
            contributes: value.manifest.contributes,
        }
    }
}

/// Plugin install result DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginInstallResultDto {
    /// Installed plugin.
    pub plugin: PluginAdminDto,
    /// Review.
    pub review: PluginReviewDto,
    /// Restart required.
    pub restart_required: bool,
}

impl From<application::PluginInstallResult> for PluginInstallResultDto {
    fn from(value: application::PluginInstallResult) -> Self {
        Self {
            plugin: value.plugin.into(),
            review: value.review.into(),
            restart_required: value.restart_required,
        }
    }
}

/// Plugin uninstall result DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginUninstallResultDto {
    /// Plugin id.
    pub plugin_id: String,
    /// Removal outcome.
    pub removal_outcome: domain::RemovalOutcome,
    /// Restart required.
    pub restart_required: bool,
}

impl From<application::UninstallPluginResult> for PluginUninstallResultDto {
    fn from(value: application::UninstallPluginResult) -> Self {
        Self {
            plugin_id: value.plugin_id,
            removal_outcome: value.removal_outcome,
            restart_required: value.restart_required,
        }
    }
}

/// Runtime catalog DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginRuntimeContributionCatalogDto {
    /// Runtime plugins.
    pub plugins: Vec<PluginRuntimePluginDto>,
}

/// Runtime plugin DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginRuntimePluginDto {
    /// Plugin id.
    pub id: String,
    /// Display name.
    pub display_name: String,
    /// Publisher.
    pub publisher: Option<String>,
    /// Version.
    pub version: String,
    /// Bundle URL.
    pub bundle_url: String,
    /// Icon URL.
    pub icon_url: Option<String>,
    /// Content hash.
    pub content_hash: String,
    /// Public manifest capabilities.
    pub capabilities: Vec<domain::PluginCapability>,
    /// Manifest-declared activation scope.
    pub activation_scope: domain::PluginActivationScope,
    /// Contributions.
    pub contributes: domain::PluginContributionSet,
}

impl From<application::PluginRuntimeCatalog> for PluginRuntimeContributionCatalogDto {
    fn from(value: application::PluginRuntimeCatalog) -> Self {
        Self {
            plugins: value
                .plugins
                .into_iter()
                .map(PluginRuntimePluginDto::from)
                .collect(),
        }
    }
}

impl From<application::PluginRuntimePlugin> for PluginRuntimePluginDto {
    fn from(value: application::PluginRuntimePlugin) -> Self {
        Self {
            id: value.id,
            display_name: value.display_name,
            publisher: value.publisher,
            version: value.version,
            bundle_url: value.bundle_url,
            icon_url: value.icon_url,
            content_hash: value.content_hash,
            capabilities: value.capabilities,
            activation_scope: value.activation_scope,
            contributes: value.contributes,
        }
    }
}

/// Plugin workspace path request DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginWorkspacePathDto {
    /// Project id.
    pub project_id: String,
    /// Relative path under the project root.
    pub path: String,
}

impl From<PluginWorkspacePathDto> for application::PluginWorkspacePathInput {
    fn from(value: PluginWorkspacePathDto) -> Self {
        Self {
            project_id: value.project_id,
            path: value.path,
        }
    }
}

/// Plugin workspace text write request DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginWorkspaceWriteTextDto {
    /// Project id.
    pub project_id: String,
    /// Relative path under the project root.
    pub path: String,
    /// UTF-8 content.
    pub content: String,
}

impl From<PluginWorkspaceWriteTextDto> for application::PluginWorkspaceWriteTextInput {
    fn from(value: PluginWorkspaceWriteTextDto) -> Self {
        Self {
            project_id: value.project_id,
            path: value.path,
            content: value.content,
        }
    }
}

/// Plugin workspace binary write request DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginWorkspaceWriteBinaryDto {
    /// Project id.
    pub project_id: String,
    /// Relative path under the project root.
    pub path: String,
    /// Raw bytes.
    pub bytes: Vec<u8>,
}

impl From<PluginWorkspaceWriteBinaryDto> for application::PluginWorkspaceWriteBinaryInput {
    fn from(value: PluginWorkspaceWriteBinaryDto) -> Self {
        Self {
            project_id: value.project_id,
            path: value.path,
            bytes: value.bytes,
        }
    }
}

/// Plugin-owned storage read/delete request DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginStorageGetDto {
    /// Plugin id owning the value.
    pub plugin_id: String,
    /// Plugin-owned key.
    pub key: String,
}

impl From<PluginStorageGetDto> for application::PluginStorageGetInput {
    fn from(value: PluginStorageGetDto) -> Self {
        Self {
            plugin_id: value.plugin_id,
            key: value.key,
        }
    }
}

/// Plugin-owned storage write request DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginStorageSetDto {
    /// Plugin id owning the value.
    pub plugin_id: String,
    /// Plugin-owned key.
    pub key: String,
    /// JSON value to persist.
    pub value: Value,
}

impl From<PluginStorageSetDto> for application::PluginStorageSetInput {
    fn from(value: PluginStorageSetDto) -> Self {
        Self {
            plugin_id: value.plugin_id,
            key: value.key,
            value: value.value,
        }
    }
}

/// Plugin structured config document read request DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginConfigDocumentReadDto {
    /// Project id.
    pub project_id: String,
    /// Relative path under the project root.
    pub path: String,
    /// Optional explicit format. Omitted means inferred from extension.
    #[serde(default)]
    pub format: Option<String>,
}

impl From<PluginConfigDocumentReadDto> for application::PluginConfigDocumentReadInput {
    fn from(value: PluginConfigDocumentReadDto) -> Self {
        Self {
            project_id: value.project_id,
            path: value.path,
            format: value.format,
        }
    }
}

/// Plugin structured config document update request DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginConfigDocumentUpdateDto {
    /// Project id.
    pub project_id: String,
    /// Relative path under the project root.
    pub path: String,
    /// Optional explicit format. Omitted means inferred from extension.
    #[serde(default)]
    pub format: Option<String>,
    /// Update mode: `mergePatch` (default) or `replace`.
    #[serde(default)]
    pub mode: Option<String>,
    /// JSON replacement or merge patch.
    pub value: Value,
}

impl From<PluginConfigDocumentUpdateDto> for application::PluginConfigDocumentUpdateInput {
    fn from(value: PluginConfigDocumentUpdateDto) -> Self {
        Self {
            project_id: value.project_id,
            path: value.path,
            format: value.format,
            mode: value.mode,
            value: value.value,
        }
    }
}

/// Plugin project structure query request DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginProjectStructureQueryDto {
    /// Project id.
    pub project_id: String,
    /// Optional relative root path.
    #[serde(default)]
    pub path: Option<String>,
    /// Optional traversal depth.
    #[serde(default)]
    pub max_depth: Option<u8>,
    /// Optional entry cap.
    #[serde(default)]
    pub max_entries: Option<usize>,
}

impl From<PluginProjectStructureQueryDto> for application::QueryProjectStructureInput {
    fn from(value: PluginProjectStructureQueryDto) -> Self {
        Self {
            project_id: value.project_id,
            path: value.path,
            max_depth: value.max_depth,
            max_entries: value.max_entries,
        }
    }
}

/// Public plugin command-task launch request DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginRunCommandDto {
    /// Owning project id.
    pub project_id: String,
    /// Agent id used for Work correlation and completion wake delivery.
    pub owner_agent_id: String,
    /// Human-facing task label.
    pub label: String,
    /// Executable to run.
    pub command: String,
    /// Arguments passed without shell parsing.
    #[serde(default)]
    pub args: Vec<String>,
    /// Relative working directory under project root. Empty/omitted means root.
    #[serde(default)]
    pub cwd: Option<String>,
    /// Extra environment variables.
    #[serde(default)]
    pub env: Vec<(String, String)>,
    /// When true, completion is only recorded; otherwise the owner is woken.
    #[serde(default)]
    pub record_only: bool,
    /// Optional absolute deadline, epoch milliseconds.
    #[serde(default)]
    pub deadline_ms: Option<u64>,
}

impl From<PluginRunCommandDto> for application::PluginRunCommandInput {
    fn from(value: PluginRunCommandDto) -> Self {
        Self {
            project_id: value.project_id,
            owner_agent_id: value.owner_agent_id,
            label: value.label,
            command: value.command,
            args: value.args,
            cwd: value.cwd,
            env: value.env,
            record_only: value.record_only,
            deadline_ms: value.deadline_ms,
        }
    }
}

/// Public plugin task status request DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginTaskStatusDto {
    /// Task id to read.
    pub task_id: String,
}

impl From<PluginTaskStatusDto> for application::PluginTaskStatusInput {
    fn from(value: PluginTaskStatusDto) -> Self {
        Self {
            task_id: value.task_id,
        }
    }
}

/// Public plugin command-task status/output DTO.
pub type PluginTaskDto = BackgroundTaskDto;

/// Public plugin external-toolchain diagnostic request DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginToolchainDiagnosticRequestDto {
    /// Owning project id.
    pub project_id: String,
    /// Relative working directory under project root.
    #[serde(default)]
    pub cwd: Option<String>,
    /// Executable probes to run.
    #[serde(default)]
    pub tools: Vec<PluginToolRequirementDto>,
    /// Environment variable prerequisites.
    #[serde(default)]
    pub env: Vec<PluginEnvRequirementDto>,
    /// Workspace file prerequisites.
    #[serde(default)]
    pub files: Vec<PluginFileRequirementDto>,
}

impl From<PluginToolchainDiagnosticRequestDto> for application::PluginToolchainDiagnosticInput {
    fn from(value: PluginToolchainDiagnosticRequestDto) -> Self {
        Self {
            project_id: value.project_id,
            cwd: value.cwd,
            tools: value.tools.into_iter().map(Into::into).collect(),
            env: value.env.into_iter().map(Into::into).collect(),
            files: value.files.into_iter().map(Into::into).collect(),
        }
    }
}

/// Public plugin executable probe DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginToolRequirementDto {
    /// Stable requirement id.
    pub id: String,
    /// Executable name or path.
    pub executable: String,
    /// Version/diagnostic arguments.
    #[serde(default)]
    pub version_args: Vec<String>,
    /// Whether this probe is required.
    #[serde(default)]
    pub required: bool,
    /// Extra environment variables for the probe.
    #[serde(default)]
    pub env: Vec<(String, String)>,
}

impl From<PluginToolRequirementDto> for application::PluginToolRequirement {
    fn from(value: PluginToolRequirementDto) -> Self {
        Self {
            id: value.id,
            executable: value.executable,
            version_args: value.version_args,
            required: value.required,
            env: value.env,
        }
    }
}

/// Public plugin environment prerequisite DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginEnvRequirementDto {
    /// Environment variable name.
    pub name: String,
    /// Whether this variable is required.
    #[serde(default)]
    pub required: bool,
    /// Optional exact expected value.
    #[serde(default)]
    pub equals: Option<String>,
}

impl From<PluginEnvRequirementDto> for application::PluginEnvRequirement {
    fn from(value: PluginEnvRequirementDto) -> Self {
        Self {
            name: value.name,
            required: value.required,
            equals: value.equals,
        }
    }
}

/// Public plugin workspace file prerequisite DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginFileRequirementDto {
    /// Relative path under project root.
    pub path: String,
    /// Whether this path is required.
    #[serde(default)]
    pub required: bool,
    /// Expected kind: `file`, `directory`, or `any`.
    #[serde(default)]
    pub kind: Option<String>,
}

impl From<PluginFileRequirementDto> for application::PluginFileRequirement {
    fn from(value: PluginFileRequirementDto) -> Self {
        Self {
            path: value.path,
            required: value.required,
            kind: value.kind,
        }
    }
}

/// Public plugin external-toolchain diagnostic output DTO.
pub type PluginToolchainDiagnosticDto = application::PluginToolchainDiagnostic;

/// Public plugin event subscription request DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginEventSubscribeDto {
    /// Project id to observe.
    pub project_id: String,
    /// Public event types to retain. Empty means all supported types.
    #[serde(default)]
    pub event_types: Vec<String>,
    /// Per-subscription retained event capacity.
    #[serde(default)]
    pub capacity: Option<usize>,
}

impl From<PluginEventSubscribeDto> for application::PluginEventSubscribeInput {
    fn from(value: PluginEventSubscribeDto) -> Self {
        Self {
            project_id: value.project_id,
            event_types: value.event_types,
            capacity: value.capacity,
        }
    }
}

/// Public plugin event poll request DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginEventPollDto {
    /// Subscription id returned by subscribe.
    pub subscription_id: String,
    /// Maximum number of events to drain.
    #[serde(default)]
    pub max_events: Option<usize>,
}

impl From<PluginEventPollDto> for application::PluginEventPollInput {
    fn from(value: PluginEventPollDto) -> Self {
        Self {
            subscription_id: value.subscription_id,
            max_events: value.max_events,
        }
    }
}

/// Public plugin event unsubscribe request DTO.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginEventUnsubscribeDto {
    /// Subscription id returned by subscribe.
    pub subscription_id: String,
}

impl From<PluginEventUnsubscribeDto> for application::PluginEventUnsubscribeInput {
    fn from(value: PluginEventUnsubscribeDto) -> Self {
        Self {
            subscription_id: value.subscription_id,
        }
    }
}

/// Public plugin event subscription output DTO.
pub type PluginEventSubscriptionDto = application::PluginEventSubscription;
/// Public plugin event poll output DTO.
pub type PluginEventBatchDto = application::PluginEventBatch;

/// Plugin workspace text file DTO.
pub type PluginWorkspaceTextFileDto = application::PluginWorkspaceTextFile;
/// Plugin workspace binary file DTO.
pub type PluginWorkspaceBinaryFileDto = application::PluginWorkspaceBinaryFile;
/// Plugin workspace directory listing DTO.
pub type PluginWorkspaceDirectoryListingDto = application::PluginWorkspaceDirectoryListing;
/// Plugin workspace stat DTO.
pub type PluginWorkspaceStatDto = application::PluginWorkspaceStat;
/// Plugin structured config document DTO.
pub type PluginConfigDocumentDto = application::PluginConfigDocument;
/// Plugin structured config document write result DTO.
pub type PluginConfigDocumentWriteResultDto = application::PluginConfigDocumentWriteResult;
/// Plugin project structure result DTO.
pub type PluginProjectStructureDto = application::ProjectStructureQuery;

/// Request DTO for the `health` command.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HealthRequestDto {
    /// Optional note echoed back by the use case.
    #[serde(default)]
    pub note: Option<String>,
}

impl From<HealthRequestDto> for HealthInput {
    fn from(dto: HealthRequestDto) -> Self {
        Self { note: dto.note }
    }
}

/// Response DTO for the `health` command.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HealthResponseDto {
    /// Application version.
    pub version: String,
    /// Liveness flag.
    pub alive: bool,
    /// Server time in epoch milliseconds.
    pub time_millis: i64,
    /// Correlation id for this call.
    pub correlation_id: String,
    /// Echoed note, if any.
    pub note: Option<String>,
}

impl From<HealthReport> for HealthResponseDto {
    fn from(r: HealthReport) -> Self {
        Self {
            version: r.version,
            alive: r.alive,
            time_millis: r.time_millis,
            correlation_id: r.correlation_id,
            note: r.note,
        }
    }
}

/// Error DTO returned to the frontend in the `Err` arm of every command.
///
/// `code` is a stable machine-readable string (see [`AppError::code`]); the
/// frontend branches on it without parsing `message`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ErrorDto {
    /// Stable error code, e.g. `NOT_FOUND`, `INVALID`.
    pub code: String,
    /// Human-readable message.
    pub message: String,
}

impl From<AppError> for ErrorDto {
    fn from(e: AppError) -> Self {
        Self {
            code: e.code().to_owned(),
            message: e.to_string(),
        }
    }
}

impl ErrorDto {
    /// Builds a stable `INVALID` error.
    #[must_use]
    pub fn invalid(message: impl Into<String>) -> Self {
        Self {
            code: "INVALID".to_owned(),
            message: message.into(),
        }
    }
}

// ---------------------------------------------------------------------------
// Projects (L2)
// ---------------------------------------------------------------------------

/// A project as seen by the frontend (camelCase wire shape).
///
/// `remote` is the domain [`domain::RemoteRef`], which already serialises
/// camelCase + tagged (`kind`), so we embed it directly.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectDto {
    /// Stable project id (UUID string).
    pub id: String,
    /// Display name.
    pub name: String,
    /// Absolute project root.
    pub root: String,
    /// Where the project lives (`{ "kind": "local" }`, `ssh`, `wsl`).
    pub remote: domain::RemoteRef,
    /// Creation timestamp, epoch milliseconds.
    pub created_at: i64,
}

impl From<Project> for ProjectDto {
    fn from(p: Project) -> Self {
        Self {
            id: p.id.to_string(),
            name: p.name,
            root: p.root.as_str().to_owned(),
            remote: p.remote,
            created_at: p.created_at,
        }
    }
}

/// Request DTO for `create_project`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateProjectRequestDto {
    /// Display name.
    pub name: String,
    /// Absolute project root.
    pub root: String,
    /// Optional remote reference; defaults to local when omitted.
    #[serde(default)]
    pub remote: Option<domain::RemoteRef>,
    /// Optional default profile id.
    #[serde(default)]
    pub default_profile_id: Option<String>,
}

impl From<CreateProjectRequestDto> for CreateProjectInput {
    fn from(dto: CreateProjectRequestDto) -> Self {
        Self {
            name: dto.name,
            root: dto.root,
            remote: dto.remote,
            default_profile_id: dto.default_profile_id,
        }
    }
}

impl From<CreateProjectOutput> for ProjectDto {
    fn from(out: CreateProjectOutput) -> Self {
        out.project.into()
    }
}

impl From<OpenProjectOutput> for ProjectDto {
    fn from(out: OpenProjectOutput) -> Self {
        out.project.into()
    }
}

/// Parses a project-id string (UUID) coming from the frontend.
///
/// # Errors
/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID.
pub fn parse_project_id(raw: &str) -> Result<ProjectId, ErrorDto> {
    uuid::Uuid::parse_str(raw)
        .map(ProjectId::from_uuid)
        .map_err(|_| ErrorDto {
            code: "INVALID".to_owned(),
            message: format!("invalid project id: {raw}"),
        })
}

/// Response DTO for `list_projects`.
#[derive(Debug, Clone, Serialize)]
#[serde(transparent)]
pub struct ProjectListDto(pub Vec<ProjectDto>);

impl From<ListProjectsOutput> for ProjectListDto {
    fn from(out: ListProjectsOutput) -> Self {
        Self(out.projects.into_iter().map(ProjectDto::from).collect())
    }
}

// ---------------------------------------------------------------------------
// Terminals (L3)
// ---------------------------------------------------------------------------

use application::{
    CloseTerminalInput, CloseTerminalOutput, OpenTerminalInput, OpenTerminalOutput,
    ResizeTerminalInput, WriteToTerminalInput,
};
use domain::SessionId;

/// Request DTO for `open_terminal`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenTerminalRequestDto {
    /// Working directory (typically the project root).
    pub cwd: String,
    /// Initial terminal height in rows.
    pub rows: u16,
    /// Initial terminal width in columns.
    pub cols: u16,
    /// Optional explicit command; defaults to the platform shell when omitted.
    #[serde(default)]
    pub command: Option<String>,
    /// Optional arguments for the command.
    #[serde(default)]
    pub args: Vec<String>,
}

impl From<OpenTerminalRequestDto> for OpenTerminalInput {
    fn from(dto: OpenTerminalRequestDto) -> Self {
        Self {
            cwd: dto.cwd,
            rows: dto.rows,
            cols: dto.cols,
            command: dto.command,
            args: dto.args,
            node_id: None,
        }
    }
}

/// Response DTO for `open_terminal`: the freshly-opened session.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TerminalSessionDto {
    /// Stable session id (UUID string) — used for write/resize/close + the
    /// output channel.
    pub session_id: String,
    /// Working directory the shell runs in.
    pub cwd: String,
    /// Current rows.
    pub rows: u16,
    /// Current cols.
    pub cols: u16,
    /// Conversation id **assigned** by this launch, when the agent's profile
    /// supports session assignment and the hosting cell had none yet (T4b). The
    /// front persists it on the leaf (`setCellConversation`) so the next open
    /// resumes. `None` for a plain terminal, a resume, or a degraded launch.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub assigned_conversation_id: Option<String>,
    /// **Id de session moteur** (resumable du provider courant) exposé par ce
    /// lancement, **distinct** de l'id de paire `assignedConversationId` (ARCHITECTURE
    /// §19.7, lot P8a). Le front le range dans le **cache** `engineSessionId` de la
    /// cellule (jamais sur `conversationId`). Absent pour un terminal nu, une reprise,
    /// ou quand le moteur n'expose encore aucun id. La source de vérité du resumable
    /// est `providers.json` (lot P8b).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub engine_session_id: Option<String>,
    /// How the frontend should render the cell hosting this session (§17.6):
    /// [`CellKind::Chat`] for a structured AI session (an `AgentChatView` driven by
    /// `agent_send`/`reattach_agent_chat`), [`CellKind::Pty`] for a raw terminal
    /// (xterm). **Derived**, not a layout field: it follows the presence of a
    /// structured session descriptor on the launch output — a single source of
    /// truth. Plain terminals and the non-launch construction paths default to
    /// [`CellKind::Pty`] (the historical, non-breaking shape: a PTY session DTO
    /// always serialises with `cellKind: "pty"`).
    pub cell_kind: CellKind,
}

/// Whether a session's hosting cell renders as a structured chat view or a raw
/// terminal (ARCHITECTURE §17.6). Serialises as `"chat"` / `"pty"` on the wire;
/// the frontend `LayoutGrid` switches `AgentChatView` vs `TerminalView` on it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum CellKind {
    /// A raw PTY terminal cell (xterm) — the historical default.
    Pty,
    /// A structured AI chat cell (`AgentChatView`), backed by an `AgentSession`.
    Chat,
}

impl From<OpenTerminalOutput> for TerminalSessionDto {
    fn from(out: OpenTerminalOutput) -> Self {
        let s = out.session;
        Self {
            session_id: s.id.to_string(),
            cwd: s.cwd.as_str().to_owned(),
            rows: s.pty_size.rows,
            cols: s.pty_size.cols,
            assigned_conversation_id: None,
            engine_session_id: None,
            cell_kind: CellKind::Pty,
        }
    }
}

/// Response DTO for `reattach_terminal`: the retained scrollback of a still-live
/// session, repainted into the re-mounting xterm before the new output stream is
/// wired. Bytes are serialised as a number array, matching the PTY output channel.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReattachResultDto {
    /// The session that was re-attached (echoed back for the frontend).
    pub session_id: String,
    /// The most-recent retained output bytes (scrollback ring buffer).
    pub scrollback: Vec<u8>,
}

/// Response DTO for `attach_background_task`: the retained bytes to repaint for
/// a background command task before optional live bytes arrive on the channel.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AttachBackgroundTaskResultDto {
    /// The task that was attached (echoed back for the frontend).
    pub task_id: String,
    /// Recent output bytes: PTY scrollback for a live task, persisted tail for a
    /// terminal task.
    pub scrollback: Vec<u8>,
    /// Whether a live PTY subscription was installed for subsequent output.
    pub live: bool,
}

/// Request DTO for `write_terminal`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WriteTerminalRequestDto {
    /// Target session id.
    pub session_id: String,
    /// Bytes to write (xterm keystrokes).
    pub data: Vec<u8>,
}

impl WriteTerminalRequestDto {
    /// Converts to the use-case input, parsing the session id.
    ///
    /// # Errors
    /// [`ErrorDto`] with code `INVALID` if the id is malformed.
    pub fn into_input(self) -> Result<WriteToTerminalInput, ErrorDto> {
        Ok(WriteToTerminalInput {
            session_id: parse_session_id(&self.session_id)?,
            data: self.data,
        })
    }
}

/// Request DTO for `resize_terminal`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResizeTerminalRequestDto {
    /// Target session id.
    pub session_id: String,
    /// New rows.
    pub rows: u16,
    /// New cols.
    pub cols: u16,
}

impl ResizeTerminalRequestDto {
    /// Converts to the use-case input, parsing the session id.
    ///
    /// # Errors
    /// [`ErrorDto`] with code `INVALID` if the id is malformed.
    pub fn into_input(self) -> Result<ResizeTerminalInput, ErrorDto> {
        Ok(ResizeTerminalInput {
            session_id: parse_session_id(&self.session_id)?,
            rows: self.rows,
            cols: self.cols,
        })
    }
}

/// Response DTO for `close_terminal`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TerminalClosedDto {
    /// Exit code, if the process reported one.
    pub code: Option<i32>,
}

impl From<CloseTerminalOutput> for TerminalClosedDto {
    fn from(out: CloseTerminalOutput) -> Self {
        Self { code: out.code }
    }
}

/// Builds a [`CloseTerminalInput`] from a raw session-id string.
///
/// # Errors
/// [`ErrorDto`] with code `INVALID` if the id is malformed.
pub fn parse_close_terminal(raw: &str) -> Result<CloseTerminalInput, ErrorDto> {
    Ok(CloseTerminalInput {
        session_id: parse_session_id(raw)?,
    })
}

/// Parses a session-id string (UUID) coming from the frontend.
///
/// # Errors
/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID.
pub fn parse_session_id(raw: &str) -> Result<SessionId, ErrorDto> {
    uuid::Uuid::parse_str(raw)
        .map(SessionId::from_uuid)
        .map_err(|_| ErrorDto {
            code: "INVALID".to_owned(),
            message: format!("invalid session id: {raw}"),
        })
}

// ---------------------------------------------------------------------------
// Layout (L4 + #4 management + #3 per-cell agent)
// ---------------------------------------------------------------------------

use application::{
    CreateLayoutOutput, DeleteLayoutOutput, LayoutInfo, LayoutOperation, ListLayoutsOutput,
    LoadLayoutOutput, MutateLayoutOutput, PluginLayoutOrigin, SetActiveLayoutOutput,
};
use domain::{AgentId, Direction, LayoutId, LayoutTree, NodeId, PluginId, PluginLayoutType};

/// Response DTO carrying a layout tree.
///
/// [`LayoutTree`] already serialises camelCase + tagged (its enum uses
/// `#[serde(tag = "type", content = "node")]`), so we embed it directly; the
/// TypeScript mirror in `@/domain` matches this shape.
#[derive(Debug, Clone, Serialize)]
#[serde(transparent)]
pub struct LayoutDto(pub LayoutTree);

impl From<LoadLayoutOutput> for LayoutDto {
    fn from(out: LoadLayoutOutput) -> Self {
        Self(out.layout)
    }
}

impl From<MutateLayoutOutput> for LayoutDto {
    fn from(out: MutateLayoutOutput) -> Self {
        Self(out.layout)
    }
}

/// A layout operation as sent by the frontend (tagged on `type`, camelCase).
///
/// Mirrors [`LayoutOperation`]; node/session ids cross the wire as UUID strings
/// and are parsed here. `direction` reuses the domain [`Direction`] (which
/// already serialises `"row"`/`"column"`).
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum LayoutOperationDto {
    /// Split a leaf into a two-child split.
    #[serde(rename_all = "camelCase")]
    Split {
        /// Leaf to split.
        target: String,
        /// Row (columns) or Column (rows).
        direction: Direction,
        /// Id for the new sibling leaf.
        new_leaf: String,
        /// Id for the wrapping split container.
        container: String,
    },
    /// Collapse a split container back to one child.
    #[serde(rename_all = "camelCase")]
    Merge {
        /// Split container to collapse.
        container: String,
        /// Index of the child to keep.
        keep_index: usize,
    },
    /// Reassign a split's child weights.
    #[serde(rename_all = "camelCase")]
    Resize {
        /// Split container to resize.
        container: String,
        /// New weights (one per child).
        weights: Vec<f32>,
    },
    /// Move a session from one leaf to another.
    #[serde(rename_all = "camelCase")]
    Move {
        /// Source leaf.
        from: String,
        /// Target (empty) leaf.
        to: String,
    },
    /// Attach/detach a session to/from a leaf.
    #[serde(rename_all = "camelCase")]
    SetSession {
        /// Hosting leaf.
        target: String,
        /// Session id, or `null` to clear.
        #[serde(default)]
        session: Option<String>,
    },
    /// Attach/detach an agent to/from a leaf (#3 per-cell agent).
    #[serde(rename_all = "camelCase")]
    SetCellAgent {
        /// Hosting leaf.
        target: String,
        /// Agent id, or `null` to clear.
        #[serde(default)]
        agent: Option<String>,
    },
    /// Record/clear the persistent CLI conversation id on a leaf (T4b).
    #[serde(rename_all = "camelCase")]
    SetCellConversation {
        /// Hosting leaf.
        target: String,
        /// Conversation id, or `null` to clear.
        #[serde(default)]
        conversation_id: Option<String>,
    },
    /// Persist opaque plugin layout state.
    #[serde(rename_all = "camelCase")]
    SetPluginLayoutState {
        /// Custom plugin layout node.
        target: String,
        /// Opaque plugin-owned state.
        #[serde(default)]
        state: serde_json::Value,
    },
}

impl LayoutOperationDto {
    /// Converts to the use-case operation, parsing all ids.
    ///
    /// # Errors
    /// [`ErrorDto`] with code `INVALID` if any id is malformed.
    pub fn into_operation(self) -> Result<LayoutOperation, ErrorDto> {
        Ok(match self {
            Self::Split {
                target,
                direction,
                new_leaf,
                container,
            } => LayoutOperation::Split {
                target: parse_node_id(&target)?,
                direction,
                new_leaf: parse_node_id(&new_leaf)?,
                container: parse_node_id(&container)?,
            },
            Self::Merge {
                container,
                keep_index,
            } => LayoutOperation::Merge {
                container: parse_node_id(&container)?,
                keep_index,
            },
            Self::Resize { container, weights } => LayoutOperation::Resize {
                container: parse_node_id(&container)?,
                weights,
            },
            Self::Move { from, to } => LayoutOperation::Move {
                from: parse_node_id(&from)?,
                to: parse_node_id(&to)?,
            },
            Self::SetSession { target, session } => LayoutOperation::SetSession {
                target: parse_node_id(&target)?,
                session: session.as_deref().map(parse_session_id).transpose()?,
            },
            Self::SetCellAgent { target, agent } => LayoutOperation::SetCellAgent {
                target: parse_node_id(&target)?,
                agent: agent.as_deref().map(parse_agent_id).transpose()?,
            },
            Self::SetCellConversation {
                target,
                conversation_id,
            } => LayoutOperation::SetCellConversation {
                target: parse_node_id(&target)?,
                conversation_id,
            },
            Self::SetPluginLayoutState { target, state } => LayoutOperation::SetPluginLayoutState {
                target: parse_node_id(&target)?,
                state,
            },
        })
    }
}

/// Parses a node-id string (UUID) coming from the frontend.
///
/// # Errors
/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID.
pub fn parse_node_id(raw: &str) -> Result<NodeId, ErrorDto> {
    uuid::Uuid::parse_str(raw)
        .map(NodeId::from_uuid)
        .map_err(|_| ErrorDto {
            code: "INVALID".to_owned(),
            message: format!("invalid node id: {raw}"),
        })
}

/// Parses a layout-id string (UUID) coming from the frontend.
///
/// # Errors
/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID.
pub fn parse_layout_id(raw: &str) -> Result<LayoutId, ErrorDto> {
    uuid::Uuid::parse_str(raw)
        .map(LayoutId::from_uuid)
        .map_err(|_| ErrorDto {
            code: "INVALID".to_owned(),
            message: format!("invalid layout id: {raw}"),
        })
}

// ---------------------------------------------------------------------------
// Layouts (#4) — management DTOs
// ---------------------------------------------------------------------------

/// Lightweight layout descriptor (id + name + kind), for the tab bar.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LayoutInfoDto {
    /// Stable layout id (UUID string).
    pub id: String,
    /// Display name.
    pub name: String,
    /// Layout kind: `"terminal"`, `"gitGraph"` or `"plugin"`.
    pub kind: String,
}

impl From<LayoutInfo> for LayoutInfoDto {
    fn from(info: LayoutInfo) -> Self {
        let kind = match info.kind {
            LayoutKind::Terminal => "terminal",
            LayoutKind::GitGraph => "gitGraph",
            LayoutKind::Plugin { .. } => "plugin",
        }
        .to_owned();
        Self {
            id: info.id.to_string(),
            name: info.name,
            kind,
        }
    }
}

/// Response DTO for `list_layouts`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ListLayoutsDto {
    /// All named layouts (id + name), in order.
    pub layouts: Vec<LayoutInfoDto>,
    /// The id of the currently active layout.
    pub active_id: String,
}

impl From<ListLayoutsOutput> for ListLayoutsDto {
    fn from(out: ListLayoutsOutput) -> Self {
        Self {
            layouts: out.layouts.into_iter().map(LayoutInfoDto::from).collect(),
            active_id: out.active_id.to_string(),
        }
    }
}

/// Response DTO for `create_layout`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateLayoutResultDto {
    /// The id minted for the new layout.
    pub layout_id: String,
}

impl From<CreateLayoutOutput> for CreateLayoutResultDto {
    fn from(out: CreateLayoutOutput) -> Self {
        Self {
            layout_id: out.layout_id.to_string(),
        }
    }
}

/// Response DTO for `delete_layout`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DeleteLayoutResultDto {
    /// The active layout after the deletion.
    pub active_id: String,
}

impl From<DeleteLayoutOutput> for DeleteLayoutResultDto {
    fn from(out: DeleteLayoutOutput) -> Self {
        Self {
            active_id: out.active_id.to_string(),
        }
    }
}

/// Request DTO for `create_layout`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateLayoutRequestDto {
    /// Owning project id.
    pub project_id: String,
    /// Display name for the new layout.
    pub name: String,
    /// Optional layout kind: `"terminal"` (default), `"gitGraph"` or `"plugin"`.
    #[serde(default)]
    pub kind: Option<String>,
    /// Plugin origin for `"plugin"` layouts.
    #[serde(default)]
    pub plugin_origin: Option<PluginLayoutOriginDto>,
    /// Opaque initial state for `"plugin"` layouts.
    #[serde(default)]
    pub state: serde_json::Value,
}

impl CreateLayoutRequestDto {
    /// Parses the optional `kind` string into a [`LayoutKind`].
    ///
    /// # Errors
    /// [`ErrorDto`] with code `INVALID` if the value is not a known kind.
    pub fn parse_kind(&self) -> Result<LayoutKind, ErrorDto> {
        match self.kind.as_deref() {
            None | Some("terminal") => Ok(LayoutKind::Terminal),
            Some("gitGraph") => Ok(LayoutKind::GitGraph),
            Some("plugin") | Some("customPluginLayout") => {
                let origin = self.plugin_origin.clone().ok_or_else(|| ErrorDto {
                    code: "INVALID".to_owned(),
                    message: "pluginOrigin is required for plugin layouts".to_owned(),
                })?;
                Ok(LayoutKind::Plugin {
                    plugin_origin: origin.try_into()?,
                    state: self.state.clone(),
                })
            }
            Some(other) => Err(ErrorDto {
                code: "INVALID".to_owned(),
                message: format!("unknown layout kind: {other}"),
            }),
        }
    }
}

/// Stable provider identity for a plugin layout creation request.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginLayoutOriginDto {
    /// Provider plugin id.
    pub plugin_id: String,
    /// Layout type declared by the provider.
    pub layout_type: String,
}

impl TryFrom<PluginLayoutOriginDto> for PluginLayoutOrigin {
    type Error = ErrorDto;

    fn try_from(value: PluginLayoutOriginDto) -> Result<Self, Self::Error> {
        let plugin_id = PluginId::new(value.plugin_id).map_err(|e| ErrorDto {
            code: "INVALID".to_owned(),
            message: e.to_string(),
        })?;
        let layout_type = PluginLayoutType::new(value.layout_type).map_err(|e| ErrorDto {
            code: "INVALID".to_owned(),
            message: e.to_string(),
        })?;
        Ok(Self {
            plugin_id,
            layout_type,
        })
    }
}

/// Request DTO for `rename_layout`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RenameLayoutRequestDto {
    /// Owning project id.
    pub project_id: String,
    /// Layout to rename.
    pub layout_id: String,
    /// New display name.
    pub name: String,
}

/// Request DTO for `delete_layout`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeleteLayoutRequestDto {
    /// Owning project id.
    pub project_id: String,
    /// Layout to delete.
    pub layout_id: String,
}

/// Response DTO for `set_active_layout`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SetActiveLayoutResultDto {
    /// The layout actually activated — equals the requested id when valid, else
    /// the unchanged current active id (self-healing fallback). Authoritative.
    pub active_id: String,
}

impl From<SetActiveLayoutOutput> for SetActiveLayoutResultDto {
    fn from(out: SetActiveLayoutOutput) -> Self {
        Self {
            active_id: out.active_id.to_string(),
        }
    }
}

/// Request DTO for `set_active_layout`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetActiveLayoutRequestDto {
    /// Owning project id.
    pub project_id: String,
    /// Layout to make active.
    pub layout_id: String,
}

// ---------------------------------------------------------------------------
// Profiles & first-run (L5)
// ---------------------------------------------------------------------------

use application::{
    CloneOpenCodeProfileFromSeedInput, CloneOpenCodeProfileFromSeedOutput, ConfigureProfilesInput,
    ConfigureProfilesOutput, DeleteProfileInput, DetectProfilesInput, DetectProfilesOutput,
    FirstRunStateOutput, ListProfilesOutput, ProfileAvailability, ReferenceProfilesOutput,
    SaveOpenCodeProviderProfileInput, SaveOpenCodeProviderProfileOutput, SaveProfileInput,
    SaveProfileOutput,
};
use domain::profile::{AgentProfile, CustomProviderConfig, OpenCodeConfig};
use domain::ProfileId;

/// A profile crossing the wire. [`AgentProfile`] already serialises camelCase
/// (id, name, command, args, `contextInjection{strategy,…}`, detect,
/// `cwdTemplate`), so we embed it directly — the TS mirror matches this shape.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ProfileDto(pub AgentProfile);

/// A list of profiles (camelCase array on the wire).
#[derive(Debug, Clone, Serialize)]
#[serde(transparent)]
pub struct ProfileListDto(pub Vec<ProfileDto>);

impl From<Vec<AgentProfile>> for ProfileListDto {
    fn from(v: Vec<AgentProfile>) -> Self {
        Self(v.into_iter().map(ProfileDto).collect())
    }
}

impl From<ListProfilesOutput> for ProfileListDto {
    fn from(out: ListProfilesOutput) -> Self {
        out.profiles.into()
    }
}

impl From<ReferenceProfilesOutput> for ProfileListDto {
    fn from(out: ReferenceProfilesOutput) -> Self {
        out.profiles.into()
    }
}

impl From<SaveProfileOutput> for ProfileDto {
    fn from(out: SaveProfileOutput) -> Self {
        Self(out.profile)
    }
}

impl From<CloneOpenCodeProfileFromSeedOutput> for ProfileDto {
    fn from(out: CloneOpenCodeProfileFromSeedOutput) -> Self {
        Self(out.profile)
    }
}

impl From<application::CloneProfileFromSeedOutput> for ProfileDto {
    fn from(out: application::CloneProfileFromSeedOutput) -> Self {
        Self(out.profile)
    }
}

/// One entry of a curated structured-profile model catalogue.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileModelCatalogEntryDto {
    /// Structured adapter this model belongs to.
    pub adapter: domain::profile::StructuredAdapter,
    /// Exact model identifier to persist on `AgentProfile.model`.
    pub model_id: String,
    /// Human-readable label for picker display.
    pub display_name: String,
    /// Extra search tokens useful to the frontend.
    pub aliases: Vec<String>,
    /// Whether this entry is the conservative default suggestion.
    pub recommended: bool,
    /// Compatibility state against the locally detected CLI version.
    pub compatibility: domain::ModelCompatibility,
    /// Source that contributed the model entry.
    pub source: domain::ModelCatalogSource,
}

impl From<application::ProfileModelCatalogEntry> for ProfileModelCatalogEntryDto {
    fn from(entry: application::ProfileModelCatalogEntry) -> Self {
        Self {
            adapter: entry.adapter,
            model_id: entry.model_id,
            display_name: entry.display_name,
            aliases: entry.aliases,
            recommended: entry.recommended,
            compatibility: entry.compatibility,
            source: entry.source,
        }
    }
}

/// Enriched structured-profile model catalogue.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileModelCatalogDto {
    /// The catalogue entries.
    pub models: Vec<ProfileModelCatalogEntryDto>,
    /// Best-effort local CLI version.
    pub cli_version: Option<String>,
    /// Non-fatal fallback/degradation warnings.
    pub warnings: Vec<String>,
}

impl From<application::ListClaudeModelsOutput> for ProfileModelCatalogDto {
    fn from(out: application::ListClaudeModelsOutput) -> Self {
        Self {
            models: out.models.into_iter().map(Into::into).collect(),
            cli_version: out.cli_version.map(|version| version.raw),
            warnings: out.warnings,
        }
    }
}

impl From<application::ListCodexModelsOutput> for ProfileModelCatalogDto {
    fn from(out: application::ListCodexModelsOutput) -> Self {
        Self {
            models: out.models.into_iter().map(Into::into).collect(),
            cli_version: out.cli_version.map(|version| version.raw),
            warnings: out.warnings,
        }
    }
}

/// One entry of the static OpenCode cloud-provider catalogue (ticket #92, lot B3).
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenCodeProviderDto {
    /// Identifier in the OpenCode provider registry (e.g. `"anthropic"`).
    pub provider_id: String,
    /// Human-readable label for the picker UI.
    pub display_name: String,
    /// Model names this provider serves, offered for selection.
    pub models: Vec<String>,
}

impl From<application::OpenCodeProviderCatalogEntry> for OpenCodeProviderDto {
    fn from(entry: application::OpenCodeProviderCatalogEntry) -> Self {
        Self {
            provider_id: entry.provider_id,
            display_name: entry.display_name,
            models: entry.models,
        }
    }
}

/// A list of OpenCode cloud-provider catalogue entries (camelCase array on the
/// wire).
#[derive(Debug, Clone, Serialize)]
#[serde(transparent)]
pub struct OpenCodeProviderListDto(pub Vec<OpenCodeProviderDto>);

impl From<application::ListOpenCodeProvidersOutput> for OpenCodeProviderListDto {
    fn from(out: application::ListOpenCodeProvidersOutput) -> Self {
        Self(out.providers.into_iter().map(Into::into).collect())
    }
}

/// Request DTO for `detect_profiles`: the candidate profiles to probe.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DetectProfilesRequestDto {
    /// Candidate profiles whose `detect` command should be run.
    pub candidates: Vec<AgentProfile>,
}

impl From<DetectProfilesRequestDto> for DetectProfilesInput {
    fn from(dto: DetectProfilesRequestDto) -> Self {
        Self {
            candidates: dto.candidates,
        }
    }
}

/// One availability result (`profile` + whether its CLI is installed).
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProfileAvailabilityDto {
    /// The probed profile.
    pub profile: AgentProfile,
    /// Whether the CLI was detected (exit code 0).
    pub available: bool,
}

impl From<ProfileAvailability> for ProfileAvailabilityDto {
    fn from(a: ProfileAvailability) -> Self {
        Self {
            profile: a.profile,
            available: a.available,
        }
    }
}

/// Response DTO for `detect_profiles`.
#[derive(Debug, Clone, Serialize)]
#[serde(transparent)]
pub struct DetectProfilesResponseDto(pub Vec<ProfileAvailabilityDto>);

impl From<DetectProfilesOutput> for DetectProfilesResponseDto {
    fn from(out: DetectProfilesOutput) -> Self {
        Self(out.results.into_iter().map(Into::into).collect())
    }
}

/// Request DTO for `save_profile`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SaveProfileRequestDto {
    /// The profile to upsert.
    pub profile: AgentProfile,
}

impl From<SaveProfileRequestDto> for SaveProfileInput {
    fn from(dto: SaveProfileRequestDto) -> Self {
        Self {
            profile: dto.profile,
        }
    }
}

/// Request DTO for `save_opencode_provider_profile` (ticket #92, lot B3): the
/// profile to upsert plus the literal provider fields. `apiKey` never reaches
/// `profiles.json` — the use case seals it into the `SecretStore`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SaveOpenCodeProviderProfileRequestDto {
    /// The profile to create or replace (by id).
    pub profile: AgentProfile,
    /// Provider id in the OpenCode registry (e.g. `"anthropic"`).
    pub provider_id: String,
    /// Model name served by this provider.
    pub model: String,
    /// Literal API key, sealed into the `SecretStore` — never persisted as-is.
    pub api_key: String,
    /// Optional custom-provider configuration (endpoint outside the OpenCode
    /// registry). Absent/`null` = known provider (unchanged behaviour).
    #[serde(default)]
    pub custom: Option<CustomProviderConfig>,
}

impl From<SaveOpenCodeProviderProfileRequestDto> for SaveOpenCodeProviderProfileInput {
    fn from(dto: SaveOpenCodeProviderProfileRequestDto) -> Self {
        Self {
            profile: dto.profile,
            provider_id: dto.provider_id,
            model: dto.model,
            api_key: dto.api_key,
            custom: dto.custom,
        }
    }
}

impl From<SaveOpenCodeProviderProfileOutput> for ProfileDto {
    fn from(out: SaveOpenCodeProviderProfileOutput) -> Self {
        Self(out.profile)
    }
}

/// Request DTO for `clone_opencode_profile_from_seed`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CloneOpenCodeProfileFromSeedRequestDto {
    /// Optional display name for the new profile.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    /// Optional OpenCode config override. When omitted, the seed config is copied.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub opencode: Option<OpenCodeConfig>,
}

impl From<CloneOpenCodeProfileFromSeedRequestDto> for CloneOpenCodeProfileFromSeedInput {
    fn from(dto: CloneOpenCodeProfileFromSeedRequestDto) -> Self {
        Self {
            name: dto.name,
            opencode: dto.opencode,
        }
    }
}

/// Request DTO for `clone_profile_from_seed`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CloneProfileFromSeedRequestDto {
    /// Id of the persisted or reference profile to clone.
    pub seed_profile_id: domain::ids::ProfileId,
    /// Optional display name for the new profile.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    /// Optional model override. When omitted, the seed model is copied.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub model: Option<String>,
}

impl From<CloneProfileFromSeedRequestDto> for application::CloneProfileFromSeedInput {
    fn from(dto: CloneProfileFromSeedRequestDto) -> Self {
        Self {
            seed_profile_id: dto.seed_profile_id,
            name: dto.name,
            model: dto.model,
        }
    }
}

/// Request DTO for `configure_profiles` (closes the first run).
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConfigureProfilesRequestDto {
    /// All profiles the user chose to keep.
    pub profiles: Vec<AgentProfile>,
}

impl From<ConfigureProfilesRequestDto> for ConfigureProfilesInput {
    fn from(dto: ConfigureProfilesRequestDto) -> Self {
        Self {
            profiles: dto.profiles,
        }
    }
}

impl From<ConfigureProfilesOutput> for ProfileListDto {
    fn from(out: ConfigureProfilesOutput) -> Self {
        out.profiles.into()
    }
}

/// Response DTO for `first_run_state`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FirstRunStateDto {
    /// `true` when the first-run wizard should be shown.
    pub is_first_run: bool,
    /// Pre-filled reference catalogue to seed the wizard.
    pub reference_profiles: Vec<AgentProfile>,
}

impl From<FirstRunStateOutput> for FirstRunStateDto {
    fn from(out: FirstRunStateOutput) -> Self {
        Self {
            is_first_run: out.is_first_run,
            reference_profiles: out.reference_profiles,
        }
    }
}

// ---------------------------------------------------------------------------
// Local model servers (B35)
// ---------------------------------------------------------------------------

use application::{
    ListModelServersOutput, ModelArtifactView, ModelServerListItem, SaveModelServerInput,
    SaveModelServerOutput,
};
use domain::model_server::{
    ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef, LocalModelServerConfig,
    LocalModelServerKind, ModelPath, ModelServerEndpoint, ModelSource,
};
use domain::{LocalModelServerId, StopPolicy};

/// Local model-server implementation on the IPC wire.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ModelServerKindDto {
    /// llama.cpp `llama-server`.
    LlamaCpp,
}

impl From<LocalModelServerKind> for ModelServerKindDto {
    fn from(kind: LocalModelServerKind) -> Self {
        match kind {
            LocalModelServerKind::LlamaCpp => Self::LlamaCpp,
        }
    }
}

impl From<ModelServerKindDto> for LocalModelServerKind {
    fn from(kind: ModelServerKindDto) -> Self {
        match kind {
            ModelServerKindDto::LlamaCpp => Self::LlamaCpp,
        }
    }
}

/// Stop policy on the IPC wire.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum StopPolicyDto {
    /// Keep the process alive.
    KeepAlive,
    /// Stop when no longer used.
    StopWhenUnused,
    /// Stop when the application exits.
    StopOnAppExit,
}

impl From<StopPolicy> for StopPolicyDto {
    fn from(policy: StopPolicy) -> Self {
        match policy {
            StopPolicy::KeepAlive => Self::KeepAlive,
            StopPolicy::StopWhenUnused => Self::StopWhenUnused,
            StopPolicy::StopOnAppExit => Self::StopOnAppExit,
        }
    }
}

impl From<StopPolicyDto> for StopPolicy {
    fn from(policy: StopPolicyDto) -> Self {
        match policy {
            StopPolicyDto::KeepAlive => Self::KeepAlive,
            StopPolicyDto::StopWhenUnused => Self::StopWhenUnused,
            StopPolicyDto::StopOnAppExit => Self::StopOnAppExit,
        }
    }
}

/// Model source on the IPC wire.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "type")]
pub enum ModelSourceDto {
    /// Local `.gguf` path.
    LocalPath {
        /// Absolute local path.
        path: String,
    },
    /// Hugging Face `namespace/repo[:quant]` reference.
    HuggingFace {
        /// Repository reference.
        repo: String,
    },
}

impl From<ModelSource> for ModelSourceDto {
    fn from(source: ModelSource) -> Self {
        match source {
            ModelSource::LocalPath { path } => Self::LocalPath { path: path.0 },
            ModelSource::HuggingFace { repo } => Self::HuggingFace { repo: repo.0 },
        }
    }
}

impl ModelSourceDto {
    fn into_domain(self) -> Result<ModelSource, ErrorDto> {
        match self {
            Self::LocalPath { path } => Ok(ModelSource::LocalPath {
                path: ModelPath::new(path).map_err(invalid_domain_error)?,
            }),
            Self::HuggingFace { repo } => Ok(ModelSource::HuggingFace {
                repo: HfModelRef::new(repo).map_err(invalid_domain_error)?,
            }),
        }
    }
}

/// A local model-server config crossing the wire.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelServerConfigDto {
    /// Stable local server config id.
    pub id: String,
    /// Server implementation kind.
    pub kind: ModelServerKindDto,
    /// Display name.
    pub name: String,
    /// OpenAI-compatible base URL.
    #[serde(rename = "baseURL")]
    pub base_url: String,
    /// TCP port.
    pub port: u16,
    /// Optional explicit model source.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub model_source: Option<ModelSourceDto>,
    /// Legacy V1 input alias for `modelSource:{type:"localPath",path}`.
    #[serde(default, skip_serializing)]
    pub model_path: Option<String>,
    /// Served model name exposed to OpenCode.
    pub served_model_name: String,
    /// Optional explicit `llama-server` executable path.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub binary_path: Option<String>,
    /// llama.cpp host passed to `--host`.
    #[serde(default = "default_llamacpp_host")]
    pub host: String,
    /// llama.cpp GPU layers passed to `-ngl`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub gpu_layers: Option<u32>,
    /// llama.cpp context size passed to `-c`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub context_size: Option<u32>,
    /// Whether to pass `--jinja`.
    #[serde(default)]
    pub jinja: bool,
    /// Extra argv entries.
    #[serde(default)]
    pub args: Vec<String>,
    /// Whether IdeA should start the server lazily.
    pub auto_start: bool,
    /// Stop policy.
    pub stop_policy: StopPolicyDto,
    /// Optional readiness warmup deadline override in seconds.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub warmup_deadline_secs: Option<u64>,
    /// Derived local artifact cache state.
    #[serde(default)]
    pub artifact: ModelArtifactDto,
}

impl ModelServerConfigDto {
    /// Maps a domain config to the flat IPC DTO.
    #[must_use]
    pub fn from_domain(config: LocalModelServerConfig) -> Self {
        Self {
            id: config.id.to_string(),
            kind: config.kind.into(),
            name: config.name,
            base_url: config.endpoint.base_url,
            port: config.endpoint.port,
            model_source: config.model.source.map(Into::into),
            model_path: None,
            served_model_name: config.model.served_name,
            binary_path: config.binary.map(|binary| binary.as_str().to_owned()),
            host: config.options.host,
            gpu_layers: config.options.gpu_layers,
            context_size: config.options.context_size,
            jinja: config.options.jinja,
            args: config.args,
            auto_start: config.auto_start,
            stop_policy: config.stop_policy.into(),
            warmup_deadline_secs: config.warmup_deadline_secs,
            artifact: ModelArtifactDto::NotManaged,
        }
    }

    /// Maps the flat IPC DTO into the domain config, preserving internal model id
    /// from the existing config when available.
    ///
    /// # Errors
    /// [`ErrorDto`] with `INVALID` code if any domain invariant rejects the input.
    pub fn into_domain(
        self,
        existing: Option<&LocalModelServerConfig>,
    ) -> Result<LocalModelServerConfig, ErrorDto> {
        let server_id = parse_model_server_id(&self.id)?;
        let endpoint = ModelServerEndpoint::new(self.base_url.clone(), self.port)
            .map_err(invalid_domain_error)?;
        let source = resolve_model_source(self.model_source, self.model_path)?;
        let model_id = existing
            .filter(|config| config.id == server_id)
            .map(|config| config.model.id.clone())
            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
        let label = non_empty_or_fallback(&self.served_model_name, &self.name);
        let model = LocalModelRef::new(model_id, label, source, self.served_model_name.clone())
            .map_err(invalid_domain_error)?;
        let binary = optional_non_empty(self.binary_path)
            .map(ExecutablePath::new)
            .transpose()
            .map_err(invalid_domain_error)?;
        let options =
            LlamaCppOptions::new(self.host, self.gpu_layers, self.context_size, self.jinja)
                .map_err(invalid_domain_error)?;
        LocalModelServerConfig::new(
            server_id,
            self.kind.into(),
            self.name,
            endpoint,
            model,
            binary,
            options,
            self.args,
            self.auto_start,
            self.stop_policy.into(),
        )
        .and_then(|config| config.with_warmup_deadline_secs(self.warmup_deadline_secs))
        .map_err(invalid_domain_error)
    }
}

/// Local model artifact cache state on the IPC wire.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "state")]
pub enum ModelArtifactDto {
    /// The configured source is not managed by IdeA's downloader.
    NotManaged,
    /// The configured source is managed but not present in cache.
    Missing,
    /// A download/prepare operation is currently running for this server.
    Downloading,
    /// The configured source is present in cache.
    Downloaded {
        /// Local artifact path.
        path: String,
        /// Total on-disk size when known.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        size_bytes: Option<u64>,
    },
}

impl Default for ModelArtifactDto {
    fn default() -> Self {
        Self::NotManaged
    }
}

impl From<ModelArtifactView> for ModelArtifactDto {
    fn from(view: ModelArtifactView) -> Self {
        match view {
            ModelArtifactView::NotManaged => Self::NotManaged,
            ModelArtifactView::Missing => Self::Missing,
            ModelArtifactView::Downloading => Self::Downloading,
            ModelArtifactView::Downloaded { path, size_bytes } => {
                Self::Downloaded { path, size_bytes }
            }
        }
    }
}

impl From<ModelServerListItem> for ModelServerConfigDto {
    fn from(item: ModelServerListItem) -> Self {
        let mut dto = Self::from_domain(item.config);
        dto.artifact = item.artifact.into();
        dto
    }
}

/// Response DTO for `preview_model_server_command`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PreviewModelServerCommandDto {
    /// Executable command.
    pub command: String,
    /// Arguments without shell parsing.
    pub args: Vec<String>,
    /// Human-readable escaped command line.
    pub display: String,
}

/// List response for local model servers.
#[derive(Debug, Clone, Serialize)]
#[serde(transparent)]
pub struct ModelServerConfigListDto(pub Vec<ModelServerConfigDto>);

impl From<ListModelServersOutput> for ModelServerConfigListDto {
    fn from(out: ListModelServersOutput) -> Self {
        Self(
            out.servers
                .into_iter()
                .map(ModelServerConfigDto::from)
                .collect(),
        )
    }
}

/// Request DTO for `save_model_server`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SaveModelServerRequestDto {
    /// Config to upsert.
    pub config: ModelServerConfigDto,
}

impl From<SaveModelServerOutput> for ModelServerConfigDto {
    fn from(out: SaveModelServerOutput) -> Self {
        Self::from_domain(out.config)
    }
}

/// Parses a local model-server id string.
///
/// # Errors
/// [`ErrorDto`] with `INVALID` code if the string is not a UUID.
pub fn parse_model_server_id(raw: &str) -> Result<LocalModelServerId, ErrorDto> {
    uuid::Uuid::parse_str(raw)
        .map(LocalModelServerId::from_uuid)
        .map_err(|_| ErrorDto {
            code: "INVALID".to_owned(),
            message: format!("invalid model server id: {raw}"),
        })
}

/// Builds a save-model-server input after the caller has resolved the existing
/// config, if any.
///
/// # Errors
/// [`ErrorDto`] if DTO-to-domain validation fails.
pub fn save_model_server_input(
    request: SaveModelServerRequestDto,
    existing: Option<&LocalModelServerConfig>,
) -> Result<SaveModelServerInput, ErrorDto> {
    Ok(SaveModelServerInput {
        config: request.config.into_domain(existing)?,
    })
}

/// Converts a model-server DTO to domain using the same path as save.
///
/// # Errors
/// [`ErrorDto`] if DTO-to-domain validation fails.
pub fn model_server_config_domain(
    config: ModelServerConfigDto,
    existing: Option<&LocalModelServerConfig>,
) -> Result<LocalModelServerConfig, ErrorDto> {
    config.into_domain(existing)
}

fn resolve_model_source(
    model_source: Option<ModelSourceDto>,
    model_path: Option<String>,
) -> Result<Option<ModelSource>, ErrorDto> {
    let legacy_path = optional_non_empty(model_path);
    match (model_source, legacy_path) {
        (None, None) => Ok(None),
        (None, Some(path)) => Ok(Some(ModelSource::LocalPath {
            path: ModelPath::new(path).map_err(invalid_domain_error)?,
        })),
        (Some(source), None) => Ok(Some(source.into_domain()?)),
        (Some(source), Some(path)) => {
            let domain_source = source.into_domain()?;
            match &domain_source {
                ModelSource::LocalPath { path: source_path } if source_path.as_str() == path => {
                    Ok(Some(domain_source))
                }
                _ => Err(ErrorDto {
                    code: "INVALID".to_owned(),
                    message: "modelPath and modelSource differ".to_owned(),
                }),
            }
        }
    }
}

fn optional_non_empty(raw: Option<String>) -> Option<String> {
    raw.map(|value| value.trim().to_owned())
        .filter(|value| !value.is_empty())
}

fn default_llamacpp_host() -> String {
    "127.0.0.1".to_owned()
}

fn non_empty_or_fallback(primary: &str, fallback: &str) -> String {
    let primary = primary.trim();
    if primary.is_empty() {
        fallback.trim().to_owned()
    } else {
        primary.to_owned()
    }
}

fn invalid_domain_error(error: impl std::fmt::Display) -> ErrorDto {
    ErrorDto {
        code: "INVALID".to_owned(),
        message: error.to_string(),
    }
}

// ---------------------------------------------------------------------------
// Embedder profiles & engines (LOT C2 — §14.5.3)
// ---------------------------------------------------------------------------

use application::{
    DismissChoice, DismissEmbedderSuggestionInput, EmbedderEnginesView, ListEmbedderProfilesOutput,
    OnnxModelView, SaveEmbedderProfileInput, SaveEmbedderProfileOutput,
};
use domain::profile::EmbedderProfile;

/// An embedder profile crossing the wire. [`EmbedderProfile`] already serialises
/// camelCase (`id, name, strategy, model?, endpoint?, apiKeyEnv?, dimension`), so we
/// embed it directly — the TS mirror matches this shape.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(transparent)]
pub struct EmbedderProfileDto(pub EmbedderProfile);

/// A list of embedder profiles (camelCase array on the wire).
#[derive(Debug, Clone, Serialize)]
#[serde(transparent)]
pub struct EmbedderProfileListDto(pub Vec<EmbedderProfileDto>);

impl From<Vec<EmbedderProfile>> for EmbedderProfileListDto {
    fn from(v: Vec<EmbedderProfile>) -> Self {
        Self(v.into_iter().map(EmbedderProfileDto).collect())
    }
}

impl From<ListEmbedderProfilesOutput> for EmbedderProfileListDto {
    fn from(out: ListEmbedderProfilesOutput) -> Self {
        out.profiles.into()
    }
}

impl From<SaveEmbedderProfileOutput> for EmbedderProfileDto {
    fn from(out: SaveEmbedderProfileOutput) -> Self {
        Self(out.profile)
    }
}

/// Request DTO for `save_embedder_profile`: the embedder profile to upsert.
///
/// Carries the [`EmbedderProfile`] fields directly (the entity validates them in the
/// use case). Deserialised camelCase to match the persisted/domain shape.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SaveEmbedderProfileRequestDto {
    /// The embedder profile to upsert.
    pub profile: EmbedderProfile,
}

impl From<SaveEmbedderProfileRequestDto> for SaveEmbedderProfileInput {
    fn from(dto: SaveEmbedderProfileRequestDto) -> Self {
        let EmbedderProfile {
            id,
            name,
            strategy,
            model,
            endpoint,
            api_key_env,
            dimension,
        } = dto.profile;
        Self {
            id,
            name,
            strategy,
            model,
            endpoint,
            api_key_env,
            dimension,
        }
    }
}

/// One recommendable local ONNX model on the wire (camelCase).
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OnnxModelInfoDto {
    /// Stable model id accepted by a `localOnnx` profile's `model` field.
    pub id: String,
    /// Human-readable name for the UI.
    pub display_name: String,
    /// Length of the vectors this model produces.
    pub dimension: usize,
    /// Approximate download/disk size in megabytes.
    pub approx_size_mb: u32,
    /// Whether this is the recommended default model.
    pub recommended: bool,
}

impl From<OnnxModelView> for OnnxModelInfoDto {
    fn from(m: OnnxModelView) -> Self {
        Self {
            id: m.id,
            display_name: m.display_name,
            dimension: m.dimension,
            approx_size_mb: m.approx_size_mb,
            recommended: m.recommended,
        }
    }
}

/// Response DTO for `describe_embedder_engines` (drives the "configure an embedder?"
/// UI). All-camelCase wire shape.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EmbedderEnginesDto {
    /// The curated catalogue of recommendable local ONNX models.
    pub recommended_onnx: Vec<OnnxModelInfoDto>,
    /// Whether an Ollama-style local embedding server was detected (best-effort).
    pub ollama_detected: bool,
    /// Ids of the recommended ONNX models already present in the local cache.
    pub onnx_cached_models: Vec<String>,
    /// Whether the HTTP capability (`localServer`/`api`) is compiled into this binary.
    pub vector_http_enabled: bool,
    /// Whether the in-process ONNX capability (`localOnnx`) is compiled into this binary.
    pub vector_onnx_enabled: bool,
}

impl From<EmbedderEnginesView> for EmbedderEnginesDto {
    fn from(v: EmbedderEnginesView) -> Self {
        Self {
            recommended_onnx: v.recommended_onnx.into_iter().map(Into::into).collect(),
            ollama_detected: v.ollama_detected,
            onnx_cached_models: v.onnx_cached_models,
            vector_http_enabled: v.vector_http_enabled,
            vector_onnx_enabled: v.vector_onnx_enabled,
        }
    }
}

// ---------------------------------------------------------------------------
// Embedder suggestion (LOT C3 — §14.5.5)
// ---------------------------------------------------------------------------

/// The user's response to the embedder suggestion, on the wire (camelCase:
/// `"later"` | `"never"`).
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum DismissChoiceDto {
    /// "Plus tard" — re-proposable next session.
    Later,
    /// "Ne plus demander" — never again.
    Never,
}

impl From<DismissChoiceDto> for DismissChoice {
    fn from(c: DismissChoiceDto) -> Self {
        match c {
            DismissChoiceDto::Later => Self::Later,
            DismissChoiceDto::Never => Self::Never,
        }
    }
}

/// Request DTO for `dismiss_embedder_suggestion`. The `project_id` is resolved to a
/// project root by the command; `choice` is the user's dismissal.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DismissEmbedderSuggestionRequestDto {
    /// The project the suggestion concerned (UUID string).
    pub project_id: String,
    /// The user's choice.
    pub choice: DismissChoiceDto,
}

impl DismissEmbedderSuggestionRequestDto {
    /// Builds the use-case input from a resolved project root + the DTO choice.
    #[must_use]
    pub fn into_input(self, project_root: domain::ProjectPath) -> DismissEmbedderSuggestionInput {
        DismissEmbedderSuggestionInput {
            project_root,
            choice: self.choice.into(),
        }
    }
}

/// Builds a [`DeleteProfileInput`] from a raw profile-id string.
///
/// # Errors
/// [`ErrorDto`] with code `INVALID` if the id is malformed.
pub fn parse_delete_profile(raw: &str) -> Result<DeleteProfileInput, ErrorDto> {
    Ok(DeleteProfileInput {
        id: parse_profile_id(raw)?,
    })
}

/// Parses a profile-id string (UUID) coming from the frontend.
///
/// # Errors
/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID.
pub fn parse_profile_id(raw: &str) -> Result<ProfileId, ErrorDto> {
    uuid::Uuid::parse_str(raw)
        .map(ProfileId::from_uuid)
        .map_err(|_| ErrorDto {
            code: "INVALID".to_owned(),
            message: format!("invalid profile id: {raw}"),
        })
}

// ---------------------------------------------------------------------------
// Agents (L6)
// ---------------------------------------------------------------------------

use application::{
    AgentCapability, ChangeAgentProfileOutput, CreateAgentOutput, InspectConversationOutput,
    LaunchAgentOutput, ListAgentsOutput, ReadAgentContextOutput, ReadMcpToolPermissionsOutput,
};
use domain::{
    Agent, AgentMcpToolPolicyOverride, EffectivePermissions, EffortSelection, McpToolPolicy,
    PermissionSet, PermissionShadowReport, ProjectPermissions, SkillKind, TerminalSession,
};

/// One discoverable capability carried by an agent.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentCapabilityDto {
    /// Capability display name.
    pub name: String,
    /// One-line affordance description.
    pub description: String,
    /// Capability nature.
    pub kind: SkillKind,
}

impl From<AgentCapability> for AgentCapabilityDto {
    fn from(value: AgentCapability) -> Self {
        Self {
            name: value.name,
            description: value.description,
            kind: value.kind,
        }
    }
}

/// An agent crossing the wire. The raw [`Agent`] shape is flattened so existing
/// fields, including `skills`, remain compatible; `capabilities` is additive.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentDto {
    /// Raw agent manifest shape.
    #[serde(flatten)]
    pub agent: Agent,
    /// Resolved discoverable capabilities.
    pub capabilities: Vec<AgentCapabilityDto>,
    /// Whether this agent is the effective project orchestrator.
    pub is_orchestrator: bool,
}

impl AgentDto {
    /// Builds a DTO without resolved capabilities.
    #[must_use]
    pub fn from_agent(agent: Agent) -> Self {
        Self {
            agent,
            capabilities: Vec::new(),
            is_orchestrator: false,
        }
    }
}

/// A list of agents (camelCase array on the wire).
#[derive(Debug, Clone, Serialize)]
#[serde(transparent)]
pub struct AgentListDto(pub Vec<AgentDto>);

impl From<ListAgentsOutput> for AgentListDto {
    fn from(out: ListAgentsOutput) -> Self {
        Self(
            out.discovery_entries()
                .into_iter()
                .map(|entry| AgentDto {
                    agent: entry.agent,
                    capabilities: entry
                        .capabilities
                        .into_iter()
                        .map(AgentCapabilityDto::from)
                        .collect(),
                    is_orchestrator: entry.is_orchestrator,
                })
                .collect(),
        )
    }
}

impl From<CreateAgentOutput> for AgentDto {
    fn from(out: CreateAgentOutput) -> Self {
        Self::from_agent(out.agent)
    }
}

/// Request DTO for `create_agent`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateAgentRequestDto {
    /// Id of the owning project.
    pub project_id: String,
    /// Display name for the new agent.
    pub name: String,
    /// Runtime profile id.
    pub profile_id: String,
    /// Initial Markdown content (empty when absent).
    #[serde(default)]
    pub initial_content: Option<String>,
}

/// Response DTO for `read_agent_context`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReadAgentContextResponseDto {
    /// The agent's Markdown context content.
    pub content: String,
}

impl From<ReadAgentContextOutput> for ReadAgentContextResponseDto {
    fn from(out: ReadAgentContextOutput) -> Self {
        Self {
            content: out.content.into_string(),
        }
    }
}

/// Request DTO for `update_agent_context`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateAgentContextRequestDto {
    /// Id of the owning project.
    pub project_id: String,
    /// Id of the agent to update.
    pub agent_id: String,
    /// New Markdown content.
    pub content: String,
}

// ---------------------------------------------------------------------------
// Permissions (LP1)
// ---------------------------------------------------------------------------

/// Full project permission document crossing the wire.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ProjectPermissionsDto(pub ProjectPermissions);

/// Effective permissions crossing the wire.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(transparent)]
pub struct EffectivePermissionsDto(pub EffectivePermissions);

/// Response for resolving one agent's file/bash permissions.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResolveAgentPermissionsResponseDto {
    /// Resolved policy, or `null` when neither project nor agent policy exists.
    pub effective: Option<EffectivePermissions>,
    /// Diagnostic report for agent-level allows shadowed by project defaults.
    pub shadowed: PermissionShadowReport,
}

impl From<application::ResolveAgentPermissionsOutput> for ResolveAgentPermissionsResponseDto {
    fn from(out: application::ResolveAgentPermissionsOutput) -> Self {
        Self {
            effective: out.effective,
            shadowed: out.shadowed,
        }
    }
}

/// Full project system permission document crossing the wire.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ProjectSystemPermissionsDto(pub ProjectSystemPermissions);

/// Resolved agent system permissions crossing the wire.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ResolvedAgentSystemPermissionsDto(pub ResolvedAgentSystemPermissions);

/// Canonical MCP tool catalogue classification crossing the wire.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpToolCatalogueDto {
    /// Tools allowed by the default read-only fallback.
    pub read_only_tools: Vec<String>,
    /// Tools treated as writing/action/execution tools.
    pub write_action_tools: Vec<String>,
}

/// Full MCP tool permission state crossing the wire.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectMcpToolPermissionsDto {
    /// Document format version.
    pub version: u32,
    /// Canonical catalogue classification used for validation and display.
    pub catalogue: McpToolCatalogueDto,
    /// Optional project-wide default MCP tool policy.
    pub project_default: Option<McpToolPolicy>,
    /// Per-agent overrides.
    pub agents: Vec<AgentMcpToolPolicyOverride>,
}

impl From<ReadMcpToolPermissionsOutput> for ProjectMcpToolPermissionsDto {
    fn from(out: ReadMcpToolPermissionsOutput) -> Self {
        Self {
            version: out.permissions.version,
            catalogue: McpToolCatalogueDto {
                read_only_tools: out.catalogue.read_only_tools,
                write_action_tools: out.catalogue.write_action_tools,
            },
            project_default: out.permissions.project_default,
            agents: out.permissions.agents,
        }
    }
}

/// Request DTO for updating project default permissions.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateProjectPermissionsRequestDto {
    /// Id of the owning project.
    pub project_id: String,
    /// New project defaults. `null` removes defaults.
    pub permissions: Option<PermissionSet>,
}

/// Request DTO for updating one agent permission override.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateAgentPermissionsRequestDto {
    /// Id of the owning project.
    pub project_id: String,
    /// Target agent id.
    pub agent_id: String,
    /// New override. `null` removes the override.
    pub permissions: Option<PermissionSet>,
}

/// Request DTO for resolving one agent's effective permissions.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResolveAgentPermissionsRequestDto {
    /// Id of the owning project.
    pub project_id: String,
    /// Target agent id.
    pub agent_id: String,
}

/// Request DTO for updating project default system permissions.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateProjectSystemPermissionsRequestDto {
    /// Id of the owning project.
    pub project_id: String,
    /// New project defaults. `null` removes defaults.
    pub permissions: Option<SystemPermissionSet>,
}

/// Request DTO for updating one agent system permission override.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateAgentSystemPermissionsRequestDto {
    /// Id of the owning project.
    pub project_id: String,
    /// Target agent id.
    pub agent_id: String,
    /// New override. `null` removes the override.
    pub permissions: Option<SystemPermissionSet>,
}

/// Request DTO for resolving one agent's effective system permissions.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResolveAgentSystemPermissionsRequestDto {
    /// Id of the owning project.
    pub project_id: String,
    /// Target agent id.
    pub agent_id: String,
}

/// Request DTO for updating project default MCP tool permissions.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateProjectMcpToolPermissionsRequestDto {
    /// Id of the owning project.
    pub project_id: String,
    /// New project MCP tool policy. `null` removes the default.
    pub policy: Option<McpToolPolicy>,
}

/// Request DTO for updating one agent MCP tool permission override.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateAgentMcpToolPermissionsRequestDto {
    /// Id of the owning project.
    pub project_id: String,
    /// Target agent id.
    pub agent_id: String,
    /// New agent MCP tool policy. `null` removes the override.
    pub policy: Option<McpToolPolicy>,
}

/// Request DTO for `update_project_context`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateProjectContextRequestDto {
    /// Id of the owning project.
    pub project_id: String,
    /// New project-level Markdown context, stored under `.ideai/CONTEXT.md`.
    pub content: String,
}

/// Request DTO for `launch_agent`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LaunchAgentRequestDto {
    /// Id of the owning project.
    pub project_id: String,
    /// Id of the agent to launch.
    pub agent_id: String,
    /// Initial terminal height in rows.
    pub rows: u16,
    /// Initial terminal width in columns.
    pub cols: u16,
    /// Persistent CLI conversation id currently recorded on the hosting cell, if
    /// any. `Some` ⇒ the launch resumes it; absent/`None` ⇒ a fresh cell (the
    /// launch may assign a new id when the profile supports it).
    #[serde(default)]
    pub conversation_id: Option<String>,
    /// The layout leaf (node) hosting this launch. Enforces the "one live session
    /// per agent" invariant: a launch into a node different from where the agent
    /// is already running is refused (`AGENT_ALREADY_RUNNING`); the same node is
    /// idempotent. Absent ⇒ a fresh node is minted (and any already-live agent is
    /// refused).
    #[serde(default)]
    pub node_id: Option<String>,
}

impl From<LaunchAgentOutput> for TerminalSessionDto {
    fn from(out: LaunchAgentOutput) -> Self {
        let assigned_conversation_id = out.assigned_conversation_id;
        let engine_session_id = out.engine_session_id;
        // §17.6: the cell kind is *derived* from the launch routing. A structured
        // descriptor (`structured: Some(..)`) means LaunchAgent routed to an
        // AgentSession ⇒ chat cell; its absence means the PTY/terminal path ⇒
        // terminal cell. Single source of truth, no layout migration.
        let cell_kind = if out.structured.is_some() {
            CellKind::Chat
        } else {
            CellKind::Pty
        };
        let s = out.session;
        Self {
            session_id: s.id.to_string(),
            cwd: s.cwd.as_str().to_owned(),
            rows: s.pty_size.rows,
            cols: s.pty_size.cols,
            assigned_conversation_id,
            engine_session_id,
            cell_kind,
        }
    }
}

/// Request DTO for `interrupt_agent` (cadrage C4 §4.2): the Interrompre path. The
/// frontend sends `{ request: { projectId, agentId } }`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InterruptAgentRequestDto {
    /// Id of the owning project.
    pub project_id: String,
    /// Id of the agent whose running turn to preempt.
    pub agent_id: String,
}

/// Request DTO for `delegation_delivered` (ARCHITECTURE §20.3): the frontend write-
/// portal acks that it **physically wrote** a delegation `ticket` into the agent's
/// native PTY. Best-effort observability — it never changes correlation (the `ask` is
/// still woken by `idea_reply`). The frontend sends `{ request: { projectId, agentId,
/// ticket } }`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeliveredDelegationRequestDto {
    /// Id of the owning project.
    pub project_id: String,
    /// Id of the agent whose terminal received the delegation.
    pub agent_id: String,
    /// Id of the delivered mailbox ticket.
    pub ticket: String,
}

/// Request DTO for `set_front_attached`: the write-portal of an agent cell reports
/// whether a **frontend terminal cell is mounted** for `agentId` (`true` on mount,
/// `false` on unmount). The mediator uses it to choose, at delivery time, between
/// publishing `DelegationReady` (a cell will write it) and writing the turn into the
/// PTY itself (headless/background-delegated agent with no cell). The frontend sends
/// `{ request: { agentId, attached } }`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FrontAttachedRequestDto {
    /// Id of the agent whose terminal cell mounted/unmounted.
    pub agent_id: String,
    /// `true` when the cell's write-portal is now active, `false` on teardown.
    pub attached: bool,
}

/// Request DTO for `change_agent_profile` (§15.1): hot-swap an agent's runtime
/// profile, optionally relaunching its live session in place.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChangeAgentProfileRequestDto {
    /// Id of the owning project.
    pub project_id: String,
    /// Id of the agent whose runtime profile to change.
    pub agent_id: String,
    /// Id of the new runtime profile.
    pub profile_id: String,
    /// Terminal height in rows for a possible hot relaunch.
    pub rows: u16,
    /// Terminal width in columns for a possible hot relaunch.
    pub cols: u16,
}

/// Request DTO for `update_agent_effort`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateAgentEffortRequestDto {
    /// Id of the owning project.
    pub project_id: String,
    /// Id of the agent whose effort override changes.
    pub agent_id: String,
    /// `null` clears the override.
    pub effort: Option<EffortSelection>,
}

/// Response DTO for `change_agent_profile`: the mutated agent plus the freshly
/// relaunched session when a live session was hot-swapped (absent otherwise).
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ChangeAgentProfileDto {
    /// The agent now carrying the new profile.
    pub agent: AgentDto,
    /// The relaunched session, present only when a live session was swapped.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub relaunched_session: Option<TerminalSessionDto>,
}

impl From<ChangeAgentProfileOutput> for ChangeAgentProfileDto {
    fn from(out: ChangeAgentProfileOutput) -> Self {
        Self {
            agent: AgentDto::from_agent(out.agent),
            relaunched_session: out.relaunched.map(TerminalSessionDto::from),
        }
    }
}

impl From<TerminalSession> for TerminalSessionDto {
    fn from(s: TerminalSession) -> Self {
        Self {
            session_id: s.id.to_string(),
            cwd: s.cwd.as_str().to_owned(),
            rows: s.pty_size.rows,
            cols: s.pty_size.cols,
            assigned_conversation_id: None,
            engine_session_id: None,
            cell_kind: CellKind::Pty,
        }
    }
}

// ---------------------------------------------------------------------------
// Structured chat sessions (§17 — D4)
// ---------------------------------------------------------------------------

/// One incremental chunk of a structured agent reply, streamed over the chat
/// session's adapter-owned channel. The serialised wire twin of a
/// [`domain::ports::ReplyEvent`]: the `agent_send` pump maps each turn event to
/// one of these and pushes it to the frontend `AgentChatView`.
///
/// Tagged on `kind` (camelCase: `"textDelta"` | `"toolActivity"` | `"final"` |
/// `"error"`), so the front branches without positional parsing. `Final` is the
/// normal deterministic terminal chunk of a turn; `Error` is a visible terminal
/// fallback for an otherwise silent/empty turn. `Deserialize` is derived too so
/// tests (and a mock gateway) can round-trip it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum ReplyChunk {
    /// An assistant text fragment (incremental chat rendering).
    #[serde(rename_all = "camelCase")]
    TextDelta {
        /// The text fragment.
        text: String,
    },
    /// A human-readable tool-activity badge (best-effort observability).
    #[serde(rename_all = "camelCase")]
    ToolActivity {
        /// The human-readable activity label.
        label: String,
    },
    /// The deterministic end-of-turn chunk carrying the aggregated final content.
    #[serde(rename_all = "camelCase")]
    Final {
        /// The aggregated final content of the turn.
        content: String,
    },
    /// A visible terminal fallback when the model stream produced no usable answer.
    #[serde(rename_all = "camelCase")]
    Error {
        /// Human-readable explanation to render in the chat cell.
        message: String,
    },
}

/// Response DTO for `reattach_agent_chat`: the retained **conversation
/// scrollback** of a still-live structured session, replayed into the
/// re-mounting `AgentChatView` before the new reply stream is wired (§17.6). The
/// typed twin of [`ReattachResultDto`] (PTY scrollback bytes) — here the
/// scrollback is the ordered list of [`ReplyChunk`]s already streamed.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReattachChatDto {
    /// The session that was re-attached (echoed back for the frontend).
    pub session_id: String,
    /// The chunks already streamed for this conversation, in order. The frontend
    /// replays them to rebuild the visible turns, then receives subsequent chunks
    /// over the freshly-registered channel.
    pub scrollback: Vec<ReplyChunk>,
}

/// Request DTO for `inspect_conversation` (T7).
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InspectConversationRequestDto {
    /// Id of the owning project.
    pub project_id: String,
    /// Id of the agent whose conversation is inspected.
    pub agent_id: String,
    /// The conversation id recorded on the hosting cell.
    pub conversation_id: String,
}

/// Response DTO for `inspect_conversation` (T7): the best-effort enriched
/// details for a resume popup. Both fields are optional and **omitted from the
/// wire when `None`** (`skip_serializing_if`), so the TypeScript side sees an
/// absent key — not `null` — for a degraded inspection.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ConversationDetailsDto {
    /// A short, best-effort label for the conversation (last user message,
    /// truncated). Absent when it could not be extracted.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub last_topic: Option<String>,
    /// A best-effort cumulative token count. Absent when no usage info exists.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub token_count: Option<u64>,
}

impl From<InspectConversationOutput> for ConversationDetailsDto {
    fn from(out: InspectConversationOutput) -> Self {
        Self {
            last_topic: out.details.last_topic,
            token_count: out.details.token_count,
        }
    }
}

/// One currently-live agent and the cell hosting it, for `list_live_agents`.
///
/// Lets the UI disable an agent already running in another cell (it cannot be
/// launched a second time — the "one live session per agent" invariant).
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LiveAgentDto {
    /// The live agent's id (UUID string).
    pub agent_id: String,
    /// The hosting layout leaf's node id (UUID string).
    pub node_id: String,
    /// The live PTY session id, used to reattach a newly-opened cell without
    /// respawning the agent.
    pub session_id: String,
    /// Runtime family that owns the session (`pty`/`structured`).
    pub kind: LiveWorkSessionKindDto,
}

/// Response DTO for `list_live_agents` (transparent array on the wire).
#[derive(Debug, Clone, Serialize)]
#[serde(transparent)]
pub struct LiveAgentListDto(pub Vec<LiveAgentDto>);

impl LiveAgentListDto {
    /// Builds the wire list from the registry's live-session snapshots.
    ///
    /// De-duplicates by `agent_id`: the "one live session per agent" invariant
    /// guarantees an agent is live in at most one registry (PTY **or**
    /// structured), but the aggregated input could in theory carry the same agent
    /// twice; the UI contract is a dup-free set (each agent appears once), so we
    /// keep the first occurrence and drop any later one for the same agent.
    #[must_use]
    pub fn from_snapshots(pairs: Vec<LiveSessionSnapshot>) -> Self {
        let mut seen = std::collections::HashSet::new();
        Self(
            pairs
                .into_iter()
                .filter(|snapshot| seen.insert(snapshot.agent_id))
                .map(|snapshot| LiveAgentDto {
                    agent_id: snapshot.agent_id.to_string(),
                    node_id: snapshot.node_id.to_string(),
                    session_id: snapshot.session_id.to_string(),
                    kind: snapshot.kind.into(),
                })
                .collect(),
        )
    }
}

/// Runtime family of a live work-state session.
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum LiveWorkSessionKindDto {
    /// Raw PTY-backed CLI session.
    Pty,
    /// Structured agent-session backend.
    Structured,
}

impl From<LiveSessionKind> for LiveWorkSessionKindDto {
    fn from(kind: LiveSessionKind) -> Self {
        match kind {
            LiveSessionKind::Pty => Self::Pty,
            LiveSessionKind::Structured => Self::Structured,
        }
    }
}

/// Live session coordinates in the project work-state read model.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LiveWorkSessionDto {
    /// The layout node currently hosting the session view.
    pub node_id: String,
    /// The live session id.
    pub session_id: String,
    /// Runtime family that owns the session.
    pub kind: LiveWorkSessionKindDto,
}

/// Derived processing status of a queued ticket.
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum TicketWorkStatusDto {
    /// The agent's current busy turn is running this ticket.
    InProgress,
    /// Waiting behind the head / the agent is idle.
    Queued,
}

impl From<TicketWorkStatus> for TicketWorkStatusDto {
    fn from(status: TicketWorkStatus) -> Self {
        match status {
            TicketWorkStatus::InProgress => Self::InProgress,
            TicketWorkStatus::Queued => Self::Queued,
        }
    }
}

/// Origin of a queued ticket (human operator or a delegating agent).
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum TicketWorkSourceDto {
    /// The human operator.
    Human,
    /// Another agent delegating via `idea_ask_agent`.
    #[serde(rename_all = "camelCase")]
    Agent {
        /// The delegating agent id.
        agent_id: String,
    },
}

impl From<TicketWorkSource> for TicketWorkSourceDto {
    fn from(source: TicketWorkSource) -> Self {
        match source {
            TicketWorkSource::Human => Self::Human,
            TicketWorkSource::Agent { agent_id } => Self::Agent {
                agent_id: agent_id.to_string(),
            },
        }
    }
}

/// One queued/in-progress delegation ticket for an agent.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentTicketStateDto {
    /// Stable id of the queued ticket.
    pub ticket_id: String,
    /// Conversation thread this task enters.
    pub conversation_id: String,
    /// FIFO position at snapshot time (`0` = head).
    pub position: u32,
    /// Derived status (in-progress vs queued).
    pub status: TicketWorkStatusDto,
    /// Origin of the ticket.
    pub source: TicketWorkSourceDto,
    /// Display label of the requester.
    pub requester_label: String,
    /// Bounded excerpt of the task.
    pub task_preview: String,
    /// Character length of the original (un-truncated) task.
    pub task_len: usize,
}

impl From<AgentTicketState> for AgentTicketStateDto {
    fn from(ticket: AgentTicketState) -> Self {
        Self {
            ticket_id: ticket.ticket_id.to_string(),
            conversation_id: ticket.conversation_id.to_string(),
            position: ticket.position,
            status: ticket.status.into(),
            source: ticket.source.into(),
            requester_label: ticket.requester_label,
            task_preview: ticket.task_preview,
            task_len: ticket.task_len,
        }
    }
}

/// One background task owned by an agent in the Work panel read model.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentBackgroundTaskStateDto {
    /// Stable task id (UUID string).
    pub task_id: String,
    /// Kind discriminant.
    pub kind: String,
    /// Lifecycle state.
    pub state: String,
    /// Process exit code, when the terminal result carries one.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub exit_code: Option<i32>,
    /// Human-readable summary / error / reason of the terminal result.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub summary: Option<String>,
    /// Bounded stdout tail.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub stdout_tail: Option<String>,
    /// Bounded stderr tail.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub stderr_tail: Option<String>,
    /// Agent that requested a headless rendezvous, when known.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub requester_agent_id: Option<String>,
    /// Target agent for a headless rendezvous.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub target_agent_id: Option<String>,
    /// Conversation opened by a headless rendezvous.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub conversation_id: Option<String>,
    /// Creation timestamp, epoch milliseconds.
    pub created_at_ms: u64,
    /// Last update timestamp, epoch milliseconds.
    pub updated_at_ms: u64,
}

impl From<AgentBackgroundTaskState> for AgentBackgroundTaskStateDto {
    fn from(task: AgentBackgroundTaskState) -> Self {
        Self {
            task_id: task.task_id.to_string(),
            kind: background_kind_label_from_work_state(task.kind).to_owned(),
            state: background_state_label(task.state).to_owned(),
            exit_code: task.exit_code,
            summary: task.summary,
            stdout_tail: task.stdout_tail,
            stderr_tail: task.stderr_tail,
            requester_agent_id: task.requester_agent_id.map(|id| id.to_string()),
            target_agent_id: task.target_agent_id.map(|id| id.to_string()),
            conversation_id: task.conversation_id.map(|id| id.to_string()),
            created_at_ms: task.created_at_ms,
            updated_at_ms: task.updated_at_ms,
        }
    }
}

/// One manifest agent's current live/busy state.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentWorkStateDto {
    /// Agent id.
    pub agent_id: String,
    /// Agent display name.
    pub name: String,
    /// Runtime profile id assigned to the agent.
    pub profile_id: String,
    /// Live session, if any.
    pub live: Option<LiveWorkSessionDto>,
    /// Current mediated-input busy state.
    pub busy: AgentBusyState,
    /// Pending/in-progress delegation tickets, in FIFO order.
    pub tickets: Vec<AgentTicketStateDto>,
    /// Best-effort first-class background tasks owned by this agent.
    pub background_tasks: Vec<AgentBackgroundTaskStateDto>,
}

/// How much of a [`ConversationWorkSummaryDto`] could be derived, best-effort.
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum ConversationPreviewStatusDto {
    /// A handoff was present and usable.
    Ready,
    /// No handoff yet; `recentTurns` may carry the log fallback.
    Missing,
    /// The handoff was unreadable but the log fallback was readable.
    Partial,
    /// Neither the handoff nor the log could be read.
    Unavailable,
}

impl From<ConversationPreviewStatus> for ConversationPreviewStatusDto {
    fn from(status: ConversationPreviewStatus) -> Self {
        match status {
            ConversationPreviewStatus::Ready => Self::Ready,
            ConversationPreviewStatus::Missing => Self::Missing,
            ConversationPreviewStatus::Partial => Self::Partial,
            ConversationPreviewStatus::Unavailable => Self::Unavailable,
        }
    }
}

/// One recent turn surfaced in a conversation summary's log fallback.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ConversationTurnWorkPreviewDto {
    /// Nature of the turn (`prompt`/`response`/`toolActivity`).
    pub role: TurnRole,
    /// Origin of the turn (human operator or a delegating agent).
    pub source: TicketWorkSourceDto,
    /// Timestamp (epoch milliseconds) of the turn.
    pub at_ms: u64,
    /// Bounded excerpt of the turn text.
    pub text_preview: String,
    /// Character length of the original (un-truncated) turn text.
    pub text_len: usize,
}

impl From<ConversationTurnWorkPreview> for ConversationTurnWorkPreviewDto {
    fn from(turn: ConversationTurnWorkPreview) -> Self {
        Self {
            role: turn.role,
            source: turn.source.into(),
            at_ms: turn.at_ms,
            text_preview: turn.text_preview,
            text_len: turn.text_len,
        }
    }
}

/// Best-effort, read-only summary of one conversation visible through the tickets.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ConversationWorkSummaryDto {
    /// The conversation (pair) this summary describes.
    pub conversation_id: String,
    /// How much could be derived.
    pub status: ConversationPreviewStatusDto,
    /// Bounded excerpt of the handoff objective, when present.
    pub objective_preview: Option<String>,
    /// Bounded excerpt of the handoff summary, when present.
    pub summary_preview: Option<String>,
    /// Character length of the original (un-truncated) handoff summary (`0` when none).
    pub summary_len: usize,
    /// Cursor (last turn id) covered by the handoff summary, when present.
    pub up_to: Option<String>,
    /// Bounded, recent turns from the log fallback.
    pub recent_turns: Vec<ConversationTurnWorkPreviewDto>,
}

impl From<ConversationWorkSummary> for ConversationWorkSummaryDto {
    fn from(summary: ConversationWorkSummary) -> Self {
        Self {
            conversation_id: summary.conversation_id.to_string(),
            status: summary.status.into(),
            objective_preview: summary.objective_preview,
            summary_preview: summary.summary_preview,
            summary_len: summary.summary_len,
            up_to: summary.up_to.map(|cursor| cursor.to_string()),
            recent_turns: summary
                .recent_turns
                .into_iter()
                .map(ConversationTurnWorkPreviewDto::from)
                .collect(),
        }
    }
}

/// Project-level read model for conversation/delegation UX.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectWorkStateDto {
    /// Manifest agents in manifest order, enriched with live/busy state.
    pub agents: Vec<AgentWorkStateDto>,
    /// Best-effort summaries of the conversations referenced by the tickets,
    /// joined frontend-side via `tickets[].conversationId`.
    pub conversations: Vec<ConversationWorkSummaryDto>,
}

impl From<ProjectWorkState> for ProjectWorkStateDto {
    fn from(state: ProjectWorkState) -> Self {
        Self {
            agents: state
                .agents
                .into_iter()
                .map(|agent| AgentWorkStateDto {
                    agent_id: agent.agent_id.to_string(),
                    name: agent.name,
                    profile_id: agent.profile_id.to_string(),
                    live: agent.live.map(|live| LiveWorkSessionDto {
                        node_id: live.node_id.to_string(),
                        session_id: live.session_id.to_string(),
                        kind: live.kind.into(),
                    }),
                    busy: agent.busy,
                    tickets: agent
                        .tickets
                        .into_iter()
                        .map(AgentTicketStateDto::from)
                        .collect(),
                    background_tasks: agent
                        .background_tasks
                        .into_iter()
                        .map(AgentBackgroundTaskStateDto::from)
                        .collect(),
                })
                .collect(),
            conversations: state
                .conversations
                .into_iter()
                .map(ConversationWorkSummaryDto::from)
                .collect(),
        }
    }
}

/// App-wide shutdown guard read model for the exit confirmation flow.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AppExitWorkGuardStateDto {
    /// Whether at least one active work item would be interrupted by app exit.
    pub has_work_in_progress: bool,
    /// Number of busy agents across all open projects.
    pub busy_agent_count: usize,
    /// Number of non-terminal background tasks across all open projects.
    pub active_background_task_count: usize,
    /// Total active work items.
    pub total_work_count: usize,
    /// Best-effort compact details for the confirmation dialog.
    pub details: Vec<AppExitWorkGuardDetailDto>,
}

impl From<AppExitWorkGuardState> for AppExitWorkGuardStateDto {
    fn from(state: AppExitWorkGuardState) -> Self {
        Self {
            has_work_in_progress: state.has_work_in_progress,
            busy_agent_count: state.busy_agent_count,
            active_background_task_count: state.active_background_task_count,
            total_work_count: state.busy_agent_count + state.active_background_task_count,
            details: state
                .details
                .into_iter()
                .map(AppExitWorkGuardDetailDto::from)
                .collect(),
        }
    }
}

/// One active work item contributing to [`AppExitWorkGuardStateDto`].
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum AppExitWorkGuardDetailDto {
    /// A manifest agent is currently processing a turn.
    BusyAgent {
        /// Owning project id.
        project_id: String,
        /// Owning project display name.
        project_name: String,
        /// Agent id.
        agent_id: String,
        /// Agent display name.
        agent_name: String,
        /// Busy ticket id, when available.
        ticket_id: Option<String>,
    },
    /// A first-class background task is queued, running or waiting.
    ActiveBackgroundTask {
        /// Owning project id.
        project_id: String,
        /// Owning project display name.
        project_name: String,
        /// Owning agent id.
        agent_id: String,
        /// Owning agent display name.
        agent_name: String,
        /// Stable task id.
        task_id: String,
        /// Lifecycle state.
        state: String,
        /// Kind discriminant.
        task_kind: String,
    },
}

impl From<AppExitWorkGuardDetail> for AppExitWorkGuardDetailDto {
    fn from(detail: AppExitWorkGuardDetail) -> Self {
        match detail {
            AppExitWorkGuardDetail::BusyAgent {
                project_id,
                project_name,
                agent_id,
                agent_name,
                ticket_id,
            } => Self::BusyAgent {
                project_id: project_id.to_string(),
                project_name,
                agent_id: agent_id.to_string(),
                agent_name,
                ticket_id: ticket_id.map(|id| id.to_string()),
            },
            AppExitWorkGuardDetail::ActiveBackgroundTask {
                project_id,
                project_name,
                agent_id,
                agent_name,
                task_id,
                state,
                kind,
            } => Self::ActiveBackgroundTask {
                project_id: project_id.to_string(),
                project_name,
                agent_id: agent_id.to_string(),
                agent_name,
                task_id: task_id.to_string(),
                state: background_state_label(state).to_owned(),
                task_kind: background_kind_label_from_work_state(kind).to_owned(),
            },
        }
    }
}

/// Request DTO for `attach_live_agent`: bind an already-running agent session to
/// a visible layout cell without spawning a new process.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AttachLiveAgentRequestDto {
    /// Id of the owning project (validated for symmetry and future scoping).
    pub project_id: String,
    /// Id of the already-running agent.
    pub agent_id: String,
    /// Layout leaf that should display the live session.
    pub node_id: String,
}

/// Response DTO for `attach_live_agent`: the rebound live session's coordinates.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AttachLiveAgentResponseDto {
    /// The rebound agent's id.
    pub agent_id: String,
    /// The node now hosting the session view.
    pub node_id: String,
    /// The (unchanged) live session id.
    pub session_id: String,
    /// Runtime family that owns the session (`pty`/`structured`).
    pub kind: LiveWorkSessionKindDto,
}

impl From<AttachLiveAgentOutput> for AttachLiveAgentResponseDto {
    fn from(out: AttachLiveAgentOutput) -> Self {
        Self {
            agent_id: out.agent_id.to_string(),
            node_id: out.node_id.to_string(),
            session_id: out.session_id.to_string(),
            kind: out.kind.into(),
        }
    }
}

/// Request DTO for `stop_live_agent`: tear down an already-running agent's live
/// session by agent id (no spawn, agent entity preserved).
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StopLiveAgentRequestDto {
    /// Id of the owning project (validated for symmetry and future scoping).
    pub project_id: String,
    /// Id of the running agent to stop.
    pub agent_id: String,
}

/// Response DTO for `stop_live_agent`: the torn-down session's coordinates.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StopLiveAgentResponseDto {
    /// The stopped agent's id.
    pub agent_id: String,
    /// The id of the session that was torn down.
    pub session_id: String,
    /// Runtime family that owned the session (`pty`/`structured`).
    pub kind: LiveWorkSessionKindDto,
}

impl From<StopLiveAgentOutput> for StopLiveAgentResponseDto {
    fn from(out: StopLiveAgentOutput) -> Self {
        Self {
            agent_id: out.agent_id.to_string(),
            session_id: out.session_id.to_string(),
            kind: out.kind.into(),
        }
    }
}

/// Parses an agent-id string (UUID) coming from the frontend.
///
/// # Errors
/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID.
pub fn parse_agent_id(raw: &str) -> Result<AgentId, ErrorDto> {
    uuid::Uuid::parse_str(raw)
        .map(AgentId::from_uuid)
        .map_err(|_| ErrorDto {
            code: "INVALID".to_owned(),
            message: format!("invalid agent id: {raw}"),
        })
}

/// Parses a ticket-id string (UUID) coming from the frontend (`delegation_delivered`).
///
/// # Errors
/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID.
pub fn parse_ticket_id(raw: &str) -> Result<domain::TicketId, ErrorDto> {
    uuid::Uuid::parse_str(raw)
        .map(domain::TicketId::from_uuid)
        .map_err(|_| ErrorDto {
            code: "INVALID".to_owned(),
            message: format!("invalid ticket id: {raw}"),
        })
}

// ---------------------------------------------------------------------------
// Resumable agents (§15.2 — Chantier B2)
// ---------------------------------------------------------------------------

use application::{ListResumableAgentsOutput, ResumableAgent};

/// One resumable agent cell, as seen by the frontend (§15.2).
///
/// Mirrors [`ResumableAgent`]: the agent's identity + its host cell, the CLI
/// conversation id to resume (absent ⇒ fresh relaunch), the `was_running` flag
/// frozen at close, and whether the agent's profile can resume a CLI
/// conversation. `conversationId` is **omitted from the wire when `None`**
/// (`skip_serializing_if`), so the TypeScript side sees an absent key — not
/// `null`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ResumableAgentDto {
    /// The resumable agent's id (UUID string).
    pub agent_id: String,
    /// The agent's display name (resolved from the manifest).
    pub name: String,
    /// The host layout leaf where the agent is relaunched/resumed (UUID string).
    pub node_id: String,
    /// Persistent CLI conversation id carried by the cell. Absent ⇒ fresh
    /// relaunch (no history to resume).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub conversation_id: Option<String>,
    /// The `agent_was_running` flag frozen at the cell's close.
    pub was_running: bool,
    /// `true` when the agent's profile carries a usable resume strategy.
    pub resume_supported: bool,
}

impl From<ResumableAgent> for ResumableAgentDto {
    fn from(r: ResumableAgent) -> Self {
        Self {
            agent_id: r.agent_id.to_string(),
            name: r.name,
            node_id: r.node_id.to_string(),
            conversation_id: r.conversation_id,
            was_running: r.was_running,
            resume_supported: r.resume_supported,
        }
    }
}

/// Response DTO for `list_resumable_agents` (§15.2).
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ResumableAgentListDto {
    /// The resumable agent cells, in layout-traversal order.
    pub resumable: Vec<ResumableAgentDto>,
}

impl From<ListResumableAgentsOutput> for ResumableAgentListDto {
    fn from(out: ListResumableAgentsOutput) -> Self {
        Self {
            resumable: out
                .resumable
                .into_iter()
                .map(ResumableAgentDto::from)
                .collect(),
        }
    }
}

// ---------------------------------------------------------------------------
// Templates & sync (L7)
// ---------------------------------------------------------------------------

use application::{
    AgentDrift, CreateAgentFromTemplateInput, CreateTemplateInput, CreateTemplateOutput,
    DetectAgentDriftOutput, ListTemplatesOutput, ReadTemplateOutput, SyncAgentWithTemplateOutput,
    UpdateTemplateInput, UpdateTemplateOutput,
};
use domain::{AgentTemplate, TemplateId};

/// A template crossing the wire. [`AgentTemplate`] already serialises camelCase
/// (`id`, `name`, `contentMd`, `version` as a number, `defaultProfileId`),
/// so we embed it directly — the TS mirror matches this shape.
#[derive(Debug, Clone, Serialize)]
#[serde(transparent)]
pub struct TemplateDto(pub AgentTemplate);

/// A list of templates (transparent array on the wire).
#[derive(Debug, Clone, Serialize)]
#[serde(transparent)]
pub struct TemplateListDto(pub Vec<TemplateDto>);

impl From<ListTemplatesOutput> for TemplateListDto {
    fn from(out: ListTemplatesOutput) -> Self {
        Self(out.templates.into_iter().map(TemplateDto).collect())
    }
}

impl From<CreateTemplateOutput> for TemplateDto {
    fn from(out: CreateTemplateOutput) -> Self {
        Self(out.template)
    }
}

impl From<ReadTemplateOutput> for TemplateDto {
    fn from(out: ReadTemplateOutput) -> Self {
        Self(out.template)
    }
}

impl From<UpdateTemplateOutput> for TemplateDto {
    fn from(out: UpdateTemplateOutput) -> Self {
        Self(out.template)
    }
}

/// Request DTO for `create_template`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateTemplateRequestDto {
    /// Display name.
    pub name: String,
    /// Initial Markdown content.
    pub content: String,
    /// Default runtime profile id for agents created from this template.
    pub default_profile_id: String,
}

impl CreateTemplateRequestDto {
    /// Converts to the use-case input, parsing the profile id.
    ///
    /// # Errors
    /// [`ErrorDto`] with code `INVALID` if the profile id is malformed.
    pub fn into_input(self) -> Result<CreateTemplateInput, ErrorDto> {
        Ok(CreateTemplateInput {
            name: self.name,
            content: self.content,
            default_profile_id: parse_profile_id(&self.default_profile_id)?,
        })
    }
}

/// Request DTO for `update_template`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateTemplateRequestDto {
    /// Id of the template to update.
    pub template_id: String,
    /// New Markdown content.
    pub content: String,
}

impl UpdateTemplateRequestDto {
    /// Converts to the use-case input, parsing the template id.
    ///
    /// # Errors
    /// [`ErrorDto`] with code `INVALID` if the template id is malformed.
    pub fn into_input(self) -> Result<UpdateTemplateInput, ErrorDto> {
        Ok(UpdateTemplateInput {
            template_id: parse_template_id(&self.template_id)?,
            content: self.content,
        })
    }
}

/// Parses a template-id string (UUID) coming from the frontend.
///
/// # Errors
/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID.
pub fn parse_template_id(raw: &str) -> Result<TemplateId, ErrorDto> {
    uuid::Uuid::parse_str(raw)
        .map(TemplateId::from_uuid)
        .map_err(|_| ErrorDto {
            code: "INVALID".to_owned(),
            message: format!("invalid template id: {raw}"),
        })
}

/// Request DTO for `create_agent_from_template`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateAgentFromTemplateRequestDto {
    /// Id of the owning project.
    pub project_id: String,
    /// Source template id.
    pub template_id: String,
    /// Optional agent name; defaults to the template's name when absent.
    #[serde(default)]
    pub name: Option<String>,
    /// Whether the agent tracks the template for future syncs.
    pub synchronized: bool,
}

impl CreateAgentFromTemplateRequestDto {
    /// Converts to the use-case input, given the resolved project.
    ///
    /// # Errors
    /// [`ErrorDto`] with code `INVALID` if the template id is malformed.
    pub fn into_input(
        self,
        project: domain::Project,
    ) -> Result<CreateAgentFromTemplateInput, ErrorDto> {
        Ok(CreateAgentFromTemplateInput {
            project,
            template_id: parse_template_id(&self.template_id)?,
            name: self.name,
            synchronized: self.synchronized,
        })
    }
}

/// One drifting agent, as seen by the frontend.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentDriftDto {
    /// The drifting agent id (UUID string).
    pub agent_id: String,
    /// Version the agent is currently synced to.
    pub from: u64,
    /// Version available from the template.
    pub to: u64,
}

impl From<AgentDrift> for AgentDriftDto {
    fn from(d: AgentDrift) -> Self {
        Self {
            agent_id: d.agent_id.to_string(),
            from: d.from.get(),
            to: d.to.get(),
        }
    }
}

/// Response DTO for `detect_agent_drift` (transparent array on the wire).
#[derive(Debug, Clone, Serialize)]
#[serde(transparent)]
pub struct AgentDriftListDto(pub Vec<AgentDriftDto>);

impl From<DetectAgentDriftOutput> for AgentDriftListDto {
    fn from(out: DetectAgentDriftOutput) -> Self {
        Self(out.drifts.into_iter().map(AgentDriftDto::from).collect())
    }
}

/// Response DTO for `sync_agent_with_template`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SyncResultDto {
    /// Whether a sync was actually applied.
    pub synced: bool,
    /// The version the agent is now at (`null` when no sync happened).
    pub version: Option<u64>,
}

impl From<SyncAgentWithTemplateOutput> for SyncResultDto {
    fn from(out: SyncAgentWithTemplateOutput) -> Self {
        Self {
            synced: out.synced,
            version: out.version.map(|v| v.get()),
        }
    }
}

/// Request DTO for `sync_agent_with_template`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SyncAgentWithTemplateRequestDto {
    /// Id of the owning project.
    pub project_id: String,
    /// Id of the agent to sync.
    pub agent_id: String,
}

// ---------------------------------------------------------------------------
// Git (L8)
// ---------------------------------------------------------------------------

use application::{GitBranchesOutput, GitCommitOutput, GitLogOutput, GitStatusOutput};
use domain::ports::{GitCommitInfo, GitFileStatus, GraphCommit};

/// One changed path returned by `git_status`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GitFileStatusDto {
    /// Repo-relative path.
    pub path: String,
    /// Whether the change is staged.
    pub staged: bool,
}

impl From<GitFileStatus> for GitFileStatusDto {
    fn from(s: GitFileStatus) -> Self {
        Self {
            path: s.path,
            staged: s.staged,
        }
    }
}

/// Response DTO for `git_status` (transparent array on the wire).
#[derive(Debug, Clone, Serialize)]
#[serde(transparent)]
pub struct GitStatusListDto(pub Vec<GitFileStatusDto>);

impl From<GitStatusOutput> for GitStatusListDto {
    fn from(out: GitStatusOutput) -> Self {
        Self(
            out.entries
                .into_iter()
                .map(GitFileStatusDto::from)
                .collect(),
        )
    }
}

/// A single commit summary crossing the wire.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GitCommitDto {
    /// Commit hash.
    pub hash: String,
    /// Commit message summary.
    pub summary: String,
}

impl From<GitCommitInfo> for GitCommitDto {
    fn from(c: GitCommitInfo) -> Self {
        Self {
            hash: c.hash,
            summary: c.summary,
        }
    }
}

impl From<GitCommitOutput> for GitCommitDto {
    fn from(out: GitCommitOutput) -> Self {
        Self::from(out.commit)
    }
}

/// Response DTO for `git_log` (transparent array on the wire).
#[derive(Debug, Clone, Serialize)]
#[serde(transparent)]
pub struct GitCommitListDto(pub Vec<GitCommitDto>);

impl From<GitLogOutput> for GitCommitListDto {
    fn from(out: GitLogOutput) -> Self {
        Self(out.commits.into_iter().map(GitCommitDto::from).collect())
    }
}

/// Response DTO for `git_branches`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GitBranchesDto {
    /// All local branches.
    pub branches: Vec<String>,
    /// The current branch (`null` when detached or unborn).
    pub current: Option<String>,
}

impl From<GitBranchesOutput> for GitBranchesDto {
    fn from(out: GitBranchesOutput) -> Self {
        Self {
            branches: out.branches,
            current: out.current,
        }
    }
}

/// A single commit enriched for graph display.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GraphCommitDto {
    /// Full commit hash.
    pub hash: String,
    /// First line of the commit message.
    pub summary: String,
    /// Parent commit hashes.
    pub parents: Vec<String>,
    /// Ref labels pointing at this commit (e.g. `"main"`, `"tag: v1.0"`).
    pub refs: Vec<String>,
    /// Author name.
    pub author: String,
    /// Author timestamp in Unix seconds.
    pub timestamp: i64,
}

impl From<GraphCommit> for GraphCommitDto {
    fn from(c: GraphCommit) -> Self {
        Self {
            hash: c.hash,
            summary: c.summary,
            parents: c.parents,
            refs: c.refs,
            author: c.author,
            timestamp: c.timestamp,
        }
    }
}

/// Response DTO for `git_graph` (transparent array on the wire).
#[derive(Debug, Clone, Serialize)]
#[serde(transparent)]
pub struct GraphCommitListDto(pub Vec<GraphCommitDto>);

impl From<GitGraphOutput> for GraphCommitListDto {
    fn from(out: GitGraphOutput) -> Self {
        Self(out.commits.into_iter().map(GraphCommitDto::from).collect())
    }
}

/// Request DTO for `git_stage` / `git_unstage`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GitStageRequestDto {
    /// Id of the owning project.
    pub project_id: String,
    /// Repo-relative path to (un)stage.
    pub path: String,
}

/// Request DTO for `git_commit`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GitCommitRequestDto {
    /// Id of the owning project.
    pub project_id: String,
    /// Commit message.
    pub message: String,
}

/// Request DTO for `git_checkout`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GitCheckoutRequestDto {
    /// Id of the owning project.
    pub project_id: String,
    /// Branch to check out.
    pub branch: String,
}

// ---------------------------------------------------------------------------
// Windows (L10)
// ---------------------------------------------------------------------------

use application::MoveTabToNewWindowOutput;
use domain::ids::TabId;

/// Response DTO for `move_tab_to_new_window`: the id minted for the new window
/// (used as the new OS window's label).
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MoveTabResultDto {
    /// The new window id (UUID string).
    pub new_window_id: String,
}

impl From<MoveTabToNewWindowOutput> for MoveTabResultDto {
    fn from(out: MoveTabToNewWindowOutput) -> Self {
        Self {
            new_window_id: out.new_window_id.to_string(),
        }
    }
}

/// Parses a tab-id string (UUID) coming from the frontend.
///
/// # Errors
/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID.
pub fn parse_tab_id(raw: &str) -> Result<TabId, ErrorDto> {
    uuid::Uuid::parse_str(raw)
        .map(TabId::from_uuid)
        .map_err(|_| ErrorDto {
            code: "INVALID".to_owned(),
            message: format!("invalid tab id: {raw}"),
        })
}

// ---------------------------------------------------------------------------
// Skills (L12)
// ---------------------------------------------------------------------------

use application::{CreateSkillOutput, ListSkillsOutput, UpdateSkillOutput};
use domain::{Skill, SkillId, SkillScope};

/// A skill crossing the wire. [`Skill`] already serialises camelCase
/// (`id`, `name`, `contentMd`, `scope` as `"global"`/`"project"`), so we embed
/// it directly — the TS mirror matches this shape.
#[derive(Debug, Clone, Serialize)]
#[serde(transparent)]
pub struct SkillDto(pub Skill);

/// A list of skills (transparent array on the wire).
#[derive(Debug, Clone, Serialize)]
#[serde(transparent)]
pub struct SkillListDto(pub Vec<SkillDto>);

impl From<ListSkillsOutput> for SkillListDto {
    fn from(out: ListSkillsOutput) -> Self {
        Self(out.skills.into_iter().map(SkillDto).collect())
    }
}

impl From<CreateSkillOutput> for SkillDto {
    fn from(out: CreateSkillOutput) -> Self {
        Self(out.skill)
    }
}

impl From<UpdateSkillOutput> for SkillDto {
    fn from(out: UpdateSkillOutput) -> Self {
        Self(out.skill)
    }
}

/// Request DTO for `create_skill`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateSkillRequestDto {
    /// Owning project (resolved to a root; ignored on disk for `Global`).
    pub project_id: String,
    /// Display name.
    pub name: String,
    /// Optional one-line affordance description.
    #[serde(default)]
    pub description: Option<String>,
    /// Initial Markdown content.
    pub content: String,
    /// Scope the skill is created in.
    pub scope: SkillScope,
    /// Capability nature. Missing legacy clients create workflow skills.
    #[serde(default)]
    pub kind: SkillKind,
}

/// Request DTO for `update_skill`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateSkillRequestDto {
    /// Owning project (resolved to a root; ignored on disk for `Global`).
    pub project_id: String,
    /// Id of the skill to update.
    pub skill_id: String,
    /// Scope the skill lives in.
    pub scope: SkillScope,
    /// New Markdown content.
    pub content: String,
}

/// Request DTO for `assign_skill_to_agent`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AssignSkillRequestDto {
    /// Owning project.
    pub project_id: String,
    /// Agent receiving the skill.
    pub agent_id: String,
    /// Skill to assign.
    pub skill_id: String,
    /// Scope of the skill (recorded alongside the ref).
    pub scope: SkillScope,
}

/// Request DTO for `unassign_skill_from_agent`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UnassignSkillRequestDto {
    /// Owning project.
    pub project_id: String,
    /// Agent losing the skill.
    pub agent_id: String,
    /// Skill to unassign.
    pub skill_id: String,
}

/// Parses a skill-id string (UUID) coming from the frontend.
///
/// # Errors
/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID.
pub fn parse_skill_id(raw: &str) -> Result<SkillId, ErrorDto> {
    uuid::Uuid::parse_str(raw)
        .map(SkillId::from_uuid)
        .map_err(|_| ErrorDto {
            code: "INVALID".to_owned(),
            message: format!("invalid skill id: {raw}"),
        })
}

// ---------------------------------------------------------------------------
// Memory (LOT A — §14.5.1)
// ---------------------------------------------------------------------------

use application::{
    CreateMemoryOutput, GetMemoryOutput, ListMemoriesOutput, ReadMemoryIndexOutput,
    RecallMemoryOutput, ResolveMemoryLinksOutput, UpdateMemoryOutput,
};
use domain::{Memory, MemoryIndexEntry, MemorySlug, MemoryType};

/// A memory note crossing the wire.
///
/// Built explicitly from [`Memory`] (its `body` is [`domain::MarkdownDoc`], not
/// directly a JSON string) so the TS mirror gets a flat camelCase shape.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MemoryDto {
    /// The note's slug (its identity, also the file stem).
    pub name: String,
    /// One-line description (the index hook).
    pub description: String,
    /// The note's kind.
    pub r#type: MemoryType,
    /// Markdown body of the note.
    pub content: String,
}

impl From<Memory> for MemoryDto {
    fn from(memory: Memory) -> Self {
        Self {
            name: memory.frontmatter.name.as_str().to_owned(),
            description: memory.frontmatter.description,
            r#type: memory.frontmatter.r#type,
            content: memory.body.into_string(),
        }
    }
}

impl From<CreateMemoryOutput> for MemoryDto {
    fn from(out: CreateMemoryOutput) -> Self {
        Self::from(out.memory)
    }
}

impl From<UpdateMemoryOutput> for MemoryDto {
    fn from(out: UpdateMemoryOutput) -> Self {
        Self::from(out.memory)
    }
}

impl From<GetMemoryOutput> for MemoryDto {
    fn from(out: GetMemoryOutput) -> Self {
        Self::from(out.memory)
    }
}

/// A list of memory notes (transparent array on the wire).
#[derive(Debug, Clone, Serialize)]
#[serde(transparent)]
pub struct MemoryListDto(pub Vec<MemoryDto>);

impl From<ListMemoriesOutput> for MemoryListDto {
    fn from(out: ListMemoriesOutput) -> Self {
        Self(out.memories.into_iter().map(MemoryDto::from).collect())
    }
}

/// One row of the structured `MEMORY.md` index crossing the wire.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MemoryIndexEntryDto {
    /// The note's slug.
    pub slug: String,
    /// The note's display title.
    pub title: String,
    /// The one-line hook (the frontmatter description).
    pub hook: String,
    /// The note's kind.
    pub r#type: MemoryType,
}

impl From<MemoryIndexEntry> for MemoryIndexEntryDto {
    fn from(entry: MemoryIndexEntry) -> Self {
        Self {
            slug: entry.slug.as_str().to_owned(),
            title: entry.title,
            hook: entry.hook,
            r#type: entry.r#type,
        }
    }
}

/// The structured memory index (transparent array on the wire).
#[derive(Debug, Clone, Serialize)]
#[serde(transparent)]
pub struct MemoryIndexDto(pub Vec<MemoryIndexEntryDto>);

impl From<ReadMemoryIndexOutput> for MemoryIndexDto {
    fn from(out: ReadMemoryIndexOutput) -> Self {
        Self(
            out.entries
                .into_iter()
                .map(MemoryIndexEntryDto::from)
                .collect(),
        )
    }
}

impl From<RecallMemoryOutput> for MemoryIndexDto {
    fn from(out: RecallMemoryOutput) -> Self {
        Self(
            out.entries
                .into_iter()
                .map(MemoryIndexEntryDto::from)
                .collect(),
        )
    }
}

/// A note's resolved outgoing links — the target slugs (transparent array).
#[derive(Debug, Clone, Serialize)]
#[serde(transparent)]
pub struct MemoryLinksDto(pub Vec<String>);

impl From<ResolveMemoryLinksOutput> for MemoryLinksDto {
    fn from(out: ResolveMemoryLinksOutput) -> Self {
        Self(
            out.links
                .into_iter()
                .map(|link| link.target.as_str().to_owned())
                .collect(),
        )
    }
}

/// Request DTO for `create_memory`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateMemoryRequestDto {
    /// Owning project (resolved to a root).
    pub project_id: String,
    /// Raw slug for the new note.
    pub name: String,
    /// One-line description (the index hook).
    pub description: String,
    /// The note's kind.
    pub r#type: MemoryType,
    /// Markdown body of the note.
    pub content: String,
}

/// Request DTO for `update_memory`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateMemoryRequestDto {
    /// Owning project (resolved to a root).
    pub project_id: String,
    /// Slug of the note to replace.
    pub slug: String,
    /// New description (the index hook).
    pub description: String,
    /// New kind.
    pub r#type: MemoryType,
    /// New Markdown body.
    pub content: String,
}

/// Request DTO for `recall_memory` (LOT B — §14.5.2).
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RecallMemoryRequestDto {
    /// Owning project (resolved to a root).
    pub project_id: String,
    /// The recall query (often the agent's current working context).
    pub text: String,
    /// Approximate token budget bounding the recalled entries (`0` ⇒ empty).
    pub token_budget: usize,
}

/// Parses a memory slug string coming from the frontend.
///
/// # Errors
/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a valid
/// kebab-case slug.
pub fn parse_memory_slug(raw: &str) -> Result<MemorySlug, ErrorDto> {
    MemorySlug::new(raw).map_err(|_| ErrorDto {
        code: "INVALID".to_owned(),
        message: format!("invalid memory slug: {raw}"),
    })
}

// ── Conversation pagination (lot LS6) ────────────────────────────────────────

/// Request DTO for `read_conversation_page` — a human, paginated transcript read.
///
/// `anchor` is a turn id to paginate around (omit to start from a thread end);
/// `direction` is `"forward"` (towards newer) or `"backward"` (towards older,
/// default — the human view opens on the latest page); `limit` is clamped by the
/// domain to `[1, 200]` (omit/`0` ⇒ default 50).
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReadConversationPageRequestDto {
    /// The owning project's id.
    pub project_id: String,
    /// The conversation (pair) id to read.
    pub conversation_id: String,
    /// Optional anchor turn id; omit to start from a thread end.
    #[serde(default)]
    pub anchor: Option<String>,
    /// Pagination direction (`"forward"` or `"backward"`); defaults to backward.
    #[serde(default)]
    pub direction: Option<String>,
    /// Requested page size (omit/`0` ⇒ default; clamped to `[1, 200]`).
    #[serde(default)]
    pub limit: Option<usize>,
}

impl ReadConversationPageRequestDto {
    /// Builds the domain [`PageCursor`] from the request (default direction backward,
    /// an unparseable anchor degrades to `None` = a thread-end page).
    #[must_use]
    pub fn cursor(&self) -> PageCursor {
        let direction = match self.direction.as_deref() {
            Some(d) if d.eq_ignore_ascii_case("forward") => PageDirection::Forward,
            _ => PageDirection::Backward,
        };
        let anchor = self
            .anchor
            .as_deref()
            .and_then(|raw| uuid::Uuid::parse_str(raw).ok())
            .map(domain::TurnId::from_uuid);
        PageCursor { anchor, direction }
    }
}

/// Origin of a turn in the human transcript view (mirrors [`TurnSource`]).
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum TurnSourceDto {
    /// The human operator.
    Human,
    /// Another agent (delegation via `idea_ask_agent`).
    #[serde(rename_all = "camelCase")]
    Agent {
        /// The originating agent id.
        agent_id: String,
    },
}

impl From<TurnSource> for TurnSourceDto {
    fn from(source: TurnSource) -> Self {
        match source {
            TurnSource::Human => Self::Human,
            TurnSource::Agent { agent_id } => Self::Agent {
                agent_id: agent_id.to_string(),
            },
        }
    }
}

/// One turn in the human transcript — **full text, never truncated**.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TurnViewDto {
    /// Stable turn id (also the pagination anchor).
    pub id: String,
    /// Timestamp (epoch milliseconds).
    pub at_ms: u64,
    /// Turn nature (prompt, response, tool activity).
    pub role: TurnRole,
    /// Origin (human or delegating agent).
    pub source: TurnSourceDto,
    /// The turn's **complete** text (not truncated).
    pub text: String,
    /// Character length of the text.
    pub text_len: usize,
}

impl From<TurnView> for TurnViewDto {
    fn from(turn: TurnView) -> Self {
        Self {
            id: turn.id.to_string(),
            at_ms: turn.at_ms,
            role: turn.role,
            source: turn.source.into(),
            text: turn.text,
            text_len: turn.text_len,
        }
    }
}

/// A page of the human transcript, oldest-to-newest.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TurnPageDto {
    /// The page's turns, oldest to newest.
    pub turns: Vec<TurnViewDto>,
    /// Whether more turns exist beyond the page in the travel direction.
    pub has_more: bool,
    /// Last turn id of the page (anchor for the next request), or `None` if empty.
    pub next_anchor: Option<String>,
}

impl From<TurnPage> for TurnPageDto {
    fn from(page: TurnPage) -> Self {
        Self {
            turns: page.turns.into_iter().map(TurnViewDto::from).collect(),
            has_more: page.has_more,
            next_anchor: page.next_anchor.map(|id| id.to_string()),
        }
    }
}

#[cfg(test)]
mod ls6_pagination_tests {
    //! LS6 — DTO de la lecture humaine paginée : parsing du curseur + mapping riche
    //! `TurnPage → TurnPageDto` (texte complet non borné, source Human/Agent, `has_more`,
    //! `next_anchor`). C'est le « round-trip d'une page » testable sans `State` Tauri.

    use super::*;
    use application::{TurnPage, TurnSource, TurnView};

    fn tid(n: u128) -> domain::TurnId {
        domain::TurnId::from_uuid(uuid::Uuid::from_u128(n))
    }

    #[test]
    fn request_cursor_defaults_to_backward_and_parses_anchor() {
        // Pas de direction ⇒ backward (la vue humaine ouvre sur la page la plus récente).
        let req = ReadConversationPageRequestDto {
            project_id: "p".into(),
            conversation_id: "c".into(),
            anchor: None,
            direction: None,
            limit: None,
        };
        let cur = req.cursor();
        assert_eq!(cur.direction, PageDirection::Backward);
        assert_eq!(cur.anchor, None);

        // Forward (insensible à la casse) + ancre UUID valide.
        let u = uuid::Uuid::from_u128(9);
        let req = ReadConversationPageRequestDto {
            project_id: "p".into(),
            conversation_id: "c".into(),
            anchor: Some(u.to_string()),
            direction: Some("Forward".into()),
            limit: Some(10),
        };
        let cur = req.cursor();
        assert_eq!(cur.direction, PageDirection::Forward);
        assert_eq!(cur.anchor, Some(domain::TurnId::from_uuid(u)));

        // Ancre non-UUID ⇒ dégrade en None (page depuis un bout du fil), jamais d'erreur.
        let req = ReadConversationPageRequestDto {
            project_id: "p".into(),
            conversation_id: "c".into(),
            anchor: Some("not-a-uuid".into()),
            direction: Some("backward".into()),
            limit: None,
        };
        assert_eq!(req.cursor().anchor, None);
    }

    #[test]
    fn turn_page_dto_round_trips_full_text_and_maps_sources() {
        let big = "Z".repeat(40_000);
        let page = TurnPage {
            turns: vec![
                TurnView {
                    id: tid(1),
                    at_ms: 1_700_000_000_000,
                    role: TurnRole::Prompt,
                    source: TurnSource::Human,
                    text: big.clone(),
                    text_len: big.chars().count(),
                },
                TurnView {
                    id: tid(2),
                    at_ms: 1_700_000_000_001,
                    role: TurnRole::Response,
                    source: TurnSource::Agent {
                        agent_id: domain::AgentId::from_uuid(uuid::Uuid::from_u128(7)),
                    },
                    text: "réponse".to_owned(),
                    text_len: 7,
                },
            ],
            has_more: true,
            next_anchor: Some(tid(2)),
        };

        let dto = TurnPageDto::from(page);
        // Texte complet préservé (non borné) + text_len.
        assert_eq!(dto.turns[0].text.chars().count(), 40_000);
        assert_eq!(dto.turns[0].text_len, 40_000);
        // next_anchor stringifié, has_more porté.
        assert!(dto.has_more);
        assert_eq!(
            dto.next_anchor.as_deref(),
            Some(tid(2).to_string().as_str())
        );

        // Round-trip JSON (la « page » telle qu'envoyée au front) : camelCase + source.
        let json = serde_json::to_value(&dto).unwrap();
        assert_eq!(json["hasMore"], serde_json::json!(true));
        assert_eq!(json["nextAnchor"], serde_json::json!(tid(2).to_string()));
        assert_eq!(
            json["turns"][0]["source"]["kind"],
            serde_json::json!("human")
        );
        assert_eq!(
            json["turns"][1]["source"]["kind"],
            serde_json::json!("agent")
        );
        assert_eq!(
            json["turns"][1]["source"]["agentId"],
            serde_json::json!(uuid::Uuid::from_u128(7).to_string())
        );
        // Le texte intégral survit au passage JSON.
        assert_eq!(
            json["turns"][0]["text"].as_str().unwrap().chars().count(),
            40_000
        );
    }

    #[test]
    fn empty_turn_page_dto_has_no_next_anchor() {
        let dto = TurnPageDto::from(TurnPage {
            turns: Vec::new(),
            has_more: false,
            next_anchor: None,
        });
        assert!(dto.turns.is_empty() && !dto.has_more && dto.next_anchor.is_none());
    }
}

// ---------------------------------------------------------------------------
// Background tasks (B8 — first-class background command)
// ---------------------------------------------------------------------------

use domain::{
    BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskState, TaskId,
};

/// One first-class background task as seen by the frontend (camelCase wire shape).
///
/// Mirrors the persisted [`BackgroundTask`], flattening the terminal
/// [`BackgroundTaskResult`] into optional `exitCode` / `summary` / tails so the
/// UI has a single shape for every lifecycle state. Optional fields are omitted
/// from the wire when absent (`skip_serializing_if`).
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BackgroundTaskDto {
    /// Stable task id (UUID string).
    pub task_id: String,
    /// Owning agent id (UUID string).
    pub owner_agent_id: String,
    /// Owning project id (UUID string).
    pub project_id: String,
    /// Kind discriminant (`"command"`, `"headlessRendezvous"`, `"sessionResume"`,
    /// `"maintenance"`).
    pub kind: String,
    /// Lifecycle state (`"queued"`, `"running"`, `"waiting"`, `"completed"`,
    /// `"failed"`, `"cancelled"`, `"expired"`).
    pub state: String,
    /// Process exit code, when the terminal result carries one.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub exit_code: Option<i32>,
    /// Human-readable summary / error / reason of the terminal result.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub summary: Option<String>,
    /// Bounded stdout tail (merged output for PTY-backed commands).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub stdout_tail: Option<String>,
    /// Bounded stderr tail (unset for PTY-backed commands, which merge streams).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub stderr_tail: Option<String>,
    /// Agent that requested a headless rendezvous, when known.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub requester_agent_id: Option<String>,
    /// Target agent for a headless rendezvous.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub target_agent_id: Option<String>,
    /// Conversation opened by a headless rendezvous.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub conversation_id: Option<String>,
    /// Creation timestamp, epoch milliseconds.
    pub created_at_ms: u64,
    /// Last update timestamp, epoch milliseconds.
    pub updated_at_ms: u64,
}

/// Kind discriminant string for a [`BackgroundTaskKind`].
fn background_kind_label(kind: &BackgroundTaskKind) -> &'static str {
    match kind {
        BackgroundTaskKind::Command { .. } => "command",
        BackgroundTaskKind::HeadlessRendezvous { .. } => "headlessRendezvous",
        BackgroundTaskKind::SessionResume { .. } => "sessionResume",
        BackgroundTaskKind::Maintenance { .. } => "maintenance",
    }
}

/// Kind discriminant string for a work-state [`BackgroundTaskKindLabel`].
fn background_kind_label_from_work_state(kind: BackgroundTaskKindLabel) -> &'static str {
    match kind {
        BackgroundTaskKindLabel::Command => "command",
        BackgroundTaskKindLabel::HeadlessRendezvous => "headlessRendezvous",
        BackgroundTaskKindLabel::SessionResume => "sessionResume",
        BackgroundTaskKindLabel::Maintenance => "maintenance",
    }
}

/// Lifecycle state string for a [`BackgroundTaskState`].
fn background_state_label(state: BackgroundTaskState) -> &'static str {
    match state {
        BackgroundTaskState::Queued => "queued",
        BackgroundTaskState::Running => "running",
        BackgroundTaskState::Waiting => "waiting",
        BackgroundTaskState::Completed => "completed",
        BackgroundTaskState::Failed => "failed",
        BackgroundTaskState::Cancelled => "cancelled",
        BackgroundTaskState::Expired => "expired",
    }
}

impl From<BackgroundTask> for BackgroundTaskDto {
    fn from(task: BackgroundTask) -> Self {
        let (requester_agent_id, target_agent_id, conversation_id) =
            background_rendezvous_context_labels(&task.kind);
        let (exit_code, summary, stdout_tail, stderr_tail) = match &task.result {
            Some(BackgroundTaskResult::Success {
                exit_code,
                summary,
                stdout_tail,
                stderr_tail,
                ..
            }) => (
                *exit_code,
                Some(summary.clone()),
                stdout_tail.clone(),
                stderr_tail.clone(),
            ),
            Some(BackgroundTaskResult::Failure {
                exit_code,
                error,
                stdout_tail,
                stderr_tail,
                ..
            }) => (
                *exit_code,
                Some(error.clone()),
                stdout_tail.clone(),
                stderr_tail.clone(),
            ),
            Some(BackgroundTaskResult::Cancelled { reason, .. })
            | Some(BackgroundTaskResult::Expired { reason, .. }) => {
                (None, Some(reason.clone()), None, None)
            }
            None => (None, None, None, None),
        };
        Self {
            task_id: task.id.to_string(),
            owner_agent_id: task.owner_agent_id.to_string(),
            project_id: task.project_id.to_string(),
            kind: background_kind_label(&task.kind).to_owned(),
            state: background_state_label(task.state).to_owned(),
            exit_code,
            summary,
            stdout_tail,
            stderr_tail,
            requester_agent_id,
            target_agent_id,
            conversation_id,
            created_at_ms: task.created_at_ms,
            updated_at_ms: task.updated_at_ms,
        }
    }
}

fn background_rendezvous_context_labels(
    kind: &BackgroundTaskKind,
) -> (Option<String>, Option<String>, Option<String>) {
    match kind {
        BackgroundTaskKind::HeadlessRendezvous {
            requester_agent_id,
            target_agent_id,
            conversation_id,
            ..
        } => (
            requester_agent_id.map(|id| id.to_string()),
            Some(target_agent_id.to_string()),
            Some(conversation_id.to_string()),
        ),
        _ => (None, None, None),
    }
}

/// Parses a task-id string (UUID) coming from the frontend.
///
/// # Errors
/// Returns an [`ErrorDto`] with code `INVALID` if the string is not a UUID.
pub fn parse_task_id(raw: &str) -> Result<TaskId, ErrorDto> {
    uuid::Uuid::parse_str(raw)
        .map(TaskId::from_uuid)
        .map_err(|_| ErrorDto {
            code: "INVALID".to_owned(),
            message: format!("invalid task id: {raw}"),
        })
}

/// Request DTO for `spawn_background_command`.
///
/// Builds a command-backed [`BackgroundTask`]: the command line runs under the
/// project host's PTY and its completion is delivered to `ownerAgentId`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SpawnBackgroundCommandRequestDto {
    /// Owning project id (UUID string).
    pub project_id: String,
    /// Agent that owns completion delivery (UUID string).
    pub owner_agent_id: String,
    /// Human-facing label.
    pub label: String,
    /// Executable to run.
    pub command: String,
    /// Arguments.
    #[serde(default)]
    pub args: Vec<String>,
    /// Working directory (absolute path).
    pub cwd: String,
    /// Extra environment variables.
    #[serde(default)]
    pub env: Vec<(String, String)>,
    /// When `true` (the default), wake the owner on completion; otherwise only
    /// record it.
    #[serde(default)]
    pub record_only: bool,
    /// Optional absolute deadline, epoch milliseconds.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub deadline_ms: Option<u64>,
}

#[cfg(test)]
mod tests {
    use application::McpToolPermissionCatalogue;
    use domain::mailbox::TicketId;
    use domain::{AgentId, ConversationId, PermissionShadowReport, ProjectMcpToolPermissions};
    use serde_json::json;
    use uuid::Uuid;

    use super::*;

    #[test]
    fn project_mcp_tool_permissions_dto_uses_stable_camel_case_contract() {
        let known_tools = ["idea_ticket_read", "idea_ticket_update"];
        let agent_id = AgentId::from_uuid(Uuid::from_u128(42));
        let output = ReadMcpToolPermissionsOutput {
            catalogue: McpToolPermissionCatalogue::new(
                vec!["idea_ticket_read".to_owned()],
                vec!["idea_ticket_update".to_owned()],
            )
            .unwrap(),
            permissions: ProjectMcpToolPermissions {
                version: 1,
                project_default: Some(
                    McpToolPolicy::new(vec!["idea_ticket_read".to_owned()], &known_tools).unwrap(),
                ),
                agents: vec![AgentMcpToolPolicyOverride::new(
                    agent_id,
                    McpToolPolicy::new(vec!["idea_ticket_update".to_owned()], &known_tools)
                        .unwrap(),
                )],
            },
        };

        let value = serde_json::to_value(ProjectMcpToolPermissionsDto::from(output)).unwrap();

        assert_eq!(
            value,
            json!({
                "version": 1,
                "catalogue": {
                    "readOnlyTools": ["idea_ticket_read"],
                    "writeActionTools": ["idea_ticket_update"]
                },
                "projectDefault": {
                    "allowedTools": ["idea_ticket_read"]
                },
                "agents": [{
                    "agentId": agent_id,
                    "policy": {
                        "allowedTools": ["idea_ticket_update"]
                    }
                }]
            })
        );
    }

    #[test]
    fn resolve_agent_permissions_response_dto_uses_effective_plus_shadowed_shape() {
        let dto = ResolveAgentPermissionsResponseDto {
            effective: None,
            shadowed: PermissionShadowReport {
                read: false,
                write: false,
                delete: false,
                execute_bash: true,
                fallback: true,
            },
        };

        assert_eq!(
            serde_json::to_value(dto).unwrap(),
            json!({
                "effective": null,
                "shadowed": {
                    "read": false,
                    "write": false,
                    "delete": false,
                    "executeBash": true,
                    "fallback": true
                }
            })
        );
    }

    #[test]
    fn dto_plugins_workspace_requests_use_stable_camel_case_contract() {
        let path = PluginWorkspacePathDto {
            project_id: Uuid::from_u128(124).to_string(),
            path: "src/main.rs".to_owned(),
        };
        let text = PluginWorkspaceWriteTextDto {
            project_id: path.project_id.clone(),
            path: path.path.clone(),
            content: "fn main() {}\n".to_owned(),
        };
        let binary = PluginWorkspaceWriteBinaryDto {
            project_id: path.project_id.clone(),
            path: "assets/icon.bin".to_owned(),
            bytes: vec![1, 2, 3],
        };

        assert_eq!(
            serde_json::to_value(&path).unwrap(),
            json!({
                "projectId": path.project_id,
                "path": "src/main.rs"
            })
        );
        assert_eq!(
            serde_json::to_value(&text).unwrap(),
            json!({
                "projectId": text.project_id,
                "path": "src/main.rs",
                "content": "fn main() {}\n"
            })
        );
        assert_eq!(
            serde_json::to_value(&binary).unwrap(),
            json!({
                "projectId": binary.project_id,
                "path": "assets/icon.bin",
                "bytes": [1, 2, 3]
            })
        );
    }

    #[test]
    fn dto_plugins_project_structure_query_maps_to_application_input() {
        let dto = PluginProjectStructureQueryDto {
            project_id: Uuid::from_u128(129).to_string(),
            path: Some("crates".to_owned()),
            max_depth: Some(4),
            max_entries: Some(250),
        };

        let json = serde_json::to_value(&dto).unwrap();
        assert_eq!(
            json,
            json!({
                "projectId": dto.project_id,
                "path": "crates",
                "maxDepth": 4,
                "maxEntries": 250
            })
        );

        let input: application::QueryProjectStructureInput = dto.into();
        assert_eq!(input.path.as_deref(), Some("crates"));
        assert_eq!(input.max_depth, Some(4));
        assert_eq!(input.max_entries, Some(250));
    }

    #[test]
    fn dto_plugins_config_document_requests_use_stable_camel_case_contract() {
        let project_id = Uuid::from_u128(130).to_string();
        let read = PluginConfigDocumentReadDto {
            project_id: project_id.clone(),
            path: "config/settings.json".to_owned(),
            format: Some("json".to_owned()),
        };
        assert_eq!(
            serde_json::to_value(&read).unwrap(),
            json!({
                "projectId": project_id,
                "path": "config/settings.json",
                "format": "json"
            })
        );
        let input: application::PluginConfigDocumentReadInput = read.into();
        assert_eq!(input.path, "config/settings.json");
        assert_eq!(input.format.as_deref(), Some("json"));

        let update = PluginConfigDocumentUpdateDto {
            project_id: Uuid::from_u128(130).to_string(),
            path: "config/settings.json".to_owned(),
            format: Some("json".to_owned()),
            mode: Some("mergePatch".to_owned()),
            value: json!({"enabled": true, "removeMe": null}),
        };
        assert_eq!(
            serde_json::to_value(&update).unwrap(),
            json!({
                "projectId": Uuid::from_u128(130).to_string(),
                "path": "config/settings.json",
                "format": "json",
                "mode": "mergePatch",
                "value": {
                    "enabled": true,
                    "removeMe": null
                }
            })
        );
        let input: application::PluginConfigDocumentUpdateInput = update.into();
        assert_eq!(input.mode.as_deref(), Some("mergePatch"));
        assert_eq!(input.value["enabled"], true);
    }

    #[test]
    fn dto_plugins_storage_requests_use_stable_camel_case_contract() {
        let get = PluginStorageGetDto {
            plugin_id: "dev.acme.gitgraph".to_owned(),
            key: "helloPlugin.launches".to_owned(),
        };
        assert_eq!(
            serde_json::to_value(&get).unwrap(),
            json!({
                "pluginId": "dev.acme.gitgraph",
                "key": "helloPlugin.launches"
            })
        );
        let input: application::PluginStorageGetInput = get.into();
        assert_eq!(input.plugin_id, "dev.acme.gitgraph");
        assert_eq!(input.key, "helloPlugin.launches");

        let set = PluginStorageSetDto {
            plugin_id: "dev.acme.gitgraph".to_owned(),
            key: "helloPlugin.enabled".to_owned(),
            value: json!({"enabled": true}),
        };
        assert_eq!(
            serde_json::to_value(&set).unwrap(),
            json!({
                "pluginId": "dev.acme.gitgraph",
                "key": "helloPlugin.enabled",
                "value": {"enabled": true}
            })
        );
        let input: application::PluginStorageSetInput = set.into();
        assert_eq!(input.plugin_id, "dev.acme.gitgraph");
        assert_eq!(input.key, "helloPlugin.enabled");
        assert_eq!(input.value, json!({"enabled": true}));
    }

    #[test]
    fn dto_plugins_workspace_outputs_use_stable_camel_case_contract() {
        let listing = PluginWorkspaceDirectoryListingDto {
            path: "src".to_owned(),
            entries: vec![application::PluginWorkspaceDirEntry {
                name: "main.rs".to_owned(),
                path: "src/main.rs".to_owned(),
                is_dir: false,
            }],
        };
        let stat = PluginWorkspaceStatDto {
            path: "src/main.rs".to_owned(),
            exists: true,
            is_file: true,
            is_dir: false,
            len: Some(13),
        };
        let structure = PluginProjectStructureDto {
            project_id: Uuid::from_u128(129).to_string(),
            root_path: String::new(),
            entries: vec![application::ProjectStructureEntry {
                path: "Cargo.toml".to_owned(),
                name: "Cargo.toml".to_owned(),
                kind: "file".to_owned(),
            }],
            conventions: vec![application::ProjectConvention {
                id: "rust-cargo".to_owned(),
                marker_path: "Cargo.toml".to_owned(),
            }],
            modules: vec![application::ProjectModule {
                path: String::new(),
                marker_path: "Cargo.toml".to_owned(),
                convention_id: "rust-cargo".to_owned(),
            }],
            truncated: false,
        };

        assert_eq!(
            serde_json::to_value(&listing).unwrap(),
            json!({
                "path": "src",
                "entries": [{
                    "name": "main.rs",
                    "path": "src/main.rs",
                    "isDir": false
                }]
            })
        );
        assert_eq!(
            serde_json::to_value(&stat).unwrap(),
            json!({
                "path": "src/main.rs",
                "exists": true,
                "isFile": true,
                "isDir": false,
                "len": 13
            })
        );
        assert_eq!(
            serde_json::to_value(&structure).unwrap(),
            json!({
                "projectId": structure.project_id,
                "rootPath": "",
                "entries": [{
                    "path": "Cargo.toml",
                    "name": "Cargo.toml",
                    "kind": "file"
                }],
                "conventions": [{
                    "id": "rust-cargo",
                    "markerPath": "Cargo.toml"
                }],
                "modules": [{
                    "path": "",
                    "markerPath": "Cargo.toml",
                    "conventionId": "rust-cargo"
                }],
                "truncated": false
            })
        );
    }

    #[test]
    fn dto_plugins_command_task_requests_use_stable_camel_case_contract() {
        let project_id = Uuid::from_u128(125).to_string();
        let owner_agent_id = Uuid::from_u128(126).to_string();
        let run = PluginRunCommandDto {
            project_id: project_id.clone(),
            owner_agent_id: owner_agent_id.clone(),
            label: "cargo test".to_owned(),
            command: "cargo".to_owned(),
            args: vec!["test".to_owned(), "-p".to_owned(), "application".to_owned()],
            cwd: Some("crates/application".to_owned()),
            env: vec![("RUST_LOG".to_owned(), "debug".to_owned())],
            record_only: true,
            deadline_ms: Some(1_800_000_000_000),
        };

        assert_eq!(
            serde_json::to_value(&run).unwrap(),
            json!({
                "projectId": project_id,
                "ownerAgentId": owner_agent_id,
                "label": "cargo test",
                "command": "cargo",
                "args": ["test", "-p", "application"],
                "cwd": "crates/application",
                "env": [["RUST_LOG", "debug"]],
                "recordOnly": true,
                "deadlineMs": 1_800_000_000_000u64
            })
        );

        let input: application::PluginRunCommandInput = run.into();
        assert_eq!(input.cwd.as_deref(), Some("crates/application"));
        assert_eq!(input.env, vec![("RUST_LOG".to_owned(), "debug".to_owned())]);
        assert!(input.record_only);

        let status = PluginTaskStatusDto {
            task_id: Uuid::from_u128(127).to_string(),
        };
        assert_eq!(
            serde_json::to_value(&status).unwrap(),
            json!({ "taskId": status.task_id })
        );
    }

    #[test]
    fn dto_plugins_toolchain_diagnostic_request_maps_to_application_input() {
        let project_id = Uuid::from_u128(126).to_string();
        let request = PluginToolchainDiagnosticRequestDto {
            project_id: project_id.clone(),
            cwd: Some("crates/backend".to_owned()),
            tools: vec![PluginToolRequirementDto {
                id: "rust".to_owned(),
                executable: "cargo".to_owned(),
                version_args: vec!["--version".to_owned()],
                required: true,
                env: vec![("CARGO_TERM_COLOR".to_owned(), "never".to_owned())],
            }],
            env: vec![PluginEnvRequirementDto {
                name: "RUSTUP_HOME".to_owned(),
                required: false,
                equals: None,
            }],
            files: vec![PluginFileRequirementDto {
                path: "Cargo.toml".to_owned(),
                required: true,
                kind: Some("file".to_owned()),
            }],
        };

        assert_eq!(
            serde_json::to_value(&request).unwrap(),
            json!({
                "projectId": project_id,
                "cwd": "crates/backend",
                "tools": [{
                    "id": "rust",
                    "executable": "cargo",
                    "versionArgs": ["--version"],
                    "required": true,
                    "env": [["CARGO_TERM_COLOR", "never"]]
                }],
                "env": [{
                    "name": "RUSTUP_HOME",
                    "required": false,
                    "equals": null
                }],
                "files": [{
                    "path": "Cargo.toml",
                    "required": true,
                    "kind": "file"
                }]
            })
        );

        let input: application::PluginToolchainDiagnosticInput = request.into();
        assert_eq!(input.cwd.as_deref(), Some("crates/backend"));
        assert_eq!(input.tools[0].id, "rust");
        assert_eq!(input.tools[0].env[0].0, "CARGO_TERM_COLOR");
        assert_eq!(input.env[0].name, "RUSTUP_HOME");
        assert_eq!(input.files[0].kind.as_deref(), Some("file"));
    }

    #[test]
    fn dto_plugins_event_subscription_requests_use_stable_camel_case_contract() {
        let project_id = Uuid::from_u128(127).to_string();
        let subscribe = PluginEventSubscribeDto {
            project_id: project_id.clone(),
            event_types: vec![
                "workspaceFileChanged".to_owned(),
                "backgroundTaskChanged".to_owned(),
            ],
            capacity: Some(250),
        };
        assert_eq!(
            serde_json::to_value(&subscribe).unwrap(),
            json!({
                "projectId": project_id,
                "eventTypes": ["workspaceFileChanged", "backgroundTaskChanged"],
                "capacity": 250
            })
        );
        let input: application::PluginEventSubscribeInput = subscribe.into();
        assert_eq!(
            input.event_types,
            vec![
                "workspaceFileChanged".to_owned(),
                "backgroundTaskChanged".to_owned()
            ]
        );
        assert_eq!(input.capacity, Some(250));

        let poll = PluginEventPollDto {
            subscription_id: Uuid::from_u128(128).to_string(),
            max_events: Some(50),
        };
        assert_eq!(
            serde_json::to_value(&poll).unwrap(),
            json!({
                "subscriptionId": poll.subscription_id,
                "maxEvents": 50
            })
        );
        let input: application::PluginEventPollInput = poll.into();
        assert_eq!(input.max_events, Some(50));

        let unsubscribe = PluginEventUnsubscribeDto {
            subscription_id: Uuid::from_u128(129).to_string(),
        };
        assert_eq!(
            serde_json::to_value(&unsubscribe).unwrap(),
            json!({ "subscriptionId": unsubscribe.subscription_id })
        );
    }

    #[test]
    fn background_task_dto_exposes_rendezvous_context_only_for_headless_rendezvous() {
        let project_id = ProjectId::from_uuid(Uuid::from_u128(1));
        let owner = AgentId::from_uuid(Uuid::from_u128(2));
        let requester = AgentId::from_uuid(Uuid::from_u128(3));
        let conversation_id = ConversationId::from_uuid(Uuid::from_u128(4));
        let task = BackgroundTask::new(
            TaskId::from_uuid(Uuid::from_u128(5)),
            project_id,
            owner,
            BackgroundTaskKind::HeadlessRendezvous {
                requester_agent_id: Some(requester),
                target_agent_id: owner,
                ticket_id: TicketId::from_uuid(Uuid::from_u128(6)),
                conversation_id,
            },
            domain::BackgroundTaskWakePolicy::RecordOnly,
            100,
            None,
        )
        .unwrap();

        let json = serde_json::to_value(BackgroundTaskDto::from(task)).unwrap();

        assert_eq!(json["kind"], "headlessRendezvous");
        assert_eq!(json["requesterAgentId"], requester.to_string());
        assert_eq!(json["targetAgentId"], owner.to_string());
        assert_eq!(json["conversationId"], conversation_id.to_string());

        let command = BackgroundTask::new(
            TaskId::from_uuid(Uuid::from_u128(7)),
            project_id,
            owner,
            BackgroundTaskKind::Command {
                label: "cargo test".to_owned(),
            },
            domain::BackgroundTaskWakePolicy::RecordOnly,
            100,
            None,
        )
        .unwrap();
        let json = serde_json::to_value(BackgroundTaskDto::from(command)).unwrap();
        assert!(json.get("requesterAgentId").is_none());
        assert!(json.get("targetAgentId").is_none());
        assert!(json.get("conversationId").is_none());
    }

    #[test]
    fn agent_background_task_state_dto_exposes_rendezvous_context() {
        let requester = AgentId::from_uuid(Uuid::from_u128(11));
        let target = AgentId::from_uuid(Uuid::from_u128(12));
        let conversation_id = ConversationId::from_uuid(Uuid::from_u128(13));
        let state = AgentBackgroundTaskState {
            task_id: TaskId::from_uuid(Uuid::from_u128(14)),
            kind: BackgroundTaskKindLabel::HeadlessRendezvous,
            state: BackgroundTaskState::Completed,
            exit_code: None,
            summary: Some("ok".to_owned()),
            stdout_tail: None,
            stderr_tail: None,
            requester_agent_id: Some(requester),
            target_agent_id: Some(target),
            conversation_id: Some(conversation_id),
            created_at_ms: 100,
            updated_at_ms: 200,
        };

        let json = serde_json::to_value(AgentBackgroundTaskStateDto::from(state)).unwrap();

        assert_eq!(json["requesterAgentId"], requester.to_string());
        assert_eq!(json["targetAgentId"], target.to_string());
        assert_eq!(json["conversationId"], conversation_id.to_string());
    }
}
