mod config;
mod leveltracker;
mod logwatch;
mod poe;
mod timer;

use config::Config;
use leveltracker::GuideData;
use timer::RunTimer;
use serde::Serialize;
use std::str::FromStr;
use std::sync::Mutex;
use tauri::{
    AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, PhysicalSize, WebviewUrl,
    WebviewWindowBuilder,
};
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};

/// Live game state derived from the client log.
#[derive(Serialize, Clone, Default)]
pub struct Status {
    pub character: String,
    pub class: String,
    pub level: u32,
    pub area_id: String,
    pub area_name: String,
    pub area_level: u32,
    pub area_seed: String,
    pub act: usize,
    pub game_focused: bool,
}

pub struct AppState {
    pub config: Mutex<Config>,
    pub guide: Mutex<GuideData>,
    pub status: Mutex<Status>,
    pub timer: Mutex<RunTimer>,
    // While the user drags the layout window by its title bar: Some((dx, dy)) is
    // the offset from the window's top-left to the cursor; a background thread
    // follows the mouse via xdotool (Tauri/KWin won't reposition this window).
    pub layout_drag: Mutex<Option<(i32, i32)>>,
    // While the user drags the layout window's corner grip: Some(()) signals a
    // background thread to follow the mouse and resize the window via set_size
    // (KWin gives this decorationless window no resize borders of its own).
    pub layout_resize: Mutex<Option<()>>,
}

// ---------------------------------------------------------------------------
// Commands
// ---------------------------------------------------------------------------

#[tauri::command]
fn get_config(state: tauri::State<AppState>) -> Config {
    state.config.lock().unwrap().clone()
}

#[tauri::command]
fn save_config(app: AppHandle, state: tauri::State<AppState>, new: Config) {
    let league_changed = {
        let mut cfg = state.config.lock().unwrap();
        let changed = cfg.league_start != new.league_start;
        *cfg = new;
        cfg.sync_progress(); // keep the active profile in sync with live fields
        cfg.save();
        changed
    };

    if league_changed {
        rebuild_guide(&app);
    }

    register_shortcuts(&app);
    apply_overlay_geometry(&app);
    let cfg = state.config.lock().unwrap().clone();
    let _ = app.emit("config://update", cfg);
}

#[tauri::command]
fn get_status(state: tauri::State<AppState>) -> Status {
    state.status.lock().unwrap().clone()
}

#[tauri::command]
fn get_guide(state: tauri::State<AppState>) -> GuideData {
    state.guide.lock().unwrap().clone()
}

#[tauri::command]
fn get_gems() -> serde_json::Value {
    leveltracker::gems_raw()
}

#[tauri::command]
fn detect_logs() -> Vec<String> {
    poe::detect_log_paths()
}

#[tauri::command]
fn get_timer(state: tauri::State<AppState>) -> RunTimer {
    state.timer.lock().unwrap().clone()
}

#[tauri::command]
fn timer_start(app: AppHandle, state: tauri::State<AppState>) {
    let act = state.status.lock().unwrap().act.max(1);
    let name = now_string();
    {
        let mut t = state.timer.lock().unwrap();
        t.start(act, name);
    }
    store_live_timer(&state);
    emit_timer(&app);
}

#[tauri::command]
fn timer_pause(app: AppHandle, state: tauri::State<AppState>) {
    {
        let mut t = state.timer.lock().unwrap();
        t.toggle_pause();
    }
    store_live_timer(&state);
    emit_timer(&app);
}

#[tauri::command]
fn timer_reset(app: AppHandle, state: tauri::State<AppState>) {
    state.timer.lock().unwrap().reset();
    store_live_timer(&state);
    emit_timer(&app);
}

#[tauri::command]
fn step_delta(app: AppHandle, state: tauri::State<AppState>, delta: i64) {
    let max = state.guide.lock().unwrap().steps.len().saturating_sub(1);
    let new = {
        let cur = state.config.lock().unwrap().current_step as i64;
        (cur + delta).clamp(0, max as i64) as usize
    };
    set_step(&app, new);
}

#[tauri::command]
fn step_goto(app: AppHandle, new: usize) {
    set_step(&app, new);
}

