//! `#[tauri::command]` handlers — the **driving adapters** (frontend → backend).
//!
//! Each handler is a thin shell: deserialise the DTO, call the use case from
//! [`AppState`], map `Result<Output, AppError>` to `Result<ResponseDto,
//! ErrorDto>`. No business logic lives here.

use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use serde::{Deserialize, Serialize};
use tauri::ipc::Channel;
use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder, WindowEvent};

use crate::dto::DismissEmbedderSuggestionRequestDto;
use application::{
    AppError, AssignSkillToAgentInput, AttachLiveAgentInput, ChangeAgentProfileInput,
    CloseProjectInput, CreateAgentInput, CreateLayoutInput, CreateMemoryInput, CreateSkillInput,
    DeleteAgentInput, DeleteEmbedderProfileInput, DeleteLayoutInput, DeleteMemoryInput,
    DeleteSkillInput, DeleteTemplateInput, DetectAgentDriftInput, GetMemoryInput,
    GetProjectSystemPermissionsInput, GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput,
    GitCommitInput, GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput,
    InspectConversationInput, LaunchAgentInput, ListAgentsInput, ListDevicesInput,
    ListLayoutsInput, ListMemoriesInput, ListResumableAgentsInput, ListSkillsInput, LiveSessions,
    LoadLayoutInput, McpRuntime, MutateLayoutInput, OpenPluginLayoutWindowInput, OpenProjectInput,
    ReadAgentContextInput, ReadConversationPageInput, ReadMcpToolPermissionsInput,
    ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput,
    ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput, ResolveAgentPermissionsInput,
    ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
    RotateConversationLogInput, SetActiveLayoutInput, SnapshotRunningAgentsInput,
    StopLiveAgentInput, SyncAgentWithTemplateInput, UnassignSkillFromAgentInput,
    UpdateAgentContextInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput,
    UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateMemoryInput,
    UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput,
    UpdateProjectSystemPermissionsInput, UpdateSkillInput,
};
use backend::stream::OutputSink;
use domain::ports::ModelServerRuntime;
use domain::ports::PtyHandle;
use domain::{PersistedPluginLayoutWindow, PluginId, PluginLayoutType};

use crate::dto::{
    model_server_config_domain, parse_agent_id, parse_close_terminal, parse_delete_profile,
    parse_layout_id, parse_memory_slug, parse_model_server_id, parse_node_id, parse_profile_id,
    parse_project_id, parse_session_id, parse_skill_id, parse_task_id, parse_template_id,
    parse_ticket_id, save_model_server_input, AgentDriftListDto, AgentDto, AgentListDto,
    AppExitWorkGuardStateDto, AssignSkillRequestDto, AttachBackgroundTaskResultDto,
    AttachLiveAgentRequestDto, AttachLiveAgentResponseDto, BackgroundTaskDto,
    ChangeAgentProfileDto, ChangeAgentProfileRequestDto, CloneOpenCodeProfileFromSeedRequestDto,
    CloneProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
    CreateAgentFromTemplateRequestDto, CreateAgentRequestDto, CreateLayoutRequestDto,
    CreateLayoutResultDto, CreateMemoryRequestDto, CreateProjectRequestDto, CreateSkillRequestDto,
    CreateTemplateRequestDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
    DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
    EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto, ErrorDto, FirstRunStateDto,
    FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto, GitCommitDto, GitCommitListDto,
    GitCommitRequestDto, GitStageRequestDto, GitStatusListDto, GraphCommitListDto,
    HealthRequestDto, HealthResponseDto, InspectConversationRequestDto, InterruptAgentRequestDto,
    LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
    MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto,
    ModelServerConfigListDto, OpenCodeProviderListDto, OpenTerminalRequestDto,
    PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProfileModelCatalogDto, ProjectDto,
    ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto,
    ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
    ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto,
    RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto,
    ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto,
    ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
    ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveModelServerRequestDto,
    SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto,
    SetActiveLayoutResultDto, SkillDto, SkillListDto, StopLiveAgentRequestDto,
    StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
    TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto,
    UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto,
    UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
    UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
    UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto,
    UpdateProjectSystemPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
    WriteTerminalRequestDto,
};
use crate::embedded_server::{
    EmbeddedServerStatusDto, ServerExposurePreviewDto, ServerExposureSettingsDto,
};
use crate::pty::{PtyBridge, PtyChunk};
use crate::state::{AppState, FocusedProjectDto};
use crate::stream::TauriChannelSink;
use domain::{DeviceId, SkillRef, SkillScope};
use uuid::Uuid;

/// `health` — trivial command validating the full IPC pipeline
/// (frontend gateway → invoke → command → use case → ports → event relay).
///
/// # Errors
/// Returns an [`ErrorDto`] if the use case fails.
#[tauri::command]
pub fn health(
    request: Option<HealthRequestDto>,
    state: State<'_, AppState>,
) -> Result<HealthResponseDto, ErrorDto> {
    let input = request.unwrap_or_default().into();
    state
        .health
        .execute(input)
        .map(HealthResponseDto::from)
        .map_err(ErrorDto::from)
}

/// `get_server_exposure_settings` — read persisted embedded-server exposure settings.
///
/// # Errors
/// Returns an [`ErrorDto`] when persisted settings are invalid.
#[tauri::command]
pub fn get_server_exposure_settings(
    state: State<'_, AppState>,
) -> Result<ServerExposureSettingsDto, ErrorDto> {
    state.embedded_server.get_settings()
}

/// `save_server_exposure_settings` — validate and persist embedded-server exposure settings.
///
/// # Errors
/// Returns an [`ErrorDto`] when settings are invalid or cannot be written.
#[tauri::command]
pub fn save_server_exposure_settings(
    settings: ServerExposureSettingsDto,
    state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
    state.embedded_server.save_settings(settings)
}

/// `preview_server_exposure_settings` — derive LAN candidates and upstream URL.
///
/// # Errors
/// Returns an [`ErrorDto`] when settings are invalid.
#[tauri::command]
pub fn preview_server_exposure_settings(
    settings: ServerExposureSettingsDto,
    state: State<'_, AppState>,
) -> Result<ServerExposurePreviewDto, ErrorDto> {
    state.embedded_server.preview(settings)
}

/// `embedded_server_status` — return the current embedded server status.
#[tauri::command]
pub fn embedded_server_status(state: State<'_, AppState>) -> EmbeddedServerStatusDto {
    state.embedded_server.status()
}

/// `embedded_server_start` — start the embedded server with the shared backend core.
///
/// # Errors
/// Returns an [`ErrorDto`] when settings are invalid or the listener cannot start.
#[tauri::command]
pub async fn embedded_server_start(
    state: State<'_, AppState>,
) -> Result<EmbeddedServerStatusDto, ErrorDto> {
    state.embedded_server.start(state.core()).await
}

/// `embedded_server_stop` — stop the embedded server.
///
/// # Errors
/// Returns an [`ErrorDto`] when stopping the listener fails.
#[tauri::command]
pub async fn embedded_server_stop(
    state: State<'_, AppState>,
) -> Result<EmbeddedServerStatusDto, ErrorDto> {
    state.embedded_server.stop().await
}

/// `embedded_server_generate_pairing_code` — generate a desktop-owned ephemeral code.
///
/// # Errors
/// Returns an [`ErrorDto`] when the embedded server is not running.
#[tauri::command]
pub fn embedded_server_generate_pairing_code(
    state: State<'_, AppState>,
) -> Result<web_server::PairingCodeDto, ErrorDto> {
    state.embedded_server.generate_pairing_code()
}

/// Device row exposed to the desktop settings surface.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DeviceDto {
    /// Device id.
    pub device_id: String,
    /// User-facing name.
    pub name: String,
    /// Pairing timestamp as epoch milliseconds.
    pub paired_at_ms: u64,
    /// Last successful access timestamp as epoch milliseconds.
    pub last_seen_at_ms: u64,
    /// Whether this is the current authenticated web device.
    pub is_current_device: bool,
}

/// List response for paired devices.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DeviceListDto {
    /// Paired devices.
    pub devices: Vec<DeviceDto>,
}

/// Rename request for paired devices.
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RenameDeviceRequestDto {
    /// Device id.
    pub device_id: String,
    /// New name.
    pub name: String,
}

/// Revoke request for one paired device.
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RevokeDeviceRequestDto {
    /// Device id.
    pub device_id: String,
}

fn parse_device_id(raw: &str) -> Result<DeviceId, ErrorDto> {
    Uuid::parse_str(raw)
        .map(DeviceId::from_uuid)
        .map_err(|_| ErrorDto {
            code: "INVALID".to_owned(),
            message: "invalid device id".to_owned(),
        })
}

/// `list_devices` — list paired devices through the desktop composition root.
///
/// # Errors
/// Returns an [`ErrorDto`] when the device store cannot be read.
#[tauri::command]
pub async fn list_devices(state: State<'_, AppState>) -> Result<DeviceListDto, ErrorDto> {
    let output = state
        .list_devices
        .execute(ListDevicesInput {
            current_device_id: None,
        })
        .await
        .map_err(ErrorDto::from)?;
    Ok(DeviceListDto {
        devices: output
            .devices
            .into_iter()
            .map(|device| DeviceDto {
                device_id: device.device_id.to_string(),
                name: device.name,
                paired_at_ms: device.paired_at_ms,
                last_seen_at_ms: device.last_seen_at_ms,
                is_current_device: device.is_current_device,
            })
            .collect(),
    })
}

/// `create_pairing_code` — generate an ephemeral pairing code via the embedded server state.
///
/// # Errors
/// Returns an [`ErrorDto`] when the embedded server is not running.
#[tauri::command]
pub fn create_pairing_code(
    state: State<'_, AppState>,
) -> Result<web_server::PairingCodeDto, ErrorDto> {
    state.embedded_server.generate_pairing_code()
}

/// `rename_device` — rename a paired device.
///
/// # Errors
/// Returns an [`ErrorDto`] for invalid input or store failures.
#[tauri::command]
pub async fn rename_device(
    request: RenameDeviceRequestDto,
    state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
    state
        .rename_device
        .execute(RenameDeviceInput {
            device_id: parse_device_id(&request.device_id)?,
            name: request.name,
        })
        .await
        .map_err(ErrorDto::from)
}

/// `revoke_device` — revoke one paired device.
///
/// # Errors
/// Returns an [`ErrorDto`] for invalid input or store failures.
#[tauri::command]
pub async fn revoke_device(
    request: RevokeDeviceRequestDto,
    state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
    state
        .revoke_device
        .execute(RevokeDeviceInput {
            device_id: parse_device_id(&request.device_id)?,
        })
        .await
        .map_err(ErrorDto::from)
}

/// `revoke_all_devices` — revoke every paired device.
///
/// # Errors
/// Returns an [`ErrorDto`] when the device store cannot be rewritten.
#[tauri::command]
pub async fn revoke_all_devices(state: State<'_, AppState>) -> Result<(), ErrorDto> {
    state
        .revoke_all_devices
        .execute()
        .await
        .map_err(ErrorDto::from)
}

/// `create_project` — create a project from a root: init `.ideai/`, register it.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a bad root/name or a duplicate
/// `(remote, root)`, `FILESYSTEM`/`STORE` on I/O failure).
#[tauri::command]
pub async fn create_project(
    request: CreateProjectRequestDto,
    state: State<'_, AppState>,
) -> Result<ProjectDto, ErrorDto> {
    let output = state
        .create_project
        .execute(request.into())
        .await
        .map_err(ErrorDto::from)?;
    // Start tailing this project's `.ideai/requests/` tree so an orchestrator
    // agent can delegate agent/skill creation to IdeA (§14.3).
    state.ensure_orchestrator_watch(&output.project);
    Ok(ProjectDto::from(output))
}

/// `open_project` — load a project and its `.ideai/` meta/manifest.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// project is unknown, `STORE` on registry I/O failure).
#[tauri::command]
pub async fn open_project(
    project_id: String,
    state: State<'_, AppState>,
) -> Result<ProjectDto, ErrorDto> {
    open_project_for_adapter(project_id, &state).await
}

/// Shared `open_project` adapter body used by desktop IPC and the HTTP server.
///
/// Keeps the non-presentation side effects attached to project opening in one
/// place while B3 adds a second driving adapter.
pub(crate) async fn open_project_for_adapter(
    project_id: String,
    state: &AppState,
) -> Result<ProjectDto, ErrorDto> {
    let id = parse_project_id(&project_id)?;
    let output = state
        .open_project
        .execute(OpenProjectInput { project_id: id })
        .await
        .map_err(ErrorDto::from)?;
    // R0c (§3.4 « Trou C ») : dé-doublonne les `layouts.json` portant plusieurs
    // feuilles sur le même agent AVANT toute reprise (`list_resumable_agents`
    // relit la version persistée), de sorte qu'on ne propose / ne relance qu'une
    // session par agent. Idempotent (no-op sans doublon) et best-effort : un échec
    // ne doit pas bloquer l'ouverture.
    let _ = state
        .reconcile_layouts
        .execute(ReconcileLayoutsInput { project_id: id })
        .await;
    // Réconcilie les lignes de live-state fantômes : un crash ne déclenche jamais
    // `close_project`, donc l'ouverture est le seul filet fiable pour repasser en
    // `idle` les agents `working`/`waiting`/`blocked` dont la session est morte.
    // Acte système (appelle le port live-state directement, jamais via la surface
    // MCP self-only). Best-effort : un échec ne bloque pas l'ouverture. Tourne
    // AVANT toute relance d'agent (toute reprise réécrit ensuite en LWW).
    let _ = state
        .reconcile_live_state
        .execute(ReconcileLiveStateInput { project_id: id })
        .await;
    // Réconcilie les tâches de fond persistées au même point de boot que le
    // live-state : les Running sans handle vivant deviennent terminales, les
    // complétions WakeOwner non livrées repassent dans l'inbox unifiée.
    let _ = state.reconcile_background_tasks.execute(id).await;
    // (Re)start the orchestrator watcher for this project (idempotent, §14.3).
    state.ensure_orchestrator_watch(&output.project);
    state.reconcile_claude_run_dirs(&output.project).await;
    Ok(ProjectDto::from(output))
}

/// `close_project` — persist state and release resources for a project.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `STORE` on failure).
#[tauri::command]
pub async fn close_project(project_id: String, state: State<'_, AppState>) -> Result<(), ErrorDto> {
    let id = parse_project_id(&project_id)?;
    // T5: freeze `agent_was_running` on every agent leaf BEFORE any PTY release,
    // reading the live registry as it stands now. Best-effort: a snapshot failure
    // must not block the close.
    let _ = state
        .snapshot_running_agents
        .execute(SnapshotRunningAgentsInput { project_id: id })
        .await;
    // Stop tailing this project's `.ideai/requests/` tree (§14.3).
    state.stop_orchestrator_watch(&id);
    state
        .close_project
        .execute(CloseProjectInput {
            project_id: id,
            // L2 has no UI-side workspace mutations to persist yet.
            workspace: None,
        })
        .await
        .map(|_| ())
        .map_err(ErrorDto::from)
}

/// `list_projects` — list the projects known to the registry.
///
/// # Errors
/// Returns an [`ErrorDto`] (`STORE` on registry I/O failure).
#[tauri::command]
pub async fn list_projects(state: State<'_, AppState>) -> Result<ProjectListDto, ErrorDto> {
    state
        .list_projects
        .execute()
        .await
        .map(ProjectListDto::from)
        .map_err(ErrorDto::from)
}

/// `read_project_context` — read `.ideai/CONTEXT.md` for a project.
///
/// Missing context is returned as an empty string so a project whose `.ideai/`
/// was deleted can still open cleanly.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// project is unknown, `FILESYSTEM`/`STORE` on read or UTF-8 failure).
#[tauri::command]
pub async fn read_project_context(
    project_id: String,
    state: State<'_, AppState>,
) -> Result<String, ErrorDto> {
    let project = resolve_project(&project_id, &state).await?;
    state
        .read_project_context
        .execute(ReadProjectContextInput { project })
        .await
        .map(|out| out.content)
        .map_err(ErrorDto::from)
}

/// `update_project_context` — overwrite `.ideai/CONTEXT.md` for a project.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// project is unknown, `FILESYSTEM` on write failure).
#[tauri::command]
pub async fn update_project_context(
    request: UpdateProjectContextRequestDto,
    state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
    let project = resolve_project(&request.project_id, &state).await?;
    state
        .update_project_context
        .execute(UpdateProjectContextInput {
            project,
            content: request.content,
        })
        .await
        .map_err(ErrorDto::from)
}

