use anyhow::Result;
use slint::{ModelRc, SharedString, VecModel};
use std::rc::Rc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};

use avatarr_core::metadata::ResolvedInfo;
use avatarr_core::search::{ListEntry, SearchRoute};

// ── State abbreviation (matches Python version) ─────────────────────────────

fn state_abbrev(state: &str) -> &'static str {
    match state {
        "downloading" | "forcedDL" => "DL",
        "metaDL" => "Meta",
        "uploading" | "forcedUP" | "stalledUP" => "Seed",
        "pausedUP" | "pausedDL" => "Paused",
        "allocating" => "Alloc",
        "checkingDL" | "checkingUP" => "Check",
        "queuedDL" | "queuedUP" => "Queue",
        "stoppedUP" => "Done",
        "stoppedDL" => "Stopped",
        _ => "???",
    }
}

slint::include_modules!();

// ── Result group management ─────────────────────────────────────────────────

/// One scored result's detail fields.
#[derive(Clone)]
struct ResultDetail {
    rank: i32,
    score: i32,
    seeds: String,
    size: String,
    quality: String,
    source_type: String,
    from_source: String,
    name: String,
    info_hash: String,
}

/// One search entry's results (header + detail rows).
#[derive(Clone)]
struct ResultGroup {
    header: String,
    details: Vec<ResultDetail>,
    expanded: bool,
}

/// All shared search state in one struct.
struct SearchState {
    groups: Vec<ResultGroup>,
    /// Per-group selected rank (1-based, 0 = none selected)
    selected: Vec<i32>,
    /// The ListEntry for each group (for category routing on download)
    entries: Vec<ListEntry>,
}

impl SearchState {
    fn new() -> Self {
        Self {
            groups: Vec::new(),
            selected: Vec::new(),
            entries: Vec::new(),
        }
    }

    fn clear(&mut self) {
        self.groups.clear();
        self.selected.clear();
        self.entries.clear();
    }

    fn selected_count(&self) -> i32 {
        self.selected.iter().filter(|&&s| s > 0).count() as i32
    }
}

type SharedSearchState = Arc<Mutex<SearchState>>;

/// Flatten groups into visible SearchRow list (only show details for expanded groups).
fn flatten_groups(state: &SearchState) -> Vec<SearchRow> {
    let mut items = Vec::new();
    for (gi, g) in state.groups.iter().enumerate() {
        let group_sel = state.selected.get(gi).copied().unwrap_or(0);

        items.push(SearchRow {
            is_header: true,
            group_index: gi as i32,
            header_text: SharedString::from(&g.header),
            expanded: g.expanded,
            rank: 0,
            score: 0,
            seeds: SharedString::default(),
            size: SharedString::default(),
            quality: SharedString::default(),
            source_type: SharedString::default(),
            from_source: SharedString::default(),
            name: SharedString::default(),
            selected: false,
        });
        if g.expanded {
            for d in &g.details {
                items.push(SearchRow {
                    is_header: false,
                    group_index: gi as i32,
                    header_text: SharedString::default(),
                    expanded: false,
                    rank: d.rank,
                    score: d.score,
                    seeds: SharedString::from(&d.seeds),
                    size: SharedString::from(&d.size),
                    quality: SharedString::from(&d.quality),
                    source_type: SharedString::from(&d.source_type),
                    from_source: SharedString::from(&d.from_source),
                    name: SharedString::from(&d.name),
                    selected: d.rank == group_sel,
                });
            }
        }
    }
    items
}

fn set_results_model(weak: &slint::Weak<MainWindow>, search_state: &SharedSearchState) {
    let state = search_state.lock().unwrap();
    let items = flatten_groups(&state);
    let count = state.selected_count();
    drop(state);

    let weak = weak.clone();
    let _ = slint::invoke_from_event_loop(move || {
        if let Some(w) = weak.upgrade() {
            let model: ModelRc<SearchRow> = Rc::new(VecModel::from(items)).into();
            w.set_search_results(model);
            w.set_selected_count(count);
            w.set_download_enabled(count > 0);
        }
    });
}

// ── Main ────────────────────────────────────────────────────────────────────

