use crate::captioner::{self, FlorenceCaptioner};
use crate::db::{self, DbPool, EmbeddingJob, FolderJobProgress, ImageRecord, IndexedMediaEntry};
use crate::embedder::{embedding_source_path, ClipImageEmbedder};
use crate::media::{probe_video_metadata, MediaTools};
use crate::storage::{detect_storage_profile, RuntimeAdaptiveProfile, StorageProfile};
use crate::tagger::{self, WdTagger};
use crate::thumbnail;
use crate::vector;
use anyhow::Result;
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
use rayon::prelude::*;
use serde::Serialize;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter, Manager};
use walkdir::WalkDir;

const IMAGE_EXTENSIONS: &[&str] = &[
    "jpg", "jpeg", "png", "gif", "bmp", "tiff", "tif", "webp", "avif",
];

const VIDEO_EXTENSIONS: &[&str] = &["mp4", "mov", "m4v", "webm"];

const JOB_PROGRESS_EMIT_INTERVAL: Duration = Duration::from_millis(750);
#[allow(dead_code)] // caption worker disabled (lib.rs)
const CAPTION_BATCH_SIZE: usize = 1;

static LAST_JOB_PROGRESS_EMIT: OnceLock<Mutex<HashMap<i64, Instant>>> = OnceLock::new();
static ACTIVE_INDEXING_FOLDERS: OnceLock<Mutex<HashSet<i64>>> = OnceLock::new();

static PAUSED_WORKER_FOLDERS: OnceLock<Mutex<PausedWorkerFolders>> = OnceLock::new();

#[derive(Default)]
struct PausedWorkerFolders {
    thumbnail: HashSet<i64>,
    metadata: HashSet<i64>,
    embedding: HashSet<i64>,
    caption: HashSet<i64>,
    tagging: HashSet<i64>,
}

#[derive(Clone, Copy)]
pub struct FolderWorkerPausedState {
    pub thumbnail: bool,
    pub metadata: bool,
    pub embedding: bool,
    pub caption: bool,
    pub tagging: bool,
}

pub fn set_worker_paused(worker: &str, folder_id: i64, paused: bool) {
    if let Ok(mut paused_folders) = PAUSED_WORKER_FOLDERS
        .get_or_init(|| Mutex::new(PausedWorkerFolders::default()))
        .lock()
    {
        let folder_set = match worker {
            "thumbnail" => Some(&mut paused_folders.thumbnail),
            "metadata" => Some(&mut paused_folders.metadata),
            "embedding" => Some(&mut paused_folders.embedding),
            "caption" => Some(&mut paused_folders.caption),
            "tagging" => Some(&mut paused_folders.tagging),
            _ => None,
        };

        if let Some(folder_set) = folder_set {
            if paused {
                folder_set.insert(folder_id);
            } else {
                folder_set.remove(&folder_id);
            }
        }
    }
}

pub fn get_worker_paused_states(folder_ids: &[i64]) -> HashMap<i64, FolderWorkerPausedState> {
    let Ok(paused_folders) = PAUSED_WORKER_FOLDERS
        .get_or_init(|| Mutex::new(PausedWorkerFolders::default()))
        .lock()
    else {
        return HashMap::new();
    };

    folder_ids
        .iter()
        .copied()
        .map(|folder_id| {
            (
                folder_id,
                FolderWorkerPausedState {
                    thumbnail: paused_folders.thumbnail.contains(&folder_id),
                    metadata: paused_folders.metadata.contains(&folder_id),
                    embedding: paused_folders.embedding.contains(&folder_id),
                    caption: paused_folders.caption.contains(&folder_id),
                    tagging: paused_folders.tagging.contains(&folder_id),
                },
            )
        })
        .collect()
}

fn paused_folder_ids(worker: &str) -> HashSet<i64> {
    let Ok(paused_folders) = PAUSED_WORKER_FOLDERS
        .get_or_init(|| Mutex::new(PausedWorkerFolders::default()))
        .lock()
    else {
        return HashSet::new();
    };

    match worker {
        "thumbnail" => paused_folders.thumbnail.clone(),
        "metadata" => paused_folders.metadata.clone(),
        "embedding" => paused_folders.embedding.clone(),
        "caption" => paused_folders.caption.clone(),
        "tagging" => paused_folders.tagging.clone(),
        _ => HashSet::new(),
    }
}
static FOLDER_STORAGE_PROFILES: OnceLock<Mutex<HashMap<i64, RuntimeAdaptiveProfile>>> =
    OnceLock::new();
static DB_WRITE_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
const EMBEDDING_BATCH_SIZE: usize = 8;

/// Background workers form a strict priority pipeline: a worker only claims
/// jobs when no higher-priority claimable work exists, so stages drain one
/// at a time (thumbnails → metadata → embeddings → tagging) instead of all
/// workers contending for CPU, disk, and the DB writer at once.
#[derive(Clone, Copy, PartialEq, PartialOrd)]
enum WorkerTier {
    Thumbnail = 0,
    Metadata = 1,
    Embedding = 2,
    Tagging = 3,
}

/// True if any tier above `own_tier` still has claimable work. Each check
/// uses the same exclusion set the corresponding claim would (that worker's
/// paused folders plus actively-indexing folders), so paused or mid-scan
/// work never gates lower tiers.
fn higher_priority_work_pending(pool: &DbPool, own_tier: WorkerTier) -> Result<bool> {
    let conn = pool.get()?;
    let active_folders = active_indexing_folders();

    // Video jobs are unclaimable until FFmpeg is provisioned; they must not
    // count as pending higher-priority work or they'd stall lower tiers.
    let ffmpeg_ready = crate::media::ffmpeg_ready();

    if own_tier > WorkerTier::Thumbnail {
        let mut excluded = paused_folder_ids("thumbnail");
        excluded.extend(active_folders.iter().copied());
        if db::has_claimable_thumbnail_jobs(&conn, &excluded, ffmpeg_ready)? {
            return Ok(true);
        }
    }
    if own_tier > WorkerTier::Metadata && ffmpeg_ready {
        let mut excluded = paused_folder_ids("metadata");
        excluded.extend(active_folders.iter().copied());
        if db::has_claimable_metadata_jobs(&conn, &excluded)? {
            return Ok(true);
        }
    }
    if own_tier > WorkerTier::Embedding {
        let mut excluded = paused_folder_ids("embedding");
        excluded.extend(active_folders.iter().copied());
        if db::has_claimable_embedding_jobs(&conn, &excluded)? {
            return Ok(true);
        }
    }
    Ok(false)
}

#[derive(Clone, Serialize)]
pub struct IndexProgress {
    pub folder_id: i64,
    pub total: usize,
    pub indexed: usize,
    pub current_file: String,
    pub done: bool,
}

#[derive(Clone, Serialize)]
pub struct IndexedImagesBatch {
    pub folder_id: i64,
    pub images: Vec<ImageRecord>,
}

#[derive(Clone, Serialize)]
pub struct MediaUpdateBatch {
    pub images: Vec<ImageRecord>,
}

#[derive(Clone, Serialize)]
pub struct ColorBackfillProgress {
    pub processed: i64,
    pub total: i64,
    pub done: bool,
}

#[derive(Clone, Serialize)]
pub struct MediaJobProgressEvent {
    pub progress: Vec<FolderJobProgress>,
}

pub fn index_folder(app: AppHandle, pool: DbPool, folder_id: i64, folder_path: PathBuf) {
    std::thread::spawn(move || {
        set_folder_indexing_state(folder_id, true);

        // If the folder path no longer exists on disk, record the error and
        // emit done. Images are intentionally kept in the DB so the user can
        // choose to relocate the folder or remove it explicitly — they should
        // not be silently destroyed.
        if !folder_path.is_dir() {
            let error_msg = format!("Folder not found: {}", folder_path.display());
            log::error!("Indexing error for folder {folder_id}: {error_msg}");
            if let Ok(conn) = pool.get() {
                let _ = db::set_folder_scan_error(&conn, folder_id, &error_msg);
            }
            emit_progress(
                &app,
                &IndexProgress {
                    folder_id,
                    total: 0,
                    indexed: 0,
                    current_file: String::new(),
                    done: true,
                },
            );
            set_folder_indexing_state(folder_id, false);
            return;
        }

        let storage_profile = detect_storage_profile(&folder_path);
        set_folder_storage_profile(folder_id, RuntimeAdaptiveProfile::new(storage_profile));
        if let Err(error) = do_index(app.clone(), &pool, folder_id, folder_path) {
            log::error!("Indexing error for folder {folder_id}: {error}");
            if let Ok(conn) = pool.get() {
                let _ = db::set_folder_scan_error(&conn, folder_id, &error.to_string());
            }
            // Always emit done so the frontend reloads and recovers from partial state.
            emit_progress(
                &app,
                &IndexProgress {
                    folder_id,
                    total: 0,
                    indexed: 0,
                    current_file: String::new(),
                    done: true,
                },
            );
        }
        set_folder_indexing_state(folder_id, false);
    });
}

