//! app.rs — Dual-CSS HF Mode
//! Main application window (the eframe::App). Layout mirrors mainwindow.py:
//!   waterfall | status panel  /  RX log  /  TX panel
//!
//! egui is immediate-mode, so there are no signals/slots. Instead the App owns
//! all state and, each frame, polls the audio engine's spectrum channel and the
//! demodulator status, then redraws. The cpal streams live in AudioEngine and
//! their callbacks only touch shared buffers — never this struct.

use std::time::Instant;

use egui::{Color32, FontId, RichText};

use crate::audio::AudioEngine;
use crate::config::ConfigManager;
use crate::dsp::{DemodStatus, FrameState, PacketEngine};
use crate::ptt::Ptt;
use crate::settings::{SettingsDialog, SettingsOutcome};
use crate::waterfall::Waterfall;

const ACCENT: Color32 = Color32::from_rgb(0, 212, 255);
const TEXT: Color32 = Color32::from_rgb(201, 209, 217);
const DIM: Color32 = Color32::from_rgb(58, 82, 104);

struct RxEntry {
    time: String,
    callsign: String,
    snr: f32,
    text: String,
}

pub struct DualCssApp {
    cfg: ConfigManager,
    audio: AudioEngine,
    pkt: PacketEngine,
    waterfall: Waterfall,
    settings: Option<SettingsDialog>,

    is_rx: bool,
    is_tx: bool,
    tx_input: String,
    rx_log: Vec<RxEntry>,
    status: DemodStatus,
    status_msg: Option<(String, Instant)>,
    /// PTT held open for the duration of a transmission so the rig stays
    /// keyed until the audio finishes playing (or the user hits STOP TX).
    active_ptt: Option<Ptt>,
    /// Encoded TX audio waiting for the PTT settle delay to elapse; `pump`
    /// starts playback at the given instant (keying delay must not block the
    /// UI thread).
    pending_tx: Option<(Instant, Vec<f32>)>,
    /// SF/rate/turbo suggestion derived from the last decoded frame's SNR,
    /// shown in the advisor box.
    last_advice: Option<String>,
}

impl DualCssApp {
    pub fn new(cfg: ConfigManager) -> Self {
        let config = cfg.config.clone();
        let audio = AudioEngine::new(config.clone());
        let pkt = PacketEngine::new(config);
        Self {
            cfg,
            audio,
            pkt,
            waterfall: Waterfall::new(),
            settings: None,
            is_rx: false,
            is_tx: false,
            tx_input: String::new(),
            rx_log: Vec::new(),
            status: DemodStatus::default(),
            status_msg: None,
            active_ptt: None,
            pending_tx: None,
            last_advice: None,
        }
    }

    fn show_status(&mut self, msg: impl Into<String>) {
        self.status_msg = Some((msg.into(), Instant::now()));
    }

    fn toggle_rx(&mut self) {
        if self.is_rx {
            self.audio.stop_rx();
            self.is_rx = false;
        } else {
            self.audio.start_rx();
            self.is_rx = self.audio.is_running();
            if !self.is_rx {
                // Stream failed to open — surface the concrete cpal error so the
                // failure isn't silent (this is what left the waterfall black).
                let msg = self
                    .audio
                    .take_error()
                    .unwrap_or_else(|| "RX failed to start".into());
                self.show_status(format!("⚠ {msg}"));
            } else {
                self.retune_to_input_rate();
            }
        }
    }

    /// The demodulator's chirp references must match the rate the input stream
    /// actually opened at, which can differ from the config when the device
    /// doesn't support the requested rate. The negotiated rate is runtime
    /// state — the saved config keeps the user's requested rate.
    fn retune_to_input_rate(&mut self) {
        if let Some(rate) = self.audio.input_sample_rate() {
            if rate != self.cfg.config.audio.sample_rate {
                let mut cfg = self.cfg.config.clone();
                cfg.audio.sample_rate = rate;
                self.pkt.rebuild(cfg);
                self.show_status(format!("Input opened at {rate} Hz — demod retuned"));
            }
        }
    }

