use ::time::{Date, Month, OffsetDateTime, PrimitiveDateTime, Time, UtcOffset};
use chrono::{DateTime, Local, TimeZone};
use chrono_english::{parse_date_string, Dialect};
use iced::widget::{
    button, column, container, image, markdown, row, scrollable, text, text_editor, text_input,
    Id as WidgetId, Space,
};
use iced::{
    application, time, Background, Border, Color, Element, Length, Shadow, Size, Subscription,
    Task, Theme,
};
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::{Mutex, OnceLock};
use std::time::Duration;

use crate::inbox_fsm::InboxState as InboxStatus;

const BUTTERFLY_BOT_LOGO_BYTES: &[u8] =
    include_bytes!("../assets/icons/hicolor/512x512/apps/butterfly-bot.png");

fn butterfly_bot_logo_handle() -> image::Handle {
    static HANDLE: OnceLock<image::Handle> = OnceLock::new();
    HANDLE
        .get_or_init(|| image::Handle::from_bytes(BUTTERFLY_BOT_LOGO_BYTES))
        .clone()
}

#[derive(Clone, Debug)]
pub struct IcedUiLaunchConfig {
    pub daemon_url: String,
    pub user_id: String,
    pub db_path: String,
}

#[derive(Clone, Serialize)]
struct ProcessTextRequest {
    user_id: String,
    text: String,
    prompt: Option<String>,
}

#[derive(Clone, Debug, Deserialize)]
struct DoctorCheckResponse {
    name: String,
    status: String,
    message: String,
    fix_hint: Option<String>,
}

#[derive(Clone, Debug, Deserialize)]
struct DoctorResponse {
    overall: String,
    checks: Vec<DoctorCheckResponse>,
}

#[derive(Clone, Debug, Deserialize)]
struct SecurityAuditFindingResponse {
    id: String,
    severity: String,
    status: String,
    message: String,
    fix_hint: Option<String>,
    auto_fixable: bool,
}

#[derive(Clone, Debug, Deserialize)]
struct SecurityAuditResponse {
    overall: String,
    findings: Vec<SecurityAuditFindingResponse>,
}

#[derive(Clone, Debug, Deserialize)]
struct SolanaWalletUiResponse {
    address: String,
}

#[derive(Clone, Debug, Deserialize)]
struct InboxApiResponse {
    items: Vec<InboxApiItem>,
}

#[derive(Clone, Debug, Deserialize)]
struct InboxApiItem {
    id: String,
    source_type: String,
    owner: String,
    title: String,
    details: Option<String>,
    status: String,
    priority: String,
    due_at: Option<i64>,
    created_at: i64,
    updated_at: i64,
    requires_human_action: bool,
    origin_ref: String,
    #[serde(default)]
    dependency_refs: Vec<String>,
    t_shirt_size: Option<String>,
    story_points: Option<i32>,
    estimate_optimistic_minutes: Option<i32>,
    estimate_likely_minutes: Option<i32>,
    estimate_pessimistic_minutes: Option<i32>,
}

#[derive(Clone, Debug, Deserialize)]
struct AuditEventsApiResponse {
    events: Vec<Value>,
}

#[derive(Clone, Debug, Deserialize)]
struct ReminderDeliveryEventsApiResponse {
    events: Vec<Value>,
}

#[derive(Clone, Debug)]
struct AuditEventRow {
    timestamp: i64,
    event_type: String,
    status: String,
    actor: Option<String>,
    line: String,
    origin_ref: Option<String>,
}

#[derive(Clone)]
struct ChatMessage {
    id: u64,
    role: MessageRole,
    text: String,
    markdown_items: Vec<markdown::Item>,
    timestamp: i64,
}

#[derive(Clone, Copy, PartialEq, Eq)]
enum MessageRole {
    User,
    Bot,
    System,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum UiTab {
    Inbox,
    Kanban,
    Dependencies,
    Gantt,
    Audit,
    Chat,
    Activity,
    Settings,
    Diagnostics,
    Context,
    Heartbeat,
}

impl UiTab {
    fn all() -> [UiTab; 11] {
        [
            UiTab::Inbox,
            UiTab::Kanban,
            UiTab::Dependencies,
            UiTab::Gantt,
            UiTab::Audit,
            UiTab::Chat,
            UiTab::Activity,
            UiTab::Settings,
            UiTab::Diagnostics,
            UiTab::Context,
            UiTab::Heartbeat,
        ]
    }

