//! Transport-neutral DTOs for domain events.
//!
//! The event relay itself belongs to each driving adapter. This module owns only
//! the stable JSON wire shape shared by Tauri IPC and the headless web server.

use serde::Serialize;

use domain::conversation::ConversationParty;
use domain::events::{DomainEvent, OrchestrationSource};
use domain::input::AgentLiveness;
use domain::model_server::ModelServerLifecycleStatus;
use domain::{IssueLinkKind, IssuePriority, IssueStatus};

/// Name of the Tauri event carrying relayed [`DomainEvent`]s.
pub const DOMAIN_EVENT: &str = "domain://event";
/// Dedicated Tauri event for local model-server status changes.
pub const MODEL_SERVER_STATUS_CHANGED: &str = "model_server_status_changed";
/// Dedicated Tauri event for launch failures.
pub const AGENT_LAUNCH_FAILED: &str = "agent_launch_failed";

/// Model-server lifecycle status on the Tauri wire.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "state", rename_all = "camelCase")]
pub enum ModelServerStatusDto {
    /// No server is configured for this profile.
    NotConfigured,
    /// Readiness probing is in progress.
    Probing,
    /// IdeA is starting the process.
    Starting,
    /// Server is downloading/preparing a remote model.
    #[serde(rename_all = "camelCase")]
    Downloading {
        /// Downloaded bytes when known.
        downloaded_bytes: Option<u64>,
        /// Total bytes when known.
        total_bytes: Option<u64>,
        /// Completion percentage when known.
        percent: Option<f32>,
        /// Remote model source.
        source: Option<String>,
    },
    /// Server is ready.
    #[serde(rename_all = "camelCase")]
    Ready {
        /// `true` when an existing server was reused.
        reused: bool,
    },
    /// Server preparation failed.
    #[serde(rename_all = "camelCase")]
    Failed {
        /// Stable failure code.
        code: String,
        /// Human-readable message.
        message: String,
    },
}

impl From<&ModelServerLifecycleStatus> for ModelServerStatusDto {
    fn from(status: &ModelServerLifecycleStatus) -> Self {
        match status {
            ModelServerLifecycleStatus::NotConfigured => Self::NotConfigured,
            ModelServerLifecycleStatus::Probing => Self::Probing,
            ModelServerLifecycleStatus::Starting => Self::Starting,
            ModelServerLifecycleStatus::Downloading {
                downloaded_bytes,
                total_bytes,
                percent,
                source,
            } => Self::Downloading {
                downloaded_bytes: *downloaded_bytes,
                total_bytes: *total_bytes,
                percent: *percent,
                source: source.clone(),
            },
            ModelServerLifecycleStatus::Ready { reused } => Self::Ready { reused: *reused },
            ModelServerLifecycleStatus::Failed { code, message } => Self::Failed {
                code: code.clone(),
                message: message.clone(),
            },
        }
    }
}

/// Payload for [`MODEL_SERVER_STATUS_CHANGED`].
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelServerStatusChangedDto {
    /// Local model server id.
    pub server_id: String,
    /// Lifecycle status.
    pub status: ModelServerStatusDto,
}

/// Payload for [`AGENT_LAUNCH_FAILED`].
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentLaunchFailedDto {
    /// Agent id.
    pub agent_id: String,
    /// Failure cause namespace.
    pub cause: String,
    /// Stable error code.
    pub code: String,
    /// Human-readable message.
    pub message: String,
}