fn main() -> Result<()> {
    tracing_subscriber::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::from_default_env()
                .add_directive(tracing::Level::INFO.into()),
        )
        .init();

    // Load config
    let config = avatarr_core::config::Config::load_or_create_default().unwrap_or_default();

    let config_path = avatarr_core::config::Config::default_path()
        .display()
        .to_string();

    // Create main window
    let main_window = MainWindow::new()?;
    main_window.set_config_path(SharedString::from(&config_path));
    main_window.set_app_version(SharedString::from(env!("CARGO_PKG_VERSION")));

    // Shared state
    let search_log = Arc::new(Mutex::new(String::new()));
    let search_active = Arc::new(AtomicBool::new(false));
    let search_state: SharedSearchState = Arc::new(Mutex::new(SearchState::new()));

    // Season Picker state: holds the TMDB-resolved show info while the
    // modal Season Picker is on screen. Populated by the pre-search hook,
    // read by the picker callbacks, cleared on cancel/selection.
    let picker_state: Arc<Mutex<Option<ResolvedInfo>>> = Arc::new(Mutex::new(None));

    // ── Result header click (toggle collapse) ───────────────────────────

    let window_weak = main_window.as_weak();
    let state_for_toggle = search_state.clone();
    main_window.on_result_header_clicked(move |group_index| {
        let mut state = state_for_toggle.lock().unwrap();
        if let Some(g) = state.groups.get_mut(group_index as usize) {
            g.expanded = !g.expanded;
        }
        let items = flatten_groups(&state);
        drop(state);
        if let Some(w) = window_weak.upgrade() {
            let model: ModelRc<SearchRow> = Rc::new(VecModel::from(items)).into();
            w.set_search_results(model);
        }
    });

    // ── Result detail click (toggle selection — radio per group) ─────────

    let window_weak = main_window.as_weak();
    let state_for_select = search_state.clone();
    main_window.on_result_detail_clicked(move |group_index, rank| {
        let mut state = state_for_select.lock().unwrap();
        let gi = group_index as usize;
        if gi < state.selected.len() {
            // Radio toggle: click same = deselect, click different = select
            if state.selected[gi] == rank {
                state.selected[gi] = 0;
            } else {
                state.selected[gi] = rank;
            }
        }
        let items = flatten_groups(&state);
        let count = state.selected_count();
        let dry_run = window_weak
            .upgrade()
            .map(|w| w.get_search_dry_run())
            .unwrap_or(false);
        drop(state);

        if let Some(w) = window_weak.upgrade() {
            let model: ModelRc<SearchRow> = Rc::new(VecModel::from(items)).into();
            w.set_search_results(model);
            w.set_selected_count(count);
            w.set_download_enabled(count > 0);
            let btn_text = if dry_run {
                format!("Preview ({count})")
            } else {
                format!("Download Selected ({count})")
            };
            w.set_download_btn_text(SharedString::from(&btn_text));
        }
    });

    // ── Search: clicked ─────────────────────────────────────────────────

    let window_weak = main_window.as_weak();
    let config_for_search = config.clone();
    let log_for_search = search_log.clone();
    let active_for_search = search_active.clone();
    let state_for_search = search_state.clone();
    let picker_for_search = picker_state.clone();

    main_window.on_search_clicked(move |input_text| {
        let Some(window) = window_weak.upgrade() else {
            return;
        };
        let input = input_text.to_string();
        tracing::info!("search_clicked: input = {:?}", input);
        if input.trim().is_empty() {
            return;
        }

        // Read GUI settings
        let quality_index = window.get_search_quality_index();
        let media_type_index = window.get_selected_media_type();
        let auto_mode = window.get_search_auto();

        // Clear previous state
        {
            log_for_search.lock().unwrap().clear();
            state_for_search.lock().unwrap().clear();
            *picker_for_search.lock().unwrap() = None;
        }
        active_for_search.store(true, Ordering::SeqCst);
        window.set_search_searching(true);
        window.set_search_status(SharedString::from("Searching..."));
        window.set_search_progress(0.0);
        window.set_search_log(SharedString::from(""));
        window.set_search_results(Rc::new(VecModel::<SearchRow>::default()).into());
        window.set_selected_count(0);
        window.set_download_enabled(false);
        window.set_download_btn_text(SharedString::from("Download Selected"));

        // ── Pre-search hook: TV bare-title path ──────────────────────────
        // Detect single-line TV bare-title. We reuse the same "single-line
        // payload" rule `run_search` uses below so the trigger only fires
        // for interactive searches, never for multi-entry list mode.
        use avatarr_core::metadata::MediaType;
        use avatarr_core::search::{self as search_core, SearchRoute};

        let payload_lines: Vec<&str> = input
            .lines()
            .map(str::trim)
            .filter(|t| {
                !t.is_empty()
                    && !t.starts_with('#')
                    && !t.starts_with('=')
                    && !t.starts_with('-')
            })
            .collect();
        let is_single_line = payload_lines.len() == 1;
        let is_tv = media_type_index == 1;

        if is_single_line && is_tv {
            let query_line = payload_lines[0].to_string();
            if let SearchRoute::TvComplete { show } =
                search_core::route_query(&query_line, MediaType::Tv)
            {
                // Bare TV title — trigger the resolver-then-picker flow.
                let config_clone = config_for_search.clone();
                let log_clone = log_for_search.clone();
                let active_clone = active_for_search.clone();
                let state_clone = state_for_search.clone();
                let picker_clone = picker_for_search.clone();
                let weak_clone = window.as_weak();

                std::thread::spawn(move || {
                    let rt = tokio::runtime::Runtime::new().unwrap();
                    rt.block_on(async move {
                        run_tv_bare_title_resolve(
                            weak_clone,
                            config_clone,
                            input,
                            show,
                            log_clone,
                            active_clone,
                            state_clone,
                            picker_clone,
                            quality_index,
                            media_type_index,
                            auto_mode,
                        )
                        .await;
                    });
                });
                return;
            }
        }

        // Normal path: no picker needed — launch the background search.
        launch_search_thread(
            window.as_weak(),
            config_for_search.clone(),
            input,
            log_for_search.clone(),
            active_for_search.clone(),
            state_for_search.clone(),
            quality_index,
            media_type_index,
            auto_mode,
            None,
        );
    });

    // ── Search: stop ────────────────────────────────────────────────────

    let window_weak = main_window.as_weak();
    let active_for_stop = search_active.clone();
    main_window.on_search_stop_clicked(move || {
        active_for_stop.store(false, Ordering::SeqCst);
        if let Some(window) = window_weak.upgrade() {
            window.set_search_searching(false);
            window.set_search_status(SharedString::from("Stopped"));
        }
    });

    // ── Season Picker: season button clicked ─────────────────────────────

    let window_weak = main_window.as_weak();
    let config_for_picker_season = config.clone();
    let log_for_picker_season = search_log.clone();
    let active_for_picker_season = search_active.clone();
    let state_for_picker_season = search_state.clone();
    let picker_for_season = picker_state.clone();
    main_window.on_season_picker_season_clicked(move |season| {
        let Some(window) = window_weak.upgrade() else {
            return;
        };
        let resolved = picker_for_season.lock().unwrap().clone();
        let Some(resolved) = resolved else {
            tracing::warn!("Season Picker clicked with no resolved title stored");
            window.set_season_picker_visible(false);
            return;
        };
        *picker_for_season.lock().unwrap() = None;
        window.set_season_picker_visible(false);

        // Fresh input text so `run_search` sees exactly one payload line
        // and the forced route it will consume.
        let input = resolved.title.clone();
        let forced = SearchRoute::TvSeason {
            show: resolved.title.clone(),
            season: season as u32,
        };

        launch_search_thread(
            window.as_weak(),
            config_for_picker_season.clone(),
            input,
            log_for_picker_season.clone(),
            active_for_picker_season.clone(),
            state_for_picker_season.clone(),
            window.get_search_quality_index(),
            1, // TV
            window.get_search_auto(),
            Some(forced),
        );
    });

    // ── Season Picker: Complete Pack clicked ─────────────────────────────

    let window_weak = main_window.as_weak();
    let config_for_picker_complete = config.clone();
    let log_for_picker_complete = search_log.clone();
    let active_for_picker_complete = search_active.clone();
    let state_for_picker_complete = search_state.clone();
    let picker_for_complete = picker_state.clone();
    main_window.on_season_picker_complete_clicked(move || {
        let Some(window) = window_weak.upgrade() else {
            return;
        };
        let resolved = picker_for_complete.lock().unwrap().clone();
        let Some(resolved) = resolved else {
            tracing::warn!("Season Picker Complete clicked with no resolved title stored");
            window.set_season_picker_visible(false);
            return;
        };
        *picker_for_complete.lock().unwrap() = None;
        window.set_season_picker_visible(false);

        let input = resolved.title.clone();
        let forced = SearchRoute::TvComplete {
            show: resolved.title.clone(),
        };

        launch_search_thread(
            window.as_weak(),
            config_for_picker_complete.clone(),
            input,
            log_for_picker_complete.clone(),
            active_for_picker_complete.clone(),
            state_for_picker_complete.clone(),
            window.get_search_quality_index(),
            1, // TV
            window.get_search_auto(),
            Some(forced),
        );
    });

    // ── Season Picker: Cancel clicked ────────────────────────────────────

    let window_weak = main_window.as_weak();
    let active_for_picker_cancel = search_active.clone();
    let picker_for_cancel = picker_state.clone();
    main_window.on_season_picker_cancel_clicked(move || {
        let Some(window) = window_weak.upgrade() else {
            return;
        };
        *picker_for_cancel.lock().unwrap() = None;
        active_for_picker_cancel.store(false, Ordering::SeqCst);
        window.set_season_picker_visible(false);
        window.set_search_searching(false);
        window.set_search_progress(0.0);
        window.set_search_status(SharedString::from("Cancelled"));
    });

    // ── Search: load file ───────────────────────────────────────────────

    let window_weak = main_window.as_weak();
    main_window.on_search_load_file(move || {
        if let Some(window) = window_weak.upgrade() {
            window.set_search_log(SharedString::from(
                "File loading: paste content directly into the input area,\n\
                 or use CLI: avatarr-cli search --file list.txt\n",
            ));
        }
    });

    // ── Search: download ────────────────────────────────────────────────

    let window_weak = main_window.as_weak();
    let config_for_download = config.clone();
    let log_for_download = search_log.clone();
    let state_for_download = search_state.clone();

    main_window.on_search_download(move || {
        let Some(window) = window_weak.upgrade() else {
            return;
        };
        let dry_run = window.get_search_dry_run();

        let state = state_for_download.lock().unwrap();
        // Collect selected results for download
        let mut to_download: Vec<(String, String, String)> = Vec::new(); // (info_hash, name, category)
        for (gi, &sel_rank) in state.selected.iter().enumerate() {
            if sel_rank <= 0 {
                continue;
            }
            if let Some(group) = state.groups.get(gi) {
                if let Some(detail) = group.details.iter().find(|d| d.rank == sel_rank) {
                    let category = if let Some(entry) = state.entries.get(gi) {
                        entry.qb_category(&detail.name).to_string()
                    } else {
                        "movies".to_string()
                    };
                    to_download.push((detail.info_hash.clone(), detail.name.clone(), category));
                }
            }
        }
        drop(state);

        if to_download.is_empty() {
            window.set_search_status(SharedString::from("No results selected"));
            return;
        }

        let config = config_for_download.clone();
        let log = log_for_download.clone();
        let weak = window.as_weak();

        window.set_search_status(SharedString::from(if dry_run {
            "Previewing..."
        } else {
            "Adding to qBittorrent..."
        }));

        std::thread::spawn(move || {
            let rt = tokio::runtime::Runtime::new().unwrap();
            rt.block_on(async move {
                run_download(weak, config, log, to_download, dry_run).await;
            });
        });
    });

    // ── Monitor: toggle ─────────────────────────────────────────────────

    let window_weak = main_window.as_weak();
    let config_for_monitor = config.clone();
    let monitor_active = Arc::new(AtomicBool::new(false));
    let monitor_dry_run = Arc::new(AtomicBool::new(false));
    let monitor_active_for_toggle = monitor_active.clone();
    let monitor_dry_for_toggle = monitor_dry_run.clone();
    main_window.on_monitor_toggle(move || {
        let Some(window) = window_weak.upgrade() else {
            return;
        };

        if monitor_active_for_toggle.load(Ordering::SeqCst) {
            // Stop
            monitor_active_for_toggle.store(false, Ordering::SeqCst);
            window.set_monitor_active(false);
            window.set_monitor_stats(SharedString::from("Stopped"));
        } else {
            // Start — capture dry_run state at start
            monitor_active_for_toggle.store(true, Ordering::SeqCst);
            monitor_dry_for_toggle.store(window.get_monitor_dry_run(), Ordering::SeqCst);
            window.set_monitor_active(true);
            window.set_monitor_stats(SharedString::from("Monitoring..."));

            let config = config_for_monitor.clone();
            let weak = window.as_weak();
            let poll_secs = window.get_monitor_poll_interval() as u64;
            let active = monitor_active_for_toggle.clone();
            let dry_run = monitor_dry_for_toggle.clone();

            std::thread::spawn(move || {
                let rt = tokio::runtime::Runtime::new().unwrap();
                rt.block_on(async move {
                    run_monitor_loop(weak, config, poll_secs, active, dry_run).await;
                });
            });
        }
    });

    // ── Monitor: process now ────────────────────────────────────────────

    let window_weak = main_window.as_weak();
    let config_for_process = config.clone();
    main_window.on_monitor_process_now(move || {
        let Some(window) = window_weak.upgrade() else {
            return;
        };
        window.set_monitor_stats(SharedString::from("Processing..."));

        let config = config_for_process.clone();
        let weak = window.as_weak();
        let dry_run = window.get_monitor_dry_run();

        std::thread::spawn(move || {
            let rt = tokio::runtime::Runtime::new().unwrap();
            rt.block_on(async move {
                run_monitor_once(weak, config, dry_run).await;
            });
        });
    });

    // ── Populate settings fields from config ─────────────────────────

    main_window.set_cfg_qb_url(SharedString::from(&config.qbittorrent.url));
    main_window.set_cfg_qb_user(SharedString::from(&config.qbittorrent.username));
    main_window.set_cfg_qb_pass(SharedString::from(&config.qbittorrent.password));
    main_window.set_cfg_staging_dir(SharedString::from(&config.paths.staging_dir));
    main_window.set_cfg_movie_dir(SharedString::from(&config.paths.movie_dir));
    main_window.set_cfg_uhd_dir(SharedString::from(&config.paths.uhd_movie_dir));
    main_window.set_cfg_tv_dir(SharedString::from(&config.paths.tv_dir));
    main_window.set_cfg_tmdb_key(SharedString::from(&config.tmdb.api_key));
    main_window.set_cfg_rt_enabled(config.sources.rutracker.enabled);
    main_window.set_cfg_rt_user(SharedString::from(&config.sources.rutracker.username));
    main_window.set_cfg_rt_pass(SharedString::from(&config.sources.rutracker.password));
    main_window.set_cfg_rt_url(SharedString::from(&config.sources.rutracker.base_url));

    // ── Menu: Quit ────────────────────────────────────────────────────

    let window_weak = main_window.as_weak();
    main_window.on_menu_quit(move || {
        if let Some(window) = window_weak.upgrade() {
            window.hide().ok();
        }
    });

    // ── Settings: Save ────────────────────────────────────────────────

    main_window.on_settings_save(
        move |qb_url, qb_user, qb_pass, staging, movie, uhd, tv, tmdb_key, rt_enabled, rt_user, rt_pass, rt_url| {
            let mut cfg =
                avatarr_core::config::Config::load_or_create_default().unwrap_or_default();
            cfg.qbittorrent.url = qb_url.to_string();
            cfg.qbittorrent.username = qb_user.to_string();
            cfg.qbittorrent.password = qb_pass.to_string();
            cfg.paths.staging_dir = staging.to_string();
            cfg.paths.movie_dir = movie.to_string();
            cfg.paths.uhd_movie_dir = uhd.to_string();
            cfg.paths.tv_dir = tv.to_string();
            cfg.tmdb.api_key = tmdb_key.to_string();
            cfg.sources.rutracker.enabled = rt_enabled;
            cfg.sources.rutracker.username = rt_user.to_string();
            cfg.sources.rutracker.password = rt_pass.to_string();
            cfg.sources.rutracker.base_url = rt_url.to_string();

            let path = avatarr_core::config::Config::default_path();
            match cfg.save(&path) {
                Ok(()) => tracing::info!("Settings saved to {}", path.display()),
                Err(e) => tracing::error!("Failed to save settings: {e}"),
            }
        },
    );

    // ── Move Log: load ────────────────────────────────────────────────

    let window_weak = main_window.as_weak();
    main_window.on_load_move_log(move || {
        let Some(window) = window_weak.upgrade() else {
            return;
        };

        let path = avatarr_core::move_log::log_path();
        let entries: Vec<HistoryItem> = match std::fs::read_to_string(&path) {
            Ok(content) => {
                let mut items: Vec<HistoryItem> = content
                    .lines()
                    .filter(|l| !l.trim().is_empty())
                    .filter_map(|line| {
                        let v: serde_json::Value = serde_json::from_str(line).ok()?;
                        let timestamp = v.get("timestamp")?.as_str()?;
                        // Parse "2026-02-23T21:12:00.000Z" or similar into "2026-02-23 21:12"
                        let time = if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(timestamp) {
                            dt.with_timezone(&chrono::Local)
                                .format("%Y-%m-%d %H:%M")
                                .to_string()
                        } else {
                            // Try parsing as local datetime
                            timestamp
                                .chars()
                                .take(16)
                                .collect::<String>()
                                .replace('T', " ")
                        };
                        let title = v.get("torrent_name")?.as_str()?.to_string();
                        let status_raw = v.get("status")?.as_str()?.to_string();
                        let status = status_raw.to_uppercase();
                        let ok = status_raw == "ok";
                        let lib_root = v.get("destination")?.as_str().unwrap_or("");
                        // Show the renamed path from the first moved file, relative to library root
                        let destination = v
                            .get("files")
                            .and_then(|f| f.as_array())
                            .and_then(|arr| arr.first())
                            .and_then(|f| f.get("dest"))
                            .and_then(|d| d.as_str())
                            .map(|dest_path| {
                                dest_path
                                    .strip_prefix(lib_root)
                                    .unwrap_or(dest_path)
                                    .to_string()
                            })
                            .unwrap_or_else(|| lib_root.to_string());

                        Some(HistoryItem {
                            time: SharedString::from(&time),
                            title: SharedString::from(&title),
                            status: SharedString::from(&status),
                            ok,
                            destination: SharedString::from(&destination),
                        })
                    })
                    .collect();
                items.reverse(); // newest first
                items
            }
            Err(_) => Vec::new(),
        };

        let model: ModelRc<HistoryItem> = Rc::new(VecModel::from(entries)).into();
        window.set_movelog_entries(model);
    });

    // ── Move Log: clear ───────────────────────────────────────────────

    main_window.on_clear_move_log(move || {
        let path = avatarr_core::move_log::log_path();
        if let Err(e) = std::fs::remove_file(&path) {
            tracing::warn!("Failed to clear move log: {e}");
        }
    });

    // ── Copy to clipboard ──────────────────────────────────────────────

    main_window.on_copy_to_clipboard(move |text| {
        if let Ok(mut clipboard) = arboard::Clipboard::new() {
            let _ = clipboard.set_text(text.to_string());
        }
    });

    // ── Connection check on startup ─────────────────────────────────────

    let window_weak = main_window.as_weak();
    let qb_url = config.qbittorrent.url.clone();
    std::thread::spawn(move || {
        let rt = tokio::runtime::Runtime::new().unwrap();
        rt.block_on(async move {
            let client = avatarr_core::qbit::QbitClient::new(&qb_url);
            let ok = client.get_torrents().await.is_ok();

            let _ = slint::invoke_from_event_loop(move || {
                if let Some(window) = window_weak.upgrade() {
                    if ok {
                        window
                            .set_connection_status(SharedString::from("Connected to qBittorrent"));
                        window.set_connected(true);
                    } else {
                        window
                            .set_connection_status(SharedString::from("qBittorrent not reachable"));
                        window.set_connected(false);
                    }
                }
            });
        });
    });

    main_window.run()?;
    Ok(())
}