pub fn start_thumbnail_worker(
    app: AppHandle,
    pool: DbPool,
    media_tools: MediaTools,
    cache_dir: PathBuf,
) {
    std::thread::spawn(move || loop {
        // Only back off when the queue is empty (or errored); while jobs are
        // pending, claim the next batch immediately.
        match process_thumbnail_batch(&app, &pool, &media_tools, &cache_dir) {
            Ok(true) => {}
            Ok(false) => std::thread::sleep(std::time::Duration::from_millis(250)),
            Err(error) => {
                log::error!("Thumbnail worker error: {error}");
                std::thread::sleep(std::time::Duration::from_millis(250));
            }
        }
    });
}

pub fn start_metadata_worker(app: AppHandle, pool: DbPool, media_tools: MediaTools) {
    std::thread::spawn(move || loop {
        // Only back off when the queue is empty (or errored); while jobs are
        // pending, claim the next batch immediately.
        match process_metadata_batch(&app, &pool, &media_tools) {
            Ok(true) => {}
            Ok(false) => std::thread::sleep(std::time::Duration::from_millis(250)),
            Err(error) => {
                log::error!("Metadata worker error: {error}");
                std::thread::sleep(std::time::Duration::from_millis(250));
            }
        }
    });
}

pub fn start_embedding_worker(app: AppHandle, pool: DbPool) {
    std::thread::spawn(move || {
        let mut embedder: Option<ClipImageEmbedder> = None;
        log::info!("Embedding worker started.");
        loop {
            // Only back off when the queue is empty (or errored); while jobs
            // are pending, claim the next batch immediately.
            match process_embedding_batch(&app, &pool, &mut embedder) {
                Ok(true) => {}
                Ok(false) => std::thread::sleep(std::time::Duration::from_millis(500)),
                Err(error) => {
                    log::error!("Embedding worker error: {error}");
                    std::thread::sleep(std::time::Duration::from_millis(500));
                }
            }
        }
    });
}

#[allow(dead_code)] // caption worker disabled (lib.rs)
pub fn start_caption_worker(app: AppHandle, pool: DbPool, app_data_dir: PathBuf) {
    std::thread::spawn(move || {
        let mut captioner: Option<FlorenceCaptioner> = None;
        log::info!("Caption worker started.");
        loop {
            // If the acceleration setting changed, drop the cached session so
            // the next batch picks it up with the new execution provider.
            if captioner::CAPTION_SESSION_DIRTY.swap(false, std::sync::atomic::Ordering::Relaxed) {
                log::info!("Caption worker: acceleration setting changed — resetting session.");
                captioner = None;
            }
            if let Err(error) = process_caption_batch(&app, &pool, &app_data_dir, &mut captioner) {
                log::error!("Caption worker error: {error}");
                captioner = None;
            }
            std::thread::sleep(std::time::Duration::from_millis(750));
        }
    });
}

pub fn start_tagging_worker(app: AppHandle, pool: DbPool, app_data_dir: PathBuf) {
    std::thread::spawn(move || {
        let mut tagger_instance: Option<WdTagger> = None;
        log::info!("Tagging worker started.");
        loop {
            // If the acceleration setting changed, drop the cached session so
            // the next batch picks it up with the new execution provider.
            if tagger::TAGGER_SESSION_DIRTY.swap(false, std::sync::atomic::Ordering::Relaxed) {
                log::info!("Tagging worker: acceleration setting changed — resetting session.");
                tagger_instance = None;
            }
            // Only back off when the queue is empty (or errored); while jobs
            // are pending, claim the next batch immediately.
            match process_tagging_batch(&app, &pool, &app_data_dir, &mut tagger_instance) {
                Ok(true) => {}
                Ok(false) => std::thread::sleep(std::time::Duration::from_millis(750)),
                Err(error) => {
                    log::error!("Tagging worker error: {error}");
                    tagger_instance = None;
                    std::thread::sleep(std::time::Duration::from_millis(750));
                }
            }
        }
    });
}

/// True when `path` lives inside Phokus's own app-data directory (thumbnail
/// cache, database, downloaded models). If a user indexes an ancestor of this
/// directory — e.g. their whole `Users` folder — the app would otherwise index
/// its own thumbnails, generate thumbnails of those thumbnails, and loop
/// forever. The whole subtree is pruned from folder scans and ignored by the
/// watcher to break that cycle.
///
/// Comparison is component-aware (so a sibling like `…/phokus-backup` never
/// matches) and case-insensitive on Windows — the indexed root may report a
/// different casing than `app_data_dir` (e.g. `c:\users\…` vs `C:\Users\…`),
/// and a case-sensitive prefix check there would silently let the loop back in.
fn is_within_app_data(path: &Path, app_data_dir: Option<&Path>) -> bool {
    let Some(dir) = app_data_dir else {
        return false;
    };
    let mut base = dir.components();
    let mut target = path.components();
    loop {
        let Some(base_component) = base.next() else {
            // Consumed every component of the app-data dir while still matching →
            // `path` is the app-data dir itself or a descendant.
            return true;
        };
        let Some(target_component) = target.next() else {
            // `path` is shorter than the app-data dir, so it cannot be inside it.
            return false;
        };
        let matches = if cfg!(windows) {
            base_component
                .as_os_str()
                .eq_ignore_ascii_case(target_component.as_os_str())
        } else {
            base_component == target_component
        };
        if !matches {
            return false;
        }
    }
}