/// Serialisable mirror of [`DomainEvent`] for the IPC wire (camelCase, tagged).
///
/// `type` is the discriminant; payload fields are flattened per variant. This is
/// the single owner of the event wire format on the backend side.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum DomainEventDto {
    /// A project was created.
    #[serde(rename_all = "camelCase")]
    ProjectCreated {
        /// Project id (UUID string).
        project_id: String,
    },
    /// An agent was launched.
    #[serde(rename_all = "camelCase")]
    AgentLaunched {
        /// Agent id.
        agent_id: String,
        /// Session id.
        session_id: String,
    },
    /// An agent launch failed before runtime session creation.
    #[serde(rename_all = "camelCase")]
    AgentLaunchFailed {
        /// Agent id.
        agent_id: String,
        /// Failure cause namespace.
        cause: String,
        /// Stable error code.
        code: String,
        /// Human-readable message.
        message: String,
    },
    /// Local model-server status changed.
    #[serde(rename_all = "camelCase")]
    ModelServerStatusChanged {
        /// Local model server id.
        server_id: String,
        /// Lifecycle status.
        status: ModelServerStatusDto,
    },
    /// An agent exited.
    #[serde(rename_all = "camelCase")]
    AgentExited {
        /// Agent id.
        agent_id: String,
        /// Exit code.
        code: i32,
    },
    /// A ticket assistant chat was opened.
    #[serde(rename_all = "camelCase")]
    TicketAssistantOpened {
        /// Bound ticket reference.
        issue_ref: String,
        /// Runtime profile id.
        profile_id: String,
    },
    /// A ticket assistant chat was closed.
    #[serde(rename_all = "camelCase")]
    TicketAssistantClosed {
        /// Bound ticket reference.
        issue_ref: String,
    },
    /// An agent's busy/idle state changed (cadrage C4 §4.2). The frontend dims
    /// "Envoyer" while `busy` is `true`.
    #[serde(rename_all = "camelCase")]
    AgentBusyChanged {
        /// Agent id.
        agent_id: String,
        /// `true` when a turn is in flight, `false` when idle.
        busy: bool,
    },
    /// A delegation is ready to be injected into the agent's **native terminal**
    /// (ARCHITECTURE §20). The frontend write-portal writes `text` + `submitSequence`
    /// (default `"\r"`) after `submitDelayMs` (default ~60) once the human line is empty,
    /// then acks via the `delegation_delivered` command. The backend no longer PTY-writes
    /// the turn.
    #[serde(rename_all = "camelCase")]
    DelegationReady {
        /// Target agent id (UUID string).
        agent_id: String,
        /// Mailbox ticket id (UUID string) to ack back via `delegation_delivered`.
        ticket: String,
        /// Task text to inject (written without a trailing newline by the portal).
        text: String,
        /// Profile's submit sequence; `null` ⇒ the front applies its default (`"\r"`).
        #[serde(skip_serializing_if = "Option::is_none")]
        submit_sequence: Option<String>,
        /// Profile's submit delay in ms; `null` ⇒ the front default (~60 ms).
        #[serde(skip_serializing_if = "Option::is_none")]
        submit_delay_ms: Option<u32>,
    },
    /// A target agent produced a synchronous reply to an inter-agent `ask` (§17.4).
    #[serde(rename_all = "camelCase")]
    AgentReplied {
        /// Target agent id.
        agent_id: String,
        /// Reply length in bytes (metric, not the payload).
        reply_len: usize,
    },
    /// Intermediate assistant announcement emitted live during an inter-agent turn.
    #[serde(rename_all = "camelCase")]
    AgentAnnouncement {
        /// Project id.
        project_id: String,
        /// Requester party (`"user"` or requester agent id).
        requester: String,
        /// Target agent id.
        target: String,
        /// FIFO ticket id.
        ticket_id: String,
        /// Announcement text.
        text: String,
        /// Wall-clock timestamp in epoch milliseconds.
        at_ms: u64,
    },
    /// An agent's liveness (alive/stalled) changed (lot 2, readiness/heartbeat). The
    /// frontend can badge a frozen agent; `"stalled"` while no proof of liveness arrived
    /// for longer than the profile's `stallAfterMs`, back to `"alive"` on a late
    /// battement or when the turn ends.
    #[serde(rename_all = "camelCase")]
    AgentLivenessChanged {
        /// Agent id (UUID string).
        agent_id: String,
        /// New liveness, as a lowercase string (`"alive"` / `"stalled"`).
        liveness: AgentLivenessDto,
    },
    /// A background task lifecycle/delivery event occurred.
    #[serde(rename_all = "camelCase")]
    BackgroundTaskChanged {
        /// Project id.
        project_id: String,
        /// Task id.
        task_id: String,
        /// Owner agent id.
        agent_id: String,
        /// Lightweight event/state label.
        state: String,
    },
    /// An agent inbox queue depth changed.
    #[serde(rename_all = "camelCase")]
    AgentInboxChanged {
        /// Agent id.
        agent_id: String,
        /// Queue depth after the operation.
        depth: usize,
        /// Queue operation label.
        action: String,
    },
    /// An owner wake event occurred.
    #[serde(rename_all = "camelCase")]
    AgentWakeChanged {
        /// Project id.
        project_id: String,
        /// Agent id.
        agent_id: String,
        /// Wake operation label.
        action: String,
        /// Failure reason, when any.
        #[serde(skip_serializing_if = "Option::is_none")]
        reason: Option<String>,
    },
    /// An agent's runtime profile was changed (hot-swap of the AI engine).
    #[serde(rename_all = "camelCase")]
    AgentProfileChanged {
        /// Agent id.
        agent_id: String,
        /// The new runtime profile id.
        profile_id: String,
    },
    /// A template was updated.
    #[serde(rename_all = "camelCase")]
    TemplateUpdated {
        /// Template id.
        template_id: String,
        /// New version.
        version: u64,
    },
    /// A synchronized agent drifted from its template.
    #[serde(rename_all = "camelCase")]
    AgentDriftDetected {
        /// Agent id.
        agent_id: String,
        /// Current version.
        from: u64,
        /// Available version.
        to: u64,
    },
    /// A synchronized agent was brought up to date.
    #[serde(rename_all = "camelCase")]
    AgentSynced {
        /// Agent id.
        agent_id: String,
        /// Version synced to.
        to: u64,
    },
    /// A paired device was revoked.
    #[serde(rename_all = "camelCase")]
    DeviceRevoked {
        /// Device id.
        device_id: String,
    },
    /// Every paired device was revoked.
    AllDevicesRevoked,
    /// A skill was assigned to (or unassigned from) an agent.
    #[serde(rename_all = "camelCase")]
    SkillAssigned {
        /// Agent id.
        agent_id: String,
        /// Skill id.
        skill_id: String,
        /// `true` if assigned, `false` if unassigned.
        assigned: bool,
    },
    /// A tab's layout changed.
    #[serde(rename_all = "camelCase")]
    LayoutChanged {
        /// Project id.
        project_id: String,
    },
    /// A remote connection was established.
    #[serde(rename_all = "camelCase")]
    RemoteConnected {
        /// Project id.
        project_id: String,
    },
    /// Git state changed.
    #[serde(rename_all = "camelCase")]
    GitStateChanged {
        /// Project id.
        project_id: String,
    },
    /// An issue-backed public ticket was created.
    #[serde(rename_all = "camelCase")]
    IssueCreated {
        /// Issue id.
        issue_id: String,
        /// Public ticket reference (`#N`).
        issue_ref: String,
    },
    /// An issue-backed public ticket was updated.
    #[serde(rename_all = "camelCase")]
    IssueUpdated {
        /// Public ticket reference (`#N`).
        issue_ref: String,
        /// New optimistic version.
        version: u64,
    },
    /// An issue-backed public ticket was deleted.
    #[serde(rename_all = "camelCase")]
    IssueDeleted {
        /// Project id.
        project_id: String,
        /// Deleted public ticket reference (`#N`).
        issue_ref: String,
        /// Released sprint id, when any.
        freed_sprint: Option<String>,
    },
    /// A public ticket status changed.
    #[serde(rename_all = "camelCase")]
    IssueStatusChanged {
        /// Public ticket reference (`#N`).
        issue_ref: String,
        /// New status.
        status: String,
        /// New optimistic version.
        version: u64,
    },
    /// A public ticket priority changed.
    #[serde(rename_all = "camelCase")]
    IssuePriorityChanged {
        /// Public ticket reference (`#N`).
        issue_ref: String,
        /// New priority.
        priority: String,
        /// New optimistic version.
        version: u64,
    },
    /// A public ticket carnet changed.
    #[serde(rename_all = "camelCase")]
    IssueCarnetUpdated {
        /// Public ticket reference (`#N`).
        issue_ref: String,
        /// New optimistic version.
        version: u64,
    },
    /// A public ticket link was added.
    #[serde(rename_all = "camelCase")]
    IssueLinked {
        /// Public source ticket reference (`#N`).
        issue_ref: String,
        /// Public target ticket reference (`#N`).
        target: String,
        /// Link kind.
        kind: String,
        /// New optimistic version.
        version: u64,
    },
    /// A public ticket link was removed.
    #[serde(rename_all = "camelCase")]
    IssueUnlinked {
        /// Public source ticket reference (`#N`).
        issue_ref: String,
        /// Public target ticket reference (`#N`).
        target: String,
        /// Link kind.
        kind: String,
        /// New optimistic version.
        version: u64,
    },
    /// An agent was assigned to a public ticket.
    #[serde(rename_all = "camelCase")]
    IssueAgentAssigned {
        /// Public ticket reference (`#N`).
        issue_ref: String,
        /// Assigned agent id.
        agent_id: String,
        /// New optimistic version.
        version: u64,
    },
    /// An agent was unassigned from a public ticket.
    #[serde(rename_all = "camelCase")]
    IssueAgentUnassigned {
        /// Public ticket reference (`#N`).
        issue_ref: String,
        /// Unassigned agent id.
        agent_id: String,
        /// New optimistic version.
        version: u64,
    },
    /// A sprint was created.
    #[serde(rename_all = "camelCase")]
    SprintCreated {
        /// Stable sprint id.
        sprint_id: String,
        /// Reorderable order.
        order: u32,
    },
    /// A sprint was renamed.
    #[serde(rename_all = "camelCase")]
    SprintRenamed {
        /// Stable sprint id.
        sprint_id: String,
        /// New name.
        name: String,
        /// New optimistic version.
        version: u64,
    },
    /// A sprint was reordered.
    #[serde(rename_all = "camelCase")]
    SprintReordered {
        /// Stable sprint id.
        sprint_id: String,
        /// New order.
        order: u32,
        /// New optimistic version.
        version: u64,
    },
    /// A sprint was deleted.
    #[serde(rename_all = "camelCase")]
    SprintDeleted {
        /// Stable sprint id.
        sprint_id: String,
    },
    /// A ticket changed sprint membership.
    #[serde(rename_all = "camelCase")]
    IssueSprintChanged {
        /// Public ticket reference (`#N`).
        issue_ref: String,
        /// Previous sprint id.
        from: Option<String>,
        /// New sprint id.
        to: Option<String>,
        /// New optimistic version.
        version: u64,
    },
    /// An orchestrator request was processed on behalf of a requester agent.
    #[serde(rename_all = "camelCase")]
    OrchestratorRequestProcessed {
        /// Id of the requesting (orchestrator) agent.
        requester_id: String,
        /// The action that was processed.
        action: String,
        /// Whether IdeA handled it successfully.
        ok: bool,
        /// Which entry door the request arrived through (`"file"` watcher vs
        /// `"mcp"` server). Serialised as a lowercase string so the frontend can
        /// badge the source.
        source: OrchestrationSourceDto,
    },
    /// The project's orchestrator designation changed (T1).
    #[serde(rename_all = "camelCase")]
    OrchestratorChanged {
        /// The project whose designation changed.
        project_id: String,
        /// The newly designated orchestrator agent, or `None` for the default
        /// (the oldest agent orchestrates).
        #[serde(skip_serializing_if = "Option::is_none")]
        orchestrator: Option<String>,
    },
    /// A memory note was created or updated.
    #[serde(rename_all = "camelCase")]
    MemorySaved {
        /// The saved note's slug.
        slug: String,
    },
    /// A memory note was deleted.
    #[serde(rename_all = "camelCase")]
    MemoryDeleted {
        /// The deleted note's slug.
        slug: String,
    },
    /// The aggregated `MEMORY.md` index was rebuilt.
    #[serde(rename_all = "camelCase")]
    MemoryIndexRebuilt {
        /// Project id.
        project_id: String,
    },
    /// A project's memory crossed the recall budget while no embedder is configured
    /// (LOT C3 — §14.5.5): a one-time, dismissible "configure an embedder?" hint.
    #[serde(rename_all = "camelCase")]
    EmbedderSuggested {
        /// Project id.
        project_id: String,
        /// Whether a local Ollama-style embedding server was detected.
        ollama_detected: bool,
        /// Ids of recommended ONNX models already present in the local cache.
        onnx_cached: Vec<String>,
        /// Whether the HTTP capability is compiled in.
        vector_http_enabled: bool,
        /// Whether the in-process ONNX capability is compiled in.
        vector_onnx_enabled: bool,
    },
    /// An agent entered a **session/rate limit** (ARCHITECTURE §21). Low-frequency,
    /// model-agnostic badge: carries only the neutral "limited, maybe resets at T"
    /// fact. The frontend badges "limité jusqu'à HH:MM".
    #[serde(rename_all = "camelCase")]
    AgentRateLimited {
        /// Agent id (UUID string).
        agent_id: String,
        /// Reset instant in **epoch-milliseconds**; `null` ⇒ unknown (no auto-resume).
        #[serde(skip_serializing_if = "Option::is_none")]
        resets_at_ms: Option<i64>,
    },
    /// An **auto-resume** was armed for a rate-limited agent (ARCHITECTURE §21). The
    /// frontend shows the countdown + the "Annuler la reprise" button (cancellable
    /// window).
    #[serde(rename_all = "camelCase")]
    AgentResumeScheduled {
        /// Agent id (UUID string).
        agent_id: String,
        /// Wake-up deadline in **epoch-milliseconds**.
        fire_at_ms: i64,
    },
    /// An agent's **auto-resume** was **cancelled** (ARCHITECTURE §21): the user
    /// clicked "Annuler la reprise". The frontend removes the countdown.
    #[serde(rename_all = "camelCase")]
    AgentResumeCancelled {
        /// Agent id (UUID string).
        agent_id: String,
    },
    /// An agent was effectively **resumed** after a limit (ARCHITECTURE §21): the
    /// wake-up fired (or immediate resume). The frontend clears the "limited" state.
    #[serde(rename_all = "camelCase")]
    AgentResumed {
        /// Agent id (UUID string).
        agent_id: String,
    },
    /// **Human net (level 3)**: a session limit is **suspected** without any reliable
    /// reset time (ARCHITECTURE §21.1). IdeA never resumes blind: this asks the front
    /// to **prompt the user** ("limit detected but time unknown — resume at?").
    #[serde(rename_all = "camelCase")]
    AgentRateLimitSuspected {
        /// Agent id (UUID string).
        agent_id: String,
        /// Reset instant in **epoch-milliseconds** if an estimate exists, else `null`.
        #[serde(skip_serializing_if = "Option::is_none")]
        resets_at_ms: Option<i64>,
    },
    /// Raw PTY output (normally routed to a per-session channel, not here).
    #[serde(rename_all = "camelCase")]
    PtyOutput {
        /// Session id.
        session_id: String,
        /// Output bytes.
        bytes: Vec<u8>,
    },
}