    fn transmit(&mut self) {
        let text = self.tx_input.trim().to_string();
        if text.is_empty() || self.is_tx {
            return;
        }

        // Key PTT (if configured) before sending audio. The Ptt handle is kept
        // alive for the whole transmission and released in `end_tx` once the
        // audio finishes (or the user hits STOP TX) — unkeying right after
        // queueing would drop the rig mid-over.
        let ptt_cfg = &self.cfg.config.ptt;
        let mut settle_ms = 0u64;
        if !matches!(ptt_cfg.method.as_str(), "VOX" | "None") {
            match Ptt::open(ptt_cfg) {
                Ok(mut ptt) => {
                    if let Err(e) = ptt.key(true) {
                        self.show_status(format!("⚠ {e}"));
                    } else {
                        settle_ms = ptt_cfg.ptt_delay_ms as u64;
                        self.active_ptt = Some(ptt);
                    }
                }
                Err(e) => self.show_status(format!("⚠ {e}")),
            }
        }

        // Honour ptt_delay_ms without sleeping on the UI thread: park the
        // encoded audio and let `pump` start playback once the rig has had
        // its settle time.
        let audio = self.pkt.encode_text(&text);
        self.pending_tx = Some((
            Instant::now() + std::time::Duration::from_millis(settle_ms),
            audio,
        ));
        self.is_tx = true;
        self.tx_input.clear();
    }

    /// Stop audio output and release PTT — used both when the TX buffer
    /// finishes playing and when the user presses STOP TX.
    fn end_tx(&mut self) {
        self.pending_tx = None; // STOP TX during the settle delay cancels the audio
        self.audio.stop_tx();
        if let Some(mut ptt) = self.active_ptt.take() {
            if let Err(e) = ptt.key(false) {
                self.show_status(format!("⚠ {e}"));
            }
        }
        self.is_tx = false;
    }

    /// Pull spectra, raw RX audio and status off the audio/DSP threads each frame.
    fn pump(&mut self) {
        // scroll_speed = rows advanced per spectrum update (WSJT-X-style pacing).
        let scroll = self.cfg.config.display.scroll_speed.max(1);
        for spec in self.audio.drain_spectra() {
            for _ in 0..scroll {
                self.waterfall.push_spectrum(&spec);
            }
        }

        // Route raw capture audio into the demodulator and collect any frames
        // it completed (preamble acquisition → header → data → RS decode).
        if self.is_rx {
            let samples = self.audio.drain_rx_audio();
            if !samples.is_empty() {
                self.pkt.feed_rx(&samples);
            }
            for msg in self.pkt.take_decoded_texts() {
                self.last_advice = Some(format!(
                    "RX SF{} {}: {}",
                    msg.sf,
                    crate::dsp::rate_label(msg.rate_code),
                    crate::dsp::advise_from_snr(msg.snr_db, msg.sf, msg.rate_code, msg.turbo)
                ));
                self.rx_log.push(RxEntry {
                    time: utc_hms(),
                    callsign: format!(
                        "SF{} {}Hz {}{}",
                        msg.sf,
                        msg.bw,
                        crate::dsp::rate_label(msg.rate_code),
                        if msg.turbo { " TURBO" } else { "" }
                    ),
                    snr: msg.snr_db,
                    text: msg.text,
                });
            }
        }

        if let Some(err) = self.audio.take_error() {
            self.show_status(format!("⚠ {err}"));
        }
        // Start parked TX audio once the PTT settle delay has elapsed.
        if self
            .pending_tx
            .as_ref()
            .is_some_and(|(start_at, _)| Instant::now() >= *start_at)
        {
            let (_, audio) = self.pending_tx.take().unwrap();
            self.audio.transmit(audio);
        }
        // Reflect TX completion (releases PTT once the buffer has played out).
        // The pending check matters: before playback starts, the *previous*
        // (fully consumed) TX buffer would report finished and unkey the rig.
        if self.is_tx && self.pending_tx.is_none() && self.audio.tx_finished() {
            self.end_tx();
        }
        self.status = self.pkt.status();
    }