fn do_index(app: AppHandle, pool: &DbPool, folder_id: i64, folder_path: PathBuf) -> Result<()> {
    // Resolve our own app-data directory so the walk can skip it (see
    // `is_within_app_data`). Resolution failure is non-fatal — we just lose the
    // guard, which only matters when indexing an ancestor of the app data dir.
    let app_data_dir = app.path().app_data_dir().ok();
    let existing_entries = {
        let conn = pool.get()?;
        db::get_folder_media_index(&conn, folder_id)?
    };
    let existing_by_path = existing_entries
        .into_iter()
        .map(|entry| (entry.path.clone(), entry))
        .collect::<HashMap<_, _>>();

    // Collect traversal errors separately. An unreadable subdirectory means we
    // cannot distinguish absent files from inaccessible ones; tracking errors
    // lets us skip the deletion step for paths under affected subtrees.
    let mut unreadable_prefixes: Vec<String> = Vec::new();
    let media_paths: Vec<PathBuf> = WalkDir::new(&folder_path)
        .follow_links(true)
        .into_iter()
        // Prune our own app-data subtree before descending into it, so we never
        // scan (and re-thumbnail) the thumbnail cache.
        .filter_entry(|entry| !is_within_app_data(entry.path(), app_data_dir.as_deref()))
        .filter_map(|entry| match entry {
            Ok(e) if e.file_type().is_file() && is_supported_media(e.path()) => {
                Some(e.path().to_path_buf())
            }
            Ok(_) => None,
            Err(err) => {
                // Record the inaccessible path so we can protect its descendants
                // from the missing-file deletion pass.
                if let Some(path) = err.path() {
                    unreadable_prefixes.push(path.to_string_lossy().into_owned());
                }
                log::error!(
                    "WalkDir error while scanning folder {}: {}",
                    folder_path.display(),
                    err
                );
                None
            }
        })
        .collect();

    let total = media_paths.len();
    emit_progress(
        &app,
        &IndexProgress {
            folder_id,
            total,
            indexed: 0,
            current_file: String::new(),
            done: false,
        },
    );

    let mut seen_paths = HashSet::with_capacity(total);
    let mut processed = 0usize;

    let mut offset = 0usize;
    while offset < media_paths.len() {
        let storage_profile = folder_storage_profile(folder_id);
        let end = (offset + storage_profile.index_batch_size()).min(media_paths.len());
        let path_chunk = &media_paths[offset..end];
        let batch_start = Instant::now();
        let records: Vec<ImageRecord> = path_chunk
            .par_iter()
            .filter_map(|path| {
                let path_str = path.to_string_lossy().to_string();
                let existing = existing_by_path.get(&path_str);
                build_record(path, folder_id, existing)
            })
            .collect();

        for path in path_chunk {
            seen_paths.insert(path.to_string_lossy().to_string());
        }

        if !records.is_empty() {
            let committed = commit_batch(pool, &records)?;
            emit_images(
                &app,
                &IndexedImagesBatch {
                    folder_id,
                    images: committed,
                },
            );
            emit_folder_job_progress(&app, pool, &[folder_id], false);
        }

        processed += path_chunk.len();
        observe_folder_scan_batch(folder_id, path_chunk.len(), batch_start.elapsed());
        let current_file = path_chunk
            .last()
            .and_then(|path| path.file_name())
            .map(|name| name.to_string_lossy().to_string())
            .unwrap_or_default();

        emit_progress(
            &app,
            &IndexProgress {
                folder_id,
                total,
                indexed: processed,
                current_file,
                done: false,
            },
        );
        offset = end;
    }

    let missing_ids = existing_by_path
        .values()
        .filter(|entry| !seen_paths.contains(&entry.path))
        .filter(|entry| {
            // If this path lives under a subtree that WalkDir couldn't read,
            // we don't know whether the file is gone or just temporarily
            // inaccessible — keep the record to avoid false deletions.
            // Use Path::starts_with (component-aware) so a sibling directory
            // whose name shares a prefix is not incorrectly protected.
            let entry_path = std::path::Path::new(&entry.path);
            unreadable_prefixes
                .iter()
                .all(|prefix| !entry_path.starts_with(prefix))
        })
        .map(|entry| entry.id)
        .collect::<Vec<_>>();

    {
        let conn = pool.get()?;
        if !missing_ids.is_empty() {
            db::delete_images_by_ids(&conn, &missing_ids)?;
        }
        let _ = db::backfill_embedding_jobs(&conn)?;
        db::update_folder_count(&conn, folder_id)?;
        let _ = db::clear_folder_scan_error(&conn, folder_id);
        // Invalidate duplicate scan cache — any reindex can change file contents
        // or the set of files, making cached duplicate groups stale.
        let folder_scope = format!("folder:{folder_id}");
        let _ = db::clear_duplicate_scan_cache(&conn, &folder_scope);
        let _ = db::clear_duplicate_scan_cache(&conn, "all");
    }

    emit_progress(
        &app,
        &IndexProgress {
            folder_id,
            total,
            indexed: processed,
            current_file: String::new(),
            done: true,
        },
    );
    emit_folder_job_progress(&app, pool, &[folder_id], true);
    Ok(())
}

/// Extract the capture date from EXIF metadata, returned as an ISO 8601 string
/// (`"YYYY-MM-DDTHH:MM:SS"`). Tries `DateTimeOriginal` first, then
/// `DateTimeDigitized`, then `DateTime`. Returns `None` for video files or
/// images with no readable EXIF date.
fn extract_exif_date(path: &Path) -> Option<String> {
    use exif::{In, Tag, Value};
    use std::io::BufReader;

    let file = std::fs::File::open(path).ok()?;
    let mut reader = BufReader::new(file);
    let exif = exif::Reader::new().read_from_container(&mut reader).ok()?;

    for tag in [Tag::DateTimeOriginal, Tag::DateTimeDigitized, Tag::DateTime] {
        if let Some(field) = exif.get_field(tag, In::PRIMARY) {
            if let Value::Ascii(ref parts) = field.value {
                if let Some(bytes) = parts.first() {
                    // EXIF datetime format: "YYYY:MM:DD HH:MM:SS" (19 bytes)
                    if bytes.len() >= 19 {
                        let s = String::from_utf8_lossy(bytes);
                        // Reject all-zero sentinel dates written by some cameras
                        // for uninitialised EXIF fields ("0000:00:00 00:00:00").
                        let year: u32 = s[0..4].parse().unwrap_or(0);
                        if year == 0 {
                            continue;
                        }
                        let iso = format!(
                            "{}-{}-{}T{}:{}:{}",
                            &s[0..4],
                            &s[5..7],
                            &s[8..10],
                            &s[11..13],
                            &s[14..16],
                            &s[17..19]
                        );
                        return Some(iso);
                    }
                }
            }
        }
    }
    None
}

fn build_record(
    path: &Path,
    folder_id: i64,
    existing: Option<&IndexedMediaEntry>,
) -> Option<ImageRecord> {
    let path_str = path.to_string_lossy().to_string();
    let filename = path.file_name()?.to_string_lossy().to_string();
    let metadata = std::fs::metadata(path).ok()?;
    let file_size = metadata.len() as i64;
    if file_size == 0 {
        return None;
    }
    let modified_at = metadata.modified().ok().map(|time| {
        let date_time: chrono::DateTime<chrono::Utc> = time.into();
        date_time.to_rfc3339()
    });

    let ext = path
        .extension()
        .and_then(|value| value.to_str())
        .unwrap_or("jpg");
    let media_kind = media_kind_for_ext(ext).to_string();

    if let Some(existing) = existing {
        if existing.file_size == file_size
            && existing.modified_at == modified_at
            && existing.media_kind == media_kind
        {
            return None;
        }
    }

    Some(ImageRecord {
        id: existing.map(|entry| entry.id).unwrap_or(0),
        folder_id,
        path: path_str,
        filename,
        thumbnail_path: None,
        width: None,
        height: None,
        file_size,
        created_at: None,
        modified_at,
        taken_at: extract_exif_date(path),
        mime_type: mime_for_ext(ext).to_string(),
        media_kind: media_kind.clone(),
        duration_ms: None,
        video_codec: None,
        audio_codec: None,
        metadata_updated_at: None,
        metadata_error: None,
        favorite: false,
        rating: 0,
        embedding_status: "pending".to_string(),
        embedding_model: Some(vector::CLIP_MODEL_NAME.to_string()),
        embedding_updated_at: None,
        embedding_error: None,
        generated_caption: None,
        caption_model: None,
        caption_updated_at: None,
        caption_error: None,
        ai_rating: None,
        ai_tagger_model: None,
        ai_tagged_at: None,
        ai_tagger_error: None,
    })
}

fn commit_batch(pool: &DbPool, records: &[ImageRecord]) -> Result<Vec<ImageRecord>> {
    let mut conn = pool.get()?;
    let tx = conn.transaction()?;
    let mut committed = Vec::with_capacity(records.len());

    for record in records {
        let mut committed_record = record.clone();
        committed_record.id = db::upsert_image(&tx, record)?;
        db::enqueue_embedding_job(&tx, committed_record.id)?;
        db::enqueue_thumbnail_job(&tx, committed_record.id)?;
        if committed_record.media_kind == "video" {
            db::enqueue_metadata_job(&tx, committed_record.id)?;
        }
        committed.push(committed_record);
    }

    tx.commit()?;
    Ok(committed)
}