// --- character profiles ---

#[tauri::command]
fn create_profile(app: AppHandle, state: tauri::State<AppState>, name: String, league_start: bool) {
    let added = {
        let mut cfg = state.config.lock().unwrap();
        let added = cfg.create_profile(&name, league_start);
        if added {
            cfg.save();
        }
        added
    };
    if added {
        // Newly created profiles become the active one.
        select_profile(app, state, name);
    }
}

#[tauri::command]
fn delete_profile(app: AppHandle, state: tauri::State<AppState>, name: String) {
    {
        let mut cfg = state.config.lock().unwrap();
        cfg.delete_profile(&name);
        cfg.save();
    }
    let cfg = state.config.lock().unwrap().clone();
    let _ = app.emit("config://update", cfg);
}

#[tauri::command]
fn select_profile(app: AppHandle, state: tauri::State<AppState>, name: String) {
    // Bank the outgoing profile's live timer before switching.
    store_live_timer(&state);

    let league_changed = {
        let mut cfg = state.config.lock().unwrap();
        let prev_league = cfg.league_start;
        let switched = cfg.select_profile(&name);
        if switched {
            cfg.save();
        }
        switched && cfg.league_start != prev_league
    };

    // Load the incoming profile's timer into the live state.
    {
        let new_timer = {
            let cfg = state.config.lock().unwrap();
            cfg.active_character
                .as_ref()
                .and_then(|a| cfg.profiles.iter().find(|p| &p.name == a))
                .map(|p| p.timer.clone())
                .unwrap_or_default()
        };
        *state.timer.lock().unwrap() = new_timer;
    }

    if league_changed {
        rebuild_guide(&app);
    }
    let (step, cfg) = {
        let cfg = state.config.lock().unwrap();
        (cfg.current_step, cfg.clone())
    };
    let _ = app.emit("tracker://step", step);
    let _ = app.emit("config://update", cfg);
    emit_timer(&app);
}

#[tauri::command]
fn set_overlay_locked(app: AppHandle, locked: bool) {
    if let Some(w) = app.get_webview_window("overlay") {
        let _ = w.set_ignore_cursor_events(locked);
        if locked {
            // Removing the drag bar on re-lock shifts the content up, and the
            // transparent WebKitGTK surface doesn't clear its previous frame,
            // leaving a ghost copy of the old layout (hide/show doesn't fix it —
            // WebKit keeps its backing buffer across map cycles). Forcing a real
            // resize reallocates and clears that surface. Two synchronous
            // set_size calls would be coalesced by GTK (the window never truly
            // changes size), so grow by 1px now and shrink back after a GTK loop
            // cycle. A resize keeps the top-left corner, so the dragged position
            // is preserved (unlike hide/show, which lets the WM re-place it).
            if let Ok(sz) = w.inner_size() {
                let _ = w.set_size(PhysicalSize::new(sz.width, sz.height + 1));
                let app2 = app.clone();
                std::thread::spawn(move || {
                    std::thread::sleep(std::time::Duration::from_millis(50));
                    if let Some(w2) = app2.get_webview_window("overlay") {
                        let _ = w2.set_size(sz);
                    }
                });
            }
        }
    }
    let _ = app.emit("overlay://locked", locked);
}

#[tauri::command]
fn toggle_overlay(app: AppHandle) {
    // Visibility is a frontend concern; just notify the overlay view.
    let _ = app.emit("overlay://toggle", ());
}