/// Wire mirror of [`OrchestrationSource`]: which entry door a processed
/// orchestration request arrived through. Serialised as a lowercase string
/// (`"file"` / `"mcp"`) the frontend badges on the event.
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum OrchestrationSourceDto {
    /// A `.ideai/requests` JSON file (filesystem watcher).
    File,
    /// A `tools/call` on the MCP server.
    Mcp,
}

/// Wire mirror of [`AgentLiveness`]: the alive/stalled liveness of an agent (lot 2),
/// serialised as a lowercase string (`"alive"` / `"stalled"`) the frontend badges.
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum AgentLivenessDto {
    /// The agent shows proof of liveness (or is idle).
    Alive,
    /// No proof of liveness past the profile's `stallAfterMs` threshold.
    Stalled,
}

impl From<AgentLiveness> for AgentLivenessDto {
    fn from(liveness: AgentLiveness) -> Self {
        match liveness {
            AgentLiveness::Alive => Self::Alive,
            AgentLiveness::Stalled => Self::Stalled,
        }
    }
}

impl From<OrchestrationSource> for OrchestrationSourceDto {
    fn from(source: OrchestrationSource) -> Self {
        match source {
            OrchestrationSource::File => Self::File,
            OrchestrationSource::Mcp => Self::Mcp,
        }
    }
}

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

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

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