/// Returns `Ok(true)` if a batch was claimed and processed, `Ok(false)` if
/// the queue was empty.
fn process_thumbnail_batch(
    app: &AppHandle,
    pool: &DbPool,
    media_tools: &MediaTools,
    cache_dir: &Path,
) -> Result<bool> {
    let jobs = {
        with_db_write_lock(|| {
            let mut conn = pool.get()?;
            let active_folders = active_indexing_folders();
            let paused_folders = paused_folder_ids("thumbnail");
            let worker_batch_size = max_worker_batch_size(&active_folders);
            let worker_fetch_size = max_worker_fetch_size(&active_folders);
            db::claim_thumbnail_jobs(
                &mut conn,
                &active_folders,
                &paused_folders,
                crate::media::ffmpeg_ready(),
                worker_fetch_size,
                worker_batch_size,
            )
        })?
    };

    if jobs.is_empty() {
        return Ok(false);
    }

    log::info!("Thumbnail batch claimed: {} items", jobs.len());

    let (image_jobs, video_jobs): (Vec<_>, Vec<_>) =
        jobs.into_iter().partition(|job| job.media_kind == "image");
    let (avif_jobs, raster_jobs): (Vec<_>, Vec<_>) = image_jobs
        .into_iter()
        .partition(|job| is_avif_path(Path::new(&job.path)));

    // Images: parallel decode, committed as one batch.
    if !raster_jobs.is_empty() {
        let results = raster_jobs
            .par_iter()
            .map(|job| {
                (
                    job.image_id,
                    thumbnail::generate_image_thumbnail(Path::new(&job.path), cache_dir).map(Some),
                )
            })
            .collect::<Vec<_>>();
        persist_thumbnail_results(app, pool, results)?;
    }

    // AVIF: FFmpeg-backed decode, like videos. Keep this off the shared rayon
    // pool because each subprocess blocks its worker thread.
    for job in &avif_jobs {
        let result =
            thumbnail::generate_avif_thumbnail(media_tools, Path::new(&job.path), cache_dir)
                .map(Some);
        persist_thumbnail_results(app, pool, vec![(job.image_id, result)])?;
    }

    // Videos: sequential, off the rayon pool — each ffmpeg call blocks its
    // thread, and a video-heavy batch on the shared pool would starve image
    // decoding across all workers. Committed per item so progress keeps
    // moving through slow stretches.
    for job in &video_jobs {
        let result =
            thumbnail::generate_video_thumbnail(media_tools, Path::new(&job.path), cache_dir)
                .map(Some);
        persist_thumbnail_results(app, pool, vec![(job.image_id, result)])?;
    }

    Ok(true)
}

fn persist_thumbnail_results(
    app: &AppHandle,
    pool: &DbPool,
    results: Vec<(i64, anyhow::Result<Option<thumbnail::GeneratedThumbnail>>)>,
) -> Result<()> {
    let updated_images = {
        with_db_write_lock(|| {
            let mut conn = pool.get()?;
            let tx = conn.transaction()?;
            let mut updated_images = Vec::new();

            for (image_id, thumbnail_result) in results {
                let generated = match thumbnail_result {
                    Ok(path) => path,
                    Err(error) => {
                        db::mark_thumbnail_failed(&tx, image_id, &error.to_string())?;
                        continue;
                    }
                };

                let thumbnail_path = generated
                    .as_ref()
                    .map(|thumb| thumb.path.to_string_lossy().to_string());
                let width = generated.as_ref().and_then(|thumb| thumb.width);
                let height = generated.as_ref().and_then(|thumb| thumb.height);

                updated_images.push(db::mark_thumbnail_ready(
                    &tx,
                    image_id,
                    thumbnail_path.as_deref(),
                    width,
                    height,
                )?);

                // Store the dominant-color palette sampled during resizing.
                if let Some(thumb) = &generated {
                    if !thumb.palette.is_empty() {
                        db::replace_image_colors(&tx, image_id, &thumb.palette)?;
                    }
                }
            }

            tx.commit()?;
            Ok(updated_images)
        })?
    };

    if !updated_images.is_empty() {
        let folder_ids = updated_images
            .iter()
            .map(|image| image.folder_id)
            .collect::<HashSet<_>>();
        emit_media_updates(
            app,
            &MediaUpdateBatch {
                images: updated_images,
            },
        );
        emit_folder_job_progress(app, pool, &folder_ids.into_iter().collect::<Vec<_>>(), true);
    }

    Ok(())
}

fn is_avif_path(path: &Path) -> bool {
    path.extension()
        .and_then(|ext| ext.to_str())
        .is_some_and(|ext| ext.eq_ignore_ascii_case("avif"))
}

/// One-shot background pass that samples a color palette from the (already
/// generated) thumbnails of images indexed before color search existed. New
/// images get their palette during thumbnail generation, so this only fills the
/// historical gap. Emits `color-backfill-progress` so the UI can show a count.
pub fn start_color_backfill(app: AppHandle, pool: DbPool) {
    std::thread::spawn(move || {
        let total = match pool.get() {
            Ok(conn) => db::count_images_missing_colors(&conn).unwrap_or(0),
            Err(_) => return,
        };
        if total == 0 {
            return;
        }
        log::info!("Color backfill: sampling palettes for {total} images.");

        let mut processed: i64 = 0;
        loop {
            let batch = match pool.get() {
                Ok(conn) => db::get_images_missing_colors(&conn, 64).unwrap_or_default(),
                Err(_) => break,
            };
            if batch.is_empty() {
                break;
            }

            for (image_id, thumbnail_path) in batch {
                let palette = crate::color::extract_palette_from_file(
                    Path::new(&thumbnail_path),
                    crate::color::PALETTE_SIZE,
                );
                let colors: Vec<(u8, u8, u8, f32)> = match palette {
                    Some(colors) if !colors.is_empty() => colors
                        .into_iter()
                        .map(|c| (c.r, c.g, c.b, c.weight))
                        .collect(),
                    // Unreadable thumbnail: store a zero-weight sentinel so the
                    // image isn't reprocessed forever (it just won't match).
                    _ => vec![(0, 0, 0, 0.0)],
                };
                let _ = with_db_write_lock(|| {
                    let conn = pool.get()?;
                    db::replace_image_colors(&conn, image_id, &colors)
                });
                processed += 1;
            }

            let _ = app.emit(
                "color-backfill-progress",
                ColorBackfillProgress {
                    processed,
                    total,
                    done: false,
                },
            );
            // Yield so the backfill stays in the background under active use.
            std::thread::sleep(Duration::from_millis(15));
        }

        log::info!("Color backfill complete: {processed} images sampled.");
        let _ = app.emit(
            "color-backfill-progress",
            ColorBackfillProgress {
                processed,
                total,
                done: true,
            },
        );
    });
}

/// Returns `Ok(true)` if a batch was claimed and processed, `Ok(false)` if
/// the queue was empty.
fn process_metadata_batch(
    app: &AppHandle,
    pool: &DbPool,
    media_tools: &MediaTools,
) -> Result<bool> {
    // Metadata jobs are video-only and need ffprobe; leave them pending until
    // FFmpeg is provisioned — the worker poll loop drains them once ready.
    if !crate::media::ffmpeg_ready() {
        return Ok(false);
    }
    if higher_priority_work_pending(pool, WorkerTier::Metadata)? {
        return Ok(false);
    }

    let jobs = {
        with_db_write_lock(|| {
            let mut conn = pool.get()?;
            let active_folders = active_indexing_folders();
            let paused_folders = paused_folder_ids("metadata");
            let worker_batch_size = max_worker_batch_size(&active_folders);
            let worker_fetch_size = max_worker_fetch_size(&active_folders);
            db::claim_metadata_jobs(
                &mut conn,
                &active_folders,
                &paused_folders,
                worker_fetch_size,
                worker_batch_size,
            )
        })?
    };

    if jobs.is_empty() {
        return Ok(false);
    }

    // Probes run sequentially (each ffprobe blocks on its process); results
    // are committed per item so progress keeps moving through slow stretches.
    for job in jobs {
        let metadata_result = probe_video_metadata(media_tools, Path::new(&job.path));

        let updated_image = with_db_write_lock(|| {
            let mut conn = pool.get()?;
            let tx = conn.transaction()?;
            let updated = match metadata_result {
                Ok(metadata) => Some(db::mark_metadata_ready(
                    &tx,
                    job.image_id,
                    metadata.duration_ms,
                    metadata.width,
                    metadata.height,
                    metadata.video_codec.as_deref(),
                    metadata.audio_codec.as_deref(),
                )?),
                Err(error) => {
                    db::mark_metadata_failed(&tx, job.image_id, &error.to_string())?;
                    None
                }
            };
            tx.commit()?;
            Ok(updated)
        })?;

        if let Some(image) = updated_image {
            let folder_id = image.folder_id;
            emit_media_updates(
                app,
                &MediaUpdateBatch {
                    images: vec![image],
                },
            );
            emit_folder_job_progress(app, pool, &[folder_id], false);
        }
    }

    Ok(true)
}

