use rand::seq::SliceRandom;
use std::collections::{HashMap, HashSet};
use std::fs::{self, File};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Mutex, OnceLock};
use std::thread;
use std::time::{Duration, SystemTime};
use chrono::{Datelike, Timelike, Local};

const TARGET_LEN: usize = 14;

// Cache de validación (path + mtime → playable). Persiste durante la vida
// del daemon para no re-decodificar el mismo archivo en cada ronda de refill.
static VALIDATION_CACHE: OnceLock<Mutex<HashMap<(PathBuf, SystemTime), bool>>> = OnceLock::new();
fn validation_cache() -> &'static Mutex<HashMap<(PathBuf, SystemTime), bool>> {
    VALIDATION_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}

/// Wrapper cacheado sobre `audio_probe::is_playable`. Decodifica el archivo
/// la primera vez; las siguientes consultas devuelven el resultado cacheado
/// (invalidado si cambió el mtime). Imprime warning a stderr si el archivo
/// resulta inválido.
fn is_playable_cached(path: &Path) -> bool {
    let mtime = match fs::metadata(path).and_then(|m| m.modified()) {
        Ok(t) => t,
        Err(_) => return false,
    };
    let key = (path.to_path_buf(), mtime);
    {
        let cache = validation_cache().lock().unwrap();
        if let Some(&v) = cache.get(&key) { return v; }
    }
    let ok = grpautaje::audio_probe::is_playable(path);
    if !ok {
        eprintln!("[refill] audio inválido, descartado: {}", path.display());
    }
    validation_cache().lock().unwrap().insert(key, ok);
    ok
}

fn main() {
    let mut last_mus_hash: Option<String> = None;
    // Índice persistente: indica la SIGUIENTE entrada de la parrilla a usar.
    // Se reinicia a 0 cuando cambia la hora (nuevo .mus).
    let mut parrilla_idx: usize = 0;

    loop {
        // Respetar pausa manual del operador (archivo creado por el botón Automático en la UI)
        let pause_file = dirs::home_dir().unwrap().join(".gradio/data/tmp/pause_refill");
        if pause_file.exists() {
            thread::sleep(Duration::from_secs(5));
            continue;
        }

        let current_mus  = parrilla_path();
        let current_hash = mus_fingerprint(&current_mus);

        match (&last_mus_hash, &current_hash) {
            (Some(prev), Some(curr)) if prev != curr => {
                eprintln!("[refill] Cambio de parrilla detectado, vaciando playlist4.");
                vaciar_playlist();
                parrilla_idx = 0; // reiniciar índice al cambiar de hora
                last_mus_hash = current_hash;
            }
            (None, _) => {
                last_mus_hash = current_hash;
            }
            _ => {}
        }

        parrilla_idx = refill(parrilla_idx);
        thread::sleep(Duration::from_secs(5));
    }
}

fn vaciar_playlist() {
    let path = playlist_path();
    if let Err(e) = fs::write(&path, "") {
        eprintln!("[refill] Error vaciando playlist4: {}", e);
    }
}

fn mus_fingerprint(path: &Path) -> Option<String> {
    let content = fs::read_to_string(path).ok()?;
    let len = content.len();
    let head: String = content.chars().take(200).collect();
    let tail: String = content.chars().rev().take(200).collect();
    Some(format!("{}|{}|{}", len, head, tail))
}