// ── Background tasks ────────────────────────────────────────────────────────

/// Spawn the background `run_search` thread. Centralised so the
/// happy-path click handler and the three Season Picker callbacks all
/// dispatch search work the same way.
#[allow(clippy::too_many_arguments)]
fn launch_search_thread(
    weak: slint::Weak<MainWindow>,
    config: avatarr_core::config::Config,
    input: String,
    log: Arc<Mutex<String>>,
    active: Arc<AtomicBool>,
    state: SharedSearchState,
    quality_index: i32,
    media_type_index: i32,
    auto_mode: bool,
    forced_route: Option<SearchRoute>,
) {
    // If we're coming from a picker callback, `active` may have been
    // reset by a cancel that never fired. Re-arm it so the search can
    // actually run. Also reset status-visible bits so the UI shows
    // "Searching..." while the background task spins up.
    active.store(true, Ordering::SeqCst);
    {
        let log = log.clone();
        let weak = weak.clone();
        let _ = slint::invoke_from_event_loop(move || {
            if let Some(w) = weak.upgrade() {
                w.set_search_searching(true);
                w.set_search_status(SharedString::from("Searching..."));
                w.set_search_progress(0.0);
                w.set_search_log(SharedString::from(log.lock().unwrap().as_str()));
            }
        });
    }

    std::thread::spawn(move || {
        let rt = tokio::runtime::Runtime::new().unwrap();
        rt.block_on(async move {
            run_search(
                weak,
                config,
                input,
                log,
                active,
                state,
                quality_index,
                media_type_index,
                auto_mode,
                forced_route,
            )
            .await;
        });
    });
}