/// Show/hide the interactive zone-layout viewer window. It is a real (focusable,
/// non-click-through) window, so visibility is the OS window's own show/hide. The
/// view itself polls the current area (its `listen` subscription doesn't fire
/// while the window is created hidden), so showing it is enough to see the zone.
#[tauri::command]
fn toggle_layout(app: AppHandle, state: tauri::State<AppState>) {
    if !state.config.lock().unwrap().feature_layouts {
        return;
    }
    if let Some(w) = app.get_webview_window("layout") {
        if w.is_visible().unwrap_or(false) {
            persist_layout_position(&app, &state);
            let _ = w.hide();
        } else {
            let (x, y) = {
                let cfg = state.config.lock().unwrap();
                (cfg.layout_x, cfg.layout_y)
            };
            let _ = w.set_position(LogicalPosition::new(x, y));
            let _ = w.show();
            let _ = w.set_focus();
            // KWin tends to clamp/centre a freshly shown decorationless window;
            // force the saved position via xdotool, same as the overlay.
            std::thread::spawn(move || {
                std::thread::sleep(std::time::Duration::from_millis(60));
                let _ = std::process::Command::new("xdotool")
                    .args([
                        "search",
                        "--name",
                        "Exile UI Layouts",
                        "windowmove",
                        &x.to_string(),
                        &y.to_string(),
                    ])
                    .status();
            });
        }
    }
}

/// Resize the layout window to an explicit size and persist it (called by the
/// settings fields). The grip uses `start_layout_resize` for live dragging.
#[tauri::command]
fn set_layout_size(app: AppHandle, width: u32, height: u32, state: tauri::State<AppState>) {
    let (w_px, h_px) = (width.max(MIN_LAYOUT_W), height.max(MIN_LAYOUT_H));
    if let Some(w) = app.get_webview_window("layout") {
        let _ = w.set_size(LogicalSize::new(w_px, h_px));
    }
    let mut cfg = state.config.lock().unwrap();
    cfg.layout_w = w_px;
    cfg.layout_h = h_px;
    cfg.save();
}

/// Minimum layout-window size (logical px), shared by the grip and settings.
const MIN_LAYOUT_W: u32 = 240;
const MIN_LAYOUT_H: u32 = 200;

/// Begin resizing the layout window by its corner grip. Mirrors the drag
/// machinery: a background thread follows the mouse (pointermove is unreliable
/// in this WebKitGTK window) and resizes via `set_size` until `end_layout_resize`.
#[tauri::command]
fn start_layout_resize(app: AppHandle, state: tauri::State<AppState>) {
    let Some(w) = app.get_webview_window("layout") else {
        return;
    };
    let Some((mx, my)) = crate::poe::mouse_position() else {
        return;
    };
    let scale = w.scale_factor().unwrap_or(1.0);
    let Ok(base) = w.inner_size() else {
        return;
    };
    let base = base.to_logical::<i32>(scale);
    *state.layout_resize.lock().unwrap() = Some(());

    let app = app.clone();
    std::thread::spawn(move || {
        let start = std::time::Instant::now();
        loop {
            if app.state::<AppState>().layout_resize.lock().unwrap().is_none() {
                break;
            }
            // Safety net so a missed pointerup can't pin the size to the cursor.
            if start.elapsed().as_secs() > 30 {
                *app.state::<AppState>().layout_resize.lock().unwrap() = None;
                break;
            }
            if let (Some(w), Some((cx, cy))) =
                (app.get_webview_window("layout"), crate::poe::mouse_position())
            {
                let nw = (base.width + (cx - mx)).max(MIN_LAYOUT_W as i32) as u32;
                let nh = (base.height + (cy - my)).max(MIN_LAYOUT_H as i32) as u32;
                let _ = w.set_size(LogicalSize::new(nw, nh));
            }
            std::thread::sleep(std::time::Duration::from_millis(16));
        }
    });
}

/// Stop resizing the layout window and persist its final size.
#[tauri::command]
fn end_layout_resize(app: AppHandle, state: tauri::State<AppState>) {
    *state.layout_resize.lock().unwrap() = None;
    std::thread::sleep(std::time::Duration::from_millis(30));
    if let Some(w) = app.get_webview_window("layout") {
        let scale = w.scale_factor().unwrap_or(1.0);
        if let Ok(s) = w.inner_size() {
            let ls = s.to_logical::<u32>(scale);
            let mut cfg = state.config.lock().unwrap();
            cfg.layout_w = ls.width.max(MIN_LAYOUT_W);
            cfg.layout_h = ls.height.max(MIN_LAYOUT_H);
            cfg.save();
        }
    }
}

/// Persist the layout window's current position into config (called by the
/// frontend after the user drags it).
#[tauri::command]
fn save_layout_geometry(app: AppHandle, state: tauri::State<AppState>) {
    persist_layout_position(&app, &state);
}