    fn open_settings(&mut self) {
        self.settings = Some(SettingsDialog::new(&self.cfg.config));
    }

    fn apply_config(&mut self, mut new_cfg: crate::config::AppConfig) {
        // The Settings dialog has free-text numeric fields; repair hostile or
        // out-of-range values the same way loading a hand-edited file does.
        new_cfg.sanitize();
        let prev = self.cfg.config.clone();
        self.cfg.config = new_cfg.clone();
        self.cfg.save();
        self.audio.update_config(new_cfg.clone());
        self.pkt.rebuild(new_cfg.clone());
        // Restart the input stream only when the parameters it was opened
        // with actually changed — every other edit applies live, and the
        // waterfall keeps flowing instead of blanking on each Settings OK.
        let stream_changed = prev.audio.input_device != new_cfg.audio.input_device
            || prev.audio.sample_rate != new_cfg.audio.sample_rate;
        if self.is_rx && stream_changed {
            self.audio.stop_rx();
            self.audio.start_rx();
            self.is_rx = self.audio.is_running();
            if !self.is_rx {
                let msg = self
                    .audio
                    .take_error()
                    .unwrap_or_else(|| "RX failed to restart".into());
                self.show_status(format!("⚠ {msg}"));
            }
        }
        // The rebuild above used the config rate; re-do the negotiated-rate
        // retune whenever a stream is (still) running.
        if self.is_rx {
            self.retune_to_input_rate();
        }
    }
}

impl eframe::App for DualCssApp {
    fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
        // eframe 0.34 hands us a root `Ui`; grab its Context so we can keep the
        // multi-panel layout (panels attach to the viewport via the Context).
        let ctx = ui.ctx().clone();
        let ctx = &ctx;

        self.pump();
        // Drive continuous repaint for the live waterfall (~30 fps).
        ctx.request_repaint_after(std::time::Duration::from_millis(33));

        // ── Settings modal ────────────────────────────────────────────────
        if let Some(dlg) = &mut self.settings {
            match dlg.show(ctx) {
                SettingsOutcome::Open => {}
                SettingsOutcome::Apply(cfg) => self.apply_config(cfg),
                SettingsOutcome::Accept(cfg) => {
                    self.apply_config(cfg);
                    self.settings = None;
                }
                SettingsOutcome::Cancel => self.settings = None,
            }
        }

        // ── Menu bar ──────────────────────────────────────────────────────
        egui::Panel::top("menu").show_inside(ui, |ui| {
            egui::MenuBar::new().ui(ui, |ui| {
                ui.menu_button("File", |ui| {
                    if ui.button("Open log file…").clicked() {
                        open_log(&self.cfg.config.station.log_file);
                        ui.close();
                    }
                    ui.separator();
                    if ui.button("Exit").clicked() {
                        ctx.send_viewport_cmd(egui::ViewportCommand::Close);
                    }
                });
                ui.menu_button("Tools", |ui| {
                    if ui.button("Settings…").clicked() {
                        self.open_settings();
                        ui.close();
                    }
                });
                ui.menu_button("Help", |ui| {
                    if ui.button("About").clicked() {
                        self.show_status("Dual-CSS HF Mode — Rust port v0.1.0");
                        ui.close();
                    }
                });
            });
        });