/// Rellena la playlist respetando estrictamente el orden de la parrilla.
/// Recibe el índice actual de parrilla y devuelve el nuevo índice tras el relleno.
fn refill(mut parrilla_idx: usize) -> usize {
    let playlist = playlist_path();
    let lines = current_lines(&playlist);
    if lines.len() >= TARGET_LEN { return parrilla_idx; }

    // Carpetas excluidas del control de repetición (leídas de gradio.config)
    let no_repeat_dirs = read_no_repeat_dirs();

    // Set de exclusión: log de 3 días + lo que ya está en la playlist actual
    // EXCEPTO para archivos en carpetas no_repeat_dirs
    let recent = played_recent_set(read_no_repeat_days());
    let mut excluded = HashSet::new();
    // Agregar lo que ya está en la playlist (siempre, para evitar duplicados inmediatos)
    for line in &lines {
        let path = line.splitn(2, '\t').next().unwrap_or("").trim();
        if !path.is_empty() {
            excluded.insert(path.to_string());
        }
    }

    let needed = TARGET_LEN - lines.len();
    let parrilla = load_parrilla();
    if parrilla.is_empty() { return parrilla_idx; }

    let parrilla_len = parrilla.len();
    // Asegurar que el índice no supere la longitud de la parrilla
    parrilla_idx = parrilla_idx % parrilla_len;

    let mut added = 0;
    // Protección contra bucle infinito: máximo una vuelta completa sin agregar nada
    let mut consecutive_empty = 0;

    while added < needed {
        let raw = parrilla[parrilla_idx].trim();

        // Marcador especial "Hora" — se inserta directamente sin filtro de exclusión
        if raw == "Hora" {
            append_line(&playlist, "Hora", 0.0);
            added += 1;
            consecutive_empty = 0;
            parrilla_idx = (parrilla_idx + 1) % parrilla_len;
            continue;
        }

        // Construir el excluded para esta entrada específica:
        // Si la carpeta está en no_repeat_dirs, no aplicar log de 3 días
        let entry_excluded = build_excluded_for_entry(raw, &recent, &excluded, &no_repeat_dirs);

        match pick_from_entry(raw, &entry_excluded) {
            Some(resolved) => {
                let dur = duration(&resolved);
                append_line(&playlist, &resolved, dur);
                excluded.insert(resolved); // evitar duplicado en este ciclo
                added += 1;
                consecutive_empty = 0;
                // Avanzar al siguiente índice de parrilla SOLO tras agregar exitosamente
                parrilla_idx = (parrilla_idx + 1) % parrilla_len;
            }
            None => {
                // Entrada agotada o no disponible: saltar y continuar
                eprintln!("[refill] entrada agotada: {}", raw);
                parrilla_idx = (parrilla_idx + 1) % parrilla_len;
                consecutive_empty += 1;
                // Si dimos una vuelta completa sin éxito, salir
                if consecutive_empty >= parrilla_len {
                    eprintln!("[refill] todas las entradas agotadas; esperando próxima ronda.");
                    break;
                }
            }
        }
    }

    parrilla_idx
}

/// Construye el set de exclusión para una entrada de parrilla concreta.
/// Si la entrada pertenece a una carpeta en no_repeat_dirs, solo excluye
/// lo que ya está en la playlist actual (no el log de 3 días).
fn build_excluded_for_entry(
    raw: &str,
    recent: &HashSet<String>,
    playlist_current: &HashSet<String>,
    no_repeat_dirs: &[String],
) -> HashSet<String> {
    let base = normalize_dir(raw);
    let in_no_repeat = no_repeat_dirs.iter().any(|excl| {
        let excl_norm = excl.trim_end_matches('/');
        base == excl_norm || base.starts_with(&format!("{}/", excl_norm))
    });

    if in_no_repeat {
        // Solo evitar duplicados inmediatos en la lista actual
        playlist_current.clone()
    } else {
        // Exclusión completa: log 3 días + playlist actual
        let mut full = recent.clone();
        full.extend(playlist_current.iter().cloned());
        full
    }
}

/// Normaliza una ruta de parrilla a su directorio base (sin /* ni /)
fn normalize_dir(raw: &str) -> String {
    raw.trim()
        .trim_end_matches('/')
        .trim_end_matches('*')
        .trim_end_matches('/')
        .to_string()
}

/// Lee las carpetas excluidas del control de repetición desde gradio.config
fn read_no_repeat_dirs() -> Vec<String> {
    let path = dirs::home_dir().unwrap().join(".gradio/data/tmp/gradio.config");
    let text = fs::read_to_string(&path).unwrap_or_default();
    let mut lines = text.lines();
    // Línea 1: tarjeta principal, 2: CUE, 3: nombre, 4: fundido,
    // 5: pisador_enabled, 6: pisador_dir, 7: pisador_every, 8: excluidas
    for _ in 0..7 { lines.next(); }
    lines.next()
        .map(|l| parse_quoted_semicolon_list(l.trim()))
        .unwrap_or_default()
}

/// Parsea lista de rutas entre comillas separadas por ;
fn parse_quoted_semicolon_list(s: &str) -> Vec<String> {
    let mut result = Vec::new();
    let mut rest = s;
    while !rest.is_empty() {
        rest = rest.trim_start();
        if rest.starts_with('"') {
            rest = &rest[1..];
            if let Some(end) = rest.find('"') {
                result.push(rest[..end].to_string());
                rest = &rest[end + 1..];
                rest = rest.trim_start_matches(';');
            } else {
                result.push(rest.to_string());
                break;
            }
        } else if let Some(end) = rest.find(';') {
            let item = rest[..end].trim().to_string();
            if !item.is_empty() { result.push(item); }
            rest = &rest[end + 1..];
        } else {
            let item = rest.trim().to_string();
            if !item.is_empty() { result.push(item); }
            break;
        }
    }
    result
}

