//! eframe / egui GUI for smtp-test-tool.
//!
//! * Single static binary, no external runtime needed.
//! * Auto-follows OS dark/light via the `dark-light` crate.
//! * AccessKit screen-reader integration is enabled via the eframe feature.
//! * All status conveyed in text too - colour is never the only signal.

#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]

use eframe::egui;
use smtp_test_tool::config::{default_save_path, discover_config_path, Config};
use smtp_test_tool::diagnostics::smtp_hints_for;
use smtp_test_tool::fonts;
use smtp_test_tool::i18n::{self, t, t_with};
use smtp_test_tool::keystore::{default_keystore, Keystore};
use smtp_test_tool::locale as os_locale;
use smtp_test_tool::providers::{self, Provider};
use smtp_test_tool::runner::{TestOutcome, TestResults};
use smtp_test_tool::theme::{detect as detect_appearance, Appearance, ThemeChoice};
use smtp_test_tool::tls::Security;
use smtp_test_tool::{outlook_defaults, run_tests, Profile};
use std::path::PathBuf;
use std::sync::mpsc::{Receiver, Sender};
use std::sync::{Arc, Mutex};
use std::thread;
use tracing::level_filters::LevelFilter;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::Layer;

// -----------------------------------------------------------------------
// Log capture - a custom tracing Layer that pushes formatted records
// into a Vec the GUI displays.  Thread-safe.
// -----------------------------------------------------------------------
#[derive(Clone, Copy, Debug)]
enum LogLevel {
    Trace,
    Debug,
    Info,
    Warn,
    Error,
}

#[derive(Clone, Debug)]
struct LogLine {
    level: LogLevel,
    text: String,
}

#[derive(Default)]
struct LogSink {
    lines: Mutex<Vec<LogLine>>,
}

impl LogSink {
    fn push(&self, level: LogLevel, text: String) {
        if let Ok(mut g) = self.lines.lock() {
            // Cap memory at 5000 lines so a long --wire run stays responsive.
            if g.len() > 5000 {
                g.drain(..1000);
            }
            g.push(LogLine { level, text });
        }
    }
    fn drain_into(&self, dst: &mut Vec<LogLine>) {
        if let Ok(mut g) = self.lines.lock() {
            dst.extend(g.drain(..));
        }
    }
}

struct GuiLayer {
    sink: Arc<LogSink>,
}

impl<S> Layer<S> for GuiLayer
where
    S: tracing::Subscriber,
{
    fn on_event(
        &self,
        event: &tracing::Event<'_>,
        _ctx: tracing_subscriber::layer::Context<'_, S>,
    ) {
        let mut visitor = FieldFmt::default();
        event.record(&mut visitor);
        let lvl = match *event.metadata().level() {
            tracing::Level::TRACE => LogLevel::Trace,
            tracing::Level::DEBUG => LogLevel::Debug,
            tracing::Level::INFO => LogLevel::Info,
            tracing::Level::WARN => LogLevel::Warn,
            tracing::Level::ERROR => LogLevel::Error,
        };
        self.sink.push(lvl, visitor.message);
    }
}

#[derive(Default)]
struct FieldFmt {
    message: String,
}

impl tracing::field::Visit for FieldFmt {
    fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
        if field.name() == "message" {
            self.message = format!("{value:?}");
            // strip the surrounding quotes Debug puts on strings
            if self.message.starts_with('"') && self.message.ends_with('"') {
                self.message = self.message[1..self.message.len() - 1].to_string();
            }
        } else {
            self.message
                .push_str(&format!(" {}={value:?}", field.name()));
        }
    }
    fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
        if field.name() == "message" {
            self.message = value.to_string();
        } else {
            self.message.push_str(&format!(" {}={value}", field.name()));
        }
    }
}

// -----------------------------------------------------------------------
// Application state
// -----------------------------------------------------------------------
struct App {
    cfg_path: Option<PathBuf>,
    cfg: Config,
    profile_name: String,
    profile: Profile,
    log_sink: Arc<LogSink>,
    log_buf: Vec<LogLine>,
    show_pwd: bool,
    busy: bool,
    /// Native credential store handle.  `Box<dyn>` so unit tests of the
    /// surrounding logic could swap in a mock; the trait is `Send+Sync`
    /// so it's safe to share across the background test thread (we
    /// don't currently, but the type promises it).
    keystore: Box<dyn Keystore>,
    /// Set to true when the current `profile.password` came out of the
    /// OS keychain (auto-loaded on startup).  Surfaces a small textual
    /// hint next to the credentials block so the user knows the
    /// password they see was restored, not just typed.
    password_from_keychain: bool,
    result_rx: Option<Receiver<TestResults>>,
    last_results: TestResults,
    to_csv: String,
    cc_csv: String,
    bcc_csv: String,
    tab: Tab,
    /// Multi-line buffer behind the *Diagnose* tab.  Persisted across
    /// frames but not across launches (would be a privacy hazard - real
    /// bounce bodies often contain headers).
    /// Domain typed into the DNS tab; persisted with the rest of the
    /// GUI state via eframe's storage.
    #[cfg(feature = "dns")]
    dns_domain: String,
    /// Last completed DNS audit + its hints.  None until the user has
    /// clicked "Audit" at least once.
    #[cfg(feature = "dns")]
    dns_report: Option<smtp_test_tool::dns::DnsReport>,
    #[cfg(feature = "dns")]
    dns_hints: Vec<smtp_test_tool::dns::DnsHint>,
    /// Background-job state for the DNS audit so the GUI does not
    /// block on slow resolvers.
    #[cfg(feature = "dns")]
    dns_running: bool,
    #[cfg(feature = "dns")]
    dns_rx: Option<std::sync::mpsc::Receiver<DnsJobResult>>,

    /// Background-job state for the M365 OAuth device-code flow.
    #[cfg(feature = "oauth")]
    oauth_login_running: bool,
    #[cfg(feature = "oauth")]
    oauth_login_status: String,
    #[cfg(feature = "oauth")]
    oauth_login_rx: Option<std::sync::mpsc::Receiver<OauthJobMsg>>,