/// `get_project_permissions` — read `.ideai/permissions.json`.
///
/// # Errors
/// Returns an [`ErrorDto`] on invalid project id or store failure.
#[tauri::command]
pub async fn get_project_permissions(
    project_id: String,
    state: State<'_, AppState>,
) -> Result<ProjectPermissionsDto, ErrorDto> {
    let project = resolve_project(&project_id, &state).await?;
    state
        .get_project_permissions
        .execute(application::GetProjectPermissionsInput { project })
        .await
        .map(|out| ProjectPermissionsDto(out.permissions))
        .map_err(ErrorDto::from)
}

/// `update_project_permissions` — replace project default permissions.
///
/// # Errors
/// Returns an [`ErrorDto`] on invalid project id or store failure.
#[tauri::command]
pub async fn update_project_permissions(
    request: UpdateProjectPermissionsRequestDto,
    state: State<'_, AppState>,
) -> Result<ProjectPermissionsDto, ErrorDto> {
    let project = resolve_project(&request.project_id, &state).await?;
    state
        .update_project_permissions
        .execute(UpdateProjectPermissionsInput {
            project,
            permissions: request.permissions,
        })
        .await
        .map(|out| ProjectPermissionsDto(out.permissions))
        .map_err(ErrorDto::from)
}

/// `update_agent_permissions` — replace or remove one agent override.
///
/// # Errors
/// Returns an [`ErrorDto`] on invalid ids or store failure.
#[tauri::command]
pub async fn update_agent_permissions(
    request: UpdateAgentPermissionsRequestDto,
    state: State<'_, AppState>,
) -> Result<ProjectPermissionsDto, ErrorDto> {
    let project = resolve_project(&request.project_id, &state).await?;
    let agent_id = parse_agent_id(&request.agent_id)?;
    state
        .update_agent_permissions
        .execute(UpdateAgentPermissionsInput {
            project,
            agent_id,
            permissions: request.permissions,
        })
        .await
        .map(|out| ProjectPermissionsDto(out.permissions))
        .map_err(ErrorDto::from)
}

/// `update_agent_effort` — set or clear one agent's per-agent effort override.
///
/// # Errors
/// Returns an [`ErrorDto`] on invalid ids or store failure.
#[tauri::command]
pub async fn update_agent_effort(
    request: UpdateAgentEffortRequestDto,
    state: State<'_, AppState>,
) -> Result<AgentDto, ErrorDto> {
    let project = resolve_project(&request.project_id, &state).await?;
    let agent_id = parse_agent_id(&request.agent_id)?;
    state
        .update_agent_effort
        .execute(UpdateAgentEffortInput {
            project,
            agent_id,
            effort: request.effort,
        })
        .await
        .map(|out| AgentDto::from_agent(out.agent))
        .map_err(ErrorDto::from)
}

/// `resolve_agent_permissions` — resolve project defaults plus agent override.
///
/// # Errors
/// Returns an [`ErrorDto`] on invalid ids or store failure.
#[tauri::command]
pub async fn resolve_agent_permissions(
    request: ResolveAgentPermissionsRequestDto,
    state: State<'_, AppState>,
) -> Result<ResolveAgentPermissionsResponseDto, ErrorDto> {
    let project = resolve_project(&request.project_id, &state).await?;
    let agent_id = parse_agent_id(&request.agent_id)?;
    state
        .resolve_agent_permissions
        .execute(ResolveAgentPermissionsInput { project, agent_id })
        .await
        .map(ResolveAgentPermissionsResponseDto::from)
        .map_err(ErrorDto::from)
}

/// `get_project_system_permissions` — read `.ideai/system-permissions.json`.
///
/// # Errors
/// Returns an [`ErrorDto`] on invalid project id or store failure.
#[tauri::command]
pub async fn get_project_system_permissions(
    project_id: String,
    state: State<'_, AppState>,
) -> Result<ProjectSystemPermissionsDto, ErrorDto> {
    let project = resolve_project(&project_id, &state).await?;
    state
        .get_project_system_permissions
        .execute(GetProjectSystemPermissionsInput { project })
        .await
        .map(|out| ProjectSystemPermissionsDto(out.permissions))
        .map_err(ErrorDto::from)
}

/// `update_project_system_permissions` — replace project default system permissions.
///
/// # Errors
/// Returns an [`ErrorDto`] on invalid project id or store failure.
#[tauri::command]
pub async fn update_project_system_permissions(
    request: UpdateProjectSystemPermissionsRequestDto,
    state: State<'_, AppState>,
) -> Result<ProjectSystemPermissionsDto, ErrorDto> {
    let project = resolve_project(&request.project_id, &state).await?;
    state
        .update_project_system_permissions
        .execute(UpdateProjectSystemPermissionsInput {
            project,
            permissions: request.permissions,
        })
        .await
        .map(|out| ProjectSystemPermissionsDto(out.permissions))
        .map_err(ErrorDto::from)
}

/// `update_agent_system_permissions` — replace or remove one agent system override.
///
/// # Errors
/// Returns an [`ErrorDto`] on invalid ids or store failure.
#[tauri::command]
pub async fn update_agent_system_permissions(
    request: UpdateAgentSystemPermissionsRequestDto,
    state: State<'_, AppState>,
) -> Result<ProjectSystemPermissionsDto, ErrorDto> {
    let project = resolve_project(&request.project_id, &state).await?;
    let agent_id = parse_agent_id(&request.agent_id)?;
    state
        .update_agent_system_permissions
        .execute(UpdateAgentSystemPermissionsInput {
            project,
            agent_id,
            permissions: request.permissions,
        })
        .await
        .map(|out| ProjectSystemPermissionsDto(out.permissions))
        .map_err(ErrorDto::from)
}

/// `resolve_agent_system_permissions` — resolve wanted plus runtime-constrained system permissions.
///
/// # Errors
/// Returns an [`ErrorDto`] on invalid ids or store/probe failure.
#[tauri::command]
pub async fn resolve_agent_system_permissions(
    request: ResolveAgentSystemPermissionsRequestDto,
    state: State<'_, AppState>,
) -> Result<ResolvedAgentSystemPermissionsDto, ErrorDto> {
    let project = resolve_project(&request.project_id, &state).await?;
    let agent_id = parse_agent_id(&request.agent_id)?;
    state
        .resolve_agent_system_permissions
        .execute(ResolveAgentSystemPermissionsInput { project, agent_id })
        .await
        .map(|out| ResolvedAgentSystemPermissionsDto(out.permissions))
        .map_err(ErrorDto::from)
}

/// `get_mcp_tool_permissions` — read `.ideai/mcp-tool-permissions.json` plus catalogue.
///
/// # Errors
/// Returns an [`ErrorDto`] on invalid project id, invalid stored policy, or store failure.
#[tauri::command]
pub async fn get_mcp_tool_permissions(
    project_id: String,
    state: State<'_, AppState>,
) -> Result<ProjectMcpToolPermissionsDto, ErrorDto> {
    let project = resolve_project(&project_id, &state).await?;
    state
        .read_mcp_tool_permissions
        .execute(ReadMcpToolPermissionsInput { project })
        .await
        .map(ProjectMcpToolPermissionsDto::from)
        .map_err(ErrorDto::from)
}

/// `update_project_mcp_tool_permissions` — replace or remove project MCP tool defaults.
///
/// # Errors
/// Returns an [`ErrorDto`] on invalid project id, invalid tool policy, or store failure.
#[tauri::command]
pub async fn update_project_mcp_tool_permissions(
    request: UpdateProjectMcpToolPermissionsRequestDto,
    state: State<'_, AppState>,
) -> Result<ProjectMcpToolPermissionsDto, ErrorDto> {
    let project = resolve_project(&request.project_id, &state).await?;
    state
        .update_project_mcp_tool_permissions
        .execute(UpdateProjectMcpToolPermissionsInput {
            project,
            policy: request.policy,
        })
        .await
        .map(ProjectMcpToolPermissionsDto::from)
        .map_err(ErrorDto::from)
}

/// `update_agent_mcp_tool_permissions` — replace or remove one agent MCP tool override.
///
/// # Errors
/// Returns an [`ErrorDto`] on invalid ids, invalid tool policy, or store failure.
#[tauri::command]
pub async fn update_agent_mcp_tool_permissions(
    request: UpdateAgentMcpToolPermissionsRequestDto,
    state: State<'_, AppState>,
) -> Result<ProjectMcpToolPermissionsDto, ErrorDto> {
    let project = resolve_project(&request.project_id, &state).await?;
    let agent_id = parse_agent_id(&request.agent_id)?;
    state
        .update_agent_mcp_tool_permissions
        .execute(UpdateAgentMcpToolPermissionsInput {
            project,
            agent_id,
            policy: request.policy,
        })
        .await
        .map(ProjectMcpToolPermissionsDto::from)
        .map_err(ErrorDto::from)
}

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

/// `open_terminal` — spawn a PTY and wire its byte stream to the frontend.
///
/// The frontend passes a per-session [`Channel`] (xterm's output sink). We:
/// 1. run [`application::OpenTerminal`] (spawn the PTY, register the session),
/// 2. register the channel in the [`PtyBridge`] keyed by the new session id,
/// 3. start a pump that drains the PTY's blocking output stream and forwards
///    each chunk through the bridge to that channel.
///
/// Returns the [`TerminalSessionDto`] (its `sessionId` is what `write`/`resize`/
/// `close` reference).
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a bad cwd/size, `PROCESS` if the PTY
/// fails to spawn or its output cannot be subscribed).
#[tauri::command]
pub async fn open_terminal(
    request: OpenTerminalRequestDto,
    on_output: Channel<PtyChunk>,
    state: State<'_, AppState>,
) -> Result<TerminalSessionDto, ErrorDto> {
    let output = state
        .open_terminal
        .execute(request.into())
        .await
        .map_err(ErrorDto::from)?;
    let session_id = output.session.id;

    // (2) Register the xterm output channel for this session.
    let gen = state.pty_bridge.register(session_id, on_output);

    // (3) Subscribe to the PTY's byte stream and pump it to the channel. The
    // stream is a blocking iterator, so it runs on a dedicated OS thread; it
    // ends when the PTY hits EOF (process exit) or this attach is superseded.
    let handle = PtyHandle { session_id };
    match state.pty_port.subscribe_output(&handle) {
        Ok(stream) => {
            let bridge: std::sync::Arc<PtyBridge> = std::sync::Arc::clone(&state.pty_bridge);
            std::thread::spawn(move || {
                for chunk in stream {
                    if !bridge.send_output(&session_id, chunk) {
                        break;
                    }
                }
                // Stream ended: drop the channel only if still ours (a re-attach
                // may have superseded this generation — don't tear down its channel).
                bridge.unregister_if(&session_id, gen);
            });
        }
        Err(e) => {
            state.pty_bridge.unregister(&session_id);
            return Err(ErrorDto::from(application::AppError::from(e)));
        }
    }

    Ok(TerminalSessionDto::from(output))
}

/// `write_terminal` — forward bytes (xterm keystrokes) to a live PTY.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// session is unknown, `PROCESS` on PTY I/O failure).
#[tauri::command]
pub fn write_terminal(
    request: WriteTerminalRequestDto,
    state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
    let input = request.into_input()?;
    let session_id = input.session_id;
    let bytes = input.data.len();
    let control = describe_terminal_write(&input.data);
    application::diag!(
        "[pty-write] write_terminal start: session={session_id} bytes={bytes} control={control}"
    );
    match state.write_terminal.execute(input) {
        Ok(()) => {
            application::diag!(
                "[pty-write] write_terminal ok: session={session_id} bytes={bytes} control={control}"
            );
            Ok(())
        }
        Err(e) => {
            application::diag!(
                "[pty-write] write_terminal failed: session={session_id} bytes={bytes} \
                 control={control} error={e}"
            );
            Err(ErrorDto::from(e))
        }
    }
}

fn describe_terminal_write(data: &[u8]) -> String {
    if data.is_empty() {
        return "<empty>".to_owned();
    }
    if data.len() > 16 {
        return "<bulk>".to_owned();
    }
    data.iter()
        .map(|byte| match *byte {
            b'\r' => "\\r".to_owned(),
            b'\n' => "\\n".to_owned(),
            0x7f => "\\x7f".to_owned(),
            byte if byte < 0x20 => format!("\\x{byte:02x}"),
            byte => char::from(byte).to_string(),
        })
        .collect::<Vec<_>>()
        .join("")
}

/// `resize_terminal` — resize a live PTY.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id/size, `NOT_FOUND` if the
/// session is unknown, `PROCESS` on failure).
#[tauri::command]
pub fn resize_terminal(
    request: ResizeTerminalRequestDto,
    state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
    let input = request.into_input()?;
    state.resize_terminal.execute(input).map_err(ErrorDto::from)
}

/// `close_terminal` — kill a live PTY and tear down its channel.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// session is unknown, `PROCESS` if the kill fails).
#[tauri::command]
pub async fn close_terminal(
    session_id: String,
    state: State<'_, AppState>,
) -> Result<TerminalClosedDto, ErrorDto> {
    let input = parse_close_terminal(&session_id)?;
    let sid = parse_session_id(&session_id)?;
    let result = state
        .close_terminal
        .execute(input)
        .await
        .map(TerminalClosedDto::from)
        .map_err(ErrorDto::from);
    // Tear down the channel regardless of kill outcome.
    state.pty_bridge.unregister(&sid);
    result
}

/// `reattach_terminal` — re-bind a view to a **still-living** PTY without
/// re-spawning it.
///
/// Navigation (switching layout/tab) tears the xterm view down but must NOT kill
/// the backend PTY (the AI keeps running). When the view comes back it calls this
/// command, which:
/// 1. reads the session's retained **scrollback** so the terminal can repaint,
/// 2. registers the new per-session [`Channel`] in the [`PtyBridge`],
/// 3. starts a fresh output pump subscribed to the live PTY (re-subscribable
///    broadcast), so new bytes flow to the new channel.
///
/// Returns the scrollback bytes; the frontend writes them into xterm first, then
/// receives subsequent output over `on_output`.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND`/`PROCESS`
/// if the session is no longer alive).
#[tauri::command]
pub fn reattach_terminal(
    session_id: String,
    on_output: Channel<PtyChunk>,
    state: State<'_, AppState>,
) -> Result<ReattachResultDto, ErrorDto> {
    let sid = parse_session_id(&session_id)?;
    let handle = PtyHandle { session_id: sid };

    // (1) Snapshot the scrollback. A NotFound here means the PTY is gone (was
    // explicitly closed or exited) — surfaced as an error so the caller falls
    // back to opening a fresh terminal.
    let scrollback = state
        .pty_port
        .scrollback(&handle)
        .map_err(|e| ErrorDto::from(AppError::from(e)))?;

    // (2) Register the new output channel for this session, replacing any stale
    // one from a previous attach (and bumping the generation).
    let gen = state.pty_bridge.register(sid, on_output);

    // (3) Subscribe afresh to the live byte stream and pump it to the channel.
    // The fresh subscription supersedes the previous attach's, so its pump thread
    // ends and stops double-delivering this session's bytes.
    match state.pty_port.subscribe_output(&handle) {
        Ok(stream) => {
            let bridge: std::sync::Arc<PtyBridge> = std::sync::Arc::clone(&state.pty_bridge);
            std::thread::spawn(move || {
                for chunk in stream {
                    if !bridge.send_output(&sid, chunk) {
                        break;
                    }
                }
                bridge.unregister_if(&sid, gen);
            });
        }
        Err(e) => {
            state.pty_bridge.unregister(&sid);
            return Err(ErrorDto::from(AppError::from(e)));
        }
    }

    Ok(ReattachResultDto {
        session_id,
        scrollback,
    })
}

// ---------------------------------------------------------------------------
// Layout (L4)
// ---------------------------------------------------------------------------

/// `load_layout` — read a project's named layout (the active one when `layout_id`
/// is omitted).
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// project or layout is unknown, `STORE` on registry I/O failure).
#[tauri::command]
pub async fn load_layout(
    project_id: String,
    layout_id: Option<String>,
    state: State<'_, AppState>,
) -> Result<LayoutDto, ErrorDto> {
    let id = parse_project_id(&project_id)?;
    let lid = layout_id.as_deref().map(parse_layout_id).transpose()?;
    state
        .load_layout
        .execute(LoadLayoutInput {
            project_id: id,
            layout_id: lid,
        })
        .await
        .map(LayoutDto::from)
        .map_err(ErrorDto::from)
}

