//! Diagnostics: panic hook, frontend error capture, and the manual
//! diagnostic-report bundler used by Settings → About.
//!
//! Privacy posture (matches Lumotia's local-first positioning):
//! - All capture is to disk only. Nothing is transmitted.
//! - The manual report bundler shows the user exactly what would be
//!   shared and lets them choose to copy/save/email it.
//! - No remote endpoint, no Sentry, no opt-out telemetry.
//!
//! The `generate_diagnostic_bundle` command produces a zip archive
//! containing logs, crash dumps, redacted preferences, and system info.
//! It NEVER includes audio files, transcripts, the SQLite database, or
//! any `.env*` file — a deny-list is applied to every candidate path.

use std::fs;
use std::io::Write;
use std::panic;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

use serde::{Deserialize, Serialize};

use lumotia_storage::{
    app_data_dir, crashes_dir, list_recent_errors, log_error, logs_dir, ErrorLogRow,
};

use crate::commands::power::active_assertions_snapshot;
use crate::commands::security::ensure_main_window;
use crate::AppState;

const DEFAULT_RECENT_ERRORS: i64 = 50;
const LUMOTIA_VERSION: &str = env!("CARGO_PKG_VERSION");

/// Install the Rust panic hook. Writes each panic to a separate file in
/// crashes_dir so the diagnostic-report bundler can attach them. Also
/// keeps the default panic message on stderr so terminal users see it.
///
/// Called once at startup before tauri::Builder.
pub fn install_panic_hook() {
    if let Err(e) = fs::create_dir_all(crashes_dir()) {
        tracing::warn!(error = %e, "could not create crashes dir; panic dumps disabled");
        return;
    }

    let default_hook = panic::take_hook();
    panic::set_hook(Box::new(move |info| {
        // Write a minimal text dump. Backtraces require RUST_BACKTRACE=1
        // and the std::backtrace API; we capture what's free.
        let ts = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_secs())
            .unwrap_or(0);
        let short = (ts % 0xFFFF) as u16;
        let path = crashes_dir().join(format!("{ts}-{short:04x}.crash"));

        let payload = format!(
            "Lumotia crash dump\n\
             ==============\n\
             Version: {ver}\n\
             Timestamp: {ts} (UTC seconds)\n\
             Thread: {thread}\n\
             \n\
             Panic message:\n\
             {info}\n\
             \n\
             OS: {os} {arch}\n\
             RUST_BACKTRACE: {bt}\n",
            ver = LUMOTIA_VERSION,
            ts = ts,
            thread = std::thread::current().name().unwrap_or("<unnamed>"),
            info = info,
            os = std::env::consts::OS,
            arch = std::env::consts::ARCH,
            bt = std::env::var("RUST_BACKTRACE").unwrap_or_else(|_| "0".to_string()),
        );

        if let Ok(mut f) = fs::File::create(&path) {
            let _ = f.write_all(payload.as_bytes());
        }

        // Still call the original hook so the panic shows in the terminal.
        default_hook(info);
    }));
}

/// Tauri command: persist a frontend-side error to error_log.
///
/// Wired from the global window.onerror + window.unhandledrejection
/// handlers in the Svelte root layout. Also called explicitly by
/// invokeWithToast on failure paths so we have a unified record.
#[tauri::command]
pub async fn log_frontend_error(
    state: tauri::State<'_, AppState>,
    context: String,
    message: String,
    stack: Option<String>,
) -> Result<(), String> {
    let metadata = stack.map(|s| {
        // Truncate stacks to keep the table from bloating; the full stack
        // is also visible in the browser console anyway.
        s.chars().take(2000).collect::<String>()
    });

    log_error(
        &state.db,
        if context.is_empty() {
            "frontend"
        } else {
            &context
        },
        Some("FRONTEND_ERROR"),
        &message,
        metadata.as_deref(),
    )
    .await
    .map_err(|e| e.to_string())
}

#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ErrorLogDto {
    pub id: i64,
    pub timestamp: String,
    pub context: String,
    pub error_code: Option<String>,
    pub message: String,
    pub metadata: Option<String>,
}

impl From<ErrorLogRow> for ErrorLogDto {
    fn from(r: ErrorLogRow) -> Self {
        Self {
            id: r.id,
            timestamp: r.timestamp,
            context: r.context,
            error_code: r.error_code,
            message: r.message,
            metadata: r.metadata,
        }
    }
}

#[tauri::command]
pub async fn list_recent_errors_command(
    window: tauri::WebviewWindow,
    state: tauri::State<'_, AppState>,
    limit: Option<i64>,
) -> Result<Vec<ErrorLogDto>, String> {
    ensure_main_window(&window)?;
    let n = limit.unwrap_or(DEFAULT_RECENT_ERRORS).clamp(1, 1000);
    list_recent_errors(&state.db, n)
        .await
        .map(|rows| rows.into_iter().map(ErrorLogDto::from).collect())
        .map_err(|e| e.to_string())
}