        // ── Status bar ────────────────────────────────────────────────────
        egui::Panel::bottom("statusbar").show_inside(ui, |ui| {
            let c = &self.cfg.config;
            let bw = c.modem.bandwidth();
            let dur = c.modem.symbol_duration_ms();
            let st = &c.station;
            let call = if st.callsign.is_empty() {
                "  ".to_string()
            } else {
                format!("  {} {}  ", st.callsign, st.grid_square)
            };
            // Estimated time-to-transmit: live countdown while sending,
            // otherwise a projection for the typed message.
            let ett = if self.is_tx {
                let (pos, len) = self.audio.tx_progress();
                let fs = c.audio.sample_rate.max(1) as f64;
                format!("TX {:.1} s left", (len.saturating_sub(pos)) as f64 / fs)
            } else {
                let secs = estimate_tx_secs(c, &self.tx_input);
                if secs > 0.0 {
                    format!("Est TX {secs:.1} s")
                } else {
                    "Est TX —".to_string()
                }
            };
            let base = format!(
                "{call}SF-{}  RS {}  PTT:{}  {} Hz BW  {:.0} ms/sym  {} Hz  {}",
                c.modem.spreading_factor,
                c.fec.rs_code_rate,
                c.ptt.method,
                bw,
                dur,
                c.audio.sample_rate,
                ett
            );
            let show_msg = self
                .status_msg
                .as_ref()
                .filter(|(_, t)| t.elapsed().as_secs() < 6)
                .map(|(m, _)| m.clone());
            ui.label(
                RichText::new(show_msg.unwrap_or(base))
                    .font(FontId::monospace(11.0))
                    .color(DIM),
            );
        });

        // ── Right panel: status + quick settings ──────────────────────────
        egui::Panel::right("right_panel")
            .resizable(false)
            .exact_size(230.0)
            .show_inside(ui, |ui| {
                // The stacked boxes outgrow short windows — scroll, don't
                // spill past the window edge.
                egui::ScrollArea::vertical()
                    .auto_shrink([false, false])
                    // Drag must always go to the sliders, never pan the panel.
                    .scroll_source(egui::scroll_area::ScrollSource {
                        drag: false,
                        ..Default::default()
                    })
                    .show(ui, |ui| {
                        self.status_panel(ui);
                        ui.add_space(8.0);
                        self.quick_settings(ui);
                        ui.add_space(8.0);
                        self.advisor_panel(ui);
                    });
            });

        // ── Bottom: RX log + TX panel ──────────────────────────────────────
        egui::Panel::bottom("tx_panel")
            .resizable(false)
            .show_inside(ui, |ui| {
                ui.group(|ui| {
                    ui.label(RichText::new("TRANSMIT").color(ACCENT).strong());
                    // Lay the row out right-to-left: the button claims its space
                    // first, then the text edit fills what remains. (Left-to-right
                    // with an infinite-width TextEdit pushed the button off-screen
                    // underneath the right side panel.) The height must be fixed:
                    // a bare with_layout claims all remaining window height, which
                    // ballooned this bottom panel over the RX log and waterfall.
                    ui.allocate_ui_with_layout(
                        egui::vec2(ui.available_width(), 26.0),
                        egui::Layout::right_to_left(egui::Align::Center),
                        |ui| {
                            let (btn_label, btn_color) = if self.is_tx {
                                ("STOP TX", Color32::from_rgb(255, 107, 107))
                            } else {
                                ("TRANSMIT", Color32::from_rgb(0, 230, 118))
                            };
                            let btn = ui.add_sized(
                                [100.0, 24.0],
                                egui::Button::new(
                                    RichText::new(btn_label).color(btn_color).strong(),
                                ),
                            );
                            let edit = ui.add(
                                egui::TextEdit::singleline(&mut self.tx_input)
                                    .hint_text("Message to transmit…")
                                    .font(FontId::monospace(12.0))
                                    .desired_width(ui.available_width()),
                            );
                            let enter =
                                edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
                            if btn.clicked() && self.is_tx {
                                self.end_tx();
                            } else if enter || btn.clicked() {
                                self.transmit();
                            }
                        },
                    );
                });
            });