/// `mutate_layout` — apply a split/merge/resize/move/setSession/setCellAgent
/// operation, persist the result and announce `LayoutChanged`.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id/operation or an invariant
/// violation, `NOT_FOUND` for an unknown project/node, `FILESYSTEM`/`STORE` on I/O
/// failure).
#[tauri::command]
pub async fn mutate_layout(
    project_id: String,
    layout_id: Option<String>,
    operation: LayoutOperationDto,
    state: State<'_, AppState>,
) -> Result<LayoutDto, ErrorDto> {
    let id = parse_project_id(&project_id)?;
    let lid = layout_id.as_deref().map(parse_layout_id).transpose()?;
    let operation = operation.into_operation()?;
    state
        .mutate_layout
        .execute(MutateLayoutInput {
            project_id: id,
            layout_id: lid,
            operation,
        })
        .await
        .map(LayoutDto::from)
        .map_err(ErrorDto::from)
}

// ---------------------------------------------------------------------------
// Named-layout management (#4)
// ---------------------------------------------------------------------------

/// `list_layouts` — list all named layouts of a project and the active one.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// project is unknown, `STORE`/`FILESYSTEM` on I/O failure).
#[tauri::command]
pub async fn list_layouts(
    project_id: String,
    state: State<'_, AppState>,
) -> Result<ListLayoutsDto, ErrorDto> {
    let id = parse_project_id(&project_id)?;
    state
        .list_layouts
        .execute(ListLayoutsInput { project_id: id })
        .await
        .map(ListLayoutsDto::from)
        .map_err(ErrorDto::from)
}

/// `create_layout` — create a new empty named layout and make it active.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for empty name or malformed id, `NOT_FOUND`
/// if the project is unknown, `STORE`/`FILESYSTEM` on I/O failure).
#[tauri::command]
pub async fn create_layout(
    request: CreateLayoutRequestDto,
    state: State<'_, AppState>,
) -> Result<CreateLayoutResultDto, ErrorDto> {
    let project_id = parse_project_id(&request.project_id)?;
    let kind = request.parse_kind()?;
    state
        .create_layout
        .execute(CreateLayoutInput {
            project_id,
            name: request.name,
            kind,
        })
        .await
        .map(CreateLayoutResultDto::from)
        .map_err(ErrorDto::from)
}

/// `rename_layout` — rename a named layout.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for empty name or malformed id, `NOT_FOUND`
/// if the project or layout is unknown).
#[tauri::command]
pub async fn rename_layout(
    request: RenameLayoutRequestDto,
    state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
    let project_id = parse_project_id(&request.project_id)?;
    let layout_id = parse_layout_id(&request.layout_id)?;
    state
        .rename_layout
        .execute(RenameLayoutInput {
            project_id,
            layout_id,
            name: request.name,
        })
        .await
        .map_err(ErrorDto::from)
}

/// `delete_layout` — delete a named layout (cannot be the last one).
///
/// Returns the active layout id after the deletion.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for malformed id or last layout attempt,
/// `NOT_FOUND` if the project or layout is unknown).
#[tauri::command]
pub async fn delete_layout(
    request: DeleteLayoutRequestDto,
    state: State<'_, AppState>,
) -> Result<DeleteLayoutResultDto, ErrorDto> {
    let project_id = parse_project_id(&request.project_id)?;
    let layout_id = parse_layout_id(&request.layout_id)?;
    state
        .delete_layout
        .execute(DeleteLayoutInput {
            project_id,
            layout_id,
        })
        .await
        .map(DeleteLayoutResultDto::from)
        .map_err(ErrorDto::from)
}

/// `set_active_layout` — switch the active named layout of a project.
///
/// Self-healing: a stale `layout_id` does not error; the active layout is left
/// unchanged and the actually-active id is returned (authoritative).
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for malformed id) or a persistence error.
#[tauri::command]
pub async fn set_active_layout(
    request: SetActiveLayoutRequestDto,
    state: State<'_, AppState>,
) -> Result<SetActiveLayoutResultDto, ErrorDto> {
    let project_id = parse_project_id(&request.project_id)?;
    let layout_id = parse_layout_id(&request.layout_id)?;
    state
        .set_active_layout
        .execute(SetActiveLayoutInput {
            project_id,
            layout_id,
        })
        .await
        .map(SetActiveLayoutResultDto::from)
        .map_err(ErrorDto::from)
}

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

/// `first_run_state` — whether the first-run wizard should show (no
/// `profiles.json` yet) plus the pre-filled reference catalogue to seed it.
///
/// # Errors
/// Returns an [`ErrorDto`] (`STORE` on profiles I/O failure).
#[tauri::command]
pub async fn first_run_state(state: State<'_, AppState>) -> Result<FirstRunStateDto, ErrorDto> {
    state
        .first_run_state
        .execute()
        .await
        .map(FirstRunStateDto::from)
        .map_err(ErrorDto::from)
}

/// `reference_profiles` — the pre-filled, editable reference catalogue offered to
/// agent creation/selection.
///
/// Restricted to the **selectable** profiles (§17.3, lot D7): only profiles
/// drivable in structured mode (today Claude + Codex) are returned. Gemini/Aider
/// stay in the catalogue data but are not proposed, and there is no custom-profile
/// entry. Persistence/editing of pre-existing (legacy) profiles is unaffected.
///
/// # Errors
/// Returns an [`ErrorDto`] (never in practice; the catalogue is in-memory).
#[tauri::command]
pub async fn reference_profiles(state: State<'_, AppState>) -> Result<ProfileListDto, ErrorDto> {
    state
        .reference_profiles
        .execute()
        .await
        .map(ProfileListDto::from)
        .map_err(ErrorDto::from)
}

/// `detect_profiles` — probe each candidate profile's detection command and
/// report which CLIs are installed (✓/✗).
///
/// # Errors
/// Returns an [`ErrorDto`] (detection failures degrade to `available: false`).
#[tauri::command]
pub async fn detect_profiles(
    request: DetectProfilesRequestDto,
    state: State<'_, AppState>,
) -> Result<DetectProfilesResponseDto, ErrorDto> {
    state
        .detect_profiles
        .execute(request.into())
        .await
        .map(DetectProfilesResponseDto::from)
        .map_err(ErrorDto::from)
}

/// `list_profiles` — list the configured profiles.
///
/// # Errors
/// Returns an [`ErrorDto`] (`STORE` on profiles I/O failure).
#[tauri::command]
pub async fn list_profiles(state: State<'_, AppState>) -> Result<ProfileListDto, ErrorDto> {
    state
        .list_profiles
        .execute()
        .await
        .map(ProfileListDto::from)
        .map_err(ErrorDto::from)
}

/// `save_profile` — create or replace (by id) a single profile.
///
/// # Errors
/// Returns an [`ErrorDto`] (`STORE` on profiles I/O failure).
#[tauri::command]
pub async fn save_profile(
    request: SaveProfileRequestDto,
    state: State<'_, AppState>,
) -> Result<ProfileDto, ErrorDto> {
    state
        .save_profile
        .execute(request.into())
        .await
        .map(ProfileDto::from)
        .map_err(ErrorDto::from)
}

/// `list_opencode_providers` — static catalogue of OpenCode cloud providers
/// (ticket #92, lot B3).
#[tauri::command]
pub async fn list_opencode_providers(
    state: State<'_, AppState>,
) -> Result<OpenCodeProviderListDto, ErrorDto> {
    Ok(state.list_opencode_providers.execute().into())
}

/// `list_claude_models` — enriched Claude model catalogue.
#[tauri::command]
pub async fn list_claude_models(
    state: State<'_, AppState>,
) -> Result<ProfileModelCatalogDto, ErrorDto> {
    Ok(state.list_claude_models.execute().await.into())
}

/// `list_codex_models` — enriched Codex model catalogue.
#[tauri::command]
pub async fn list_codex_models(
    state: State<'_, AppState>,
) -> Result<ProfileModelCatalogDto, ErrorDto> {
    Ok(state.list_codex_models.execute().await.into())
}

/// `save_opencode_provider_profile` — create or replace an OpenCode profile
/// backed by a cloud provider (ticket #92, lot B3). The literal API key is
/// sealed into the `SecretStore`, never persisted in `profiles.json`.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for an empty `providerId`/`model`,
/// `STORE` on secret or profile persistence failure).
#[tauri::command]
pub async fn save_opencode_provider_profile(
    request: SaveOpenCodeProviderProfileRequestDto,
    state: State<'_, AppState>,
) -> Result<ProfileDto, ErrorDto> {
    state
        .save_opencode_provider_profile
        .execute(request.into())
        .await
        .map(ProfileDto::from)
        .map_err(ErrorDto::from)
}

/// `clone_opencode_profile_from_seed` — create a new OpenCode profile instance
/// from the canonical `opencode-llamacpp` seed/template.
///
/// # Errors
/// Returns an [`ErrorDto`] (`STORE` on profiles I/O failure, `INVALID` for a
/// blank requested name).
#[tauri::command]
pub async fn clone_opencode_profile_from_seed(
    request: CloneOpenCodeProfileFromSeedRequestDto,
    state: State<'_, AppState>,
) -> Result<ProfileDto, ErrorDto> {
    state
        .clone_opencode_profile_from_seed
        .execute(request.into())
        .await
        .map(ProfileDto::from)
        .map_err(ErrorDto::from)
}

/// `clone_profile_from_seed` — create a new profile instance from a
/// persisted/reference seed, with optional name/model overrides.
///
/// # Errors
/// Returns an [`ErrorDto`] (`NOT_FOUND` for an unknown seed, `STORE` on profiles
/// I/O failure, `INVALID` for a blank requested name/model).
#[tauri::command]
pub async fn clone_profile_from_seed(
    request: CloneProfileFromSeedRequestDto,
    state: State<'_, AppState>,
) -> Result<ProfileDto, ErrorDto> {
    state
        .clone_profile_from_seed
        .execute(request.into())
        .await
        .map(ProfileDto::from)
        .map_err(ErrorDto::from)
}

/// `delete_profile` — delete a profile by id.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if absent,
/// `STORE` on failure).
#[tauri::command]
pub async fn delete_profile(
    profile_id: String,
    state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
    let input = parse_delete_profile(&profile_id)?;
    state
        .delete_profile
        .execute(input)
        .await
        .map_err(ErrorDto::from)
}

/// `configure_profiles` — persist the batch of chosen/edited profiles, closing
/// the first run.
///
/// The selection surface offered upstream is already restricted to selectable
/// profiles (§17.3, D7), so the wizard sends only structured-drivable profiles
/// and no arbitrary custom command. This command itself stays permissive on
/// purpose: it must keep persisting any profile shape so a project with a
/// pre-existing Gemini/Aider/custom **legacy** profile remains editable and
/// runnable (we restrict creation, not the existing).
///
/// # Errors
/// Returns an [`ErrorDto`] (`STORE` on profiles I/O failure).
#[tauri::command]
pub async fn configure_profiles(
    request: ConfigureProfilesRequestDto,
    state: State<'_, AppState>,
) -> Result<ProfileListDto, ErrorDto> {
    state
        .configure_profiles
        .execute(request.into())
        .await
        .map(ProfileListDto::from)
        .map_err(ErrorDto::from)
}

/// `list_model_servers` — list configured local model servers.
///
/// # Errors
/// Returns an [`ErrorDto`] on registry failure.
#[tauri::command]
pub async fn list_model_servers(
    state: State<'_, AppState>,
) -> Result<ModelServerConfigListDto, ErrorDto> {
    state
        .list_model_servers
        .execute()
        .await
        .map(ModelServerConfigListDto::from)
        .map_err(ErrorDto::from)
}

/// `save_model_server` — upsert a local model server config.
///
/// # Errors
/// Returns an [`ErrorDto`] on registry failure.
#[tauri::command]
pub async fn save_model_server(
    request: SaveModelServerRequestDto,
    state: State<'_, AppState>,
) -> Result<ModelServerConfigDto, ErrorDto> {
    let server_id = parse_model_server_id(&request.config.id)?;
    let existing = state
        .list_model_servers
        .execute()
        .await
        .map_err(ErrorDto::from)?
        .servers
        .into_iter()
        .map(|item| item.config)
        .find(|config| config.id == server_id);
    let input = save_model_server_input(request, existing.as_ref())?;
    state
        .save_model_server
        .execute(input)
        .await
        .map(ModelServerConfigDto::from)
        .map_err(ErrorDto::from)
}

/// `preview_model_server_command` — build the llama.cpp argv without persisting.
///
/// # Errors
/// Returns an [`ErrorDto`] when the DTO or runtime invocation is invalid.
#[tauri::command]
pub fn preview_model_server_command(
    config: ModelServerConfigDto,
) -> Result<PreviewModelServerCommandDto, ErrorDto> {
    let config = model_server_config_domain(config, None)?;
    let argv = infrastructure::LlamaCppRuntime::new()
        .build_argv(&config)
        .map_err(|err| ErrorDto {
            code: "INVALID".to_owned(),
            message: err.to_string(),
        })?;
    Ok(PreviewModelServerCommandDto {
        display: display_command(&argv.command, &argv.args),
        command: argv.command,
        args: argv.args,
    })
}

/// `delete_model_server` — delete a local model server config when unused.
///
/// # Errors
/// Returns `model_server_in_use` if any OpenCode profile still references it.
#[tauri::command]
pub async fn delete_model_server(
    server_id: String,
    state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
    let server_id = parse_model_server_id(&server_id)?;
    state
        .delete_model_server
        .execute(application::DeleteModelServerInput { server_id })
        .await
        .map_err(model_server_command_error)
}

/// `delete_model_artifact` — delete a managed downloaded model artifact while
/// keeping the local model-server config.
///
/// # Errors
/// Returns `invalid` for non-managed `localPath` sources, `model_server_in_use`
/// when a download or live agent blocks deletion, and model-server errors for
/// cache I/O failures.
#[tauri::command]
pub async fn delete_model_artifact(
    server_id: String,
    state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
    let server_id = parse_model_server_id(&server_id)?;
    state
        .delete_model_artifact
        .execute(application::DeleteModelArtifactInput { server_id })
        .await
        .map_err(model_server_command_error)
}

fn model_server_command_error(err: AppError) -> ErrorDto {
    match err {
        AppError::ModelServer { code, message } => ErrorDto { code, message },
        other => ErrorDto::from(other),
    }
}

fn display_command(command: &str, args: &[String]) -> String {
    std::iter::once(command)
        .chain(args.iter().map(String::as_str))
        .map(shell_escape)
        .collect::<Vec<_>>()
        .join(" ")
}

fn shell_escape(value: &str) -> String {
    if value.is_empty() {
        return "''".to_owned();
    }
    if value
        .chars()
        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | ':' | '='))
    {
        return value.to_owned();
    }
    format!("'{}'", value.replace('\'', "'\\''"))
}

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

/// `list_embedder_profiles` — list the configured embedder profiles (empty when
/// none configured ⇒ the default `none` posture).
///
/// # Errors
/// Returns an [`ErrorDto`] (`STORE` on `embedder.json` I/O failure).
#[tauri::command]
pub async fn list_embedder_profiles(
    state: State<'_, AppState>,
) -> Result<EmbedderProfileListDto, ErrorDto> {
    state
        .list_embedder_profiles
        .execute()
        .await
        .map(EmbedderProfileListDto::from)
        .map_err(ErrorDto::from)
}

/// `save_embedder_profile` — create or replace (by id) a single embedder profile.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for empty id/name or zero dimension, `STORE`
/// on I/O failure).
#[tauri::command]
pub async fn save_embedder_profile(
    request: SaveEmbedderProfileRequestDto,
    state: State<'_, AppState>,
) -> Result<EmbedderProfileDto, ErrorDto> {
    state
        .save_embedder_profile
        .execute(request.into())
        .await
        .map(EmbedderProfileDto::from)
        .map_err(ErrorDto::from)
}