    fn label(self) -> &'static str {
        match self {
            UiTab::Inbox => "Inbox",
            UiTab::Kanban => "Kanban",
            UiTab::Dependencies => "Dependencies",
            UiTab::Gantt => "Gantt",
            UiTab::Audit => "Audit",
            UiTab::Chat => "Chat",
            UiTab::Activity => "Activity",
            UiTab::Settings => "Config",
            UiTab::Diagnostics => "Diagnostics",
            UiTab::Context => "Context",
            UiTab::Heartbeat => "Heartbeat",
        }
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum InboxSourceType {
    Reminder,
    Todo,
    Task,
    PlanStep,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum InboxPriority {
    Low,
    Normal,
    High,
    Urgent,
}

#[derive(Clone, Debug)]
struct InboxItem {
    id: String,
    source_type: InboxSourceType,
    owner: String,
    title: String,
    details: Option<String>,
    status: InboxStatus,
    priority: InboxPriority,
    due_at: Option<i64>,
    created_at: i64,
    updated_at: i64,
    requires_human_action: bool,
    origin_ref: String,
    dependency_refs: Vec<String>,
    t_shirt_size: Option<String>,
    story_points: Option<i32>,
    estimate_optimistic_minutes: Option<i32>,
    estimate_likely_minutes: Option<i32>,
    estimate_pessimistic_minutes: Option<i32>,
}

#[derive(Clone, Copy, Debug)]
enum InboxActionKind {
    Acknowledge,
    Start,
    Block,
    Done,
    Reopen,
    Snooze,
}

#[derive(Clone, Debug, Default)]
struct UiServerRow {
    name: String,
    url: String,
    header_key: String,
    header_value: String,
}

#[derive(Clone, Debug)]
struct SettingsForm {
    github_pat: String,
    zapier_token: String,
    openai_api_key: String,
    grok_api_key: String,
    solana_rpc_endpoint: String,
    search_network_allow: String,
    wakeup_poll_seconds: String,
    tpm_mode: String,
    proactive_chat_enabled: bool,
    proactive_chat_min_interval_seconds: String,
    proactive_chat_severity: String,
    proactive_chat_quiet_start_hhmm: String,
    proactive_chat_quiet_end_hhmm: String,
    mcp_servers: Vec<UiServerRow>,
    http_call_servers: Vec<UiServerRow>,
    prompt_text: String,
    heartbeat_text: String,
}

impl Default for SettingsForm {
    fn default() -> Self {
        Self {
            github_pat: String::new(),
            zapier_token: String::new(),
            openai_api_key: String::new(),
            grok_api_key: String::new(),
            solana_rpc_endpoint: String::new(),
            search_network_allow: String::new(),
            wakeup_poll_seconds: "60".to_string(),
            tpm_mode: "auto".to_string(),
            proactive_chat_enabled: true,
            proactive_chat_min_interval_seconds: "45".to_string(),
            proactive_chat_severity: "blocked_or_overdue".to_string(),
            proactive_chat_quiet_start_hhmm: String::new(),
            proactive_chat_quiet_end_hhmm: String::new(),
            mcp_servers: vec![],
            http_call_servers: vec![],
            prompt_text: String::new(),
            heartbeat_text: String::new(),
        }
    }
}

#[derive(Clone, Debug)]
struct LoadedSettings {
    form: SettingsForm,
}

struct ButterflyIcedApp {
    daemon_url: String,
    user_id: String,
    db_path: String,
    token: String,
    active_tab: UiTab,
    composer: String,
    busy: bool,
    error: String,
    daemon_running: bool,
    daemon_starting: bool,
    daemon_status: String,
    next_id: u64,
    history_loaded: bool,
    chat_messages: Vec<ChatMessage>,
    activity_messages: Vec<ChatMessage>,
    inbox_items: Vec<InboxItem>,
    inbox_status: String,
    inbox_error: String,
    inbox_refresh_in_flight: bool,
    inbox_action_origin_ref_in_flight: Option<String>,
    inbox_last_refresh_ts: i64,
    last_badge_actionable_count: Option<usize>,
    audit_events: Vec<AuditEventRow>,
    audit_status: String,
    audit_error: String,
    audit_refresh_in_flight: bool,
    audit_last_refresh_ts: i64,
    audit_last_activity_bridge_ts: i64,
    audit_origin_filter: Option<String>,
    timeline_focus_origin_ref: Option<String>,
    chat_origin_anchor: Option<String>,
    chat_anchor_message_id: Option<u64>,
    chat_scroll_id: WidgetId,
    proactive_notified_origin_refs: HashSet<String>,
    proactive_last_chat_ts: i64,
    settings: SettingsForm,
    settings_status: String,
    settings_error: String,
    doctor_status: String,
    doctor_error: String,
    doctor_overall: String,
    doctor_checks: Vec<DoctorCheckResponse>,
    security_status: String,
    security_error: String,
    security_overall: String,
    security_findings: Vec<SecurityAuditFindingResponse>,
    reminder_delivery_status: String,
    reminder_delivery_error: String,
    reminder_delivery_events: Vec<String>,
    solana_wallet_address: Option<String>,
    solana_wallet_status: String,
    solana_wallet_fetch_in_flight: bool,
    solana_wallet_refresh_pending: bool,
    context_preview_items: Vec<markdown::Item>,
    heartbeat_preview_items: Vec<markdown::Item>,
    context_editor: text_editor::Content,
    heartbeat_editor: text_editor::Content,
    manage_local_daemon: bool,
    daemon_autostart_attempted: bool,
}

#[derive(Clone, Debug)]
struct DaemonHealth {
    daemon_url: String,
    healthy: bool,
    switched: bool,
}

#[derive(Clone, Debug)]
enum Message {
    Tick,
    TabSelected(UiTab),
    ComposerChanged(String),
    SendPressed,
    ResponseReady(Result<String, String>),
    HealthChecked(DaemonHealth),
    StartDaemonPressed,
    StopDaemonPressed,
    DaemonStartFinished(Result<String, String>),
    DaemonStopFinished(Result<String, String>),
    HistoryLoaded(Result<Vec<String>, String>),
    ClearHistoryPressed,
    HistoryCleared(Result<(), String>),
    LoadSettingsPressed,
    SettingsLoaded(Box<Result<LoadedSettings, String>>),
    SaveSettingsPressed,
    SettingsSaved(Result<String, String>),
    GithubPatChanged(String),
    ZapierTokenChanged(String),
    OpenAiKeyChanged(String),
    GrokKeyChanged(String),
    SolanaRpcEndpointChanged(String),
    SearchNetworkAllowChanged(String),
    WakeupPollSecondsChanged(String),
    TpmModeChanged(String),
    ToggleProactiveChatEnabled,
    ProactiveChatMinIntervalChanged(String),
    ProactiveChatSeverityChanged(String),
    ProactiveChatQuietStartChanged(String),
    ProactiveChatQuietEndChanged(String),
    AddMcpServer,
    RemoveMcpServer(usize),
    McpServerNameChanged(usize, String),
    McpServerUrlChanged(usize, String),
    McpServerHeaderKeyChanged(usize, String),
    McpServerHeaderValueChanged(usize, String),
    AddHttpServer,
    RemoveHttpServer(usize),
    HttpServerNameChanged(usize, String),
    HttpServerUrlChanged(usize, String),
    HttpServerHeaderKeyChanged(usize, String),
    HttpServerHeaderValueChanged(usize, String),
    MarkdownLinkClicked(String),
    ContextEdited(text_editor::Action),
    HeartbeatEdited(text_editor::Action),
    RunDoctorPressed,
    DoctorFinished(Result<DoctorResponse, String>),
    SecurityFinished(Result<SecurityAuditResponse, String>),
    RefreshSolanaWallet,
    SolanaWalletLoaded(Result<Option<String>, String>),
    CopyToClipboard(String),
    InboxRefreshRequested,
    InboxLoaded(Result<Vec<InboxItem>, String>),
    InboxAcknowledge(String),
    InboxStart(String),
    InboxBlock(String),
    InboxDone(String),
    InboxReopen(String),
    InboxSnooze(String),
    InboxActionFinished(Result<String, String>),
    RefreshReminderDeliveryEvents,
    ReminderDeliveryEventsLoaded(Result<Vec<String>, String>),
    AuditRefreshRequested,
    AuditEventsLoaded(Result<Vec<AuditEventRow>, String>),
    TimelineOpenItem(String),
    TimelineOpenAudit(String),
    OpenChatWithContext(String),
    OpenChatAtEvent(String, i64),
    ChatClearAnchor,
    AuditClearFilter,
}

pub fn launch_ui(config: IcedUiLaunchConfig) -> iced::Result {
    if env_flag_enabled("BUTTERFLY_UI_MANAGE_DAEMON", true) {
        kill_all_daemons_best_effort();
    }

    let boot_config = config.clone();
    application(
        move || {
            let state = ButterflyIcedApp::new(boot_config.clone());
            let boot = Task::batch(vec![
                Task::perform(
                    check_daemon_health(state.daemon_url.clone()),
                    Message::HealthChecked,
                ),
                Task::perform(
                    load_inbox_items(
                        state.daemon_url.clone(),
                        state.token.clone(),
                        state.user_id.clone(),
                    ),
                    Message::InboxLoaded,
                ),
                Task::perform(
                    fetch_audit_events(
                        state.daemon_url.clone(),
                        state.token.clone(),
                        state.user_id.clone(),
                        200,
                    ),
                    Message::AuditEventsLoaded,
                ),
                Task::perform(load_settings(state.db_path.clone()), |result| {
                    Message::SettingsLoaded(Box::new(result))
                }),
            ]);
            (state, boot)
        },
        update,
        view,
    )
    .title(app_title)
    .theme(app_theme)
    .window(iced::window::Settings {
        size: Size::new(1280.0, 860.0),
        min_size: Some(Size::new(980.0, 700.0)),
        ..Default::default()
    })
    .subscription(subscription)
    .run()
}

fn app_title(_state: &ButterflyIcedApp) -> String {
    "Butterfly Bot".to_string()
}

fn build_stamp() -> String {
    format!(
        "v{} ({})",
        env!("CARGO_PKG_VERSION"),
        option_env!("BUTTERFLY_GIT_SHA").unwrap_or("dev")
    )
}

fn app_theme(_state: &ButterflyIcedApp) -> Theme {
    Theme::Dark
}

fn subscription(_state: &ButterflyIcedApp) -> Subscription<Message> {
    time::every(Duration::from_secs(2)).map(|_| Message::Tick)
}

impl ButterflyIcedApp {
    fn new(flags: IcedUiLaunchConfig) -> Self {
        let manage_local_daemon = env_flag_enabled("BUTTERFLY_UI_MANAGE_DAEMON", true);
        Self {
            daemon_url: normalize_daemon_url(&flags.daemon_url),
            user_id: flags.user_id,
            db_path: flags.db_path,
            token: std::env::var("BUTTERFLY_BOT_TOKEN").unwrap_or_default(),
            active_tab: UiTab::Chat,
            composer: String::new(),
            busy: false,
            error: String::new(),
            daemon_running: false,
            daemon_starting: false,
            daemon_status: if manage_local_daemon {
                "Local daemon control enabled".to_string()
            } else {
                "External daemon mode".to_string()
            },
            next_id: 1,
            history_loaded: false,
            chat_messages: vec![],
            activity_messages: vec![],
            inbox_items: vec![],
            inbox_status: "Loading inbox...".to_string(),
            inbox_error: String::new(),
            inbox_refresh_in_flight: true,
            inbox_action_origin_ref_in_flight: None,
            inbox_last_refresh_ts: 0,
            last_badge_actionable_count: None,
            audit_events: vec![],
            audit_status: "Loading audit events...".to_string(),
            audit_error: String::new(),
            audit_refresh_in_flight: true,
            audit_last_refresh_ts: 0,
            audit_last_activity_bridge_ts: 0,
            audit_origin_filter: None,
            timeline_focus_origin_ref: None,
            chat_origin_anchor: None,
            chat_anchor_message_id: None,
            chat_scroll_id: WidgetId::unique(),
            proactive_notified_origin_refs: HashSet::new(),
            proactive_last_chat_ts: 0,
            settings: SettingsForm::default(),
            settings_status: String::new(),
            settings_error: String::new(),
            doctor_status: String::new(),
            doctor_error: String::new(),
            doctor_overall: String::new(),
            doctor_checks: vec![],
            security_status: String::new(),
            security_error: String::new(),
            security_overall: String::new(),
            security_findings: vec![],
            reminder_delivery_status: String::new(),
            reminder_delivery_error: String::new(),
            reminder_delivery_events: vec![],
            solana_wallet_address: None,
            solana_wallet_status: String::new(),
            solana_wallet_fetch_in_flight: false,
            solana_wallet_refresh_pending: true,
            context_preview_items: vec![],
            heartbeat_preview_items: vec![],
            context_editor: text_editor::Content::new(),
            heartbeat_editor: text_editor::Content::new(),
            manage_local_daemon,
            daemon_autostart_attempted: false,
        }
    }

    fn push_chat(&mut self, role: MessageRole, text: String) {
        let markdown_items = parse_markdown_items(&text);
        self.chat_messages.push(ChatMessage {
            id: self.next_id,
            role,
            text,
            markdown_items,
            timestamp: now_unix_ts(),
        });
        self.next_id = self.next_id.saturating_add(1);
        if self.chat_messages.len() > 300 {
            let drop_count = self.chat_messages.len() - 300;
            self.chat_messages.drain(0..drop_count);
        }
    }

    fn push_activity(&mut self, text: String) {
        let markdown_items = parse_markdown_items(&text);
        self.activity_messages.push(ChatMessage {
            id: self.next_id,
            role: MessageRole::System,
            text,
            markdown_items,
            timestamp: now_unix_ts(),
        });
        self.next_id = self.next_id.saturating_add(1);
        if self.activity_messages.len() > 300 {
            let drop_count = self.activity_messages.len() - 300;
            self.activity_messages.drain(0..drop_count);
        }
    }
}

impl Drop for ButterflyIcedApp {
    fn drop(&mut self) {
        let _ = set_macos_dock_badge(0);
        if self.manage_local_daemon {
            let _ = stop_local_daemon_blocking();
        }
    }
}

fn update(state: &mut ButterflyIcedApp, message: Message) -> Task<Message> {
    match message {
        Message::Tick => {
            let mut tasks: Vec<Task<Message>> = Vec::new();
            if !state.daemon_starting {
                tasks.push(Task::perform(
                    check_daemon_health(state.daemon_url.clone()),
                    Message::HealthChecked,
                ));
            }

            let now = now_unix_ts();
            if !state.inbox_refresh_in_flight
                && now.saturating_sub(state.inbox_last_refresh_ts) >= 15
            {
                state.inbox_refresh_in_flight = true;
                tasks.push(Task::perform(
                    load_inbox_items(
                        state.daemon_url.clone(),
                        state.token.clone(),
                        state.user_id.clone(),
                    ),
                    Message::InboxLoaded,
                ));
            }

            if !state.audit_refresh_in_flight
                && now.saturating_sub(state.audit_last_refresh_ts) >= 15
            {
                state.audit_refresh_in_flight = true;
                tasks.push(Task::perform(
                    fetch_audit_events(
                        state.daemon_url.clone(),
                        state.token.clone(),
                        state.user_id.clone(),
                        200,
                    ),
                    Message::AuditEventsLoaded,
                ));
            }

            if tasks.is_empty() {
                Task::none()
            } else {
                Task::batch(tasks)
            }
        }
        Message::TabSelected(tab) => {
            state.active_tab = tab;
            if tab == UiTab::Inbox && !state.inbox_refresh_in_flight {
                state.inbox_refresh_in_flight = true;
                return Task::perform(
                    load_inbox_items(
                        state.daemon_url.clone(),
                        state.token.clone(),
                        state.user_id.clone(),
                    ),
                    Message::InboxLoaded,
                );
            }
            if tab == UiTab::Audit && !state.audit_refresh_in_flight {
                state.audit_refresh_in_flight = true;
                return Task::perform(
                    fetch_audit_events(
                        state.daemon_url.clone(),
                        state.token.clone(),
                        state.user_id.clone(),
                        200,
                    ),
                    Message::AuditEventsLoaded,
                );
            }
            Task::none()
        }
        Message::TimelineOpenItem(origin_ref) => {
            state.timeline_focus_origin_ref = Some(origin_ref.clone());
            state.active_tab = UiTab::Inbox;
            state.push_activity(format!("timeline drilldown → inbox ({origin_ref})"));
            if !state.inbox_refresh_in_flight {
                state.inbox_refresh_in_flight = true;
                return Task::perform(
                    load_inbox_items(
                        state.daemon_url.clone(),
                        state.token.clone(),
                        state.user_id.clone(),
                    ),
                    Message::InboxLoaded,
                );
            }
            Task::none()
        }
        Message::TimelineOpenAudit(origin_ref) => {
            state.audit_origin_filter = Some(origin_ref.clone());
            state.active_tab = UiTab::Audit;
            state.push_activity(format!("timeline drilldown → audit ({origin_ref})"));
            if !state.audit_refresh_in_flight {
                state.audit_refresh_in_flight = true;
                return Task::perform(
                    fetch_audit_events(
                        state.daemon_url.clone(),
                        state.token.clone(),
                        state.user_id.clone(),
                        200,
                    ),
                    Message::AuditEventsLoaded,
                );
            }
            Task::none()
        }
        Message::OpenChatWithContext(origin_ref) => {
            state.active_tab = UiTab::Chat;
            state.chat_origin_anchor = Some(origin_ref.clone());
            state.chat_anchor_message_id =
                find_latest_chat_message_id(&state.chat_messages, &origin_ref, None);
            state.composer = format!(
                "Show full context and latest state for work item {origin_ref}. Include blockers and next action."
            );
            state.push_activity(format!("drilldown → chat context ({origin_ref})"));
            scroll_chat_to_anchor_task(state)
        }
        Message::OpenChatAtEvent(origin_ref, event_ts) => {
            state.active_tab = UiTab::Chat;
            state.chat_origin_anchor = Some(origin_ref.clone());
            state.chat_anchor_message_id =
                find_latest_chat_message_id(&state.chat_messages, &origin_ref, Some(event_ts));
            state.composer = format!(
                "Show full context around event time {} for work item {origin_ref}.",
                format_local_time(event_ts)
            );
            state.push_activity(format!(
                "drilldown → chat anchor ({origin_ref}) @ {}",
                format_local_time(event_ts)
            ));
            scroll_chat_to_anchor_task(state)
        }
        Message::ChatClearAnchor => {
            state.chat_origin_anchor = None;
            state.chat_anchor_message_id = None;
            Task::none()
        }
        Message::AuditClearFilter => {
            state.audit_origin_filter = None;
            Task::none()
        }
        Message::ComposerChanged(value) => {
            state.composer = value;
            Task::none()
        }
        Message::SendPressed => {
            if state.busy || state.composer.trim().is_empty() {
                return Task::none();
            }

            if !state.daemon_running {
                state.error = "Daemon is not healthy. Start/check daemon first.".to_string();
                return Task::none();
            }

            let prompt = state.composer.trim().to_string();
            state.push_chat(MessageRole::User, prompt.clone());
            state.composer.clear();
            state.busy = true;
            state.error.clear();

            let daemon_url = state.daemon_url.clone();
            let user_id = state.user_id.clone();
            let token = state.token.clone();

            Task::perform(
                send_prompt(daemon_url, user_id, token, prompt),
                Message::ResponseReady,
            )
        }
        Message::ResponseReady(result) => {
            state.busy = false;
            match result {
                Ok(reply) => state.push_chat(MessageRole::Bot, reply),
                Err(err) => {
                    state.error = err.clone();
                    state.push_activity(format!("chat error: {err}"));
                }
            }
            Task::none()
        }
        Message::HealthChecked(health) => {
            if health.switched && state.daemon_url != health.daemon_url {
                state.daemon_url = health.daemon_url.clone();
                state.push_activity(format!("daemon auto-detected on {}", state.daemon_url));
            }

            state.daemon_running = health.healthy;
            if health.healthy {
                if state.daemon_status.is_empty() || state.daemon_status.contains("not reachable") {
                    state.daemon_status = "Daemon healthy".to_string();
                }
                if state.solana_wallet_refresh_pending && !state.solana_wallet_fetch_in_flight {
                    state.solana_wallet_fetch_in_flight = true;
                    return Task::perform(
                        fetch_solana_wallet_address(
                            state.daemon_url.clone(),
                            state.token.clone(),
                            state.user_id.clone(),
                        ),
                        Message::SolanaWalletLoaded,
                    );
                }
                if !state.history_loaded {
                    state.history_loaded = true;
                    return Task::perform(
                        run_chat_history_request(
                            state.daemon_url.clone(),
                            state.token.clone(),
                            state.user_id.clone(),
                            60,
                        ),
                        Message::HistoryLoaded,
                    );
                }
            } else if state.manage_local_daemon
                && !state.daemon_autostart_attempted
                && !state.daemon_starting
            {
                state.daemon_autostart_attempted = true;
                state.daemon_starting = true;
                state.daemon_status = "Starting daemon...".to_string();
                state.push_activity("daemon auto-start requested".to_string());
                return Task::perform(
                    start_local_daemon(
                        state.daemon_url.clone(),
                        state.db_path.clone(),
                        state.token.clone(),
                    ),
                    Message::DaemonStartFinished,
                );
            } else if !state.daemon_starting {
                state.daemon_status = "Daemon not reachable".to_string();
            }
            Task::none()
        }
        Message::StartDaemonPressed => {
            state.daemon_starting = true;
            state.daemon_status = "Starting daemon...".to_string();
            state.push_activity("daemon start requested".to_string());
            if state.manage_local_daemon {
                return Task::perform(
                    start_local_daemon(
                        state.daemon_url.clone(),
                        state.db_path.clone(),
                        state.token.clone(),
                    ),
                    Message::DaemonStartFinished,
                );
            }
            Task::perform(
                check_external_start_status(state.daemon_url.clone()),
                Message::DaemonStartFinished,
            )
        }
        Message::StopDaemonPressed => {
            state.daemon_starting = true;
            if state.manage_local_daemon {
                return Task::perform(
                    stop_daemon_by_url(state.daemon_url.clone()),
                    Message::DaemonStopFinished,
                );
            }
            state.daemon_starting = false;
            state.daemon_status =
                "External mode: stop daemon from service/process manager".to_string();
            Task::none()
        }
        Message::DaemonStartFinished(result) => {
            state.daemon_starting = false;
            match result {
                Ok(status) => {
                    state.daemon_status = status.clone();
                    state.push_activity(status);
                    Task::perform(
                        check_daemon_health(state.daemon_url.clone()),
                        Message::HealthChecked,
                    )
                }
                Err(err) => {
                    state.daemon_running = false;
                    state.daemon_status = err.clone();
                    state.push_activity(format!("daemon start failed: {err}"));
                    Task::none()
                }
            }
        }
        Message::DaemonStopFinished(result) => {
            state.daemon_starting = false;
            match result {
                Ok(status) => {
                    state.daemon_running = false;
                    state.daemon_status = status.clone();
                    state.push_activity(status);
                }
                Err(err) => {
                    state.daemon_status = err.clone();
                    state.push_activity(format!("daemon stop failed: {err}"));
                }
            }
            Task::none()
        }
        Message::HistoryLoaded(result) => {
            match result {
                Ok(lines) => {
                    state.chat_messages.clear();
                    state.activity_messages.clear();
                    state.next_id = 1;
                    for line in lines {
                        if let Some((role, text, ts)) = parse_history_entry(&line) {
                            if role == MessageRole::System {
                                state.push_activity(format!("history/system • {}", text));
                                continue;
                            }
                            let markdown_items = parse_markdown_items(&text);
                            state.chat_messages.push(ChatMessage {
                                id: state.next_id,
                                role,
                                text,
                                markdown_items,
                                timestamp: ts.unwrap_or_else(now_unix_ts),
                            });
                            state.next_id = state.next_id.saturating_add(1);
                        }
                    }
                    state.chat_anchor_message_id =
                        state.chat_origin_anchor.as_ref().and_then(|origin| {
                            find_latest_chat_message_id(&state.chat_messages, origin, None)
                        });
                    state.push_activity("chat history loaded".to_string());
                    return scroll_chat_to_anchor_task(state);
                }
                Err(err) => {
                    state.error = format!("History load failed: {err}");
                }
            }
            Task::none()
        }
        Message::ClearHistoryPressed => Task::perform(
            run_clear_user_data_request(
                state.daemon_url.clone(),
                state.token.clone(),
                state.user_id.clone(),
            ),
            Message::HistoryCleared,
        ),
        Message::HistoryCleared(result) => {
            match result {
                Ok(()) => {
                    state.chat_messages.clear();
                    state.activity_messages.clear();
                    state.inbox_items.clear();
                    state.audit_events.clear();
                    state.push_activity("all user work data cleared".to_string());
                    state.inbox_refresh_in_flight = true;
                    return Task::batch(vec![
                        Task::perform(
                            load_inbox_items(
                                state.daemon_url.clone(),
                                state.token.clone(),
                                state.user_id.clone(),
                            ),
                            Message::InboxLoaded,
                        ),
                        Task::perform(
                            fetch_audit_events(
                                state.daemon_url.clone(),
                                state.token.clone(),
                                state.user_id.clone(),
                                200,
                            ),
                            Message::AuditEventsLoaded,
                        ),
                    ]);
                }
                Err(err) => state.error = format!("Clear data failed: {err}"),
            }
            Task::none()
        }
        Message::LoadSettingsPressed => {
            Task::perform(load_settings(state.db_path.clone()), |result| {
                Message::SettingsLoaded(Box::new(result))
            })
        }
        Message::SettingsLoaded(result) => {
            match *result {
                Ok(loaded) => {
                    state.settings = loaded.form;
                    state.context_editor =
                        text_editor::Content::with_text(&state.settings.prompt_text);
                    state.heartbeat_editor =
                        text_editor::Content::with_text(&state.settings.heartbeat_text);
                    state.context_preview_items = parse_markdown_items(&state.settings.prompt_text);
                    state.heartbeat_preview_items =
                        parse_markdown_items(&state.settings.heartbeat_text);
                    state.settings_status = "Settings loaded".to_string();
                    state.settings_error.clear();
                    state.solana_wallet_refresh_pending = true;
                    if state.daemon_running && !state.solana_wallet_fetch_in_flight {
                        state.solana_wallet_fetch_in_flight = true;
                        return Task::perform(
                            fetch_solana_wallet_address(
                                state.daemon_url.clone(),
                                state.token.clone(),
                                state.user_id.clone(),
                            ),
                            Message::SolanaWalletLoaded,
                        );
                    }
                }
                Err(err) => {
                    state.settings_error = err;
                    state.settings_status.clear();
                }
            }
            Task::none()
        }
        Message::SaveSettingsPressed => Task::perform(
            save_settings(
                state.db_path.clone(),
                state.settings.clone(),
                state.daemon_url.clone(),
                state.token.clone(),
            ),
            Message::SettingsSaved,
        ),
        Message::SettingsSaved(result) => {
            match result {
                Ok(status) => {
                    state.settings_status = status.clone();
                    state.settings_error.clear();
                    state.push_activity(status);
                    state.solana_wallet_refresh_pending = true;
                    if state.daemon_running && !state.solana_wallet_fetch_in_flight {
                        state.solana_wallet_fetch_in_flight = true;
                        return Task::perform(
                            fetch_solana_wallet_address(
                                state.daemon_url.clone(),
                                state.token.clone(),
                                state.user_id.clone(),
                            ),
                            Message::SolanaWalletLoaded,
                        );
                    }
                }
                Err(err) => {
                    state.settings_error = err.clone();
                    state.settings_status.clear();
                    state.push_activity(format!("settings save failed: {err}"));
                }
            }
            Task::none()
        }
        Message::GithubPatChanged(value) => {
            state.settings.github_pat = value;
            Task::none()
        }
        Message::ZapierTokenChanged(value) => {
            state.settings.zapier_token = value;
            Task::none()
        }
        Message::OpenAiKeyChanged(value) => {
            state.settings.openai_api_key = value;
            Task::none()
        }
        Message::GrokKeyChanged(value) => {
            state.settings.grok_api_key = value;
            Task::none()
        }
        Message::SolanaRpcEndpointChanged(value) => {
            state.settings.solana_rpc_endpoint = value;
            Task::none()
        }
        Message::SearchNetworkAllowChanged(value) => {
            state.settings.search_network_allow = value;
            Task::none()
        }
        Message::WakeupPollSecondsChanged(value) => {
            state.settings.wakeup_poll_seconds = value;
            Task::none()
        }
        Message::TpmModeChanged(value) => {
            state.settings.tpm_mode = value;
            Task::none()
        }
        Message::ToggleProactiveChatEnabled => {
            state.settings.proactive_chat_enabled = !state.settings.proactive_chat_enabled;
            Task::none()
        }
        Message::ProactiveChatMinIntervalChanged(value) => {
            state.settings.proactive_chat_min_interval_seconds = value;
            Task::none()
        }
        Message::ProactiveChatSeverityChanged(value) => {
            state.settings.proactive_chat_severity = value;
            Task::none()
        }
        Message::ProactiveChatQuietStartChanged(value) => {
            state.settings.proactive_chat_quiet_start_hhmm = value;
            Task::none()
        }
        Message::ProactiveChatQuietEndChanged(value) => {
            state.settings.proactive_chat_quiet_end_hhmm = value;
            Task::none()
        }
        Message::AddMcpServer => {
            state.settings.mcp_servers.push(UiServerRow::default());
            Task::none()
        }
        Message::RemoveMcpServer(index) => {
            if index < state.settings.mcp_servers.len() {
                state.settings.mcp_servers.remove(index);
            }
            Task::none()
        }
        Message::McpServerNameChanged(index, value) => {
            if let Some(row) = state.settings.mcp_servers.get_mut(index) {
                row.name = value;
            }
            Task::none()
        }
        Message::McpServerUrlChanged(index, value) => {
            if let Some(row) = state.settings.mcp_servers.get_mut(index) {
                row.url = value;
            }
            Task::none()
        }
        Message::McpServerHeaderKeyChanged(index, value) => {
            if let Some(row) = state.settings.mcp_servers.get_mut(index) {
                row.header_key = value;
            }
            Task::none()
        }
        Message::McpServerHeaderValueChanged(index, value) => {
            if let Some(row) = state.settings.mcp_servers.get_mut(index) {
                row.header_value = value;
            }
            Task::none()
        }
        Message::AddHttpServer => {
            state
                .settings
                .http_call_servers
                .push(UiServerRow::default());
            Task::none()
        }
        Message::RemoveHttpServer(index) => {
            if index < state.settings.http_call_servers.len() {
                state.settings.http_call_servers.remove(index);
            }
            Task::none()
        }
        Message::HttpServerNameChanged(index, value) => {
            if let Some(row) = state.settings.http_call_servers.get_mut(index) {
                row.name = value;
            }
            Task::none()
        }
        Message::HttpServerUrlChanged(index, value) => {
            if let Some(row) = state.settings.http_call_servers.get_mut(index) {
                row.url = value;
            }
            Task::none()
        }
        Message::HttpServerHeaderKeyChanged(index, value) => {
            if let Some(row) = state.settings.http_call_servers.get_mut(index) {
                row.header_key = value;
            }
            Task::none()
        }
        Message::HttpServerHeaderValueChanged(index, value) => {
            if let Some(row) = state.settings.http_call_servers.get_mut(index) {
                row.header_value = value;
            }
            Task::none()
        }
        Message::MarkdownLinkClicked(uri) => {
            state.push_activity(format!("link clicked: {uri}"));
            let _ = open_uri_best_effort(&uri);
            Task::none()
        }
        Message::ContextEdited(action) => {
            state.context_editor.perform(action);
            state.settings.prompt_text = state.context_editor.text();
            state.context_preview_items = parse_markdown_items(&state.settings.prompt_text);
            Task::none()
        }
        Message::HeartbeatEdited(action) => {
            state.heartbeat_editor.perform(action);
            state.settings.heartbeat_text = state.heartbeat_editor.text();
            state.heartbeat_preview_items = parse_markdown_items(&state.settings.heartbeat_text);
            Task::none()
        }
        Message::RunDoctorPressed => {
            if !state.daemon_running {
                state.doctor_error = "Daemon is not running".to_string();
                state.security_error = "Daemon is not running".to_string();
                state.reminder_delivery_error = "Daemon is not running".to_string();
                return Task::none();
            }
            state.doctor_status = "Running security doctor...".to_string();
            state.security_status = "Running security audit...".to_string();
            state.reminder_delivery_status = "Loading reminder delivery diagnostics...".to_string();
            state.doctor_error.clear();
            state.security_error.clear();
            state.reminder_delivery_error.clear();
            Task::batch(vec![
                Task::perform(
                    run_doctor_request(state.daemon_url.clone(), state.token.clone()),
                    Message::DoctorFinished,
                ),
                Task::perform(
                    run_security_audit_request(state.daemon_url.clone(), state.token.clone()),
                    Message::SecurityFinished,
                ),
                Task::perform(
                    fetch_reminder_delivery_events(
                        state.daemon_url.clone(),
                        state.token.clone(),
                        state.user_id.clone(),
                        60,
                    ),
                    Message::ReminderDeliveryEventsLoaded,
                ),
            ])
        }
        Message::DoctorFinished(result) => {
            match result {
                Ok(report) => {
                    let reported_overall = report.overall;
                    state.doctor_checks = report
                        .checks
                        .into_iter()
                        .filter(|check| {
                            let name = check.name.trim().to_ascii_lowercase();
                            name != "provider_health" && name != "provider_check"
                        })
                        .collect();
                    state.doctor_overall = if state.doctor_checks.is_empty() {
                        reported_overall
                    } else {
                        derive_doctor_overall(&state.doctor_checks).to_string()
                    };
                    state.doctor_status = format!(
                        "Doctor complete ({})",
                        display_posture_level(&state.doctor_overall)
                    );
                    state.push_activity(state.doctor_status.clone());
                }
                Err(err) => {
                    state.doctor_error = err.clone();
                    state.doctor_status.clear();
                    state.push_activity(format!("doctor failed: {err}"));
                }
            }
            Task::none()
        }
        Message::SecurityFinished(result) => {
            match result {
                Ok(report) => {
                    state.security_overall = report.overall.clone();
                    state.security_findings = report
                        .findings
                        .into_iter()
                        .filter(|finding| {
                            let id = finding.id.trim().to_ascii_lowercase();
                            id != "provider_health" && id != "provider_check"
                        })
                        .collect();
                    state.security_status = format!(
                        "Security audit complete ({})",
                        display_posture_level(&state.security_overall)
                    );
                    state.push_activity(state.security_status.clone());
                }
                Err(err) => {
                    state.security_error = err.clone();
                    state.security_status.clear();
                    state.push_activity(format!("security audit failed: {err}"));
                }
            }
            Task::none()
        }
        Message::RefreshSolanaWallet => {
            state.solana_wallet_refresh_pending = true;
            if state.daemon_running && !state.solana_wallet_fetch_in_flight {
                state.solana_wallet_fetch_in_flight = true;
                Task::perform(
                    fetch_solana_wallet_address(
                        state.daemon_url.clone(),
                        state.token.clone(),
                        state.user_id.clone(),
                    ),
                    Message::SolanaWalletLoaded,
                )
            } else {
                Task::none()
            }
        }
        Message::SolanaWalletLoaded(result) => {
            state.solana_wallet_fetch_in_flight = false;
            state.solana_wallet_refresh_pending = false;
            match result {
                Ok(Some(address)) => {
                    state.solana_wallet_address = Some(address);
                    state.solana_wallet_status = "Butterfly Bot Wallet detected".to_string();
                }
                Ok(None) => {
                    state.solana_wallet_address = None;
                    state.solana_wallet_status = "No Butterfly Bot Wallet available".to_string();
                }
                Err(err) => {
                    state.solana_wallet_address = None;
                    state.solana_wallet_status = format!("Solana wallet unavailable: {err}");
                }
            }
            Task::none()
        }
        Message::CopyToClipboard(value) => {
            state.push_activity("copied to clipboard".to_string());
            iced::clipboard::write(value)
        }
        Message::InboxRefreshRequested => {
            if state.inbox_refresh_in_flight {
                return Task::none();
            }
            state.inbox_refresh_in_flight = true;
            state.inbox_error.clear();
            Task::perform(
                load_inbox_items(
                    state.daemon_url.clone(),
                    state.token.clone(),
                    state.user_id.clone(),
                ),
                Message::InboxLoaded,
            )
        }
        Message::InboxLoaded(result) => {
            state.inbox_refresh_in_flight = false;
            state.inbox_action_origin_ref_in_flight = None;
            state.inbox_last_refresh_ts = now_unix_ts();
            match result {
                Ok(items) => {
                    state.inbox_items = items;
                    state.inbox_error.clear();
                    state.inbox_status =
                        format!("Inbox synced ({} items)", state.inbox_items.len());
                    sync_actionable_badge(state);
                    maybe_emit_proactive_chat_nudge(state);
                }
                Err(err) => {
                    state.inbox_error = err;
                    state.inbox_status.clear();
                }
            }
            Task::none()
        }
        Message::InboxAcknowledge(origin_ref) => {
            let Some(item) = state
                .inbox_items
                .iter()
                .find(|item| item.origin_ref == origin_ref)
                .cloned()
            else {
                return Task::none();
            };

            optimistic_inbox_transition(state, &origin_ref, InboxActionKind::Acknowledge);
            state.inbox_action_origin_ref_in_flight = Some(origin_ref.clone());
            state.inbox_refresh_in_flight = true;
            Task::perform(
                apply_inbox_action(
                    state.daemon_url.clone(),
                    state.token.clone(),
                    state.user_id.clone(),
                    item,
                    InboxActionKind::Acknowledge,
                ),
                Message::InboxActionFinished,
            )
        }
        Message::InboxStart(origin_ref) => {
            let Some(item) = state
                .inbox_items
                .iter()
                .find(|item| item.origin_ref == origin_ref)
                .cloned()
            else {
                return Task::none();
            };

            optimistic_inbox_transition(state, &origin_ref, InboxActionKind::Start);
            state.inbox_action_origin_ref_in_flight = Some(origin_ref.clone());
            state.inbox_refresh_in_flight = true;
            Task::perform(
                apply_inbox_action(
                    state.daemon_url.clone(),
                    state.token.clone(),
                    state.user_id.clone(),
                    item,
                    InboxActionKind::Start,
                ),
                Message::InboxActionFinished,
            )
        }
        Message::InboxBlock(origin_ref) => {
            let Some(item) = state
                .inbox_items
                .iter()
                .find(|item| item.origin_ref == origin_ref)
                .cloned()
            else {
                return Task::none();
            };

            optimistic_inbox_transition(state, &origin_ref, InboxActionKind::Block);
            state.inbox_action_origin_ref_in_flight = Some(origin_ref.clone());
            state.inbox_refresh_in_flight = true;
            Task::perform(
                apply_inbox_action(
                    state.daemon_url.clone(),
                    state.token.clone(),
                    state.user_id.clone(),
                    item,
                    InboxActionKind::Block,
                ),
                Message::InboxActionFinished,
            )
        }
        Message::InboxDone(origin_ref) => {
            let Some(item) = state
                .inbox_items
                .iter()
                .find(|item| item.origin_ref == origin_ref)
                .cloned()
            else {
                return Task::none();
            };

            optimistic_inbox_transition(state, &origin_ref, InboxActionKind::Done);
            state.inbox_action_origin_ref_in_flight = Some(origin_ref.clone());
            state.inbox_refresh_in_flight = true;
            Task::perform(
                apply_inbox_action(
                    state.daemon_url.clone(),
                    state.token.clone(),
                    state.user_id.clone(),
                    item,
                    InboxActionKind::Done,
                ),
                Message::InboxActionFinished,
            )
        }
        Message::InboxReopen(origin_ref) => {
            let Some(item) = state
                .inbox_items
                .iter()
                .find(|item| item.origin_ref == origin_ref)
                .cloned()
            else {
                return Task::none();
            };

            optimistic_inbox_transition(state, &origin_ref, InboxActionKind::Reopen);
            state.inbox_action_origin_ref_in_flight = Some(origin_ref.clone());
            state.inbox_refresh_in_flight = true;
            Task::perform(
                apply_inbox_action(
                    state.daemon_url.clone(),
                    state.token.clone(),
                    state.user_id.clone(),
                    item,
                    InboxActionKind::Reopen,
                ),
                Message::InboxActionFinished,
            )
        }
        Message::InboxSnooze(origin_ref) => {
            let Some(item) = state
                .inbox_items
                .iter()
                .find(|item| item.origin_ref == origin_ref)
                .cloned()
            else {
                return Task::none();
            };

            optimistic_inbox_transition(state, &origin_ref, InboxActionKind::Snooze);
            state.inbox_action_origin_ref_in_flight = Some(origin_ref.clone());
            state.inbox_refresh_in_flight = true;
            Task::perform(
                apply_inbox_action(
                    state.daemon_url.clone(),
                    state.token.clone(),
                    state.user_id.clone(),
                    item,
                    InboxActionKind::Snooze,
                ),
                Message::InboxActionFinished,
            )
        }
        Message::InboxActionFinished(result) => {
            let mut tasks = Vec::new();
            state.inbox_action_origin_ref_in_flight = None;
            match result {
                Ok(status) => {
                    state.push_activity(status.clone());
                    state.inbox_status = status;
                    state.inbox_error.clear();
                }
                Err(err) => {
                    state.inbox_error = err;
                    state.inbox_refresh_in_flight = false;
                    return Task::none();
                }
            }

            tasks.push(Task::perform(
                load_inbox_items(
                    state.daemon_url.clone(),
                    state.token.clone(),
                    state.user_id.clone(),
                ),
                Message::InboxLoaded,
            ));

            Task::batch(tasks)
        }
        Message::RefreshReminderDeliveryEvents => {
            if !state.daemon_running {
                state.reminder_delivery_error = "Daemon is not running".to_string();
                return Task::none();
            }
            state.reminder_delivery_status = "Loading reminder delivery diagnostics...".to_string();
            state.reminder_delivery_error.clear();
            Task::perform(
                fetch_reminder_delivery_events(
                    state.daemon_url.clone(),
                    state.token.clone(),
                    state.user_id.clone(),
                    60,
                ),
                Message::ReminderDeliveryEventsLoaded,
            )
        }
        Message::ReminderDeliveryEventsLoaded(result) => {
            match result {
                Ok(events) => {
                    state.reminder_delivery_events = events;
                    state.reminder_delivery_error.clear();
                    state.reminder_delivery_status = format!(
                        "Reminder delivery diagnostics loaded ({})",
                        state.reminder_delivery_events.len()
                    );
                }
                Err(err) => {
                    state.reminder_delivery_error = err;
                    state.reminder_delivery_status.clear();
                }
            }
            Task::none()
        }
        Message::AuditRefreshRequested => {
            if state.audit_refresh_in_flight {
                return Task::none();
            }
            state.audit_refresh_in_flight = true;
            state.audit_error.clear();
            Task::perform(
                fetch_audit_events(
                    state.daemon_url.clone(),
                    state.token.clone(),
                    state.user_id.clone(),
                    200,
                ),
                Message::AuditEventsLoaded,
            )
        }
        Message::AuditEventsLoaded(result) => {
            state.audit_refresh_in_flight = false;
            state.audit_last_refresh_ts = now_unix_ts();
            match result {
                Ok(events) => {
                    let mut bridged = 0usize;
                    let bridge_after_ts = state.audit_last_activity_bridge_ts;
                    for event in events
                        .iter()
                        .filter(|event| event.timestamp > bridge_after_ts)
                    {
                        let should_bridge = matches!(
                            event.event_type.as_str(),
                            "inbox_transition" | "reminder_delivery"
                        );
                        if should_bridge {
                            let origin = event.origin_ref.as_deref().unwrap_or("-");
                            let actor = event.actor.as_deref().unwrap_or("system");
                            state.push_activity(format!(
                                "Ops update ({actor}) • {} • {} • origin:{}",
                                event.event_type, event.status, origin
                            ));
                            bridged += 1;
                        }
                    }
                    if let Some(max_ts) = events.iter().map(|e| e.timestamp).max() {
                        state.audit_last_activity_bridge_ts =
                            state.audit_last_activity_bridge_ts.max(max_ts);
                    }
                    state.audit_events = events;
                    state.audit_error.clear();
                    state.audit_status = format!("Audit synced ({})", state.audit_events.len());
                    if bridged > 0 {
                        state.push_activity(format!("audit→activity bridge: {bridged} updates"));
                    }
                }
                Err(err) => {
                    state.audit_error = err;
                    state.audit_status.clear();
                }
            }
            Task::none()
        }
    }
}

fn view(state: &ButterflyIcedApp) -> Element<'_, Message> {
    let tabs_row = UiTab::all()
        .into_iter()
        .fold(row!().spacing(8), |row, tab| {
            let active = tab == state.active_tab;
            row.push(
                button(text(tab.label()).size(14))
                    .padding([8, 14])
                    .style(if active {
                        rounded_primary_button
                    } else {
                        rounded_secondary_button
                    })
                    .on_press(Message::TabSelected(tab)),
            )
        });

    let daemon_controls = row![
        button(if state.daemon_starting { "⏳" } else { "▶" })
            .padding([8, 12])
            .style(rounded_primary_button)
            .on_press_maybe(
                (!state.daemon_starting && !state.daemon_running)
                    .then_some(Message::StartDaemonPressed)
            ),
        button("⏹")
            .padding([8, 12])
            .style(rounded_primary_button)
            .on_press_maybe(
                (!state.daemon_starting && state.daemon_running)
                    .then_some(Message::StopDaemonPressed)
            ),
        button("🗑")
            .padding([8, 12])
            .style(rounded_danger_button)
            .on_press(Message::ClearHistoryPressed),
    ]
    .spacing(8)
    .align_y(iced::Alignment::Center);

    let nav_bar = row![
        container(
            scrollable(tabs_row).direction(iced::widget::scrollable::Direction::Horizontal(
                iced::widget::scrollable::Scrollbar::default(),
            ))
        )
        .width(Length::Fill)
        .padding([2, 0]),
        daemon_controls,
    ]
    .spacing(10)
    .align_y(iced::Alignment::Center)
    .width(Length::Fill);

    let body = container(match state.active_tab {
        UiTab::Inbox => view_inbox_tab(state),
        UiTab::Kanban => view_kanban_tab(state),
        UiTab::Dependencies => view_dependencies_tab(state),
        UiTab::Gantt => view_gantt_tab(state),
        UiTab::Audit => view_audit_tab(state),
        UiTab::Chat => view_chat_tab(state),
        UiTab::Activity => view_activity_tab(state),
        UiTab::Settings => view_settings_tab(state),
        UiTab::Diagnostics => view_diagnostics_tab(state),
        UiTab::Context => view_context_tab(state),
        UiTab::Heartbeat => view_heartbeat_tab(state),
    })
    .width(Length::Fill)
    .height(Length::Fill);

    let logo: Element<'_, Message> = image::<image::Handle>(butterfly_bot_logo_handle())
        .width(40)
        .height(40)
        .into();

    let actionable_count = count_actionable_human_items(&state.inbox_items);

    let content = column![
        row![
            logo,
            column![
                text("Butterfly Bot").size(30),
                text("Personal-ops assistant").size(14),
                text(build_stamp()).size(12)
            ]
            .spacing(2),
            Space::new().width(Length::Fill),
            text(format!("Actionable now: {actionable_count}")).size(14),
            text(state.daemon_status.clone()).size(14)
        ]
        .spacing(16)
        .width(Length::Fill)
        .align_y(iced::Alignment::Center),
        container(nav_bar).padding(8).style(glass_panel),
        body
    ]
    .spacing(12)
    .padding(16)
    .height(Length::Fill);

    container(container(content).height(Length::Fill).style(glass_shell))
        .width(Length::Fill)
        .height(Length::Fill)
        .center_x(Length::Fill)
        .into()
}

fn glass_shell(_theme: &Theme) -> iced::widget::container::Style {
    iced::widget::container::Style {
        text_color: None,
        background: Some(Background::Color(Color::from_rgba(0.07, 0.10, 0.18, 0.65))),
        border: Border {
            radius: 18.0.into(),
            width: 1.0,
            color: Color::from_rgba(1.0, 1.0, 1.0, 0.10),
        },
        shadow: Shadow::default(),
        snap: false,
    }
}

fn glass_panel(_theme: &Theme) -> iced::widget::container::Style {
    iced::widget::container::Style {
        text_color: None,
        background: Some(Background::Color(Color::from_rgba(0.10, 0.14, 0.24, 0.58))),
        border: Border {
            radius: 16.0.into(),
            width: 1.0,
            color: Color::from_rgba(1.0, 1.0, 1.0, 0.12),
        },
        shadow: Shadow::default(),
        snap: false,
    }
}

fn glass_alert_panel(_theme: &Theme) -> iced::widget::container::Style {
    iced::widget::container::Style {
        text_color: Some(Color::from_rgb(1.0, 0.92, 0.92)),
        background: Some(Background::Color(Color::from_rgba(0.42, 0.12, 0.18, 0.58))),
        border: Border {
            radius: 16.0.into(),
            width: 1.0,
            color: Color::from_rgba(1.0, 0.35, 0.40, 0.55),
        },
        shadow: Shadow::default(),
        snap: false,
    }
}

fn glass_accent_panel(_theme: &Theme) -> iced::widget::container::Style {
    iced::widget::container::Style {
        text_color: Some(Color::WHITE),
        background: Some(Background::Color(Color::from_rgba(0.20, 0.27, 0.62, 0.56))),
        border: Border {
            radius: 16.0.into(),
            width: 1.0,
            color: Color::from_rgba(0.58, 0.70, 1.0, 0.50),
        },
        shadow: Shadow::default(),
        snap: false,
    }
}

fn glass_success_panel(_theme: &Theme) -> iced::widget::container::Style {
    iced::widget::container::Style {
        text_color: Some(Color::WHITE),
        background: Some(Background::Color(Color::from_rgba(0.10, 0.46, 0.30, 0.58))),
        border: Border {
            radius: 16.0.into(),
            width: 1.0,
            color: Color::from_rgba(0.45, 0.90, 0.65, 0.52),
        },
        shadow: Shadow::default(),
        snap: false,
    }
}

fn glass_warning_panel(_theme: &Theme) -> iced::widget::container::Style {
    iced::widget::container::Style {
        text_color: Some(Color::WHITE),
        background: Some(Background::Color(Color::from_rgba(0.52, 0.36, 0.10, 0.58))),
        border: Border {
            radius: 16.0.into(),
            width: 1.0,
            color: Color::from_rgba(0.98, 0.79, 0.42, 0.50),
        },
        shadow: Shadow::default(),
        snap: false,
    }
}

fn glass_muted_panel(_theme: &Theme) -> iced::widget::container::Style {
    iced::widget::container::Style {
        text_color: Some(Color::from_rgb(0.75, 0.79, 0.88)),
        background: Some(Background::Color(Color::from_rgba(0.14, 0.16, 0.22, 0.50))),
        border: Border {
            radius: 16.0.into(),
            width: 1.0,
            color: Color::from_rgba(0.62, 0.68, 0.82, 0.28),
        },
        shadow: Shadow::default(),
        snap: false,
    }
}

fn glass_user_bubble(_theme: &Theme) -> iced::widget::container::Style {
    iced::widget::container::Style {
        text_color: Some(Color::WHITE),
        background: Some(Background::Color(Color::from_rgba(0.39, 0.40, 0.95, 0.62))),
        border: Border {
            radius: 16.0.into(),
            width: 1.0,
            color: Color::from_rgba(1.0, 1.0, 1.0, 0.14),
        },
        shadow: Shadow::default(),
        snap: false,
    }
}

fn glass_bot_bubble(_theme: &Theme) -> iced::widget::container::Style {
    iced::widget::container::Style {
        text_color: Some(Color::WHITE),
        background: Some(Background::Color(Color::from_rgba(0.49, 0.23, 0.92, 0.55))),
        border: Border {
            radius: 16.0.into(),
            width: 1.0,
            color: Color::from_rgba(1.0, 1.0, 1.0, 0.14),
        },
        shadow: Shadow::default(),
        snap: false,
    }
}

fn rounded_button_style(base: iced::widget::button::Style) -> iced::widget::button::Style {
    let mut style = base;
    style.border.radius = 999.0.into();
    style
}

fn rounded_secondary_button(
    theme: &Theme,
    status: iced::widget::button::Status,
) -> iced::widget::button::Style {
    rounded_button_style(iced::widget::button::secondary(theme, status))
}

fn rounded_primary_button(
    theme: &Theme,
    status: iced::widget::button::Status,
) -> iced::widget::button::Style {
    rounded_button_style(iced::widget::button::primary(theme, status))
}

fn rounded_success_button(
    theme: &Theme,
    status: iced::widget::button::Status,
) -> iced::widget::button::Style {
    rounded_button_style(iced::widget::button::success(theme, status))
}

fn rounded_danger_button(
    theme: &Theme,
    status: iced::widget::button::Status,
) -> iced::widget::button::Style {
    rounded_button_style(iced::widget::button::danger(theme, status))
}

fn view_inbox_tab(state: &ButterflyIcedApp) -> Element<'_, Message> {
    let now = now_unix_ts();
    let actionable_now = state
        .inbox_items
        .iter()
        .filter(|item| item.requires_human_action && item.status.is_actionable())
        .count();
    let overdue = state
        .inbox_items
        .iter()
        .filter(|item| {
            item.requires_human_action
                && item.status.is_actionable()
                && item.due_at.map(|due| due < now).unwrap_or(false)
        })
        .count();
    let blocked = state
        .inbox_items
        .iter()
        .filter(|item| item.status == InboxStatus::Blocked)
        .count();
    let awaiting_human = state
        .inbox_items
        .iter()
        .filter(|item| item.requires_human_action && item.status != InboxStatus::Done)
        .count();