/// One crash file on disk.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CrashFile {
    pub filename: String,
    pub mtime_secs: u64,
    pub size_bytes: u64,
    pub preview: String, // first ~600 chars
}

#[tauri::command]
pub async fn list_crash_files(window: tauri::WebviewWindow) -> Result<Vec<CrashFile>, String> {
    ensure_main_window(&window)?;
    list_crash_files_inner().await
}

async fn list_crash_files_inner() -> Result<Vec<CrashFile>, String> {
    let dir = crashes_dir();
    let entries = match fs::read_dir(&dir) {
        Ok(e) => e,
        Err(_) => return Ok(Vec::new()), // no crashes dir = no crashes, perfect
    };

    let mut out: Vec<CrashFile> = Vec::new();
    for entry in entries.flatten() {
        let path = entry.path();
        if path.extension().and_then(|s| s.to_str()) != Some("crash") {
            continue;
        }
        let meta = match fs::metadata(&path) {
            Ok(m) => m,
            Err(_) => continue,
        };
        let mtime_secs = meta
            .modified()
            .ok()
            .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
            .map(|d| d.as_secs())
            .unwrap_or(0);
        let size_bytes = meta.len();
        let preview = match fs::read_to_string(&path) {
            Ok(s) => s.chars().take(600).collect::<String>(),
            Err(_) => String::new(),
        };
        out.push(CrashFile {
            filename: path
                .file_name()
                .and_then(|s| s.to_str())
                .map(String::from)
                .unwrap_or_default(),
            mtime_secs,
            size_bytes,
            preview,
        });
    }
    // Newest first
    out.sort_by(|a, b| b.mtime_secs.cmp(&a.mtime_secs));
    Ok(out)
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReportOptions {
    pub include_recent_errors: bool,
    pub include_crashes: bool,
    pub include_settings: bool,
    pub include_log_tail: bool,
}

impl Default for ReportOptions {
    fn default() -> Self {
        Self {
            include_recent_errors: true,
            include_crashes: true,
            include_settings: false,
            include_log_tail: false,
        }
    }
}

fn redact_home(input: &str) -> String {
    match std::env::var("HOME") {
        Ok(home) if !home.is_empty() => input.replace(&home, "~"),
        _ => input.to_string(),
    }
}

fn looks_sensitive_key(key: &str) -> bool {
    let lower = key.to_ascii_lowercase();
    lower.contains("path")
        || lower.contains("folder")
        || lower.contains("directory")
        || lower.contains("device")
        || lower.contains("microphone")
}

fn redact_json_value(value: &mut serde_json::Value, key_hint: Option<&str>) {
    match value {
        serde_json::Value::Object(map) => {
            for (key, child) in map.iter_mut() {
                redact_json_value(child, Some(key));
            }
        }
        serde_json::Value::Array(items) => {
            for item in items {
                redact_json_value(item, key_hint);
            }
        }
        serde_json::Value::String(text) => {
            if key_hint.map(looks_sensitive_key).unwrap_or(false)
                || text.starts_with('/')
                || text.contains(":\\")
                || text.contains("/home/")
                || text.contains("/Users/")
            {
                *text = redact_home(text);
            }
        }
        _ => {}
    }
}

fn sanitise_preferences_json(raw: &str) -> String {
    match serde_json::from_str::<serde_json::Value>(raw) {
        Ok(mut value) => {
            redact_json_value(&mut value, None);
            serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".to_string())
        }
        Err(_) => "_preferences could not be parsed as JSON_".to_string(),
    }
}

fn redact_line(input: &str) -> String {
    redact_home(input)
        .chars()
        .take(400)
        .collect::<String>()
        .replace('\n', " ")
}

/// Build a single human-readable diagnostic-report string. The user inspects
/// this in Settings → About before deciding whether to copy/save/email it.
///
/// The string is plain markdown so it can be pasted into an email, a
/// Discord thread, or a GitHub issue without further conversion.
#[tauri::command]
pub async fn generate_diagnostic_report(
    window: tauri::WebviewWindow,
    state: tauri::State<'_, AppState>,
    options: Option<ReportOptions>,
) -> Result<String, String> {
    ensure_main_window(&window)?;
    generate_diagnostic_report_inner(&state, options).await
}

async fn generate_diagnostic_report_inner(
    state: &tauri::State<'_, AppState>,
    options: Option<ReportOptions>,
) -> Result<String, String> {
    let opts = options.unwrap_or_default();
    let mut out = String::new();

    out.push_str("# Lumotia diagnostic report\n\n");
    out.push_str(&format!("- Version: `{}`\n", LUMOTIA_VERSION));
    out.push_str(&format!(
        "- OS / arch: `{} / {}`\n",
        std::env::consts::OS,
        std::env::consts::ARCH
    ));
    out.push_str(&format!(
        "- App data dir: `{}`\n",
        redact_home(&app_data_dir().display().to_string())
    ));
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0);
    out.push_str(&format!("- Generated: unix `{}`\n", now));
    out.push('\n');
    out.push_str(
        "> This report is local-only until you choose to share it. \
                  Review the contents below before sending to anyone.\n\n",
    );

    if opts.include_settings {
        out.push_str("## Settings (sanitised)\n\n");
        match lumotia_storage::get_setting(&state.db, "lumotia_preferences").await {
            Ok(Some(json)) => {
                out.push_str("```json\n");
                out.push_str(&sanitise_preferences_json(&json));
                out.push_str("\n```\n\n");
            }
            Ok(None) => out.push_str("_(no preferences saved)_\n\n"),
            Err(e) => out.push_str(&format!("_(could not read preferences: {e})_\n\n")),
        }
    }

    if opts.include_recent_errors {
        out.push_str("## Recent errors (newest first)\n\n");
        match list_recent_errors(&state.db, DEFAULT_RECENT_ERRORS).await {
            Ok(rows) if rows.is_empty() => out.push_str("_(no errors logged)_\n\n"),
            Ok(rows) => {
                for r in rows {
                    out.push_str(&format!(
                        "- `{}` **{}** [{}]{}: {}\n",
                        r.timestamp,
                        r.context,
                        r.error_code.as_deref().unwrap_or("?"),
                        if r.metadata.is_some() { " (+meta)" } else { "" },
                        redact_line(&r.message),
                    ));
                }
                out.push('\n');
            }
            Err(e) => out.push_str(&format!("_(could not read error log: {e})_\n\n")),
        }
    }

    out.push_str("## Power assertions\n\n");
    let power_assertions = active_assertions_snapshot();
    if power_assertions.is_empty() {
        out.push_str("_(no active power assertions at report time)_\n\n");
    } else {
        for assertion in power_assertions {
            out.push_str(&format!(
                "- `#{}` reason=`{}` backend=`{}` acquired=`{}`\n",
                assertion.id, assertion.reason, assertion.backend, assertion.acquired
            ));
        }
        out.push('\n');
    }

    if opts.include_crashes {
        out.push_str("## Crash dumps\n\n");
        let crashes = list_crash_files_inner().await.unwrap_or_default();
        if crashes.is_empty() {
            out.push_str("_(no crash dumps on disk — nothing has crashed)_\n\n");
        } else {
            for c in crashes.iter().take(5) {
                out.push_str(&format!(
                    "### `{}` ({} bytes)\n\n```\n{}\n```\n\n",
                    c.filename, c.size_bytes, c.preview
                ));
            }
            if crashes.len() > 5 {
                out.push_str(&format!(
                    "_(plus {} older crash dumps in `{}`)_\n\n",
                    crashes.len() - 5,
                    redact_home(&crashes_dir().display().to_string())
                ));
            }
        }
    }

    if opts.include_log_tail {
        out.push_str("## Log tail\n\n");
        let log_path = logs_dir().join("lumotia.log");
        match read_tail(&log_path, 8000) {
            Ok(tail) if tail.is_empty() => {
                out.push_str("_(log file empty or not yet written)_\n\n");
            }
            Ok(tail) => {
                out.push_str("```\n");
                out.push_str(&redact_home(&tail));
                out.push_str("\n```\n\n");
            }
            Err(_) => out.push_str("_(no log file found)_\n\n"),
        }
    }

    out.push_str("---\n\n");
    out.push_str(
        "Generated by Lumotia. To share, copy the entire markdown above \
                  and paste it into an email or issue. Email: jake@corbel.consulting.\n",
    );

    Ok(out)
}