fn conversation_party_wire(party: ConversationParty) -> String {
    match party {
        ConversationParty::User => "user".to_owned(),
        ConversationParty::Agent { agent_id } => agent_id.to_string(),
    }
}

impl From<&DomainEvent> for DomainEventDto {
    fn from(e: &DomainEvent) -> Self {
        match e {
            DomainEvent::ProjectCreated { project_id } => Self::ProjectCreated {
                project_id: project_id.to_string(),
            },
            DomainEvent::AgentLaunched {
                agent_id,
                session_id,
            } => Self::AgentLaunched {
                agent_id: agent_id.to_string(),
                session_id: session_id.to_string(),
            },
            DomainEvent::AgentLaunchFailed {
                agent_id,
                cause,
                code,
                message,
            } => Self::AgentLaunchFailed {
                agent_id: agent_id.to_string(),
                cause: cause.clone(),
                code: code.clone(),
                message: message.clone(),
            },
            DomainEvent::ModelServerStatusChanged { server_id, status } => {
                Self::ModelServerStatusChanged {
                    server_id: server_id.to_string(),
                    status: status.into(),
                }
            }
            DomainEvent::AgentExited { agent_id, code } => Self::AgentExited {
                agent_id: agent_id.to_string(),
                code: *code,
            },
            DomainEvent::TicketAssistantOpened {
                issue_ref,
                profile_id,
            } => Self::TicketAssistantOpened {
                issue_ref: issue_ref.to_string(),
                profile_id: profile_id.to_string(),
            },
            DomainEvent::TicketAssistantClosed { issue_ref } => Self::TicketAssistantClosed {
                issue_ref: issue_ref.to_string(),
            },
            DomainEvent::AgentBusyChanged { agent_id, busy } => Self::AgentBusyChanged {
                agent_id: agent_id.to_string(),
                busy: *busy,
            },
            DomainEvent::DelegationReady {
                agent_id,
                ticket,
                text,
                submit_sequence,
                submit_delay_ms,
            } => Self::DelegationReady {
                agent_id: agent_id.to_string(),
                ticket: ticket.to_string(),
                text: text.clone(),
                submit_sequence: submit_sequence.clone(),
                submit_delay_ms: *submit_delay_ms,
            },
            DomainEvent::AgentReplied {
                agent_id,
                reply_len,
            } => Self::AgentReplied {
                agent_id: agent_id.to_string(),
                reply_len: *reply_len,
            },
            DomainEvent::AgentAnnouncement {
                project_id,
                requester,
                target,
                ticket,
                text,
                at_ms,
            } => Self::AgentAnnouncement {
                project_id: project_id.to_string(),
                requester: conversation_party_wire(*requester),
                target: target.to_string(),
                ticket_id: ticket.to_string(),
                text: text.clone(),
                at_ms: *at_ms,
            },
            DomainEvent::AgentLivenessChanged { agent_id, liveness } => {
                Self::AgentLivenessChanged {
                    agent_id: agent_id.to_string(),
                    liveness: (*liveness).into(),
                }
            }
            DomainEvent::BackgroundTaskStarted {
                project_id,
                task_id,
                owner_agent_id,
            } => Self::BackgroundTaskChanged {
                project_id: project_id.to_string(),
                task_id: task_id.to_string(),
                agent_id: owner_agent_id.to_string(),
                state: "started".to_owned(),
            },
            DomainEvent::BackgroundTaskStateChanged {
                project_id,
                task_id,
                owner_agent_id,
                state,
            } => Self::BackgroundTaskChanged {
                project_id: project_id.to_string(),
                task_id: task_id.to_string(),
                agent_id: owner_agent_id.to_string(),
                state: format!("{state:?}"),
            },
            DomainEvent::BackgroundTaskCompleted {
                project_id,
                task_id,
                owner_agent_id,
            } => Self::BackgroundTaskChanged {
                project_id: project_id.to_string(),
                task_id: task_id.to_string(),
                agent_id: owner_agent_id.to_string(),
                state: "completed".to_owned(),
            },
            DomainEvent::BackgroundTaskFailed {
                project_id,
                task_id,
                owner_agent_id,
            } => Self::BackgroundTaskChanged {
                project_id: project_id.to_string(),
                task_id: task_id.to_string(),
                agent_id: owner_agent_id.to_string(),
                state: "failed".to_owned(),
            },
            DomainEvent::BackgroundTaskCancelled {
                project_id,
                task_id,
                owner_agent_id,
            } => Self::BackgroundTaskChanged {
                project_id: project_id.to_string(),
                task_id: task_id.to_string(),
                agent_id: owner_agent_id.to_string(),
                state: "cancelled".to_owned(),
            },
            DomainEvent::BackgroundTaskCompletionDeliveryPending {
                project_id,
                task_id,
                owner_agent_id,
            } => Self::BackgroundTaskChanged {
                project_id: project_id.to_string(),
                task_id: task_id.to_string(),
                agent_id: owner_agent_id.to_string(),
                state: "deliveryPending".to_owned(),
            },
            DomainEvent::BackgroundTaskCompletionDelivered {
                project_id,
                task_id,
                owner_agent_id,
            } => Self::BackgroundTaskChanged {
                project_id: project_id.to_string(),
                task_id: task_id.to_string(),
                agent_id: owner_agent_id.to_string(),
                state: "delivered".to_owned(),
            },
            DomainEvent::AgentInboxQueued { agent_id, depth } => Self::AgentInboxChanged {
                agent_id: agent_id.to_string(),
                depth: *depth,
                action: "queued".to_owned(),
            },
            DomainEvent::AgentInboxDrained { agent_id, depth } => Self::AgentInboxChanged {
                agent_id: agent_id.to_string(),
                depth: *depth,
                action: "drained".to_owned(),
            },
            DomainEvent::AgentWakeScheduled {
                project_id,
                agent_id,
            } => Self::AgentWakeChanged {
                project_id: project_id.to_string(),
                agent_id: agent_id.to_string(),
                action: "scheduled".to_owned(),
                reason: None,
            },
            DomainEvent::AgentWakeStarted {
                project_id,
                agent_id,
            } => Self::AgentWakeChanged {
                project_id: project_id.to_string(),
                agent_id: agent_id.to_string(),
                action: "started".to_owned(),
                reason: None,
            },
            DomainEvent::AgentWakeFailed {
                project_id,
                agent_id,
                reason,
            } => Self::AgentWakeChanged {
                project_id: project_id.to_string(),
                agent_id: agent_id.to_string(),
                action: "failed".to_owned(),
                reason: Some(reason.clone()),
            },
            DomainEvent::AgentProfileChanged {
                agent_id,
                profile_id,
            } => Self::AgentProfileChanged {
                agent_id: agent_id.to_string(),
                profile_id: profile_id.to_string(),
            },
            DomainEvent::TemplateUpdated {
                template_id,
                version,
            } => Self::TemplateUpdated {
                template_id: template_id.to_string(),
                version: version.get(),
            },
            DomainEvent::AgentDriftDetected { agent_id, from, to } => Self::AgentDriftDetected {
                agent_id: agent_id.to_string(),
                from: from.get(),
                to: to.get(),
            },
            DomainEvent::AgentSynced { agent_id, to } => Self::AgentSynced {
                agent_id: agent_id.to_string(),
                to: to.get(),
            },
            DomainEvent::DeviceRevoked { device_id } => Self::DeviceRevoked {
                device_id: device_id.to_string(),
            },
            DomainEvent::AllDevicesRevoked => Self::AllDevicesRevoked,
            DomainEvent::SkillAssigned {
                agent_id,
                skill_id,
                assigned,
            } => Self::SkillAssigned {
                agent_id: agent_id.to_string(),
                skill_id: skill_id.to_string(),
                assigned: *assigned,
            },
            DomainEvent::LayoutChanged { project_id } => Self::LayoutChanged {
                project_id: project_id.to_string(),
            },
            DomainEvent::RemoteConnected { project_id } => Self::RemoteConnected {
                project_id: project_id.to_string(),
            },
            DomainEvent::GitStateChanged { project_id } => Self::GitStateChanged {
                project_id: project_id.to_string(),
            },
            DomainEvent::IssueCreated {
                issue_id,
                issue_ref,
            } => Self::IssueCreated {
                issue_id: issue_id.to_string(),
                issue_ref: issue_ref.to_string(),
            },
            DomainEvent::IssueUpdated { issue_ref, version } => Self::IssueUpdated {
                issue_ref: issue_ref.to_string(),
                version: version.get(),
            },
            DomainEvent::IssueDeleted {
                project_id,
                issue_ref,
                freed_sprint,
            } => Self::IssueDeleted {
                project_id: project_id.to_string(),
                issue_ref: issue_ref.to_string(),
                freed_sprint: freed_sprint.map(|sprint| sprint.to_string()),
            },
            DomainEvent::IssueStatusChanged {
                issue_ref,
                status,
                version,
            } => Self::IssueStatusChanged {
                issue_ref: issue_ref.to_string(),
                status: issue_status_wire(*status).to_owned(),
                version: version.get(),
            },
            DomainEvent::IssuePriorityChanged {
                issue_ref,
                priority,
                version,
            } => Self::IssuePriorityChanged {
                issue_ref: issue_ref.to_string(),
                priority: issue_priority_wire(*priority).to_owned(),
                version: version.get(),
            },
            DomainEvent::IssueCarnetUpdated { issue_ref, version } => Self::IssueCarnetUpdated {
                issue_ref: issue_ref.to_string(),
                version: version.get(),
            },
            DomainEvent::IssueLinked {
                issue_ref,
                target,
                kind,
                version,
            } => Self::IssueLinked {
                issue_ref: issue_ref.to_string(),
                target: target.to_string(),
                kind: issue_link_kind_wire(*kind).to_owned(),
                version: version.get(),
            },
            DomainEvent::IssueUnlinked {
                issue_ref,
                target,
                kind,
                version,
            } => Self::IssueUnlinked {
                issue_ref: issue_ref.to_string(),
                target: target.to_string(),
                kind: issue_link_kind_wire(*kind).to_owned(),
                version: version.get(),
            },
            DomainEvent::IssueAgentAssigned {
                issue_ref,
                agent_id,
                version,
            } => Self::IssueAgentAssigned {
                issue_ref: issue_ref.to_string(),
                agent_id: agent_id.to_string(),
                version: version.get(),
            },
            DomainEvent::IssueAgentUnassigned {
                issue_ref,
                agent_id,
                version,
            } => Self::IssueAgentUnassigned {
                issue_ref: issue_ref.to_string(),
                agent_id: agent_id.to_string(),
                version: version.get(),
            },
            DomainEvent::SprintCreated { sprint_id, order } => Self::SprintCreated {
                sprint_id: sprint_id.to_string(),
                order: order.get(),
            },
            DomainEvent::SprintRenamed {
                sprint_id,
                name,
                version,
            } => Self::SprintRenamed {
                sprint_id: sprint_id.to_string(),
                name: name.clone(),
                version: version.get(),
            },
            DomainEvent::SprintReordered {
                sprint_id,
                order,
                version,
            } => Self::SprintReordered {
                sprint_id: sprint_id.to_string(),
                order: order.get(),
                version: version.get(),
            },
            DomainEvent::SprintDeleted { sprint_id } => Self::SprintDeleted {
                sprint_id: sprint_id.to_string(),
            },
            DomainEvent::IssueSprintChanged {
                issue_ref,
                from,
                to,
                version,
            } => Self::IssueSprintChanged {
                issue_ref: issue_ref.to_string(),
                from: from.map(|id| id.to_string()),
                to: to.map(|id| id.to_string()),
                version: version.get(),
            },
            DomainEvent::OrchestratorRequestProcessed {
                requester_id,
                action,
                ok,
                source,
            } => Self::OrchestratorRequestProcessed {
                requester_id: requester_id.clone(),
                action: action.clone(),
                ok: *ok,
                source: (*source).into(),
            },
            DomainEvent::OrchestratorChanged {
                project_id,
                orchestrator,
            } => Self::OrchestratorChanged {
                project_id: project_id.to_string(),
                orchestrator: orchestrator.as_ref().map(|a| a.to_string()),
            },
            DomainEvent::MemorySaved { slug } => Self::MemorySaved {
                slug: slug.as_str().to_string(),
            },
            DomainEvent::MemoryDeleted { slug } => Self::MemoryDeleted {
                slug: slug.as_str().to_string(),
            },
            DomainEvent::MemoryIndexRebuilt { project_id } => Self::MemoryIndexRebuilt {
                project_id: project_id.to_string(),
            },
            DomainEvent::EmbedderSuggested {
                project_id,
                ollama_detected,
                onnx_cached,
                vector_http_enabled,
                vector_onnx_enabled,
            } => Self::EmbedderSuggested {
                project_id: project_id.to_string(),
                ollama_detected: *ollama_detected,
                onnx_cached: onnx_cached.clone(),
                vector_http_enabled: *vector_http_enabled,
                vector_onnx_enabled: *vector_onnx_enabled,
            },
            DomainEvent::AgentRateLimited {
                agent_id,
                resets_at_ms,
            } => Self::AgentRateLimited {
                agent_id: agent_id.to_string(),
                resets_at_ms: *resets_at_ms,
            },
            DomainEvent::AgentResumeScheduled {
                agent_id,
                fire_at_ms,
            } => Self::AgentResumeScheduled {
                agent_id: agent_id.to_string(),
                fire_at_ms: *fire_at_ms,
            },
            DomainEvent::AgentResumeCancelled { agent_id } => Self::AgentResumeCancelled {
                agent_id: agent_id.to_string(),
            },
            DomainEvent::AgentResumed { agent_id } => Self::AgentResumed {
                agent_id: agent_id.to_string(),
            },
            DomainEvent::AgentRateLimitSuspected {
                agent_id,
                resets_at_ms,
            } => Self::AgentRateLimitSuspected {
                agent_id: agent_id.to_string(),
                resets_at_ms: *resets_at_ms,
            },
            DomainEvent::PtyOutput { session_id, bytes } => Self::PtyOutput {
                session_id: session_id.to_string(),
                bytes: bytes.clone(),
            },
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use domain::ids::AgentId;
    use domain::mailbox::TicketId;
    use domain::{LocalModelServerId, ProjectId};
    use serde_json::json;

    fn agent(n: u128) -> AgentId {
        AgentId::from_uuid(uuid::Uuid::from_u128(n))
    }

    fn server(n: u128) -> LocalModelServerId {
        LocalModelServerId::from_uuid(uuid::Uuid::from_u128(n))
    }

    #[test]
    fn model_server_status_changed_relays_ready_to_dto_and_wire() {
        let dto = DomainEventDto::from(&DomainEvent::ModelServerStatusChanged {
            server_id: server(35),
            status: ModelServerLifecycleStatus::Ready { reused: true },
        });

        let json = serde_json::to_value(&dto).expect("serialisable");
        assert_eq!(json["type"], "modelServerStatusChanged");
        assert_eq!(json["serverId"], server(35).to_string());
        assert_eq!(json["status"]["state"], "ready");
        assert_eq!(json["status"]["reused"], true);
    }

    #[test]
    fn model_server_status_changed_relays_downloading_to_dto_and_wire() {
        let dto = DomainEventDto::from(&DomainEvent::ModelServerStatusChanged {
            server_id: server(37),
            status: ModelServerLifecycleStatus::Downloading {
                downloaded_bytes: None,
                total_bytes: None,
                percent: None,
                source: Some("Qwen/Qwen3-Coder-30B-A3B-Instruct-GGUF".to_owned()),
            },
        });

        let json = serde_json::to_value(&dto).expect("serialisable");
        assert_eq!(json["type"], "modelServerStatusChanged");
        assert_eq!(json["serverId"], server(37).to_string());
        assert_eq!(json["status"]["state"], "downloading");
        assert_eq!(json["status"]["downloadedBytes"], serde_json::Value::Null);
        assert_eq!(json["status"]["totalBytes"], serde_json::Value::Null);
        assert_eq!(json["status"]["percent"], serde_json::Value::Null);
        assert_eq!(
            json["status"]["source"],
            "Qwen/Qwen3-Coder-30B-A3B-Instruct-GGUF"
        );
    }

    #[test]
    fn agent_launch_failed_relays_model_server_cause() {
        let dto = DomainEventDto::from(&DomainEvent::AgentLaunchFailed {
            agent_id: agent(36),
            cause: "model_server".to_owned(),
            code: "path_not_accessible".to_owned(),
            message: "path not accessible: /models/missing.gguf".to_owned(),
        });

        let json = serde_json::to_value(&dto).expect("serialisable");
        assert_eq!(json["type"], "agentLaunchFailed");
        assert_eq!(json["agentId"], agent(36).to_string());
        assert_eq!(json["cause"], "model_server");
        assert_eq!(json["code"], "path_not_accessible");
    }

    /// Lot 2 : un `AgentLivenessChanged{Stalled}` du domaine se relaie en DTO
    /// `Stalled` portant le même agent, et se sérialise en `"stalled"` (le mot que
    /// le front badge). Garantit le câblage présentation de la détection de stall.
    #[test]
    fn liveness_changed_stalled_relays_to_dto_and_wire() {
        let dto = DomainEventDto::from(&DomainEvent::AgentLivenessChanged {
            agent_id: agent(7),
            liveness: AgentLiveness::Stalled,
        });
        let json = serde_json::to_value(&dto).expect("serialisable");
        assert_eq!(json["type"], "agentLivenessChanged");
        assert_eq!(json["agentId"], agent(7).to_string());
        assert_eq!(json["liveness"], "stalled");
    }

    /// La reprise `Stalled→Alive` se relaie en DTO `Alive` ⇒ wire `"alive"`.
    #[test]
    fn liveness_changed_alive_relays_to_dto_and_wire() {
        let dto = DomainEventDto::from(&DomainEvent::AgentLivenessChanged {
            agent_id: agent(3),
            liveness: AgentLiveness::Alive,
        });
        let json = serde_json::to_value(&dto).expect("serialisable");
        assert_eq!(json["type"], "agentLivenessChanged");
        assert_eq!(json["liveness"], "alive");
    }

    #[test]
    fn agent_announcement_relays_to_camel_case_wire_with_requester_party() {
        let project_id = ProjectId::from_uuid(uuid::Uuid::from_u128(1));
        let requester_agent = agent(2);
        let target = agent(3);
        let ticket = TicketId::from_uuid(uuid::Uuid::from_u128(4));

        let human = DomainEventDto::from(&DomainEvent::AgentAnnouncement {
            project_id,
            requester: ConversationParty::User,
            target,
            ticket,
            text: "statut humain".into(),
            at_ms: 123_456,
        });
        assert_eq!(
            serde_json::to_value(&human).unwrap(),
            json!({
                "type": "agentAnnouncement",
                "projectId": project_id.to_string(),
                "requester": "user",
                "target": target.to_string(),
                "ticketId": ticket.to_string(),
                "text": "statut humain",
                "atMs": 123456,
            })
        );

        let agent_requester = DomainEventDto::from(&DomainEvent::AgentAnnouncement {
            project_id,
            requester: ConversationParty::agent(requester_agent),
            target,
            ticket,
            text: "statut agent".into(),
            at_ms: 654_321,
        });
        assert_eq!(
            serde_json::to_value(&agent_requester).unwrap(),
            json!({
                "type": "agentAnnouncement",
                "projectId": project_id.to_string(),
                "requester": requester_agent.to_string(),
                "target": target.to_string(),
                "ticketId": ticket.to_string(),
                "text": "statut agent",
                "atMs": 654321,
            })
        );
    }

    /// LS6 : un `AgentRateLimited` du domaine se relaie en DTO portant le même agent
    /// et l'heure de reset (époche-ms), et se sérialise en `"agentRateLimited"` avec
    /// `resetsAtMs` — le fait neutre que le front badge « limité jusqu'à HH:MM ».
    #[test]
    fn rate_limited_relays_to_dto_and_wire() {
        let dto = DomainEventDto::from(&DomainEvent::AgentRateLimited {
            agent_id: agent(11),
            resets_at_ms: Some(1_700_000_000_000),
        });
        let json = serde_json::to_value(&dto).expect("serialisable");
        assert_eq!(json["type"], "agentRateLimited");
        assert_eq!(json["agentId"], agent(11).to_string());
        assert_eq!(json["resetsAtMs"], 1_700_000_000_000_i64);
    }

    #[test]
    fn issue_sprint_changed_relays_to_dto_and_wire() {
        let from = domain::SprintId::from_uuid(uuid::Uuid::from_u128(41));
        let to = domain::SprintId::from_uuid(uuid::Uuid::from_u128(42));
        let dto = DomainEventDto::from(&DomainEvent::IssueSprintChanged {
            issue_ref: domain::IssueRef::from(domain::IssueNumber::new(7).unwrap()),
            from: Some(from),
            to: Some(to),
            version: domain::IssueVersion::new(3).unwrap(),
        });
        let json = serde_json::to_value(&dto).expect("serialisable");
        assert_eq!(json["type"], "issueSprintChanged");
        assert_eq!(json["issueRef"], "#7");
        assert_eq!(json["from"], from.to_string());
        assert_eq!(json["to"], to.to_string());
        assert_eq!(json["version"], 3);
    }

    #[test]
    fn issue_deleted_relays_to_dto_and_wire() {
        let project_id = domain::ProjectId::from_uuid(uuid::Uuid::from_u128(40));
        let sprint_id = domain::SprintId::from_uuid(uuid::Uuid::from_u128(42));
        let dto = DomainEventDto::from(&DomainEvent::IssueDeleted {
            project_id,
            issue_ref: domain::IssueRef::from(domain::IssueNumber::new(7).unwrap()),
            freed_sprint: Some(sprint_id),
        });
        let json = serde_json::to_value(&dto).expect("serialisable");
        assert_eq!(json["type"], "issueDeleted");
        assert_eq!(json["projectId"], project_id.to_string());
        assert_eq!(json["issueRef"], "#7");
        assert_eq!(json["freedSprint"], sprint_id.to_string());
    }

    #[test]
    fn ticket_assistant_events_relay_to_dto_and_wire() {
        let profile_id = domain::ProfileId::from_uuid(uuid::Uuid::from_u128(9));
        let opened = DomainEventDto::from(&DomainEvent::TicketAssistantOpened {
            issue_ref: domain::IssueRef::from(domain::IssueNumber::new(7).unwrap()),
            profile_id,
        });
        let opened = serde_json::to_value(&opened).expect("serialisable");
        assert_eq!(opened["type"], "ticketAssistantOpened");
        assert_eq!(opened["issueRef"], "#7");
        assert_eq!(opened["profileId"], profile_id.to_string());

        let closed = DomainEventDto::from(&DomainEvent::TicketAssistantClosed {
            issue_ref: domain::IssueRef::from(domain::IssueNumber::new(7).unwrap()),
        });
        let closed = serde_json::to_value(&closed).expect("serialisable");
        assert_eq!(closed["type"], "ticketAssistantClosed");
        assert_eq!(closed["issueRef"], "#7");
    }
}