    let mut needs_action_items = Vec::new();
    let mut in_progress_items = Vec::new();
    let mut blocked_items = Vec::new();
    let mut done_items = Vec::new();
    for item in &state.inbox_items {
        match item.status {
            InboxStatus::New | InboxStatus::Acknowledged => needs_action_items.push(item),
            InboxStatus::InProgress => in_progress_items.push(item),
            InboxStatus::Blocked => blocked_items.push(item),
            InboxStatus::Done | InboxStatus::Dismissed => done_items.push(item),
        }
    }

    let sort_section_by_due = |items: &mut Vec<&InboxItem>| {
        items.sort_by(|a, b| {
            a.due_at
                .or_else(|| infer_due_at_from_item_text(a))
                .unwrap_or(i64::MAX)
                .cmp(
                    &b.due_at
                        .or_else(|| infer_due_at_from_item_text(b))
                        .unwrap_or(i64::MAX),
                )
                .then_with(|| priority_rank(a.priority).cmp(&priority_rank(b.priority)))
                .then_with(|| b.created_at.cmp(&a.created_at))
        });
    };

    sort_section_by_due(&mut needs_action_items);
    sort_section_by_due(&mut in_progress_items);
    sort_section_by_due(&mut blocked_items);
    sort_section_by_due(&mut done_items);

    let content = column![
        row![
            inbox_chip("Actionable now", actionable_now),
            inbox_chip("Overdue", overdue),
            inbox_chip("Blocked", blocked),
            inbox_chip("Awaiting human", awaiting_human),
            Space::new().width(Length::Fill),
            button("Refresh")
                .padding([8, 12])
                .style(rounded_primary_button)
                .on_press_maybe((!state.inbox_refresh_in_flight).then_some(Message::InboxRefreshRequested))
        ]
        .spacing(8)
        .align_y(iced::Alignment::Center),
        container(
            text("Action semantics: Seen = reviewed • Start = in progress • Block = waiting/dependency • Done = completed • Undo = reopen if DoD not met • Snooze = remind later")
                .size(13)
        )
        .padding([8, 10])
        .style(glass_accent_panel),
        if state.inbox_error.is_empty() {
            text(state.inbox_status.clone())
        } else {
            text(state.inbox_error.clone()).color([0.95, 0.45, 0.45])
        },
        inbox_section(
            "Needs Action",
            &needs_action_items,
            state.timeline_focus_origin_ref.as_deref(),
            state.inbox_action_origin_ref_in_flight.as_deref(),
        ),
        inbox_section(
            "In progress",
            &in_progress_items,
            state.timeline_focus_origin_ref.as_deref(),
            state.inbox_action_origin_ref_in_flight.as_deref(),
        ),
        inbox_section(
            "Blocked",
            &blocked_items,
            state.timeline_focus_origin_ref.as_deref(),
            state.inbox_action_origin_ref_in_flight.as_deref(),
        ),
        inbox_section(
            "Done",
            &done_items,
            state.timeline_focus_origin_ref.as_deref(),
            state.inbox_action_origin_ref_in_flight.as_deref(),
        ),
    ]
    .spacing(10)
    .width(Length::Fill);

    container(
        scrollable(container(content).width(Length::Fill).padding([4, 14]))
            .height(Length::Fill)
            .width(Length::Fill),
    )
    .padding(10)
    .style(glass_panel)
    .width(Length::Fill)
    .height(Length::Fill)
    .into()
}

fn inbox_chip<'a>(label: &'a str, value: usize) -> Element<'a, Message> {
    container(text(format!("{label}: {value}")).size(13))
        .padding([6, 10])
        .style(glass_panel)
        .into()
}

#[derive(Clone, Copy)]
enum BadgeTone {
    Neutral,
    Info,
    Success,
    Warning,
    Danger,
    Muted,
}

fn metric_badge(label: &str, value: impl Into<String>) -> Element<'static, Message> {
    metric_badge_tone(label, value, BadgeTone::Neutral)
}

fn metric_badge_tone(
    label: &str,
    value: impl Into<String>,
    tone: BadgeTone,
) -> Element<'static, Message> {
    let style = match tone {
        BadgeTone::Neutral => glass_panel,
        BadgeTone::Info => glass_accent_panel,
        BadgeTone::Success => glass_success_panel,
        BadgeTone::Warning => glass_warning_panel,
        BadgeTone::Danger => glass_alert_panel,
        BadgeTone::Muted => glass_muted_panel,
    };

    container(text(format!("{label}: {}", value.into())).size(11))
        .padding([4, 8])
        .style(style)
        .into()
}

fn format_minutes_short(minutes: i32) -> String {
    let minutes = minutes.max(1);
    if minutes >= 8 * 60 {
        let days = minutes as f32 / (8.0 * 60.0);
        format!("{days:.1}d")
    } else if minutes >= 60 {
        let hours = minutes as f32 / 60.0;
        format!("{hours:.1}h")
    } else {
        format!("{minutes}m")
    }
}

fn inbox_section<'a>(
    title: &'a str,
    items: &[&InboxItem],
    focused_origin_ref: Option<&str>,
    action_in_flight_origin_ref: Option<&str>,
) -> Element<'a, Message> {
    let rows = if items.is_empty() {
        column![container(text("No items")).padding(8).style(glass_panel)]
    } else {
        items.iter().fold(column!().spacing(8), |col, item| {
            let priority = match item.priority {
                InboxPriority::Low => "low",
                InboxPriority::Normal => "normal",
                InboxPriority::High => "high",
                InboxPriority::Urgent => "urgent",
            };
            let source = match item.source_type {
                InboxSourceType::Reminder => "reminder",
                InboxSourceType::Todo => "todo",
                InboxSourceType::Task => "task",
                InboxSourceType::PlanStep => "plan",
            };
            let due_ts = item.due_at.or_else(|| infer_due_at_from_item_text(item));
            let due = due_ts
                .map(format_due_badge_time)
                .unwrap_or_else(|| "no due date".to_string());

            let now = now_unix_ts();
            let due_tone = if let Some(ts) = due_ts {
                if item.status.is_actionable() && ts < now {
                    BadgeTone::Danger
                } else if item.status.is_actionable() && ts <= now + 24 * 60 * 60 {
                    BadgeTone::Warning
                } else {
                    BadgeTone::Success
                }
            } else {
                BadgeTone::Muted
            };

            let mut meta_badges = row![
                metric_badge_tone("Type", source.to_string(), BadgeTone::Info),
                metric_badge("Priority", priority.to_string()),
                metric_badge_tone("Due", due.clone(), due_tone),
            ]
            .spacing(6)
            .align_y(iced::Alignment::Center);

            if let Some(size) = item.t_shirt_size.as_ref() {
                let size_norm = size.to_ascii_uppercase();
                let size_tone = match size_norm.as_str() {
                    "XS" | "S" => BadgeTone::Success,
                    "M" => BadgeTone::Info,
                    "L" => BadgeTone::Warning,
                    "XL" | "XXL" => BadgeTone::Danger,
                    _ => BadgeTone::Neutral,
                };
                meta_badges = meta_badges.push(metric_badge_tone("Size", size_norm, size_tone));
            }
            if let Some(points) = item.story_points {
                if points > 0 {
                    let points_tone = if points >= 8 {
                        BadgeTone::Danger
                    } else if points >= 5 {
                        BadgeTone::Warning
                    } else {
                        BadgeTone::Success
                    };
                    meta_badges =
                        meta_badges.push(metric_badge_tone("SP", points.to_string(), points_tone));
                }
            }
            if let Some(minutes) = item.estimate_optimistic_minutes {
                meta_badges = meta_badges.push(metric_badge_tone(
                    "Opt",
                    format_minutes_short(minutes),
                    BadgeTone::Info,
                ));
            }
            if let Some(minutes) = item.estimate_likely_minutes {
                meta_badges = meta_badges.push(metric_badge_tone(
                    "Likely",
                    format_minutes_short(minutes),
                    BadgeTone::Warning,
                ));
            }
            if let Some(minutes) = item.estimate_pessimistic_minutes {
                meta_badges = meta_badges.push(metric_badge_tone(
                    "Pess",
                    format_minutes_short(minutes),
                    BadgeTone::Danger,
                ));
            }

            let row_in_flight = action_in_flight_origin_ref == Some(item.origin_ref.as_str());
            let can_transition = item.status.is_actionable() && !row_in_flight;
            let can_seen = can_transition && item.status == InboxStatus::New;
            let can_start = can_transition
                && matches!(
                    item.status,
                    InboxStatus::New | InboxStatus::Acknowledged | InboxStatus::Blocked
                );
            let can_block = can_transition
                && matches!(
                    item.status,
                    InboxStatus::New | InboxStatus::Acknowledged | InboxStatus::InProgress
                );
            let can_done = can_transition
                && matches!(
                    item.status,
                    InboxStatus::New
                        | InboxStatus::Acknowledged
                        | InboxStatus::InProgress
                        | InboxStatus::Blocked
                );
            let can_reopen = !row_in_flight && item.status == InboxStatus::Done;
            let can_snooze = can_transition
                && item.source_type == InboxSourceType::Reminder
                && matches!(
                    item.status,
                    InboxStatus::New
                        | InboxStatus::Acknowledged
                        | InboxStatus::InProgress
                        | InboxStatus::Blocked
                );
            let action_row = row![
                button("Seen")
                    .padding([6, 10])
                    .style(rounded_secondary_button)
                    .on_press_maybe(
                        can_seen.then_some(Message::InboxAcknowledge(item.origin_ref.clone()))
                    ),
                button("Start")
                    .padding([6, 10])
                    .style(rounded_primary_button)
                    .on_press_maybe(
                        can_start.then_some(Message::InboxStart(item.origin_ref.clone()))
                    ),
                button("Blocked")
                    .padding([6, 10])
                    .style(rounded_secondary_button)
                    .on_press_maybe(
                        can_block.then_some(Message::InboxBlock(item.origin_ref.clone()))
                    ),
                button("Done")
                    .padding([6, 10])
                    .style(rounded_success_button)
                    .on_press_maybe(
                        can_done.then_some(Message::InboxDone(item.origin_ref.clone()))
                    ),
                button("Undo")
                    .padding([6, 10])
                    .style(rounded_secondary_button)
                    .on_press_maybe(
                        can_reopen.then_some(Message::InboxReopen(item.origin_ref.clone()))
                    ),
                button("Snooze")
                    .padding([6, 10])
                    .style(rounded_secondary_button)
                    .on_press_maybe(
                        can_snooze.then_some(Message::InboxSnooze(item.origin_ref.clone()))
                    ),
            ]
            .spacing(8);

            col.push(
                container(
                    column![
                        row![
                            text(if item.status == InboxStatus::New {
                                "●"
                            } else {
                                "○"
                            })
                            .size(15)
                            .color(
                                if item.status == InboxStatus::New {
                                    Color::from_rgb(0.30, 0.66, 1.0)
                                } else {
                                    Color::from_rgb(0.60, 0.64, 0.72)
                                }
                            ),
                            Space::new().width(8),
                            text(item.title.clone()).size(16),
                            Space::new().width(Length::Fill),
                            text(inbox_status_label(item.status)).size(12)
                        ]
                        .align_y(iced::Alignment::Center),
                        if let Some(details) = &item.details {
                            text(details.clone()).size(13)
                        } else {
                            text("").size(1)
                        },
                        scrollable(meta_badges)
                            .direction(iced::widget::scrollable::Direction::Horizontal(
                                iced::widget::scrollable::Scrollbar::default(),
                            ))
                            .height(Length::Shrink)
                            .width(Length::Fill),
                        text(format!(
                            "Ref: {} • Updated: {}",
                            item.id,
                            format_local_time(item.updated_at)
                        ))
                        .size(12),
                        action_row,
                        if row_in_flight {
                            text("Updating...").size(12).color([0.70, 0.86, 1.0])
                        } else {
                            text("").size(1)
                        },
                    ]
                    .spacing(6),
                )
                .padding(10)
                .style(if focused_origin_ref == Some(item.origin_ref.as_str()) {
                    glass_accent_panel
                } else {
                    glass_panel
                }),
            )
        })
    };

    container(
        column![
            row![
                text(title.to_uppercase()).size(22),
                Space::new().width(Length::Fill),
                inbox_chip("items", items.len()),
            ]
            .align_y(iced::Alignment::Center),
            rows
        ]
        .spacing(8),
    )
    .padding(8)
    .style(glass_panel)
    .into()
}