// --- Rutas -------------------------------------------------------------------

fn playlist_path() -> PathBuf {
    dirs::home_dir().unwrap().join(".gradio/data/tmp/playlist4")
}

fn parrilla_path() -> PathBuf {
    let now  = Local::now();
    let dow  = now.weekday().number_from_monday();
    let hour = now.hour();
    let file = format!("{}-{}.mus", hour, hour + 1);
    dirs::home_dir()
        .unwrap()
        .join(".gradio/data/parrilla")
        .join(dow.to_string())
        .join(file)
}

fn log_parrilla_path_for(date: chrono::NaiveDate) -> PathBuf {
    dirs::home_dir()
        .unwrap()
        .join(".gradio/data/reporte")
        .join(format!("GR6-parrilla-{}.txt", date.format("%Y-%m-%d")))
}

fn read_no_repeat_days() -> u32 {
    let path = dirs::home_dir()
        .unwrap_or_else(|| std::path::PathBuf::from("/root"))
        .join(".gradio/data/tmp/gradio.config");
    std::fs::read_to_string(&path)
        .unwrap_or_default()
        .lines()
        .nth(15)  // línea 16, índice 0-based
        .and_then(|l| l.trim().parse::<u32>().ok())
        .filter(|&v| v >= 1)
        .unwrap_or(3)
}

fn played_recent_set(days: u32) -> HashSet<String> {
    let today = Local::now().date_naive();
    let mut set = HashSet::new();
    for d in 0..days {
        let date = today - chrono::Duration::days(d as i64);
        let path = log_parrilla_path_for(date);
        if !path.exists() { continue; }
        if let Ok(f) = File::open(&path) {
            for line in BufReader::new(f).lines().filter_map(|l| l.ok()) {
                if let Some(ruta) = line.splitn(4, ',').nth(2) {
                    let ruta = ruta.trim().to_string();
                    if !ruta.is_empty() { set.insert(ruta); }
                }
            }
        }
    }
    set
}

// --- Carga de parrilla -------------------------------------------------------

fn load_parrilla() -> Vec<String> {
    let path = parrilla_path();
    if !path.exists() {
        eprintln!("[refill] Parrilla no encontrada: {:?}", path);
        return vec![];
    }
    match File::open(&path) {
        Ok(f) => BufReader::new(f)
            .lines()
            .filter_map(|l| l.ok())
            .filter(|l| !l.trim().is_empty())
            .collect(),
        Err(e) => { eprintln!("[refill] No se puede abrir parrilla: {}", e); vec![] }
    }
}

// --- Selección de entradas ---------------------------------------------------

/// Intenta resolver una entrada de parrilla con exclusión dada.
/// Primero respeta la exclusión completa; si falla, relaja a solo la playlist actual.
fn pick_from_entry(raw: &str, excluded: &HashSet<String>) -> Option<String> {
    if let Some(r) = resolve_entry(raw, excluded) {
        return Some(r);
    }
    // Relajar: solo evitar duplicados inmediatos (excluded ya es el correcto según no_repeat)
    // En este punto no podemos relajar más sin conocer si es no_repeat o no,
    // así que simplemente intentamos con un set vacío como último recurso
    resolve_entry_any(raw, &HashSet::new())
}

fn resolve_entry(raw: &str, excluded: &HashSet<String>) -> Option<String> {
    let raw = raw.trim();
    // Ignorar cualquier entrada cuya ruta contenga una carpeta "No Tocar"
    if is_no_tocar(Path::new(raw)) { return None; }
    if !raw.ends_with("/*") && !Path::new(raw).is_dir() {
        if excluded.contains(raw) { return None; }
        let p = Path::new(raw);
        if !p.exists() {
            eprintln!("[refill] Archivo no encontrado: {}", raw);
            return None;
        }
        if !is_playable_cached(p) { return None; }
        return Some(raw.to_string());
    }
    let base_dir = base_dir_of(raw);
    if !base_dir.exists() {
        eprintln!("[refill] Directorio no encontrado: {:?}", base_dir);
        return None;
    }
    let subdirs = subdirs_of(&base_dir);
    if subdirs.is_empty() {
        pick_from_flat_dir(&base_dir, excluded)
    } else {
        pick_from_subdirs(&subdirs, excluded)
    }
}

fn resolve_entry_any(raw: &str, excluded: &HashSet<String>) -> Option<String> {
    resolve_entry(raw, excluded)
}

