// gr_playlist.rs — Editor de playlists G Radio (.gradio)
//
// Formato .gradio = mismo que playlist4:
//   /ruta/archivo.mp3\tMM:SS.000
//
// Funciones:
//   - Agregar archivos por drag & drop o FileChooser
//   - Reordenar con botones ↑ ↓
//   - Eliminar entradas
//   - Borrar lista completa
//   - Cargar lista existente
//   - Guardar lista como .gradio
//   - Muestra tiempo total acumulado

use gtk4 as gtk;
use gtk::prelude::*;
use gtk::{
    Application, ApplicationWindow, Box as GtkBox, Button, Label,
    ListBox, ListBoxRow, Orientation, ScrolledWindow,
    CssProvider, STYLE_PROVIDER_PRIORITY_APPLICATION,
    FileChooserAction,
};
use gdk4::FileList;

const ICONO_PLAYLIST: &[u8] = include_bytes!("../assets/playlist48x48.png");
use std::cell::RefCell;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::rc::Rc;

use grpautaje::i18n::tr;
use grpautaje::skin;

const APP_ID: &str = "com.gradio.playlist";

// ── Modelo de una entrada ────────────────────────────────────────────────────
#[derive(Clone, Debug)]
struct Entry {
    path:     PathBuf,
    title:    String,
    duration: String, // "MM:SS.000"
    secs:     f64,
}

impl Entry {
    fn from_path(path: PathBuf) -> Self {
        let title = path.file_stem()
            .and_then(|s| s.to_str())
            .unwrap_or("Audio")
            .to_string();
        let (duration, secs) = probe_duration(&path);
        Entry { path, title, duration, secs }
    }

    fn from_line(line: &str) -> Option<Self> {
        let line = line.trim();
        if line.is_empty() { return None; }
        let (path_str, dur_str) = if let Some(pos) = line.find('\t') {
            (line[..pos].trim(), line[pos+1..].trim())
        } else {
            (line, "")
        };
        if path_str.is_empty() { return None; }
        let path = PathBuf::from(path_str);
        let title = path.file_stem()
            .and_then(|s| s.to_str())
            .unwrap_or("Audio")
            .to_string();
        let secs = parse_duration_str(dur_str);
        let duration = if dur_str.is_empty() {
            probe_duration(&path).0
        } else {
            dur_str.to_string()
        };
        Some(Entry { path, title, duration, secs })
    }

    fn to_line(&self) -> String {
        format!("{}\t{}\n", self.path.display(), self.duration)
    }
}

fn parse_duration_str(s: &str) -> f64 {
    let parts: Vec<&str> = s.splitn(2, ':').collect();
    if parts.len() == 2 {
        let m: f64 = parts[0].parse().unwrap_or(0.0);
        let s2: f64 = parts[1].trim_end_matches(".000")
            .trim_end_matches(".mmm")
            .parse().unwrap_or(0.0);
        m * 60.0 + s2
    } else {
        s.parse().unwrap_or(0.0)
    }
}

fn probe_duration(path: &Path) -> (String, f64) {
    let out = Command::new("ffprobe")
        .args(["-v", "error",
               "-show_entries", "format=duration",
               "-of", "default=noprint_wrappers=1:nokey=1",
               path.to_str().unwrap_or("")])
        .output();
    let secs = match out {
        Ok(o) => String::from_utf8_lossy(&o.stdout)
            .trim().parse::<f64>().unwrap_or(0.0),
        Err(_) => 0.0,
    };
    let total = secs as u64;
    let dur = format!("{:02}:{:02}.000", total / 60, total % 60);
    (dur, secs)
}

fn format_total(secs: f64) -> String {
    let t = secs as u64;
    format!("{:02}:{:02}:{:02}", t / 3600, (t % 3600) / 60, t % 60)
}

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