/// TV bare-title pre-search hook. Resolves the show via TMDB, then
/// either pops the Season Picker dialog (on hit) or falls through to
/// a literal `search_tv_complete` (on miss) with an inline log notice.
#[allow(clippy::too_many_arguments)]
async fn run_tv_bare_title_resolve(
    window_weak: slint::Weak<MainWindow>,
    config: avatarr_core::config::Config,
    input: String,
    show: String,
    log: Arc<Mutex<String>>,
    active: Arc<AtomicBool>,
    search_state: SharedSearchState,
    picker_state: Arc<Mutex<Option<ResolvedInfo>>>,
    quality_index: i32,
    media_type_index: i32,
    auto_mode: bool,
) {
    use avatarr_core::search::{SearchEngine, SearchRoute};

    // Nudge the status so the user sees what's happening while TMDB spins.
    append_log(&log, &format!("Resolving \"{show}\" via TMDB...\n"));
    {
        let weak = window_weak.clone();
        let log_text = log.lock().unwrap().clone();
        let _ = slint::invoke_from_event_loop(move || {
            if let Some(w) = weak.upgrade() {
                w.set_search_status(SharedString::from("Resolving title..."));
                w.set_search_log(SharedString::from(&log_text));
            }
        });
    }

    let engine = SearchEngine::new(config.clone());
    let resolved = engine.resolver().resolve_tv_show(&show, None).await;

    if !active.load(Ordering::SeqCst) {
        // User bailed out via Stop before TMDB came back.
        append_log(&log, "Search cancelled during title resolution.\n");
        update_search_status(&window_weak, &log, false, "Cancelled");
        return;
    }

    match resolved {
        Some(info) => {
            append_log(
                &log,
                &format!("Resolved: {} ({})\n", info.title, info.year),
            );
            let title_text = format!("{} ({})", info.title, info.year);
            *picker_state.lock().unwrap() = Some(info);

            let weak = window_weak.clone();
            let log_text = log.lock().unwrap().clone();
            let _ = slint::invoke_from_event_loop(move || {
                if let Some(w) = weak.upgrade() {
                    w.set_season_picker_title(SharedString::from(&title_text));
                    w.set_season_picker_visible(true);
                    // Pause the status so the user knows the picker is up.
                    w.set_search_status(SharedString::from("Pick a season"));
                    w.set_search_log(SharedString::from(&log_text));
                    // Keep the spinner hidden while the modal is up —
                    // we're not doing network work right now.
                    w.set_search_searching(false);
                }
            });
        }
        None => {
            append_log(
                &log,
                &format!(
                    "Couldn't resolve \"{show}\" via TMDB. Searching as typed...\n"
                ),
            );
            {
                let weak = window_weak.clone();
                let log_text = log.lock().unwrap().clone();
                let _ = slint::invoke_from_event_loop(move || {
                    if let Some(w) = weak.upgrade() {
                        w.set_search_log(SharedString::from(&log_text));
                        w.set_search_status(SharedString::from(
                            "Couldn't resolve title, searching as typed",
                        ));
                    }
                });
            }

            // Fall through to a literal complete-pack search. We reuse
            // `run_search` directly (not `launch_search_thread`) because
            // we're already on a background runtime.
            let forced = SearchRoute::TvComplete { show: show.clone() };
            run_search(
                window_weak,
                config,
                input,
                log,
                active,
                search_state,
                quality_index,
                media_type_index,
                auto_mode,
                Some(forced),
            )
            .await;
        }
    }
}