    diagnose_input: String,
    /// Last result of running `smtp_hints_for` on `diagnose_input`,
    /// rendered as a bullet list under the *Analyse* button.
    diagnose_hints: Vec<String>,
    /// What the OS reported at startup; cached so 'Follow OS' does not
    /// re-shell-out to `defaults` / `gsettings` every frame.
    os_appearance: Appearance,
    /// OS-reported language code (e.g. `Some("nl")`) at startup;
    /// cached because sys-locale touches the registry / a subprocess
    /// on first call.  Drives the Language combobox on the Advanced
    /// tab: the picker offers en + this (if shipped + not en) only.
    os_locale_code: Option<String>,
    /// Last appearance we actually applied via `Context::set_visuals`;
    /// lets us re-apply only on real change.
    applied_appearance: Appearance,
}

#[derive(PartialEq, Copy, Clone)]
enum Tab {
    Servers,
    Send,
    Tls,
    Advanced,
    /// Paste a bounce message body and get IT-actionable hints via the
    /// same `smtp_hints_for` translator that powers the live tests.
    Diagnose,
    /// Run an MX / SPF / DMARC audit against a domain (gated by the
    /// `dns` feature; tab is hidden in CLI-only builds).
    #[cfg(feature = "dns")]
    Dns,
}

impl App {
    fn new(sink: Arc<LogSink>, cc: &eframe::CreationContext<'_>) -> Self {
        // Load config FIRST so we can honour the user's theme choice
        // (rule #4: dark + light, with manual override).
        let cfg_path = discover_config_path();
        // Surface any parse error so the user knows their config was
        // ignored - silent fallback used to mask real bugs.
        let cfg = cfg_path
            .as_ref()
            .and_then(|p| match Config::load(p) {
                Ok(c) => {
                    tracing::info!("loaded config {}", p.display());
                    Some(c)
                }
                Err(e) => {
                    tracing::warn!("failed to load config {}: {:#}", p.display(), e);
                    None
                }
            })
            .unwrap_or_else(|| Config {
                active: "default".into(),
                profiles: [("default".into(), outlook_defaults())]
                    .into_iter()
                    .collect(),
            });
        let profile_name = cfg.active.clone();
        let mut profile = cfg
            .profile(&profile_name)
            .cloned()
            .unwrap_or_else(outlook_defaults);

        // Best-effort keychain auto-load: if a user is known and an
        // entry exists in the OS-native store, populate the password
        // field.  Silent on a miss; logs a warning only on real backend
        // failure (e.g. no Secret Service daemon on a headless Linux).
        let keystore = default_keystore();
        let mut password_from_keychain = false;
        if profile.password.is_none() {
            if let Some(user) = profile.user.as_deref() {
                match keystore.load(user) {
                    Ok(Some(pwd)) => {
                        tracing::info!("loaded password for {user} from OS keychain");
                        profile.password = Some(pwd);
                        password_from_keychain = true;
                    }
                    Ok(None) => {}
                    Err(e) => {
                        tracing::warn!("keychain load failed for {user}: {e:#}");
                    }
                }
            }
        }

        // ---- locale: explicit profile.locale overrides OS detection;
        // unsupported codes fall back to base inside set_locale().  Must
        // happen BEFORE any UI render so the first paint already shows
        // translated strings.
        let os_locale_code = os_locale::detect();
        let active_locale = match profile.locale.as_deref() {
            Some(explicit) if i18n::is_supported(explicit) => explicit.to_string(),
            _ => match &os_locale_code {
                Some(code) if i18n::is_supported(code) => code.clone(),
                _ => i18n::BASE.to_string(),
            },
        };
        i18n::set_locale(&active_locale);

        // Augment egui's font tables with OS-installed fonts for the
        // active locale's script.  No-op for Latin / Cyrillic / Greek
        // (bundled fonts cover those); logs a warning for CJK / Arabic /
        // Devanagari / Thai / etc. when no OS font is installed.
        if let Err(e) = fonts::load_for_locale(&cc.egui_ctx, &active_locale) {
            tracing::warn!("font loader returned error: {e}");
        }

        // Detect OS appearance once (cached), then resolve through the
        // user's stored ThemeChoice (Auto / Dark / Light).
        let os_appearance = detect_appearance();
        let initial_choice = ThemeChoice::from_config_str(&profile.theme);
        let initial = initial_choice.resolve(os_appearance);
        cc.egui_ctx.set_visuals(visuals_for(initial));
        if os_appearance == Appearance::Unknown && initial_choice == ThemeChoice::Auto {
            tracing::info!("OS did not advertise a colour scheme; defaulting to dark");
        }

        let to_csv = profile.to.join(", ");
        let cc_csv = profile.cc.join(", ");
        let bcc_csv = profile.bcc.join(", ");

        Self {
            cfg_path,
            cfg,
            profile_name,
            profile,
            log_sink: sink,
            log_buf: Vec::new(),
            show_pwd: false,
            busy: false,
            result_rx: None,
            last_results: TestResults::default(),
            to_csv,
            cc_csv,
            bcc_csv,
            tab: Tab::Servers,
            diagnose_input: String::new(),
            diagnose_hints: Vec::new(),
            #[cfg(feature = "dns")]
            dns_domain: String::new(),
            #[cfg(feature = "dns")]
            dns_report: None,
            #[cfg(feature = "dns")]
            dns_hints: Vec::new(),
            #[cfg(feature = "dns")]
            dns_running: false,
            #[cfg(feature = "dns")]
            dns_rx: None,
            #[cfg(feature = "oauth")]
            oauth_login_running: false,
            #[cfg(feature = "oauth")]
            oauth_login_status: String::new(),
            #[cfg(feature = "oauth")]
            oauth_login_rx: None,
            os_appearance,
            os_locale_code,
            applied_appearance: initial,
            keystore,
            password_from_keychain,
        }
    }