/// Begin dragging the layout window by its title bar. The frontend's
/// `data-tauri-drag-region` / `setPosition` don't move this window under KWin, so
/// a background thread follows the mouse with xdotool until `end_layout_drag`.
#[tauri::command]
fn start_layout_drag(app: AppHandle, state: tauri::State<AppState>) {
    let Some(w) = app.get_webview_window("layout") else {
        return;
    };
    let Some((mx, my)) = crate::poe::mouse_position() else {
        return;
    };
    let Ok(pos) = w.outer_position() else {
        return;
    };
    // Cursor offset within the window (physical px == screen px at scale 1).
    *state.layout_drag.lock().unwrap() = Some((mx - pos.x, my - pos.y));

    // Resolve the X window id once so the loop only spawns one xdotool per tick.
    let id = std::process::Command::new("xdotool")
        .args(["search", "--name", "Exile UI Layouts"])
        .output()
        .ok()
        .and_then(|o| {
            String::from_utf8_lossy(&o.stdout)
                .lines()
                .last()
                .map(|s| s.trim().to_string())
        });
    let Some(id) = id.filter(|s| !s.is_empty()) else {
        return;
    };

    let app = app.clone();
    std::thread::spawn(move || {
        let start = std::time::Instant::now();
        loop {
            let off = match *app.state::<AppState>().layout_drag.lock().unwrap() {
                Some(o) => o,
                None => break,
            };
            // Safety net so a missed pointerup can't pin the window to the cursor.
            if start.elapsed().as_secs() > 30 {
                *app.state::<AppState>().layout_drag.lock().unwrap() = None;
                break;
            }
            if let Some((mx, my)) = crate::poe::mouse_position() {
                let _ = std::process::Command::new("xdotool")
                    .args([
                        "windowmove",
                        &id,
                        &(mx - off.0).to_string(),
                        &(my - off.1).to_string(),
                    ])
                    .status();
            }
            std::thread::sleep(std::time::Duration::from_millis(16));
        }
    });
}

/// Stop dragging the layout window and persist its final position.
#[tauri::command]
fn end_layout_drag(app: AppHandle, state: tauri::State<AppState>) {
    *state.layout_drag.lock().unwrap() = None;
    std::thread::sleep(std::time::Duration::from_millis(30));
    persist_layout_position(&app, &state);
}

fn persist_layout_position(app: &AppHandle, state: &AppState) {
    if let Some(w) = app.get_webview_window("layout") {
        let scale = w.scale_factor().unwrap_or(1.0);
        if let Ok(pos) = w.outer_position() {
            let lp = pos.to_logical::<i32>(scale);
            let mut cfg = state.config.lock().unwrap();
            cfg.layout_x = lp.x;
            cfg.layout_y = lp.y;
            cfg.save();
        }
    }
}

/// Resize the overlay window's height to fit its content (called by the frontend
/// whenever the rendered overlay's height changes). Keeps the window only as tall
/// as needed so it can be placed anywhere without the WM clamping it on-screen.
#[tauri::command]
fn set_overlay_height(app: AppHandle, state: tauri::State<AppState>, height: u32) {
    if let Some(w) = app.get_webview_window("overlay") {
        let width = state.config.lock().unwrap().overlay_width;
        let _ = w.set_size(LogicalSize::new(width, height.max(1)));
    }
}

/// Move the overlay to its saved position via xdotool. Tauri/tao's set_position
/// is clamped on-screen by KWin (and unreliable at startup), so we drive the X11
/// window move directly — it honors off-edge positions for the short window.
#[tauri::command]
fn restore_overlay_position(state: tauri::State<AppState>) {
    let (x, y) = {
        let cfg = state.config.lock().unwrap();
        (cfg.overlay_x, cfg.overlay_y)
    };
    let _ = std::process::Command::new("xdotool")
        .args([
            "search",
            "--name",
            "Exile UI Overlay",
            "windowmove",
            &x.to_string(),
            &y.to_string(),
        ])
        .status();
}