/// Read the last `n_bytes` of a file as a UTF-8 string, lossy-decoded.
/// Used to show recent log content without loading huge files.
fn read_tail(path: &PathBuf, n_bytes: u64) -> std::io::Result<String> {
    use std::io::{Read, Seek, SeekFrom};
    let mut f = fs::File::open(path)?;
    let len = f.metadata()?.len();
    let start = len.saturating_sub(n_bytes);
    f.seek(SeekFrom::Start(start))?;
    let mut buf = Vec::new();
    f.read_to_end(&mut buf)?;
    Ok(String::from_utf8_lossy(&buf).to_string())
}

/// Cross-platform OS info exposed to the frontend so the UI can adapt:
/// hotkey labels (Cmd vs Ctrl), file-picker text, "open file location"
/// terminology, etc. Returned on demand via the `get_os_info` Tauri
/// command rather than recomputed in JS so we have a single source of
/// truth in the Rust layer.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OsInfo {
    /// "windows" | "macos" | "linux" | "ios" | "android" | other
    pub os: String,
    /// "x86_64" | "aarch64" | other
    pub arch: String,
    /// Human-readable platform family for tooltips: "Windows", "macOS",
    /// "Linux"
    pub family: String,
    /// True if the platform uses the Cmd modifier rather than Ctrl as
    /// the primary accelerator (macOS).
    pub uses_cmd: bool,
    /// True if the user is currently in a Wayland session (Linux only).
    /// Useful for status display; the actual workaround is automatic.
    pub is_wayland: bool,
    /// True if the custom evdev hotkey listener is the primary backend
    /// (Linux only). Otherwise the Tauri global-shortcut plugin is used.
    pub custom_hotkey_backend: bool,
    /// Convenience: a localised label for the primary modifier
    /// ("Cmd" on macOS, "Ctrl" elsewhere).
    pub primary_modifier_label: String,
}