#[allow(clippy::too_many_arguments)]
async fn run_search(
    window_weak: slint::Weak<MainWindow>,
    config: avatarr_core::config::Config,
    input: String,
    log: Arc<Mutex<String>>,
    active: Arc<AtomicBool>,
    search_state: SharedSearchState,
    quality_index: i32,
    media_type_index: i32,
    auto_mode: bool,
    // When set, the single-line code path uses this route verbatim instead
    // of inferring one from the Media Type dropdown via `route_query`. This
    // is how the Season Picker dispatches a search with a resolved TMDB
    // title and an explicit season (or Complete Pack) after the user has
    // made their selection.
    forced_route: Option<SearchRoute>,
) {
    use avatarr_core::metadata::MediaType;
    use avatarr_core::scoring::QualityPref;
    use avatarr_core::search::{self, SearchEngine};
    use avatarr_core::sources;

    let engine = SearchEngine::new(config);
    let mut entries = search::parse_list_file(&input);

    // Detect single-line interactive search: exactly one non-blank, non-comment
    // line of input. In that mode the Media Type dropdown forces routing via
    // `search_with_type`, overriding whatever `parse_list_file` inferred. Lists
    // (2+ entries) stay on the explicit per-line parser path and ignore the
    // dropdown, because each line may specify its own type via `SXX` / year.
    let payload_line_count = input
        .lines()
        .filter(|l| {
            let t = l.trim();
            !t.is_empty() && !t.starts_with('#') && !t.starts_with('=') && !t.starts_with('-')
        })
        .count();
    let single_line_mode = entries.len() == 1 && payload_line_count == 1;
    let forced_query: Option<(String, MediaType)> = if single_line_mode {
        // Grab the raw user query (the only payload line) so the dropdown can
        // override the parser's best-guess classification.
        let query_line = input
            .lines()
            .map(str::trim)
            .find(|t| {
                !t.is_empty()
                    && !t.starts_with('#')
                    && !t.starts_with('=')
                    && !t.starts_with('-')
            })
            .unwrap_or("")
            .to_string();
        let media_type = match media_type_index {
            1 => MediaType::Tv,
            _ => MediaType::Movie,
        };
        // If the caller supplied an explicit route (Season Picker path), use
        // it verbatim and rewrite the entry to match. Otherwise fall back to
        // `route_query` for the happy path.
        let route = forced_route
            .clone()
            .unwrap_or_else(|| search::route_query(&query_line, media_type));
        entries[0] = match route.clone() {
            search::SearchRoute::Movie { title, year } => ListEntry::Movie { title, year },
            search::SearchRoute::TvSeason { show, season } => ListEntry::Tv { show, season },
            search::SearchRoute::TvComplete { show } => ListEntry::TvComplete { show },
        };
        Some((query_line, media_type))
    } else {
        None
    };

    if entries.is_empty() {
        append_log(&log, "No entries found in input.\n");
        update_search_status(&window_weak, &log, false, "No entries found");
        return;
    }

    let total = entries.len();
    let quality_pref = match quality_index {
        1 => QualityPref::Specific("720p".to_string()),
        2 => QualityPref::Specific("1080p".to_string()),
        3 => QualityPref::Specific("2160p".to_string()),
        _ => QualityPref::Any,
    };

    for (i, entry) in entries.iter_mut().enumerate() {
        if !active.load(Ordering::SeqCst) {
            append_log(&log, "Search stopped by user.\n");
            break;
        }

        let label = entry.label();
        append_log(&log, &format!("[{}/{}] {label}\n", i + 1, total));

        // Update progress
        let progress = (i as f32 + 0.5) / total as f32;
        let status = format!("[{}/{}] {label}", i + 1, total);
        {
            let log_text = log.lock().unwrap().clone();
            let weak = window_weak.clone();
            let status_clone = status.clone();
            let _ = slint::invoke_from_event_loop(move || {
                if let Some(w) = weak.upgrade() {
                    w.set_search_progress(progress);
                    w.set_search_status(SharedString::from(&status_clone));
                    w.set_search_log(SharedString::from(&log_text));
                }
            });
        }

        let scored = if let Some(route) = forced_route.clone() {
            // Season Picker path: the user already made an explicit choice,
            // so skip `search_with_type`'s routing entirely and dispatch
            // directly to the chosen per-type search function.
            match route {
                SearchRoute::Movie { title, year } => {
                    engine.search_movie(&title, &year, &quality_pref).await
                }
                SearchRoute::TvSeason { show, season } => {
                    engine.search_tv_season(&show, season, &quality_pref).await
                }
                SearchRoute::TvComplete { show } => {
                    engine.search_tv_complete(&show, &quality_pref).await
                }
            }
        } else if let Some((query, media_type)) = forced_query.as_ref() {
            // Single-line interactive search: honour the Media Type dropdown
            // rather than whatever `parse_list_file` inferred.
            engine
                .search_with_type(query, *media_type, &quality_pref)
                .await
        } else {
            // Multi-line list mode: each entry carries its own explicit type.
            engine.process_entry(entry, &quality_pref).await
        };

        // Build group for this entry
        let mut group = ResultGroup {
            header: String::new(),
            details: Vec::new(),
            expanded: true,
        };

        // Auto-select: rank 1 (best) if auto_mode, 0 (none) otherwise
        let auto_sel = if auto_mode && !scored.is_empty() {
            1
        } else {
            0
        };

        if scored.is_empty() {
            append_log(&log, "  No results\n");
            group.header = format!("{label} — no results");
        } else {
            group.header = format!("{label}: {} results", scored.len());
            for (rank, sr) in scored.iter().take(10).enumerate() {
                group.details.push(ResultDetail {
                    rank: (rank + 1) as i32,
                    score: sr.score as i32,
                    seeds: sr.result.seeders.to_string(),
                    size: sources::format_size(sr.result.size),
                    quality: sources::quality_tag(&sr.result.name).to_string(),
                    source_type: sources::source_tag(&sr.result.name).to_string(),
                    from_source: sr.result.source.clone(),
                    name: sr.result.name.clone(),
                    info_hash: sr.result.info_hash.clone(),
                });
            }
            let best = &scored[0];
            append_log(
                &log,
                &format!(
                    "  Best: {} [{}, {}]\n",
                    best.result.name,
                    sources::quality_tag(&best.result.name),
                    sources::format_size(best.result.size)
                ),
            );
        }

        {
            let mut state = search_state.lock().unwrap();
            state.groups.push(group);
            state.selected.push(auto_sel);
            state.entries.push(entry.clone());
        }

        // Update UI with current groups
        set_results_model(&window_weak, &search_state);

        // Update download button
        {
            let state = search_state.lock().unwrap();
            let count = state.selected_count();
            drop(state);
            // Read dry_run from UI thread
            let weak = window_weak.clone();
            let _ = slint::invoke_from_event_loop(move || {
                if let Some(w) = weak.upgrade() {
                    let dry_run = w.get_search_dry_run();
                    let btn_text = if dry_run {
                        format!("Preview ({count})")
                    } else {
                        format!("Download Selected ({count})")
                    };
                    w.set_download_btn_text(SharedString::from(&btn_text));
                }
            });
        }

        {
            let log_text = log.lock().unwrap().clone();
            let weak = window_weak.clone();
            let _ = slint::invoke_from_event_loop(move || {
                if let Some(w) = weak.upgrade() {
                    w.set_search_log(SharedString::from(&log_text));
                }
            });
        }
    }

    active.store(false, Ordering::SeqCst);
    append_log(&log, &format!("\nDone — {} entries processed.\n", total));
    update_search_status(
        &window_weak,
        &log,
        false,
        &format!("Done — {} entries", total),
    );
}