/// Returns `Ok(true)` if a batch was claimed and processed, `Ok(false)` if
/// the queue was empty.
fn process_embedding_batch(
    app: &AppHandle,
    pool: &DbPool,
    embedder: &mut Option<ClipImageEmbedder>,
) -> Result<bool> {
    if higher_priority_work_pending(pool, WorkerTier::Embedding)? {
        return Ok(false);
    }

    let batch_started_at = Instant::now();
    let claim_started_at = Instant::now();
    // Exclude folders that are actively indexing (matching the thumbnail and
    // metadata workers): embedding mid-scan competes with the scanner for
    // CPU, disk, and the DB writer, and video jobs would fail-fast anyway
    // because their thumbnails are deferred until indexing completes.
    let mut excluded_folders = paused_folder_ids("embedding");
    excluded_folders.extend(active_indexing_folders());
    let jobs = with_db_write_lock(|| {
        let mut conn = pool.get()?;
        db::claim_embedding_jobs(&mut conn, &excluded_folders, EMBEDDING_BATCH_SIZE)
    })?;
    let claim_elapsed = claim_started_at.elapsed();

    if jobs.is_empty() {
        return Ok(false);
    }

    if embedder.is_none() {
        *embedder = Some(ClipImageEmbedder::new()?);
    }

    log::info!("Embedding batch claimed: {} items", jobs.len());
    let folder_ids = jobs.iter().map(|job| job.folder_id).collect::<HashSet<_>>();
    emit_folder_job_progress(
        app,
        pool,
        &folder_ids.iter().copied().collect::<Vec<_>>(),
        false,
    );
    let embedder = embedder.as_ref().expect("embedder should be initialized");

    let infer_started_at = Instant::now();
    // Resolve each source path. Video jobs without thumbnails are not claimable, so an
    // error here represents a real race or missing thumbnail rather than normal deferral.
    let source_results: Vec<Result<PathBuf>> = jobs
        .iter()
        .map(|job| embedding_source_path(&job.path, job.thumbnail_path.as_deref(), &job.media_kind))
        .collect();

    // Separate jobs with a valid source from genuine early failures.
    let mut embeddable_indices: Vec<usize> = Vec::new();
    let mut embeddable_paths: Vec<PathBuf> = Vec::new();
    // image_id -> early error message for jobs that cannot be embedded yet
    let mut pre_failed: HashMap<i64, String> = HashMap::new();

    for (i, (job, result)) in jobs.iter().zip(source_results).enumerate() {
        match result {
            Ok(path) => {
                embeddable_indices.push(i);
                embeddable_paths.push(path);
            }
            Err(e) => {
                pre_failed.insert(job.image_id, e.to_string());
            }
        }
    }

    // Run CLIP only on the jobs that have a valid source image.
    // image_id -> embedding result
    let mut embed_results: HashMap<i64, Result<Vec<f32>>> = HashMap::new();

    if !embeddable_indices.is_empty() {
        let embeddable_jobs: Vec<&EmbeddingJob> =
            embeddable_indices.iter().map(|&i| &jobs[i]).collect();

        match embedder.embed_images(&embeddable_paths) {
            Ok(embeddings) => {
                for (job, embedding) in embeddable_jobs.iter().zip(embeddings) {
                    embed_results.insert(job.image_id, Ok(embedding));
                }
            }
            Err(batch_error) => {
                log::error!("Embedding batch fallback to per-image mode: {batch_error}");
                for (job, source_path) in embeddable_jobs.into_iter().zip(embeddable_paths) {
                    embed_results.insert(job.image_id, embedder.embed_image(&source_path));
                }
            }
        }
    }
    let infer_elapsed = infer_started_at.elapsed();

    let write_started_at = Instant::now();
    let updated_images = with_db_write_lock(|| {
        let mut conn = pool.get()?;
        let tx = conn.transaction()?;
        let mut updated_images = Vec::new();

        for job in &jobs {
            let embedding_result: Result<Vec<f32>> =
                if let Some(err) = pre_failed.remove(&job.image_id) {
                    Err(anyhow::anyhow!("{err}"))
                } else if let Some(r) = embed_results.remove(&job.image_id) {
                    r
                } else {
                    Err(anyhow::anyhow!("no result for image {}", job.image_id))
                };

            match embedding_result {
                Ok(embedding) => {
                    vector::upsert_embedding(&tx, job.image_id, &embedding)?;
                    db::mark_embedding_ready(&tx, job.image_id, vector::CLIP_MODEL_NAME)?;
                }
                Err(error) => {
                    db::mark_embedding_failed(&tx, job.image_id, &error.to_string())?;
                }
            }

            updated_images.push(db::get_image_by_id(&tx, job.image_id)?);
        }

        tx.commit()?;
        Ok(updated_images)
    })?;

    if !updated_images.is_empty() {
        log::info!("Embedding batch completed: {} items", updated_images.len());
        let folder_ids = updated_images
            .iter()
            .map(|image| image.folder_id)
            .collect::<HashSet<_>>();
        emit_media_updates(
            app,
            &MediaUpdateBatch {
                images: updated_images,
            },
        );
        emit_folder_job_progress(app, pool, &folder_ids.into_iter().collect::<Vec<_>>(), true);
    }

    let write_elapsed = write_started_at.elapsed();
    let batch_elapsed = batch_started_at.elapsed();
    log::info!(
        "Embedding batch timing: claimed {EMBEDDING_BATCH_SIZE} in {claim_elapsed:?}, infer {infer_elapsed:?}, write {write_elapsed:?}, total {batch_elapsed:?}"
    );

    Ok(true)
}

#[allow(dead_code)] // caption worker disabled (lib.rs)
fn process_caption_batch(
    app: &AppHandle,
    pool: &DbPool,
    app_data_dir: &Path,
    captioner: &mut Option<FlorenceCaptioner>,
) -> Result<()> {
    if !captioner::caption_model_status(app_data_dir).ready {
        return Ok(());
    }

    let paused_folders = paused_folder_ids("caption");
    let jobs = with_db_write_lock(|| {
        let mut conn = pool.get()?;
        db::claim_caption_jobs(&mut conn, &paused_folders, CAPTION_BATCH_SIZE)
    })?;

    if jobs.is_empty() {
        return Ok(());
    }

    if captioner.is_none() {
        match FlorenceCaptioner::new(app_data_dir) {
            Ok(model) => *captioner = Some(model),
            Err(error) => {
                with_db_write_lock(|| {
                    let conn = pool.get()?;
                    db::requeue_caption_jobs(
                        &conn,
                        &jobs.iter().map(|job| job.image_id).collect::<Vec<_>>(),
                    )
                })?;
                return Err(error);
            }
        }
    }

    let folder_ids = jobs.iter().map(|job| job.folder_id).collect::<HashSet<_>>();
    emit_folder_job_progress(
        app,
        pool,
        &folder_ids.iter().copied().collect::<Vec<_>>(),
        false,
    );

    let captioner = captioner
        .as_mut()
        .expect("captioner should be initialized before caption batch processing");
    let caption_results = jobs
        .iter()
        .map(|job| (job.clone(), captioner.generate(Path::new(&job.path))))
        .collect::<Vec<_>>();

    let updated_images = with_db_write_lock(|| {
        let mut conn = pool.get()?;
        let tx = conn.transaction()?;
        let mut updated_images = Vec::with_capacity(caption_results.len());

        for (job, caption_result) in &caption_results {
            if db::is_caption_job_cancelled(&tx, job.image_id)? {
                tx.execute(
                    "DELETE FROM caption_jobs WHERE image_id = ?1",
                    [job.image_id],
                )?;
                continue;
            }
            match caption_result {
                Ok(caption) => {
                    updated_images.push(db::update_generated_caption(
                        &tx,
                        job.image_id,
                        caption,
                        captioner::FLORENCE_CAPTION_MODEL_NAME,
                    )?);
                }
                Err(error) => {
                    db::mark_caption_failed(&tx, job.image_id, &error.to_string())?;
                    updated_images.push(db::get_image_by_id(&tx, job.image_id)?);
                }
            }
        }

        tx.commit()?;
        Ok(updated_images)
    })?;

    if !updated_images.is_empty() {
        let folder_ids = updated_images
            .iter()
            .map(|image| image.folder_id)
            .collect::<HashSet<_>>();
        emit_media_updates(
            app,
            &MediaUpdateBatch {
                images: updated_images,
            },
        );
        emit_folder_job_progress(app, pool, &folder_ids.into_iter().collect::<Vec<_>>(), true);
    }

    Ok(())
}