// ── CSS ──────────────────────────────────────────────────────────────────────
const APP_CSS: &str = r#"
window { background-color: #1e1e1e; color: #e0e0e0; }

.toolbar {
    background-color: #2a2a2a;
    border-bottom: 1px solid #444;
    padding: 4px 8px;
}

.total-label {
    color: #88cc88;
    font-size: 13px;
    font-weight: bold;
    padding: 0 12px;
}

.header-row {
    background-color: #2a3a4a;
    padding: 4px 8px;
    border-bottom: 1px solid #555;
}

.header-label {
    color: #aaccee;
    font-weight: bold;
    font-size: 12px;
}

.pl-row {
    padding: 3px 6px;
    border-bottom: 1px solid #2a2a2a;
    background-color: #1e1e1e;
}

.pl-row:nth-child(even) { background-color: #252525; }
.pl-row:hover          { background-color: #2a4a6a; }
.pl-row:selected       { background-color: #1a6496; }

.col-num   { color: #888888; font-size: 11px; min-width: 32px; }
.col-title { color: #e0e0e0; font-size: 12px; }
.col-time  { color: #88cc88; font-size: 12px; min-width: 80px; }
.col-path  { color: #666666; font-size: 11px; }

.btn-toolbar {
    background: #3a3a3a;
    color: #e0e0e0;
    border: 1px solid #555;
    border-radius: 4px;
    padding: 4px 8px;
    font-size: 12px;
    min-width: 32px;
    min-height: 32px;
}
.btn-toolbar:hover { background: #4a90d9; color: #ffffff; }

.btn-danger {
    background: #4a1a1a;
    color: #ff8888;
    border: 1px solid #7a2a2a;
    border-radius: 4px;
    padding: 4px 10px;
    font-size: 12px;
    min-height: 32px;
}
.btn-danger:hover { background: #cc0000; color: #ffffff; }

.btn-action {
    background: #2a3a4a;
    color: #88ccee;
    border: 1px solid #3a5a7a;
    border-radius: 4px;
    padding: 4px 16px;
    font-size: 12px;
    min-height: 32px;
}
.btn-action:hover { background: #3a6a9a; color: #ffffff; }

.bottom-bar {
    background-color: #2a2a2a;
    border-top: 1px solid #444;
    padding: 6px 8px;
}

.drop-hint {
    color: #555555;
    font-size: 13px;
    font-style: italic;
}
"#;

fn main() -> glib::ExitCode {
    let locale_cfg = read_locale_from_config();
    grpautaje::i18n::init(locale_cfg.as_deref());

    let app = Application::builder()
        .application_id(APP_ID)
        .build();
    app.connect_activate(|app| {
        if let Some(w) = app.active_window() { w.present(); return; }
        build_ui(app);
    });
    app.run()
}

fn read_locale_from_config() -> Option<String> {
    let home = dirs::home_dir()?;
    let path = home.join(".gradio/data/gradio_config");
    let content = std::fs::read_to_string(path).ok()?;
    let line = content.lines().nth(16)?.trim();
    if line.is_empty() { None } else { Some(line.to_string()) }
}

fn build_ui(app: &Application) {
    if let Some(settings) = gtk::Settings::default() {
        settings.set_gtk_application_prefer_dark_theme(true);
    }

    let provider = CssProvider::new();
    provider.load_from_data(APP_CSS);
    if let Some(display) = gdk4::Display::default() {
        gtk::style_context_add_provider_for_display(
            &display, &provider,
            STYLE_PROVIDER_PRIORITY_APPLICATION + 200,
        );
        skin::aplicar_css_extra(&display);
    }

    let window = ApplicationWindow::builder()
        .application(app)
        .title(tr("pl.win_title"))
        .default_width(1200)
        .default_height(700)
        .build();

    // Modelo compartido
    let model: Rc<RefCell<Vec<Entry>>> = Rc::new(RefCell::new(Vec::new()));
    // Archivo actual (para guardar)
    let current_file: Rc<RefCell<Option<PathBuf>>> = Rc::new(RefCell::new(None));

    // ── Layout ───────────────────────────────────────────────────────────────
    let vbox = GtkBox::new(Orientation::Vertical, 0);

    // ── Barra superior ────────────────────────────────────────────────────────
    let toolbar = GtkBox::new(Orientation::Horizontal, 6);
    toolbar.add_css_class("toolbar");
    toolbar.set_margin_top(4); toolbar.set_margin_bottom(4);
    toolbar.set_margin_start(6); toolbar.set_margin_end(6);

    let btn_up   = Button::with_label("▲");
    let btn_down = Button::with_label("▼");
    let btn_del  = Button::with_label("🗑");
    let btn_play_cue = Button::with_label("♪");
    let btn_stop_cue = Button::with_label("⊗");
    let btn_clock    = Button::with_label("⏱");

    for btn in [&btn_up, &btn_down, &btn_del, &btn_play_cue, &btn_stop_cue, &btn_clock] {
        btn.add_css_class("btn-toolbar");
    }
    btn_del.set_tooltip_text(Some(tr("pl.tip_del")));
    btn_up.set_tooltip_text(Some(tr("pl.tip_up")));
    btn_down.set_tooltip_text(Some(tr("pl.tip_down")));
    btn_play_cue.set_tooltip_text(Some(tr("pl.tip_play_cue")));
    btn_stop_cue.set_tooltip_text(Some(tr("pl.tip_stop_cue")));
    btn_clock.set_tooltip_text(Some(tr("pl.tip_clock")));

    let total_lbl = Label::new(Some("00:00:00"));
    total_lbl.add_css_class("total-label");
    total_lbl.set_hexpand(true);
    total_lbl.set_halign(gtk::Align::Start);

    toolbar.append(&btn_up);
    toolbar.append(&btn_down);
    toolbar.append(&btn_del);
    toolbar.append(&btn_play_cue);
    toolbar.append(&btn_stop_cue);
    toolbar.append(&btn_clock);
    toolbar.append(&total_lbl);

    // ── Cabecera columnas ─────────────────────────────────────────────────────
    let header = GtkBox::new(Orientation::Horizontal, 0);
    header.add_css_class("header-row");
    for (text, width, expand) in [
        (tr("pl.col_tema"),   400, true),
        (tr("pl.col_tiempo"),  90, false),
        (tr("pl.col_ruta"),    -1, true),
    ] {
        let lbl = Label::new(Some(text));
        lbl.add_css_class("header-label");
        lbl.set_halign(gtk::Align::Start);
        lbl.set_margin_start(6);
        if width > 0 { lbl.set_size_request(width, -1); }
        if expand    { lbl.set_hexpand(true); }
        header.append(&lbl);
    }

    // ── Lista ─────────────────────────────────────────────────────────────────
    let listbox = ListBox::new();
    listbox.set_selection_mode(gtk::SelectionMode::Single);

    let scroll = ScrolledWindow::new();
    scroll.set_vexpand(true);
    scroll.set_child(Some(&listbox));

    // Drop target: aceptar archivos desde gestor de archivos y desde gr-buscador.
    // Se instalan tres DropTargets sobre el listbox Y sobre el scroll para cubrir
    // tanto las filas como el área vacía debajo de la última fila:
    //   - STRING       → text/uri-list del buscador (file://...)
    //   - FileList     → múltiples archivos desde Nemo/Thunar/Nautilus
    //   - gio::File    → un solo archivo desde un file manager
    install_drop_targets(&listbox.clone().upcast::<gtk::Widget>(),
                         &model, &listbox, &total_lbl);
    install_drop_targets(&scroll.clone().upcast::<gtk::Widget>(),
                         &model, &listbox, &total_lbl);

    // ── Barra inferior ────────────────────────────────────────────────────────
    let bottom = GtkBox::new(Orientation::Horizontal, 0);
    bottom.add_css_class("bottom-bar");

    let btn_clear  = Button::with_label(tr("pl.btn_clear"));
    let btn_load   = Button::with_label(tr("pl.btn_load"));
    let btn_save   = Button::with_label(tr("pl.btn_save"));

    btn_clear.add_css_class("btn-danger");
    btn_load.add_css_class("btn-action");
    btn_save.add_css_class("btn-action");

    btn_clear.set_hexpand(true);
    btn_load.set_hexpand(true);
    btn_save.set_hexpand(true);

    bottom.append(&btn_clear);
    bottom.append(&btn_load);
    bottom.append(&btn_save);

    vbox.append(&toolbar);
    vbox.append(&header);
    vbox.append(&scroll);
    vbox.append(&bottom);
    window.set_child(Some(&vbox));

    // ── Señales ───────────────────────────────────────────────────────────────

    // Subir
    {
        let model_u   = model.clone();
        let listbox_u = listbox.clone();
        let total_u   = total_lbl.clone();
        btn_up.connect_clicked(move |_| {
            if let Some(row) = listbox_u.selected_row() {
                let idx = row.index() as usize;
                if idx == 0 { return; }
                let mut m = model_u.borrow_mut();
                m.swap(idx, idx - 1);
                drop(m);
                rebuild_list(&model_u, &listbox_u);
                if let Some(r) = listbox_u.row_at_index((idx - 1) as i32) {
                    listbox_u.select_row(Some(&r));
                }
                update_total(&model_u, &total_u);
            }
        });
    }

    // Bajar
    {
        let model_d   = model.clone();
        let listbox_d = listbox.clone();
        let total_d   = total_lbl.clone();
        btn_down.connect_clicked(move |_| {
            if let Some(row) = listbox_d.selected_row() {
                let idx = row.index() as usize;
                let len = model_d.borrow().len();
                if idx + 1 >= len { return; }
                let mut m = model_d.borrow_mut();
                m.swap(idx, idx + 1);
                drop(m);
                rebuild_list(&model_d, &listbox_d);
                if let Some(r) = listbox_d.row_at_index((idx + 1) as i32) {
                    listbox_d.select_row(Some(&r));
                }
                update_total(&model_d, &total_d);
            }
        });
    }

    // Eliminar
    {
        let model_e   = model.clone();
        let listbox_e = listbox.clone();
        let total_e   = total_lbl.clone();
        btn_del.connect_clicked(move |_| {
            if let Some(row) = listbox_e.selected_row() {
                let idx = row.index() as usize;
                model_e.borrow_mut().remove(idx);
                rebuild_list(&model_e, &listbox_e);
                update_total(&model_e, &total_e);
            }
        });
    }

    // Calcular tiempos (re-probe todos)
    {
        let model_c = model.clone();
        let total_c = total_lbl.clone();
        btn_clock.connect_clicked(move |_| {
            let mut m = model_c.borrow_mut();
            for entry in m.iter_mut() {
                let (dur, secs) = probe_duration(&entry.path);
                entry.duration = dur;
                entry.secs     = secs;
            }
            let total: f64 = m.iter().map(|e| e.secs).sum();
            total_c.set_text(&format_total(total));
        });
    }

    // Pre-escucha (CUE)
    let cue_proc: Rc<RefCell<Option<std::process::Child>>> = Rc::new(RefCell::new(None));
    {
        let model_p  = model.clone();
        let listbox_p = listbox.clone();
        let cue_p    = cue_proc.clone();
        btn_play_cue.connect_clicked(move |_| {
            if let Some(row) = listbox_p.selected_row() {
                let idx = row.index() as usize;
                let m = model_p.borrow();
                if let Some(entry) = m.get(idx) {
                    // Detener pre-escucha anterior
                    if let Some(mut child) = cue_p.borrow_mut().take() {
                        let _ = child.kill();
                    }
                    // Usar mpv o ffplay para CUE
                    let child = Command::new("mpv")
                        .arg("--no-video")
                        .arg(entry.path.to_str().unwrap_or(""))
                        .spawn()
                        .or_else(|_| Command::new("ffplay")
                            .args(["-nodisp", "-autoexit"])
                            .arg(entry.path.to_str().unwrap_or(""))
                            .spawn());
                    if let Ok(c) = child {
                        *cue_p.borrow_mut() = Some(c);
                    }
                }
            }
        });
    }

    {
        let cue_s = cue_proc.clone();
        btn_stop_cue.connect_clicked(move |_| {
            if let Some(mut child) = cue_s.borrow_mut().take() {
                let _ = child.kill();
            }
        });
    }

    // Borrar lista
    {
        let model_cl  = model.clone();
        let listbox_cl = listbox.clone();
        let total_cl  = total_lbl.clone();
        let cf_cl     = current_file.clone();
        btn_clear.connect_clicked(move |_| {
            model_cl.borrow_mut().clear();
            while let Some(ch) = listbox_cl.first_child() { listbox_cl.remove(&ch); }
            total_cl.set_text("00:00:00");
            *cf_cl.borrow_mut() = None;
        });
    }

    // Cargar lista
    {
        let model_lo   = model.clone();
        let listbox_lo = listbox.clone();
        let total_lo   = total_lbl.clone();
        let cf_lo      = current_file.clone();
        let win_lo     = window.clone();
        btn_load.connect_clicked(move |_| {
            let fc = gtk::FileChooserDialog::new(
                Some(tr("pl.load_title")),
                Some(&win_lo),
                FileChooserAction::Open,
                &[(tr("btn.cancel"), gtk::ResponseType::Cancel),
                  (tr("btn.open"),   gtk::ResponseType::Accept)],
            );
            fc.set_modal(true);
            let filter = gtk::FileFilter::new();
            filter.set_name(Some(tr("pl.filter")));
            filter.add_pattern("*.gradio");
            fc.add_filter(&filter);

            let model_c   = model_lo.clone();
            let listbox_c = listbox_lo.clone();
            let total_c   = total_lo.clone();
            let cf_c      = cf_lo.clone();
            fc.connect_response(move |fc, resp| {
                if resp == gtk::ResponseType::Accept {
                    if let Some(file) = fc.file() {
                        if let Some(path) = file.path() {
                            load_gradio_file(&path, &model_c, &listbox_c, &total_c);
                            *cf_c.borrow_mut() = Some(path);
                        }
                    }
                }
                fc.close();
            });
            fc.present();
        });
    }

    // Guardar lista
    {
        let model_sv = model.clone();
        let cf_sv    = current_file.clone();
        let win_sv   = window.clone();
        btn_save.connect_clicked(move |_| {
            let fc = gtk::FileChooserDialog::new(
                Some(tr("pl.save_title")),
                Some(&win_sv),
                FileChooserAction::Save,
                &[(tr("btn.cancel"), gtk::ResponseType::Cancel),
                  (tr("btn.save"),   gtk::ResponseType::Accept)],
            );
            fc.set_modal(true);
            fc.set_current_name(tr("pl.default_name"));

            // Si hay archivo actual, navegar a su carpeta
            if let Some(ref cur) = *cf_sv.borrow() {
                if let Some(parent) = cur.parent() {
                    let _ = fc.set_current_folder(
                        Some(&gtk4::gio::File::for_path(parent))
                    );
                }
            }

            let filter = gtk::FileFilter::new();
            filter.set_name(Some(tr("pl.filter")));
            filter.add_pattern("*.gradio");
            fc.add_filter(&filter);

            let model_c = model_sv.clone();
            let cf_c    = cf_sv.clone();
            fc.connect_response(move |fc, resp| {
                if resp == gtk::ResponseType::Accept {
                    if let Some(file) = fc.file() {
                        if let Some(mut path) = file.path() {
                            if path.extension().and_then(|e| e.to_str()) != Some("gradio") {
                                path.set_extension("gradio");
                            }
                            save_gradio_file(&path, &model_c.borrow());
                            *cf_c.borrow_mut() = Some(path);
                        }
                    }
                }
                fc.close();
            });
            fc.present();
        });
    }

    // Doble clic en fila → pre-escucha rápida
    {
        let model_dc  = model.clone();
        let listbox_dc = listbox.clone();
        let click = gtk::GestureClick::new();
        click.set_button(1);
        click.connect_released(move |_, n, _, _| {
            if n == 2 {
                if let Some(row) = listbox_dc.selected_row() {
                    let idx = row.index() as usize;
                    let m = model_dc.borrow();
                    if let Some(entry) = m.get(idx) {
                        let _ = Command::new("mpv")
                            .arg("--no-video")
                            .arg(entry.path.to_str().unwrap_or(""))
                            .spawn()
                            .or_else(|_| Command::new("ffplay")
                                .args(["-nodisp", "-autoexit"])
                                .arg(entry.path.to_str().unwrap_or(""))
                                .spawn());
                    }
                }
            }
        });
        listbox.add_controller(click);
    }

    // Si se pasa un argumento, abrir ese archivo directamente
    {
        let args: Vec<String> = std::env::args().collect();
        if let Some(path_str) = args.get(1) {
            let path = PathBuf::from(path_str);
            if path.exists() {
                load_gradio_file(&path, &model, &listbox, &total_lbl);
                *current_file.borrow_mut() = Some(path);
            }
        }
    }

    // Ícono en taskbar
    {
        let loader = gdk4::gdk_pixbuf::PixbufLoader::new();
        let _ = loader.write(&skin::icono("playlist48x48.png", ICONO_PLAYLIST));
        let _ = loader.close();
        if let Some(pb) = loader.pixbuf() {
            let tex = gdk4::Texture::for_pixbuf(&pb);
            window.connect_realize(move |w| {
                if let Some(surf) = w.surface() {
                    use gdk4::prelude::ToplevelExt;
                    if let Some(tl) = surf.dynamic_cast_ref::<gdk4::Toplevel>() {
                        tl.set_icon_list(&[tex.clone()]);
                    }
                }
            });
        }
    }

    // Tamaño dinámico según resolución
    {
        let win = window.clone();
        window.connect_realize(move |w| {
            if let Some(surf) = w.surface() {
                let display = gtk4::prelude::WidgetExt::display(w);
                use gdk4::prelude::DisplayExt;
                if let Some(monitor) = display.monitor_at_surface(&surf) {
                    use gdk4::prelude::MonitorExt;
                    let geo = monitor.geometry();
                    let ancho = (geo.width() - 20).min(1300).max(600);
                    let alto  = (geo.height() - 90).min(680).max(400);
                    win.set_default_size(ancho, alto);
                }
            }
        });
    }
    window.present();
}

// ── Funciones de modelo / UI ──────────────────────────────────────────────────

/// Procesa una lista de rutas: las inserta en el modelo y la UI.
/// Devuelve `true` si se agregó al menos una entrada.
fn ingest_paths(
    paths: Vec<PathBuf>,
    model: &Rc<RefCell<Vec<Entry>>>,
    listbox: &ListBox,
    total_lbl: &Label,
) -> bool {
    let mut added = false;
    for path in paths {
        if path.is_dir() {
            collect_audio_from_dir(&path, model, listbox);
            added = true;
        } else if is_audio(&path) {
            let entry = Entry::from_path(path);
            append_row(&entry, listbox);
            model.borrow_mut().push(entry);
            added = true;
        }
    }
    if added { update_total(model, total_lbl); }
    added
}

/// Instala 3 DropTargets (STRING / FileList / gio::File) sobre `widget`,
/// para aceptar drops desde gr-buscador y file managers.
fn install_drop_targets(
    widget: &gtk::Widget,
    model: &Rc<RefCell<Vec<Entry>>>,
    listbox: &ListBox,
    total_lbl: &Label,
) {
    // STRING — text/uri-list (buscador, fallback de file managers)
    let drop_string = gtk4::DropTarget::new(glib::Type::STRING, gdk4::DragAction::COPY);
    {
        let model_d   = model.clone();
        let listbox_d = listbox.clone();
        let total_d   = total_lbl.clone();
        drop_string.connect_drop(move |_, value, _, _| {
            let text = match value.get::<String>() {
                Ok(t) => t,
                Err(_) => return false,
            };
            let paths: Vec<PathBuf> = text.lines()
                .map(|l| l.trim().trim_start_matches("file://").to_string())
                .filter(|s| !s.is_empty())
                .map(PathBuf::from)
                .collect();
            if paths.is_empty() { return false; }
            ingest_paths(paths, &model_d, &listbox_d, &total_d)
        });
    }
    widget.add_controller(drop_string);

    // FileList — múltiples archivos desde Nemo/Thunar/Nautilus
    let drop_files = gtk4::DropTarget::new(FileList::static_type(), gdk4::DragAction::COPY);
    {
        let model_d   = model.clone();
        let listbox_d = listbox.clone();
        let total_d   = total_lbl.clone();
        drop_files.connect_drop(move |_, value, _, _| {
            if let Ok(file_list) = value.get::<FileList>() {
                let paths: Vec<PathBuf> = file_list.files()
                    .into_iter()
                    .filter_map(|f| f.path())
                    .collect();
                if paths.is_empty() { return false; }
                return ingest_paths(paths, &model_d, &listbox_d, &total_d);
            }
            false
        });
    }
    widget.add_controller(drop_files);

    // gio::File — un solo archivo desde un file manager
    let drop_file = gtk4::DropTarget::new(gtk4::gio::File::static_type(), gdk4::DragAction::COPY);
    {
        let model_d   = model.clone();
        let listbox_d = listbox.clone();
        let total_d   = total_lbl.clone();
        drop_file.connect_drop(move |_, value, _, _| {
            if let Ok(file) = value.get::<gtk4::gio::File>() {
                if let Some(path) = file.path() {
                    return ingest_paths(vec![path], &model_d, &listbox_d, &total_d);
                }
            }
            false
        });
    }
    widget.add_controller(drop_file);
}

fn append_row(entry: &Entry, listbox: &ListBox) {
    let idx = listbox.observe_children().n_items() as usize + 1;
    let row = build_row(idx, entry);
    listbox.append(&row);
}

fn build_row(num: usize, entry: &Entry) -> ListBoxRow {
    let row_box = GtkBox::new(Orientation::Horizontal, 0);
    row_box.add_css_class("pl-row");

    let lbl_num   = Label::new(Some(&format!("{}", num)));
    let lbl_title = Label::new(Some(&entry.title));
    let lbl_time  = Label::new(Some(&entry.duration));
    let lbl_path  = Label::new(Some(
        entry.path.parent()
            .map(|p| p.to_string_lossy().to_string())
            .unwrap_or_default()
            .as_str()
    ));

    lbl_num.add_css_class("col-num");
    lbl_num.set_size_request(32, -1);
    lbl_num.set_halign(gtk::Align::End);
    lbl_num.set_margin_end(6);

    lbl_title.add_css_class("col-title");
    lbl_title.set_size_request(400, -1);
    lbl_title.set_halign(gtk::Align::Start);
    lbl_title.set_ellipsize(gtk::pango::EllipsizeMode::End);

    lbl_time.add_css_class("col-time");
    lbl_time.set_size_request(90, -1);
    lbl_time.set_halign(gtk::Align::Start);

    lbl_path.add_css_class("col-path");
    lbl_path.set_hexpand(true);
    lbl_path.set_halign(gtk::Align::Start);
    lbl_path.set_ellipsize(gtk::pango::EllipsizeMode::Start);

    row_box.append(&lbl_num);
    row_box.append(&lbl_title);
    row_box.append(&lbl_time);
    row_box.append(&lbl_path);

    let row = ListBoxRow::new();
    row.set_child(Some(&row_box));
    row
}

fn rebuild_list(model: &Rc<RefCell<Vec<Entry>>>, listbox: &ListBox) {
    while let Some(ch) = listbox.first_child() { listbox.remove(&ch); }
    let m = model.borrow();
    for (i, entry) in m.iter().enumerate() {
        listbox.append(&build_row(i + 1, entry));
    }
}

fn update_total(model: &Rc<RefCell<Vec<Entry>>>, lbl: &Label) {
    let total: f64 = model.borrow().iter().map(|e| e.secs).sum();
    lbl.set_text(&format_total(total));
}

fn collect_audio_from_dir(
    dir: &Path,
    model: &Rc<RefCell<Vec<Entry>>>,
    listbox: &ListBox,
) {
    let Ok(entries) = fs::read_dir(dir) else { return };
    let mut paths: Vec<PathBuf> = entries
        .flatten()
        .map(|e| e.path())
        .filter(|p| p.is_file() && is_audio(p))
        .collect();
    paths.sort();
    for path in paths {
        let entry = Entry::from_path(path);
        append_row(&entry, listbox);
        model.borrow_mut().push(entry);
    }
}

fn load_gradio_file(
    path: &Path,
    model: &Rc<RefCell<Vec<Entry>>>,
    listbox: &ListBox,
    total_lbl: &Label,
) {
    let Ok(content) = fs::read_to_string(path) else { return };
    model.borrow_mut().clear();
    while let Some(ch) = listbox.first_child() { listbox.remove(&ch); }
    for line in content.lines() {
        if let Some(entry) = Entry::from_line(line) {
            append_row(&entry, listbox);
            model.borrow_mut().push(entry);
        }
    }
    update_total(model, total_lbl);
}

fn save_gradio_file(path: &Path, entries: &[Entry]) {
    let content: String = entries.iter().map(|e| e.to_line()).collect();
    if let Err(e) = fs::write(path, &content) {
        eprintln!("[gr-playlist] Error guardando {:?}: {}", path, e);
    } else {
        eprintln!("[gr-playlist] Guardado: {:?} ({} temas)", path, entries.len());
    }
}