fn update_search_status(
    weak: &slint::Weak<MainWindow>,
    log: &Arc<Mutex<String>>,
    searching: bool,
    status: &str,
) {
    let log_text = log.lock().unwrap().clone();
    let status = status.to_string();
    let weak = weak.clone();
    let _ = slint::invoke_from_event_loop(move || {
        if let Some(w) = weak.upgrade() {
            w.set_search_searching(searching);
            w.set_search_progress(if searching { 0.0 } else { 1.0 });
            w.set_search_status(SharedString::from(&status));
            w.set_search_log(SharedString::from(&log_text));
        }
    });
}

/// Download selected results to qBittorrent.
async fn run_download(
    window_weak: slint::Weak<MainWindow>,
    config: avatarr_core::config::Config,
    log: Arc<Mutex<String>>,
    to_download: Vec<(String, String, String)>, // (info_hash, name, category)
    dry_run: bool,
) {
    use avatarr_core::sources;

    let mut added = 0u32;
    let mut failed = 0u32;

    if dry_run {
        for (_, name, category) in &to_download {
            append_log(
                &log,
                &format!("[DRY RUN] Would add: {name} [cat={category}]\n"),
            );
            added += 1;
        }
    } else {
        let client = avatarr_core::qbit::QbitClient::new(&config.qbittorrent.url);
        if !config.qbittorrent.username.is_empty() {
            if let Err(e) = client
                .login(&config.qbittorrent.username, &config.qbittorrent.password)
                .await
            {
                append_log(&log, &format!("ERROR: qBittorrent auth failed: {e}\n"));
                let weak = window_weak.clone();
                let log_text = log.lock().unwrap().clone();
                let _ = slint::invoke_from_event_loop(move || {
                    if let Some(w) = weak.upgrade() {
                        w.set_search_status(SharedString::from(format!("Auth failed: {e}")));
                        w.set_search_log(SharedString::from(&log_text));
                    }
                });
                return;
            }
        }

        for (info_hash, name, category) in &to_download {
            let magnet = sources::build_magnet(info_hash, name);
            match client.add_magnet(&magnet, Some(category)).await {
                Ok(_) => {
                    append_log(&log, &format!("Added: {name} [cat={category}]\n"));
                    added += 1;
                }
                Err(e) => {
                    append_log(&log, &format!("ERROR: Failed to add {name}: {e}\n"));
                    failed += 1;
                }
            }
        }
    }

    let action = if dry_run { "Previewed" } else { "Added" };
    let status = if failed > 0 {
        format!("{action}: {added}  Failed: {failed}")
    } else {
        format!("{action}: {added}")
    };
    append_log(&log, &format!("\n{status}\n"));

    let log_text = log.lock().unwrap().clone();
    let weak = window_weak.clone();
    let disable_btn = !dry_run && added > 0;
    let _ = slint::invoke_from_event_loop(move || {
        if let Some(w) = weak.upgrade() {
            w.set_search_status(SharedString::from(&status));
            w.set_search_log(SharedString::from(&log_text));
            if disable_btn {
                w.set_download_enabled(false);
                w.set_download_btn_text(SharedString::from(format!("Downloaded ({added})")));
            }
        }
    });
}