/// Returns `Ok(true)` if a batch was claimed and processed, `Ok(false)` if
/// the queue was empty or the model is not ready.
fn process_tagging_batch(
    app: &AppHandle,
    pool: &DbPool,
    app_data_dir: &Path,
    tagger_instance: &mut Option<WdTagger>,
) -> Result<bool> {
    if !tagger::tagger_model_status(app_data_dir).ready {
        return Ok(false);
    }

    if higher_priority_work_pending(pool, WorkerTier::Tagging)? {
        return Ok(false);
    }

    // Exclude actively-indexing folders for the same reason as the other
    // workers: don't compete with a running scan.
    let batch_started_at = Instant::now();
    let mut excluded_folders = paused_folder_ids("tagging");
    excluded_folders.extend(active_indexing_folders());
    let batch_size = crate::tagger::tagger_batch_size(app_data_dir);
    let claim_started_at = Instant::now();
    let jobs = with_db_write_lock(|| {
        let mut conn = pool.get()?;
        db::claim_tagging_jobs(&mut conn, &excluded_folders, batch_size)
    })?;
    let claim_elapsed = claim_started_at.elapsed();

    if jobs.is_empty() {
        return Ok(false);
    }

    if tagger_instance.is_none() {
        match WdTagger::new(app_data_dir) {
            Ok(model) => *tagger_instance = Some(model),
            Err(error) => {
                with_db_write_lock(|| {
                    let conn = pool.get()?;
                    db::requeue_tagging_jobs(
                        &conn,
                        &jobs.iter().map(|job| job.image_id).collect::<Vec<_>>(),
                    )
                })?;
                return Err(error);
            }
        }
    }

    let folder_ids = jobs.iter().map(|job| job.folder_id).collect::<HashSet<_>>();
    emit_folder_job_progress(
        app,
        pool,
        &folder_ids.iter().copied().collect::<Vec<_>>(),
        false,
    );

    let tagger_ref = tagger_instance
        .as_mut()
        .expect("tagger should be initialized before tagging batch processing");

    // Resolve each job's source image (AVIF can't be decoded directly, so it
    // falls back to its thumbnail), then tag the batch in small chunks (below).
    let source_paths: Vec<PathBuf> = jobs
        .iter()
        .map(|job| {
            if is_avif_path(Path::new(&job.path)) {
                PathBuf::from(job.thumbnail_path.as_deref().unwrap_or(&job.path))
            } else {
                PathBuf::from(&job.path)
            }
        })
        .collect();

    // Tag in small micro-batches instead of one wide forward pass. On a shared
    // GPU every DirectML dispatch blocks the WebView2 compositor for its whole
    // duration, so a 16-wide batch freezes the UI for seconds; small chunks keep
    // each GPU lock short (and bound peak decode memory) while a brief yield
    // between them lets the UI and other workers grab the GPU/CPU. The model is
    // compute-bound here, so this costs almost no throughput.
    let infer_started_at = Instant::now();
    let mut outputs = Vec::with_capacity(source_paths.len());
    let mut chunks = source_paths.chunks(tagger::TAGGER_INFER_CHUNK).peekable();
    while let Some(chunk) = chunks.next() {
        outputs.extend(tagger_ref.run_batch(chunk, tagger::DEFAULT_MAX_TAGS));
        if chunks.peek().is_some() {
            std::thread::sleep(std::time::Duration::from_millis(
                tagger::TAGGER_INFER_YIELD_MS,
            ));
        }
    }
    let infer_elapsed = infer_started_at.elapsed();

    let tag_results = jobs.iter().cloned().zip(outputs).collect::<Vec<_>>();

    let write_started_at = Instant::now();
    let updated_images = with_db_write_lock(|| {
        let mut conn = pool.get()?;
        let tx = conn.transaction()?;
        let mut updated_images = Vec::with_capacity(tag_results.len());

        for (job, tag_result) in &tag_results {
            // If the job is no longer in 'processing' state it was either
            // cancelled or reset to 'pending' (pause) while inference was running.
            // Discard the result in both cases — for cancelled rows also delete
            // the row; for paused rows leave it so the job is retried later.
            if !db::is_tagging_job_processing(&tx, job.image_id)? {
                tx.execute(
                    "DELETE FROM tagging_jobs WHERE image_id = ?1 AND status = 'cancelled'",
                    [job.image_id],
                )?;
                continue;
            }
            match tag_result {
                Ok(output) => {
                    let tag_pairs: Vec<(String, f64)> = output
                        .tags
                        .iter()
                        .map(|t| (t.tag.clone(), t.confidence as f64))
                        .collect();
                    db::update_ai_tags(
                        &tx,
                        job.image_id,
                        &tag_pairs,
                        &output.rating,
                        tagger::WD_TAGGER_MODEL_NAME,
                    )?;
                }
                Err(error) => {
                    db::mark_tagging_failed(&tx, job.image_id, &error.to_string())?;
                }
            }
            updated_images.push(db::get_image_by_id(&tx, job.image_id)?);
        }

        tx.commit()?;
        Ok(updated_images)
    })
    .inspect_err(|_db_err| {
        // The DB write failed.  Try to requeue the claimed jobs so they aren't
        // left stuck in 'processing' until the next app restart.
        let image_ids: Vec<i64> = jobs.iter().map(|job| job.image_id).collect();
        let _ = with_db_write_lock(|| {
            let conn = pool.get()?;
            db::requeue_tagging_jobs(&conn, &image_ids)
        });
    })?;
    let write_elapsed = write_started_at.elapsed();

    if !updated_images.is_empty() {
        let folder_ids = updated_images
            .iter()
            .map(|image| image.folder_id)
            .collect::<HashSet<_>>();
        emit_media_updates(
            app,
            &MediaUpdateBatch {
                images: updated_images,
            },
        );
        emit_folder_job_progress(app, pool, &folder_ids.into_iter().collect::<Vec<_>>(), true);
    }

    log::info!(
        "Tagging batch timing: {} items, claim {:?}, tag {:?}, write {:?}, total {:?}",
        jobs.len(),
        claim_elapsed,
        infer_elapsed,
        write_elapsed,
        batch_started_at.elapsed()
    );

    Ok(true)
}

fn active_indexing_folders() -> HashSet<i64> {
    ACTIVE_INDEXING_FOLDERS
        .get_or_init(|| Mutex::new(HashSet::new()))
        .lock()
        .map(|folders| folders.clone())
        .unwrap_or_default()
}

fn set_folder_indexing_state(folder_id: i64, is_active: bool) {
    if let Ok(mut folders) = ACTIVE_INDEXING_FOLDERS
        .get_or_init(|| Mutex::new(HashSet::new()))
        .lock()
    {
        if is_active {
            folders.insert(folder_id);
        } else {
            folders.remove(&folder_id);
        }
    }
}

fn set_folder_storage_profile(folder_id: i64, profile: RuntimeAdaptiveProfile) {
    if let Ok(mut profiles) = FOLDER_STORAGE_PROFILES
        .get_or_init(|| Mutex::new(HashMap::new()))
        .lock()
    {
        profiles.insert(folder_id, profile);
    }
}

fn folder_storage_profile(folder_id: i64) -> StorageProfile {
    FOLDER_STORAGE_PROFILES
        .get_or_init(|| Mutex::new(HashMap::new()))
        .lock()
        .ok()
        .and_then(|profiles| profiles.get(&folder_id).copied())
        .map(|profile| profile.profile())
        .unwrap_or(StorageProfile::Balanced)
}

fn observe_folder_scan_batch(folder_id: i64, item_count: usize, elapsed: Duration) {
    if let Ok(mut profiles) = FOLDER_STORAGE_PROFILES
        .get_or_init(|| Mutex::new(HashMap::new()))
        .lock()
    {
        if let Some(profile) = profiles.get_mut(&folder_id) {
            profile.observe_scan_batch(item_count, elapsed);
        }
    }
}

fn max_worker_batch_size(active_folders: &HashSet<i64>) -> usize {
    active_folders
        .iter()
        .map(|folder_id| folder_storage_profile(*folder_id).worker_batch_size())
        .min()
        .unwrap_or(StorageProfile::Balanced.worker_batch_size())
}

fn max_worker_fetch_size(active_folders: &HashSet<i64>) -> usize {
    active_folders
        .iter()
        .map(|folder_id| folder_storage_profile(*folder_id).worker_fetch_size())
        .min()
        .unwrap_or(StorageProfile::Balanced.worker_fetch_size())
}