        egui::Panel::bottom("rx_panel")
            .resizable(true)
            .default_size(130.0)
            .show_inside(ui, |ui| {
                ui.label(RichText::new("RECEIVE").color(ACCENT).strong());
                let header = |ui: &mut egui::Ui, text: &str| {
                    ui.label(RichText::new(text).color(DIM).small().strong());
                };
                egui_extras::TableBuilder::new(ui)
                    .striped(true)
                    .stick_to_bottom(true)
                    .cell_layout(egui::Layout::left_to_right(egui::Align::Center))
                    .column(egui_extras::Column::exact(62.0)) // UTC
                    .column(egui_extras::Column::auto()) // mode
                    .column(egui_extras::Column::exact(56.0)) // SNR
                    .column(egui_extras::Column::remainder().clip(true)) // message
                    .header(16.0, |mut h| {
                        h.col(|ui| header(ui, "UTC"));
                        h.col(|ui| header(ui, "Mode"));
                        h.col(|ui| header(ui, "SNR"));
                        h.col(|ui| header(ui, "Message"));
                    })
                    .body(|body| {
                        body.rows(18.0, self.rx_log.len(), |mut row| {
                            let e = &self.rx_log[row.index()];
                            row.col(|ui| {
                                ui.label(RichText::new(&e.time).color(DIM).monospace());
                            });
                            row.col(|ui| {
                                ui.label(RichText::new(&e.callsign).color(ACCENT).monospace());
                            });
                            row.col(|ui| {
                                ui.label(
                                    RichText::new(format!("{:.1} dB", e.snr))
                                        .color(Color32::from_rgb(255, 171, 64))
                                        .monospace(),
                                );
                            });
                            row.col(|ui| {
                                ui.label(RichText::new(&e.text).color(TEXT).monospace())
                                    .on_hover_text(&e.text);
                            });
                        });
                    });
            });

        // ── Central: waterfall ─────────────────────────────────────────────
        egui::CentralPanel::default().show_inside(ui, |ui| {
            let cfg = self.cfg.config.clone();
            self.waterfall.ui(ui, &cfg);
        });
    }

    fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
        self.end_tx(); // make sure a rig is never left keyed
        self.audio.stop_rx();
        self.cfg.save();
    }
}

impl DualCssApp {
    fn status_panel(&mut self, ui: &mut egui::Ui) {
        ui.group(|ui| {
            ui.label(RichText::new("SIGNAL STATUS").color(ACCENT).strong());
            ui.add_space(4.0);

            // SNR
            ui.label(
                RichText::new(format!("SNR  {:.1} dB", self.status.snr_db))
                    .font(FontId::monospace(11.0)),
            );
            let snr_pct = ((self.status.snr_db + 35.0) / 35.0).clamp(0.0, 1.0);
            let snr_color = if snr_pct > 0.55 {
                Color32::from_rgb(0, 230, 118)
            } else if snr_pct > 0.30 {
                Color32::from_rgb(255, 171, 64)
            } else {
                Color32::from_rgb(255, 107, 107)
            };
            bar(ui, snr_pct, snr_color);

            // Erasures + RS-corrected errors
            ui.label(
                RichText::new(format!(
                    "Erasures {}   RS fixed {}",
                    self.status.erasure_count, self.status.error_count
                ))
                .font(FontId::monospace(11.0)),
            );
            let era_pct = (self.status.erasure_count as f32 * 0.1).clamp(0.0, 1.0);
            bar(ui, era_pct, Color32::from_rgb(255, 171, 64));

            ui.add_space(6.0);

            led_row(ui, "Δ LOCK", self.status.delta_locked, {
                if self.status.delta_locked {
                    ("acquired", Color32::from_rgb(0, 230, 118))
                } else {
                    ("searching", Color32::from_rgb(255, 171, 64))
                }
            });
            let frame_active = self.status.state != FrameState::Idle;
            led_row(
                ui,
                "FRAME",
                frame_active,
                (
                    self.status.state.label(),
                    if frame_active { ACCENT } else { DIM },
                ),
            );
            led_row(
                ui,
                "AUDIO",
                self.is_rx,
                if self.is_rx {
                    ("active", ACCENT)
                } else {
                    ("stopped", DIM)
                },
            );
        });
    }