#[allow(dead_code)]
fn view_timeline_tab(state: &ButterflyIcedApp) -> Element<'_, Message> {
    let mut human_lane: Vec<&InboxItem> = vec![];
    let mut agent_lane: Vec<&InboxItem> = vec![];
    for item in &state.inbox_items {
        if item.owner.eq_ignore_ascii_case("agent") {
            agent_lane.push(item);
        } else {
            human_lane.push(item);
        }
    }

    let human_blocked = human_lane
        .iter()
        .filter(|item| item.status == InboxStatus::Blocked)
        .count();
    let agent_blocked = agent_lane
        .iter()
        .filter(|item| item.status == InboxStatus::Blocked)
        .count();

    let mut critical_paths = Vec::new();
    let item_index: HashMap<&str, &InboxItem> = state
        .inbox_items
        .iter()
        .map(|item| (item.origin_ref.as_str(), item))
        .collect();

    for item in state
        .inbox_items
        .iter()
        .filter(|item| item.status == InboxStatus::Blocked)
    {
        let mut visited = HashSet::new();
        let mut chain = vec![item.title.clone()];
        let mut current = item;
        visited.insert(current.origin_ref.clone());
        let mut unresolved = false;

        for _ in 0..4 {
            let Some(dep_ref) = current.dependency_refs.first() else {
                break;
            };

            if !visited.insert(dep_ref.clone()) {
                unresolved = true;
                chain.push("cycle detected".to_string());
                break;
            }

            if let Some(next) = item_index.get(dep_ref.as_str()) {
                chain.push(next.title.clone());
                current = next;
            } else {
                unresolved = true;
                chain.push(format!("missing dependency ({dep_ref})"));
                break;
            }
        }

        critical_paths.push((
            item.origin_ref.clone(),
            item.priority,
            item.due_at.unwrap_or(i64::MAX),
            chain.join("  →  "),
            unresolved,
        ));
    }

    critical_paths.sort_by_key(|entry| (priority_rank(entry.1), entry.2));
    if critical_paths.len() > 8 {
        critical_paths.truncate(8);
    }

    let critical_path_cards = if critical_paths.is_empty() {
        column![
            container(text("No blocked dependency chains right now").size(13))
                .padding([10, 12])
                .style(glass_panel)
        ]
    } else {
        critical_paths.into_iter().fold(
            column!().spacing(8),
            |col, (origin_ref, _, _, chain, unresolved)| {
                col.push(
                    container(
                        column![
                            row![
                                text(if unresolved { "⚠" } else { "⛓" }).size(15),
                                text(chain).size(13),
                            ]
                            .spacing(8)
                            .align_y(iced::Alignment::Center),
                            row![
                                text(origin_ref.clone()).size(11),
                                Space::new().width(Length::Fill),
                                button("Open item")
                                    .padding([4, 10])
                                    .style(rounded_secondary_button)
                                    .on_press(Message::TimelineOpenItem(origin_ref.clone())),
                                button("Open audit")
                                    .padding([4, 10])
                                    .style(rounded_primary_button)
                                    .on_press(Message::TimelineOpenAudit(origin_ref.clone())),
                                button("Open chat")
                                    .padding([4, 10])
                                    .style(rounded_secondary_button)
                                    .on_press(Message::OpenChatWithContext(origin_ref.clone())),
                            ]
                            .spacing(6)
                            .align_y(iced::Alignment::Center),
                        ]
                        .spacing(6),
                    )
                    .padding([8, 10])
                    .style(if unresolved {
                        glass_alert_panel
                    } else {
                        glass_panel
                    }),
                )
            },
        )
    };

    let lane = |title: &'static str, mut items: Vec<&InboxItem>| {
        let lane_blocked = items
            .iter()
            .filter(|item| item.status == InboxStatus::Blocked)
            .count();
        items.sort_by_key(|item| {
            (
                item.status != InboxStatus::Blocked,
                item.due_at.unwrap_or(i64::MAX),
                item.created_at,
            )
        });

        let rows = if items.is_empty() {
            column![container(text("No items in this lane").size(13))
                .padding([10, 12])
                .style(glass_panel)]
        } else {
            items.into_iter().fold(column!().spacing(8), |col, item| {
                let due = item
                    .due_at
                    .map(format_local_time)
                    .unwrap_or_else(|| "no due date".to_string());
                let status = inbox_status_label(item.status);
                let status_color = inbox_status_color(item.status);
                let priority = match item.priority {
                    InboxPriority::Low => "low",
                    InboxPriority::Normal => "normal",
                    InboxPriority::High => "high",
                    InboxPriority::Urgent => "urgent",
                };
                let owner = if item.owner.eq_ignore_ascii_case("agent") {
                    "agent"
                } else {
                    "human"
                };

                let mut badges = row![
                    metric_badge("Owner", owner.to_string()),
                    metric_badge("Priority", priority.to_string()),
                    metric_badge("Due", due.clone()),
                ]
                .spacing(6)
                .align_y(iced::Alignment::Center);
                if let Some(size) = item.t_shirt_size.as_ref() {
                    badges = badges.push(metric_badge("Size", size.to_ascii_uppercase()));
                }
                if let Some(points) = item.story_points {
                    if points > 0 {
                        badges = badges.push(metric_badge("SP", points.to_string()));
                    }
                }
                if let Some(minutes) = item.estimate_likely_minutes {
                    badges = badges.push(metric_badge("Likely", format_minutes_short(minutes)));
                }

                col.push(
                    container(
                        column![
                            row![
                                text(item.title.clone()).size(15),
                                Space::new().width(Length::Fill),
                                text("●").size(13).color(status_color),
                                text(status).size(12).color(status_color)
                            ]
                            .align_y(iced::Alignment::Center),
                            scrollable(badges)
                                .direction(iced::widget::scrollable::Direction::Horizontal(
                                    iced::widget::scrollable::Scrollbar::default(),
                                ))
                                .height(Length::Shrink)
                                .width(Length::Fill),
                        ]
                        .spacing(6),
                    )
                    .padding([10, 12])
                    .style(if item.status == InboxStatus::Blocked {
                        glass_alert_panel
                    } else {
                        glass_panel
                    }),
                )
            })
        };

        container(
            column![
                row![
                    text(title).size(18),
                    Space::new().width(Length::Fill),
                    inbox_chip("blocked", lane_blocked),
                ]
                .align_y(iced::Alignment::Center),
                rows
            ]
            .spacing(8),
        )
        .padding(8)
        .style(glass_panel)
    };

    let total_overdue = state
        .inbox_items
        .iter()
        .filter(|item| item.due_at.map(|due| due < now_unix_ts()).unwrap_or(false))
        .count();

    let content = column![
        row![
            inbox_chip("Human blocked", human_blocked),
            inbox_chip("Agent blocked", agent_blocked),
            inbox_chip("Overdue", total_overdue),
            inbox_chip("Total items", state.inbox_items.len()),
            Space::new().width(Length::Fill),
            button("Refresh")
                .padding([8, 12])
                .style(rounded_primary_button)
                .on_press(Message::InboxRefreshRequested),
        ]
        .spacing(8)
        .align_y(iced::Alignment::Center),
        container(
            row![
                text("Blocker-first timeline").size(14),
                Space::new().width(Length::Fill),
                text("Human + Agent lanes sorted by blocked status and due time").size(12)
            ]
            .align_y(iced::Alignment::Center)
        )
        .padding([10, 12])
        .style(glass_accent_panel),
        container(
            column![
                row![
                    text("Critical path").size(16),
                    Space::new().width(Length::Fill),
                    text("Blocked chains surfaced first").size(12),
                ]
                .align_y(iced::Alignment::Center),
                critical_path_cards,
            ]
            .spacing(8)
        )
        .padding(8)
        .style(glass_panel),
        lane("Human lane", human_lane),
        lane("Agent lane", agent_lane),
    ]
    .spacing(10);

    container(
        scrollable(container(content).padding([4, 14]))
            .height(Length::Fill)
            .width(Length::Fill),
    )
    .padding(10)
    .style(glass_panel)
    .width(Length::Fill)
    .height(Length::Fill)
    .into()
}

fn view_kanban_tab(state: &ButterflyIcedApp) -> Element<'_, Message> {
    let now = now_unix_ts();
    let seven_days_ago = now - (7 * 24 * 60 * 60);

    let mut new_items = Vec::new();
    let mut acknowledged_items = Vec::new();
    let mut in_progress_items = Vec::new();
    let mut blocked_items = Vec::new();
    let mut done_items = Vec::new();

    for item in &state.inbox_items {
        match item.status {
            InboxStatus::New => new_items.push(item),
            InboxStatus::Acknowledged => acknowledged_items.push(item),
            InboxStatus::InProgress => in_progress_items.push(item),
            InboxStatus::Blocked => blocked_items.push(item),
            InboxStatus::Done | InboxStatus::Dismissed => done_items.push(item),
        }
    }

    let completed_with_due = state
        .inbox_items
        .iter()
        .filter(|item| {
            matches!(item.status, InboxStatus::Done | InboxStatus::Dismissed)
                && item.due_at.is_some()
        })
        .count();
    let completed_on_time = state
        .inbox_items
        .iter()
        .filter(|item| {
            matches!(item.status, InboxStatus::Done | InboxStatus::Dismissed)
                && item
                    .due_at
                    .map(|due| item.updated_at <= due)
                    .unwrap_or(false)
        })
        .count();
    let deadline_hit_rate_pct = if completed_with_due > 0 {
        ((completed_on_time as f32 / completed_with_due as f32) * 100.0).round() as i32
    } else {
        0
    };

    let throughput_7d = state
        .inbox_items
        .iter()
        .filter(|item| {
            matches!(item.status, InboxStatus::Done | InboxStatus::Dismissed)
                && item.updated_at >= seven_days_ago
        })
        .count();

    let velocity_points_7d: i32 = state
        .inbox_items
        .iter()
        .filter(|item| {
            matches!(item.status, InboxStatus::Done | InboxStatus::Dismissed)
                && item.updated_at >= seven_days_ago
        })
        .map(|item| item.story_points.unwrap_or(0).max(0))
        .sum();

    let active_count =
        new_items.len() + acknowledged_items.len() + in_progress_items.len() + blocked_items.len();
    let done_count = done_items.len();
    let done_open_ratio = if active_count > 0 {
        format!("{:.1}", done_count as f32 / active_count as f32)
    } else {
        "∞".to_string()
    };

    let overdue_open = state
        .inbox_items
        .iter()
        .filter(|item| {
            !matches!(item.status, InboxStatus::Done | InboxStatus::Dismissed)
                && item.due_at.map(|due| due < now).unwrap_or(false)
        })
        .count();

    let item_index: HashMap<&str, &InboxItem> = state
        .inbox_items
        .iter()
        .map(|item| (item.origin_ref.as_str(), item))
        .collect();
    let blocked_with_dependencies = blocked_items
        .iter()
        .filter(|item| !item.dependency_refs.is_empty())
        .count();
    let unresolved_dependency_edges = blocked_items
        .iter()
        .flat_map(|item| item.dependency_refs.iter())
        .filter(|dep_ref| !item_index.contains_key(dep_ref.as_str()))
        .count();

    let mut dependency_chains = blocked_items
        .iter()
        .filter_map(|item| {
            let dep_ref = item.dependency_refs.first()?;
            let dep_title = item_index
                .get(dep_ref.as_str())
                .map(|dep| dep.title.clone())
                .unwrap_or_else(|| format!("missing ({dep_ref})"));
            Some((
                item.origin_ref.clone(),
                format!("{} → {}", item.title, dep_title),
            ))
        })
        .collect::<Vec<_>>();
    dependency_chains.sort_by(|a, b| a.1.cmp(&b.1));
    dependency_chains.truncate(3);

    let dependency_chain_preview: Element<'_, Message> = if dependency_chains.is_empty() {
        container(text("No blocked dependency chains right now").size(12))
            .padding([8, 10])
            .style(glass_panel)
            .width(Length::Fill)
            .into()
    } else {
        dependency_chains
            .into_iter()
            .fold(column!().spacing(6), |col, (origin_ref, chain)| {
                col.push(
                    container(
                        column![
                            text(chain).size(12),
                            row![
                                Space::new().width(Length::Fill),
                                button("Inbox")
                                    .padding([4, 8])
                                    .style(rounded_secondary_button)
                                    .on_press(Message::TimelineOpenItem(origin_ref.clone())),
                                button("Audit")
                                    .padding([4, 8])
                                    .style(rounded_secondary_button)
                                    .on_press(Message::TimelineOpenAudit(origin_ref.clone())),
                                button("Chat")
                                    .padding([4, 8])
                                    .style(rounded_primary_button)
                                    .on_press(Message::OpenChatWithContext(origin_ref.clone())),
                            ]
                            .spacing(6)
                            .align_y(iced::Alignment::Center),
                        ]
                        .spacing(6),
                    )
                    .padding([8, 10])
                    .style(glass_panel)
                    .width(Length::Fill),
                )
            })
            .into()
    };

    let column_view = |title: &'static str, items: Vec<&InboxItem>| {
        let max_cards = 4usize;
        let item_count = items.len();
        let cards = if items.is_empty() {
            column![container(text("No items").size(12))
                .padding([8, 10])
                .style(glass_panel)
                .width(Length::Fill)]
        } else {
            let mut cards =
                items
                    .into_iter()
                    .take(max_cards)
                    .fold(column!().spacing(8), |col, item| {
                        let due = item
                            .due_at
                            .map(format_due_badge_time)
                            .unwrap_or_else(|| "no due".to_string());
                        let source = match item.source_type {
                            InboxSourceType::Reminder => "reminder",
                            InboxSourceType::Todo => "todo",
                            InboxSourceType::Task => "task",
                            InboxSourceType::PlanStep => "plan",
                        };
                        let size = item
                            .t_shirt_size
                            .clone()
                            .unwrap_or_else(|| "-".to_string())
                            .to_ascii_uppercase();
                        let points = item.story_points.unwrap_or(0).max(0);
                        let likely = item
                            .estimate_likely_minutes
                            .map(format_minutes_short)
                            .unwrap_or_else(|| "-".to_string());

                        col.push(
                            container(
                                column![
                                    text(item.title.clone()).size(14),
                                    text(format!("{source} • {}", item.owner)).size(12),
                                    text(format!(
                                    "due: {due} • size: {size} • sp: {points} • likely: {likely}"
                                ))
                                    .size(11),
                                    text(item.origin_ref.to_string()).size(11),
                                ]
                                .spacing(4),
                            )
                            .padding([8, 10])
                            .width(Length::Fill)
                            .style(
                                if item.status == InboxStatus::Blocked {
                                    glass_alert_panel
                                } else {
                                    glass_panel
                                },
                            ),
                        )
                    });

            let overflow = item_count.saturating_sub(max_cards);
            if overflow > 0 {
                cards = cards.push(
                    container(
                        text(format!("+{overflow} more items (see Inbox)"))
                            .size(12)
                            .color([0.78, 0.82, 0.92]),
                    )
                    .padding([8, 10])
                    .style(glass_muted_panel)
                    .width(Length::Fill),
                );
            }

            cards
        };

        container(
            column![
                row![
                    text(title).size(18),
                    Space::new().width(Length::Fill),
                    inbox_chip("items", item_count),
                ]
                .align_y(iced::Alignment::Center),
                cards
            ]
            .spacing(8),
        )
        .padding(8)
        .style(glass_panel)
        .width(Length::FillPortion(1))
    };

    let board = row![
        column_view("NEW", new_items),
        column_view("SEEN", acknowledged_items),
        column_view("IN PROGRESS", in_progress_items),
        column_view("BLOCKED", blocked_items),
        column_view("DONE", done_items),
    ]
    .spacing(10)
    .height(Length::Fill)
    .width(Length::Fill);

    let content = column![
        container(
            row![
                text("Kanban board + delivery metrics").size(14),
                Space::new().width(Length::Fill),
                text("No inline edits in this view").size(12),
            ]
            .align_y(iced::Alignment::Center),
        )
        .padding([8, 10])
        .style(glass_accent_panel),
        row![
            metric_badge(
                "On-time",
                format!("{}%", deadline_hit_rate_pct.clamp(0, 100))
            ),
            metric_badge("Overdue open", overdue_open.to_string()),
            metric_badge("Throughput (7d)", throughput_7d.to_string()),
            metric_badge("Velocity SP (7d)", velocity_points_7d.max(0).to_string()),
            metric_badge("Done/Open", done_open_ratio),
        ]
        .spacing(8)
        .align_y(iced::Alignment::Center),
        container(
            column![
                row![
                    text("Dependency watch").size(15),
                    Space::new().width(Length::Fill),
                    text("Lightweight graph preview").size(12),
                ]
                .align_y(iced::Alignment::Center),
                row![
                    inbox_chip("Blocked w/ deps", blocked_with_dependencies),
                    inbox_chip("Unresolved edges", unresolved_dependency_edges),
                ]
                .spacing(8)
                .align_y(iced::Alignment::Center),
                dependency_chain_preview
            ]
            .spacing(8)
        )
        .padding(8)
        .style(glass_panel),
        board,
    ]
    .spacing(10)
    .height(Length::Fill);

    container(
        scrollable(container(content).padding([4, 14]))
            .height(Length::Fill)
            .width(Length::Fill),
    )
    .padding(10)
    .style(glass_panel)
    .width(Length::Fill)
    .height(Length::Fill)
    .into()
}

fn view_dependencies_tab(state: &ButterflyIcedApp) -> Element<'_, Message> {
    let item_index: HashMap<&str, &InboxItem> = state
        .inbox_items
        .iter()
        .map(|item| (item.origin_ref.as_str(), item))
        .collect();

    let mut edges = state
        .inbox_items
        .iter()
        .flat_map(|item| {
            item.dependency_refs.iter().map(|dep_ref| {
                let dep_item = item_index.get(dep_ref.as_str()).copied();
                (
                    item.origin_ref.clone(),
                    item.title.clone(),
                    item.owner.clone(),
                    item.status,
                    dep_ref.clone(),
                    dep_item.map(|dep| dep.title.clone()),
                    dep_item.map(|dep| dep.owner.clone()),
                    dep_item.is_some(),
                )
            })
        })
        .collect::<Vec<_>>();

    edges.sort_by(|a, b| {
        let a_unresolved = !a.7;
        let b_unresolved = !b.7;
        a_unresolved
            .cmp(&b_unresolved)
            .reverse()
            .then_with(|| (a.3 != InboxStatus::Blocked).cmp(&(b.3 != InboxStatus::Blocked)))
            .then_with(|| a.1.cmp(&b.1))
    });

    let total_edges = edges.len();
    let unresolved_edges = edges.iter().filter(|edge| !edge.7).count();
    let blocked_edges = edges
        .iter()
        .filter(|edge| edge.3 == InboxStatus::Blocked)
        .count();
    let cross_owner_edges = edges
        .iter()
        .filter(|edge| {
            edge.6
                .as_ref()
                .map(|dep_owner| !edge.2.eq_ignore_ascii_case(dep_owner))
                .unwrap_or(false)
        })
        .count();
    let cross_owner_blocked_edges = edges
        .iter()
        .filter(|edge| {
            edge.3 == InboxStatus::Blocked
                && edge
                    .6
                    .as_ref()
                    .map(|dep_owner| !edge.2.eq_ignore_ascii_case(dep_owner))
                    .unwrap_or(false)
        })
        .count();

    let mut owner_pair_rollups: HashMap<String, (usize, usize, usize)> = HashMap::new();
    for edge in &edges {
        let source_owner = edge.2.trim().to_ascii_lowercase();
        let dep_owner = edge
            .6
            .as_ref()
            .map(|value| value.trim().to_ascii_lowercase())
            .unwrap_or_else(|| "unknown".to_string());
        let key = format!("{source_owner} → {dep_owner}");
        let entry = owner_pair_rollups.entry(key).or_insert((0, 0, 0));
        entry.0 += 1;
        if edge.3 == InboxStatus::Blocked {
            entry.1 += 1;
        }
        if !edge.7 {
            entry.2 += 1;
        }
    }

    let mut owner_pair_rows = owner_pair_rollups.into_iter().collect::<Vec<_>>();
    owner_pair_rows.sort_by(|a, b| {
        b.1 .2
            .cmp(&a.1 .2)
            .then_with(|| b.1 .1.cmp(&a.1 .1))
            .then_with(|| b.1 .0.cmp(&a.1 .0))
            .then_with(|| a.0.cmp(&b.0))
    });
    owner_pair_rows.truncate(8);

    let mut cycle_count = 0usize;
    for item in &state.inbox_items {
        for dep_ref in &item.dependency_refs {
            if let Some(dep_item) = item_index.get(dep_ref.as_str()) {
                if dep_item
                    .dependency_refs
                    .iter()
                    .any(|back_ref| back_ref == &item.origin_ref)
                {
                    cycle_count += 1;
                }
            }
        }
    }

    let graph_rows = if edges.is_empty() {
        column![container(text("No dependency edges yet.").size(13))
            .padding([10, 12])
            .style(glass_panel)
            .width(Length::Fill)]
    } else {
        edges
            .iter()
            .take(40)
            .fold(column!().spacing(8), |col, edge| {
                let source_ref = edge.0.clone();
                let from_title = edge.1.clone();
                let status = edge.3;
                let dep_ref = edge.4.clone();
                let dep_title = edge
                    .5
                    .clone()
                    .unwrap_or_else(|| format!("missing dependency ({dep_ref})"));
                let unresolved = !edge.7;

                let owner_pair = match edge.6.clone() {
                    Some(dep_owner) => format!("{} → {dep_owner}", edge.2),
                    None => format!("{} → unknown", edge.2),
                };

                col.push(
                    container(
                        column![
                            row![
                                text(if unresolved { "⚠" } else { "⛓" }).size(14),
                                text(format!("{from_title} → {dep_title}")).size(13),
                                Space::new().width(Length::Fill),
                                text(inbox_status_label(status)).size(12),
                            ]
                            .spacing(8)
                            .align_y(iced::Alignment::Center),
                            row![
                                text(owner_pair).size(11),
                                Space::new().width(Length::Fill),
                                button("Inbox")
                                    .padding([4, 8])
                                    .style(rounded_secondary_button)
                                    .on_press(Message::TimelineOpenItem(source_ref.clone())),
                                button("Audit")
                                    .padding([4, 8])
                                    .style(rounded_secondary_button)
                                    .on_press(Message::TimelineOpenAudit(source_ref.clone())),
                                button("Chat")
                                    .padding([4, 8])
                                    .style(rounded_primary_button)
                                    .on_press(Message::OpenChatWithContext(source_ref.clone())),
                            ]
                            .spacing(6)
                            .align_y(iced::Alignment::Center),
                        ]
                        .spacing(6),
                    )
                    .padding([8, 10])
                    .style(if unresolved {
                        glass_alert_panel
                    } else if status == InboxStatus::Blocked {
                        glass_warning_panel
                    } else {
                        glass_panel
                    }),
                )
            })
    };

    let owner_rollup_rows: Element<'_, Message> = if owner_pair_rows.is_empty() {
        container(text("No owner handoffs detected yet.").size(12))
            .padding([8, 10])
            .style(glass_panel)
            .width(Length::Fill)
            .into()
    } else {
        owner_pair_rows
            .into_iter()
            .fold(
                column!().spacing(6),
                |col, (pair, (total, blocked, unresolved))| {
                    col.push(
                        container(
                            row![
                                text(pair).size(12),
                                Space::new().width(Length::Fill),
                                metric_badge("Edges", total.to_string()),
                                metric_badge_tone(
                                    "Blocked",
                                    blocked.to_string(),
                                    if blocked > 0 {
                                        BadgeTone::Warning
                                    } else {
                                        BadgeTone::Muted
                                    }
                                ),
                                metric_badge_tone(
                                    "Unresolved",
                                    unresolved.to_string(),
                                    if unresolved > 0 {
                                        BadgeTone::Danger
                                    } else {
                                        BadgeTone::Success
                                    }
                                ),
                            ]
                            .spacing(6)
                            .align_y(iced::Alignment::Center),
                        )
                        .padding([8, 10])
                        .style(glass_panel)
                        .width(Length::Fill),
                    )
                },
            )
            .into()
    };

    let content = column![
        container(
            row![
                text("Dependency graph").size(14),
                Space::new().width(Length::Fill),
                text("Edges across todos/plans/tasks/reminders").size(12),
            ]
            .align_y(iced::Alignment::Center),
        )
        .padding([8, 10])
        .style(glass_accent_panel),
        row![
            inbox_chip("Total edges", total_edges),
            inbox_chip("Unresolved", unresolved_edges),
            inbox_chip("Blocked edges", blocked_edges),
            inbox_chip("Cross-owner", cross_owner_edges),
            inbox_chip("Cross-owner blocked", cross_owner_blocked_edges),
            inbox_chip("2-node cycles", cycle_count),
        ]
        .spacing(8)
        .align_y(iced::Alignment::Center),
        container(
            column![
                row![
                    text("Org handoff rollup").size(15),
                    Space::new().width(Length::Fill),
                    text("owner → dependency-owner edges").size(12),
                ]
                .align_y(iced::Alignment::Center),
                owner_rollup_rows,
            ]
            .spacing(8),
        )
        .padding(8)
        .style(glass_panel),
        graph_rows,
    ]
    .spacing(10)
    .width(Length::Fill);

    container(
        scrollable(container(content).padding([4, 14]))
            .height(Length::Fill)
            .width(Length::Fill),
    )
    .padding(10)
    .style(glass_panel)
    .width(Length::Fill)
    .height(Length::Fill)
    .into()
}

