mod commands;
mod tauri_app_data_migration;
// System tray uses Tauri's `tray-icon` feature which is desktop-only.
// Android has no tray surface — drop the module entirely on that target.
#[cfg(not(target_os = "android"))]
mod tray;

use std::path::Path;
use std::sync::Arc;
use std::sync::{Once, OnceLock};
use std::time::Instant;

use sqlx::SqlitePool;
use tauri::Manager;
use tracing_appender::non_blocking::WorkerGuard;
use tracing_appender::rolling::{RollingFileAppender, Rotation};
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::{EnvFilter, Layer};

use lumotia_core::paths::{check_target_ambiguity, migrate_legacy_data_dir, MigrationStatus};
use lumotia_core::types::EngineName;
use lumotia_llm::LlmEngine;
use lumotia_storage::{
    database_path, get_setting, init as init_db, migrate_legacy_setting_keys, prune_error_log,
    purge_deleted_transcripts, set_setting,
};

/// How long to retain `error_log` rows. Pruned once on startup.
/// 90 days is long enough to triage "this happened a few weeks ago"
/// reports and short enough that the table stays kilobyte-scale.
const ERROR_LOG_RETENTION_DAYS: i64 = 90;

/// How long soft-deleted transcripts live in the trash before
/// `purge_deleted_transcripts` hard-removes them on startup. 30 days
/// gives the user a comfortable undo window without keeping months of
/// abandoned audio on disk.
const TRANSCRIPT_TRASH_RETENTION_DAYS: i64 = 30;
use lumotia_transcription::LocalEngine;

static TRACING_INIT: Once = Once::new();

/// Parks the non-blocking file-appender's `WorkerGuard` for the process
/// lifetime. Dropping the guard halts the background log-writing thread
/// and discards any pending log lines, so it must outlive every
/// `tracing::*!` call. A `OnceLock` here gives us a free `'static`
/// home and matches the `Once`-guarded init pattern above.
static FILE_APPENDER_GUARD: OnceLock<WorkerGuard> = OnceLock::new();

/// Shared app state holding the transcription engines and database pool.
pub struct AppState {
    pub whisper_engine: Arc<LocalEngine>,
    pub parakeet_engine: Arc<LocalEngine>,
    pub db: SqlitePool,
    pub llm_engine: Arc<LlmEngine>,
    /// Race-4/5 TOCTOU guard: two concurrent `ensure_model_loaded`
    /// callers (e.g. a double-clicked "Test" + "Transcribe" button)
    /// would both observe `loaded_model_id != requested`, both decide
    /// "not loaded — load it", and both spawn a multi-second
    /// `load_model_from_disk` whose result the second overwrites.
    /// Holding this `tokio::sync::Mutex` across the check-then-load
    /// critical section forces them to serialise; the second caller
    /// acquires the lock after the first finishes, observes the
    /// now-loaded state, and short-circuits.
    pub load_serialise: Arc<tokio::sync::Mutex<()>>,
}

/// Holds the preferences init script for injection into secondary windows.
pub struct PreferencesScript(pub String);

/// Build a JavaScript snippet that applies saved preferences to the DOM
/// before Svelte mounts, preventing any flash of unstyled content.
fn build_preferences_script(prefs_json: Option<String>) -> String {
    let json = prefs_json.unwrap_or_default();
    if json.is_empty() {
        return String::new();
    }
    let js_value: serde_json::Value = match serde_json::from_str(&json) {
        Ok(value) => value,
        Err(_) => return String::new(),
    };
    let js_obj = serde_json::to_string(&js_value).unwrap_or_else(|_| "{}".to_string());
    format!(
        r#"(function() {{
  try {{
    var p = {js_obj};
    var el = document.documentElement;
    if (p.theme === 'system') {{
      el.dataset.theme = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
    }} else if (p.theme) {{
      el.dataset.theme = p.theme;
    }}
    if (p.zone && p.zone !== 'default') el.dataset.zone = p.zone;
    if (p.accessibility) {{
      var a = p.accessibility;
      var fonts = {{ lexend: "'Lexend', system-ui, sans-serif", atkinson: "'Atkinson Hyperlegible Next', system-ui, sans-serif", opendyslexic: "'OpenDyslexic', system-ui, sans-serif" }};
      if (a.fontFamily) {{ el.style.setProperty('--font-family-body', fonts[a.fontFamily] || fonts.lexend); el.dataset.fontFamily = a.fontFamily; }}
      if (a.fontSize) el.style.setProperty('--font-size-body', a.fontSize + 'px');
      if (a.letterSpacing != null) el.style.setProperty('--letter-spacing-body', a.letterSpacing + 'em');
      if (a.lineHeight) el.style.setProperty('--line-height-body', String(a.lineHeight));
      if (a.transcriptSize) el.style.setProperty('--text-transcript', a.transcriptSize + 'px');
      if (a.bionicReading) el.dataset.bionicReading = 'true';
      if (a.reduceMotion === 'on' || (a.reduceMotion === 'system' && window.matchMedia('(prefers-reduced-motion: reduce)').matches)) el.dataset.reduceMotion = 'true';
    }}
  }} catch (e) {{ console.warn('Preferences injection failed:', e); }}
}})();"#
    )
}