    fn quick_settings(&mut self, ui: &mut egui::Ui) {
        ui.group(|ui| {
            ui.label(RichText::new("QUICK SETTINGS").color(ACCENT).strong());
            ui.add_space(4.0);

            let mut changed = false;
            // Gain/level changes are applied live (atomic gain, modulator-only
            // rebuild) — they must not tear down the RX path like `changed`.
            let mut audio_changed = false;

            ui.horizontal(|ui| {
                ui.label("SF");
                let m = &mut self.cfg.config.modem;
                egui::ComboBox::from_id_salt("qs_sf")
                    .selected_text(format!("SF-{}", m.spreading_factor))
                    .show_ui(ui, |ui| {
                        for s in 7..=12u32 {
                            if ui
                                .selectable_value(&mut m.spreading_factor, s, format!("SF-{s}"))
                                .changed()
                            {
                                changed = true;
                            }
                        }
                    });
            });

            ui.horizontal(|ui| {
                ui.label("Rate");
                let f = &mut self.cfg.config.fec;
                egui::ComboBox::from_id_salt("qs_rate")
                    .selected_text(&f.rs_code_rate)
                    .show_ui(ui, |ui| {
                        for r in ["1/2", "1/3", "2/3"] {
                            if ui
                                .selectable_value(&mut f.rs_code_rate, r.to_string(), r)
                                .changed()
                            {
                                changed = true;
                            }
                        }
                    });
            });

            // Bandwidth + centre frequency, anchored inside the 300–3000 Hz
            // bandpass (the band placement is re-derived via set_band).
            ui.horizontal(|ui| {
                ui.label("BW");
                let m = &mut self.cfg.config.modem;
                let cur_bw = m.bandwidth();
                egui::ComboBox::from_id_salt("qs_bw")
                    .selected_text(format!("{cur_bw} Hz"))
                    .show_ui(ui, |ui| {
                        for &bw in crate::config::BW_PRESETS.iter() {
                            if ui
                                .selectable_label(cur_bw == bw, format!("{bw} Hz"))
                                .clicked()
                                && cur_bw != bw
                            {
                                let center = m.center_freq();
                                m.set_band(center, bw);
                                changed = true;
                            }
                        }
                    });
            });

            ui.horizontal(|ui| {
                ui.label("Center");
                ui.spacing_mut().slider_width = 110.0;
                let m = &mut self.cfg.config.modem;
                let bw = m.bandwidth();
                let half = bw / 2;
                let lo = crate::config::BAND_MIN_HZ + half;
                let hi = crate::config::BAND_MAX_HZ - (bw - half);
                let mut center = m.center_freq();
                if hi > lo {
                    if ui
                        .add(egui::Slider::new(&mut center, lo..=hi).suffix(" Hz"))
                        .changed()
                    {
                        m.set_band(center, bw);
                        changed = true;
                    }
                } else {
                    // Bandwidth fills the whole bandpass — centre is fixed.
                    ui.label(RichText::new(format!("{center} Hz (fixed)")).color(DIM))
                        .on_hover_text(
                            "The bandwidth fills the whole 300–3000 Hz passband, \
                             so the centre cannot move. Pick a narrower BW to \
                             place the band.",
                        );
                }
            });

            if ui
                .checkbox(&mut self.cfg.config.modem.auto_rx, "Auto RX (SF/BW)")
                .on_hover_text(
                    "Scan incoming preambles for any SF and bandwidth preset; \
                     the coding rate is read from the frame header.",
                )
                .changed()
            {
                changed = true;
            }

            if ui
                .checkbox(&mut self.cfg.config.modem.turbo, "Turbo TX (2× rate)")
                .on_hover_text(
                    "Carry an independent symbol on the down chirp instead of a \
                     redundant copy — double the data rate, a few dB less robust. \
                     Receivers read the mode from the frame header automatically.",
                )
                .changed()
            {
                changed = true;
            }

            ui.horizontal(|ui| {
                ui.label("PTT");
                let p = &mut self.cfg.config.ptt;
                egui::ComboBox::from_id_salt("qs_ptt")
                    .selected_text(&p.method)
                    .show_ui(ui, |ui| {
                        for m in ["RTS", "DTR", "VOX", "flrig", "None"] {
                            if ui
                                .selectable_value(&mut p.method, m.to_string(), m)
                                .changed()
                            {
                                changed = true;
                            }
                        }
                    });
            });

            ui.horizontal(|ui| {
                ui.label("In gain");
                ui.spacing_mut().slider_width = 100.0;
                let a = &mut self.cfg.config.audio;
                if ui
                    .add(egui::Slider::new(&mut a.input_gain_db, -20.0..=20.0).suffix(" dB"))
                    .changed()
                {
                    audio_changed = true;
                }
            });

            ui.horizontal(|ui| {
                ui.label("Out lvl");
                ui.spacing_mut().slider_width = 100.0;
                let a = &mut self.cfg.config.audio;
                if ui
                    .add(egui::Slider::new(&mut a.output_level, 0.0..=1.0))
                    .changed()
                {
                    audio_changed = true;
                }
            });

            ui.add_space(4.0);

            let rx_label = if self.is_rx {
                "⏹  Stop RX"
            } else {
                "▶  Start RX"
            };
            if ui
                .add_sized([200.0, 26.0], egui::Button::new(rx_label))
                .clicked()
            {
                self.toggle_rx();
            }
            if ui
                .add_sized([200.0, 26.0], egui::Button::new("⚙  Settings…"))
                .clicked()
            {
                self.open_settings();
            }

            if changed {
                let cfg = self.cfg.config.clone();
                self.cfg.save();
                self.pkt.rebuild(cfg.clone());
                self.audio.update_config(cfg);
            } else if audio_changed {
                let cfg = self.cfg.config.clone();
                self.cfg.save();
                self.audio.update_config(cfg.clone()); // live atomic gain
                self.pkt.set_output_level(cfg.audio.output_level);
            }
        });
    }