#[tauri::command]
fn save_overlay_geometry(app: AppHandle, state: tauri::State<AppState>) {
    // Persist the overlay window's current position/size back into config.
    if let Some(w) = app.get_webview_window("overlay") {
        let scale = w.scale_factor().unwrap_or(1.0);
        if let (Ok(pos), Ok(size)) = (w.outer_position(), w.inner_size()) {
            let lp = pos.to_logical::<i32>(scale);
            let ls = size.to_logical::<u32>(scale);
            let mut cfg = state.config.lock().unwrap();
            cfg.overlay_x = lp.x;
            cfg.overlay_y = lp.y;
            cfg.overlay_width = ls.width;
            cfg.save();
        }
    }
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

fn emit_timer(app: &AppHandle) {
    let t = app.state::<AppState>().timer.lock().unwrap().clone();
    let _ = app.emit("timer://update", t);
}

/// Persist the live timer into the active profile and save the config. Replaces
/// the old standalone timer.json so each profile keeps its own timer + splits.
fn store_live_timer(state: &AppState) {
    let t = { state.timer.lock().unwrap().clone() };
    let mut cfg = state.config.lock().unwrap();
    if let Some(a) = cfg.active_character.clone() {
        if let Some(p) = cfg.profiles.iter_mut().find(|p| p.name == a) {
            p.timer = t;
        }
    }
    cfg.save();
}

pub(crate) fn persist_timer(app: &AppHandle) {
    store_live_timer(&app.state::<AppState>());
}

/// A friendly local timestamp used to label a run.
pub(crate) fn now_string() -> String {
    std::process::Command::new("date")
        .arg("+%Y/%m/%d %H:%M")
        .output()
        .ok()
        .and_then(|o| String::from_utf8(o.stdout).ok())
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
        .unwrap_or_else(|| {
            let secs = std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .map(|d| d.as_secs())
                .unwrap_or(0);
            format!("run {}", secs)
        })
}

fn set_step(app: &AppHandle, new: usize) {
    let state = app.state::<AppState>();
    {
        let mut cfg = state.config.lock().unwrap();
        cfg.current_step = new;
        cfg.sync_progress();
        cfg.save();
    }
    let _ = app.emit("tracker://step", new);
}

/// Rebuild the guide for the active profile's league-start branch and clamp the
/// cursor; emits a reload so both windows refresh.
fn rebuild_guide(app: &AppHandle) {
    let state = app.state::<AppState>();
    let league = state.config.lock().unwrap().league_start;
    let rebuilt = leveltracker::load(league);
    let max = rebuilt.steps.len().saturating_sub(1);
    *state.guide.lock().unwrap() = rebuilt;
    let step = {
        let mut cfg = state.config.lock().unwrap();
        if cfg.current_step > max {
            cfg.current_step = max;
            cfg.sync_progress();
            cfg.save();
        }
        cfg.current_step
    };
    let _ = app.emit("guide://reload", ());
    let _ = app.emit("tracker://step", step);
}

/// Auto-switch to the profile matching the character read from the log, if one
/// exists. Manually-created profiles only — an unknown character is ignored.
pub(crate) fn activate_profile_if_exists(app: &AppHandle, name: &str) {
    let state = app.state::<AppState>();
    let exists = {
        let cfg = state.config.lock().unwrap();
        cfg.active_character.as_deref() != Some(name)
            && cfg.profiles.iter().any(|p| p.name == name)
    };
    if exists {
        select_profile(app.clone(), state, name.to_string());
    }
}

fn register_shortcuts(app: &AppHandle) {
    let gs = app.global_shortcut();
    let _ = gs.unregister_all();
    let cfg = app.state::<AppState>().config.lock().unwrap().clone();
    for accel in [
        &cfg.hotkey_next,
        &cfg.hotkey_prev,
        &cfg.hotkey_toggle,
        &cfg.hotkey_timer_pause,
        &cfg.hotkey_layout,
    ] {
        if let Ok(sc) = Shortcut::from_str(accel) {
            let _ = gs.register(sc);
        }
    }
}

fn apply_overlay_geometry(app: &AppHandle) {
    if let Some(w) = app.get_webview_window("overlay") {
        let cfg = app.state::<AppState>().config.lock().unwrap().clone();
        let _ = w.set_position(LogicalPosition::new(cfg.overlay_x, cfg.overlay_y));
        // Width is user-controlled; height tracks the content (set_overlay_height)
        // so a tall window isn't clamped on-screen when placed near a screen edge.
        let scale = w.scale_factor().unwrap_or(1.0);
        let h = w
            .inner_size()
            .map(|s| s.to_logical::<u32>(scale).height)
            .unwrap_or(200);
        let _ = w.set_size(LogicalSize::new(cfg.overlay_width, h));
    }
}

fn handle_shortcut(app: &AppHandle, shortcut: &Shortcut) {
    let cfg = app.state::<AppState>().config.lock().unwrap().clone();
    if Shortcut::from_str(&cfg.hotkey_next).ok().as_ref() == Some(shortcut) {
        step_delta_internal(app, 1);
    } else if Shortcut::from_str(&cfg.hotkey_prev).ok().as_ref() == Some(shortcut) {
        step_delta_internal(app, -1);
    } else if Shortcut::from_str(&cfg.hotkey_toggle).ok().as_ref() == Some(shortcut) {
        toggle_overlay(app.clone());
    } else if Shortcut::from_str(&cfg.hotkey_timer_pause).ok().as_ref() == Some(shortcut) {
        let state = app.state::<AppState>();
        {
            let mut t = state.timer.lock().unwrap();
            t.toggle_pause();
        }
        store_live_timer(&state);
        emit_timer(app);
    } else if Shortcut::from_str(&cfg.hotkey_layout).ok().as_ref() == Some(shortcut) {
        toggle_layout(app.clone(), app.state::<AppState>());
    }
}

fn step_delta_internal(app: &AppHandle, delta: i64) {
    let state = app.state::<AppState>();
    let max = state.guide.lock().unwrap().steps.len().saturating_sub(1);
    let new = {
        let cur = state.config.lock().unwrap().current_step as i64;
        (cur + delta).clamp(0, max as i64) as usize
    };
    set_step(app, new);
}

// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    // WebKitGTK's DMABUF/GBM renderer fails on a number of Linux GPU/driver
    // setups, leaving the webview blank ("Failed to create GBM buffer").
    // Disabling it forces a software/GL path that renders reliably. Users can
    // still override by exporting the variable themselves.
    #[cfg(target_os = "linux")]
    {
        if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
            std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
        }

        // Game overlays need self-positioning, always-on-top, global hotkeys
        // and active-window detection — all of which Wayland forbids for
        // clients. Running under XWayland restores them (and PoE2 itself runs
        // under XWayland via Proton, so both share one X11 space). Force the
        // X11 GDK backend whenever an X server (real or XWayland) is reachable,
        // unless the user explicitly chose a backend.
        if std::env::var_os("GDK_BACKEND").is_none() && std::env::var_os("DISPLAY").is_some() {
            std::env::set_var("GDK_BACKEND", "x11");
        }
    }

    let config = Config::load();
    let guide = leveltracker::load(config.league_start);
    // The live timer mirrors the active profile's timer.
    let live_timer = config
        .active_character
        .as_ref()
        .and_then(|a| config.profiles.iter().find(|p| &p.name == a))
        .map(|p| p.timer.clone())
        .unwrap_or_default();
    let state = AppState {
        config: Mutex::new(config),
        guide: Mutex::new(guide),
        status: Mutex::new(Status::default()),
        timer: Mutex::new(live_timer),
        layout_drag: Mutex::new(None),
        layout_resize: Mutex::new(None),
    };

    tauri::Builder::default()
        .on_window_event(|window, event| {
            // The overlay window lives for the whole session (hidden/click-through),
            // so Tauri never auto-exits when the user closes the main window.
            // Closing "main" should tear the whole app down, overlay included.
            if let tauri::WindowEvent::CloseRequested { .. } = event {
                if window.label() == "main" {
                    window.app_handle().exit(0);
                }
            }
        })
        .plugin(tauri_plugin_opener::init())
        .plugin(
            tauri_plugin_global_shortcut::Builder::new()
                .with_handler(|app, shortcut, event| {
                    if event.state() == ShortcutState::Pressed {
                        handle_shortcut(app, shortcut);
                    }
                })
                .build(),
        )
        .manage(state)
        .invoke_handler(tauri::generate_handler![
            get_config,
            save_config,
            get_status,
            get_guide,
            get_gems,
            detect_logs,
            get_timer,
            timer_start,
            timer_pause,
            timer_reset,
            step_delta,
            step_goto,
            create_profile,
            delete_profile,
            select_profile,
            set_overlay_locked,
            toggle_overlay,
            save_overlay_geometry,
            set_overlay_height,
            restore_overlay_position,
            toggle_layout,
            set_layout_size,
            save_layout_geometry,
            start_layout_drag,
            end_layout_drag,
            start_layout_resize,
            end_layout_resize,
        ])
        .setup(|app| {
            let handle = app.handle().clone();

            // Auto-detect a log path on first run if none configured.
            {
                let state = handle.state::<AppState>();
                let mut cfg = state.config.lock().unwrap();
                if cfg.log_path.is_none() {
                    if let Some(p) = poe::detect_log_paths().into_iter().next() {
                        cfg.log_path = Some(p);
                        cfg.save();
                    }
                }
            }

            // Create the transparent, click-through overlay window.
            let (ox, oy, ow) = {
                let state = handle.state::<AppState>();
                let cfg = state.config.lock().unwrap();
                (cfg.overlay_x, cfg.overlay_y, cfg.overlay_width)
            };
            let overlay = WebviewWindowBuilder::new(
                &handle,
                "overlay",
                WebviewUrl::App("index.html".into()),
            )
            .title("Exile UI Overlay")
            // Small initial height; the frontend resizes it to fit the rendered
            // content (set_overlay_height). A short window won't be clamped
            // on-screen by the WM when restored near a screen edge.
            .inner_size(ow as f64, 200.0)
            .position(ox as f64, oy as f64)
            .decorations(false)
            .transparent(true)
            .always_on_top(true)
            .skip_taskbar(true)
            .resizable(true)
            .shadow(false)
            // Must be created visible: the GTK/Gdk window only exists once
            // realized, and set_ignore_cursor_events() needs it to apply the
            // X11 input-shape (otherwise tao panics on an unrealized window).
            .visible(true)
            .build();
            // The OS window stays visible and click-through for its whole
            // lifetime; actual show/hide is handled in the frontend (a
            // transparent, empty overlay is invisible anyway). This avoids
            // GTK window-realization races around the input shape.
            if let Ok(w) = overlay {
                let _ = w.set_ignore_cursor_events(true);
                // Position is restored by the frontend (restore_overlay_position)
                // once it has shrunk the window to its content height — doing it
                // earlier lets the WM clamp the still-tall window on-screen.
            }

            // Create the interactive zone-layout viewer window, hidden until the
            // user toggles it with its hotkey. It is focusable and not
            // click-through (you click to pick/refine the matching layout).
            // Unlike the overlay it is NOT transparent: it's an opaque panel, and
            // transparent WebKitGTK windows don't reliably repaint here (the view
            // stays blank/ghosted), whereas an opaque window composites fine.
            let (lx, ly, lw, lh) = {
                let state = handle.state::<AppState>();
                let cfg = state.config.lock().unwrap();
                (cfg.layout_x, cfg.layout_y, cfg.layout_w, cfg.layout_h)
            };
            let _ = WebviewWindowBuilder::new(
                &handle,
                "layout",
                WebviewUrl::App("index.html".into()),
            )
            .title("Exile UI Layouts")
            .inner_size(lw as f64, lh as f64)
            .min_inner_size(MIN_LAYOUT_W as f64, MIN_LAYOUT_H as f64)
            .position(lx as f64, ly as f64)
            .decorations(false)
            .always_on_top(true)
            .skip_taskbar(true)
            .resizable(true)
            .shadow(false)
            .visible(false)
            .build();

            register_shortcuts(&handle);
            logwatch::spawn(handle.clone());
            Ok(())
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