/// `delete_embedder_profile` — delete an embedder profile by id.
///
/// # Errors
/// Returns an [`ErrorDto`] (`NOT_FOUND` if absent, `STORE` on I/O failure).
#[tauri::command]
pub async fn delete_embedder_profile(
    embedder_id: String,
    state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
    state
        .delete_embedder_profile
        .execute(DeleteEmbedderProfileInput { id: embedder_id })
        .await
        .map_err(ErrorDto::from)
}

/// `describe_embedder_engines` — describe the engines available to the
/// "configure an embedder?" UI: the recommended ONNX catalogue, a best-effort
/// snapshot of the local environment, and which strategies are compiled in.
///
/// # Errors
/// Returns an [`ErrorDto`] — in practice never (the environment probe is best-effort).
#[tauri::command]
pub async fn describe_embedder_engines(
    state: State<'_, AppState>,
) -> Result<EmbedderEnginesDto, ErrorDto> {
    state
        .describe_embedder_engines
        .execute()
        .await
        .map(EmbedderEnginesDto::from)
        .map_err(ErrorDto::from)
}

/// `dismiss_embedder_suggestion` — persist the user's response to the one-time
/// embedder suggestion (LOT C3 — §14.5.5): `later` (re-proposable next session) or
/// `never` (silenced for good). Resolves the project root from `project_id`.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the project
/// is unknown, `STORE` on `.embedder-prompt.json` I/O failure).
#[tauri::command]
pub async fn dismiss_embedder_suggestion(
    request: DismissEmbedderSuggestionRequestDto,
    state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
    let project = resolve_project(&request.project_id, &state).await?;
    state
        .dismiss_embedder_suggestion
        .execute(request.into_input(project.root))
        .await
        .map_err(ErrorDto::from)
}

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

/// Resolves a [`domain::Project`] by id, mapping `StoreError` → `AppError` → `ErrorDto`.
async fn resolve_project(
    project_id: &str,
    state: &State<'_, AppState>,
) -> Result<domain::Project, ErrorDto> {
    let id = parse_project_id(project_id)?;
    state
        .project_store
        .load_project(id)
        .await
        .map_err(|e| ErrorDto::from(AppError::from(e)))
}

/// `create_agent` — create a new project agent from scratch.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a bad id/name, `NOT_FOUND` if the
/// project is unknown, `STORE` on I/O failure).
#[tauri::command]
pub async fn create_agent(
    request: CreateAgentRequestDto,
    state: State<'_, AppState>,
) -> Result<AgentDto, ErrorDto> {
    let project = resolve_project(&request.project_id, &state).await?;
    let profile_id = parse_profile_id(&request.profile_id)?;
    state
        .create_agent
        .execute(CreateAgentInput {
            project,
            name: request.name,
            profile_id,
            initial_content: request.initial_content,
        })
        .await
        .map(AgentDto::from)
        .map_err(ErrorDto::from)
}

/// `list_agents` — list the agents of a project.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// project is unknown, `STORE` on I/O failure).
#[tauri::command]
pub async fn list_agents(
    project_id: String,
    state: State<'_, AppState>,
) -> Result<AgentListDto, ErrorDto> {
    let project = resolve_project(&project_id, &state).await?;
    state
        .list_agents
        .execute(ListAgentsInput { project })
        .await
        .map(AgentListDto::from)
        .map_err(ErrorDto::from)
}

/// `get_project_work_state` — read-only live/busy state for manifest agents.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// project is unknown, `STORE` on manifest I/O failure).
#[tauri::command]
pub async fn get_project_work_state(
    project_id: String,
    state: State<'_, AppState>,
) -> Result<ProjectWorkStateDto, ErrorDto> {
    let project = resolve_project(&project_id, &state).await?;
    state
        .get_project_work_state
        .execute(GetProjectWorkStateInput { project })
        .await
        .map(ProjectWorkStateDto::from)
        .map_err(ErrorDto::from)
}

/// `get_app_exit_work_guard_state` — aggregate active work across all open projects.
///
/// # Errors
/// Returns an [`ErrorDto`] if an open project or its work-state read model cannot be read.
#[tauri::command]
pub async fn get_app_exit_work_guard_state(
    app: AppHandle,
) -> Result<AppExitWorkGuardStateDto, ErrorDto> {
    crate::read_app_exit_work_guard_state(&app)
        .await
        .map(AppExitWorkGuardStateDto::from)
        .map_err(ErrorDto::from)
}

/// `confirm_app_exit` — bypass the close guard once and request main-window shutdown.
///
/// # Errors
/// Returns an [`ErrorDto`] if the main window cannot be closed programmatically.
#[tauri::command]
pub async fn confirm_app_exit(app: AppHandle) -> Result<(), ErrorDto> {
    crate::confirm_next_main_window_close();
    if let Some(window) = app.get_webview_window("main") {
        window.close().map_err(|err| ErrorDto {
            code: "INTERNAL".to_owned(),
            message: format!("failed to close main window: {err}"),
        })?;
    } else {
        crate::shutdown_app_after_confirm(&app);
        app.exit(0);
    }
    Ok(())
}

/// `read_conversation_page` — human, paginated read of a conversation's **full**
/// transcript (lot LS6). Archive-aware (segments + active), text never truncated.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed project/conversation id,
/// `NOT_FOUND` if the project is unknown, `STORE` on log I/O failure).
#[tauri::command]
pub async fn read_conversation_page(
    request: ReadConversationPageRequestDto,
    state: State<'_, AppState>,
) -> Result<TurnPageDto, ErrorDto> {
    let project = resolve_project(&request.project_id, &state).await?;
    let conversation = uuid::Uuid::parse_str(&request.conversation_id)
        .map(domain::ConversationId::from_uuid)
        .map_err(|_| ErrorDto {
            code: "INVALID".to_owned(),
            message: format!("invalid conversation id: {}", request.conversation_id),
        })?;
    let cursor = request.cursor();
    let limit = request.limit.unwrap_or(0);
    state
        .read_conversation_page
        .execute(ReadConversationPageInput {
            project_root: project.root,
            conversation,
            cursor,
            limit,
        })
        .await
        .map(TurnPageDto::from)
        .map_err(ErrorDto::from)
}

/// `list_live_agents` — list every agent that currently owns a live session
/// (raw PTY **or** structured/chat) and the cell hosting each, so the UI can
/// disable an agent already running in another cell (the "one live session per
/// agent" invariant).
///
/// Reads the **aggregator** [`LiveSessions::live_agents`], the single source of
/// truth for liveness across both registries (PTY + structured). Reading only
/// `terminal_sessions` here was blind to structured chat agents, so a live chat
/// agent would not be disabled in the UI and could be relaunched elsewhere
/// (cadrage v5 §3.3, Trou B). `LiveSessions` is built on the fly from the two
/// `Arc` registries already held by [`AppState`]; the aggregation logic itself
/// is not duplicated.
///
/// `project_id` scopes the process-wide runtime registry before serialization so two
/// open projects carrying the same persisted `AgentId` cannot see each other's live
/// session.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed project id).
#[tauri::command]
pub fn list_live_agents(
    project_id: String,
    state: State<'_, AppState>,
) -> Result<LiveAgentListDto, ErrorDto> {
    // Validate the id shape for a consistent contract, even though the registry
    // is not project-scoped yet.
    let project_id = parse_project_id(&project_id)?;
    let live = LiveSessions::new(
        std::sync::Arc::clone(&state.terminal_sessions),
        std::sync::Arc::clone(&state.structured_sessions),
    );
    Ok(LiveAgentListDto::from_snapshots(
        live.live_agent_snapshots()
            .into_iter()
            .filter(|snapshot| snapshot.project_id == project_id)
            .collect(),
    ))
}

/// `attach_live_agent` — rebind an already-running agent session to a visible
/// layout cell without respawning the CLI process.
///
/// This is the backend side of "a cell is a view": a closed cell can leave an
/// agent running in the background, and opening the agent in a new cell updates
/// the session's host node while preserving the PTY/session/scrollback.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for malformed ids, `NOT_FOUND` if the
/// project/agent/live session is unknown).
#[tauri::command]
pub async fn attach_live_agent(
    request: AttachLiveAgentRequestDto,
    state: State<'_, AppState>,
) -> Result<AttachLiveAgentResponseDto, ErrorDto> {
    let project = resolve_project(&request.project_id, &state).await?;
    let agent_id = parse_agent_id(&request.agent_id)?;
    let node_id = parse_node_id(&request.node_id)?;
    state
        .attach_live_agent
        .execute(AttachLiveAgentInput {
            project,
            agent_id,
            node_id,
        })
        .map(AttachLiveAgentResponseDto::from)
        .map_err(ErrorDto::from)
}

/// `stop_live_agent` — tear down an already-running agent's live session by agent
/// id, polymorphically (PTY kill or structured shutdown), without removing the
/// agent, its tickets, conversation summary or handoff.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for malformed ids, `NOT_FOUND` if the
/// project/agent is unknown or the agent has no live session, `PROCESS` on a
/// kill/shutdown failure).
#[tauri::command]
pub async fn stop_live_agent(
    request: StopLiveAgentRequestDto,
    state: State<'_, AppState>,
) -> Result<StopLiveAgentResponseDto, ErrorDto> {
    let project = resolve_project(&request.project_id, &state).await?;
    let agent_id = parse_agent_id(&request.agent_id)?;
    let dependencies = state
        .orchestrator_service
        .active_wait_dependencies(agent_id);
    // Backstop no-reply : arrêter l'observateur de fin de tour de l'agent (le handle est
    // droppé ⇒ polling stoppé) avant de démonter sa session.
    state.stop_turn_watch(project.id, agent_id);
    for dependency in dependencies {
        state.stop_turn_watch(project.id, dependency);
    }
    state
        .stop_live_agent
        .execute(StopLiveAgentInput { project, agent_id })
        .await
        .map(StopLiveAgentResponseDto::from)
        .map_err(ErrorDto::from)
}

/// `read_agent_context` — read an agent's Markdown context.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// project or agent is unknown, `STORE` on I/O failure).
#[tauri::command]
pub async fn read_agent_context(
    project_id: String,
    agent_id: String,
    state: State<'_, AppState>,
) -> Result<ReadAgentContextResponseDto, ErrorDto> {
    let project = resolve_project(&project_id, &state).await?;
    let agent_id = parse_agent_id(&agent_id)?;
    state
        .read_agent_context
        .execute(ReadAgentContextInput { project, agent_id })
        .await
        .map(ReadAgentContextResponseDto::from)
        .map_err(ErrorDto::from)
}

/// `update_agent_context` — overwrite an agent's Markdown context.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// project or agent is unknown, `STORE` on I/O failure).
#[tauri::command]
pub async fn update_agent_context(
    request: UpdateAgentContextRequestDto,
    state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
    let project = resolve_project(&request.project_id, &state).await?;
    let agent_id = parse_agent_id(&request.agent_id)?;
    state
        .update_agent_context
        .execute(UpdateAgentContextInput {
            project,
            agent_id,
            content: request.content,
        })
        .await
        .map_err(ErrorDto::from)
}

/// `delete_agent` — remove an agent from the project manifest.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// project or agent is unknown, `STORE` on I/O failure).
#[tauri::command]
pub async fn delete_agent(
    project_id: String,
    agent_id: String,
    state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
    let project = resolve_project(&project_id, &state).await?;
    let agent_id = parse_agent_id(&agent_id)?;
    state
        .delete_agent
        .execute(DeleteAgentInput { project, agent_id })
        .await
        .map_err(ErrorDto::from)
}

/// `inspect_conversation` — best-effort enriched details (last topic + token
/// indicator) for a resume popup (T7).
///
/// Best-effort by contract: a missing/unsupported inspector or a missing
/// transcript yields **empty details** (absent `lastTopic`/`tokenCount`), never
/// an error. Only a genuine store failure (resolving the agent/profile) errors.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// project, agent or profile is unknown, `STORE` on a manifest/profile failure).
#[tauri::command]
pub async fn inspect_conversation(
    request: InspectConversationRequestDto,
    state: State<'_, AppState>,
) -> Result<ConversationDetailsDto, ErrorDto> {
    let project = resolve_project(&request.project_id, &state).await?;
    let agent_id = parse_agent_id(&request.agent_id)?;
    state
        .inspect_conversation
        .execute(InspectConversationInput {
            project,
            agent_id,
            conversation_id: request.conversation_id,
        })
        .await
        .map(ConversationDetailsDto::from)
        .map_err(ErrorDto::from)
}