    /// Noise-aware suggestions: quietest band placement (from the whitener's
    /// learned floor) and SF/rate/turbo advice from the last decoded frame.
    fn advisor_panel(&mut self, ui: &mut egui::Ui) {
        ui.group(|ui| {
            ui.label(RichText::new("ADVISOR").color(ACCENT).strong());
            ui.add_space(4.0);

            // Fixed 230 px panel: every line must wrap rather than widen the
            // group past the window edge.
            let wrapped = |ui: &mut egui::Ui, text: RichText| {
                ui.add(egui::Label::new(text).wrap());
            };

            match self.pkt.noise_report() {
                Some(r) => {
                    let moved = r.quietest_center.abs_diff(r.current_center) >= 50;
                    if moved && r.advantage_db >= 2.0 {
                        wrapped(
                            ui,
                            RichText::new(format!(
                                "Quieter slot: {} Hz (−{:.0} dB)",
                                r.quietest_center, r.advantage_db
                            ))
                            .font(FontId::monospace(10.0)),
                        );
                        if ui
                            .button(format!("Move center to {} Hz", r.quietest_center))
                            .on_hover_text(
                                "Re-centre the modem band on the quietest part of \
                                 the passband (both stations must use the same \
                                 centre frequency).",
                            )
                            .clicked()
                        {
                            let bw = self.cfg.config.modem.bandwidth();
                            self.cfg.config.modem.set_band(r.quietest_center, bw);
                            let cfg = self.cfg.config.clone();
                            self.cfg.save();
                            self.pkt.rebuild(cfg.clone());
                            self.audio.update_config(cfg);
                        }
                    } else {
                        wrapped(
                            ui,
                            RichText::new("Band sits in the quietest slot")
                                .color(DIM)
                                .font(FontId::monospace(10.0)),
                        );
                    }
                }
                None => {
                    wrapped(
                        ui,
                        RichText::new("Start RX to measure the noise floor")
                            .color(DIM)
                            .font(FontId::monospace(10.0)),
                    );
                }
            }

            if let Some(advice) = &self.last_advice {
                ui.add_space(2.0);
                wrapped(
                    ui,
                    RichText::new(advice.clone()).font(FontId::monospace(10.0)),
                );
            }
        });
    }
}