    /// Re-apply the user's current theme preference if it produces a
    /// different concrete appearance than what we last applied.  Called
    /// once per frame; cheap no-op when nothing changed.
    fn refresh_theme(&mut self, ctx: &egui::Context) {
        let choice = ThemeChoice::from_config_str(&self.profile.theme);
        let target = choice.resolve(self.os_appearance);
        if target != self.applied_appearance {
            ctx.set_visuals(visuals_for(target));
            self.applied_appearance = target;
            tracing::info!(
                "theme: now {} (choice={}, os={:?})",
                target_label(target),
                choice.as_str(),
                self.os_appearance
            );
        }
    }

    /// Overwrite the SMTP / IMAP / POP3 host, port, and security fields
    /// on the active profile from a curated provider preset.  Leaves
    /// every other field (credentials, profile name, theme, ...) alone.
    /// Providers without POP3 (iCloud, Proton Bridge) disable the POP3
    /// test rather than leaving stale data behind.
    fn apply_provider(&mut self, p: &Provider) {
        self.profile.smtp_host = p.smtp.host.into();
        self.profile.smtp_port = p.smtp.port;
        self.profile.smtp_security = p.smtp.security;
        self.profile.imap_host = p.imap.host.into();
        self.profile.imap_port = p.imap.port;
        self.profile.imap_security = p.imap.security;
        match p.pop {
            Some(pop) => {
                self.profile.pop_host = pop.host.into();
                self.profile.pop_port = pop.port;
                self.profile.pop_security = pop.security;
                // Leave pop_enabled untouched; user may want POP off
                // even on providers that support it.
            }
            None => {
                self.profile.pop_enabled = false;
            }
        }
        tracing::info!("applied provider preset: {}", p.name);
    }

    fn run_tests_async(&mut self) {
        // Sync CSV fields back into profile.
        self.profile.to = csv_to_vec(&self.to_csv);
        self.profile.cc = csv_to_vec(&self.cc_csv);
        self.profile.bcc = csv_to_vec(&self.bcc_csv);

        let (tx, rx): (Sender<TestResults>, Receiver<TestResults>) = std::sync::mpsc::channel();
        self.result_rx = Some(rx);
        self.busy = true;
        let profile = self.profile.clone();
        thread::spawn(move || {
            let r = run_tests(&profile);
            let _ = tx.send(r);
        });
    }

    fn save_config(&mut self) -> anyhow::Result<()> {
        // sync CSVs first
        self.profile.to = csv_to_vec(&self.to_csv);
        self.profile.cc = csv_to_vec(&self.cc_csv);
        self.profile.bcc = csv_to_vec(&self.bcc_csv);

        // No credential handling here: Profile.password and .oauth_token
        // are `#[serde(skip)]` so they are dropped at serialise time
        // unconditionally.  See AGENTS.md rule #8.
        self.cfg
            .upsert_profile(&self.profile_name, self.profile.clone());
        self.cfg.active = self.profile_name.clone();
        let target = self.cfg_path.clone().unwrap_or_else(default_save_path);
        self.cfg.save(&target)?;
        self.cfg_path = Some(target);
        Ok(())
    }
}

fn csv_to_vec(s: &str) -> Vec<String> {
    s.split(',')
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
        .collect()
}