fn view_gantt_tab(state: &ButterflyIcedApp) -> Element<'_, Message> {
    let extract_plan_step_ref = |details: Option<&String>| -> Option<String> {
        static PLAN_STEP_REF_RE: OnceLock<Regex> = OnceLock::new();
        let text = details?.as_str();
        let re = PLAN_STEP_REF_RE
            .get_or_init(|| Regex::new(r"(?i)planstepref\s*:\s*(plan_step:\d+:\d+)").unwrap());
        re.captures(text)
            .and_then(|caps| caps.get(1))
            .map(|m| m.as_str().to_ascii_lowercase())
    };

    let plan_step_refs_in_view = state
        .inbox_items
        .iter()
        .filter(|item| item.source_type == InboxSourceType::PlanStep)
        .map(|item| item.origin_ref.to_ascii_lowercase())
        .collect::<HashSet<_>>();

    let mut gantt_rows = state
        .inbox_items
        .iter()
        .filter(|item| {
            matches!(
                item.source_type,
                InboxSourceType::Todo | InboxSourceType::PlanStep
            ) && (item.due_at.is_some() || item.estimate_likely_minutes.is_some())
                && !(item.source_type == InboxSourceType::Todo
                    && extract_plan_step_ref(item.details.as_ref())
                        .map(|origin_ref| plan_step_refs_in_view.contains(&origin_ref))
                        .unwrap_or(false))
        })
        .collect::<Vec<_>>();

    let item_index: HashMap<String, &InboxItem> = state
        .inbox_items
        .iter()
        .map(|item| (item.origin_ref.to_ascii_lowercase(), item))
        .collect();

    let mut seen_fingerprints = HashSet::new();
    gantt_rows.retain(|item| {
        let due_bucket = item
            .due_at
            .map(|ts| ts / 86_400)
            .unwrap_or(i64::MAX / 86_400);
        let fingerprint = format!(
            "{}|{}|{}|{}|{}",
            item.title.trim().to_ascii_lowercase(),
            due_bucket,
            item.story_points.unwrap_or(0),
            item.estimate_likely_minutes.unwrap_or(0),
            item.t_shirt_size
                .as_deref()
                .unwrap_or("")
                .trim()
                .to_ascii_lowercase()
        );

        if seen_fingerprints.contains(&fingerprint) {
            return false;
        }
        seen_fingerprints.insert(fingerprint)
    });

    let gantt_start_end = |item: &InboxItem| -> (i64, i64) {
        let likely_seconds = (item.estimate_likely_minutes.unwrap_or(60).max(1) as i64) * 60;
        let end_ts = item
            .due_at
            .unwrap_or_else(|| item.created_at + likely_seconds)
            .max(item.created_at + 60);

        let latest_dep_end = item
            .dependency_refs
            .iter()
            .filter_map(|dep_ref| item_index.get(&dep_ref.to_ascii_lowercase()).copied())
            .map(|dep| {
                dep.due_at.unwrap_or_else(|| {
                    dep.created_at + (dep.estimate_likely_minutes.unwrap_or(60).max(1) as i64 * 60)
                })
            })
            .max();

        let start_from_due = item
            .due_at
            .map(|due| (due - likely_seconds).max(0))
            .unwrap_or(item.created_at);

        let start_ts = latest_dep_end
            .filter(|dep_end| *dep_end < end_ts)
            .unwrap_or(start_from_due)
            .min(end_ts - 60);
        (start_ts, end_ts)
    };

    gantt_rows.sort_by_key(|item| {
        let (start_ts, end_ts) = gantt_start_end(item);
        (start_ts, end_ts, item.created_at)
    });

    let time_window_start = gantt_rows
        .iter()
        .map(|item| gantt_start_end(item).0)
        .min()
        .unwrap_or_else(now_unix_ts);
    let time_window_end = gantt_rows
        .iter()
        .map(|item| gantt_start_end(item).1)
        .max()
        .unwrap_or(time_window_start + 3600)
        .max(time_window_start + 3600);
    let total_window_seconds = (time_window_end - time_window_start).max(1) as f32;

    let rows = if gantt_rows.is_empty() {
        column![container(text("No todo tickets available for Gantt view"))
            .padding([10, 12])
            .style(glass_panel)]
    } else {
        gantt_rows
            .into_iter()
            .fold(column!().spacing(10), |col, item| {
                let (start_ts, end_ts) = gantt_start_end(item);

                let offset_px =
                    (((start_ts - time_window_start).max(0) as f32) / total_window_seconds * 560.0)
                        .clamp(0.0, 560.0);
                let bar_px = (((end_ts - start_ts).max(60) as f32) / total_window_seconds * 560.0)
                    .clamp(6.0, 560.0);

                let size = item.t_shirt_size.clone().unwrap_or_else(|| "-".to_string());
                let points = item.story_points.unwrap_or(0).max(0);
                let likely_minutes = item.estimate_likely_minutes.unwrap_or(60).max(1);
                let due_label = item
                    .due_at
                    .map(format_local_time)
                    .unwrap_or_else(|| "no due date".to_string());

                col.push(
                    container(
                        column![
                            row![
                                text(item.title.clone()).size(14),
                                Space::new().width(Length::Fill),
                                text(format!("size: {size} • points: {points}")).size(12),
                            ]
                            .align_y(iced::Alignment::Center),
                            row![
                                Space::new().width(offset_px),
                                container(Space::new().width(bar_px).height(14)).style(
                                    if matches!(
                                        item.status,
                                        InboxStatus::Done | InboxStatus::Dismissed
                                    ) {
                                        glass_success_panel
                                    } else if item.status == InboxStatus::Blocked {
                                        glass_alert_panel
                                    } else {
                                        glass_accent_panel
                                    }
                                ),
                                Space::new().width(Length::Fill),
                            ]
                            .spacing(0)
                            .align_y(iced::Alignment::Center),
                            text(format!(
                                "start {} • due {} • likely {} • ref {}",
                                format_local_time(start_ts),
                                due_label,
                                format_minutes_short(likely_minutes),
                                item.origin_ref
                            ))
                            .size(11),
                        ]
                        .spacing(6),
                    )
                    .padding([8, 10])
                    .style(glass_panel),
                )
            })
    };

    let content = column![
        container(
            row![
                text("Read-only Gantt").size(14),
                Space::new().width(Length::Fill),
                text("Blue=open • Green=done • Red=blocked (due-date aligned)").size(12),
            ]
            .align_y(iced::Alignment::Center),
        )
        .padding([8, 10])
        .style(glass_accent_panel),
        rows,
    ]
    .spacing(10)
    .width(Length::Fill);

    container(
        scrollable(container(content).padding([4, 14]))
            .height(Length::Fill)
            .width(Length::Fill),
    )
    .padding(10)
    .style(glass_panel)
    .width(Length::Fill)
    .height(Length::Fill)
    .into()
}

#[allow(dead_code)]
fn view_burndown_tab(state: &ButterflyIcedApp) -> Element<'_, Message> {
    let extract_plan_step_ref = |details: Option<&String>| -> Option<String> {
        static PLAN_STEP_REF_RE: OnceLock<Regex> = OnceLock::new();
        let text = details?.as_str();
        let re = PLAN_STEP_REF_RE
            .get_or_init(|| Regex::new(r"(?i)planstepref\s*:\s*(plan_step:\d+:\d+)").unwrap());
        re.captures(text)
            .and_then(|caps| caps.get(1))
            .map(|m| m.as_str().to_ascii_lowercase())
    };

    let plan_step_refs_in_view = state
        .inbox_items
        .iter()
        .filter(|item| item.source_type == InboxSourceType::PlanStep)
        .map(|item| item.origin_ref.to_ascii_lowercase())
        .collect::<HashSet<_>>();

    let mut burn_items = state
        .inbox_items
        .iter()
        .filter(|item| {
            matches!(
                item.source_type,
                InboxSourceType::Todo | InboxSourceType::PlanStep
            ) && (item.story_points.is_some() || item.estimate_likely_minutes.is_some())
                && !(item.source_type == InboxSourceType::Todo
                    && extract_plan_step_ref(item.details.as_ref())
                        .map(|origin_ref| plan_step_refs_in_view.contains(&origin_ref))
                        .unwrap_or(false))
        })
        .collect::<Vec<_>>();

    let mut seen_fingerprints = HashSet::new();
    burn_items.retain(|item| {
        let due_bucket = item
            .due_at
            .map(|ts| ts / 86_400)
            .unwrap_or(i64::MAX / 86_400);
        let fingerprint = format!(
            "{}|{}|{}|{}|{}",
            item.title.trim().to_ascii_lowercase(),
            due_bucket,
            item.story_points.unwrap_or(0),
            item.estimate_likely_minutes.unwrap_or(0),
            item.t_shirt_size
                .as_deref()
                .unwrap_or("")
                .trim()
                .to_ascii_lowercase()
        );

        if seen_fingerprints.contains(&fingerprint) {
            return false;
        }
        seen_fingerprints.insert(fingerprint)
    });

    let total_points: i32 = burn_items
        .iter()
        .map(|item| item.story_points.unwrap_or(1).max(1))
        .sum();
    let completed_points: i32 = burn_items
        .iter()
        .filter(|item| matches!(item.status, InboxStatus::Done | InboxStatus::Dismissed))
        .map(|item| item.story_points.unwrap_or(1).max(1))
        .sum();
    let remaining_points = (total_points - completed_points).max(0);

    let total_likely_minutes: i32 = burn_items
        .iter()
        .map(|item| item.estimate_likely_minutes.unwrap_or(60).max(1))
        .sum();
    let completed_likely_minutes: i32 = burn_items
        .iter()
        .filter(|item| matches!(item.status, InboxStatus::Done | InboxStatus::Dismissed))
        .map(|item| item.estimate_likely_minutes.unwrap_or(60).max(1))
        .sum();
    let remaining_likely_minutes = (total_likely_minutes - completed_likely_minutes).max(0);

    let progress_ratio = if total_points > 0 {
        completed_points as f32 / total_points as f32
    } else {
        0.0
    };
    let ideal_remaining_points = ((1.0 - progress_ratio) * total_points as f32).round() as i32;

    let progress_bar = row![
        container(
            Space::new()
                .width((progress_ratio.clamp(0.0, 1.0) * 560.0).max(4.0))
                .height(14)
        )
        .style(glass_accent_panel),
        container(
            Space::new()
                .width(((1.0 - progress_ratio.clamp(0.0, 1.0)) * 560.0).max(4.0))
                .height(14)
        )
        .style(glass_panel),
    ]
    .spacing(0)
    .align_y(iced::Alignment::Center);

    let content = column![
        container(
            row![
                text("Read-only Burndown").size(14),
                Space::new().width(Length::Fill),
                text("Uses sized tickets (Todo + Plan steps)").size(12),
            ]
            .align_y(iced::Alignment::Center),
        )
        .padding([8, 10])
        .style(glass_accent_panel),
        row![
            inbox_chip("Total points", total_points.max(0) as usize),
            inbox_chip("Completed points", completed_points.max(0) as usize),
            inbox_chip("Remaining points", remaining_points.max(0) as usize),
            inbox_chip("Ideal remaining", ideal_remaining_points.max(0) as usize),
        ]
        .spacing(8)
        .align_y(iced::Alignment::Center),
        row![
            inbox_chip("Total likely (min)", total_likely_minutes.max(0) as usize),
            inbox_chip(
                "Completed likely (min)",
                completed_likely_minutes.max(0) as usize
            ),
            inbox_chip(
                "Remaining likely (min)",
                remaining_likely_minutes.max(0) as usize
            ),
        ]
        .spacing(8)
        .align_y(iced::Alignment::Center),
        container(progress_bar).padding([8, 10]).style(glass_panel),
        container(
            text("Interpretation: left segment = burned work, right segment = remaining work.")
                .size(12)
        )
        .padding([8, 10])
        .style(glass_panel),
    ]
    .spacing(10)
    .width(Length::Fill);

    container(
        scrollable(container(content).padding([4, 14]))
            .height(Length::Fill)
            .width(Length::Fill),
    )
    .padding(10)
    .style(glass_panel)
    .width(Length::Fill)
    .height(Length::Fill)
    .into()
}

fn view_audit_tab(state: &ButterflyIcedApp) -> Element<'_, Message> {
    let filtered_events = state
        .audit_events
        .iter()
        .filter(|event| {
            if let Some(origin_ref) = state.audit_origin_filter.as_ref() {
                event.origin_ref.as_deref() == Some(origin_ref.as_str())
            } else {
                true
            }
        })
        .cloned()
        .collect::<Vec<_>>();

    let lines = filtered_events
        .iter()
        .fold(column!().spacing(6), |col, event| {
            let (icon, tone, panel) = if event.line.contains("error")
                || event.line.contains("failed")
                || event.line.contains("blocked")
            {
                (
                    "⛔",
                    Color::from_rgb(0.98, 0.58, 0.58),
                    glass_alert_panel as fn(&Theme) -> _,
                )
            } else if event.line.contains("ok") || event.line.contains("done") {
                (
                    "✅",
                    Color::from_rgb(0.62, 0.95, 0.72),
                    glass_panel as fn(&Theme) -> _,
                )
            } else {
                (
                    "🧭",
                    Color::from_rgb(0.74, 0.80, 1.0),
                    glass_panel as fn(&Theme) -> _,
                )
            };

            let open_item_button: Element<'_, Message> = if let Some(origin_ref) = &event.origin_ref
            {
                row![
                    button("Open item")
                        .padding([4, 10])
                        .style(rounded_secondary_button)
                        .on_press(Message::TimelineOpenItem(origin_ref.clone())),
                    button("Open chat")
                        .padding([4, 10])
                        .style(rounded_secondary_button)
                        .on_press(Message::OpenChatAtEvent(
                            origin_ref.clone(),
                            event.timestamp
                        )),
                ]
                .spacing(6)
                .into()
            } else {
                Space::new().width(0).into()
            };

            col.push(
                container(
                    row![
                        text(icon).size(16).color(tone),
                        text(event.line.clone()).size(13),
                        Space::new().width(Length::Fill),
                        open_item_button,
                    ]
                    .spacing(8)
                    .align_y(iced::Alignment::Center),
                )
                .padding([8, 10])
                .style(panel),
            )
        });

    let last_sync = if state.audit_last_refresh_ts > 0 {
        format_local_time(state.audit_last_refresh_ts)
    } else {
        "never".to_string()
    };

    let status_banner: Element<'_, Message> = if state.audit_error.is_empty() {
        container(text(state.audit_status.clone()).size(13))
            .padding([8, 10])
            .style(glass_accent_panel)
            .into()
    } else {
        container(
            text(state.audit_error.clone())
                .size(13)
                .color([0.98, 0.70, 0.70]),
        )
        .padding([8, 10])
        .style(glass_alert_panel)
        .into()
    };

    let filter_bar: Element<'_, Message> =
        if let Some(origin_ref) = state.audit_origin_filter.as_ref() {
            container(
                row![
                    text(format!("Filter: {origin_ref}")).size(12),
                    Space::new().width(Length::Fill),
                    button("Clear filter")
                        .padding([4, 10])
                        .style(rounded_secondary_button)
                        .on_press(Message::AuditClearFilter),
                ]
                .align_y(iced::Alignment::Center),
            )
            .padding([8, 10])
            .style(glass_panel)
            .into()
        } else {
            container(text("Showing all events").size(12))
                .padding([8, 10])
                .style(glass_panel)
                .into()
        };

    let content = column![
        row![
            text("Audit trail").size(18),
            Space::new().width(Length::Fill),
            inbox_chip("Events", filtered_events.len()),
            text(last_sync).size(12),
            Space::new().width(Length::Fill),
            button("Refresh")
                .padding([8, 12])
                .style(rounded_primary_button)
                .on_press(Message::AuditRefreshRequested),
        ]
        .spacing(8)
        .align_y(iced::Alignment::Center),
        status_banner,
        filter_bar,
        lines,
    ]
    .spacing(10);

    container(
        scrollable(container(content).padding([4, 14]))
            .height(Length::Fill)
            .width(Length::Fill),
    )
    .padding(10)
    .style(glass_panel)
    .width(Length::Fill)
    .height(Length::Fill)
    .into()
}

fn inbox_status_label(status: InboxStatus) -> &'static str {
    match status {
        InboxStatus::New => "new",
        InboxStatus::Acknowledged => "acknowledged",
        InboxStatus::InProgress => "in_progress",
        InboxStatus::Blocked => "blocked",
        InboxStatus::Done => "done",
        InboxStatus::Dismissed => "dismissed",
    }
}

#[allow(dead_code)]
fn inbox_status_color(status: InboxStatus) -> Color {
    match status {
        InboxStatus::New => Color::from_rgb(0.64, 0.76, 1.0),
        InboxStatus::Acknowledged => Color::from_rgb(0.72, 0.82, 1.0),
        InboxStatus::InProgress => Color::from_rgb(0.73, 0.85, 1.0),
        InboxStatus::Blocked => Color::from_rgb(0.98, 0.55, 0.58),
        InboxStatus::Done => Color::from_rgb(0.62, 0.95, 0.70),
        InboxStatus::Dismissed => Color::from_rgb(0.68, 0.72, 0.78),
    }
}

fn view_chat_tab(state: &ButterflyIcedApp) -> Element<'_, Message> {
    let anchored_matches = state
        .chat_origin_anchor
        .as_ref()
        .map(|anchor| {
            state
                .chat_messages
                .iter()
                .filter(|msg| msg.text.contains(anchor))
                .count()
        })
        .unwrap_or(0);

    let anchor_banner: Element<'_, Message> =
        if let Some(anchor) = state.chat_origin_anchor.as_ref() {
            let anchor_id_text = state
                .chat_anchor_message_id
                .map(|id| id.to_string())
                .unwrap_or_else(|| "none".to_string());
            container(
                row![
                    text(format!("Chat anchor: {anchor}")).size(12),
                    Space::new().width(Length::Fill),
                    text(format!("message-id: {anchor_id_text}")).size(12),
                    inbox_chip("matches", anchored_matches),
                    button("Clear")
                        .padding([4, 10])
                        .style(rounded_secondary_button)
                        .on_press(Message::ChatClearAnchor),
                ]
                .align_y(iced::Alignment::Center),
            )
            .padding([8, 10])
            .style(glass_accent_panel)
            .into()
        } else {
            Space::new().height(0).into()
        };

    let list = state
        .chat_messages
        .iter()
        .filter(|msg| msg.role != MessageRole::System)
        .fold(column!().spacing(10).width(Length::Fill), |col, msg| {
            let who = match msg.role {
                MessageRole::User => "You",
                MessageRole::Bot => "Butterfly",
                MessageRole::System => "System",
            };
            let bubble = container(
                column![
                    row![
                        text(format!("{} • {}", who, format_local_time(msg.timestamp))).size(12),
                        Space::new().width(Length::Fill),
                        button(text("📋").size(14))
                            .padding(6)
                            .width(30)
                            .height(30)
                            .style(rounded_primary_button)
                            .on_press(Message::CopyToClipboard(msg.text.clone()))
                    ]
                    .align_y(iced::Alignment::Center),
                    markdown::view(msg.markdown_items.iter(), markdown_render_settings())
                        .map(Message::MarkdownLinkClicked)
                ]
                .spacing(6),
            )
            .padding(12)
            .style(if state.chat_anchor_message_id == Some(msg.id) {
                glass_alert_panel
            } else if state
                .chat_origin_anchor
                .as_ref()
                .map(|anchor| msg.text.contains(anchor))
                .unwrap_or(false)
            {
                glass_accent_panel
            } else {
                match msg.role {
                    MessageRole::User => glass_user_bubble,
                    MessageRole::Bot => glass_bot_bubble,
                    MessageRole::System => glass_panel,
                }
            });
            col.push(bubble)
        })
        .push(Space::new().height(18));

    let composer = row![
        text_input("Type a message", &state.composer)
            .on_input(Message::ComposerChanged)
            .on_submit(Message::SendPressed)
            .padding(12)
            .width(Length::Fill),
        button(if state.busy { "Sending..." } else { "Send" })
            .padding([10, 16])
            .style(rounded_primary_button)
            .on_press_maybe((!state.busy).then_some(Message::SendPressed)),
    ]
    .spacing(10)
    .align_y(iced::Alignment::Center);

    column![
        container(
            scrollable(container(list).padding([0, 14]).width(Length::Fill))
                .id(state.chat_scroll_id.clone())
                .height(Length::Fill)
                .width(Length::Fill)
                .anchor_bottom()
                .auto_scroll(true)
        )
        .padding(8)
        .style(glass_panel)
        .width(Length::Fill)
        .height(Length::Fill),
        if state.error.is_empty() {
            text("")
        } else {
            text(state.error.clone()).color([0.95, 0.45, 0.45])
        },
        anchor_banner,
        composer
    ]
    .spacing(10)
    .height(Length::Fill)
    .into()
}

fn view_activity_tab(state: &ButterflyIcedApp) -> Element<'_, Message> {
    let list = state
        .activity_messages
        .iter()
        .fold(column!().spacing(8), |col, msg| {
            col.push(
                container(
                    row![
                        text(format!(
                            "[{}] {}",
                            format_local_time(msg.timestamp),
                            msg.text
                        )),
                        Space::new().width(Length::Fill),
                        button(text("📋").size(14))
                            .padding(6)
                            .width(30)
                            .height(30)
                            .style(rounded_primary_button)
                            .on_press(Message::CopyToClipboard(msg.text.clone()))
                    ]
                    .align_y(iced::Alignment::Center),
                )
                .padding(10)
                .style(glass_panel),
            )
        });

    container(scrollable(list).height(Length::Fill).width(Length::Fill))
        .padding(8)
        .style(glass_panel)
        .width(Length::Fill)
        .height(Length::Fill)
        .into()
}