#[tauri::command]
pub fn get_os_info() -> OsInfo {
    let os = std::env::consts::OS.to_string();
    let arch = std::env::consts::ARCH.to_string();
    let family = match os.as_str() {
        "windows" => "Windows",
        "macos" => "macOS",
        "linux" => "Linux",
        "ios" => "iOS",
        "android" => "Android",
        _ => "Unknown",
    }
    .to_string();
    let uses_cmd = os == "macos";
    let primary_modifier_label = if uses_cmd { "Cmd" } else { "Ctrl" }.to_string();

    let is_wayland = if os == "linux" {
        std::env::var("XDG_SESSION_TYPE")
            .map(|v| v.eq_ignore_ascii_case("wayland"))
            .unwrap_or(false)
    } else {
        false
    };

    let custom_hotkey_backend = os == "linux";

    OsInfo {
        os,
        arch,
        family,
        uses_cmd,
        is_wayland,
        custom_hotkey_backend,
        primary_modifier_label,
    }
}

/// Write the report to a file and return the path. Lets the user save it
/// somewhere they can attach to an email.
#[tauri::command]
pub async fn save_diagnostic_report(
    window: tauri::WebviewWindow,
    app: tauri::AppHandle,
    state: tauri::State<'_, AppState>,
    options: Option<ReportOptions>,
) -> Result<String, String> {
    ensure_main_window(&window)?;
    let _ = app; // reserved for future dialog integration
    let report = generate_diagnostic_report_inner(&state, options).await?;

    let dir = lumotia_storage::app_data_dir().join("diagnostic-reports");
    fs::create_dir_all(&dir).map_err(|e| format!("create dir: {e}"))?;

    let ts = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0);
    let path = dir.join(format!("lumotia-diagnostic-{ts}.md"));

    fs::write(&path, &report).map_err(|e| format!("write file: {e}"))?;
    Ok(path.to_string_lossy().to_string())
}

// ---------------------------------------------------------------------------
// Diagnostic bundle (zip archive) — Task 3.8
// ---------------------------------------------------------------------------

/// Summary returned to the frontend after bundling.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DiagnosticBundleSummary {
    /// Absolute path to the zip that was written.
    pub path: String,
    /// Size of the zip in bytes.
    pub bytes: u64,
    /// Human-readable labels for sections that made it into the zip.
    pub included: Vec<String>,
    /// Human-readable labels for sections deliberately excluded.
    pub excluded: Vec<String>,
}

/// Deny-list: extensions and path components that MUST NEVER appear in a
/// diagnostic bundle. Checked case-insensitively against every candidate
/// path before any bytes are written to the zip.
///
/// Rules:
///   - Audio extensions: wav, mp3, opus, ogg, flac
///   - Transcript / capture directories: transcripts, captures
///   - The SQLite database (transcripts.db and its WAL/SHM siblings)
///   - Dot-env files (.env, .env.local, …)
///   - Anything under an `audio/` path component
const AUDIO_EXTENSIONS: &[&str] = &["wav", "mp3", "opus", "ogg", "flac"];
const DENIED_PATH_COMPONENTS: &[&str] = &["transcripts", "captures", "audio"];

/// Return `true` when the file at `path` must be excluded from any bundle.
/// This is the contract; it is tested by the unit tests below.
pub(crate) fn is_denied(path: &Path) -> bool {
    let path_str = path.to_string_lossy();
    let path_lower = path_str.to_ascii_lowercase();

    // 1. Audio by extension
    if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
        if AUDIO_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()) {
            return true;
        }
    }

    // 2. Denied path components (directory names that must not appear)
    for component in path.components() {
        if let std::path::Component::Normal(c) = component {
            let c_lower = c.to_string_lossy().to_ascii_lowercase();
            if DENIED_PATH_COMPONENTS.contains(&c_lower.as_str()) {
                return true;
            }
        }
    }

    // 3. SQLite database files (*.db, *.db-wal, *.db-shm)
    if path_lower.ends_with(".db")
        || path_lower.ends_with(".db-wal")
        || path_lower.ends_with(".db-shm")
    {
        return true;
    }

    // 4. Dot-env files
    if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
        let name_lower = name.to_ascii_lowercase();
        if name_lower == ".env" || name_lower.starts_with(".env.") {
            return true;
        }
    }

    // 5. Literal "transcripts.db" anywhere in the path (belt-and-suspenders)
    if path_lower.contains("transcripts.db") {
        return true;
    }

    false
}