impl eframe::App for App {
    // egui 0.34: `ui()` is the required entry; `update(ctx, frame)` is
    // deprecated and provided as a no-op default by the trait.
    fn ui(&mut self, root_ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
        let ctx = root_ui.ctx().clone();
        // React to a theme change made on the Advanced tab the previous
        // frame.  No-op when the chosen theme already matches.
        self.refresh_theme(&ctx);
        // Drain log lines from the sink.
        self.log_sink.drain_into(&mut self.log_buf);
        if self.log_buf.len() > 5000 {
            let drop = self.log_buf.len() - 5000;
            self.log_buf.drain(..drop);
        }

        // Poll background test thread.
        if let Some(rx) = &self.result_rx {
            if let Ok(r) = rx.try_recv() {
                self.last_results = r;
                self.busy = false;
                self.result_rx = None;
            }
        }
        if self.busy {
            ctx.request_repaint_after(std::time::Duration::from_millis(150));
        }

        // ----- top bar ------------------------------------------------
        egui::Panel::top("top").show_inside(root_ui, |ui| {
            ui.horizontal(|ui| {
                ui.label(t("ui.topbar.profile_label"));
                let names = self.cfg.profile_names();
                egui::ComboBox::from_id_salt("profile")
                    .selected_text(&self.profile_name)
                    .show_ui(ui, |ui| {
                        for n in &names {
                            if ui.selectable_label(&self.profile_name == n, n).clicked() {
                                self.profile_name = n.clone();
                                if let Some(p) = self.cfg.profile(n) {
                                    self.profile = p.clone();
                                    self.to_csv = self.profile.to.join(", ");
                                    self.cc_csv = self.profile.cc.join(", ");
                                    self.bcc_csv = self.profile.bcc.join(", ");
                                }
                            }
                        }
                    });
                if ui
                    .button(t("ui.topbar.save_config"))
                    .on_hover_text(t("ui.topbar.save_config_tooltip"))
                    .clicked()
                {
                    if let Err(e) = self.save_config() {
                        tracing::error!("save failed: {e:#}");
                    } else {
                        tracing::info!(
                            "Saved profile [{}] to {}",
                            self.profile_name,
                            self.cfg_path
                                .as_ref()
                                .map(|p| p.display().to_string())
                                .unwrap_or_default()
                        );
                    }
                }
                // Provider preset menu: applies one curated set of
                // host/port/security values to all three protocol
                // blocks below.  Other fields (credentials, etc.) are
                // left alone.
                let mut chosen: Option<&'static Provider> = None;
                // "..." rather than a unicode arrow because the default
                // egui font ships without U+25BE (small down-pointing
                // triangle) and falls back to a tofu glyph.
                ui.menu_button(t("ui.topbar.provider_preset"), |ui| {
                    for p in providers::PROVIDERS {
                        let mut label = p.name.to_string();
                        if p.pop.is_none() {
                            label.push_str("  (no POP3)");
                        }
                        if ui.button(label).clicked() {
                            chosen = Some(p);
                            ui.close();
                        }
                    }
                });
                if let Some(p) = chosen {
                    self.apply_provider(p);
                    if let Some(note) = p.note {
                        tracing::info!("note: {note}");
                    }
                }
                ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
                    ui.label(
                        self.cfg_path
                            .as_ref()
                            .map(|p| format!("{}", p.display()))
                            .unwrap_or_else(|| t("ui.topbar.config_path_none")),
                    );
                });
            });
        });

        // ----- bottom: action bar + summary ---------------------------
        egui::Panel::bottom("bottom").show_inside(root_ui, |ui| {
            ui.horizontal(|ui| {
                let run = ui.add_enabled(
                    !self.busy,
                    egui::Button::new(if self.busy {
                        t("ui.actions.running")
                    } else {
                        t("ui.actions.run_test")
                    }),
                );
                if run.clicked() {
                    self.run_tests_async();
                }
                ui.separator();
                outcome_chip(ui, "SMTP", self.last_results.smtp);
                outcome_chip(ui, "IMAP", self.last_results.imap);
                outcome_chip(ui, "POP3", self.last_results.pop3);
                ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
                    if ui.button(t("ui.actions.clear_log")).clicked() {
                        self.log_buf.clear();
                    }
                });
            });
        });

        // ----- log panel along the bottom (own region, deterministic
        // height) so the tab content above never fights it for space.
        // egui 0.34: BottomPanel can have a fixed default + be resizable
        // by the user via the splitter at the top edge.
        egui::Panel::bottom("log")
            .resizable(true)
            .default_size(260.0)
            .min_size(120.0)
            .show_inside(root_ui, |ui| {
                ui.label(egui::RichText::new(t("ui.log.heading")).strong());
                egui::ScrollArea::vertical()
                    .stick_to_bottom(true)
                    .auto_shrink([false, false])
                    .show(ui, |ui| {
                        for line in &self.log_buf {
                            let (color, tag) = level_style(line.level, ui.visuals().dark_mode);
                            ui.horizontal_wrapped(|ui| {
                                ui.label(egui::RichText::new(tag).color(color).monospace());
                                ui.label(egui::RichText::new(&line.text).monospace());
                            });
                        }
                    });
            });

        // ----- main: tabs + tab content fills the remaining space ----
        // CentralPanel must be added AFTER all other panels per egui 0.34.
        egui::CentralPanel::default().show_inside(root_ui, |ui| {
            ui.horizontal(|ui| {
                ui.selectable_value(&mut self.tab, Tab::Servers, t("ui.tab.servers"));
                ui.selectable_value(&mut self.tab, Tab::Send, t("ui.tab.send_mail"));
                ui.selectable_value(&mut self.tab, Tab::Tls, t("ui.tab.tls_auth"));
                ui.selectable_value(&mut self.tab, Tab::Advanced, t("ui.tab.advanced"));
                ui.selectable_value(&mut self.tab, Tab::Diagnose, t("ui.tab.diagnose"));
                #[cfg(feature = "dns")]
                ui.selectable_value(&mut self.tab, Tab::Dns, t("ui.tab.dns"));
            });
            ui.separator();

            egui::ScrollArea::vertical()
                .auto_shrink([false, false])
                .show(ui, |ui| match self.tab {
                    Tab::Servers => tab_servers(ui, self),
                    Tab::Send => tab_send(ui, self),
                    Tab::Tls => tab_tls(ui, self),
                    Tab::Advanced => tab_advanced(ui, self),
                    Tab::Diagnose => tab_diagnose(ui, self),
                    #[cfg(feature = "dns")]
                    Tab::Dns => tab_dns(ui, self),
                });
        });
    }
}

// ---------- helpers -------------------------------------------------------

fn visuals_for(a: Appearance) -> egui::Visuals {
    match a {
        Appearance::Light => egui::Visuals::light(),
        // Both Dark and (defensively) Unknown map to dark, matching the
        // documented fallback in ThemeChoice::resolve.
        Appearance::Dark | Appearance::Unknown => egui::Visuals::dark(),
    }
}

fn target_label(a: Appearance) -> &'static str {
    match a {
        Appearance::Dark => "dark",
        Appearance::Light => "light",
        Appearance::Unknown => "dark (fallback)",
    }
}

/// Show the resolved OS hint next to 'Follow OS' so the user knows
/// what Auto currently maps to.
fn theme_label(choice: ThemeChoice, os: Appearance) -> String {
    match choice {
        ThemeChoice::Auto => t_with("ui.advanced.theme_follow_os", &[("hint", target_label(os))]),
        ThemeChoice::Dark => t("ui.advanced.theme_dark"),
        ThemeChoice::Light => t("ui.advanced.theme_light"),
    }
}

fn outcome_chip(ui: &mut egui::Ui, name: &str, o: Option<TestOutcome>) {
    // `name` is the protocol token used both as a stable display label
    // and as the suffix in localisation keys ('smtp', 'imap', 'pop3').
    let key_suffix = name.to_ascii_lowercase();
    let (txt, col) = match o {
        Some(TestOutcome::Pass) => (
            t(&format!("ui.status.{key_suffix}_pass")),
            egui::Color32::from_rgb(0x0e, 0x7c, 0x0e),
        ),
        Some(TestOutcome::Fail) => (
            t(&format!("ui.status.{key_suffix}_fail")),
            egui::Color32::from_rgb(0xa3, 0x00, 0x00),
        ),
        Some(TestOutcome::Skipped) => (
            t(&format!("ui.status.{key_suffix}_skip")),
            egui::Color32::GRAY,
        ),
        None => (
            t(&format!("ui.status.{key_suffix}_idle")),
            egui::Color32::GRAY,
        ),
    };
    ui.label(egui::RichText::new(txt).color(col).monospace());
}