fn view_settings_tab(state: &ButterflyIcedApp) -> Element<'_, Message> {
    let mcp_rows = state.settings.mcp_servers.iter().enumerate().fold(
        column!().spacing(8),
        |col, (index, server)| {
            col.push(
                column![
                    row![
                        text_input("Server name", &server.name)
                            .on_input(move |value| Message::McpServerNameChanged(index, value))
                            .padding(8)
                            .width(Length::FillPortion(2)),
                        text_input("https://server.example/mcp", &server.url)
                            .on_input(move |value| Message::McpServerUrlChanged(index, value))
                            .padding(8)
                            .width(Length::FillPortion(3)),
                        button("Remove")
                            .padding([8, 10])
                            .style(rounded_danger_button)
                            .on_press(Message::RemoveMcpServer(index)),
                    ]
                    .spacing(8)
                    .align_y(iced::Alignment::Center),
                    row![
                        text_input("Header key (optional)", &server.header_key)
                            .on_input(move |value| Message::McpServerHeaderKeyChanged(index, value))
                            .padding(8)
                            .width(Length::FillPortion(1)),
                        text_input("Header value (optional)", &server.header_value)
                            .on_input(move |value| Message::McpServerHeaderValueChanged(
                                index, value
                            ))
                            .padding(8)
                            .width(Length::FillPortion(1)),
                    ]
                    .spacing(8)
                    .align_y(iced::Alignment::Center),
                ]
                .spacing(8),
            )
        },
    );

    let http_rows = state.settings.http_call_servers.iter().enumerate().fold(
        column!().spacing(8),
        |col, (index, server)| {
            col.push(
                column![
                    row![
                        text_input("Server name", &server.name)
                            .on_input(move |value| Message::HttpServerNameChanged(index, value))
                            .padding(8)
                            .width(Length::FillPortion(2)),
                        text_input("https://api.example.com/v1", &server.url)
                            .on_input(move |value| Message::HttpServerUrlChanged(index, value))
                            .padding(8)
                            .width(Length::FillPortion(3)),
                        button("Remove")
                            .padding([8, 10])
                            .style(rounded_danger_button)
                            .on_press(Message::RemoveHttpServer(index)),
                    ]
                    .spacing(8)
                    .align_y(iced::Alignment::Center),
                    row![
                        text_input("Header key (optional)", &server.header_key)
                            .on_input(move |value| Message::HttpServerHeaderKeyChanged(
                                index, value
                            ))
                            .padding(8)
                            .width(Length::FillPortion(1)),
                        text_input("Header value (optional)", &server.header_value)
                            .on_input(move |value| Message::HttpServerHeaderValueChanged(
                                index, value
                            ))
                            .padding(8)
                            .width(Length::FillPortion(1)),
                    ]
                    .spacing(8)
                    .align_y(iced::Alignment::Center),
                ]
                .spacing(8),
            )
        },
    );

    let form = column![
            row![
                text("Provider: OpenAI (fixed)").size(14),
                Space::new().width(Length::Fill),
                button("Save")
                    .padding([8, 12])
                    .style(rounded_success_button)
                    .on_press(Message::SaveSettingsPressed)
            ]
        .spacing(10)
        .align_y(iced::Alignment::Center),
        container(column![
            text("API keys").size(16),
            text_input("OpenAI API key", &state.settings.openai_api_key)
                .on_input(Message::OpenAiKeyChanged)
                .secure(true)
                .padding(8),
            text_input("Grok API key", &state.settings.grok_api_key)
                .on_input(Message::GrokKeyChanged)
                .secure(true)
                .padding(8),
            text_input("GitHub PAT (MCP)", &state.settings.github_pat)
                .on_input(Message::GithubPatChanged)
                .secure(true)
                .padding(8),
            text_input("Zapier token (MCP)", &state.settings.zapier_token)
                .on_input(Message::ZapierTokenChanged)
                .secure(true)
                .padding(8),
        ]
        .spacing(8))
        .padding(10)
        .style(glass_panel),
        container(column![
            text("Runtime policy").size(16),
            text("Memory: enabled (fixed)").size(14),
            text("OpenAI chat model: gpt-4.1-mini (fixed)").size(14),
            text("OpenAI memory models: gpt-4.1-mini / text-embedding-3-small / gpt-4.1-mini (fixed)").size(14),
            text("OpenAI coding model: gpt-5.2-codex (fixed)").size(14),
            text("Proactive Agent→Human chat policy").size(14),
            row![
                text(if state.settings.proactive_chat_enabled {
                    "Enabled"
                } else {
                    "Disabled"
                })
                .size(13),
                Space::new().width(Length::Fill),
                button(if state.settings.proactive_chat_enabled {
                    "Disable"
                } else {
                    "Enable"
                })
                .padding([6, 10])
                .style(rounded_secondary_button)
                .on_press(Message::ToggleProactiveChatEnabled),
            ]
            .spacing(8)
            .align_y(iced::Alignment::Center),
            text_input(
                "Proactive min interval seconds",
                &state.settings.proactive_chat_min_interval_seconds
            )
            .on_input(Message::ProactiveChatMinIntervalChanged)
            .padding(8),
            text_input(
                "Proactive severity (blocked_only|blocked_or_overdue)",
                &state.settings.proactive_chat_severity
            )
            .on_input(Message::ProactiveChatSeverityChanged)
            .padding(8),
            row![
                text_input(
                    "Quiet start HH:MM (optional)",
                    &state.settings.proactive_chat_quiet_start_hhmm
                )
                .on_input(Message::ProactiveChatQuietStartChanged)
                .padding(8)
                .width(Length::FillPortion(1)),
                text_input(
                    "Quiet end HH:MM (optional)",
                    &state.settings.proactive_chat_quiet_end_hhmm
                )
                .on_input(Message::ProactiveChatQuietEndChanged)
                .padding(8)
                .width(Length::FillPortion(1)),
            ]
            .spacing(8)
            .align_y(iced::Alignment::Center),
        ]
        .spacing(6))
        .padding(10)
        .style(glass_panel),
        container(column![
            text("MCP Servers").size(16),
            mcp_rows,
            button("+ Add MCP Server")
                .padding([8, 12])
                .style(rounded_primary_button)
                .on_press(Message::AddMcpServer),
        ]
        .spacing(8))
        .padding(10)
        .style(glass_panel),
        container(column![
            text("HTTP Call Servers").size(16),
            http_rows,
            button("+ Add HTTP Server")
                .padding([8, 12])
                .style(rounded_primary_button)
                .on_press(Message::AddHttpServer),
        ]
        .spacing(8))
        .padding(10)
        .style(glass_panel),
        container(column![
            text("Network + system").size(16),
            text("Search default deny: enabled (fixed)").size(14),
            text_input("Search network allow (comma-separated)", &state.settings.search_network_allow)
                .on_input(Message::SearchNetworkAllowChanged)
                .padding(8),
            text_input("Wakeup poll seconds", &state.settings.wakeup_poll_seconds)
                .on_input(Message::WakeupPollSecondsChanged)
                .padding(8),
            text_input("TPM mode (strict|auto|compatible)", &state.settings.tpm_mode)
                .on_input(Message::TpmModeChanged)
                .padding(8),
        ]
        .spacing(8))
        .padding(10)
        .style(glass_panel),
        container(column![
            text("Solana").size(16),
            text("RPC endpoint").size(14),
            text_input("https://...", &state.settings.solana_rpc_endpoint)
                .on_input(Message::SolanaRpcEndpointChanged)
                .padding(8),
            if let Some(address) = &state.solana_wallet_address {
                row![
                    text("Butterfly Bot Wallet address:").size(14),
                    text(address.clone()).size(14),
                    Space::new().width(Length::Fill),
                    button(text("📋").size(14))
                        .padding(6)
                        .width(30)
                        .height(30)
                        .style(rounded_primary_button)
                        .on_press(Message::CopyToClipboard(address.clone())),
                ]
                .spacing(8)
                .align_y(iced::Alignment::Center)
            } else {
                row![
                    text("Butterfly Bot Wallet address: unavailable").size(14),
                    Space::new().width(Length::Fill),
                    button("Refresh")
                        .padding([6, 10])
                        .style(rounded_primary_button)
                        .on_press(Message::RefreshSolanaWallet),
                ]
                .spacing(8)
                .align_y(iced::Alignment::Center)
            },
            if state.solana_wallet_status.is_empty() {
                text("")
            } else {
                text(state.solana_wallet_status.clone()).size(13)
            }
        ]
        .spacing(8))
        .padding(10)
        .style(glass_panel),
        if state.settings_error.is_empty() {
            text(state.settings_status.clone()).color([0.55, 0.9, 0.65])
        } else {
            text(state.settings_error.clone()).color([0.95, 0.45, 0.45])
        }
    ]
    .spacing(12);

    container(
        scrollable(container(form).width(Length::Fill).padding([0, 16]))
            .height(Length::Fill)
            .width(Length::Fill),
    )
    .padding(10)
    .style(glass_panel)
    .width(Length::Fill)
    .height(Length::Fill)
    .into()
}

fn view_diagnostics_tab(state: &ButterflyIcedApp) -> Element<'_, Message> {
    let doctor_lines = state
        .doctor_checks
        .iter()
        .fold(column!().spacing(6), |col, check| {
            let mut line = format!("{} [{}] — {}", check.name, check.status, check.message);
            if let Some(hint) = &check.fix_hint {
                line.push_str(&format!(" ({hint})"));
            }
            col.push(text(line))
        });

    let security_lines =
        state
            .security_findings
            .iter()
            .fold(column!().spacing(6), |col, finding| {
                let mut line = format!(
                    "{} [{} / {}] — {}{}",
                    finding.id,
                    finding.severity,
                    finding.status,
                    finding.message,
                    if finding.auto_fixable {
                        " (auto-fixable)"
                    } else {
                        ""
                    }
                );
                if let Some(hint) = &finding.fix_hint {
                    line.push_str(&format!(" ({hint})"));
                }
                col.push(text(line))
            });

    let content = column![
        row![
            button("Run security doctor")
                .padding([8, 12])
                .style(rounded_primary_button)
                .on_press(Message::RunDoctorPressed),
            button("Refresh reminder delivery")
                .padding([8, 12])
                .style(rounded_secondary_button)
                .on_press(Message::RefreshReminderDeliveryEvents),
        ]
        .spacing(8)
        .align_y(iced::Alignment::Center),
        text(state.doctor_status.clone()),
        if state.doctor_error.is_empty() {
            text("")
        } else {
            text(state.doctor_error.clone()).color([0.95, 0.45, 0.45])
        },
        text(format!(
            "Doctor overall: {}",
            display_posture_level(&state.doctor_overall)
        )),
        container(doctor_lines).padding(8).style(glass_panel),
        text(""),
        text(state.security_status.clone()),
        if state.security_error.is_empty() {
            text("")
        } else {
            text(state.security_error.clone()).color([0.95, 0.45, 0.45])
        },
        text(format!(
            "Security overall: {}",
            display_posture_level(&state.security_overall)
        )),
        container(security_lines).padding(8).style(glass_panel),
        text(""),
        text(state.reminder_delivery_status.clone()),
        if state.reminder_delivery_error.is_empty() {
            text("")
        } else {
            text(state.reminder_delivery_error.clone()).color([0.95, 0.45, 0.45])
        },
        container(
            state
                .reminder_delivery_events
                .iter()
                .fold(column!().spacing(6), |col, line| col
                    .push(text(line.clone())))
        )
        .padding(8)
        .style(glass_panel),
    ]
    .spacing(10);

    container(scrollable(content).height(Length::Fill).width(Length::Fill))
        .padding(10)
        .style(glass_panel)
        .width(Length::Fill)
        .height(Length::Fill)
        .into()
}

fn view_context_tab(state: &ButterflyIcedApp) -> Element<'_, Message> {
    let content = column![
        text("Context prompt").size(22),
        text("Paste markdown text or a URL.").size(14),
        text_editor(&state.context_editor)
            .height(160)
            .padding(10)
            .on_action(Message::ContextEdited),
        row![
            button("Save Context")
                .padding([8, 12])
                .style(rounded_success_button)
                .on_press(Message::SaveSettingsPressed),
            button("Reload")
                .padding([8, 12])
                .style(rounded_primary_button)
                .on_press(Message::LoadSettingsPressed),
        ]
        .spacing(10),
        text("Preview").size(14),
        container(
            scrollable(
                markdown::view(
                    state.context_preview_items.iter(),
                    markdown_render_settings()
                )
                .map(Message::MarkdownLinkClicked)
            )
            .height(Length::Fill)
            .width(Length::Fill)
        )
        .padding(10)
        .style(glass_panel)
        .width(Length::Fill)
        .height(Length::Fill),
        if state.settings_error.is_empty() {
            text(state.settings_status.clone()).color([0.55, 0.9, 0.65])
        } else {
            text(state.settings_error.clone()).color([0.95, 0.45, 0.45])
        }
    ]
    .spacing(10)
    .width(Length::Fill)
    .height(Length::Fill);

    container(content)
        .padding(10)
        .style(glass_panel)
        .width(Length::Fill)
        .height(Length::Fill)
        .into()
}

fn view_heartbeat_tab(state: &ButterflyIcedApp) -> Element<'_, Message> {
    let content = column![
        text("Heartbeat").size(22),
        text("Paste markdown text or a URL.").size(14),
        text_editor(&state.heartbeat_editor)
            .height(160)
            .padding(10)
            .on_action(Message::HeartbeatEdited),
        row![
            button("Save Heartbeat")
                .padding([8, 12])
                .style(rounded_success_button)
                .on_press(Message::SaveSettingsPressed),
            button("Reload")
                .padding([8, 12])
                .style(rounded_primary_button)
                .on_press(Message::LoadSettingsPressed),
        ]
        .spacing(10),
        text("Preview").size(14),
        container(
            scrollable(
                markdown::view(
                    state.heartbeat_preview_items.iter(),
                    markdown_render_settings()
                )
                .map(Message::MarkdownLinkClicked)
            )
            .height(Length::Fill)
            .width(Length::Fill)
        )
        .padding(10)
        .style(glass_panel)
        .width(Length::Fill)
        .height(Length::Fill),
        if state.settings_error.is_empty() {
            text(state.settings_status.clone()).color([0.55, 0.9, 0.65])
        } else {
            text(state.settings_error.clone()).color([0.95, 0.45, 0.45])
        }
    ]
    .spacing(10)
    .width(Length::Fill)
    .height(Length::Fill);

    container(content)
        .padding(10)
        .style(glass_panel)
        .width(Length::Fill)
        .height(Length::Fill)
        .into()
}

fn count_actionable_human_items(items: &[InboxItem]) -> usize {
    items
        .iter()
        .filter(|item| item.requires_human_action && item.status.is_actionable())
        .count()
}

fn sync_actionable_badge(state: &mut ButterflyIcedApp) {
    let actionable = count_actionable_human_items(&state.inbox_items);
    if state.last_badge_actionable_count == Some(actionable) {
        return;
    }

    if let Err(err) = set_macos_dock_badge(actionable) {
        state.push_activity(format!("dock badge update failed: {err}"));
    }
    state.last_badge_actionable_count = Some(actionable);
}

fn set_macos_dock_badge(count: usize) -> std::io::Result<()> {
    #[cfg(target_os = "macos")]
    {
        let label = if count == 0 {
            "".to_string()
        } else {
            count.to_string()
        };
        let script = format!(
            "ObjC.import('AppKit'); $.NSApplication.sharedApplication.dockTile.badgeLabel = '{}';",
            label.replace('\'', "\\'")
        );
        let _status = Command::new("osascript")
            .args(["-l", "JavaScript", "-e", &script])
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status()?;
        return Ok(());
    }

    #[cfg(not(target_os = "macos"))]
    {
        let _ = count;
        Ok(())
    }
}

fn parse_inbox_status(value: Option<&str>, default: InboxStatus) -> InboxStatus {
    match value.unwrap_or("").trim().to_ascii_lowercase().as_str() {
        "new" => InboxStatus::New,
        "ack" | "acknowledged" => InboxStatus::Acknowledged,
        "in_progress" | "in progress" | "started" => InboxStatus::InProgress,
        "blocked" => InboxStatus::Blocked,
        "done" | "completed" | "complete" => InboxStatus::Done,
        "dismissed" | "archived" => InboxStatus::Dismissed,
        _ => default,
    }
}

fn parse_inbox_priority(value: Option<&str>, default: InboxPriority) -> InboxPriority {
    match value.unwrap_or("").trim().to_ascii_lowercase().as_str() {
        "low" => InboxPriority::Low,
        "high" => InboxPriority::High,
        "urgent" | "critical" => InboxPriority::Urgent,
        "normal" | "medium" => InboxPriority::Normal,
        _ => default,
    }
}

fn priority_rank(priority: InboxPriority) -> i32 {
    match priority {
        InboxPriority::Urgent => 0,
        InboxPriority::High => 1,
        InboxPriority::Normal => 2,
        InboxPriority::Low => 3,
    }
}

async fn load_inbox_items(
    daemon_url: String,
    token: String,
    user_id: String,
) -> Result<Vec<InboxItem>, String> {
    let extract_plan_step_ref = |details: Option<&String>| -> Option<String> {
        static PLAN_STEP_REF_RE: OnceLock<Regex> = OnceLock::new();
        let text = details?.as_str();
        let re = PLAN_STEP_REF_RE
            .get_or_init(|| Regex::new(r"(?i)planstepref\s*:\s*(plan_step:\d+:\d+)").unwrap());
        re.captures(text)
            .and_then(|caps| caps.get(1))
            .map(|m| m.as_str().to_ascii_lowercase())
    };

    let client = daemon_request_client();
    let url = format!(
        "{}/inbox?user_id={}&limit=250&include_done=true",
        daemon_url.trim_end_matches('/'),
        user_id
    );
    let mut request = client.get(url);
    if !token.trim().is_empty() {
        request = request.header("authorization", format!("Bearer {token}"));
    }

    let response = request.send().await.map_err(|err| err.to_string())?;
    if !response.status().is_success() {
        let status = response.status();
        let body = response
            .text()
            .await
            .unwrap_or_else(|_| "Unable to read response body".to_string());
        return Err(format!("Inbox request failed: HTTP {status}: {body}"));
    }

    let parsed = response
        .json::<InboxApiResponse>()
        .await
        .map_err(|err| err.to_string())?;

    let mut items = parsed
        .items
        .into_iter()
        .map(|item| {
            let source_type = match item.source_type.as_str() {
                "reminder" => InboxSourceType::Reminder,
                "todo" => InboxSourceType::Todo,
                "task" => InboxSourceType::Task,
                _ => InboxSourceType::PlanStep,
            };
            let default_status = parse_inbox_status(Some(&item.status), InboxStatus::New);
            let status = default_status;
            let priority = parse_inbox_priority(Some(&item.priority), InboxPriority::Normal);

            InboxItem {
                id: item.id,
                source_type,
                owner: item.owner,
                title: item.title,
                details: item.details,
                status,
                priority,
                due_at: item.due_at,
                created_at: item.created_at,
                updated_at: item.updated_at,
                requires_human_action: item.requires_human_action,
                origin_ref: item.origin_ref,
                dependency_refs: item.dependency_refs,
                t_shirt_size: item.t_shirt_size,
                story_points: item.story_points,
                estimate_optimistic_minutes: item.estimate_optimistic_minutes,
                estimate_likely_minutes: item.estimate_likely_minutes,
                estimate_pessimistic_minutes: item.estimate_pessimistic_minutes,
            }
        })
        .collect::<Vec<_>>();

    let plan_step_refs_in_view = items
        .iter()
        .filter(|item| item.source_type == InboxSourceType::PlanStep)
        .map(|item| item.origin_ref.to_ascii_lowercase())
        .collect::<HashSet<_>>();

    items.retain(|item| {
        !(item.source_type == InboxSourceType::Todo
            && extract_plan_step_ref(item.details.as_ref())
                .map(|origin_ref| plan_step_refs_in_view.contains(&origin_ref))
                .unwrap_or(false))
    });

    let mut seen_origin_refs = HashSet::new();
    items.retain(|item| seen_origin_refs.insert(item.origin_ref.clone()));

    items.sort_by(|a, b| {
        priority_rank(a.priority)
            .cmp(&priority_rank(b.priority))
            .then_with(|| {
                a.due_at
                    .unwrap_or(i64::MAX)
                    .cmp(&b.due_at.unwrap_or(i64::MAX))
            })
            .then_with(|| b.created_at.cmp(&a.created_at))
    });

    Ok(items)
}

async fn apply_inbox_action(
    daemon_url: String,
    token: String,
    user_id: String,
    item: InboxItem,
    action: InboxActionKind,
) -> Result<String, String> {
    let action_name = match action {
        InboxActionKind::Acknowledge => "acknowledge",
        InboxActionKind::Start => "start",
        InboxActionKind::Block => "block",
        InboxActionKind::Done => "done",
        InboxActionKind::Reopen => "reopen",
        InboxActionKind::Snooze => "snooze",
    };

    let client = daemon_request_client();
    let url = format!("{}/inbox/transition", daemon_url.trim_end_matches('/'));
    let mut request = client.post(url).json(&serde_json::json!({
        "user_id": user_id,
        "origin_ref": item.origin_ref,
        "action": action_name,
    }));
    if !token.trim().is_empty() {
        request = request.header("authorization", format!("Bearer {token}"));
    }

    let response = request.send().await.map_err(|err| err.to_string())?;
    let status = response.status();
    let body = response.text().await.map_err(|err| err.to_string())?;
    if !status.is_success() {
        return Err(format!("Inbox action failed: HTTP {status}: {body}"));
    }

    Ok(format!("Inbox action applied: {}", action_name))
}

async fn send_prompt(
    daemon_url: String,
    user_id: String,
    token: String,
    prompt: String,
) -> Result<String, String> {
    let client = daemon_request_client();
    let url = format!("{}/process_text", daemon_url.trim_end_matches('/'));
    let mut request = client.post(url).json(&ProcessTextRequest {
        user_id,
        text: prompt,
        prompt: None,
    });
    if !token.trim().is_empty() {
        request = request.header("authorization", format!("Bearer {token}"));
    }

    let response = request.send().await.map_err(|e| e.to_string())?;
    let status = response.status();
    let body = response.text().await.map_err(|e| e.to_string())?;
    if !status.is_success() {
        return Err(format!("HTTP {status}: {body}"));
    }

    if let Ok(json) = serde_json::from_str::<serde_json::Value>(&body) {
        if let Some(text) = extract_assistant_text(&json) {
            return Ok(text);
        }
    }

    Ok(body)
}

fn extract_assistant_text(value: &serde_json::Value) -> Option<String> {
    if let Some(text) = value.as_str() {
        return Some(text.to_string());
    }

    for key in ["response", "output", "text", "message", "content", "answer"] {
        if let Some(text) = value.get(key).and_then(|v| v.as_str()) {
            return Some(text.to_string());
        }
    }

    if let Some(message) = value.get("message") {
        if let Some(text) = message.get("content").and_then(|v| v.as_str()) {
            return Some(text.to_string());
        }
        if let Some(text) = message.get("text").and_then(|v| v.as_str()) {
            return Some(text.to_string());
        }
    }

    if let Some(choice) = value
        .get("choices")
        .and_then(|v| v.as_array())
        .and_then(|arr| arr.first())
    {
        if let Some(text) = choice
            .get("message")
            .and_then(|m| m.get("content"))
            .and_then(|v| v.as_str())
        {
            return Some(text.to_string());
        }
        if let Some(text) = choice.get("text").and_then(|v| v.as_str()) {
            return Some(text.to_string());
        }
    }

    None
}

fn daemon_request_client() -> reqwest::Client {
    reqwest::Client::builder()
        .connect_timeout(Duration::from_secs(2))
        .timeout(Duration::from_secs(60))
        .build()
        .expect("request client")
}

async fn check_daemon_health(daemon_url: String) -> DaemonHealth {
    let normalized = normalize_daemon_url(&daemon_url);
    if check_daemon_health_once(&normalized).await {
        return DaemonHealth {
            daemon_url: normalized,
            healthy: true,
            switched: false,
        };
    }

    if running_from_macos_app_bundle() {
        return DaemonHealth {
            daemon_url: normalized,
            healthy: false,
            switched: false,
        };
    }

    let (host, port) = parse_daemon_address(&normalized);
    if port == 7878 && (host == "127.0.0.1" || host == "localhost") {
        let scheme = if normalized.starts_with("https://") {
            "https"
        } else {
            "http"
        };
        let fallback = format!("{scheme}://{host}:7979");
        if check_daemon_health_once(&fallback).await {
            return DaemonHealth {
                daemon_url: fallback,
                healthy: true,
                switched: true,
            };
        }
    }

    DaemonHealth {
        daemon_url: normalized,
        healthy: false,
        switched: false,
    }
}

async fn check_daemon_health_once(daemon_url: &str) -> bool {
    let client = daemon_request_client();
    let url = format!("{}/health", daemon_url.trim_end_matches('/'));
    match client.get(url).send().await {
        Ok(resp) => resp.status().is_success(),
        Err(_) => false,
    }
}

async fn run_doctor_request(daemon_url: String, token: String) -> Result<DoctorResponse, String> {
    let client = daemon_request_client();
    let url = format!("{}/doctor", daemon_url.trim_end_matches('/'));
    let mut request = client.post(url);
    if !token.trim().is_empty() {
        request = request.header("authorization", format!("Bearer {token}"));
    }
    let response = request.send().await.map_err(|err| err.to_string())?;
    if !response.status().is_success() {
        let status = response.status();
        let text = response
            .text()
            .await
            .unwrap_or_else(|_| "Unable to read response body".to_string());
        return Err(format!("HTTP {status}: {text}"));
    }
    response
        .json::<DoctorResponse>()
        .await
        .map_err(|err| err.to_string())
}

async fn run_security_audit_request(
    daemon_url: String,
    token: String,
) -> Result<SecurityAuditResponse, String> {
    let client = daemon_request_client();
    let url = format!("{}/security_audit", daemon_url.trim_end_matches('/'));
    let mut request = client.post(url);
    if !token.trim().is_empty() {
        request = request.header("authorization", format!("Bearer {token}"));
    }
    let response = request.send().await.map_err(|err| err.to_string())?;
    if !response.status().is_success() {
        let status = response.status();
        let text = response
            .text()
            .await
            .unwrap_or_else(|_| "Unable to read response body".to_string());
        return Err(format!("HTTP {status}: {text}"));
    }
    response
        .json::<SecurityAuditResponse>()
        .await
        .map_err(|err| err.to_string())
}

async fn fetch_reminder_delivery_events(
    daemon_url: String,
    token: String,
    user_id: String,
    limit: usize,
) -> Result<Vec<String>, String> {
    let client = daemon_request_client();
    let url = format!(
        "{}/reminders/delivery_events?user_id={}&limit={}",
        daemon_url.trim_end_matches('/'),
        user_id,
        limit
    );
    let mut request = client.get(url);
    if !token.trim().is_empty() {
        request = request.header("authorization", format!("Bearer {token}"));
    }

    let response = request.send().await.map_err(|err| err.to_string())?;
    if !response.status().is_success() {
        let status = response.status();
        let body = response
            .text()
            .await
            .unwrap_or_else(|_| "Unable to read response body".to_string());
        return Err(format!(
            "Reminder delivery diagnostics failed: HTTP {status}: {body}"
        ));
    }

    let parsed = response
        .json::<ReminderDeliveryEventsApiResponse>()
        .await
        .map_err(|err| err.to_string())?;

    let lines = parsed
        .events
        .into_iter()
        .map(|event| {
            let status = event
                .get("status")
                .and_then(|v| v.as_str())
                .unwrap_or("unknown");
            let ts = event
                .get("timestamp")
                .and_then(|v| v.as_i64())
                .map(format_local_time)
                .unwrap_or_else(|| "unknown-time".to_string());
            let title = event
                .get("payload")
                .and_then(|v| v.get("title"))
                .and_then(|v| v.as_str())
                .unwrap_or("(untitled)");
            format!("[{ts}] {status} — {title}")
        })
        .collect::<Vec<_>>();

    Ok(lines)
}