/// Redact preference JSON before including in the bundle.
///
/// Secrets are any field whose key contains:
///   api_key, token, secret, password, credential (case-insensitive)
/// Vocabulary entries are replaced with `[redacted]` per the spec.
fn redact_preferences_for_bundle(raw: &str) -> String {
    fn is_secret_key(key: &str) -> bool {
        let k = key.to_ascii_lowercase();
        k.contains("api_key")
            || k.contains("token")
            || k.contains("secret")
            || k.contains("password")
            || k.contains("credential")
    }

    fn redact_value(value: &mut serde_json::Value, key_hint: Option<&str>) {
        match value {
            serde_json::Value::Object(map) => {
                for (k, v) in map.iter_mut() {
                    if is_secret_key(k) {
                        *v = serde_json::Value::String("[redacted]".to_string());
                    } else {
                        redact_value(v, Some(k));
                    }
                }
            }
            serde_json::Value::Array(items) => {
                // Vocabulary-style arrays: if the parent key looks like vocabulary,
                // redact each entry individually.
                let is_vocab = key_hint
                    .map(|k| {
                        let k = k.to_ascii_lowercase();
                        k.contains("vocab") || k.contains("dictionary") || k.contains("terms")
                    })
                    .unwrap_or(false);
                if is_vocab {
                    for item in items.iter_mut() {
                        *item = serde_json::Value::String("[redacted]".to_string());
                    }
                } else {
                    for item in items.iter_mut() {
                        redact_value(item, None);
                    }
                }
            }
            // Strings: mask home-dir paths (same logic as existing redact_home)
            serde_json::Value::String(s) => {
                *s = redact_home(s);
            }
            _ => {}
        }
    }

    match serde_json::from_str::<serde_json::Value>(raw) {
        Ok(mut v) => {
            redact_value(&mut v, None);
            serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
        }
        Err(_) => "_preferences could not be parsed as JSON_".to_string(),
    }
}

/// Add a single in-memory byte slice to the zip under `zip_path`.
/// Skips silently if `zip_path` is denied (should not happen for
/// synthesised files, but belt-and-suspenders).
fn zip_add_bytes(
    zip: &mut zip::ZipWriter<fs::File>,
    zip_path: &str,
    data: &[u8],
) -> Result<(), String> {
    let options = zip::write::SimpleFileOptions::default()
        .compression_method(zip::CompressionMethod::Deflated);
    zip.start_file(zip_path, options)
        .map_err(|e| format!("zip start_file({zip_path}): {e}"))?;
    zip.write_all(data)
        .map_err(|e| format!("zip write({zip_path}): {e}"))?;
    Ok(())
}

/// Add a real file from `src_path` to the zip under `zip_path`.
/// Applies the deny-list; logs a warning and skips if denied.
/// Returns `true` if the file was added, `false` if skipped.
fn zip_add_file(
    zip: &mut zip::ZipWriter<fs::File>,
    src_path: &Path,
    zip_path: &str,
) -> Result<bool, String> {
    if is_denied(src_path) {
        tracing::warn!(
            path = %src_path.display(),
            "diagnostic bundle: deny-list match — skipping file"
        );
        return Ok(false);
    }

    let data = fs::read(src_path).map_err(|e| format!("read {}: {e}", src_path.display()))?;
    zip_add_bytes(zip, zip_path, &data)?;
    Ok(true)
}