/// `launch_agent` — spawn an agent's CLI in a PTY and wire its byte stream to
/// the frontend via a [`Channel`].
///
/// Mirrors `open_terminal`: execute the use case (spawn + register session),
/// register the xterm channel in the [`PtyBridge`], then pump PTY output to
/// that channel on a dedicated OS thread.
///
/// Returns the [`TerminalSessionDto`] (its `sessionId` is what
/// `write`/`resize`/`close` reference).
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a bad id/size, `NOT_FOUND` if the
/// project, agent or profile is unknown, `PROCESS` if the PTY fails to spawn).
#[tauri::command]
pub async fn launch_agent(
    request: LaunchAgentRequestDto,
    on_output: Channel<PtyChunk>,
    state: State<'_, AppState>,
) -> Result<TerminalSessionDto, ErrorDto> {
    let project = resolve_project(&request.project_id, &state).await?;
    let project_id = project.id;
    let agent_id = parse_agent_id(&request.agent_id)?;
    // The hosting cell drives the singleton-invariant guard. Parse it when the
    // frontend supplies one; absent ⇒ `None` (a fresh node is minted, and an
    // already-live agent is refused).
    let node_id = request.node_id.as_deref().map(parse_node_id).transpose()?;

    // Compose the MCP runtime (M5d): inject the OS/runtime facts the application
    // layer cannot compute. The endpoint comes from the **single source of truth**
    // (`mcp_endpoint`), the same value `ensure_mcp_server` binds the listener on
    // (cadrage v5 §2). The `--project` is the hyphen-free 32-hex `simple` form the
    // M5c handshake guard (`serve_peer`) compares against; the `--requester` is the
    // launching agent's id. A missing executable path (should not happen) degrades
    // to no runtime ⇒ apply_mcp_config writes the minimal declaration.
    // L'exe vient de `idea_exe_path()` (privilégie `$APPIMAGE`, sinon `current_exe`)
    // pour que chemin GUI et chemin `ask` écrivent un `command` identique et stable.
    let mcp_runtime = crate::mcp_endpoint::idea_exe_path().map(|exe| McpRuntime {
        exe,
        endpoint: crate::mcp_endpoint::mcp_endpoint(&project.id)
            .as_cli_arg()
            .to_owned(),
        project_id: project.id.as_uuid().simple().to_string(),
        requester: agent_id.to_string(),
    });

    // Functional migration seam: before any launch/reattach/idempotent early-return,
    // repair the target Claude run dir so stale `.mcp.json` / `.claude/settings.local.json`
    // artefacts from previous AppImages do not survive into this activation.
    state.reconcile_claude_run_dirs(&project).await;

    // Session-limit resume context (LS7, §21.5): record the Project + cell size keyed by
    // agent so an auto-resume (which only carries agent/node/conversation) can recompose a
    // full `LaunchAgentInput`. Cloned here — `project` is moved into the launch below.
    let resume_project = project.clone();
    // Lot LS6 — project root captured before `project` moves, for the off-hot-path log
    // rotation triggered at thread (re)open below.
    let rotation_root = project.root.clone();
    // Backstop no-reply — project root captured before `project` moves, to arm the
    // end-of-turn transcript watcher for the launched agent (cf. `AppState::arm_turn_watch`).
    let watch_root = project.root.clone();

    let output = state
        .launch_agent
        .execute(LaunchAgentInput {
            project,
            agent_id,
            rows: request.rows,
            cols: request.cols,
            node_id,
            // Resume id is a property of the hosting cell; the frontend passes the
            // leaf's current conversation id here.
            conversation_id: request.conversation_id.clone(),
            mcp_runtime,
            allow_structured_alongside_pty: false,
        })
        .await
        .map_err(ErrorDto::from)?;

    if let Ok(mut contexts) = state.resume_contexts.lock() {
        contexts.insert(
            domain::RuntimeAgentKey::new(project_id, agent_id),
            crate::state::ResumeContext {
                project: resume_project,
                rows: request.rows,
                cols: request.cols,
            },
        );
    }

    // Backstop no-reply : armer l'observateur de fin de tour transcript pour la cible si
    // son profil est supporté (Claude). Idempotent par remplacement à chaque (re)lancement
    // effectif (profil résolu présent) ; no-op sur reattach (profil `None`) — le handle
    // posé au 1er lancement persiste. cwd = run dir isolé de l'agent.
    if let Some(profile) = output.profile.as_ref() {
        state.arm_turn_watch(
            project_id,
            &watch_root,
            agent_id,
            profile,
            output.assigned_conversation_id.clone(),
        );
    }

    // Lot LS6 — rotation **best-effort** du log, déclenchée à la (re)ouverture du fil et
    // **hors chemin chaud** : détachée (`tokio::spawn`) pour n'ajouter aucune latence au
    // launch, erreurs **avalées** (la rotation ne doit jamais casser une reprise). Un
    // `append` ne déclenche JAMAIS la rotation. Skippée si la cellule ne porte pas une
    // `conversation_id` UUID (rien à roter).
    if let Some(conversation) = request
        .conversation_id
        .as_deref()
        .and_then(|raw| uuid::Uuid::parse_str(raw).ok())
        .map(domain::ConversationId::from_uuid)
    {
        let rotate = std::sync::Arc::clone(&state.rotate_conversation_log);
        tokio::spawn(async move {
            let _ = rotate
                .execute(RotateConversationLogInput {
                    project_root: rotation_root,
                    conversation,
                })
                .await;
        });
    }

    let session_id = output.session.id;
    // Host cell of the freshly (re)launched session — the pivot the level-2 tap reports
    // to the service alongside the agent.
    let host_node_id = output.session.node_id;

    // §17.4/§17.6: a structured launch routes to an `AgentSession`, registered
    // **only** in `StructuredSessions` — its id is never a live PTY. Such a cell is
    // a chat view driven by `agent_send`/`reattach_agent_chat`, not by xterm bytes,
    // so we must NOT wire it to the PTY bridge. Doing so would call
    // `subscribe_output` with an id the PTY adapter has never seen → `NotFound`
    // ("process error: pty handle not found"), failing every structured agent
    // launch. Only the raw-PTY path (`structured: None`) gets the byte pump.
    if output.structured.is_none() {
        // Register the xterm output channel for this session.
        let gen = state.pty_bridge.register(session_id, on_output);

        // Level-2 session-limit detector (§21, niveau 2 — PTY/TUI sans adapter
        // structuré). Selection rule (§21.10-4) is the single infra source `applies`:
        // arm a parser **only** for a profile without a structured adapter that declares
        // a `rate_limit_pattern` (anti-double-détection avec le niveau 1). A `None`
        // profile (reattach/idempotent) or an invalid regex ⇒ no parser, jamais de panique.
        let rate_limit_parser = output
            .profile
            .as_ref()
            .filter(|p| infrastructure::ratelimit::applies(p))
            .and_then(|p| p.rate_limit_pattern.as_ref())
            .and_then(infrastructure::RateLimitParser::new);

        // Subscribe to the PTY's byte stream and pump it to the channel.
        // The stream is a blocking iterator; it runs on a dedicated OS thread and
        // ends when the PTY hits EOF or this attach is superseded.
        let handle = PtyHandle { session_id };
        match state.pty_port.subscribe_output(&handle) {
            Ok(stream) => {
                let bridge: std::sync::Arc<PtyBridge> = std::sync::Arc::clone(&state.pty_bridge);
                // Captured for the level-2 tap (moved into the pump thread).
                let service = std::sync::Arc::clone(&state.session_limit_service);
                let detect_clock = std::sync::Arc::new(infrastructure::SystemClock::new());
                let conversation_id = request.conversation_id.clone();
                std::thread::spawn(move || {
                    for chunk in stream {
                        // §21.10-4 tap (best-effort par fragment) : avant de consommer le
                        // fragment, on cherche le motif de limite. Une détection arme la
                        // reprise via le service (détecter→planifier). Dormant si pas de
                        // parser. La fragmentation PTV (motif coupé entre deux fragments)
                        // est un raté best-effort connu pour LS7.
                        if let Some(parser) = &rate_limit_parser {
                            let text = String::from_utf8_lossy(&chunk);
                            if let Some(limit) = parser
                                .detect(&text, domain::ports::Clock::now_millis(&*detect_clock))
                            {
                                service.on_rate_limited(
                                    project_id,
                                    agent_id,
                                    host_node_id,
                                    conversation_id.clone(),
                                    limit.resets_at_ms,
                                );
                            }
                        }
                        if !bridge.send_output(&session_id, chunk) {
                            break;
                        }
                    }
                    bridge.unregister_if(&session_id, gen);
                });
            }
            Err(e) => {
                state.pty_bridge.unregister(&session_id);
                return Err(ErrorDto::from(AppError::from(e)));
            }
        }
    }

    Ok(TerminalSessionDto::from(output))
}

/// `change_agent_profile` — hot-swap an agent's runtime profile (§15.1).
///
/// Mutates the profile in the manifest, clears the now-foreign conversation id on
/// every persisted layout cell hosting the agent, and — if the agent is live —
/// kills its PTY and relaunches the session in the same cell with the new engine.
/// Announces [`DomainEvent::AgentProfileChanged`].
///
/// Returns the mutated [`AgentDto`] and the relaunched [`TerminalSessionDto`] when
/// a live session was hot-swapped (absent otherwise).
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for malformed ids, `NOT_FOUND` if the
/// project, agent or target profile is unknown, `STORE`/`FILESYSTEM`/`PROCESS` on
/// the respective port failures).
#[tauri::command]
pub async fn change_agent_profile(
    request: ChangeAgentProfileRequestDto,
    state: State<'_, AppState>,
) -> Result<ChangeAgentProfileDto, ErrorDto> {
    let project = resolve_project(&request.project_id, &state).await?;
    let agent_id = parse_agent_id(&request.agent_id)?;
    let profile_id = parse_profile_id(&request.profile_id)?;
    state
        .change_agent_profile
        .execute(ChangeAgentProfileInput {
            project,
            agent_id,
            profile_id,
            rows: request.rows,
            cols: request.cols,
        })
        .await
        .map(ChangeAgentProfileDto::from)
        .map_err(ErrorDto::from)
}

/// `agent_send` — send a prompt to a live **structured** (chat) session and pump
/// the turn's reply events to the frontend over `on_reply` (ARCHITECTURE §17.7).
///
/// Twin of the PTY output pump: resolve the live [`AgentSession`] from the
/// structured registry, open the turn stream (`session.send`), then drain that
/// blocking [`ReplyStream`] on a dedicated OS thread, mapping each
/// [`ReplyEvent`](domain::ports::ReplyEvent) to a [`ReplyChunk`] and forwarding it
/// through the [`ChatBridge`]. The turn ends deterministically at
/// [`ReplyChunk::Final`]; the stream is bounded and closes right after.
///
/// The channel is (re-)registered each call, bumping the bridge **generation** so
/// a previous attach's pump can no longer deliver (generation supersede): only the
/// current generation's channel receives chunks, never a double emission.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if no live
/// structured session owns the id, `PROCESS` if the turn fails to start).
#[tauri::command]
pub async fn agent_send(
    session_id: String,
    prompt: String,
    on_reply: Channel<ReplyChunk>,
    state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
    let sid = parse_session_id(&session_id)?;

    // Resolve the live structured session (the registry is the single source of
    // truth for liveness — no adapter is constructed here, §17.8 rule D).
    let session = state
        .structured_sessions
        .session(&sid)
        .ok_or_else(|| ErrorDto::from(AppError::NotFound(format!("structured session {sid}"))))?;

    // (Re-)register the reply channel; bumps the generation so an earlier attach's
    // pump (if any) is superseded and stops delivering to its stale channel.
    let gen = state.chat_bridge.register(sid, on_reply);

    // Open the turn stream. A start failure leaves the just-registered channel in
    // place (the cell stays attached, ready for a retry) — mirrors the PTY pump,
    // which only unregisters on a hard subscribe failure; here the session is
    // still live, so we keep the attach and surface the error.
    let stream = session
        .send(&prompt)
        .await
        .map_err(|e| ErrorDto::from(AppError::from(e)))?;

    // Drain the blocking reply iterator on a dedicated OS thread (the stream is a
    // synchronous `Iterator`, exactly like the PTY byte stream). It runs to the
    // `Final` event (or stream end / superseded channel), then detaches *only its
    // own* generation so a concurrent re-attach is never torn down.
    let bridge = std::sync::Arc::clone(&state.chat_bridge);
    // Level-1 session-limit tap (§21, niveau 1 — structuré). Resolve the agent, host
    // cell and engine conversation once from the live registry; a `RateLimited` turn
    // event then feeds the service (détecter→planifier). Without a conversation id the
    // service must not pretend an automatic resume is armed.
    let service = std::sync::Arc::clone(&state.session_limit_service);
    let meta = state.structured_sessions.meta_for_session(&sid);
    std::thread::spawn(move || {
        let mut terminal_visible = false;
        for event in stream {
            // Non-terminal, sans contenu chat : un signal de limite alimente le service
            // (le badge UI vient du bus `AgentRateLimited`, pas du flux chat) puis on
            // continue à drainer comme pour un battement.
            if let domain::ports::ReplyEvent::RateLimited { resets_at_ms } = &event {
                if let Some((project_id, agent_id, node_id, conversation_id)) = &meta {
                    let (conversation_id, resets_at_ms) = match (conversation_id, resets_at_ms) {
                        (Some(conversation_id), resets_at_ms) => {
                            (Some(conversation_id.clone()), *resets_at_ms)
                        }
                        (None, None) => (None, None),
                        // Reset connu mais conversation moteur absente : ne pas armer
                        // une reprise non reprenable ; surfacer le fallback humain.
                        (None, Some(_)) => (None, None),
                    };
                    service.on_rate_limited(
                        *project_id,
                        *agent_id,
                        *node_id,
                        conversation_id,
                        resets_at_ms,
                    );
                }
            }
            // Heartbeats carry no chat content (readiness/heartbeat lot 1) ⇒ no wire
            // chunk; skip them while still draining so the turn runs to its `Final`.
            let Some(chunk) = crate::chat::chunk_from_event(event) else {
                continue;
            };
            if matches!(chunk, ReplyChunk::Final { .. } | ReplyChunk::Error { .. }) {
                terminal_visible = true;
            }
            // `send_output` always records into the conversation scrollback; the
            // boolean only reflects live delivery. If the view navigated away
            // (no channel at this generation) we keep draining so the turn still
            // completes and the scrollback stays whole for the next re-attach.
            let _ = bridge.send_output(&sid, chunk);
        }
        if !terminal_visible {
            let _ = bridge.send_output(
                &sid,
                ReplyChunk::Error {
                    message: "Le modèle n'a renvoyé aucune réponse.".to_owned(),
                },
            );
        }
        bridge.detach_if(&sid, gen);
    });

    Ok(())
}

/// `cancel_resume` — annule la **reprise automatique** armée pour un agent limité
/// (ARCHITECTURE §21.1-4, fenêtre annulable).
///
/// Délègue à [`SessionLimitService::cancel_resume`](application::SessionLimitService::cancel_resume) :
/// désarme le réveil et, **seulement si** l'annulation a réussi (le réveil n'avait pas
/// encore tiré), publie `AgentResumeCancelled`. Renvoie `true` ssi une reprise a
/// effectivement été annulée (`false` si aucune n'était armée, ou si elle venait de
/// tirer — auquel cas la reprise suit son cours).
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed agent id).
#[tauri::command]
pub async fn cancel_resume(agent_id: String, state: State<'_, AppState>) -> Result<bool, ErrorDto> {
    let id = parse_agent_id(&agent_id)?;
    Ok(state.session_limit_service.cancel_resume(id))
}

/// `set_resume_at` — **filet humain niveau 3** (ARCHITECTURE §21.1) : l'utilisateur a
/// saisi l'heure de reset d'un agent en limite **suspectée** (rien n'a matché
/// automatiquement). Arme la **même** reprise annulable que les niveaux 1/2.
///
/// Le front ne dispose que de l'`agent_id` ; on résout côté backend :
/// - `node_id` : la cellule vivante hébergeant l'agent, cherchée dans la registry
///   structurée puis dans la registry terminal ([`StructuredSessions::node_for_agent`]
///   / [`TerminalSessions::node_for_agent`]). Sans cellule vivante, la saisie n'a pas de
///   cible ⇒ `NOT_FOUND`.
/// - `conversation_id` : best-effort via la session structurée de l'agent
///   ([`AgentSession::conversation_id`]) ; `None` toléré (reprise en mode dégradé).
///
/// Délègue ensuite à
/// [`SessionLimitService::confirm_human_resume`](application::SessionLimitService::confirm_human_resume),
/// qui réémet la paire `AgentRateLimited{Some}` + `AgentResumeScheduled` déjà relayée au
/// front. Aucun nouvel événement.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed agent id, `NOT_FOUND` if no live
/// cell hosts the agent).
#[tauri::command]
pub async fn set_resume_at(
    agent_id: String,
    resets_at_ms: i64,
    state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
    let id = parse_agent_id(&agent_id)?;

    // Résolution agent→(projet, cellule) : la registry des sessions vivantes est la
    // source de vérité. Sans `project_id` dans le DTO historique, on exige un match
    // unique pour éviter d'armer la reprise d'un homonyme dans le mauvais projet.
    let live = LiveSessions::new(
        std::sync::Arc::clone(&state.terminal_sessions),
        std::sync::Arc::clone(&state.structured_sessions),
    );
    let matches = live
        .live_agent_snapshots()
        .into_iter()
        .filter(|snapshot| snapshot.agent_id == id)
        .collect::<Vec<_>>();
    let snapshot = match matches.as_slice() {
        [snapshot] => snapshot,
        [] => {
            return Err(ErrorDto::from(AppError::NotFound(format!(
                "aucune cellule vivante pour l'agent {id}"
            ))));
        }
        _ => {
            return Err(ErrorDto::from(AppError::Invalid(format!(
                "plusieurs projets vivants portent l'agent {id}"
            ))));
        }
    };
    let project_id = snapshot.project_id;
    let node_id = snapshot.node_id;

    // `conversation_id` best-effort : seule une session structurée vivante l'expose.
    let conversation_id = state
        .structured_sessions
        .session_for_agent_in_project(project_id, &id)
        .and_then(|s| s.conversation_id());

    state.session_limit_service.confirm_human_resume(
        project_id,
        id,
        node_id,
        conversation_id,
        resets_at_ms,
    );
    Ok(())
}

/// `interrupt_agent` — the **Interrompre** path (cadrage C4 §4.2).
///
/// Routes to [`OrchestratorService::interrupt_agent`], which `preempt`s the agent's
/// running turn (best-effort interrupt to its PTY). Not an enqueue; resolves no
/// ticket. Idempotent on an idle agent.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id or unwired mediator,
/// `NOT_FOUND` if the project or agent is unknown).
#[tauri::command]
pub async fn interrupt_agent(
    request: InterruptAgentRequestDto,
    state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
    let project = resolve_project(&request.project_id, &state).await?;
    let agent_id = parse_agent_id(&request.agent_id)?;
    state
        .orchestrator_service
        .interrupt_agent(&project, agent_id)
        .await
        .map(|_| ())
        .map_err(ErrorDto::from)
}

/// `delegation_delivered` — the frontend write-portal's **ack** (ARCHITECTURE §20.3).
///
/// Called once the cell has physically written a delegation `ticket` into the agent's
/// native PTY (text + submit sequence). Routes to the best-effort
/// [`OrchestratorService::note_delegation_delivered`] (observability/log only): it does
/// **not** change correlation — the requester's `ask` is still woken by `idea_reply`,
/// and timeouts/cycle guards are untouched. Infallible on the application side; only a
/// malformed id (project/agent/ticket) yields an `INVALID` error here.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the project is
/// unknown).
#[tauri::command]
pub async fn delegation_delivered(
    request: DeliveredDelegationRequestDto,
    state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
    let project = resolve_project(&request.project_id, &state).await?;
    let agent_id = parse_agent_id(&request.agent_id)?;
    let ticket = parse_ticket_id(&request.ticket)?;
    application::diag!(
        "[delivery] delegation_delivered command: project={} agent={agent_id} ticket={ticket}",
        project.id
    );
    state
        .orchestrator_service
        .note_delegation_delivered(&project, agent_id, ticket);
    Ok(())
}