#[cfg(test)]
mod tests {
    use super::build_preferences_script;

    #[test]
    fn preferences_script_injects_object_without_redundant_json_parse() {
        let script = build_preferences_script(Some(r#"{"theme":"dark"}"#.to_string()));
        assert!(script.contains("var p = {\"theme\":\"dark\"};"));
        assert!(!script.contains("JSON.parse"));
    }

    #[test]
    fn preferences_script_rejects_malformed_json() {
        assert!(build_preferences_script(Some("not json".to_string())).is_empty());
    }
}

/// Save preferences JSON to the SQLite settings table.
#[tauri::command]
async fn save_preferences(
    state: tauri::State<'_, AppState>,
    preferences: String,
) -> Result<(), String> {
    set_setting(&state.db, "lumotia_preferences", &preferences)
        .await
        .map_err(|e| e.to_string())
}

/// On Linux Wayland sessions (KDE, GNOME) WebKitGTK + DMABUF rendering hits
/// known crashes that the dev launcher works around by setting rendering
/// env vars before WebKitGTK/WINIT initialise. In development, launch via:
///
/// ```sh
/// npm run dev:tauri
/// ```
///
/// Detect the Wayland session at startup and warn when the process was not
/// launched with the safe Linux rendering env vars. Rust 2024 marks runtime
/// environment mutation unsafe in multi-threaded programs, so the app must be
/// launched with these values by `run.sh`, the desktop file, or a package
/// wrapper rather than patching them here.
///
/// Inspired by Open-Whispr's similar Chromium self-relaunch (with
/// --ozone-platform=x11). Day 6 of the upgrade plan.
#[cfg(target_os = "linux")]
fn warn_if_x11_env_unset_on_wayland() {
    let warn_if_unset = |key: &str, value: &str, why: &str| {
        if std::env::var_os(key).is_none() {
            tracing::warn!(
                target: "lumotia_startup",
                key,
                expected = value,
                why,
                "Linux rendering workaround env var is not set; configure the launcher instead of mutating process env at runtime"
            );
        }
    };

    // Always-on Linux: disable WebKit's DMA-BUF renderer. On RADV / Renoir
    // class iGPUs and a number of NVIDIA driver stacks, the DMA-BUF path
    // burns ~10pp idle GPU and ~10pp idle CPU compared to the legacy
    // renderer for no visible quality difference. Empirically (env-var
    // matrix on Ryzen 5 4650U / Vega 6, May 2026) toggling this dropped
    // lumotia idle CPU 12.30% → 2.80% of one core and idle GPU 17% → 10%.
    // Applies to both X11 and Wayland sessions; users can opt back in by
    // setting WEBKIT_DISABLE_DMABUF_RENDERER=0 explicitly.
    warn_if_unset(
        "WEBKIT_DISABLE_DMABUF_RENDERER",
        "1",
        "iGPU idle-cost workaround",
    );

    // If the user is on a Wayland session, also force WebKitGTK + GDK
    // onto X11 via XWayland. Idempotent: if a value is already set, keep
    // it (the user knows best). PipeWire screen-capture portal still
    // works fine through XWayland; we only redirect the GUI rendering
    // path.
    let session_type = std::env::var("XDG_SESSION_TYPE").unwrap_or_default();
    if session_type.eq_ignore_ascii_case("wayland") {
        warn_if_unset("GDK_BACKEND", "x11", "Wayland XWayland fallback");
        warn_if_unset("WINIT_UNIX_BACKEND", "x11", "Wayland XWayland fallback");
    }
}

/// Default `EnvFilter` string for the stderr (developer-facing) layer.
///
/// `lumotia_lib::commands::live=info` is listed explicitly so that the
/// operator's documented triage filter (per
/// docs/superpowers/audits/2026-05-10-phase10a-dogfood-notes.md) works
/// without a manual `RUST_LOG=` override on every dogfooding session.
const DEFAULT_STDERR_FILTER: &str =
    "warn,lumotia=info,lumotia_lib=info,lumotia_lib::commands::live=info,lumotia_core=info,lumotia_audio=info,lumotia_hotkey=info,lumotia_ai_formatting=info,lumotia_llm=info,lumotia_storage=info,lumotia_transcription=info,lumotia_startup=info";

/// Default `EnvFilter` string for the rolling-file (forensic) layer.
///
/// More verbose than stderr by design: the file is what users attach to
/// diagnostic reports, so DEBUG-level Lumotia internals are worth the
/// disk cost. Third-party crates that spam at DEBUG (`sqlx`, `hyper`,
/// `reqwest`, `h2`, `rustls`, `tokio_util`) are pinned at INFO so they
/// don't drown out our own log lines.
const DEFAULT_FILE_FILTER: &str =
    "info,lumotia=debug,lumotia_lib=debug,lumotia_lib::commands::live=debug,lumotia_core=debug,lumotia_audio=debug,lumotia_hotkey=debug,lumotia_ai_formatting=debug,lumotia_llm=debug,lumotia_storage=debug,lumotia_transcription=debug,lumotia_startup=debug,sqlx=info,hyper=info,reqwest=info,h2=info,rustls=info,tokio_util=info";

/// Build the stderr-side `EnvFilter`, honouring `RUST_LOG` if set.
fn stderr_env_filter() -> EnvFilter {
    EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(DEFAULT_STDERR_FILTER))
}

/// Build the file-side `EnvFilter`, honouring `LUMOTIA_LOG_FILE` if set
/// (falling back to `RUST_LOG`, then the verbose default).
fn file_env_filter() -> EnvFilter {
    EnvFilter::try_from_env("LUMOTIA_LOG_FILE")
        .or_else(|_| EnvFilter::try_from_default_env())
        .unwrap_or_else(|_| EnvFilter::new(DEFAULT_FILE_FILTER))
}

/// Build the rolling daily appender for `lumotia.log` inside `logs_dir`.
///
/// Rotation policy: one file per day, keep 7 most-recent days. Older
/// files are pruned by `tracing-appender` itself. The base filename is
/// `lumotia.log`; rotated files get a date suffix (e.g.
/// `lumotia.log.2026-05-12`).
fn build_rolling_appender(logs_dir: &Path) -> std::io::Result<RollingFileAppender> {
    std::fs::create_dir_all(logs_dir)?;
    RollingFileAppender::builder()
        .rotation(Rotation::DAILY)
        .filename_prefix("lumotia")
        .filename_suffix("log")
        .max_log_files(7)
        .build(logs_dir)
        .map_err(std::io::Error::other)
}

/// Install a `tracing` subscriber that writes to stderr (developer
/// stream, ANSI-coloured) AND to a rolling daily file at
/// `logs_dir/lumotia.log` (forensic stream, plain text). The file
/// stream is what diagnostic-report bundles attach.
///
/// Returns the `WorkerGuard` for the file appender so the caller can
/// park it for the process lifetime; dropping it discards pending logs.
/// Returns `None` if the file appender could not be built (e.g.
/// permissions); in that case only the stderr layer is installed.
///
/// Exposed `pub` so the `tracing_appender_smoke` integration test can
/// drive it against a tempdir; production code calls it via
/// `init_tracing` against the platform-default `logs_dir()`.
pub fn install_subscriber(logs_dir: &Path) -> Option<WorkerGuard> {
    let stderr_layer = tracing_subscriber::fmt::layer()
        .with_writer(std::io::stderr)
        .with_filter(stderr_env_filter());

    match build_rolling_appender(logs_dir) {
        Ok(appender) => {
            let (non_blocking, guard) = tracing_appender::non_blocking(appender);
            let file_layer = tracing_subscriber::fmt::layer()
                .with_writer(non_blocking)
                .with_ansi(false)
                .with_filter(file_env_filter());

            let _ = tracing_subscriber::registry()
                .with(stderr_layer)
                .with(file_layer)
                .try_init();
            Some(guard)
        }
        Err(e) => {
            // Best-effort: keep stderr logging even if the file path is
            // unwritable, and surface the failure to stderr so dogfooders
            // notice the missing forensic stream.
            let _ = tracing_subscriber::registry().with(stderr_layer).try_init();
            eprintln!(
                "lumotia: failed to install rolling file log appender at {}: {e}",
                logs_dir.display()
            );
            None
        }
    }
}

fn init_tracing() {
    TRACING_INIT.call_once(|| {
        let logs_dir = lumotia_storage::file_storage::logs_dir();
        if let Some(guard) = install_subscriber(&logs_dir) {
            // Park the guard for the process lifetime. set() only fails
            // if already set (impossible inside Once::call_once), so we
            // ignore the result.
            let _ = FILE_APPENDER_GUARD.set(guard);
        }
    });
}

/// One-shot data migration that runs BEFORE anything else in `run()`.
///
/// CRITICAL ORDERING: this must come before `init_tracing`,
/// `install_panic_hook`, AND `tauri::Builder::default()`. Each of those
/// either calls `create_dir_all` on a child of `app_data_dir()` or causes
/// Tauri/WebKitGTK to create its own bundle-identifier-keyed dir for
/// plugin state. Either way, by the time the migration would otherwise
/// run from inside the Tauri setup hook, the destination already exists
/// and the migration short-circuits via `TargetAlreadyExists` /
/// `BothExistLegacyPreserved` — silently leaving the user's legacy
/// Magnotia data orphaned next to a fresh empty Lumotia install. The
/// `scripts/dogfood-rebrand-drill.sh` integration probe is what surfaced
/// this race; see commit history for the original buggy ordering.
///
/// Tracing isn't initialised yet, so migration outcomes are written to
/// stderr via `eprintln!`. The format mirrors the structured fields the
/// setup-hook tracing layer would have emitted — same content, different
/// transport. systemd-journald + a foreground terminal both capture
/// stderr, which is the audit surface that matters at boot.
///
/// Fatal failures (data-dir migration error, ambiguous lumotia paths on
/// disk) call `process::exit(1)` rather than panicking. We refuse to
/// start with the wrong path resolved — silently continuing would
/// orphan user data, which is the failure mode this fix is closing.
fn migrate_user_data_pre_runtime() {
    // 1. Hand-rolled data-dir migration: ~/.local/share/magnotia (and
    //    macOS / Windows equivalents) -> ~/.local/share/lumotia. Drives
    //    every legacy candidate independently so multi-legacy Linux
    //    users (`~/.magnotia` AND `~/.local/share/magnotia` from
    //    different historical builds) get all of them migrated, not
    //    just the first one probed.
    let t = std::time::Instant::now();
    match migrate_legacy_data_dir() {
        Ok(statuses) => {
            for status in &statuses {
                match status {
                    MigrationStatus::Migrated {
                        from,
                        to,
                        renamed_db,
                    } => {
                        eprintln!(
                            "[lumotia-startup] migrated legacy magnotia data dir to lumotia: \
                             {from} -> {to} (renamed_db={renamed_db}, elapsed_ms={ms})",
                            from = from.display(),
                            to = to.display(),
                            renamed_db = *renamed_db,
                            ms = t.elapsed().as_millis(),
                        );
                    }
                    MigrationStatus::TargetAlreadyExists { .. }
                    | MigrationStatus::NoLegacyFound => {
                        // Steady state on the second-or-later boot. Chatty
                        // logging here would dwarf the genuine first-boot
                        // event, so we stay silent.
                    }
                }
            }
        }
        Err(e) => {
            eprintln!(
                "[lumotia-startup] FATAL: legacy data dir migration failed — refusing \
                 to start (would orphan user data): {e}"
            );
            std::process::exit(1);
        }
    }

    // 2. Tauri app_data_dir migration: ~/.local/share/uk.co.corbel.magnotia
    //    -> ~/.local/share/consulting.corbel.lumotia. Copy-via-staging so
    //    a half-written destination cannot appear on disk. Legacy is
    //    preserved as a backup.
    use crate::tauri_app_data_migration::{
        current_tauri_app_data_dir, legacy_tauri_app_data_dir,
        migrate_tauri_app_data_dir_with_paths, AppDataMigrationStatus,
    };
    let t = std::time::Instant::now();
    if let (Some(legacy), Some(current)) =
        (legacy_tauri_app_data_dir(), current_tauri_app_data_dir())
    {
        match migrate_tauri_app_data_dir_with_paths(&legacy, &current) {
            AppDataMigrationStatus::Migrated { old, new } => {
                eprintln!(
                    "[lumotia-startup] migrated Tauri app_data_dir from legacy bundle \
                     identifier: {old} -> {new} (elapsed_ms={ms})",
                    old = old.display(),
                    new = new.display(),
                    ms = t.elapsed().as_millis(),
                );
            }
            AppDataMigrationStatus::BothExistLegacyPreserved { old, new } => {
                // Reachable on the second-or-later boot OR if the user
                // ran a side-by-side install. Either is benign — we
                // preserve legacy, use new — so log at INFO not WARN.
                eprintln!(
                    "[lumotia-startup] Tauri app_data_dir present at new path; legacy \
                     preserved: old={old} new={new}",
                    old = old.display(),
                    new = new.display(),
                );
            }
            AppDataMigrationStatus::NoLegacyFound => {}
        }
    }

    // 3. Ambiguity guard. After migrations, if more than one lumotia
    //    target candidate exists on disk (`~/.lumotia` AND
    //    `~/.local/share/lumotia` from split runs), refuse to start
    //    rather than silently pick one. The user must consolidate
    //    manually.
    if let Err(amb) = check_target_ambiguity() {
        eprintln!(
            "[lumotia-startup] FATAL: ambiguous lumotia data directory — refusing to \
             start: {amb}"
        );
        std::process::exit(1);
    }
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    // Step 1: migrate legacy magnotia data BEFORE anything else touches
    // the lumotia data dir. init_tracing, install_panic_hook, and
    // tauri::Builder::default() all lazily create directories under
    // app_data_dir on first use; if any of those run before migration,
    // every migrate_one() probe returns TargetAlreadyExists / both-exist
    // and the legacy data is silently orphaned.
    migrate_user_data_pre_runtime();

    // Step 2: structured logging on the (now correctly migrated) logs dir.
    init_tracing();

    #[cfg(target_os = "linux")]
    warn_if_x11_env_unset_on_wayland();

    // Capture Rust panics to disk so the diagnostic-report bundler in
    // Settings → About can attach them. Local only; nothing transmitted.
    commands::diagnostics::install_panic_hook();

    let builder = tauri::Builder::default()
        .plugin(tauri_plugin_opener::init())
        .plugin(tauri_plugin_dialog::init())
        // Phase 6 nudges: OS-native notifications. The plugin exposes
        // isPermissionGranted / requestPermission / sendNotification on
        // the JS side; our deliver_nudge wrapper guards those calls
        // against the nudge-bus suppression rules and restricts
        // invocation to the main window via ensure_main_window.
        .plugin(tauri_plugin_notification::init());

    // Desktop-only plugins. Each is either unsupported on Android
    // (global-shortcut, autostart) or structurally meaningless on a
    // single-window mobile app (window-state). Gating here matches the
    // Cargo.toml `cfg(not(target_os = "android"))` block that drops the
    // crates entirely on Android targets.
    #[cfg(not(target_os = "android"))]
    let builder = builder
        .plugin(tauri_plugin_global_shortcut::Builder::new().build())
        // Phase 5 rituals: autostart. The plugin registers JS-facing
        // commands (isEnabled / enable / disable) that the Settings
        // toggle and first-run prompt invoke directly — no bespoke
        // Rust commands needed. `LaunchAgent` is the non-root macOS
        // install path (per-user, no sudo).
        .plugin(tauri_plugin_autostart::init(
            tauri_plugin_autostart::MacosLauncher::LaunchAgent,
            None,
        ))
        // Remember size + position of every window across app restarts.
        // Without this, secondary windows (preview overlay, task float,
        // transcript viewer) open at whatever spot the compositor picks,
        // which feels random. State is persisted per-window-label to
        // app-data/window-state.json.
        .plugin(tauri_plugin_window_state::Builder::default().build());

    builder
        .setup(|app| {
            // Data migrations + ambiguity guard ran in
            // `migrate_user_data_pre_runtime()` BEFORE tauri::Builder
            // was constructed — see the doc comment on that function
            // for why setup-hook timing is too late. By the time we get
            // here every app_data_dir path is the post-rebrand one and
            // any legacy magnotia state has either been migrated or
            // preserved as a backup.
            let _ = app;

            // Initialise database and startup settings in one runtime entry.
            let db_path = database_path();
            let (db, init_script) = tauri::async_runtime::block_on(async {
                let t0 = Instant::now();
                let db = init_db(&db_path)
                    .await
                    .map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
                tracing::info!(target: "lumotia_startup", elapsed_ms = t0.elapsed().as_millis(), "DB init complete");

                // One-shot settings-key migration: rename any leftover
                // `magnotia_*` rows from the magnotia era to `lumotia_*`.
                // Deletes any magnotia_ rows that are orphans of an existing
                // lumotia_ row. Idempotent; logs only when rows actually move.
                let t_keys = Instant::now();
                match migrate_legacy_setting_keys(&db).await {
                    Ok((0, 0)) => {}
                    Ok((renamed, orphans_deleted)) => tracing::info!(
                        target: "lumotia_startup",
                        rows_renamed = renamed,
                        orphans_deleted = orphans_deleted,
                        elapsed_ms = t_keys.elapsed().as_millis(),
                        "migrated legacy magnotia_* settings keys to lumotia_*"
                    ),
                    Err(e) => tracing::warn!(
                        target: "lumotia_startup",
                        error = %e,
                        "settings-key migration failed — continuing"
                    ),
                }

                // Prune old `error_log` rows so the table doesn't grow unbounded
                // across months of dogfooding. Best-effort — a prune failure is
                // not worth blocking startup over.
                let t_prune = Instant::now();
                match prune_error_log(&db, ERROR_LOG_RETENTION_DAYS).await {
                    Ok(n) if n > 0 => tracing::info!(
                        target: "lumotia_startup",
                        rows_removed = n,
                        retention_days = ERROR_LOG_RETENTION_DAYS,
                        elapsed_ms = t_prune.elapsed().as_millis(),
                        "error log prune complete"
                    ),
                    Ok(_) => {}
                    Err(e) => tracing::warn!(target: "lumotia_startup", error = %e, "error log prune failed"),
                }

                // Hard-delete soft-deleted transcripts past the retention
                // window (Rev-2 atomiser fix). Best-effort — a purge failure
                // means trash rows stick around an extra day, not a blocker.
                let t_purge_trash = Instant::now();
                match purge_deleted_transcripts(&db, TRANSCRIPT_TRASH_RETENTION_DAYS).await {
                    Ok(n) if n > 0 => tracing::info!(
                        target: "lumotia_startup",
                        rows_removed = n,
                        retention_days = TRANSCRIPT_TRASH_RETENTION_DAYS,
                        elapsed_ms = t_purge_trash.elapsed().as_millis(),
                        "transcript trash purge complete"
                    ),
                    Ok(_) => {}
                    Err(e) => tracing::warn!(
                        target: "lumotia_startup",
                        error = %e,
                        "transcript trash purge failed — trash rows remain for next startup"
                    ),
                }

                // Load saved preferences for webview injection.
                let t1 = Instant::now();
                let prefs_json = get_setting(&db, "lumotia_preferences").await.unwrap_or(None);
                tracing::info!(target: "lumotia_startup", elapsed_ms = t1.elapsed().as_millis(), "preferences load complete");
                let init_script = build_preferences_script(prefs_json);

                Ok::<_, Box<dyn std::error::Error>>((db, init_script))
            })?;

            // Apply preferences to the main window (defined in tauri.conf.json)
            if let Some(main_window) = app.get_webview_window("main") {
                if !init_script.is_empty() {
                    // Tauri's WebviewWindow.eval() is the standard API for
                    // running JS in a webview — not the JS eval() function
                    let _ = main_window.eval(&init_script);
                }

                // Auto-grant microphone permission on Linux (WebKitGTK).
                // WebKitGTK doesn't show a permission dialog — it just denies
                // getUserMedia by default. We connect to the permission-request
                // signal and grant audio capture requests automatically.
                #[cfg(target_os = "linux")]
                {
                    main_window
                        .with_webview(|webview| {
                            use webkit2gtk::glib::prelude::Cast;
                            use webkit2gtk::{
                                PermissionRequest, PermissionRequestExt, SettingsExt,
                                UserMediaPermissionRequest, UserMediaPermissionRequestExt,
                                WebViewExt,
                            };

                            let wv: webkit2gtk::WebView = webview.inner().clone();

                            // Enable media stream in WebKit settings
                            if let Some(settings) = WebViewExt::settings(&wv) {
                                settings.set_enable_media_stream(true);
                                settings.set_enable_media_capabilities(true);
                            }

                            tracing::warn!(
                                target: "lumotia_startup",
                                "Linux WebKitGTK microphone permission requests are auto-granted for audio-only capture; other permission classes remain denied"
                            );

                            // Auto-grant microphone capture only. Other WebKitGTK
                            // permission requests are denied so future surfaces do
                            // not inherit camera/geolocation/pointer-lock access.
                            WebViewExt::connect_permission_request(
                                &wv,
                                |_wv, request: &PermissionRequest| {
                                    if let Ok(media) =
                                        request.clone().downcast::<UserMediaPermissionRequest>()
                                    {
                                        if media.is_for_audio_device()
                                            && !media.is_for_video_device()
                                        {
                                            request.allow();
                                        } else {
                                            request.deny();
                                        }
                                    } else {
                                        request.deny();
                                    }
                                    true
                                },
                            );
                        })
                        .unwrap_or_else(|e| {
                            // Non-fatal: WebKitGTK may already have media
                            // capture wired by some compositors, or the
                            // signal binding may fail on unusual builds.
                            // Falling back means getUserMedia() prompts (or
                            // silently denies) instead of auto-granting,
                            // which is degraded but recoverable.
                            tracing::warn!(
                                target: "lumotia_startup",
                                error = %e,
                                "failed to configure webview media permissions"
                            );
                        });
                }

                // Close-to-tray: hide the window instead of exiting so the
                // tray icon stays as the canonical entry point. Desktop-only;
                // mobile has no tray and "hide on close" maps to the
                // platform's own background-app behaviour.
                #[cfg(not(target_os = "android"))]
                {
                    let win = main_window.clone();
                    main_window.on_window_event(move |event| {
                        if let tauri::WindowEvent::CloseRequested { api, .. } = event {
                            api.prevent_close();
                            let _ = win.hide();
                        }
                    });
                }
            }

            // Store init script for secondary windows (float, viewer)
            app.manage(PreferencesScript(init_script));

            app.manage(commands::hotkey::HotkeyState::new());
            app.manage(commands::live::LiveTranscriptionState::default());
            app.manage(commands::tts::TtsState::new());
            app.manage(commands::meeting::MeetingState::new());

            app.manage(AppState {
                whisper_engine: Arc::new(LocalEngine::new(EngineName::new("whisper"))),
                parakeet_engine: Arc::new(LocalEngine::new(EngineName::new("parakeet"))),
                db,
                llm_engine: Arc::new(LlmEngine::new()),
                load_serialise: Arc::new(tokio::sync::Mutex::new(())),
            });

            // Runtime-warning banner: push CPU-feature + Vulkan-loader
            // fallbacks to the frontend so Settings can render a one-line
            // hint. No-ops on a fully-supported box.
            crate::commands::models::emit_runtime_warnings(app.handle());

            #[cfg(not(target_os = "android"))]
            if let Err(e) = tray::setup(app) {
                tracing::warn!(target: "lumotia_startup", error = %e, "failed to setup tray");
            }

            Ok(())
        })
        .invoke_handler(tauri::generate_handler![
            // Preferences
            save_preferences,
            // Whisper model management
            commands::models::download_model,
            commands::models::list_models,
            commands::models::load_model,
            commands::models::prewarm_default_model_cmd,
            commands::models::check_engine,
            commands::models::get_runtime_capabilities,
            // Local LLM management
            commands::llm::recommend_llm_tier,
            commands::llm::check_llm_model,
            commands::llm::download_llm_model,
            commands::llm::load_llm_model,
            commands::llm::unload_llm_model,
            commands::llm::delete_llm_model,
            commands::llm::get_llm_status,
            commands::llm::test_llm_model,
            commands::llm::cleanup_transcript_text_cmd,
            // Parakeet model management
            commands::models::download_parakeet_model,
            commands::models::check_parakeet_model,
            commands::models::list_parakeet_models,
            commands::models::load_parakeet_model,
            commands::models::check_parakeet_engine,
            // Transcription
            commands::transcription::transcribe_file,
            // Audio
            commands::audio::list_audio_devices,
            // Tasks (canonical SQLite-backed task CRUD)
            commands::tasks::create_task_cmd,
            commands::tasks::list_tasks_cmd,
            commands::tasks::update_task_cmd,
            commands::tasks::complete_task_cmd,
            commands::tasks::delete_task_cmd,
            commands::tasks::uncomplete_task_cmd,
            commands::tasks::set_task_energy_cmd,
            commands::tasks::decompose_and_store,
            commands::tasks::extract_tasks_from_transcript_cmd,
            commands::tasks::list_subtasks_cmd,
            commands::tasks::complete_subtask_cmd,
            commands::tasks::list_recent_completions_cmd,
            // HITL feedback (Phase 2 roadmap)
            commands::feedback::record_feedback,
            // Read aloud (Phase 4 roadmap)
            commands::tts::tts_speak,
            commands::tts::tts_stop,
            commands::tts::tts_list_voices,
            // Rituals (Phase 5 roadmap)
            commands::rituals::get_last_morning_triage,
            commands::rituals::mark_morning_triage_shown,
            // Nudges (Phase 6 roadmap)
            commands::nudges::deliver_nudge,
            // Implementation intentions (Phase 7 roadmap)
            commands::intentions::list_implementation_rules,
            commands::intentions::create_implementation_rule,
            commands::intentions::set_implementation_rule_enabled,
            commands::intentions::mark_implementation_rule_fired,
            commands::intentions::delete_implementation_rule,
            // Profiles + profile terms (canonical SQLite-backed profile CRUD) — Task 12
            commands::profiles::list_profiles_cmd,
            commands::profiles::create_profile_cmd,
            commands::profiles::update_profile_cmd,
            commands::profiles::delete_profile_cmd,
            commands::profiles::list_profile_terms_cmd,
            commands::profiles::add_profile_term_cmd,
            commands::profiles::learn_profile_terms_from_edit_cmd,
            commands::profiles::delete_profile_term_cmd,
            // Transcripts (canonical SQLite-backed history) — Day 4
            commands::transcripts::add_transcript,
            commands::transcripts::list_transcripts,
            commands::transcripts::get_transcript,
            commands::transcripts::update_transcript,
            commands::transcripts::update_transcript_meta_cmd,
            commands::transcripts::delete_transcript,
            commands::transcripts::list_trashed_transcripts,
            commands::transcripts::restore_transcript,
            commands::transcripts::search_transcripts,
            // Diagnostics (panic + error capture, manual report bundle)
            commands::diagnostics::log_frontend_error,
            commands::diagnostics::list_recent_errors_command,
            commands::diagnostics::list_crash_files,
            commands::diagnostics::generate_diagnostic_report,
            commands::diagnostics::save_diagnostic_report,
            commands::diagnostics::get_os_info,
            commands::live::start_live_transcription_session,
            commands::live::stop_live_transcription_session,
            // Windows
            commands::windows::open_task_window,
            commands::windows::open_viewer_window,
            commands::windows::open_preview_window,
            // Clipboard
            commands::clipboard::copy_to_clipboard,
            // Filesystem (Phase 9 save-dialog path)
            commands::fs::write_text_file_cmd,
            // LLM content tags (Phase 9)
            commands::llm::extract_content_tags_cmd,
            // Paste (auto-insert at cursor)
            commands::paste::paste_text,
            commands::paste::paste_text_replacing,
            commands::paste::detect_paste_backends,
            // Meeting auto-capture (process-list poll)
            commands::meeting::detect_meeting_processes,
            // Hardware
            commands::hardware::probe_system,
            commands::hardware::rank_models,
            // Hotkey (evdev — Wayland-compatible)
            commands::hotkey::is_wayland_session,
            commands::hotkey::check_hotkey_access,
            commands::hotkey::start_evdev_hotkey,
            commands::hotkey::update_evdev_hotkey,
            commands::hotkey::stop_evdev_hotkey,
            // Updater
            commands::update::check_for_update,
        ])
        .run(tauri::generate_context!())
        .expect("error while running Lumotia");
}