fn level_style(lvl: LogLevel, dark: bool) -> (egui::Color32, &'static str) {
    if dark {
        match lvl {
            LogLevel::Trace => (egui::Color32::from_rgb(0xa0, 0xa0, 0xa0), "[TRACE]"),
            LogLevel::Debug => (egui::Color32::from_rgb(0xa0, 0xa0, 0xa0), "[DEBUG]"),
            LogLevel::Info => (egui::Color32::from_rgb(0xf0, 0xf0, 0xf0), "[INFO ]"),
            LogLevel::Warn => (egui::Color32::from_rgb(0xff, 0xd1, 0x66), "[WARN ]"),
            LogLevel::Error => (egui::Color32::from_rgb(0xff, 0x6b, 0x6b), "[ERROR]"),
        }
    } else {
        match lvl {
            LogLevel::Trace => (egui::Color32::from_rgb(0x55, 0x55, 0x55), "[TRACE]"),
            LogLevel::Debug => (egui::Color32::from_rgb(0x55, 0x55, 0x55), "[DEBUG]"),
            LogLevel::Info => (egui::Color32::from_rgb(0x11, 0x11, 0x11), "[INFO ]"),
            LogLevel::Warn => (egui::Color32::from_rgb(0x8a, 0x4b, 0x00), "[WARN ]"),
            LogLevel::Error => (egui::Color32::from_rgb(0xa3, 0x00, 0x00), "[ERROR]"),
        }
    }
}

// ---------- tabs ----------------------------------------------------------
fn tab_servers(ui: &mut egui::Ui, a: &mut App) {
    // Credentials: use ui.horizontal() rows (like proto_block) rather than
    // a Grid, because Grid sizes cells by intrinsic content and never
    // grows text-edits.  We left-align labels to a fixed width column
    // (LABEL_W) so they line up vertically, then hand the remainder of
    // the row to the entry.
    const LABEL_W: f32 = 100.0;
    const SHOW_W: f32 = 70.0; // approx "☑ Show" checkbox
    const HINT_W: f32 = 160.0; // approx "(XOAUTH2, optional)"

    ui.horizontal(|ui| {
        ui.add_sized([LABEL_W, 0.0], egui::Label::new(t("ui.servers.username")));
        let mut u = a.profile.user.clone().unwrap_or_default();
        let resp = ui.add_sized(
            [ui.available_width(), 0.0],
            egui::TextEdit::singleline(&mut u),
        );
        if resp.changed() {
            a.profile.user = Some(u).filter(|s| !s.is_empty());
        }
    });
    ui.horizontal(|ui| {
        ui.add_sized([LABEL_W, 0.0], egui::Label::new(t("ui.servers.password")));
        let mut pwd = a.profile.password.clone().unwrap_or_default();
        let entry_w = (ui.available_width() - SHOW_W).max(80.0);
        let resp = ui.add_sized(
            [entry_w, 0.0],
            egui::TextEdit::singleline(&mut pwd).password(!a.show_pwd),
        );
        if resp.changed() {
            a.profile.password = Some(pwd).filter(|s| !s.is_empty());
        }
        ui.checkbox(&mut a.show_pwd, t("ui.servers.show"));
    });
    ui.horizontal(|ui| {
        ui.add_sized(
            [LABEL_W, 0.0],
            egui::Label::new(t("ui.servers.oauth_token")),
        );
        let mut token = a.profile.oauth_token.clone().unwrap_or_default();
        let entry_w = (ui.available_width() - HINT_W).max(80.0);
        let resp = ui.add_sized(
            [entry_w, 0.0],
            egui::TextEdit::singleline(&mut token).password(true),
        );
        if resp.changed() {
            a.profile.oauth_token = Some(token).filter(|s| !s.is_empty());
        }
        ui.add_sized(
            [HINT_W, 0.0],
            egui::Label::new(egui::RichText::new(t("ui.servers.oauth_hint")).weak()),
        );
    });

    // OS keychain controls (Windows Credential Manager / macOS Keychain /
    // Linux Secret Service).  Buttons are explicit (no auto-save) so the
    // user always knows when a secret is being persisted.  Per AGENTS.md
    // rule #8 this is the ONE approved store outside process memory.
    ui.horizontal(|ui| {
        ui.add_sized([LABEL_W, 0.0], egui::Label::new(""));
        let user_set = a
            .profile
            .user
            .as_deref()
            .map(|s| !s.is_empty())
            .unwrap_or(false);
        let pwd_set = a
            .profile
            .password
            .as_deref()
            .map(|s| !s.is_empty())
            .unwrap_or(false);

        if ui
            .add_enabled(
                user_set && pwd_set,
                egui::Button::new(t("ui.servers.save_to_keychain")),
            )
            .on_hover_text(t("ui.servers.save_to_keychain_tooltip"))
            .clicked()
        {
            if let (Some(u), Some(p)) = (&a.profile.user, &a.profile.password) {
                match a.keystore.save(u, p) {
                    Ok(()) => {
                        tracing::info!("saved password for {u} to OS keychain");
                        a.password_from_keychain = true;
                    }
                    Err(e) => tracing::error!("keychain save failed for {u}: {e:#}"),
                }
            }
        }
        if ui
            .add_enabled(user_set, egui::Button::new(t("ui.servers.forget_keychain")))
            .on_hover_text(t("ui.servers.forget_keychain_tooltip"))
            .clicked()
        {
            if let Some(u) = &a.profile.user.clone() {
                match a.keystore.forget(u) {
                    Ok(()) => {
                        tracing::info!("forgot keychain entry for {u}");
                        a.profile.password = None;
                        a.password_from_keychain = false;
                    }
                    Err(e) => tracing::error!("keychain forget failed for {u}: {e:#}"),
                }
            }
        }
        if a.password_from_keychain {
            ui.label(egui::RichText::new(t("ui.servers.loaded_from_keychain")).weak());
        }
        #[cfg(feature = "oauth")]
        {
            let user_set = a
                .profile
                .user
                .as_deref()
                .map(|s| !s.is_empty())
                .unwrap_or(false);
            if ui
                .add_enabled(
                    user_set && !a.oauth_login_running,
                    egui::Button::new(t("ui.servers.oauth_login_m365")),
                )
                .on_hover_text(t("ui.servers.oauth_login_m365_tooltip"))
                .clicked()
            {
                let user = a.profile.user.clone().unwrap_or_default();
                let (tx, rx) = std::sync::mpsc::channel();
                a.oauth_login_rx = Some(rx);
                a.oauth_login_running = true;
                a.oauth_login_status.clear();
                let ctx = ui.ctx().clone();
                std::thread::spawn(move || {
                    let _ = tx.send(OauthJobMsg::Status(
                        "Contacting Microsoft for a device code...".into(),
                    ));
                    let start = match smtp_test_tool::oauth::m365_start() {
                        Ok(s) => s,
                        Err(e) => {
                            let _ = tx.send(OauthJobMsg::Failed(e.to_string()));
                            ctx.request_repaint();
                            return;
                        }
                    };
                    let msg = start.message.clone().unwrap_or_else(|| {
                        format!(
                            "Open {} and enter the code {}",
                            start.verification_uri, start.user_code
                        )
                    });
                    let _ = tx.send(OauthJobMsg::Status(msg));
                    ctx.request_repaint();
                    // Open the verification URI in the user's default
                    // browser (best-effort; ignore failure).
                    let _ = webbrowser::open(&start.verification_uri);
                    match smtp_test_tool::oauth::m365_poll(&start, || false) {
                        Ok(tok) => {
                            let _ = tx.send(OauthJobMsg::Done {
                                user,
                                refresh_token: tok.refresh_token.clone(),
                                expires_in: tok.expires_in,
                                access_token: tok.access_token,
                            });
                            ctx.request_repaint();
                        }
                        Err(e) => {
                            let _ = tx.send(OauthJobMsg::Failed(e.to_string()));
                            ctx.request_repaint();
                        }
                    }
                });
            }
            if a.oauth_login_running && !a.oauth_login_status.is_empty() {
                ui.label(
                    egui::RichText::new(&a.oauth_login_status)
                        .small()
                        .italics(),
                );
            }
            // Drain the channel each frame.
            if let Some(rx) = a.oauth_login_rx.as_ref() {
                if let Ok(msg) = rx.try_recv() {
                    match msg {
                        OauthJobMsg::Status(s) => {
                            a.oauth_login_status = s;
                        }
                        OauthJobMsg::Done {
                            user,
                            refresh_token,
                            expires_in,
                            access_token,
                        } => {
                            a.oauth_login_running = false;
                            a.oauth_login_rx = None;
                            if let Some(rt) = refresh_token {
                                match a
                                    .keystore
                                    .save(&format!("oauth-refresh:{user}"), &rt)
                                {
                                    Ok(()) => tracing::info!(
                                        "stored M365 refresh token for {user} (access expires {expires_in}s)"
                                    ),
                                    Err(e) => tracing::error!(
                                        "keychain save failed for oauth-refresh:{user}: {e:#}"
                                    ),
                                }
                            }
                            a.profile.oauth_token = Some(access_token);
                            a.oauth_login_status =
                                "Sign-in complete - access token loaded for this session.".into();
                        }
                        OauthJobMsg::Failed(e) => {
                            a.oauth_login_running = false;
                            a.oauth_login_rx = None;
                            tracing::warn!("M365 OAuth sign-in failed: {e}");
                            a.oauth_login_status = format!("sign-in failed: {e}");
                        }
                    }
                }
            }
        }
    });

    ui.separator();
    proto_block(
        ui,
        "SMTP",
        &mut a.profile.smtp_enabled,
        &mut a.profile.smtp_host,
        &mut a.profile.smtp_port,
        &mut a.profile.smtp_security,
    );
    proto_block(
        ui,
        "IMAP",
        &mut a.profile.imap_enabled,
        &mut a.profile.imap_host,
        &mut a.profile.imap_port,
        &mut a.profile.imap_security,
    );
    proto_block(
        ui,
        "POP3",
        &mut a.profile.pop_enabled,
        &mut a.profile.pop_host,
        &mut a.profile.pop_port,
        &mut a.profile.pop_security,
    );
}