async fn run_monitor_loop(
    window_weak: slint::Weak<MainWindow>,
    config: avatarr_core::config::Config,
    poll_secs: u64,
    active: Arc<AtomicBool>,
    dry_run_flag: Arc<AtomicBool>,
) {
    let client = avatarr_core::qbit::QbitClient::new(&config.qbittorrent.url);
    let resolver = avatarr_core::metadata::MetadataResolver::new(&config.tmdb.api_key);

    if !config.qbittorrent.username.is_empty() {
        if let Err(e) = client
            .login(&config.qbittorrent.username, &config.qbittorrent.password)
            .await
        {
            active.store(false, Ordering::SeqCst);
            let weak = window_weak.clone();
            let _ = slint::invoke_from_event_loop(move || {
                if let Some(w) = weak.upgrade() {
                    w.set_monitor_stats(SharedString::from(format!("Auth failed: {e}")));
                    w.set_monitor_active(false);
                }
            });
            return;
        }
    }

    // Move history shared across iterations
    let history: Arc<Mutex<Vec<HistoryItem>>> = Arc::new(Mutex::new(Vec::new()));
    let mut processed_hashes: std::collections::HashSet<String> = std::collections::HashSet::new();

    loop {
        if !active.load(Ordering::SeqCst) {
            break;
        }

        let dry_run = dry_run_flag.load(Ordering::SeqCst);

        match client.get_torrents().await {
            Ok(torrents) => {
                // Count by state category
                let downloading = torrents
                    .iter()
                    .filter(|t| {
                        matches!(
                            t.state.as_str(),
                            "downloading" | "forcedDL" | "metaDL" | "allocating"
                        )
                    })
                    .count();
                let seeding = torrents
                    .iter()
                    .filter(|t| matches!(t.state.as_str(), "uploading" | "forcedUP" | "stalledUP"))
                    .count();
                let completed = torrents
                    .iter()
                    .filter(|t| matches!(t.state.as_str(), "stoppedUP"))
                    .count();
                let paused = torrents
                    .iter()
                    .filter(|t| matches!(t.state.as_str(), "pausedUP" | "pausedDL"))
                    .count();

                let stats = format!(
                    "Downloading: {downloading}  Seeding: {seeding}  Completed: {completed}  Paused: {paused}"
                );

                // Build structured torrent items
                let items: Vec<TorrentItem> = torrents
                    .iter()
                    .map(|t| {
                        let pct = (t.progress * 100.0) as u32;
                        TorrentItem {
                            name: SharedString::from(&t.name),
                            category: SharedString::from(&t.category),
                            progress: t.progress as f32,
                            progress_text: SharedString::from(format!("{pct}%")),
                            state: SharedString::from(state_abbrev(&t.state)),
                            size: SharedString::from(avatarr_core::sources::format_size(
                                t.size,
                            )),
                        }
                    })
                    .collect();

                // Update UI immediately with torrent list (before any processing)
                {
                    let history_items: Vec<HistoryItem> = history.lock().unwrap().clone();
                    let weak = window_weak.clone();
                    let stats_clone = stats.clone();
                    let _ = slint::invoke_from_event_loop(move || {
                        if let Some(w) = weak.upgrade() {
                            w.set_monitor_stats(SharedString::from(&stats_clone));
                            w.set_monitor_torrents(Rc::new(VecModel::from(items)).into());
                            w.set_monitor_history(Rc::new(VecModel::from(history_items)).into());
                        }
                    });
                }

                // Find newly completed torrents for per-torrent processing
                let newly_completed: Vec<_> = torrents
                    .iter()
                    .filter(|t| t.is_completed() && !processed_hashes.contains(&t.hash))
                    .cloned()
                    .collect();

                // Process each torrent individually, updating UI after each
                for (i, torrent) in newly_completed.iter().enumerate() {
                    if !active.load(Ordering::SeqCst) {
                        break;
                    }

                    // Show which torrent is being processed
                    {
                        let name = torrent.name.clone();
                        let idx = i + 1;
                        let total = newly_completed.len();
                        let weak = window_weak.clone();
                        let _ = slint::invoke_from_event_loop(move || {
                            if let Some(w) = weak.upgrade() {
                                w.set_monitor_stats(SharedString::from(format!(
                                    "Processing [{idx}/{total}]: {name}"
                                )));
                            }
                        });
                    }

                    let result = avatarr_core::monitor::process_torrent(
                        &client, &config, &resolver, torrent, dry_run,
                    )
                    .await;

                    let now = chrono::Local::now().format("%H:%M:%S").to_string();
                    let category = torrent.category.to_lowercase();
                    let dest = match category.as_str() {
                        "tv" => &config.paths.tv_dir,
                        "uhd" => &config.paths.uhd_movie_dir,
                        _ => &config.paths.movie_dir,
                    };

                    let (status_text, ok) = match &result {
                        Ok(true) => ("OK", true),
                        Ok(false) => ("SKIP", true),
                        Err(_) => ("FAIL", false),
                    };

                    history.lock().unwrap().insert(
                        0,
                        HistoryItem {
                            time: SharedString::from(&now),
                            title: SharedString::from(&torrent.name),
                            status: SharedString::from(status_text),
                            ok,
                            destination: SharedString::from(dest.as_str()),
                        },
                    );

                    processed_hashes.insert(torrent.hash.clone());

                    // Update UI history after each torrent
                    {
                        let history_items: Vec<HistoryItem> = history.lock().unwrap().clone();
                        let weak = window_weak.clone();
                        let stats_clone = stats.clone();
                        let _ = slint::invoke_from_event_loop(move || {
                            if let Some(w) = weak.upgrade() {
                                w.set_monitor_stats(SharedString::from(&stats_clone));
                                w.set_monitor_history(
                                    Rc::new(VecModel::from(history_items)).into(),
                                );
                            }
                        });
                    }
                }
            }
            Err(e) => {
                let weak = window_weak.clone();
                let _ = slint::invoke_from_event_loop(move || {
                    if let Some(w) = weak.upgrade() {
                        w.set_monitor_stats(SharedString::from(format!("Error: {e}")));
                    }
                });
            }
        }

        tokio::time::sleep(std::time::Duration::from_secs(poll_secs)).await;
    }
}