/// Tauri command: produce a zip diagnostic bundle at `output_path`.
///
/// The caller (frontend) is responsible for obtaining the output path via
/// the Tauri dialog plugin before invoking this command.
///
/// Privacy contract:
///   - NEVER includes audio, transcripts, the SQLite database, or .env files.
///   - Preferences are redacted before inclusion (secrets + vocabulary).
///   - Home-directory paths are replaced with `~`.
#[tauri::command]
pub async fn generate_diagnostic_bundle(
    output_path: String,
    state: tauri::State<'_, AppState>,
) -> Result<DiagnosticBundleSummary, String> {
    let dest = PathBuf::from(&output_path);

    // Ensure parent directory exists.
    if let Some(parent) = dest.parent() {
        fs::create_dir_all(parent).map_err(|e| format!("create output dir: {e}"))?;
    }

    let file = fs::File::create(&dest).map_err(|e| format!("create bundle file: {e}"))?;
    let mut zip = zip::ZipWriter::new(file);

    let mut included: Vec<String> = Vec::new();
    // Excluded is always the same — audio + transcripts are always denied.
    let excluded: Vec<String> = vec!["transcripts".to_string(), "audio".to_string()];

    // -----------------------------------------------------------------------
    // 1. system_info.txt
    // -----------------------------------------------------------------------
    {
        let data_dir_display = redact_home(&app_data_dir().display().to_string());
        let now_secs = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_secs())
            .unwrap_or(0);

        let system_info = format!(
            "Lumotia diagnostic bundle\n\
             ========================\n\
             App version:  {ver}\n\
             OS:           {os}\n\
             Arch:         {arch}\n\
             Data dir:     {data_dir}\n\
             Generated:    {ts} (UTC epoch seconds)\n\
             Rust:         {rust_ver}\n",
            ver = LUMOTIA_VERSION,
            os = std::env::consts::OS,
            arch = std::env::consts::ARCH,
            data_dir = data_dir_display,
            ts = now_secs,
            rust_ver = option_env!("CARGO_PKG_RUST_VERSION").unwrap_or("unknown"),
        );
        zip_add_bytes(&mut zip, "system_info.txt", system_info.as_bytes())?;
        included.push("system_info".to_string());
    }

    // -----------------------------------------------------------------------
    // 2. logs/ — last 7 days of log files, capped at 5 MB total
    // -----------------------------------------------------------------------
    {
        const MAX_LOG_BYTES: u64 = 5 * 1024 * 1024; // 5 MB
        const MAX_LOG_DAYS: usize = 7;

        let log_dir = logs_dir();
        let mut log_count: usize = 0;

        if log_dir.is_dir() {
            // Collect log files sorted newest-first by mtime.
            let mut log_files: Vec<(u64, PathBuf)> = Vec::new();
            if let Ok(entries) = fs::read_dir(&log_dir) {
                for entry in entries.flatten() {
                    let path = entry.path();
                    if !path.is_file() {
                        continue;
                    }
                    // Deny-list check on each candidate
                    if is_denied(&path) {
                        tracing::warn!(
                            path = %path.display(),
                            "diagnostic bundle: deny-list match in logs dir — skipping"
                        );
                        continue;
                    }
                    let mtime = entry
                        .metadata()
                        .ok()
                        .and_then(|m| m.modified().ok())
                        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
                        .map(|d| d.as_secs())
                        .unwrap_or(0);
                    log_files.push((mtime, path));
                }
            }
            // Sort newest-first
            log_files.sort_by(|a, b| b.0.cmp(&a.0));

            let mut bytes_so_far: u64 = 0;
            for (_, path) in log_files.iter().take(MAX_LOG_DAYS) {
                let file_size = fs::metadata(path).map(|m| m.len()).unwrap_or(0);
                if bytes_so_far + file_size > MAX_LOG_BYTES {
                    tracing::info!(
                        "diagnostic bundle: log cap reached at {} bytes; stopping",
                        bytes_so_far
                    );
                    break;
                }
                let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("log");
                let zip_path = format!("logs/{name}");
                match zip_add_file(&mut zip, path, &zip_path) {
                    Ok(true) => {
                        bytes_so_far += file_size;
                        log_count += 1;
                    }
                    Ok(false) => {} // denied — already warned
                    Err(e) => tracing::warn!(
                        "diagnostic bundle: could not add log {}: {e}",
                        path.display()
                    ),
                }
            }
        }

        if log_count > 0 {
            included.push(format!("logs ({log_count} files)"));
        }
    }

    // -----------------------------------------------------------------------
    // 3. crashes/ — most recent 3 crash dumps
    // -----------------------------------------------------------------------
    {
        const MAX_CRASHES: usize = 3;
        let crash_dir = crashes_dir();
        let mut crash_count: usize = 0;

        if crash_dir.is_dir() {
            let mut crash_files: Vec<(u64, PathBuf)> = Vec::new();
            if let Ok(entries) = fs::read_dir(&crash_dir) {
                for entry in entries.flatten() {
                    let path = entry.path();
                    if path.extension().and_then(|e| e.to_str()) != Some("crash") {
                        continue;
                    }
                    if is_denied(&path) {
                        tracing::warn!(
                            path = %path.display(),
                            "diagnostic bundle: deny-list match in crashes dir — skipping"
                        );
                        continue;
                    }
                    let mtime = entry
                        .metadata()
                        .ok()
                        .and_then(|m| m.modified().ok())
                        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
                        .map(|d| d.as_secs())
                        .unwrap_or(0);
                    crash_files.push((mtime, path));
                }
            }
            crash_files.sort_by(|a, b| b.0.cmp(&a.0));

            for (_, path) in crash_files.iter().take(MAX_CRASHES) {
                let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("crash");
                let zip_path = format!("crashes/{name}");
                match zip_add_file(&mut zip, path, &zip_path) {
                    Ok(true) => crash_count += 1,
                    Ok(false) => {}
                    Err(e) => tracing::warn!(
                        "diagnostic bundle: could not add crash {}: {e}",
                        path.display()
                    ),
                }
            }
        }

        if crash_count > 0 {
            included.push(format!("crash_dumps ({crash_count})"));
        }
    }

    // -----------------------------------------------------------------------
    // 4. preferences-redacted.json
    // -----------------------------------------------------------------------
    {
        let prefs_raw = lumotia_storage::get_setting(&state.db, "lumotia_preferences")
            .await
            .unwrap_or(None);

        let redacted = match prefs_raw {
            Some(raw) => redact_preferences_for_bundle(&raw),
            None => "{}".to_string(),
        };

        zip_add_bytes(&mut zip, "preferences-redacted.json", redacted.as_bytes())?;
        included.push("preferences (redacted)".to_string());
    }

    // -----------------------------------------------------------------------
    // 5. metadata.json
    // -----------------------------------------------------------------------
    {
        let now_secs = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_secs())
            .unwrap_or(0);

        let metadata = serde_json::json!({
            "generated_at": now_secs,
            "lumotia_version": LUMOTIA_VERSION,
            "included": included,
            "excluded": excluded,
            "redaction_policy": "no audio, no transcripts, vocabulary redacted, secrets redacted"
        });
        let metadata_bytes =
            serde_json::to_vec_pretty(&metadata).unwrap_or_else(|_| b"{}".to_vec());
        zip_add_bytes(&mut zip, "metadata.json", &metadata_bytes)?;
    }

    // Finalise zip
    zip.finish().map_err(|e| format!("zip finalise: {e}"))?;

    // Report bundle size
    let bytes = fs::metadata(&dest).map(|m| m.len()).unwrap_or(0);

    tracing::info!(
        path = %dest.display(),
        bytes,
        "diagnostic bundle written"
    );

    Ok(DiagnosticBundleSummary {
        path: output_path,
        bytes,
        included,
        excluded,
    })
}