async fn fetch_audit_events(
    daemon_url: String,
    token: String,
    user_id: String,
    limit: usize,
) -> Result<Vec<AuditEventRow>, String> {
    let client = daemon_request_client();
    let url = format!(
        "{}/audit/events?user_id={}&limit={}",
        daemon_url.trim_end_matches('/'),
        user_id,
        limit
    );
    let mut request = client.get(url);
    if !token.trim().is_empty() {
        request = request.header("authorization", format!("Bearer {token}"));
    }

    let response = request.send().await.map_err(|err| err.to_string())?;
    if !response.status().is_success() {
        let status = response.status();
        let body = response
            .text()
            .await
            .unwrap_or_else(|_| "Unable to read response body".to_string());
        return Err(format!(
            "Audit events request failed: HTTP {status}: {body}"
        ));
    }

    let parsed = response
        .json::<AuditEventsApiResponse>()
        .await
        .map_err(|err| err.to_string())?;

    let lines = parsed
        .events
        .into_iter()
        .map(|event| {
            let ts = event.get("timestamp").and_then(|v| v.as_i64()).unwrap_or(0);
            let ts_label = if ts > 0 {
                format_local_time(ts)
            } else {
                "unknown-time".to_string()
            };
            let event_type = event
                .get("event_type")
                .and_then(|v| v.as_str())
                .unwrap_or("event")
                .to_string();
            let status = event
                .get("status")
                .and_then(|v| v.as_str())
                .unwrap_or("unknown")
                .to_string();
            let tool = event
                .get("tool")
                .and_then(|v| v.as_str())
                .unwrap_or("-")
                .to_string();
            let user = event.get("user_id").and_then(|v| v.as_str()).unwrap_or("-");
            let actor = event
                .get("payload")
                .and_then(|v| v.get("actor"))
                .and_then(|v| v.as_str())
                .map(|v| v.to_string());
            let origin_ref = event
                .get("payload")
                .and_then(|v| v.get("origin_ref"))
                .and_then(|v| v.as_str())
                .map(|s| s.to_string());
            AuditEventRow {
                timestamp: ts,
                event_type: event_type.clone(),
                status: status.clone(),
                actor,
                line: format!(
                    "[{ts_label}] {event_type} • {tool} • {status} • {user} • origin:{}",
                    origin_ref.clone().unwrap_or_else(|| "-".to_string())
                ),
                origin_ref,
            }
        })
        .collect::<Vec<_>>();

    Ok(lines)
}

async fn fetch_solana_wallet_address(
    daemon_url: String,
    token: String,
    user_id: String,
) -> Result<Option<String>, String> {
    if user_id.trim().is_empty() {
        return Ok(None);
    }

    let client = daemon_request_client();
    let url = format!("{}/solana/wallet", daemon_url.trim_end_matches('/'));
    let mut request = client
        .get(url)
        .query(&[("user_id", user_id.as_str()), ("actor", "agent")]);
    if !token.trim().is_empty() {
        request = request.header("authorization", format!("Bearer {token}"));
    }

    let response = request.send().await.map_err(|err| err.to_string())?;
    if response.status().is_success() {
        let parsed = response
            .json::<SolanaWalletUiResponse>()
            .await
            .map_err(|err| err.to_string())?;
        if parsed.address.trim().is_empty() {
            Ok(None)
        } else {
            Ok(Some(parsed.address))
        }
    } else {
        Ok(None)
    }
}

async fn run_chat_history_request(
    daemon_url: String,
    token: String,
    user_id: String,
    limit: usize,
) -> Result<Vec<String>, String> {
    let client = daemon_request_client();
    let url = format!(
        "{}/chat_history?user_id={}&limit={}",
        daemon_url.trim_end_matches('/'),
        user_id,
        limit
    );
    let mut request = client.get(url);
    if !token.trim().is_empty() {
        request = request.header("authorization", format!("Bearer {token}"));
    }
    let response = request.send().await.map_err(|err| err.to_string())?;
    if !response.status().is_success() {
        let status = response.status();
        let text = response
            .text()
            .await
            .unwrap_or_else(|_| "Unable to read response body".to_string());
        return Err(format!("HTTP {status}: {text}"));
    }

    let value = response
        .json::<Value>()
        .await
        .map_err(|err| err.to_string())?;
    let history = value
        .get("history")
        .and_then(|v| v.as_array())
        .map(|arr| {
            arr.iter()
                .filter_map(|item| item.as_str().map(|s| s.to_string()))
                .collect::<Vec<_>>()
        })
        .unwrap_or_default();
    Ok(history)
}

async fn run_clear_user_data_request(
    daemon_url: String,
    token: String,
    user_id: String,
) -> Result<(), String> {
    let client = daemon_request_client();
    let url = format!("{}/clear_user_data", daemon_url.trim_end_matches('/'));
    let mut request = client.post(url);
    if !token.trim().is_empty() {
        request = request.header("authorization", format!("Bearer {token}"));
    }
    let response = request
        .json(&serde_json::json!({"user_id": user_id}))
        .send()
        .await
        .map_err(|err| err.to_string())?;
    if !response.status().is_success() {
        let status = response.status();
        let text = response
            .text()
            .await
            .unwrap_or_else(|_| "Unable to read response body".to_string());
        return Err(format!("HTTP {status}: {text}"));
    }
    Ok(())
}

fn optimistic_inbox_transition(
    state: &mut ButterflyIcedApp,
    origin_ref: &str,
    action: InboxActionKind,
) {
    let Some(item) = state
        .inbox_items
        .iter_mut()
        .find(|item| item.origin_ref == origin_ref)
    else {
        return;
    };

    let Some(previous) = parse_inbox_status_state(item.status) else {
        return;
    };
    let action = match action {
        InboxActionKind::Acknowledge => crate::inbox_fsm::InboxAction::Acknowledge,
        InboxActionKind::Start => crate::inbox_fsm::InboxAction::Start,
        InboxActionKind::Block => crate::inbox_fsm::InboxAction::Block,
        InboxActionKind::Done => crate::inbox_fsm::InboxAction::Done,
        InboxActionKind::Reopen => crate::inbox_fsm::InboxAction::Reopen,
        InboxActionKind::Snooze => crate::inbox_fsm::InboxAction::Snooze,
    };
    let Some(next) = crate::inbox_fsm::transition(previous, action) else {
        return;
    };
    item.status = next;
    item.updated_at = now_unix_ts();
}

fn parse_inbox_status_state(status: InboxStatus) -> Option<crate::inbox_fsm::InboxState> {
    match status {
        InboxStatus::New => Some(crate::inbox_fsm::InboxState::New),
        InboxStatus::Acknowledged => Some(crate::inbox_fsm::InboxState::Acknowledged),
        InboxStatus::InProgress => Some(crate::inbox_fsm::InboxState::InProgress),
        InboxStatus::Blocked => Some(crate::inbox_fsm::InboxState::Blocked),
        InboxStatus::Done => Some(crate::inbox_fsm::InboxState::Done),
        InboxStatus::Dismissed => Some(crate::inbox_fsm::InboxState::Dismissed),
    }
}

fn parse_history_entry(line: &str) -> Option<(MessageRole, String, Option<i64>)> {
    let trimmed = line.trim();
    if trimmed.is_empty() {
        return None;
    }

    let mut parsed_ts: Option<i64> = None;
    if let Some(close_idx) = trimmed.find(']') {
        if trimmed.starts_with('[') && close_idx > 1 {
            let bracket_ts = &trimmed[1..close_idx];
            parsed_ts = parse_history_bracket_timestamp(bracket_ts);
        }
    }

    let normalized = if let Some(idx) = trimmed.find("] ") {
        let (left, right) = trimmed.split_at(idx + 2);
        if left.starts_with('[') {
            right.trim()
        } else {
            trimmed
        }
    } else {
        trimmed
    };

    for (marker, role) in [
        ("user:", MessageRole::User),
        ("assistant:", MessageRole::Bot),
        ("system:", MessageRole::System),
    ] {
        if let Some(value) = normalized.strip_prefix(marker) {
            return Some((role, value.trim().to_string(), parsed_ts));
        }
        let with_space = format!(" {marker}");
        if let Some(pos) = normalized.find(&with_space) {
            let body = normalized[(pos + with_space.len())..].trim();
            return Some((role, body.to_string(), parsed_ts));
        }
    }

    let (role, rest) = if let Some(value) = trimmed.strip_prefix("user:") {
        (MessageRole::User, value.trim())
    } else if let Some(value) = trimmed.strip_prefix("assistant:") {
        (MessageRole::Bot, value.trim())
    } else {
        (MessageRole::System, trimmed)
    };

    if let Some((ts_text, body)) = rest.split_once(' ') {
        if let Ok(ts) = ts_text.parse::<i64>() {
            return Some((role, body.trim().to_string(), Some(ts)));
        }
    }

    Some((role, rest.to_string(), parsed_ts))
}

fn find_latest_chat_message_id(
    messages: &[ChatMessage],
    origin_ref: &str,
    at_or_before_ts: Option<i64>,
) -> Option<u64> {
    let by_origin = messages
        .iter()
        .rev()
        .find(|msg| {
            msg.text.contains(origin_ref)
                && at_or_before_ts
                    .map(|ts| msg.timestamp <= ts)
                    .unwrap_or(true)
        })
        .map(|msg| msg.id);
    if by_origin.is_some() {
        return by_origin;
    }

    at_or_before_ts.and_then(|ts| {
        messages
            .iter()
            .rev()
            .find(|msg| msg.timestamp <= ts)
            .map(|msg| msg.id)
    })
}

fn maybe_emit_proactive_chat_nudge(state: &mut ButterflyIcedApp) {
    if !state.settings.proactive_chat_enabled {
        return;
    }

    if in_quiet_hours(
        &state.settings.proactive_chat_quiet_start_hhmm,
        &state.settings.proactive_chat_quiet_end_hhmm,
    ) {
        return;
    }

    let now = now_unix_ts();
    let min_interval_seconds = state
        .settings
        .proactive_chat_min_interval_seconds
        .trim()
        .parse::<i64>()
        .unwrap_or(45)
        .max(5);
    let severity = state
        .settings
        .proactive_chat_severity
        .trim()
        .to_ascii_lowercase();
    let blocked_only = severity == "blocked_only";

    let candidates = state
        .inbox_items
        .iter()
        .filter(|item| {
            item.requires_human_action
                && item.status.is_actionable()
                && if blocked_only {
                    item.status == InboxStatus::Blocked
                } else {
                    item.status == InboxStatus::Blocked
                        || item.due_at.map(|due| due <= now).unwrap_or(false)
                }
        })
        .collect::<Vec<_>>();

    let active_origin_refs = candidates
        .iter()
        .map(|item| item.origin_ref.clone())
        .collect::<HashSet<_>>();
    state
        .proactive_notified_origin_refs
        .retain(|origin_ref| active_origin_refs.contains(origin_ref));

    if now.saturating_sub(state.proactive_last_chat_ts) < min_interval_seconds {
        return;
    }

    let mut ordered = candidates;
    ordered.sort_by_key(|item| {
        (
            item.status != InboxStatus::Blocked,
            priority_rank(item.priority),
            item.due_at.unwrap_or(i64::MAX),
        )
    });

    let Some(item) = ordered
        .into_iter()
        .find(|item| {
            !state
                .proactive_notified_origin_refs
                .contains(&item.origin_ref)
        })
        .cloned()
    else {
        return;
    };

    let due_label = item
        .due_at
        .map(format_local_time)
        .unwrap_or_else(|| "no due date".to_string());
    let ask = if item.status == InboxStatus::Blocked {
        "I am blocked and need your decision to unblock this."
    } else {
        "This is overdue and needs your action now."
    };

    state.push_chat(
        MessageRole::Bot,
        format!(
            "Heads up: {} ({}) • due: {} • ref: {}. {} Next step: choose Acknowledge/Start/Done in Inbox.",
            item.title,
            inbox_status_label(item.status),
            due_label,
            item.origin_ref,
            ask
        ),
    );
    state
        .proactive_notified_origin_refs
        .insert(item.origin_ref.clone());
    state.proactive_last_chat_ts = now;
    state.push_activity(format!(
        "agent proactive chat nudge sent for {}",
        item.origin_ref
    ));
}

fn parse_hhmm_to_minutes(value: &str) -> Option<u16> {
    let trimmed = value.trim();
    if trimmed.is_empty() {
        return None;
    }
    let (h, m) = trimmed.split_once(':')?;
    let hour = h.trim().parse::<u16>().ok()?;
    let minute = m.trim().parse::<u16>().ok()?;
    if hour > 23 || minute > 59 {
        return None;
    }
    Some(hour * 60 + minute)
}

fn in_quiet_hours(start_hhmm: &str, end_hhmm: &str) -> bool {
    let Some(start) = parse_hhmm_to_minutes(start_hhmm) else {
        return false;
    };
    let Some(end) = parse_hhmm_to_minutes(end_hhmm) else {
        return false;
    };

    let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
    let now_local = OffsetDateTime::now_utc().to_offset(local_offset);
    let current = now_local.hour() as u16 * 60 + now_local.minute() as u16;

    if start == end {
        return false;
    }
    if start < end {
        current >= start && current < end
    } else {
        current >= start || current < end
    }
}

fn scroll_chat_to_anchor_task(state: &ButterflyIcedApp) -> Task<Message> {
    let Some(anchor_id) = state.chat_anchor_message_id else {
        return Task::none();
    };

    let Some(index) = state
        .chat_messages
        .iter()
        .position(|msg| msg.id == anchor_id)
    else {
        return Task::none();
    };

    let denom = (state.chat_messages.len().saturating_sub(1)).max(1) as f32;
    let y = (index as f32 / denom).clamp(0.0, 1.0);

    iced::widget::operation::snap_to(
        state.chat_scroll_id.clone(),
        iced::widget::operation::RelativeOffset { x: 0.0, y },
    )
}

fn parse_history_bracket_timestamp(raw: &str) -> Option<i64> {
    let trimmed = raw.trim();
    if trimmed.is_empty() {
        return None;
    }

    if let Ok(epoch) = trimmed.parse::<i64>() {
        return Some(epoch);
    }

    const TS_MINUTE: &[::time::format_description::FormatItem<'static>] =
        ::time::macros::format_description!("[year]-[month]-[day] [hour]:[minute]");
    const TS_SECOND: &[::time::format_description::FormatItem<'static>] =
        ::time::macros::format_description!("[year]-[month]-[day] [hour]:[minute]:[second]");

    let parsed = PrimitiveDateTime::parse(trimmed, TS_SECOND)
        .ok()
        .or_else(|| PrimitiveDateTime::parse(trimmed, TS_MINUTE).ok())?;

    Some(parsed.assume_utc().unix_timestamp())
}

fn now_unix_ts() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0)
}

fn parse_yyyy_mm_dd_to_unix(value: &str) -> Option<i64> {
    let normalized = value.trim().replace('/', "-");
    let mut parts = normalized.split('-');
    let year = parts.next()?.trim().parse::<i32>().ok()?;
    let month = parts.next()?.trim().parse::<u8>().ok()?;
    let day = parts.next()?.trim().parse::<u8>().ok()?;
    if parts.next().is_some() {
        return None;
    }
    let date = Date::from_calendar_date(year, Month::try_from(month).ok()?, day).ok()?;
    let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
    Some(
        PrimitiveDateTime::new(date, Time::MIDNIGHT)
            .assume_offset(local_offset)
            .unix_timestamp(),
    )
}

fn normalize_nlp_due_text(input: &str) -> String {
    static FROM_START_RE: OnceLock<Regex> = OnceLock::new();
    static FROM_NOW_RE: OnceLock<Regex> = OnceLock::new();

    let from_start_re = FROM_START_RE.get_or_init(|| {
        Regex::new(r"(?i)\b(\d+)\s*(day|days|week|weeks|month|months)\s+from\s+start\b").unwrap()
    });
    let from_now_re = FROM_NOW_RE.get_or_init(|| {
        Regex::new(r"(?i)\b(\d+)\s*(day|days|week|weeks|month|months)\s+from\s+now\b").unwrap()
    });

    let normalized = from_start_re.replace_all(input, "in $1 $2").to_string();
    from_now_re.replace_all(&normalized, "in $1 $2").to_string()
}

fn text_has_explicit_time(input: &str) -> bool {
    static TIME_RE: OnceLock<Regex> = OnceLock::new();
    let re = TIME_RE.get_or_init(|| {
        Regex::new(r"(?i)(\b\d{1,2}:\d{2}\b|\b\d{1,2}\s*(am|pm)\b|\bnoon\b|\bmidnight\b)").unwrap()
    });
    re.is_match(input)
}

fn local_midnight_unix(ts: i64) -> i64 {
    let Ok(utc_dt) = OffsetDateTime::from_unix_timestamp(ts) else {
        return ts;
    };
    let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
    let local_dt = utc_dt.to_offset(local_offset);
    let date = local_dt.date();
    PrimitiveDateTime::new(date, Time::MIDNIGHT)
        .assume_offset(local_offset)
        .unix_timestamp()
}

fn extract_explicit_year(input: &str) -> Option<i32> {
    static YEAR_RE: OnceLock<Regex> = OnceLock::new();
    let re = YEAR_RE.get_or_init(|| Regex::new(r"\b(20\d{2})\b").unwrap());
    re.captures(input)
        .and_then(|caps| caps.get(1))
        .and_then(|m| m.as_str().parse::<i32>().ok())
}

fn with_local_year(ts: i64, year: i32) -> Option<i64> {
    let utc_dt = OffsetDateTime::from_unix_timestamp(ts).ok()?;
    let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
    let local_dt = utc_dt.to_offset(local_offset);
    let month = local_dt.month();
    let mut day = local_dt.day();

    let date = loop {
        if let Ok(value) = Date::from_calendar_date(year, month, day) {
            break value;
        }
        if day == 1 {
            return None;
        }
        day -= 1;
    };

    let pdt = PrimitiveDateTime::new(
        date,
        Time::from_hms(local_dt.hour(), local_dt.minute(), local_dt.second()).ok()?,
    );
    Some(pdt.assume_offset(local_offset).unix_timestamp())
}

fn normalize_due_year_if_stale(ts: i64, input: &str) -> i64 {
    let now = now_unix_ts();
    let Some(explicit_year) = extract_explicit_year(input) else {
        return ts;
    };

    let Ok(now_local) = OffsetDateTime::from_unix_timestamp(now) else {
        return ts;
    };
    let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
    let current_year = now_local.to_offset(local_offset).year();

    if explicit_year >= current_year {
        return ts;
    }

    if let Some(mut adjusted) = with_local_year(ts, current_year) {
        if adjusted < now {
            if let Some(next_year) = with_local_year(ts, current_year + 1) {
                adjusted = next_year;
            }
        }
        return adjusted;
    }

    ts
}

fn infer_due_at_from_text(text: &str, anchor_ts: i64) -> Option<i64> {
    let trimmed = text.trim();
    if trimmed.is_empty() {
        return None;
    }

    static LABELED_RE: OnceLock<Regex> = OnceLock::new();
    static DATE_RE: OnceLock<Regex> = OnceLock::new();

    let labeled_re = LABELED_RE.get_or_init(|| {
        Regex::new(r"(?i)(?:due(?:\s+date)?|deadline)\s*:\s*(\d{4}[-/]\d{2}[-/]\d{2})").unwrap()
    });
    if let Some(value) = labeled_re
        .captures(trimmed)
        .and_then(|caps| caps.get(1))
        .map(|m| m.as_str())
    {
        if let Some(ts) = parse_yyyy_mm_dd_to_unix(value) {
            return Some(ts);
        }
    }

    let date_re = DATE_RE.get_or_init(|| Regex::new(r"(\d{4}[-/]\d{2}[-/]\d{2})").unwrap());
    if let Some(ts) = date_re
        .captures(trimmed)
        .and_then(|caps| caps.get(1))
        .and_then(|m| parse_yyyy_mm_dd_to_unix(m.as_str()))
    {
        return Some(normalize_due_year_if_stale(ts, trimmed));
    }

    let normalized = normalize_nlp_due_text(trimmed);
    let anchor = Local
        .timestamp_opt(anchor_ts, 0)
        .single()
        .or_else(|| Local.timestamp_opt(now_unix_ts(), 0).single())
        .unwrap_or_else(Local::now);

    parse_date_string(&normalized, anchor, Dialect::Us)
        .or_else(|_| parse_date_string(&normalized, anchor, Dialect::Uk))
        .ok()
        .map(|dt: DateTime<Local>| {
            let ts = dt.timestamp();
            let parsed = if text_has_explicit_time(trimmed) {
                ts
            } else {
                local_midnight_unix(ts)
            };
            normalize_due_year_if_stale(parsed, trimmed)
        })
}

fn infer_due_at_from_item_text(item: &InboxItem) -> Option<i64> {
    infer_due_at_from_text(&item.title, item.created_at).or_else(|| {
        item.details
            .as_deref()
            .and_then(|details| infer_due_at_from_text(details, item.created_at))
    })
}

fn format_local_time(ts: i64) -> String {
    let Ok(utc_dt) = OffsetDateTime::from_unix_timestamp(ts) else {
        return "1970-01-01 00:00:00".to_string();
    };

    let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
    let local_dt = utc_dt.to_offset(local_offset);

    format!(
        "{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
        local_dt.year(),
        u8::from(local_dt.month()),
        local_dt.day(),
        local_dt.hour(),
        local_dt.minute(),
        local_dt.second()
    )
}

fn format_due_badge_time(ts: i64) -> String {
    let Ok(utc_dt) = OffsetDateTime::from_unix_timestamp(ts) else {
        return "1970-01-01".to_string();
    };
    let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
    let local_dt = utc_dt.to_offset(local_offset);
    if local_dt.hour() == 0 && local_dt.minute() == 0 && local_dt.second() == 0 {
        format!(
            "{:04}-{:02}-{:02}",
            local_dt.year(),
            u8::from(local_dt.month()),
            local_dt.day()
        )
    } else {
        format_local_time(ts)
    }
}

fn display_posture_level(value: &str) -> &'static str {
    match value.trim().to_ascii_lowercase().as_str() {
        "low" | "pass" => "high",
        "warn" | "medium" => "medium",
        "high" | "critical" | "fail" => "low",
        _ => "unknown",
    }
}

fn derive_doctor_overall(checks: &[DoctorCheckResponse]) -> &'static str {
    let has_fail = checks.iter().any(|check| check.status == "fail");
    let has_warn = checks.iter().any(|check| check.status == "warn");

    if has_fail {
        "fail"
    } else if has_warn {
        "warn"
    } else {
        "pass"
    }
}

fn parse_markdown_items(input: &str) -> Vec<markdown::Item> {
    markdown::parse(input).collect()
}

fn markdown_render_settings() -> markdown::Settings {
    let mut settings = markdown::Settings::with_text_size(15, Theme::Dark);
    settings.h1_size = 30.0.into();
    settings.h2_size = 25.0.into();
    settings.h3_size = 21.0.into();
    settings.h4_size = 18.0.into();
    settings.h5_size = 16.0.into();
    settings.h6_size = 15.0.into();
    settings.code_size = 13.0.into();
    settings.spacing = 10.0.into();
    settings
}

fn open_uri_best_effort(uri: &str) -> std::io::Result<()> {
    if uri.trim().is_empty() {
        return Ok(());
    }
    #[cfg(target_os = "linux")]
    {
        Command::new("xdg-open")
            .arg(uri)
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .spawn()
            .map(|_| ())
    }
    #[cfg(target_os = "macos")]
    {
        Command::new("open")
            .arg(uri)
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .spawn()
            .map(|_| ())
    }
    #[cfg(target_os = "windows")]
    {
        Command::new("cmd")
            .args(["/C", "start", "", uri])
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .spawn()
            .map(|_| ())
    }
}

async fn check_external_start_status(daemon_url: String) -> Result<String, String> {
    let health = check_daemon_health(daemon_url).await;
    if health.healthy {
        if health.switched {
            Ok(format!("External daemon healthy ({})", health.daemon_url))
        } else {
            Ok("External daemon healthy".to_string())
        }
    } else {
        Err("External daemon not reachable".to_string())
    }
}

fn env_flag_enabled(name: &str, default: bool) -> bool {
    match std::env::var(name) {
        Ok(value) => {
            let normalized = value.trim().to_ascii_lowercase();
            matches!(normalized.as_str(), "1" | "true" | "yes" | "on")
        }
        Err(_) => default,
    }
}