fn proto_block(
    ui: &mut egui::Ui,
    name: &str,
    enabled: &mut bool,
    host: &mut String,
    port: &mut u16,
    sec: &mut Security,
) {
    ui.horizontal(|ui| {
        // Key lookup per protocol so each row gets the localised
        // 'Test SMTP' / 'Test IMAP' / 'Test POP3' label.
        let proto_key = name.to_ascii_lowercase();
        ui.checkbox(enabled, t(&format!("ui.proto.test_{proto_key}")));
        ui.label(t("ui.proto.host"));
        ui.text_edit_singleline(host);
        ui.label(t("ui.proto.port"));
        ui.add(egui::DragValue::new(port).range(1..=65535));
        ui.label(t("ui.proto.security"));
        egui::ComboBox::from_id_salt(format!("{name}-sec"))
            .selected_text(sec.as_str())
            .show_ui(ui, |ui| {
                ui.selectable_value(sec, Security::None, "none");
                ui.selectable_value(sec, Security::StartTls, "starttls");
                ui.selectable_value(sec, Security::Implicit, "ssl");
            });
    });
}

fn tab_send(ui: &mut egui::Ui, a: &mut App) {
    ui.checkbox(&mut a.profile.send_test, t("ui.send.toggle"));
    ui.separator();
    egui::Grid::new("msg").num_columns(2).show(ui, |ui| {
        opt_line(ui, &t("ui.send.mail_from"), &mut a.profile.mail_from);
        opt_line(ui, &t("ui.send.from_header"), &mut a.profile.from_addr);
        ui.label(t("ui.send.to"));
        ui.text_edit_singleline(&mut a.to_csv);
        ui.end_row();
        ui.label(t("ui.send.cc"));
        ui.text_edit_singleline(&mut a.cc_csv);
        ui.end_row();
        ui.label(t("ui.send.bcc"));
        ui.text_edit_singleline(&mut a.bcc_csv);
        ui.end_row();
        opt_line(ui, &t("ui.send.reply_to"), &mut a.profile.reply_to);
        ui.label(t("ui.send.subject"));
        ui.text_edit_singleline(&mut a.profile.subject);
        ui.end_row();
    });
    ui.label(t("ui.send.body"));
    ui.add(
        egui::TextEdit::multiline(&mut a.profile.body)
            .desired_rows(6)
            .desired_width(f32::INFINITY),
    );
}