fn base_dir_of(raw: &str) -> PathBuf {
    PathBuf::from(if raw.ends_with("/*") {
        raw.trim_end_matches("/*")
    } else {
        raw
    })
}

// --- Selección en carpetas ---------------------------------------------------

fn pick_from_flat_dir(dir: &Path, excluded: &HashSet<String>) -> Option<String> {
    let mut files = audio_files_in_dir(dir);
    if files.is_empty() {
        eprintln!("[refill] Carpeta vacía: {:?}", dir);
        return None;
    }
    files.shuffle(&mut rand::thread_rng());
    for f in files {
        let s = f.to_str().unwrap_or("");
        if excluded.contains(s) { continue; }
        if !is_playable_cached(&f) { continue; }
        return Some(f.to_string_lossy().into_owned());
    }
    None
}

/// Elige un tema al azar de entre TODOS los disponibles en todas las subcarpetas
/// combinadas en un pool plano. Esto evita que carpetas con pocos temas sean
/// elegidas con la misma frecuencia que carpetas grandes (sesgo de subdir).
fn pick_from_subdirs(subdirs: &[PathBuf], excluded: &HashSet<String>) -> Option<String> {
    let mut pool: Vec<PathBuf> = subdirs.iter()
        .flat_map(|sd| audio_files_in_dir(sd))
        .filter(|f| !excluded.contains(f.to_str().unwrap_or("")))
        .collect();
    pool.shuffle(&mut rand::thread_rng());
    for f in pool {
        if !is_playable_cached(&f) { continue; }
        return Some(f.to_string_lossy().into_owned());
    }
    None
}

// --- Archivos de audio -------------------------------------------------------

fn audio_files_in_dir(dir: &Path) -> Vec<PathBuf> {
    match fs::read_dir(dir) {
        Ok(rd) => rd
            .filter_map(|e| e.ok())
            .map(|e| e.path())
            .filter(|p| p.is_file() && is_audio(p))
            .collect(),
        Err(_) => vec![],
    }
}

/// Devuelve true si algún componente del path se llama "No Tocar" (sin distinguir
/// mayúsculas/minúsculas ASCII), para filtrar carpetas reservadas.
fn is_no_tocar(path: &Path) -> bool {
    path.components().any(|c| {
        c.as_os_str().to_string_lossy().eq_ignore_ascii_case("no tocar")
    })
}

fn subdirs_of(dir: &Path) -> Vec<PathBuf> {
    match fs::read_dir(dir) {
        Ok(rd) => rd
            .filter_map(|e| e.ok())
            .map(|e| e.path())
            .filter(|p| p.is_dir() && !is_no_tocar(p))
            .collect(),
        Err(_) => vec![],
    }
}

fn is_audio(p: &Path) -> bool {
    p.extension()
        .and_then(|e| e.to_str())
        .map_or(false, |e| matches!(
            e.to_ascii_lowercase().as_str(),
            "mp3" | "m4a" | "wav" | "flac" | "ogg"
        ))
}

// --- Utilidades de archivo ---------------------------------------------------

fn current_lines(p: &Path) -> Vec<String> {
    if !p.exists() { return vec![]; }
    BufReader::new(match File::open(p) {
        Ok(f) => f,
        Err(_) => return vec![],
    })
    .lines()
    .filter_map(|l| l.ok())
    .filter(|l| !l.trim().is_empty())
    .collect()
}

fn duration(path: &str) -> f64 {
    let out = Command::new("ffprobe")
        .args(&[
            "-v", "error",
            "-show_entries", "format=duration",
            "-of", "default=noprint_wrappers=1:nokey=1",
            path,
        ])
        .output();
    match out {
        Ok(o) => String::from_utf8_lossy(&o.stdout)
            .trim()
            .parse::<f64>()
            .unwrap_or(0.0),
        Err(_) => 0.0,
    }
}

fn append_line(playlist: &Path, file: &str, secs: f64) {
    let file = sanitize_path(file);
    let mut fh = fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(playlist)
        .unwrap();
    writeln!(
        fh,
        "{}\t{:02}:{:02}.000",
        file,
        secs as u64 / 60,
        secs as u64 % 60
    )
    .unwrap();
}

fn sanitize_path(raw: &str) -> String {
    let raw = raw.trim().trim_end_matches('\r').trim_end_matches('/');
    for ext in &["mp3", "MP3", "wav", "WAV", "flac", "FLAC", "m4a", "M4A", "ogg", "OGG"] {
        let needle = format!(".{}/", ext);
        if let Some(p) = raw.find(&needle) {
            return raw[..p + needle.len() - 1].to_string();
        }
    }
    raw.to_string()
}