// ---------------------------------------------------------------------------
// Unit tests for the deny-list and preference redaction
// ---------------------------------------------------------------------------

#[cfg(test)]
mod bundle_tests {
    use super::*;
    /// Helper: create a temp dir, populate with fake files, run the deny-list,
    /// and return (allowed_names, denied_names).
    fn classify(files: &[&str]) -> (Vec<String>, Vec<String>) {
        let mut allowed = Vec::new();
        let mut denied = Vec::new();
        for name in files {
            let p = PathBuf::from(name);
            if is_denied(&p) {
                denied.push(name.to_string());
            } else {
                allowed.push(name.to_string());
            }
        }
        (allowed, denied)
    }

    #[test]
    fn deny_audio_extensions() {
        let (allowed, denied) = classify(&[
            "lumotia.log",
            "recording.wav",
            "clip.mp3",
            "capture.opus",
            "sound.ogg",
            "track.flac",
            "system_info.txt",
        ]);
        assert!(
            denied.contains(&"recording.wav".to_string()),
            "wav must be denied"
        );
        assert!(
            denied.contains(&"clip.mp3".to_string()),
            "mp3 must be denied"
        );
        assert!(
            denied.contains(&"capture.opus".to_string()),
            "opus must be denied"
        );
        assert!(
            denied.contains(&"sound.ogg".to_string()),
            "ogg must be denied"
        );
        assert!(
            denied.contains(&"track.flac".to_string()),
            "flac must be denied"
        );
        assert!(
            allowed.contains(&"lumotia.log".to_string()),
            "log must be allowed"
        );
        assert!(
            allowed.contains(&"system_info.txt".to_string()),
            "txt must be allowed"
        );
    }

    #[test]
    fn deny_transcript_path_component() {
        let (allowed, denied) = classify(&[
            "/home/user/.local/share/lumotia/transcripts/2024-01-01.txt",
            "/home/user/.local/share/lumotia/logs/lumotia.log",
            "/home/user/audio/recording.flac",
            "/home/user/.local/share/lumotia/captures/cap001.bin",
        ]);
        assert!(
            denied.iter().any(|p| p.contains("transcripts")),
            "transcripts dir must be denied"
        );
        assert!(
            denied.iter().any(|p| p.contains("captures")),
            "captures dir must be denied"
        );
        assert!(
            denied.iter().any(|p| p.contains("audio")),
            "audio dir must be denied"
        );
        assert!(
            allowed.iter().any(|p| p.contains("logs")),
            "logs dir must be allowed"
        );
    }

    #[test]
    fn deny_sqlite_database() {
        let (_, denied) = classify(&[
            "/data/lumotia.db",
            "/data/lumotia.db-wal",
            "/data/lumotia.db-shm",
            "/data/transcripts.db",
        ]);
        assert_eq!(denied.len(), 4, "all db files must be denied: {denied:?}");
    }

    #[test]
    fn deny_dotenv_files() {
        let (allowed, denied) = classify(&[
            ".env",
            ".env.local",
            ".env.production",
            "env.txt",       // should be allowed
            "my.env.backup", // should be allowed (doesn't start with .env)
        ]);
        assert!(denied.contains(&".env".to_string()), ".env must be denied");
        assert!(
            denied.contains(&".env.local".to_string()),
            ".env.local must be denied"
        );
        assert!(
            denied.contains(&".env.production".to_string()),
            ".env.production must be denied"
        );
        assert!(
            allowed.contains(&"env.txt".to_string()),
            "env.txt must be allowed"
        );
    }