/// `set_front_attached` — the write-portal reports whether a **frontend terminal cell**
/// is mounted for an agent (mount ⇒ `true`, unmount ⇒ `false`).
///
/// Routes to [`OrchestratorService::set_agent_front_attached`]. This is what lets the
/// mediator deliver a turn to a **headless** (background-delegated, cell-less) agent by
/// writing its PTY itself: with no mounted cell nobody consumes `DelegationReady`, so
/// the task would otherwise be lost (a delegated agent that never receives — and never
/// answers — its task). An agent **with** a cell keeps the frontend write-portal path.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID`) for a malformed agent id.
#[tauri::command]
pub async fn set_front_attached(
    request: FrontAttachedRequestDto,
    state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
    let agent_id = parse_agent_id(&request.agent_id)?;
    application::diag!(
        "[delivery] set_front_attached command: agent={agent_id} attached={}",
        request.attached
    );
    let mut matches = Vec::new();
    for project in state
        .project_store
        .list_projects()
        .await
        .map_err(|err| ErrorDto::from(application::AppError::Store(err.to_string())))?
    {
        if state
            .list_agents
            .execute(application::ListAgentsInput {
                project: project.clone(),
            })
            .await
            .map(|out| out.agents.iter().any(|agent| agent.id == agent_id))
            .unwrap_or(false)
        {
            matches.push(project);
        }
    }
    match matches.as_slice() {
        [project] => {
            state
                .orchestrator_service
                .set_agent_front_attached(project, agent_id, request.attached)
        }
        [] => application::diag!(
            "[delivery] set_front_attached ignored: agent={agent_id} not found in known projects"
        ),
        _ => application::diag!(
            "[delivery] set_front_attached ignored: ambiguous agent={agent_id} across {} projects",
            matches.len()
        ),
    }
    Ok(())
}

/// `reattach_agent_chat` — re-bind a view to a **still-living** structured session
/// without re-sending or re-spawning it (ARCHITECTURE §17.6/§17.7).
///
/// Navigation (switching layout/tab) tears the chat view down but must NOT kill
/// the backend session (the AI keeps running in the registry). When the view comes
/// back it calls this, which:
/// 1. reads the session's retained **conversation scrollback** (the chunks already
///    streamed) so the chat can repaint its prior turns,
/// 2. registers the new per-session [`Channel`] in the [`ChatBridge`], bumping the
///    generation so the previous attach's pump can no longer deliver.
///
/// No new turn is started here (a turn is driven by `agent_send`); a pump only
/// runs while a turn is in flight. Returns the scrollback; the frontend replays it
/// then receives subsequent chunks over `on_reply`.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if no live
/// structured session owns the id — the caller then falls back to a fresh launch).
#[tauri::command]
pub fn reattach_agent_chat(
    session_id: String,
    on_reply: Channel<ReplyChunk>,
    state: State<'_, AppState>,
) -> Result<ReattachChatDto, ErrorDto> {
    let sid = parse_session_id(&session_id)?;

    // A missing live session means the conversation is gone (closed/never live) —
    // surfaced as NOT_FOUND so the caller falls back to opening a fresh cell.
    if state.structured_sessions.session(&sid).is_none() {
        return Err(ErrorDto::from(AppError::NotFound(format!(
            "structured session {sid}"
        ))));
    }

    // (1) Snapshot the conversation scrollback before swapping the channel.
    let scrollback = state.chat_bridge.scrollback(&sid);

    // (2) Register the new channel, superseding any previous attach (the bumped
    // generation makes the prior pump's `detach_if` a no-op, so it can't tear down
    // this live channel — generation supersede, no double emission).
    let _gen = state.chat_bridge.register(sid, on_reply);

    Ok(ReattachChatDto {
        session_id,
        scrollback,
    })
}

/// `close_agent_session` — shut a live structured session down and tear its
/// transport (channel + conversation scrollback) down (ARCHITECTURE §17.7).
///
/// Removes the session from the structured registry (so liveness checks no longer
/// see it), `shutdown`s it polymorphically (kills the underlying process/SDK;
/// idempotent by the port contract), and unregisters it from the [`ChatBridge`].
/// The chat twin of `close_terminal`.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if no live
/// structured session owns the id, `PROCESS` if the shutdown fails).
#[tauri::command]
pub async fn close_agent_session(
    session_id: String,
    state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
    let sid = parse_session_id(&session_id)?;

    // Remove from the registry first so concurrent liveness checks stop seeing it;
    // we then own the only handle to shut down (outside the registry lock).
    let session = state
        .structured_sessions
        .remove(&sid)
        .ok_or_else(|| ErrorDto::from(AppError::NotFound(format!("structured session {sid}"))))?;

    let result = session
        .shutdown()
        .await
        .map_err(|e| ErrorDto::from(AppError::from(e)));

    // Tear down the transport regardless of the shutdown outcome (the session is
    // already out of the registry; the cell is gone).
    state.chat_bridge.unregister(&sid);

    result
}

/// `list_resumable_agents` — read-only inventory of an open project's resumable
/// agent cells (§15.2). Each entry carries the agent + its host cell, the CLI
/// conversation id to resume (absent ⇒ fresh relaunch), the `was_running` flag
/// frozen at close, and whether the profile can resume a conversation.
///
/// Best-effort by contract: an unreadable project/layout/manifest degrades to an
/// empty list, never an error. Drives the reopen panel, which reuses
/// `launch_agent` for the actual resume (no new resume command).
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// project is unknown, `STORE` on a project-registry failure).
#[tauri::command]
pub async fn list_resumable_agents(
    project_id: String,
    state: State<'_, AppState>,
) -> Result<ResumableAgentListDto, ErrorDto> {
    let project = resolve_project(&project_id, &state).await?;
    state
        .list_resumable_agents
        .execute(ListResumableAgentsInput { project })
        .await
        .map(ResumableAgentListDto::from)
        .map_err(ErrorDto::from)
}

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

/// `create_template` — create a template in the global IDE store.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for an empty name or malformed profile id,
/// `STORE` on persistence failure).
#[tauri::command]
pub async fn create_template(
    request: CreateTemplateRequestDto,
    state: State<'_, AppState>,
) -> Result<TemplateDto, ErrorDto> {
    let input = request.into_input()?;
    state
        .create_template
        .execute(input)
        .await
        .map(TemplateDto::from)
        .map_err(ErrorDto::from)
}

/// `update_template` — update a template's content (bumps version, fires
/// `TemplateUpdated` event).
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// template is unknown, `STORE` on persistence failure).
#[tauri::command]
pub async fn update_template(
    request: UpdateTemplateRequestDto,
    state: State<'_, AppState>,
) -> Result<TemplateDto, ErrorDto> {
    let input = request.into_input()?;
    state
        .update_template
        .execute(input)
        .await
        .map(TemplateDto::from)
        .map_err(ErrorDto::from)
}

/// `list_templates` — list all templates in the global IDE store.
///
/// # Errors
/// Returns an [`ErrorDto`] (`STORE` on persistence failure).
#[tauri::command]
pub async fn list_templates(state: State<'_, AppState>) -> Result<TemplateListDto, ErrorDto> {
    state
        .list_templates
        .execute()
        .await
        .map(TemplateListDto::from)
        .map_err(ErrorDto::from)
}

/// `delete_template` — remove a template from the global IDE store.
///
/// Agents previously created from it keep their `.md`; drift detection simply
/// finds nothing to compare against afterwards.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// template is unknown, `STORE` on failure).
#[tauri::command]
pub async fn delete_template(
    template_id: String,
    state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
    let id = parse_template_id(&template_id)?;
    state
        .delete_template
        .execute(DeleteTemplateInput { template_id: id })
        .await
        .map_err(ErrorDto::from)
}

/// `create_agent_from_template` — instantiate a project agent from a template.
///
/// Copies the template's Markdown content, links the agent origin and version,
/// and records the manifest entry.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// project or template is unknown, `STORE` on I/O failure).
#[tauri::command]
pub async fn create_agent_from_template(
    request: CreateAgentFromTemplateRequestDto,
    state: State<'_, AppState>,
) -> Result<AgentDto, ErrorDto> {
    let project = resolve_project(&request.project_id, &state).await?;
    let input = request.into_input(project)?;
    state
        .create_agent_from_template
        .execute(input)
        .await
        .map(|out| AgentDto::from_agent(out.agent))
        .map_err(ErrorDto::from)
}

/// `detect_agent_drift` — list which synchronized agents are behind their
/// template (version drift).
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// project is unknown, `STORE` on I/O failure).
#[tauri::command]
pub async fn detect_agent_drift(
    project_id: String,
    state: State<'_, AppState>,
) -> Result<AgentDriftListDto, ErrorDto> {
    let project = resolve_project(&project_id, &state).await?;
    state
        .detect_agent_drift
        .execute(DetectAgentDriftInput { project })
        .await
        .map(AgentDriftListDto::from)
        .map_err(ErrorDto::from)
}

/// `sync_agent_with_template` — apply the latest template content to a
/// synchronized agent.
///
/// Returns whether a sync was applied and the resulting version.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// project, agent or template is unknown, `STORE` on I/O failure).
#[tauri::command]
pub async fn sync_agent_with_template(
    request: SyncAgentWithTemplateRequestDto,
    state: State<'_, AppState>,
) -> Result<SyncResultDto, ErrorDto> {
    let project = resolve_project(&request.project_id, &state).await?;
    let agent_id = parse_agent_id(&request.agent_id)?;
    state
        .sync_agent_with_template
        .execute(SyncAgentWithTemplateInput { project, agent_id })
        .await
        .map(SyncResultDto::from)
        .map_err(ErrorDto::from)
}

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

/// `git_status` — report the working-tree status of a project's repository.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a bad project id or root, `GIT` if
/// the repo is missing or the operation fails).
#[tauri::command]
pub async fn git_status(
    project_id: String,
    state: State<'_, AppState>,
) -> Result<GitStatusListDto, ErrorDto> {
    let project = resolve_project(&project_id, &state).await?;
    let root = project.root.as_str().to_owned();
    state
        .git_status
        .execute(GitStatusInput { root })
        .await
        .map(GitStatusListDto::from)
        .map_err(ErrorDto::from)
}

/// `git_stage` — stage a path in a project's repository.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a bad project id or root, `GIT` on
/// failure).
#[tauri::command]
pub async fn git_stage(
    request: GitStageRequestDto,
    state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
    let project = resolve_project(&request.project_id, &state).await?;
    let root = project.root.as_str().to_owned();
    state
        .git_stage
        .execute(GitStagePathInput {
            root,
            path: request.path,
        })
        .await
        .map_err(ErrorDto::from)
}

/// `git_unstage` — unstage a path in a project's repository.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a bad project id or root, `GIT` on
/// failure).
#[tauri::command]
pub async fn git_unstage(
    request: GitStageRequestDto,
    state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
    let project = resolve_project(&request.project_id, &state).await?;
    let root = project.root.as_str().to_owned();
    state
        .git_unstage
        .execute(GitStagePathInput {
            root,
            path: request.path,
        })
        .await
        .map_err(ErrorDto::from)
}

/// `git_commit` — create a commit in a project's repository.
///
/// Announces [`DomainEvent::GitStateChanged`].
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a bad id or an empty message, `GIT`
/// on failure).
#[tauri::command]
pub async fn git_commit(
    request: GitCommitRequestDto,
    state: State<'_, AppState>,
) -> Result<GitCommitDto, ErrorDto> {
    let project = resolve_project(&request.project_id, &state).await?;
    let root = project.root.as_str().to_owned();
    state
        .git_commit
        .execute(GitCommitInput {
            project_id: project.id,
            root,
            message: request.message,
        })
        .await
        .map(GitCommitDto::from)
        .map_err(ErrorDto::from)
}

/// `git_branches` — list branches and the current one for a project's repository.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a bad project id or root, `GIT` on
/// failure).
#[tauri::command]
pub async fn git_branches(
    project_id: String,
    state: State<'_, AppState>,
) -> Result<GitBranchesDto, ErrorDto> {
    let project = resolve_project(&project_id, &state).await?;
    let root = project.root.as_str().to_owned();
    state
        .git_branches
        .execute(GitBranchesInput { root })
        .await
        .map(GitBranchesDto::from)
        .map_err(ErrorDto::from)
}

/// `git_checkout` — check out a branch in a project's repository.
///
/// Announces [`DomainEvent::GitStateChanged`].
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a bad project id or root, `GIT` on
/// failure).
#[tauri::command]
pub async fn git_checkout(
    request: GitCheckoutRequestDto,
    state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
    let project = resolve_project(&request.project_id, &state).await?;
    let root = project.root.as_str().to_owned();
    state
        .git_checkout
        .execute(GitCheckoutInput {
            project_id: project.id,
            root,
            branch: request.branch,
        })
        .await
        .map_err(ErrorDto::from)
}

/// `git_log` — return the recent commit log for a project's repository.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a bad project id or root, `GIT` on
/// failure).
#[tauri::command]
pub async fn git_log(
    project_id: String,
    limit: usize,
    state: State<'_, AppState>,
) -> Result<GitCommitListDto, ErrorDto> {
    let project = resolve_project(&project_id, &state).await?;
    let root = project.root.as_str().to_owned();
    state
        .git_log
        .execute(GitLogInput { root, limit })
        .await
        .map(GitCommitListDto::from)
        .map_err(ErrorDto::from)
}

/// `git_init` — initialise a git repository at a project's root.
///
/// Announces [`DomainEvent::GitStateChanged`].
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a bad project id or root, `GIT` on
/// failure).
#[tauri::command]
pub async fn git_init(project_id: String, state: State<'_, AppState>) -> Result<(), ErrorDto> {
    let project = resolve_project(&project_id, &state).await?;
    let root = project.root.as_str().to_owned();
    state
        .git_init
        .execute(GitInitInput {
            project_id: project.id,
            root,
        })
        .await
        .map_err(ErrorDto::from)
}

/// `git_graph` — return the commit graph for all local branches of a project.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a bad project id or root, `GIT` on
/// failure).
#[tauri::command]
pub async fn git_graph(
    project_id: String,
    limit: usize,
    state: State<'_, AppState>,
) -> Result<GraphCommitListDto, ErrorDto> {
    let project = resolve_project(&project_id, &state).await?;
    let root = project.root.as_str().to_owned();
    state
        .git_graph
        .execute(GitGraphInput { root, limit })
        .await
        .map(GraphCommitListDto::from)
        .map_err(ErrorDto::from)
}

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

use crate::dto::{parse_tab_id, MoveTabResultDto};
use application::MoveTabToNewWindowInput;

/// Event emitted for detached view-window lifecycle changes.
pub const VIEW_WINDOW_LIFECYCLE_EVENT: &str = "view-window://lifecycle";
/// Event emitted when the main-window focused project changes.
pub const FOCUSED_PROJECT_CHANGED_EVENT: &str = "focused-project://changed";

/// Response returned by `open_view_window`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenViewWindowResponseDto {
    /// Stable Tauri window label for this panel.
    pub label: String,
    /// App URL loaded by the panel-only window.
    pub url: String,
    /// Whether the command reused and focused an existing window.
    pub already_open: bool,
}

/// Response returned by `close_view_window`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CloseViewWindowResponseDto {
    /// Stable Tauri window label for this panel.
    pub label: String,
    /// `true` when a live window was found and close was requested.
    pub closed: bool,
}

/// Snapshot returned by `list_open_view_windows`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ViewWindowSnapshot {
    /// Panel id rendered by the detached window.
    pub panel: String,
    /// Stable Tauri window label.
    pub label: String,
    /// Whether Tauri currently reports the OS window as visible.
    pub visible: bool,
}

/// Request accepted by `open_plugin_layout_window`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenPluginLayoutWindowRequestDto {
    /// Provider plugin id.
    pub plugin_id: String,
    /// Layout type declared by the provider plugin.
    pub layout_type: String,
    /// Opaque plugin-owned initial/window state.
    #[serde(default)]
    pub state: serde_json::Value,
}

/// Response returned by `open_plugin_layout_window`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenPluginLayoutWindowResponseDto {
    /// Stable Tauri window label for this plugin layout.
    pub label: String,
    /// App URL loaded by the plugin-layout window.
    pub url: String,
    /// Whether the command reused and focused an existing window.
    pub already_open: bool,
    /// Runtime provider display name.
    pub provider_plugin_display_name: String,
    /// Layout display label.
    pub layout_label: String,
    /// Persistable plugin layout surface.
    pub surface: PersistedPluginLayoutWindow,
}