fn opt_line(ui: &mut egui::Ui, label: &str, v: &mut Option<String>) {
    ui.label(label);
    let mut buf = v.clone().unwrap_or_default();
    if ui.text_edit_singleline(&mut buf).changed() {
        *v = if buf.is_empty() { None } else { Some(buf) };
    }
    ui.end_row();
}

fn tab_tls(ui: &mut egui::Ui, a: &mut App) {
    ui.checkbox(&mut a.profile.insecure_tls, t("ui.tls.insecure"));
    ui.horizontal(|ui| {
        ui.label(t("ui.tls.ca_bundle"));
        let mut buf = a
            .profile
            .ca_file
            .as_ref()
            .map(|p| p.display().to_string())
            .unwrap_or_default();
        if ui.text_edit_singleline(&mut buf).changed() {
            a.profile.ca_file = if buf.is_empty() {
                None
            } else {
                Some(buf.into())
            };
        }
    });
}

fn tab_advanced(ui: &mut egui::Ui, a: &mut App) {
    egui::Grid::new("adv").num_columns(2).show(ui, |ui| {
        ui.label(t("ui.advanced.timeout"));
        ui.add(egui::DragValue::new(&mut a.profile.timeout_secs).range(1..=600));
        ui.end_row();

        ui.label(t("ui.advanced.ehlo"));
        let mut e = a.profile.ehlo_name.clone().unwrap_or_default();
        if ui.text_edit_singleline(&mut e).changed() {
            a.profile.ehlo_name = if e.is_empty() { None } else { Some(e) };
        }
        ui.end_row();

        ui.label(t("ui.advanced.imap_folder"));
        ui.text_edit_singleline(&mut a.profile.imap_folder);
        ui.end_row();

        ui.label(t("ui.advanced.log_level"));
        egui::ComboBox::from_id_salt("loglvl")
            .selected_text(&a.profile.log_level)
            .show_ui(ui, |ui| {
                for lv in ["trace", "debug", "info", "warn", "error"] {
                    ui.selectable_value(&mut a.profile.log_level, lv.into(), lv);
                }
            });
        ui.end_row();

        ui.label(t("ui.advanced.theme"));
        let mut current = ThemeChoice::from_config_str(&a.profile.theme);
        let previous = current;
        egui::ComboBox::from_id_salt("themechoice")
            .selected_text(theme_label(current, a.os_appearance))
            .show_ui(ui, |ui| {
                ui.selectable_value(
                    &mut current,
                    ThemeChoice::Auto,
                    theme_label(ThemeChoice::Auto, a.os_appearance),
                );
                ui.selectable_value(&mut current, ThemeChoice::Dark, t("ui.advanced.theme_dark"));
                ui.selectable_value(
                    &mut current,
                    ThemeChoice::Light,
                    t("ui.advanced.theme_light"),
                );
            });
        if current != previous {
            a.profile.theme = current.as_str().to_string();
        }
        ui.end_row();

        // Language selector.  Per design: only the user's OS locale
        // (if we ship a translation for it) + English.  This keeps the
        // combo box short and obvious even when the binary ships 25
        // locales.  Picking a value writes profile.locale and applies
        // immediately via i18n::set_locale.
        ui.label(t("ui.advanced.language"));
        let current_code = i18n::current_locale();
        let os_code = a.os_locale_code.as_deref();
        let os_supported = os_code
            .map(|c| i18n::is_supported(c) && c != i18n::BASE)
            .unwrap_or(false);
        let display_label = |code: &str| -> String {
            // "Nederlands (nl)" / "English (en)"
            format!("{} ({code})", i18n::native_name(code))
        };
        egui::ComboBox::from_id_salt("langchoice")
            .selected_text(display_label(&current_code))
            .show_ui(ui, |ui| {
                if os_supported {
                    if let Some(c) = os_code {
                        let selected = current_code == c;
                        if ui.selectable_label(selected, display_label(c)).clicked() && !selected {
                            i18n::set_locale(c);
                            a.profile.locale = Some(c.to_string());
                        }
                    }
                }
                let en_selected = current_code == i18n::BASE;
                if ui
                    .selectable_label(en_selected, display_label(i18n::BASE))
                    .clicked()
                    && !en_selected
                {
                    i18n::set_locale(i18n::BASE);
                    a.profile.locale = Some(i18n::BASE.to_string());
                }
            });
        if !os_supported {
            if let Some(code) = os_code {
                ui.label(
                    egui::RichText::new(t_with(
                        "ui.advanced.language_unsupported",
                        &[("code", code)],
                    ))
                    .weak(),
                );
            }
        }
        ui.end_row();
    });
}