    #[test]
    fn preferences_secret_keys_redacted() {
        let raw = serde_json::json!({
            "display_name": "Jake",
            "api_key": "sk-supersecret",
            "auth_token": "tok_abc123",
            "password": "hunter2",
            "some_credential": "cred_xyz",
            "theme": "dark"
        })
        .to_string();

        let redacted: serde_json::Value =
            serde_json::from_str(&redact_preferences_for_bundle(&raw)).unwrap();

        assert_eq!(
            redacted["api_key"], "[redacted]",
            "api_key must be redacted"
        );
        assert_eq!(
            redacted["auth_token"], "[redacted]",
            "token must be redacted"
        );
        assert_eq!(
            redacted["password"], "[redacted]",
            "password must be redacted"
        );
        assert_eq!(
            redacted["some_credential"], "[redacted]",
            "credential must be redacted"
        );
        // Non-secret fields must survive
        assert_eq!(
            redacted["display_name"], "Jake",
            "display_name must survive"
        );
        assert_eq!(redacted["theme"], "dark", "theme must survive");
    }

    #[test]
    fn preferences_vocabulary_entries_redacted() {
        let raw = serde_json::json!({
            "vocab": ["CORBEL", "Lumotia", "Jake"],
            "user_dictionary": ["word1", "word2"],
            "settings_list": ["keep_me"]
        })
        .to_string();

        let redacted: serde_json::Value =
            serde_json::from_str(&redact_preferences_for_bundle(&raw)).unwrap();

        // Vocabulary arrays must be fully redacted
        let vocab = redacted["vocab"].as_array().unwrap();
        assert!(
            vocab.iter().all(|v| v == "[redacted]"),
            "vocab entries must be redacted: {vocab:?}"
        );

        let dict = redacted["user_dictionary"].as_array().unwrap();
        assert!(
            dict.iter().all(|v| v == "[redacted]"),
            "user_dictionary entries must be redacted: {dict:?}"
        );

        // Non-vocabulary arrays survive
        let settings = redacted["settings_list"].as_array().unwrap();
        assert_eq!(settings[0], "keep_me", "settings_list must survive");
    }

    /// End-to-end: write a real zip to a tempfile with a fake dir tree.
    /// Verify that:
    ///   - `system_info.txt` is present
    ///   - `metadata.json` is present
    ///   - A fake `.wav` is NOT in the zip
    ///   - A fake `transcript.txt` inside a `transcripts/` path is NOT in the zip
    ///   - A fake log file IS in the zip (via zip_add_bytes — this tests the
    ///     deny-list path directly)
    #[test]
    fn deny_list_in_zip_add_file() {
        let tmp = tempfile::TempDir::new().expect("tempdir");

        let wav_path = tmp.path().join("test_recording.wav");
        let log_path = tmp.path().join("lumotia.log");
        let transcript_dir = tmp.path().join("transcripts");
        fs::create_dir_all(&transcript_dir).unwrap();
        let transcript_path = transcript_dir.join("my_note.txt");

        fs::write(&wav_path, b"RIFF fake wav data").unwrap();
        fs::write(&log_path, b"2024-01-01 INFO test log line").unwrap();
        fs::write(&transcript_path, b"Top secret transcript").unwrap();

        let out_path = tmp.path().join("bundle.zip");
        let zip_file = fs::File::create(&out_path).unwrap();
        let mut zip = zip::ZipWriter::new(zip_file);

        // wav — must be denied
        let wav_added = zip_add_file(&mut zip, &wav_path, "test_recording.wav").unwrap();
        assert!(!wav_added, "wav must not be added");

        // transcript inside transcripts/ — must be denied
        let tx_added = zip_add_file(&mut zip, &transcript_path, "transcripts/my_note.txt").unwrap();
        assert!(!tx_added, "transcript must not be added");

        // log file — must be allowed
        let log_added = zip_add_file(&mut zip, &log_path, "logs/lumotia.log").unwrap();
        assert!(log_added, "log must be added");

        zip.finish().unwrap();

        // Verify zip contents
        let zip_file = fs::File::open(&out_path).unwrap();
        let mut archive = zip::ZipArchive::new(zip_file).unwrap();
        let names: Vec<String> = (0..archive.len())
            .map(|i| archive.by_index(i).unwrap().name().to_string())
            .collect();

        assert!(
            names.contains(&"logs/lumotia.log".to_string()),
            "log must be in zip: {names:?}"
        );
        assert!(
            !names.contains(&"test_recording.wav".to_string()),
            "wav must NOT be in zip: {names:?}"
        );
        assert!(
            !names.contains(&"transcripts/my_note.txt".to_string()),
            "transcript must NOT be in zip: {names:?}"
        );
    }
}