// ── small widgets ────────────────────────────────────────────────────────────

fn bar(ui: &mut egui::Ui, frac: f32, color: Color32) {
    let (rect, _) =
        ui.allocate_exact_size(egui::vec2(ui.available_width(), 6.0), egui::Sense::hover());
    let painter = ui.painter();
    painter.rect_filled(rect, 3.0, Color32::from_rgb(26, 37, 53));
    let mut fill = rect;
    fill.set_width(rect.width() * frac.clamp(0.0, 1.0));
    painter.rect_filled(fill, 3.0, color);
}

fn led_row(ui: &mut egui::Ui, title: &str, _on: bool, val: (&str, Color32)) {
    ui.horizontal(|ui| {
        ui.label(RichText::new("●").color(val.1));
        ui.label(RichText::new(title).color(DIM).font(FontId::monospace(9.0)));
        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
            ui.label(RichText::new(val.0).font(FontId::monospace(9.0)));
        });
    });
}

/// Estimated on-air duration (seconds) for transmitting `text` with the
/// current modem/FEC parameters. Mirrors the framing in `PacketEngine`:
/// L1 + sync + L2 preambles, the repeated header, and the RS-encoded data.
fn estimate_tx_secs(cfg: &crate::config::AppConfig, text: &str) -> f64 {
    let len = text.trim().len();
    if len == 0 {
        return 0.0;
    }
    let m = &cfg.modem;
    let sf = m.spreading_factor as usize;
    let nsym = crate::dsp::nsym_for_rate(crate::dsp::rate_code(&cfg.fec.rs_code_rate));
    let chunk = 255 - nsym;
    let enc_len = len + len.div_ceil(chunk) * nsym;
    let hdr_syms = crate::dsp::header_symbol_count(m.spreading_factor) * crate::dsp::HEADER_REPEATS;
    let data_syms = (enc_len * 8).div_ceil(sf);
    // Turbo blocks carry two payload symbols each (up + down stream).
    let data_blocks = if m.turbo {
        data_syms.div_ceil(2)
    } else {
        data_syms
    };
    let total_syms = (m.l1_preamble_symbols + m.sync_symbols + m.l2_preamble_symbols) as usize
        + hdr_syms
        + data_blocks;
    total_syms as f64 * m.n_chips() as f64 / m.bandwidth() as f64
}

/// Current UTC time as HH:MM:SS (hams log in UTC; avoids a chrono dependency).
fn utc_hms() -> String {
    let secs = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0);
    format!(
        "{:02}:{:02}:{:02}",
        (secs / 3600) % 24,
        (secs / 60) % 60,
        secs % 60
    )
}

/// Open the log file in the platform default text viewer.
fn open_log(path: &str) {
    #[cfg(target_os = "windows")]
    let _ = std::process::Command::new("cmd")
        .args(["/C", "start", "", path])
        .spawn();
    #[cfg(target_os = "macos")]
    let _ = std::process::Command::new("open").arg(path).spawn();
    #[cfg(all(unix, not(target_os = "macos")))]
    let _ = std::process::Command::new("xdg-open").arg(path).spawn();
}