/// Paste-a-bounce diagnostic.  Runs the bounce body through the same
/// `smtp_hints_for` translator that live tests use, so a user who got a
/// bounce in their main mail client can find out what to ask IT for
/// without re-running the protocol against the server.
fn tab_diagnose(ui: &mut egui::Ui, a: &mut App) {
    ui.label(t("ui.diagnose.intro"));
    ui.add_space(6.0);

    let avail_h = ui.available_height();
    // Reserve space for the action row + result panel underneath; tune
    // to ~40 % of the tab body so the result is always visible without
    // scrolling.
    let input_h = (avail_h * 0.4).clamp(120.0, 260.0);
    ui.add_sized(
        [ui.available_width(), input_h],
        egui::TextEdit::multiline(&mut a.diagnose_input)
            .hint_text(t("ui.diagnose.input_placeholder"))
            .desired_rows(8),
    );

    ui.horizontal(|ui| {
        let has_input = !a.diagnose_input.trim().is_empty();
        if ui
            .add_enabled(has_input, egui::Button::new(t("ui.diagnose.analyse")))
            .clicked()
        {
            a.diagnose_hints = smtp_hints_for(&a.diagnose_input);
            if a.diagnose_hints.is_empty() {
                tracing::info!("diagnose: no known patterns matched");
            } else {
                tracing::info!(
                    "diagnose: {} hint line(s) generated",
                    a.diagnose_hints.len()
                );
            }
        }
        if ui.button(t("ui.diagnose.clear")).clicked() {
            a.diagnose_input.clear();
            a.diagnose_hints.clear();
        }
    });

    ui.add_space(8.0);
    ui.separator();
    ui.label(egui::RichText::new(t("ui.diagnose.hints_heading")).strong());
    if a.diagnose_hints.is_empty() {
        ui.label(egui::RichText::new(t("ui.diagnose.no_hints_yet")).weak());
    } else {
        egui::ScrollArea::vertical()
            .auto_shrink([false, true])
            .show(ui, |ui| {
                for line in &a.diagnose_hints {
                    ui.label(egui::RichText::new(line).monospace());
                }
            });
    }
}

// -----------------------------------------------------------------------
// DNS tab
// -----------------------------------------------------------------------
#[cfg(feature = "dns")]
struct DnsJobResult {
    domain: String,
    res: Result<smtp_test_tool::dns::DnsReport, String>,
}

#[cfg(feature = "oauth")]
enum OauthJobMsg {
    Status(String),
    Done {
        user: String,
        refresh_token: Option<String>,
        expires_in: u64,
        access_token: String,
    },
    Failed(String),
}

#[cfg(feature = "dns")]
fn tab_dns(ui: &mut egui::Ui, a: &mut App) {
    ui.label(t("ui.dns.intro"));
    ui.add_space(6.0);

    // Poll the background job channel for completion.
    if let Some(rx) = a.dns_rx.as_ref() {
        if let Ok(done) = rx.try_recv() {
            a.dns_running = false;
            a.dns_rx = None;
            match done.res {
                Ok(report) => {
                    tracing::info!("dns: {} audited", done.domain);
                    a.dns_hints = smtp_test_tool::dns::interpret(&report);
                    a.dns_report = Some(report);
                }
                Err(e) => {
                    tracing::warn!("dns: audit of {} failed: {e}", done.domain);
                    a.dns_report = None;
                    a.dns_hints.clear();
                }
            }
            ui.ctx().request_repaint();
        }
    }

    ui.horizontal(|ui| {
        ui.label(t("ui.dns.domain"));
        ui.add_enabled(
            !a.dns_running,
            egui::TextEdit::singleline(&mut a.dns_domain)
                .hint_text("example.com")
                .desired_width(280.0),
        );
        let has_domain = !a.dns_domain.trim().is_empty();
        let label = if a.dns_running {
            t("ui.dns.running")
        } else {
            t("ui.dns.audit")
        };
        if ui
            .add_enabled(has_domain && !a.dns_running, egui::Button::new(label))
            .clicked()
        {
            let domain = a.dns_domain.trim().to_string();
            let (tx, rx) = std::sync::mpsc::channel();
            a.dns_rx = Some(rx);
            a.dns_running = true;
            let ctx = ui.ctx().clone();
            std::thread::spawn(move || {
                let res = smtp_test_tool::dns::audit_domain(&domain).map_err(|e| e.to_string());
                let _ = tx.send(DnsJobResult { domain, res });
                ctx.request_repaint();
            });
        }
        if ui.button(t("ui.dns.clear")).clicked() {
            a.dns_domain.clear();
            a.dns_report = None;
            a.dns_hints.clear();
        }
    });

    ui.add_space(8.0);
    ui.separator();

    match &a.dns_report {
        None => {
            ui.label(
                egui::RichText::new(if a.dns_running {
                    t("ui.dns.running")
                } else {
                    t("ui.dns.no_results_yet")
                })
                .weak(),
            );
        }
        Some(report) => {
            let text = smtp_test_tool::dns::render_report(report, &a.dns_hints);
            egui::ScrollArea::vertical()
                .auto_shrink([false, true])
                .show(ui, |ui| {
                    ui.add(
                        egui::TextEdit::multiline(&mut text.as_str())
                            .font(egui::TextStyle::Monospace)
                            .desired_width(f32::INFINITY)
                            .desired_rows(20),
                    );
                });
        }
    }
}

// -----------------------------------------------------------------------
// main
// -----------------------------------------------------------------------
fn main() -> eframe::Result<()> {
    let sink = Arc::new(LogSink::default());
    let layer = GuiLayer { sink: sink.clone() };
    // The GUI log panel shows user-relevant events.  Default to INFO so we
    // do not flood it with eframe/winit/glow internal trace lines; advanced
    // users can opt into DEBUG by setting RUST_LOG, e.g.:
    //   RUST_LOG=smtp_test_tool=debug smtp-test-tool-gui
    let filter = tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
        tracing_subscriber::EnvFilter::new("info,eframe=warn,winit=warn,wgpu_core=warn,naga=warn")
    });
    tracing_subscriber::registry()
        .with(filter)
        .with(layer)
        .init();
    // LevelFilter is still re-exported, keep the import so future tweaks
    // don't need a re-edit.
    let _keep = LevelFilter::INFO;

    let opts = eframe::NativeOptions {
        viewport: egui::ViewportBuilder::default()
            .with_inner_size([960.0, 760.0])
            .with_min_inner_size([720.0, 520.0])
            .with_title("SMTP Test Tool"),
        ..Default::default()
    };
    eframe::run_native(
        "SMTP Test Tool",
        opts,
        Box::new(|cc| Ok(Box::new(App::new(sink, cc)))),
    )
}