fn normalize_daemon_url(daemon: &str) -> String {
    let trimmed = daemon.trim();
    let (scheme, rest) = if let Some(value) = trimmed.strip_prefix("https://") {
        ("https://", value)
    } else if let Some(value) = trimmed.strip_prefix("http://") {
        ("http://", value)
    } else {
        ("http://", trimmed)
    };
    let host_port = rest.split('/').next().unwrap_or("127.0.0.1:7878");
    format!("{scheme}{host_port}")
}

fn parse_daemon_address(daemon: &str) -> (String, u16) {
    let trimmed = daemon.trim();
    let without_scheme = trimmed
        .strip_prefix("http://")
        .or_else(|| trimmed.strip_prefix("https://"))
        .unwrap_or(trimmed);
    let host_port = without_scheme.split('/').next().unwrap_or("127.0.0.1:7878");
    let mut parts = host_port.splitn(2, ':');
    let host = parts.next().unwrap_or("127.0.0.1");
    let port = parts
        .next()
        .and_then(|value| value.parse::<u16>().ok())
        .unwrap_or(7878);
    (host.to_string(), port)
}

struct DaemonControl {
    child: Child,
}

fn daemon_control() -> &'static Mutex<Option<DaemonControl>> {
    static CONTROL: OnceLock<Mutex<Option<DaemonControl>>> = OnceLock::new();
    CONTROL.get_or_init(|| Mutex::new(None))
}

fn daemon_binary_candidates() -> Vec<PathBuf> {
    let mut candidates = Vec::new();
    if let Ok(current) = std::env::current_exe() {
        if let Some(dir) = current.parent() {
            candidates.push(dir.join("butterfly-botd"));
            if let Some(parent) = dir.parent() {
                candidates.push(parent.join("butterfly-botd"));
                candidates.push(parent.join("Resources").join("butterfly-botd"));
            }
        }
    }
    candidates.push(PathBuf::from("butterfly-botd"));
    candidates
}

fn running_from_macos_app_bundle() -> bool {
    #[cfg(target_os = "macos")]
    {
        if let Ok(current) = std::env::current_exe() {
            let path = current.to_string_lossy();
            return path.contains(".app/Contents/MacOS/");
        }
    }
    false
}

async fn start_local_daemon(
    daemon_url: String,
    db_path: String,
    token: String,
) -> Result<String, String> {
    let (host, port) = parse_daemon_address(&daemon_url);
    let strict_bundle_mode = running_from_macos_app_bundle();
    let mut selected = None;
    for candidate in daemon_binary_candidates() {
        if strict_bundle_mode && candidate == Path::new("butterfly-botd") {
            continue;
        }
        if candidate.exists() || candidate == Path::new("butterfly-botd") {
            selected = Some(candidate);
            break;
        }
    }
    let Some(binary) = selected else {
        if strict_bundle_mode {
            return Err(
                "Could not find bundled butterfly-botd in app bundle. Rebuild the mac app with ./scripts/build-macos-app.sh."
                    .to_string(),
            );
        }
        return Err("Could not find butterfly-botd binary".to_string());
    };

    let mut command = Command::new(binary);
    command
        .arg("--host")
        .arg(&host)
        .arg("--port")
        .arg(port.to_string())
        .env("BUTTERFLY_BOT_DB", db_path)
        .stdout(Stdio::null())
        .stderr(Stdio::null());
    if !token.trim().is_empty() {
        command.env("BUTTERFLY_BOT_TOKEN", token);
    }

    let child = command
        .spawn()
        .map_err(|err| format!("Failed to spawn daemon: {err}"))?;
    if let Ok(mut control) = daemon_control().lock() {
        *control = Some(DaemonControl { child });
    }

    let client = daemon_request_client();
    let health_url = format!("{}/health", daemon_url.trim_end_matches('/'));
    for _ in 0..30 {
        tokio::time::sleep(Duration::from_millis(500)).await;
        if let Ok(resp) = client.get(&health_url).send().await {
            if resp.status().is_success() {
                return Ok("Daemon started".to_string());
            }
        }
    }
    Err("Daemon start timed out".to_string())
}

async fn stop_local_daemon() -> Result<String, String> {
    let mut control = daemon_control()
        .lock()
        .map_err(|_| "Failed to lock daemon control".to_string())?;
    if let Some(mut daemon) = control.take() {
        daemon
            .child
            .kill()
            .map_err(|err| format!("Failed to stop daemon: {err}"))?;
        let _ = daemon.child.wait();
        Ok("Daemon stopped".to_string())
    } else {
        Ok("No local daemon process to stop".to_string())
    }
}

async fn stop_daemon_by_url(daemon_url: String) -> Result<String, String> {
    match stop_local_daemon().await {
        Ok(status) if status == "Daemon stopped" => Ok(status),
        Ok(_) | Err(_) => {
            let (_host, port) = parse_daemon_address(&daemon_url);

            #[cfg(any(target_os = "linux", target_os = "macos"))]
            {
                let pattern = format!("butterfly-botd.*--port {port}");
                let status = Command::new("pkill")
                    .arg("-f")
                    .arg(&pattern)
                    .status()
                    .map_err(|err| format!("Failed to stop daemon process: {err}"))?;

                if !status.success() {
                    let _ = Command::new("pkill")
                        .arg("-f")
                        .arg("butterfly-botd")
                        .status();
                }

                tokio::time::sleep(Duration::from_millis(150)).await;
                let _ = Command::new("pkill")
                    .arg("-9")
                    .arg("-f")
                    .arg(&pattern)
                    .status();
                let _ = Command::new("pkill")
                    .arg("-9")
                    .arg("-f")
                    .arg("butterfly-botd")
                    .status();

                tokio::time::sleep(Duration::from_millis(300)).await;
                if !check_daemon_health_once(&daemon_url).await {
                    return Ok("Daemon stopped".to_string());
                }

                Err("Daemon still reachable after stop attempt".to_string())
            }

            #[cfg(not(any(target_os = "linux", target_os = "macos")))]
            {
                Err("No local daemon process to stop".to_string())
            }
        }
    }
}

fn stop_local_daemon_blocking() -> Result<String, String> {
    let mut control = daemon_control()
        .lock()
        .map_err(|_| "Failed to lock daemon control".to_string())?;
    if let Some(mut daemon) = control.take() {
        daemon
            .child
            .kill()
            .map_err(|err| format!("Failed to stop daemon: {err}"))?;
        let _ = daemon.child.wait();
        Ok("Daemon stopped".to_string())
    } else {
        Ok("No local daemon process to stop".to_string())
    }
}

fn kill_all_daemons_best_effort() {
    let _ = stop_local_daemon_blocking();

    #[cfg(any(target_os = "linux", target_os = "macos"))]
    {
        let _ = Command::new("pkill")
            .arg("-f")
            .arg("butterfly-botd")
            .status();
        std::thread::sleep(std::time::Duration::from_millis(120));
        let _ = Command::new("pkill")
            .arg("-9")
            .arg("-f")
            .arg("butterfly-botd")
            .status();
    }
}

fn markdown_source_from_input(value: &str) -> crate::config::MarkdownSource {
    let trimmed = value.trim();
    if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
        crate::config::MarkdownSource::Url {
            url: trimmed.to_string(),
        }
    } else {
        crate::config::MarkdownSource::Database {
            markdown: value.to_string(),
        }
    }
}

async fn load_settings(db_path: String) -> Result<LoadedSettings, String> {
    tokio::task::spawn_blocking(move || {
        fn get_path<'a>(value: &'a Value, path: &[&str]) -> Option<&'a Value> {
            let mut cursor = value;
            for key in path {
                cursor = cursor.get(*key)?;
            }
            Some(cursor)
        }

        fn parse_server_rows(value: Option<&Value>) -> Vec<UiServerRow> {
            value
                .and_then(|v| v.as_array())
                .map(|servers| {
                    servers
                        .iter()
                        .filter_map(|entry| {
                            let name = entry
                                .get("name")
                                .and_then(|v| v.as_str())
                                .unwrap_or_default()
                                .trim()
                                .to_string();
                            let url = entry
                                .get("url")
                                .and_then(|v| v.as_str())
                                .unwrap_or_default()
                                .trim()
                                .to_string();
                            if name.is_empty() && url.is_empty() {
                                return None;
                            }
                            let (header_key, header_value) = entry
                                .get("headers")
                                .and_then(|v| v.as_object())
                                .and_then(|map| {
                                    map.iter().find_map(|(k, v)| {
                                        v.as_str().map(|value| {
                                            (k.trim().to_string(), value.trim().to_string())
                                        })
                                    })
                                })
                                .unwrap_or_else(|| (String::new(), String::new()));
                            Some(UiServerRow {
                                name,
                                url,
                                header_key,
                                header_value,
                            })
                        })
                        .collect::<Vec<_>>()
                })
                .unwrap_or_default()
        }

        let mut config = crate::config::Config::from_store(&db_path)
            .map_err(|err| format!("Failed to load config: {err}"))?;

        if config.provider.is_none() {
            config.provider = Some(crate::config::ProviderConfig {
                runtime: crate::config::RuntimeProvider::Openai,
            });
        }

        let github_pat = crate::vault::get_secret_required("github_pat")
            .map_err(|err| err.to_string())?
            .unwrap_or_default();
        let zapier_token = crate::vault::get_secret_required("zapier_token")
            .map_err(|err| err.to_string())?
            .unwrap_or_default();
        let openai_api_key = crate::vault::get_secret_required("openai_api_key")
            .map_err(|err| err.to_string())?
            .unwrap_or_default();
        let grok_api_key = crate::vault::get_secret_required("search_internet_grok_api_key")
            .map_err(|err| err.to_string())?
            .unwrap_or_default();

        let tools = config.tools.as_ref().unwrap_or(&Value::Null);
        let solana_rpc_endpoint = get_path(tools, &["settings", "solana", "rpc", "endpoint"])
            .and_then(|v| v.as_str())
            .unwrap_or_default()
            .to_string();

        let search_network_allow = get_path(tools, &["settings", "permissions", "network_allow"])
            .and_then(|v| v.as_array())
            .map(|arr| {
                arr.iter()
                    .filter_map(|v| v.as_str())
                    .collect::<Vec<_>>()
                    .join(",")
            })
            .unwrap_or_default();
        let wakeup_poll_seconds = get_path(tools, &["wakeup", "poll_seconds"])
            .and_then(|v| v.as_u64())
            .unwrap_or(60)
            .to_string();
        let tpm_mode = get_path(tools, &["settings", "security", "tpm_mode"])
            .and_then(|v| v.as_str())
            .unwrap_or("auto")
            .to_string();
        let proactive_chat_enabled = get_path(tools, &["settings", "proactive_chat", "enabled"])
            .and_then(|v| v.as_bool())
            .unwrap_or(true);
        let proactive_chat_min_interval_seconds = get_path(
            tools,
            &["settings", "proactive_chat", "min_interval_seconds"],
        )
        .and_then(|v| v.as_u64())
        .unwrap_or(45)
        .to_string();
        let proactive_chat_severity = get_path(tools, &["settings", "proactive_chat", "severity"])
            .and_then(|v| v.as_str())
            .unwrap_or("blocked_or_overdue")
            .to_string();
        let proactive_chat_quiet_start_hhmm =
            get_path(tools, &["settings", "proactive_chat", "quiet_start_hhmm"])
                .and_then(|v| v.as_str())
                .unwrap_or_default()
                .to_string();
        let proactive_chat_quiet_end_hhmm =
            get_path(tools, &["settings", "proactive_chat", "quiet_end_hhmm"])
                .and_then(|v| v.as_str())
                .unwrap_or_default()
                .to_string();
        let mut mcp_servers = parse_server_rows(get_path(tools, &["mcp", "servers"]));
        let mut http_call_servers = parse_server_rows(get_path(tools, &["http_call", "servers"]));

        if http_call_servers.is_empty() {
            let shared_header = get_path(tools, &["http_call", "custom_headers"])
                .and_then(|v| v.as_object())
                .and_then(|map| {
                    map.iter().find_map(|(k, v)| {
                        v.as_str()
                            .map(|value| (k.trim().to_string(), value.trim().to_string()))
                    })
                })
                .unwrap_or_else(|| (String::new(), String::new()));

            if let Some(base_urls) =
                get_path(tools, &["http_call", "base_urls"]).and_then(|v| v.as_array())
            {
                for (index, value) in base_urls.iter().enumerate() {
                    if let Some(url) = value.as_str() {
                        let trimmed = url.trim();
                        if !trimmed.is_empty() {
                            http_call_servers.push(UiServerRow {
                                name: format!("server_{}", index + 1),
                                url: trimmed.to_string(),
                                header_key: shared_header.0.clone(),
                                header_value: shared_header.1.clone(),
                            });
                        }
                    }
                }
            }
        }

        let prompt_text = match &config.prompt_source {
            crate::config::MarkdownSource::Url { url } => url.clone(),
            crate::config::MarkdownSource::Database { markdown } => markdown.clone(),
        };
        let heartbeat_text = match &config.heartbeat_source {
            crate::config::MarkdownSource::Url { url } => url.clone(),
            crate::config::MarkdownSource::Database { markdown } => markdown.clone(),
        };

        Ok(LoadedSettings {
            form: SettingsForm {
                github_pat,
                zapier_token,
                openai_api_key,
                grok_api_key,
                solana_rpc_endpoint,
                search_network_allow,
                wakeup_poll_seconds,
                tpm_mode,
                proactive_chat_enabled,
                proactive_chat_min_interval_seconds,
                proactive_chat_severity,
                proactive_chat_quiet_start_hhmm,
                proactive_chat_quiet_end_hhmm,
                mcp_servers: std::mem::take(&mut mcp_servers),
                http_call_servers: std::mem::take(&mut http_call_servers),
                prompt_text,
                heartbeat_text,
            },
        })
    })
    .await
    .map_err(|err| err.to_string())?
}

async fn save_settings(
    db_path: String,
    form: SettingsForm,
    daemon_url: String,
    token: String,
) -> Result<String, String> {
    let config = tokio::task::spawn_blocking({
        let db_path = db_path.clone();
        let form = form.clone();
        move || {
            let mut config = crate::config::Config::from_store(&db_path)
                .map_err(|err| format!("Failed to load config: {err}"))?;

            config.provider = Some(crate::config::ProviderConfig {
                runtime: crate::config::RuntimeProvider::Openai,
            });

            let openai = config.openai.get_or_insert(crate::config::OpenAiConfig {
                api_key: None,
                model: None,
                base_url: None,
            });
            openai.base_url = Some("https://api.openai.com/v1".to_string());
            openai.model = Some("gpt-4.1-mini".to_string());
            openai.api_key = None;

            let memory = config.memory.get_or_insert(crate::config::MemoryConfig {
                enabled: Some(true),
                sqlite_path: Some(db_path.clone()),
                summary_model: None,
                embedding_model: None,
                rerank_model: None,
                openai: None,
                context_embed_enabled: Some(false),
                summary_threshold: None,
                retention_days: None,
            });
            memory.enabled = Some(true);
            memory.summary_model = Some("gpt-4.1-mini".to_string());
            memory.embedding_model = Some("text-embedding-3-small".to_string());
            memory.rerank_model = Some("gpt-4.1-mini".to_string());
            memory.openai = None;

            config.prompt_source = markdown_source_from_input(&form.prompt_text);
            config.heartbeat_source = markdown_source_from_input(&form.heartbeat_text);

            crate::vault::set_secret_required("github_pat", &form.github_pat)
                .map_err(|err| format!("Failed to store GitHub PAT: {err}"))?;
            crate::vault::set_secret_required("zapier_token", &form.zapier_token)
                .map_err(|err| format!("Failed to store Zapier token: {err}"))?;
            crate::vault::set_secret_required("openai_api_key", &form.openai_api_key)
                .map_err(|err| format!("Failed to store OpenAI API key: {err}"))?;
            crate::vault::set_secret_required("search_internet_grok_api_key", &form.grok_api_key)
                .map_err(|err| format!("Failed to store search API key: {err}"))?;

            let tools = config
                .tools
                .get_or_insert_with(|| Value::Object(serde_json::Map::new()));
            let tools_obj = tools
                .as_object_mut()
                .ok_or_else(|| "tools must be an object".to_string())?;
            {
                let settings = tools_obj
                    .entry("settings")
                    .or_insert_with(|| Value::Object(serde_json::Map::new()));
                let settings_obj = settings
                    .as_object_mut()
                    .ok_or_else(|| "tools.settings must be an object".to_string())?;
                let permissions = settings_obj
                    .entry("permissions")
                    .or_insert_with(|| Value::Object(serde_json::Map::new()));
                let permissions_obj = permissions
                    .as_object_mut()
                    .ok_or_else(|| "tools.settings.permissions must be an object".to_string())?;
                permissions_obj.insert("default_deny".to_string(), Value::Bool(true));
                let allow_items = form
                    .search_network_allow
                    .split(',')
                    .map(|s| s.trim())
                    .filter(|s| !s.is_empty())
                    .map(|s| Value::String(s.to_string()))
                    .collect::<Vec<_>>();
                permissions_obj.insert("network_allow".to_string(), Value::Array(allow_items));

                let security = settings_obj
                    .entry("security")
                    .or_insert_with(|| Value::Object(serde_json::Map::new()));
                let security_obj = security
                    .as_object_mut()
                    .ok_or_else(|| "tools.settings.security must be an object".to_string())?;
                security_obj.insert("tpm_mode".to_string(), Value::String(form.tpm_mode.clone()));

                let proactive_chat = settings_obj
                    .entry("proactive_chat")
                    .or_insert_with(|| Value::Object(serde_json::Map::new()));
                let proactive_chat_obj = proactive_chat
                    .as_object_mut()
                    .ok_or_else(|| "tools.settings.proactive_chat must be an object".to_string())?;
                let proactive_min_interval = form
                    .proactive_chat_min_interval_seconds
                    .trim()
                    .parse::<u64>()
                    .unwrap_or(45)
                    .max(5);
                proactive_chat_obj.insert(
                    "enabled".to_string(),
                    Value::Bool(form.proactive_chat_enabled),
                );
                proactive_chat_obj.insert(
                    "min_interval_seconds".to_string(),
                    Value::Number(serde_json::Number::from(proactive_min_interval)),
                );
                proactive_chat_obj.insert(
                    "severity".to_string(),
                    Value::String(form.proactive_chat_severity.trim().to_ascii_lowercase()),
                );
                proactive_chat_obj.insert(
                    "quiet_start_hhmm".to_string(),
                    Value::String(form.proactive_chat_quiet_start_hhmm.trim().to_string()),
                );
                proactive_chat_obj.insert(
                    "quiet_end_hhmm".to_string(),
                    Value::String(form.proactive_chat_quiet_end_hhmm.trim().to_string()),
                );

                let solana = settings_obj
                    .entry("solana")
                    .or_insert_with(|| Value::Object(serde_json::Map::new()));
                let solana_obj = solana
                    .as_object_mut()
                    .ok_or_else(|| "tools.settings.solana must be an object".to_string())?;
                let rpc = solana_obj
                    .entry("rpc")
                    .or_insert_with(|| Value::Object(serde_json::Map::new()));
                let rpc_obj = rpc
                    .as_object_mut()
                    .ok_or_else(|| "tools.settings.solana.rpc must be an object".to_string())?;
                rpc_obj.insert(
                    "endpoint".to_string(),
                    Value::String(form.solana_rpc_endpoint.clone()),
                );
            }
            std::env::set_var("BUTTERFLY_TPM_MODE", &form.tpm_mode);

            let wakeup = tools_obj
                .entry("wakeup")
                .or_insert_with(|| Value::Object(serde_json::Map::new()));
            let wakeup_obj = wakeup
                .as_object_mut()
                .ok_or_else(|| "tools.wakeup must be an object".to_string())?;
            let poll_seconds = form.wakeup_poll_seconds.trim().parse::<u64>().unwrap_or(60);
            wakeup_obj.insert(
                "poll_seconds".to_string(),
                Value::Number(serde_json::Number::from(poll_seconds)),
            );

            let search_internet = tools_obj
                .entry("search_internet")
                .or_insert_with(|| Value::Object(serde_json::Map::new()));
            let search_internet_obj = search_internet
                .as_object_mut()
                .ok_or_else(|| "tools.search_internet must be an object".to_string())?;
            let search_settings = search_internet_obj
                .entry("settings")
                .or_insert_with(|| Value::Object(serde_json::Map::new()));
            let search_settings_obj = search_settings
                .as_object_mut()
                .ok_or_else(|| "tools.search_internet.settings must be an object".to_string())?;
            search_settings_obj.insert("provider".to_string(), Value::String("grok".to_string()));

            let mcp = tools_obj
                .entry("mcp")
                .or_insert_with(|| Value::Object(serde_json::Map::new()));
            let mcp_obj = mcp
                .as_object_mut()
                .ok_or_else(|| "tools.mcp must be an object".to_string())?;
            let mcp_servers = form
                .mcp_servers
                .iter()
                .filter_map(|entry| {
                    let name = entry.name.trim();
                    let url = entry.url.trim();
                    if name.is_empty() || url.is_empty() {
                        return None;
                    }
                    let mut server = serde_json::Map::new();
                    server.insert("name".to_string(), Value::String(name.to_string()));
                    server.insert("url".to_string(), Value::String(url.to_string()));
                    let header_key = entry.header_key.trim();
                    let header_value = entry.header_value.trim();
                    if !header_key.is_empty() && !header_value.is_empty() {
                        let mut headers = serde_json::Map::new();
                        headers.insert(
                            header_key.to_string(),
                            Value::String(header_value.to_string()),
                        );
                        server.insert("headers".to_string(), Value::Object(headers));
                    }
                    Some(Value::Object(server))
                })
                .collect::<Vec<_>>();
            mcp_obj.insert("servers".to_string(), Value::Array(mcp_servers));

            let coding = tools_obj
                .entry("coding")
                .or_insert_with(|| Value::Object(serde_json::Map::new()));
            let coding_obj = coding
                .as_object_mut()
                .ok_or_else(|| "tools.coding must be an object".to_string())?;
            coding_obj.insert(
                "model".to_string(),
                Value::String("gpt-5.2-codex".to_string()),
            );
            coding_obj.insert(
                "base_url".to_string(),
                Value::String("https://api.openai.com/v1".to_string()),
            );

            let http_call = tools_obj
                .entry("http_call")
                .or_insert_with(|| Value::Object(serde_json::Map::new()));
            let http_call_obj = http_call
                .as_object_mut()
                .ok_or_else(|| "tools.http_call must be an object".to_string())?;
            let http_servers = form
                .http_call_servers
                .iter()
                .filter_map(|entry| {
                    let name = entry.name.trim();
                    let url = entry.url.trim();
                    if name.is_empty() || url.is_empty() {
                        return None;
                    }
                    let mut server = serde_json::Map::new();
                    server.insert("name".to_string(), Value::String(name.to_string()));
                    server.insert("url".to_string(), Value::String(url.to_string()));
                    let header_key = entry.header_key.trim();
                    let header_value = entry.header_value.trim();
                    if !header_key.is_empty() && !header_value.is_empty() {
                        let mut headers = serde_json::Map::new();
                        headers.insert(
                            header_key.to_string(),
                            Value::String(header_value.to_string()),
                        );
                        server.insert("headers".to_string(), Value::Object(headers));
                    }
                    Some(Value::Object(server))
                })
                .collect::<Vec<_>>();
            http_call_obj.insert("servers".to_string(), Value::Array(http_servers.clone()));

            let base_urls = http_servers
                .iter()
                .filter_map(|value| {
                    value
                        .get("url")
                        .and_then(|url| url.as_str())
                        .map(|url| Value::String(url.to_string()))
                })
                .collect::<Vec<_>>();
            http_call_obj.insert("base_urls".to_string(), Value::Array(base_urls));

            crate::config_store::save_config(&db_path, &config)
                .map_err(|err| format!("Failed to save config: {err}"))?;

            Ok::<crate::config::Config, String>(config)
        }
    })
    .await
    .map_err(|err| err.to_string())??;

    let pretty = serde_json::to_string_pretty(&config).map_err(|err| err.to_string())?;
    let _ = tokio::task::spawn_blocking(move || {
        crate::vault::set_secret_required("app_config_json", &pretty)
    })
    .await;

    let client = daemon_request_client();
    let url = format!("{}/reload_config", daemon_url.trim_end_matches('/'));
    let mut request = client.post(url);
    if !token.trim().is_empty() {
        request = request.header("authorization", format!("Bearer {token}"));
    }
    let _ = request.send().await;

    Ok("Settings saved".to_string())
}