async fn run_monitor_once(
    window_weak: slint::Weak<MainWindow>,
    config: avatarr_core::config::Config,
    dry_run: bool,
) {
    let client = avatarr_core::qbit::QbitClient::new(&config.qbittorrent.url);
    let resolver = avatarr_core::metadata::MetadataResolver::new(&config.tmdb.api_key);

    if !config.qbittorrent.username.is_empty() {
        if let Err(e) = client
            .login(&config.qbittorrent.username, &config.qbittorrent.password)
            .await
        {
            let weak = window_weak.clone();
            let _ = slint::invoke_from_event_loop(move || {
                if let Some(w) = weak.upgrade() {
                    w.set_monitor_stats(SharedString::from(format!("Auth failed: {e}")));
                }
            });
            return;
        }
    }

    // Fetch and display current torrent list
    if let Ok(torrents) = client.get_torrents().await {
        let items: Vec<TorrentItem> = torrents
            .iter()
            .map(|t| {
                let pct = (t.progress * 100.0) as u32;
                TorrentItem {
                    name: SharedString::from(&t.name),
                    category: SharedString::from(&t.category),
                    progress: t.progress as f32,
                    progress_text: SharedString::from(format!("{pct}%")),
                    state: SharedString::from(state_abbrev(&t.state)),
                    size: SharedString::from(avatarr_core::sources::format_size(t.size)),
                }
            })
            .collect();

        let weak = window_weak.clone();
        let _ = slint::invoke_from_event_loop(move || {
            if let Some(w) = weak.upgrade() {
                w.set_monitor_torrents(Rc::new(VecModel::from(items)).into());
            }
        });
    }

    match avatarr_core::monitor::process_completed(&client, &config, &resolver, dry_run).await
    {
        Ok(r) => {
            let prefix = if dry_run { "[DRY RUN] " } else { "" };
            let msg = format!(
                "{prefix}Processed: {} (skipped: {}, failed: {})",
                r.processed, r.skipped, r.failed
            );
            let weak = window_weak.clone();
            let _ = slint::invoke_from_event_loop(move || {
                if let Some(w) = weak.upgrade() {
                    w.set_monitor_stats(SharedString::from(&msg));
                }
            });
        }
        Err(e) => {
            let weak = window_weak.clone();
            let _ = slint::invoke_from_event_loop(move || {
                if let Some(w) = weak.upgrade() {
                    w.set_monitor_stats(SharedString::from(format!("Error: {e}")));
                }
            });
        }
    }
}

fn append_log(log: &Arc<Mutex<String>>, text: &str) {
    log.lock().unwrap().push_str(text);
}