pub fn with_db_write_lock<T>(operation: impl FnOnce() -> Result<T>) -> Result<T> {
    let lock = DB_WRITE_LOCK.get_or_init(|| Mutex::new(()));
    let _guard = lock.lock().unwrap();
    operation()
}

fn emit_progress(app: &AppHandle, progress: &IndexProgress) {
    let _ = app.emit("index-progress", progress);
}

fn emit_images(app: &AppHandle, batch: &IndexedImagesBatch) {
    let _ = app.emit("indexed-images", batch);
}

fn emit_media_updates(app: &AppHandle, batch: &MediaUpdateBatch) {
    let _ = app.emit("media-updated", batch);
}

pub fn emit_folder_job_progress(app: &AppHandle, pool: &DbPool, folder_ids: &[i64], force: bool) {
    let mut unique_folder_ids = folder_ids.to_vec();
    unique_folder_ids.sort_unstable();
    unique_folder_ids.dedup();

    let now = Instant::now();
    let emit_tracker = LAST_JOB_PROGRESS_EMIT.get_or_init(|| Mutex::new(HashMap::new()));
    let mut tracker = match emit_tracker.lock() {
        Ok(guard) => guard,
        Err(_) => return,
    };
    unique_folder_ids.retain(|folder_id| {
        let should_emit = force
            || tracker
                .get(folder_id)
                .map(|last_emit| now.duration_since(*last_emit) >= JOB_PROGRESS_EMIT_INTERVAL)
                .unwrap_or(true);
        if should_emit {
            tracker.insert(*folder_id, now);
        }
        should_emit
    });
    drop(tracker);

    if unique_folder_ids.is_empty() {
        return;
    }

    let Ok(conn) = pool.get() else {
        return;
    };

    let progress = unique_folder_ids
        .into_iter()
        .filter_map(|folder_id| db::get_folder_job_progress(&conn, folder_id).ok())
        .collect::<Vec<_>>();

    if !progress.is_empty() {
        let _ = app.emit("media-job-progress", MediaJobProgressEvent { progress });
    }
}

fn is_supported_media(path: &Path) -> bool {
    path.extension()
        .and_then(|value| value.to_str())
        .map(|value| {
            let extension = value.to_lowercase();
            IMAGE_EXTENSIONS.contains(&extension.as_str())
                || VIDEO_EXTENSIONS.contains(&extension.as_str())
        })
        .unwrap_or(false)
}

fn media_kind_for_ext(ext: &str) -> &'static str {
    match ext.to_lowercase().as_str() {
        "mp4" | "mov" | "m4v" | "webm" => "video",
        _ => "image",
    }
}

fn mime_for_ext(ext: &str) -> &'static str {
    match ext.to_lowercase().as_str() {
        "jpg" | "jpeg" => "image/jpeg",
        "png" => "image/png",
        "gif" => "image/gif",
        "bmp" => "image/bmp",
        "webp" => "image/webp",
        "tiff" | "tif" => "image/tiff",
        "avif" => "image/avif",
        "mp4" | "m4v" => "video/mp4",
        "mov" => "video/quicktime",
        "webm" => "video/webm",
        _ => "image/jpeg",
    }
}

// ── Filesystem watcher ────────────────────────────────────────────────────────

/// How long to wait after the last event for a path before processing it.
/// Absorbs bursts of OS events that accompany a single logical file write.
const WATCHER_DEBOUNCE: Duration = Duration::from_millis(500);

struct WatcherInner {
    watcher: Mutex<RecommendedWatcher>,
    /// Maps each watched folder root → its folder_id in the DB.
    folder_map: Arc<Mutex<HashMap<PathBuf, i64>>>,
}

/// Shared handle that lets command handlers register and deregister watched
/// directories without touching the debounce thread directly.
#[derive(Clone)]
pub struct WatcherHandle {
    inner: Arc<WatcherInner>,
}

impl WatcherHandle {
    pub fn add_folder(&self, path: PathBuf, folder_id: i64) {
        {
            let mut w = self.inner.watcher.lock().unwrap();
            if path.is_dir() {
                if let Err(e) = w.watch(&path, RecursiveMode::Recursive) {
                    log::error!("Watcher: failed to watch {path:?}: {e}");
                }
            }
        }
        self.inner
            .folder_map
            .lock()
            .unwrap()
            .insert(path, folder_id);
    }

    pub fn remove_folder(&self, path: &Path) {
        {
            let mut w = self.inner.watcher.lock().unwrap();
            let _ = w.unwatch(path);
        }
        self.inner.folder_map.lock().unwrap().remove(path);
    }

    pub fn update_folder(&self, old_path: &Path, new_path: PathBuf, folder_id: i64) {
        {
            let mut w = self.inner.watcher.lock().unwrap();
            let _ = w.unwatch(old_path);
            if new_path.is_dir() {
                if let Err(e) = w.watch(&new_path, RecursiveMode::Recursive) {
                    log::error!("Watcher: failed to watch {new_path:?}: {e}");
                }
            }
        }
        let mut map = self.inner.folder_map.lock().unwrap();
        map.remove(old_path);
        map.insert(new_path, folder_id);
    }
}

/// Start the filesystem watcher. Watches all folders currently in the DB and
/// returns a handle that command handlers can use to add/remove watched paths.
///
/// The debounce loop uses adaptive blocking:
/// - `recv()` when no events are pending — zero CPU
/// - `recv_timeout(earliest_deadline)` when events are pending — wakes exactly
///   when the soonest debounce window expires, no busy-polling
pub fn start_watcher(app: AppHandle, pool: DbPool, thumb_dir: PathBuf) -> WatcherHandle {
    let (tx, rx) = std::sync::mpsc::channel::<notify::Result<notify::Event>>();

    let raw_watcher = notify::recommended_watcher(move |result| {
        let _ = tx.send(result);
    })
    .expect("Failed to create filesystem watcher");

    // Seed the folder map from the DB so existing folders are watched on startup.
    let folder_map: Arc<Mutex<HashMap<PathBuf, i64>>> = Arc::new(Mutex::new(HashMap::new()));
    {
        let conn = pool
            .get()
            .expect("Watcher: failed to get DB connection for init");
        let folders = db::get_folders(&conn).unwrap_or_default();
        let mut map = folder_map.lock().unwrap();
        for f in folders {
            map.insert(PathBuf::from(f.path), f.id);
        }
    }

    // Register each known folder with the OS watcher.
    let raw_watcher = {
        let mut w = raw_watcher;
        let map = folder_map.lock().unwrap();
        for path in map.keys() {
            if path.is_dir() {
                if let Err(e) = w.watch(path, RecursiveMode::Recursive) {
                    log::error!("Watcher: failed to watch {path:?}: {e}");
                }
            }
        }
        w
    };

    let handle = WatcherHandle {
        inner: Arc::new(WatcherInner {
            watcher: Mutex::new(raw_watcher),
            folder_map: Arc::clone(&folder_map),
        }),
    };

    // Spawn the debounce loop on its own thread.
    let folder_map_thread = Arc::clone(&folder_map);
    // `thumb_dir` is `<app data>/thumbnails`; its parent is the app-data root.
    // Watched roots can be ancestors of it (e.g. a whole user profile), so we
    // drop any event inside that subtree to avoid re-indexing our own cache.
    let app_data_dir = thumb_dir.parent().map(Path::to_path_buf);
    std::thread::spawn(move || {
        // path → deadline: the earliest instant at which this path should be processed.
        let mut pending: HashMap<PathBuf, Instant> = HashMap::new();
        // old_path → new_path for rename events that carry both sides atomically.
        let mut pending_renames: Vec<(PathBuf, PathBuf)> = Vec::new();

        loop {
            // Adaptive blocking: block forever when idle, wake at the earliest
            // deadline when events are queued.
            let received = if pending.is_empty() && pending_renames.is_empty() {
                match rx.recv() {
                    Ok(e) => Some(e),
                    Err(_) => break, // sender dropped — app is shutting down
                }
            } else {
                let earliest = pending.values().copied().min().unwrap_or(Instant::now());
                let timeout = earliest.saturating_duration_since(Instant::now());
                match rx.recv_timeout(timeout) {
                    Ok(e) => Some(e),
                    Err(std::sync::mpsc::RecvTimeoutError::Timeout) => None,
                    Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
                }
            };

            let now = Instant::now();

            // Absorb incoming event — coalesces rapid writes into one deadline.
            if let Some(Ok(event)) = received {
                use notify::{
                    event::{ModifyKind, RenameMode},
                    EventKind,
                };
                if !matches!(event.kind, EventKind::Access(_)) {
                    // Intercept atomic rename events (old + new path in one event).
                    // Handle these separately to preserve embeddings and thumbnails.
                    if matches!(
                        event.kind,
                        EventKind::Modify(ModifyKind::Name(RenameMode::Both))
                    ) && event.paths.len() == 2
                    {
                        let old = event.paths[0].clone();
                        let new = event.paths[1].clone();
                        let old_in_app = is_within_app_data(&old, app_data_dir.as_deref());
                        let new_in_app = is_within_app_data(&new, app_data_dir.as_deref());
                        if old_in_app && new_in_app {
                            // Internal app-data churn (e.g. thumbnail cache) — ignore.
                        } else if old_in_app || new_in_app {
                            // Only one side is app-data, so the rename pairing is
                            // meaningless (we don't track app-data files). Handle the
                            // legitimate side as an independent create/delete via the
                            // normal debounce queue: a file moved out of app-data is
                            // indexed as a create; one moved in is processed as a
                            // delete (process_watcher_path sees it no longer exists).
                            let legit = if old_in_app { new } else { old };
                            if is_supported_media(&legit) {
                                pending.insert(legit, now + WATCHER_DEBOUNCE);
                            }
                        } else if is_supported_media(&old) || is_supported_media(&new) {
                            // Remove either side from regular pending so it isn't
                            // processed as an independent delete/create.
                            pending.remove(&old);
                            pending.remove(&new);
                            pending_renames.push((old, new));
                        }
                    } else {
                        for path in event.paths {
                            if is_supported_media(&path)
                                && !is_within_app_data(&path, app_data_dir.as_deref())
                            {
                                pending.insert(path, now + WATCHER_DEBOUNCE);
                            }
                        }
                    }
                }
            }

            // Process renames immediately — they are atomic filesystem operations
            // and do not benefit from debouncing.
            for (old_path, new_path) in pending_renames.drain(..) {
                process_watcher_rename(
                    &app,
                    &pool,
                    &folder_map_thread,
                    &thumb_dir,
                    &old_path,
                    &new_path,
                );
            }

            // Process all paths whose debounce window has expired.
            let ready: Vec<PathBuf> = pending
                .iter()
                .filter(|(_, &deadline)| deadline <= now)
                .map(|(p, _)| p.clone())
                .collect();

            for path in ready {
                pending.remove(&path);
                process_watcher_path(&app, &pool, &folder_map_thread, &path);
            }
        }
    });

    handle
}