/// Payload emitted on `view-window://lifecycle`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ViewWindowLifecycleEventDto {
    /// Lifecycle kind: `"opened"`, `"focused"` or `"closed"`.
    pub kind: &'static str,
    /// Panel id rendered by the detached window.
    pub panel: String,
    /// Stable Tauri window label.
    pub label: String,
}

fn encode_label_part(raw: &str) -> String {
    URL_SAFE_NO_PAD.encode(raw.as_bytes())
}

fn decode_label_part(raw: &str) -> Option<String> {
    String::from_utf8(URL_SAFE_NO_PAD.decode(raw).ok()?).ok()
}

/// Payload emitted on `focused-project://changed`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FocusedProjectChangedEventDto {
    /// Focused project, or `null` when no project is focused.
    pub project: Option<FocusedProjectDto>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ViewPanel {
    Projects,
    Context,
    Work,
    Tickets,
    Agents,
    Templates,
    Skills,
    Permissions,
    Memory,
    Git,
}

impl ViewPanel {
    pub(crate) fn parse(raw: &str) -> Result<Self, ErrorDto> {
        match raw {
            "projects" => Ok(Self::Projects),
            "context" => Ok(Self::Context),
            "work" => Ok(Self::Work),
            "tickets" => Ok(Self::Tickets),
            "agents" => Ok(Self::Agents),
            "templates" => Ok(Self::Templates),
            "skills" => Ok(Self::Skills),
            "permissions" => Ok(Self::Permissions),
            "memory" => Ok(Self::Memory),
            "git" => Ok(Self::Git),
            _ => Err(ErrorDto {
                code: "INVALID".to_owned(),
                message: format!("invalid view panel: {raw}"),
            }),
        }
    }

    pub(crate) const fn as_str(self) -> &'static str {
        match self {
            Self::Projects => "projects",
            Self::Context => "context",
            Self::Work => "work",
            Self::Tickets => "tickets",
            Self::Agents => "agents",
            Self::Templates => "templates",
            Self::Skills => "skills",
            Self::Permissions => "permissions",
            Self::Memory => "memory",
            Self::Git => "git",
        }
    }

    pub(crate) const fn title(self) -> &'static str {
        match self {
            Self::Projects => "Projects",
            Self::Context => "Project context",
            Self::Work => "Work state",
            Self::Tickets => "Tickets",
            Self::Agents => "Agents",
            Self::Templates => "Templates",
            Self::Skills => "Skills",
            Self::Permissions => "Permissions",
            Self::Memory => "Memory",
            Self::Git => "Git",
        }
    }
}

pub(crate) fn view_window_label(panel: ViewPanel) -> String {
    format!("view-{}", panel.as_str())
}

pub(crate) fn view_window_url(panel: ViewPanel) -> String {
    format!("index.html?panel={}", panel.as_str())
}

pub(crate) fn plugin_layout_window_label(
    plugin_id: &PluginId,
    layout_type: &PluginLayoutType,
) -> String {
    format!(
        "view-plugin-layout-{}.{}",
        encode_label_part(plugin_id.as_str()),
        encode_label_part(layout_type.as_str())
    )
}

pub(crate) fn plugin_layout_window_url(surface: &PersistedPluginLayoutWindow) -> String {
    let state = URL_SAFE_NO_PAD
        .encode(serde_json::to_vec(&surface.state).unwrap_or_else(|_| b"null".to_vec()));
    format!(
        "index.html?pluginLayout=1&pluginId={}&layoutType={}&state={}",
        encode_label_part(surface.plugin_id.as_str()),
        encode_label_part(surface.layout_type.as_str()),
        state
    )
}

pub(crate) fn plugin_layout_window_from_label(label: &str) -> Option<PersistedPluginLayoutWindow> {
    let rest = label.strip_prefix("view-plugin-layout-")?;
    let (plugin_id, layout_type) = rest.split_once('.')?;
    Some(PersistedPluginLayoutWindow {
        plugin_id: PluginId::new(decode_label_part(plugin_id)?).ok()?,
        layout_type: PluginLayoutType::new(decode_label_part(layout_type)?).ok()?,
        state: serde_json::Value::Null,
    })
}

fn view_panel_from_window_label(label: &str) -> Option<ViewPanel> {
    let rest = label.strip_prefix("view-")?;
    if let Ok(panel) = ViewPanel::parse(rest) {
        return Some(panel);
    }

    let (panel, project_id) = rest.rsplit_once('-')?;
    if project_id.len() != 32 || uuid::Uuid::parse_str(project_id).is_err() {
        return None;
    }
    ViewPanel::parse(panel).ok()
}

pub(crate) fn emit_view_window_lifecycle(
    app: &AppHandle,
    kind: &'static str,
    panel: ViewPanel,
    label: &str,
) {
    let _ = app.emit(
        VIEW_WINDOW_LIFECYCLE_EVENT,
        ViewWindowLifecycleEventDto {
            kind,
            panel: panel.as_str().to_owned(),
            label: label.to_owned(),
        },
    );
}

/// `set_focused_project` — update the backend focused-project state and notify panels.
#[tauri::command]
pub async fn set_focused_project(
    app: AppHandle,
    project: Option<FocusedProjectDto>,
    state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
    state.set_focused_project(project.clone());
    app.emit(
        FOCUSED_PROJECT_CHANGED_EVENT,
        FocusedProjectChangedEventDto { project },
    )
    .map_err(internal_window_error)
}

/// `get_focused_project` — read the backend focused-project state.
#[tauri::command]
pub async fn get_focused_project(
    state: State<'_, AppState>,
) -> Result<Option<FocusedProjectDto>, ErrorDto> {
    Ok(state.get_focused_project())
}

/// `list_open_view_windows` — return the detached panel windows currently
/// registered by Tauri.
#[tauri::command]
pub fn list_open_view_windows(app: AppHandle) -> Vec<ViewWindowSnapshot> {
    let mut windows = app
        .webview_windows()
        .into_iter()
        .filter_map(|(label, window)| {
            let panel = view_panel_from_window_label(&label)?;
            Some(ViewWindowSnapshot {
                panel: panel.as_str().to_owned(),
                label,
                visible: window.is_visible().unwrap_or(true),
            })
        })
        .collect::<Vec<_>>();
    windows.sort_by(|left, right| {
        left.panel
            .cmp(&right.panel)
            .then(left.label.cmp(&right.label))
    });
    windows
}

/// `open_view_window` — open or focus a detached OS window for one panel.
///
/// The window is a normal decorated, resizable system window. Its webview loads
/// `index.html?panel=<panel>` so the frontend can boot a panel-only shell that
/// follows the backend focused-project state.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for an unknown panel, `INTERNAL` if Tauri
/// fails to create/focus the window).
#[tauri::command]
pub async fn open_view_window(
    app: AppHandle,
    panel: String,
) -> Result<OpenViewWindowResponseDto, ErrorDto> {
    let panel = ViewPanel::parse(&panel)?;
    let label = view_window_label(panel);
    let url = view_window_url(panel);

    if let Some(window) = app.get_webview_window(&label) {
        window.show().map_err(internal_window_error)?;
        window.set_focus().map_err(internal_window_error)?;
        emit_view_window_lifecycle(&app, "focused", panel, &label);
        return Ok(OpenViewWindowResponseDto {
            label,
            url,
            already_open: true,
        });
    }

    let window = WebviewWindowBuilder::new(&app, &label, WebviewUrl::App(url.clone().into()))
        .title(format!("IdeA - {}", panel.title()))
        .inner_size(1120.0, 760.0)
        .min_inner_size(720.0, 480.0)
        .resizable(true)
        .maximizable(true)
        .minimizable(true)
        .closable(true)
        .decorations(true)
        .build()
        .map_err(internal_window_error)?;

    let event_app = app.clone();
    let event_label = label.clone();
    window.on_window_event(move |event| {
        if let WindowEvent::CloseRequested { .. } = event {
            emit_view_window_lifecycle(&event_app, "closed", panel, &event_label);
        }
    });
    emit_view_window_lifecycle(&app, "opened", panel, &label);

    Ok(OpenViewWindowResponseDto {
        label,
        url,
        already_open: false,
    })
}

/// `open_plugin_layout_window` — open or focus a detached OS window for a
/// plugin-contributed layout.
///
/// The contribution must exist in the plugin's existing `contributes.layouts`
/// manifest surface and the plugin must be runtime-active.
#[tauri::command]
pub async fn open_plugin_layout_window(
    app: AppHandle,
    input: OpenPluginLayoutWindowRequestDto,
    state: State<'_, AppState>,
) -> Result<OpenPluginLayoutWindowResponseDto, ErrorDto> {
    let plugin_id = PluginId::new(input.plugin_id).map_err(|e| ErrorDto {
        code: "INVALID".to_owned(),
        message: e.to_string(),
    })?;
    let layout_type = PluginLayoutType::new(input.layout_type).map_err(|e| ErrorDto {
        code: "INVALID".to_owned(),
        message: e.to_string(),
    })?;
    let out = state
        .open_plugin_layout_window
        .execute(OpenPluginLayoutWindowInput {
            plugin_id,
            layout_type,
            state: input.state,
        })
        .await
        .map_err(ErrorDto::from)?;
    let label = plugin_layout_window_label(&out.surface.plugin_id, &out.surface.layout_type);
    let url = plugin_layout_window_url(&out.surface);
    state.set_plugin_window_surface(label.clone(), out.surface.clone());

    if let Some(window) = app.get_webview_window(&label) {
        window.show().map_err(internal_window_error)?;
        window.set_focus().map_err(internal_window_error)?;
        return Ok(OpenPluginLayoutWindowResponseDto {
            label,
            url,
            already_open: true,
            provider_plugin_display_name: out.contribution.provider_plugin_display_name,
            layout_label: out.contribution.label,
            surface: out.surface,
        });
    }

    let window = WebviewWindowBuilder::new(&app, &label, WebviewUrl::App(url.clone().into()))
        .title(format!("IdeA - {}", out.contribution.label))
        .inner_size(1120.0, 760.0)
        .min_inner_size(720.0, 480.0)
        .resizable(true)
        .maximizable(true)
        .minimizable(true)
        .closable(true)
        .decorations(true)
        .build()
        .map_err(internal_window_error)?;

    let event_app = app.clone();
    let event_label = label.clone();
    window.on_window_event(move |event| {
        if let WindowEvent::CloseRequested { .. } = event {
            if let Some(state) = event_app.try_state::<AppState>() {
                state.clear_plugin_window_surface(&event_label);
            }
        }
    });

    Ok(OpenPluginLayoutWindowResponseDto {
        label,
        url,
        already_open: false,
        provider_plugin_display_name: out.contribution.provider_plugin_display_name,
        layout_label: out.contribution.label,
        surface: out.surface,
    })
}

/// `close_view_window` — request closing the detached OS window for one panel.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for an unknown panel, `INTERNAL` if Tauri
/// fails to close the window).
#[tauri::command]
pub async fn close_view_window(
    app: AppHandle,
    panel: String,
) -> Result<CloseViewWindowResponseDto, ErrorDto> {
    let panel = ViewPanel::parse(&panel)?;
    let label = view_window_label(panel);
    let Some(window) = app.get_webview_window(&label) else {
        return Ok(CloseViewWindowResponseDto {
            label,
            closed: false,
        });
    };

    window.close().map_err(internal_window_error)?;
    Ok(CloseViewWindowResponseDto {
        label,
        closed: true,
    })
}

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

#[cfg(test)]
mod view_window_tests {
    use super::*;

    #[test]
    fn view_window_label_and_url_are_stable() {
        let panel = ViewPanel::Tickets;

        assert_eq!(view_window_label(panel), "view-tickets");
        assert_eq!(view_window_url(panel), "index.html?panel=tickets");
    }

    #[test]
    fn view_window_rejects_unknown_panel() {
        let err = ViewPanel::parse("terminal").unwrap_err();

        assert_eq!(err.code, "INVALID");
        assert!(err.message.contains("invalid view panel: terminal"));
    }

    #[test]
    fn view_window_label_parser_accepts_stable_and_legacy_labels() {
        assert_eq!(
            view_panel_from_window_label("view-tickets")
                .unwrap()
                .as_str(),
            "tickets"
        );
        assert_eq!(
            view_panel_from_window_label("view-tickets-0000000000000000000000000000002a")
                .unwrap()
                .as_str(),
            "tickets"
        );
        assert!(view_panel_from_window_label("main").is_none());
        assert!(view_panel_from_window_label("view-unknown").is_none());
        assert!(view_panel_from_window_label("view-tickets-not-a-project").is_none());
    }

    #[test]
    fn view_window_snapshot_payload_is_camel_case() {
        let payload = ViewWindowSnapshot {
            panel: "tickets".to_owned(),
            label: "view-tickets".to_owned(),
            visible: true,
        };

        let json = serde_json::to_string(&payload).unwrap();
        assert!(json.contains("\"panel\":\"tickets\""), "json was {json}");
        assert!(
            json.contains("\"label\":\"view-tickets\""),
            "json was {json}"
        );
        assert!(json.contains("\"visible\":true"), "json was {json}");
    }

    #[test]
    fn view_window_lifecycle_payload_is_camel_case() {
        let payload = ViewWindowLifecycleEventDto {
            kind: "closed",
            panel: "tickets".to_owned(),
            label: "view-tickets-7".to_owned(),
        };

        let json = serde_json::to_string(&payload).unwrap();
        assert!(!json.contains("projectId"), "json was {json}");
        assert!(!json.contains("project_id"), "no snake_case leak: {json}");
    }

    #[test]
    fn plugin_layout_window_label_round_trips_surface_identity() {
        let plugin_id = PluginId::new("dev.idea.android-plugin").unwrap();
        let layout_type = PluginLayoutType::new("idea-android.health").unwrap();

        let label = plugin_layout_window_label(&plugin_id, &layout_type);
        let surface = plugin_layout_window_from_label(&label).unwrap();

        assert!(label.starts_with("view-plugin-layout-"));
        assert_eq!(surface.plugin_id, plugin_id);
        assert_eq!(surface.layout_type, layout_type);
        assert_eq!(surface.state, serde_json::Value::Null);
    }

    #[test]
    fn plugin_layout_window_url_carries_surface_contract() {
        let surface = PersistedPluginLayoutWindow {
            plugin_id: PluginId::new("dev.idea.android-plugin").unwrap(),
            layout_type: PluginLayoutType::new("idea-android.health").unwrap(),
            state: serde_json::json!({ "deviceId": "pixel-8" }),
        };

        let url = plugin_layout_window_url(&surface);

        assert!(url.starts_with("index.html?pluginLayout=1&"));
        assert!(url.contains("pluginId="), "url was {url}");
        assert!(url.contains("layoutType="), "url was {url}");
        assert!(url.contains("state="), "url was {url}");
    }

    #[test]
    fn plugin_layout_window_response_payload_is_camel_case() {
        let payload = OpenPluginLayoutWindowResponseDto {
            label: "view-plugin-layout-x.y".to_owned(),
            url: "index.html?pluginLayout=1".to_owned(),
            already_open: false,
            provider_plugin_display_name: "Android".to_owned(),
            layout_label: "Android Health".to_owned(),
            surface: PersistedPluginLayoutWindow {
                plugin_id: PluginId::new("dev.idea.android-plugin").unwrap(),
                layout_type: PluginLayoutType::new("idea-android.health").unwrap(),
                state: serde_json::json!({ "deviceId": "pixel-8" }),
            },
        };

        let json = serde_json::to_string(&payload).unwrap();
        assert!(json.contains("\"alreadyOpen\":false"), "json was {json}");
        assert!(
            json.contains("\"providerPluginDisplayName\":\"Android\""),
            "json was {json}"
        );
        assert!(
            json.contains("\"layoutLabel\":\"Android Health\""),
            "json was {json}"
        );
        assert!(!json.contains("provider_plugin_display_name"));
    }
}