/// Decide what to do with a path whose debounce window just expired.
/// If the file exists → upsert (with change-detection to avoid clobbering
/// metadata like thumbnail_path). If the file is gone → delete from DB.
fn process_watcher_path(
    app: &AppHandle,
    pool: &DbPool,
    folder_map: &Arc<Mutex<HashMap<PathBuf, i64>>>,
    path: &Path,
) {
    // Determine which registered folder owns this file.
    let folder_id = {
        let map = folder_map.lock().unwrap();
        map.iter()
            .find(|(folder_path, _)| path.starts_with(folder_path.as_path()))
            .map(|(_, &id)| id)
    };
    let Some(folder_id) = folder_id else { return };

    let conn = match pool.get() {
        Ok(c) => c,
        Err(e) => {
            log::error!("Watcher: DB pool error: {e}");
            return;
        }
    };

    if path.exists() {
        // File still on disk — upsert if content changed.
        let path_str = path.to_string_lossy();
        let existing = db::get_indexed_entry_by_path(&conn, &path_str).unwrap_or(None);
        let Some(record) = build_record(path, folder_id, existing.as_ref()) else {
            return; // file unchanged (same size + mtime) — nothing to do
        };
        drop(conn); // commit_batch acquires its own connection from the pool

        match commit_batch(pool, &[record]) {
            Ok(committed) if !committed.is_empty() => {
                // Always emit the images — they are committed to the DB.
                emit_images(
                    app,
                    &IndexedImagesBatch {
                        folder_id,
                        images: committed,
                    },
                );
                emit_folder_job_progress(app, pool, &[folder_id], false);
                // Update the sidebar count only if we successfully write the new
                // count; skip the frontend notification on pool/DB failure to
                // avoid showing a stale number.
                if let Ok(count_conn) = pool.get() {
                    if db::update_folder_count(&count_conn, folder_id).is_ok() {
                        let _ = app.emit("folder-counts-changed", ());
                    }
                }
            }
            Ok(_) => {}
            Err(e) => log::error!("Watcher: commit error for {path:?}: {e}"),
        }
    } else {
        // File removed from disk — clean up DB row and thumbnail.
        let path_str = path.to_string_lossy();
        match db::get_image_id_and_thumbnail_by_path(&conn, &path_str) {
            Ok(Some((image_id, thumb_path))) => {
                if db::delete_images_by_ids(&conn, &[image_id]).is_ok() {
                    if let Some(thumb) = thumb_path {
                        let _ = std::fs::remove_file(thumb);
                    }
                    db::update_folder_count(&conn, folder_id).ok();
                    let _ = app.emit("watcher-deleted", vec![image_id]);
                    let _ = app.emit("folder-counts-changed", ());
                }
            }
            Ok(None) => {} // never indexed or already removed
            Err(e) => log::error!("Watcher: lookup error for {path:?}: {e}"),
        }
    }
}

/// Handles a filesystem rename/move event where both the old and new paths are
/// known. Updates the DB row in-place (preserving the embedding) and renames
/// the thumbnail file to match the new path hash.
fn process_watcher_rename(
    app: &AppHandle,
    pool: &DbPool,
    folder_map: &Arc<Mutex<HashMap<PathBuf, i64>>>,
    thumb_dir: &Path,
    old_path: &Path,
    new_path: &Path,
) {
    // Resolve folder_id from the old path first, fall back to new path.
    let folder_id = {
        let map = folder_map.lock().unwrap();
        map.iter()
            .find(|(fp, _)| {
                old_path.starts_with(fp.as_path()) || new_path.starts_with(fp.as_path())
            })
            .map(|(_, &id)| id)
    };
    let Some(folder_id) = folder_id else { return };

    let conn = match pool.get() {
        Ok(c) => c,
        Err(e) => {
            log::error!("Watcher rename: DB pool error: {e}");
            return;
        }
    };

    let old_path_str = old_path.to_string_lossy();
    let new_path_str = new_path.to_string_lossy();

    let (image_id, old_thumb) = match db::get_image_id_and_thumbnail_by_path(&conn, &old_path_str) {
        Ok(Some(record)) => record,
        Ok(None) => {
            // Not yet indexed under the old path — treat as a brand-new file.
            drop(conn);
            process_watcher_path(app, pool, folder_map, new_path);
            return;
        }
        Err(e) => {
            log::error!("Watcher rename: DB lookup error: {e}");
            return;
        }
    };

    // Compute new thumbnail path and attempt to rename the file on disk.
    let new_thumb_path = crate::thumbnail::thumb_path(thumb_dir, &new_path_str);
    let new_thumb_str = new_thumb_path.to_string_lossy().into_owned();
    let thumb_ok = old_thumb
        .as_deref()
        .map(|old| std::fs::rename(old, &new_thumb_path).is_ok())
        .unwrap_or(false);
    // If rename failed (e.g. old thumb never existed), clear thumbnail_path so
    // the thumbnail worker regenerates it.
    let effective_thumb: Option<&str> = if thumb_ok { Some(&new_thumb_str) } else { None };

    let new_filename = new_path.file_name().and_then(|f| f.to_str()).unwrap_or("");

    if let Err(e) = db::update_image_path(
        &conn,
        image_id,
        &new_path_str,
        new_filename,
        effective_thumb,
    ) {
        log::error!("Watcher rename: DB update error: {e}");
        return;
    }

    // Emit the updated record so the frontend refreshes without a full reload.
    match db::get_image_by_id(&conn, image_id) {
        Ok(record) => emit_images(
            app,
            &IndexedImagesBatch {
                folder_id,
                images: vec![record],
            },
        ),
        Err(e) => log::error!("Watcher rename: post-update fetch error: {e}"),
    }
}