/// `move_tab_to_new_window` — detach a tab into a brand-new OS window.
///
/// Applies the workspace topology change (the tab is *moved*, not duplicated)
/// and opens a fresh [`tauri::WebviewWindow`]. The session-state handoff to that
/// window (rendering the detached tab) is the L11 multi-window UI work; this
/// command provides the backend primitive and the new OS window.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed tab id, `NOT_FOUND` if the
/// tab is unknown to the persisted workspace, `INTERNAL` if the window fails to
/// open).
#[tauri::command]
pub async fn move_tab_to_new_window(
    app: tauri::AppHandle,
    tab_id: String,
    state: State<'_, AppState>,
) -> Result<MoveTabResultDto, ErrorDto> {
    let tid = parse_tab_id(&tab_id)?;
    let out = state
        .move_tab
        .execute(MoveTabToNewWindowInput { tab_id: tid })
        .await
        .map_err(ErrorDto::from)?;

    let label = format!("win-{}", out.new_window_id);
    tauri::WebviewWindowBuilder::new(&app, &label, tauri::WebviewUrl::App("index.html".into()))
        .title("IdeA")
        .inner_size(1280.0, 800.0)
        .build()
        .map_err(|e| ErrorDto {
            code: "INTERNAL".to_owned(),
            message: e.to_string(),
        })?;

    Ok(MoveTabResultDto::from(out))
}

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

/// `create_skill` — create a skill in its scope's store.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for an empty name/content or malformed
/// project id, `NOT_FOUND` if the project is unknown, `STORE` on failure).
#[tauri::command]
pub async fn create_skill(
    request: CreateSkillRequestDto,
    state: State<'_, AppState>,
) -> Result<SkillDto, ErrorDto> {
    let project = resolve_project(&request.project_id, &state).await?;
    state
        .create_skill
        .execute(CreateSkillInput {
            name: request.name,
            description: request.description,
            content: request.content,
            scope: request.scope,
            kind: request.kind,
            project_root: project.root,
        })
        .await
        .map(SkillDto::from)
        .map_err(ErrorDto::from)
}

/// `update_skill` — replace a skill's Markdown content.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for malformed ids or empty content,
/// `NOT_FOUND` if the project or skill is unknown, `STORE` on failure).
#[tauri::command]
pub async fn update_skill(
    request: UpdateSkillRequestDto,
    state: State<'_, AppState>,
) -> Result<SkillDto, ErrorDto> {
    let project = resolve_project(&request.project_id, &state).await?;
    let skill_id = parse_skill_id(&request.skill_id)?;
    state
        .update_skill
        .execute(UpdateSkillInput {
            scope: request.scope,
            skill_id,
            content: request.content,
            project_root: project.root,
        })
        .await
        .map(SkillDto::from)
        .map_err(ErrorDto::from)
}

/// `list_skills` — list the skills in one scope.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// project is unknown, `STORE` on failure).
#[tauri::command]
pub async fn list_skills(
    project_id: String,
    scope: SkillScope,
    state: State<'_, AppState>,
) -> Result<SkillListDto, ErrorDto> {
    let project = resolve_project(&project_id, &state).await?;
    state
        .list_skills
        .execute(ListSkillsInput {
            scope,
            project_root: project.root,
        })
        .await
        .map(SkillListDto::from)
        .map_err(ErrorDto::from)
}

/// `delete_skill` — remove a skill from its scope's store.
///
/// Agents that referenced it keep their `SkillRef`; injection simply skips the
/// now-absent skill.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for malformed ids, `NOT_FOUND` if the
/// project or skill is unknown, `STORE` on failure).
#[tauri::command]
pub async fn delete_skill(
    project_id: String,
    scope: SkillScope,
    skill_id: String,
    state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
    let project = resolve_project(&project_id, &state).await?;
    let id = parse_skill_id(&skill_id)?;
    state
        .delete_skill
        .execute(DeleteSkillInput {
            scope,
            skill_id: id,
            project_root: project.root,
        })
        .await
        .map_err(ErrorDto::from)
}

/// `assign_skill_to_agent` — record a `SkillRef` in the agent's manifest entry.
/// Idempotent.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for malformed ids, `NOT_FOUND` if the
/// project or agent is unknown, `STORE` on failure).
#[tauri::command]
pub async fn assign_skill_to_agent(
    request: AssignSkillRequestDto,
    state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
    let project = resolve_project(&request.project_id, &state).await?;
    let agent_id = parse_agent_id(&request.agent_id)?;
    let skill_id = parse_skill_id(&request.skill_id)?;
    state
        .assign_skill
        .execute(AssignSkillToAgentInput {
            project,
            agent_id,
            skill: SkillRef::new(skill_id, request.scope),
        })
        .await
        .map_err(ErrorDto::from)
}

/// `unassign_skill_from_agent` — drop a skill assignment from an agent.
/// Idempotent.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for malformed ids, `NOT_FOUND` if the
/// project or agent is unknown, `STORE` on failure).
#[tauri::command]
pub async fn unassign_skill_from_agent(
    request: UnassignSkillRequestDto,
    state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
    let project = resolve_project(&request.project_id, &state).await?;
    let agent_id = parse_agent_id(&request.agent_id)?;
    let skill_id = parse_skill_id(&request.skill_id)?;
    state
        .unassign_skill
        .execute(UnassignSkillFromAgentInput {
            project,
            agent_id,
            skill_id,
        })
        .await
        .map_err(ErrorDto::from)
}

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

/// `create_memory` — create a memory note in the project's store.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for an invalid slug/empty fields or
/// malformed project id, `NOT_FOUND` if the project is unknown, `STORE` on
/// failure).
#[tauri::command]
pub async fn create_memory(
    request: CreateMemoryRequestDto,
    state: State<'_, AppState>,
) -> Result<MemoryDto, ErrorDto> {
    let project = resolve_project(&request.project_id, &state).await?;
    state
        .create_memory
        .execute(CreateMemoryInput {
            project_root: project.root,
            name: request.name,
            description: request.description,
            r#type: request.r#type,
            content: request.content,
        })
        .await
        .map(MemoryDto::from)
        .map_err(ErrorDto::from)
}

/// `update_memory` — replace an existing memory note (re-validates invariants).
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for an invalid slug/empty fields,
/// `NOT_FOUND` if the project is unknown, `STORE` on failure).
#[tauri::command]
pub async fn update_memory(
    request: UpdateMemoryRequestDto,
    state: State<'_, AppState>,
) -> Result<MemoryDto, ErrorDto> {
    let project = resolve_project(&request.project_id, &state).await?;
    let slug = parse_memory_slug(&request.slug)?;
    state
        .update_memory
        .execute(UpdateMemoryInput {
            project_root: project.root,
            slug,
            description: request.description,
            r#type: request.r#type,
            content: request.content,
        })
        .await
        .map(MemoryDto::from)
        .map_err(ErrorDto::from)
}

/// `list_memories` — list the project's memory notes.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// project is unknown, `STORE` on failure).
#[tauri::command]
pub async fn list_memories(
    project_id: String,
    state: State<'_, AppState>,
) -> Result<MemoryListDto, ErrorDto> {
    let project = resolve_project(&project_id, &state).await?;
    state
        .list_memories
        .execute(ListMemoriesInput {
            project_root: project.root,
        })
        .await
        .map(MemoryListDto::from)
        .map_err(ErrorDto::from)
}

/// `get_memory` — read one memory note by slug.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for an invalid slug/id, `NOT_FOUND` if the
/// project or note is unknown, `STORE` on failure).
#[tauri::command]
pub async fn get_memory(
    project_id: String,
    slug: String,
    state: State<'_, AppState>,
) -> Result<MemoryDto, ErrorDto> {
    let project = resolve_project(&project_id, &state).await?;
    let slug = parse_memory_slug(&slug)?;
    state
        .get_memory
        .execute(GetMemoryInput {
            project_root: project.root,
            slug,
        })
        .await
        .map(MemoryDto::from)
        .map_err(ErrorDto::from)
}

/// `delete_memory` — remove a memory note (and its index row).
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for an invalid slug/id, `NOT_FOUND` if the
/// project or note is unknown, `STORE` on failure).
#[tauri::command]
pub async fn delete_memory(
    project_id: String,
    slug: String,
    state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
    let project = resolve_project(&project_id, &state).await?;
    let slug = parse_memory_slug(&slug)?;
    state
        .delete_memory
        .execute(DeleteMemoryInput {
            project_root: project.root,
            slug,
        })
        .await
        .map_err(ErrorDto::from)
}

/// `read_memory_index` — read the structured `MEMORY.md` index.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// project is unknown, `STORE` on failure).
#[tauri::command]
pub async fn read_memory_index(
    project_id: String,
    state: State<'_, AppState>,
) -> Result<MemoryIndexDto, ErrorDto> {
    let project = resolve_project(&project_id, &state).await?;
    state
        .read_memory_index
        .execute(ReadMemoryIndexInput {
            project_root: project.root,
        })
        .await
        .map(MemoryIndexDto::from)
        .map_err(ErrorDto::from)
}

/// `recall_memory` — recall the most relevant memory entries for a query within
/// a token budget (LOT B — §14.5.2).
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `NOT_FOUND` if the
/// project is unknown, `STORE` on failure). An empty or absent memory and a zero
/// budget both yield an empty list, not an error.
#[tauri::command]
pub async fn recall_memory(
    request: RecallMemoryRequestDto,
    state: State<'_, AppState>,
) -> Result<MemoryIndexDto, ErrorDto> {
    let project = resolve_project(&request.project_id, &state).await?;
    state
        .recall_memory
        .execute(RecallMemoryInput {
            project_root: project.root,
            text: request.text,
            token_budget: request.token_budget,
        })
        .await
        .map(MemoryIndexDto::from)
        .map_err(ErrorDto::from)
}

/// `resolve_memory_links` — resolve a note's outgoing `[[slug]]` links.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for an invalid slug/id, `NOT_FOUND` if the
/// project or source note is unknown, `STORE` on failure).
#[tauri::command]
pub async fn resolve_memory_links(
    project_id: String,
    slug: String,
    state: State<'_, AppState>,
) -> Result<MemoryLinksDto, ErrorDto> {
    let project = resolve_project(&project_id, &state).await?;
    let slug = parse_memory_slug(&slug)?;
    state
        .resolve_memory_links
        .execute(ResolveMemoryLinksInput {
            project_root: project.root,
            slug,
        })
        .await
        .map(MemoryLinksDto::from)
        .map_err(ErrorDto::from)
}

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

/// Maps a background-task port error to the frontend [`ErrorDto`] shape.
fn background_error(err: domain::ports::BackgroundTaskPortError) -> ErrorDto {
    use domain::ports::BackgroundTaskPortError as E;
    let code = match &err {
        E::NotFound => "NOT_FOUND",
        E::AlreadyExists | E::Invalid(_) => "INVALID",
        E::Runner(_) => "PROCESS",
        E::Store(_) => "STORE",
    };
    ErrorDto {
        code: code.to_owned(),
        message: err.to_string(),
    }
}

/// `spawn_background_command` — start a command-backed first-class background
/// task whose completion is delivered to the owning agent's inbox.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id/path, `PROCESS` if the
/// command cannot be spawned, `STORE` on persistence failure).
#[tauri::command]
pub async fn spawn_background_command(
    request: crate::dto::SpawnBackgroundCommandRequestDto,
    state: State<'_, AppState>,
) -> Result<BackgroundTaskDto, ErrorDto> {
    let project_id = parse_project_id(&request.project_id)?;
    let owner_agent_id = parse_agent_id(&request.owner_agent_id)?;
    let cwd = domain::project::ProjectPath::new(request.cwd.clone()).map_err(|_| ErrorDto {
        code: "INVALID".to_owned(),
        message: format!("invalid working directory: {}", request.cwd),
    })?;
    let command = domain::ports::SpawnSpec {
        command: request.command,
        args: request.args,
        cwd,
        env: request.env,
        context_plan: None,
        sandbox: None,
    };
    let wake_policy = if request.record_only {
        domain::BackgroundTaskWakePolicy::RecordOnly
    } else {
        domain::BackgroundTaskWakePolicy::WakeOwner
    };
    state
        .spawn_background_command
        .execute(application::SpawnBackgroundCommandInput {
            project_id,
            owner_agent_id,
            label: request.label,
            command,
            wake_policy,
            rendezvous: None,
            deadline_ms: request.deadline_ms,
        })
        .await
        .map(|out| BackgroundTaskDto::from(out.task))
        .map_err(ErrorDto::from)
}

/// `cancel_background_task` — request cancellation of a running background task.
///
/// The terminal `Cancelled` state is written by the completion sink; this returns
/// the task as currently persisted (absent if it no longer exists).
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `PROCESS`/`STORE` on
/// failure).
#[tauri::command]
pub async fn cancel_background_task(
    task_id: String,
    state: State<'_, AppState>,
) -> Result<Option<BackgroundTaskDto>, ErrorDto> {
    let id = parse_task_id(&task_id)?;
    state
        .cancel_background_task
        .execute(id)
        .await
        .map(|out| out.task.map(BackgroundTaskDto::from))
        .map_err(ErrorDto::from)
}

/// `retry_background_task` — re-run a terminal command task under a **new** task
/// id (the original id is never reused).
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id or a non-retryable task,
/// `NOT_FOUND` if the task is unknown, `PROCESS`/`STORE` on failure).
#[tauri::command]
pub async fn retry_background_task(
    task_id: String,
    state: State<'_, AppState>,
) -> Result<BackgroundTaskDto, ErrorDto> {
    let id = parse_task_id(&task_id)?;
    state
        .retry_background_task
        .execute(id)
        .await
        .map(|out| BackgroundTaskDto::from(out.task))
        .map_err(ErrorDto::from)
}

/// `attach_background_task` — attach a UI output subscriber to a command-backed
/// background task.
///
/// If the task is still live, the response contains PTY scrollback and a fresh
/// live subscription is pumped to `on_output`. If it is already terminal, the
/// response contains only the persisted output tail and no PTY subscription is
/// attempted.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for malformed id, `NOT_FOUND` if the task
/// has no live handle and no terminal result, `PROCESS`/`STORE` on backend
/// failure).
#[tauri::command]
pub async fn attach_background_task(
    task_id: String,
    on_output: Channel<PtyChunk>,
    state: State<'_, AppState>,
) -> Result<AttachBackgroundTaskResultDto, ErrorDto> {
    let id = parse_task_id(&task_id)?;
    let plan = state
        .background_task_attach_plan(id)
        .await
        .map_err(ErrorDto::from)?;
    let live = plan.live_handle.is_some();

    if let Some(handle) = plan.live_handle {
        match state.pty_port.subscribe_output(&handle) {
            Ok(stream) => {
                std::thread::spawn(move || {
                    let sink = TauriChannelSink::new(on_output);
                    for chunk in stream {
                        if sink.send(chunk).is_err() {
                            break;
                        }
                    }
                });
            }
            Err(e) => return Err(ErrorDto::from(AppError::from(e))),
        }
    }

    Ok(AttachBackgroundTaskResultDto {
        task_id: plan.task_id.to_string(),
        scrollback: plan.scrollback,
        live,
    })
}

/// `list_background_tasks` — read the background-task read-model for a project,
/// optionally narrowed to one owning agent.
///
/// Returns the union of the agent's open tasks and undelivered completions,
/// filtered to `projectId`. Without `agentId`, only the project's undelivered
/// completions are returned (the store cannot enumerate open tasks project-wide).
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `STORE` on failure).
#[tauri::command]
pub async fn list_background_tasks(
    project_id: String,
    agent_id: Option<String>,
    state: State<'_, AppState>,
) -> Result<Vec<BackgroundTaskDto>, ErrorDto> {
    let project_id = parse_project_id(&project_id)?;
    let agent_id = match &agent_id {
        Some(raw) => Some(parse_agent_id(raw)?),
        None => None,
    };

    let store = &state.background_task_store;
    let mut by_id: std::collections::HashMap<domain::TaskId, domain::BackgroundTask> =
        std::collections::HashMap::new();

    if let Some(agent_id) = agent_id {
        for task in store
            .list_open_for_agent(agent_id)
            .await
            .map_err(background_error)?
        {
            by_id.insert(task.id, task);
        }
    }
    for task in store
        .list_undelivered_completions()
        .await
        .map_err(background_error)?
    {
        by_id.entry(task.id).or_insert(task);
    }

    let mut tasks: Vec<domain::BackgroundTask> = by_id
        .into_values()
        .filter(|task| task.project_id == project_id)
        .filter(|task| agent_id.map_or(true, |a| task.owner_agent_id == a))
        .collect();
    tasks.sort_by_key(|task| (task.created_at_ms, task.id));
    Ok(tasks.into_iter().map(BackgroundTaskDto::from).collect())
}
