//! Session / application configuration.
//!
//! Persists a simple JSON file in a portable config directory next to the
//! executable by default: `<executable-dir>/config/sessions.json`.
//!
//! ## Password storage
//!
//! Passwords are stored in the OS keychain when available.  The JSON file keeps
//! only a stable `password_ref` such as `session:<uuid>`.
//!
//! On systems without a usable keychain backend, passwords are still **not**
//! stored in plaintext.  On first launch a random 256-bit key is written to
//! `secret.key` in the same config directory (mode `0600` on Unix).  Every
//! non-empty password is then encrypted with **ChaCha20-Poly1305** (a random
//! 96-bit nonce per value) and stored as
//!
//! ```text
//! enc:v1:<base64url(nonce_12_bytes || ciphertext)>
//! ```
//!
//! Legacy plaintext passwords (from older installs) are left untouched in
//! memory and silently re-encrypted the next time the config is saved.

use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use chacha20poly1305::{
    aead::{Aead, AeadCore, KeyInit},
    ChaCha20Poly1305,
};
use directories::ProjectDirs;
use rand::rngs::OsRng;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use zeroize::Zeroize;

/// A secret string (e.g. a session password) whose heap buffer is zeroed when
/// it is dropped, so plaintext credentials don't survive in freed memory and
/// turn up in core dumps, a debugger, or `/proc/<pid>/mem`.  `Clone` makes an
/// independent copy that is likewise zeroed on its own drop, and `Debug` is
/// redacted so a password can never be logged by accident.
#[derive(Clone, Default)]
pub struct Secret(String);

impl Secret {
    pub fn new(s: impl Into<String>) -> Self {
        Secret(s.into())
    }
    pub fn as_str(&self) -> &str {
        &self.0
    }
    pub fn is_empty(&self) -> bool {
        self.0.is_empty()
    }
}

impl Drop for Secret {
    fn drop(&mut self) {
        self.0.zeroize();
    }
}

impl std::fmt::Debug for Secret {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        // Never reveal the contents in logs / debug output.
        f.write_str(if self.0.is_empty() { "Secret(\"\")" } else { "Secret(***)" })
    }
}

impl Serialize for Secret {
    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
        s.serialize_str(&self.0)
    }
}

impl<'de> Deserialize<'de> for Secret {
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
        Ok(Secret(String::deserialize(d)?))
    }
}

/// Which transport a session uses.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum SessionKind {
    /// SSH shell + SFTP (the original and default behaviour).
    #[default]
    Ssh,
    /// Local serial port (COM3 / /dev/ttyUSB0) for switches, routers, MCUs (#14).
    Serial,
    /// Plain Telnet over TCP, for legacy network gear (#17).
    Telnet,
}

impl SessionKind {
    pub fn as_str(&self) -> &'static str {
        match self {
            SessionKind::Ssh => "ssh",
            SessionKind::Serial => "serial",
            SessionKind::Telnet => "telnet",
        }
    }

    pub fn from_str(s: &str) -> Self {
        match s {
            "serial" => SessionKind::Serial,
            "telnet" => SessionKind::Telnet,
            _ => SessionKind::Ssh,
        }
    }
}

fn default_baud() -> u32 {
    115_200
}
fn default_data_bits() -> u8 {
    8
}
fn default_stop_bits() -> u8 {
    1
}
fn default_parity() -> String {
    "none".to_string()
}
fn default_flow() -> String {
    "none".to_string()
}
fn default_group() -> String {
    "Default".to_string()
}
fn default_max_terminal_tabs() -> u32 {
    10
}
fn default_terminal_font_size() -> u32 {
    13
}
fn default_sftp_file_font_size() -> u32 {
    16
}
fn default_sftp_name_column_width() -> u32 {
    180
}
fn default_background_mode() -> u8 {
    1
}
fn default_background_blur_px() -> u32 {
    3
}
fn default_terminal_opacity_percent() -> u32 {
    88
}
fn default_ui_opacity_percent() -> u32 {
    72
}

const CONFIG_FILE_NAME: &str = "sessions.json";
const SECRET_KEY_FILE_NAME: &str = "secret.key";
const CONFIG_DIR_OVERRIDE_FILE: &str = "config-dir.txt";

/// How a session authenticates.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum AuthMethod {
    Password,
    Key,
}

impl AuthMethod {
    pub fn as_str(&self) -> &'static str {
        match self {
            AuthMethod::Password => "password",
            AuthMethod::Key => "key",
        }
    }

    pub fn from_str(s: &str) -> Self {
        match s {
            "key" => AuthMethod::Key,
            _ => AuthMethod::Password,
        }
    }
}

/// A single saved SSH target.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
    pub id: String,
    pub name: String,
    pub host: String,
    pub port: u16,
    pub user: String,
    /// Session folder/group shown on the welcome page.
    #[serde(default = "default_group")]
    pub group: String,
    pub auth: AuthMethod,
    #[serde(default, skip_serializing_if = "Secret::is_empty")]
    pub password: Secret,
    /// OS keychain account/reference for the password. Empty means either no
    /// password or legacy encrypted fallback in `password`.
    #[serde(default)]
    pub password_ref: String,
    #[serde(default)]
    pub private_key_path: String,
    /// Optional outbound proxy, e.g. "socks5://127.0.0.1:1080" or
    /// "http://user:pass@host:8080". Empty = use $ALL_PROXY, else direct.
    #[serde(default)]
    pub proxy: String,
    #[serde(default)]
    pub last_used: Option<String>,

    // --- Transport ----------------------------------------------------------
    /// SSH (default), Serial, or Telnet. Absent in old config files → Ssh.
    #[serde(default)]
    pub kind: SessionKind,

    // --- Serial-only fields (ignored unless kind == Serial) -----------------
    /// Serial device path, e.g. "COM3" (Windows) or "/dev/ttyUSB0" (Linux).
    #[serde(default)]
    pub serial_port: String,
    #[serde(default = "default_baud")]
    pub baud_rate: u32,
    #[serde(default = "default_data_bits")]
    pub data_bits: u8,
    #[serde(default = "default_stop_bits")]
    pub stop_bits: u8,
    /// "none" | "odd" | "even".
    #[serde(default = "default_parity")]
    pub parity: String,
    /// "none" | "hardware" | "software".
    #[serde(default = "default_flow")]
    pub flow_control: String,
}

impl Session {
    pub fn new_empty() -> Self {
        Self {
            id: Uuid::new_v4().to_string(),
            name: String::new(),
            host: String::new(),
            port: 22,
            user: "root".into(),
            group: default_group(),
            auth: AuthMethod::Password,
            password: Secret::default(),
            password_ref: String::new(),
            private_key_path: String::new(),
            proxy: String::new(),
            last_used: None,
            kind: SessionKind::Ssh,
            serial_port: String::new(),
            baud_rate: default_baud(),
            data_bits: default_data_bits(),
            stop_bits: default_stop_bits(),
            parity: default_parity(),
            flow_control: default_flow(),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommandSnippet {
    pub id: String,
    pub name: String,
    pub command: String,
}

impl CommandSnippet {
    #[allow(dead_code)] // legacy snippet format; migrated into StoredCommand on load
    pub fn new(command: String) -> Self {
        let name = command
            .split_whitespace()
            .take(6)
            .collect::<Vec<_>>()
            .join(" ");
        Self {
            id: Uuid::new_v4().to_string(),
            name: if name.is_empty() { "Command".into() } else { name },
            command,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredCommandCategory {
    pub id: String,
    pub name: String,
    #[serde(default)]
    pub builtin: bool,
}

impl StoredCommandCategory {
    fn default_category() -> Self {
        Self {
            id: DEFAULT_COMMAND_CATEGORY_ID.to_string(),
            name: "默认分类".to_string(),
            builtin: true,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredCommand {
    pub id: String,
    pub category_id: String,
    pub name: String,
    pub command: String,
    #[serde(default = "default_append_cr")]
    pub append_cr: bool,
}

fn default_append_cr() -> bool {
    true
}

pub const DEFAULT_COMMAND_CATEGORY_ID: &str = "default";

/// On-disk layout. Keep additive to ease forward-compat.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ConfigFile {
    #[serde(default)]
    pub sessions: Vec<Session>,
    /// Connection folders shown in the welcome page. Sessions keep the folder
    /// name in `Session::group` for backward compatibility.
    #[serde(default)]
    pub connection_folders: Vec<String>,
    /// Connection folders currently collapsed in the welcome page.
    #[serde(default)]
    pub connection_folder_collapsed: Vec<String>,
    /// Preset SFTP download directory. Empty = ask each time.
    #[serde(default)]
    pub download_dir: String,
    /// UI language code: "zh" (default) or "en".
    #[serde(default)]
    pub language: String,
    /// Theme preference: "system" (default) | "dark" | "light".
    #[serde(default)]
    pub theme_pref: String,
    /// Maximum number of simultaneously open terminal tabs.
    #[serde(default = "default_max_terminal_tabs")]
    pub max_terminal_tabs: u32,
    /// Font size used by terminal output.
    #[serde(default = "default_terminal_font_size")]
    pub terminal_font_size: u32,
    /// Font size used by the SFTP/file-management panel.
    #[serde(default = "default_sftp_file_font_size")]
    pub sftp_file_font_size: u32,
    /// Optional background image path. Empty = disabled.
    #[serde(default)]
    pub background_path: String,
    /// Background scope: 0 none, 1 terminal only, 2 whole app.
    #[serde(default = "default_background_mode")]
    pub background_mode: u8,
    /// Stored blur preference in px. Slint rendering currently uses a tint
    /// overlay; keeping the value preserves the setting for richer rendering.
    #[serde(default = "default_background_blur_px")]
    pub background_blur_px: u32,
    /// Terminal panel opacity when a background image is visible (30..100).
    #[serde(default = "default_terminal_opacity_percent")]
    pub terminal_opacity_percent: u32,
    /// Main UI panel opacity when the full-app background is visible (30..100).
    #[serde(default = "default_ui_opacity_percent")]
    pub ui_opacity_percent: u32,
    /// Show fixed debug ids beside major UI parts to make visual bug reports precise.
    #[serde(default)]
    pub ui_debug_ids: bool,
    /// Name column width in the SFTP/file-management panel.
    #[serde(default = "default_sftp_name_column_width")]
    pub sftp_name_column_width: u32,
    /// Whether the user manually resized the SFTP name column.
    #[serde(default)]
    pub sftp_name_column_manual: bool,
    /// Most recent terminal commands submitted from meatshell's command bar.
    #[serde(default)]
    pub command_history: Vec<String>,
    /// User-defined command snippets.
    #[serde(default)]
    pub snippets: Vec<CommandSnippet>,
    /// FinalShell-style custom command categories.
    #[serde(default)]
    pub command_categories: Vec<StoredCommandCategory>,
    /// FinalShell-style custom commands.
    #[serde(default)]
    pub commands: Vec<StoredCommand>,
}

pub struct ConfigStore {
    path: PathBuf,
    cache: ConfigFile,
    /// ChaCha20-Poly1305 key loaded from (or freshly generated into)
    /// `secret.key` in the same directory as `sessions.json`.
    key: [u8; 32],
}

impl ConfigStore {
    /// The prefix that marks an encrypted password blob in sessions.json.
    const ENC_PREFIX: &'static str = "enc:v1:";
    const KEYCHAIN_PREFIX: &'static str = "session:";

    // ── Encryption helpers ────────────────────────────────────────────────

    /// Encrypt `plaintext` with ChaCha20-Poly1305 and return
    /// `"enc:v1:<base64url(nonce_12_bytes || ciphertext)>"`.
    fn encrypt(key: &[u8; 32], plaintext: &str) -> Result<String> {
        let cipher = ChaCha20Poly1305::new(key.into());
        let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng); // 12 random bytes
        let ciphertext = cipher
            .encrypt(&nonce, plaintext.as_bytes())
            .map_err(|e| anyhow::anyhow!("password encrypt error: {e}"))?;
        let mut blob = nonce.to_vec();
        blob.extend_from_slice(&ciphertext);
        Ok(format!("{}{}", Self::ENC_PREFIX, URL_SAFE_NO_PAD.encode(&blob)))
    }

    /// Try to decrypt a value produced by [`Self::encrypt`].
    /// Returns `None` if the string is not an encrypted blob (e.g. a legacy
    /// plaintext value, an empty string, or a tampered/corrupt blob).
    fn try_decrypt(key: &[u8; 32], s: &str) -> Option<String> {
        let b64 = s.strip_prefix(Self::ENC_PREFIX)?;
        let blob = URL_SAFE_NO_PAD.decode(b64).ok()?;
        if blob.len() < 12 {
            return None;
        }
        let (nonce_bytes, ciphertext) = blob.split_at(12);
        let cipher = ChaCha20Poly1305::new(key.into());
        let nonce = chacha20poly1305::Nonce::from_slice(nonce_bytes);
        let plain = cipher.decrypt(nonce, ciphertext).ok()?;
        String::from_utf8(plain).ok()
    }

    fn normalise_config(cfg: &mut ConfigFile) {
        if cfg.max_terminal_tabs == 0 {
            cfg.max_terminal_tabs = default_max_terminal_tabs();
        }
        cfg.max_terminal_tabs = cfg.max_terminal_tabs.clamp(1, 50);
        if cfg.terminal_font_size == 0 {
            cfg.terminal_font_size = default_terminal_font_size();
        }
        cfg.terminal_font_size = cfg.terminal_font_size.clamp(10, 28);
        if cfg.sftp_file_font_size == 0 {
            cfg.sftp_file_font_size = default_sftp_file_font_size();
        }
        cfg.sftp_file_font_size = cfg.sftp_file_font_size.clamp(10, 28);
        cfg.background_mode = cfg.background_mode.min(2);
        cfg.background_blur_px = cfg.background_blur_px.clamp(0, 20);
        if cfg.terminal_opacity_percent == 0 {
            cfg.terminal_opacity_percent = default_terminal_opacity_percent();
        }
        cfg.terminal_opacity_percent = cfg.terminal_opacity_percent.clamp(30, 100);
        if cfg.ui_opacity_percent == 0 {
            cfg.ui_opacity_percent = default_ui_opacity_percent();
        }
        cfg.ui_opacity_percent = cfg.ui_opacity_percent.clamp(30, 100);
        if cfg.sftp_name_column_width == 0 {
            cfg.sftp_name_column_width = default_sftp_name_column_width();
        }
        cfg.sftp_name_column_width = cfg.sftp_name_column_width.clamp(96, 360);

        let mut folders = Vec::<String>::new();
        let mut seen = std::collections::HashSet::<String>::new();
        let mut add_folder = |name: &str| {
            let name = clean_name(name, &default_group());
            if seen.insert(name.clone()) {
                folders.push(name);
            }
        };
        add_folder(&default_group());
        for folder in &cfg.connection_folders {
            add_folder(folder);
        }
        for session in &cfg.sessions {
            add_folder(&session.group);
        }
        cfg.connection_folders = folders;

        let folder_names: std::collections::HashSet<String> =
            cfg.connection_folders.iter().cloned().collect();
        let mut collapsed = Vec::<String>::new();
        let mut collapsed_seen = std::collections::HashSet::<String>::new();
        for folder in &cfg.connection_folder_collapsed {
            let folder = clean_name(folder, &default_group());
            if folder_names.contains(&folder) && collapsed_seen.insert(folder.clone()) {
                collapsed.push(folder);
            }
        }
        cfg.connection_folder_collapsed = collapsed;

        for session in &mut cfg.sessions {
            let group = clean_name(&session.group, &default_group());
            session.group = if folder_names.contains(&group) {
                group
            } else {
                default_group()
            };
        }

        if !cfg
            .command_categories
            .iter()
            .any(|c| c.id == DEFAULT_COMMAND_CATEGORY_ID)
        {
            cfg.command_categories
                .insert(0, StoredCommandCategory::default_category());
        }
        for cat in &mut cfg.command_categories {
            if cat.id == DEFAULT_COMMAND_CATEGORY_ID {
                cat.builtin = true;
                if cat.name.trim().is_empty() {
                    cat.name = "默认分类".to_string();
                }
            } else if cat.name.trim().is_empty() {
                cat.name = "未命名分类".to_string();
            }
        }

        let category_ids: std::collections::HashSet<String> = cfg
            .command_categories
            .iter()
            .map(|c| c.id.clone())
            .collect();
        for cmd in &mut cfg.commands {
            if !category_ids.contains(&cmd.category_id) {
                cmd.category_id = DEFAULT_COMMAND_CATEGORY_ID.to_string();
            }
            if cmd.name.trim().is_empty() {
                cmd.name = cmd
                    .command
                    .split_whitespace()
                    .take(6)
                    .collect::<Vec<_>>()
                    .join(" ");
                if cmd.name.trim().is_empty() {
                    cmd.name = "未命名命令".to_string();
                }
            }
        }

        let mut existing: std::collections::HashSet<String> =
            cfg.commands.iter().map(|c| c.command.clone()).collect();
        for snip in &cfg.snippets {
            let command = snip.command.trim();
            if command.is_empty() || existing.contains(command) {
                continue;
            }
            existing.insert(command.to_string());
            cfg.commands.push(StoredCommand {
                id: Uuid::new_v4().to_string(),
                category_id: DEFAULT_COMMAND_CATEGORY_ID.to_string(),
                name: if snip.name.trim().is_empty() {
                    "未命名命令".to_string()
                } else {
                    snip.name.clone()
                },
                command: command.to_string(),
                append_cr: true,
            });
        }
    }

    // ── Key file management ───────────────────────────────────────────────

    /// Load the 32-byte key from `<config_dir>/secret.key`, or generate and
    /// persist a fresh one.  On Unix the key file is created with mode `0600`
    /// so other local accounts cannot read it.  On Windows files in `%APPDATA%`
    /// are already restricted to the owning user by default ACLs.
    fn load_or_create_key(config_dir: &Path) -> Result<[u8; 32]> {
        use rand::RngCore as _;
        let key_path = config_dir.join(SECRET_KEY_FILE_NAME);

        if key_path.exists() {
            let bytes = fs::read(&key_path)
                .with_context(|| format!("failed to read {}", key_path.display()))?;
            if bytes.len() == 32 {
                let mut key = [0u8; 32];
                key.copy_from_slice(&bytes);
                return Ok(key);
            }
            tracing::warn!("secret.key has wrong length — regenerating");
        }

        let mut key = [0u8; 32];
        OsRng.fill_bytes(&mut key);
        Self::write_key(config_dir, &key)?;
        if crate::debug_log::log_paths_enabled() {
            tracing::info!("generated new encryption key at {}", key_path.display());
        } else {
            tracing::info!("generated new encryption key");
        }
        Ok(key)
    }

    fn write_key(config_dir: &Path, key: &[u8; 32]) -> Result<()> {
        fs::create_dir_all(config_dir).with_context(|| {
            format!("failed to create config dir {}", config_dir.display())
        })?;
        let key_path = config_dir.join(SECRET_KEY_FILE_NAME);
        fs::write(&key_path, key)
            .with_context(|| format!("failed to write {}", key_path.display()))?;
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600))
                .with_context(|| {
                    format!("failed to set permissions on {}", key_path.display())
                })?;
        }
        Ok(())
    }

    // ── Public API ────────────────────────────────────────────────────────

    /// Load (or initialise) the config file. On any parse error we back up the
    /// broken file and start fresh — losing saved sessions is better than
    /// crashing at launch.
    pub fn load() -> Result<Self> {
        let path = Self::config_path()?;
        let config_dir = path
            .parent()
            .context("config path has no parent directory")?
            .to_path_buf();

        fs::create_dir_all(&config_dir).with_context(|| {
            format!("failed to create config dir {}", config_dir.display())
        })?;

        let key = Self::load_or_create_key(&config_dir)?;

        let cache = if path.exists() {
            let raw = fs::read_to_string(&path)
                .with_context(|| format!("failed to read {}", path.display()))?;
            match serde_json::from_str::<ConfigFile>(&raw) {
                Ok(mut cfg) => {
                    // Load keychain passwords first. If the keychain is
                    // unavailable, fall back to any encrypted legacy value that
                    // is still present in the JSON file.
                    for session in &mut cfg.sessions {
                        if !session.password_ref.is_empty() {
                            match crate::keychain::get_password(&session.password_ref) {
                                Ok(plain) => {
                                    session.password = Secret::new(plain);
                                    continue;
                                }
                                Err(err) => {
                                    tracing::warn!(
                                        "failed to read keychain secret {}: {err:#}",
                                        session.password_ref
                                    );
                                }
                            }
                        }
                        if let Some(plain) = Self::try_decrypt(&key, session.password.as_str()) {
                            session.password = Secret::new(plain);
                        }
                    }
                    Self::normalise_config(&mut cfg);
                    cfg
                }
                Err(err) => {
                    let backup = path.with_extension("json.broken");
                    let _ = fs::rename(&path, &backup);
                    if crate::debug_log::log_paths_enabled() {
                        tracing::warn!(
                            "config file was corrupt ({err}); backed up to {}",
                            backup.display()
                        );
                    } else {
                        tracing::warn!("config file was corrupt ({err}); created backup");
                    }
                    let mut cfg = ConfigFile::default();
                    Self::normalise_config(&mut cfg);
                    cfg
                }
            }
        } else {
            let mut cfg = ConfigFile::default();
            Self::normalise_config(&mut cfg);
            cfg
        };

        Ok(Self { path, cache, key })
    }

    fn config_path() -> Result<PathBuf> {
        let config_dir = Self::configured_config_dir()?;
        Self::migrate_legacy_config_if_needed(&config_dir)?;
        Ok(config_dir.join(CONFIG_FILE_NAME))
    }

    fn default_config_dir() -> Result<PathBuf> {
        let exe = std::env::current_exe().context("could not determine executable path")?;
        let exe_dir = exe
            .parent()
            .context("executable path has no parent directory")?;
        Ok(exe_dir.join("config"))
    }

    fn project_config_dir() -> Result<PathBuf> {
        let dirs = ProjectDirs::from("dev", "meatshell", "meatshell")
            .context("could not determine user config directory")?;
        Ok(dirs.config_dir().to_path_buf())
    }

    fn config_dir_override_path() -> Result<PathBuf> {
        Ok(Self::project_config_dir()?.join(CONFIG_DIR_OVERRIDE_FILE))
    }

    fn configured_config_dir() -> Result<PathBuf> {
        let override_path = Self::config_dir_override_path()?;
        if override_path.exists() {
            let raw = fs::read_to_string(&override_path)
                .with_context(|| format!("failed to read {}", override_path.display()))?;
            let configured = raw.trim();
            if !configured.is_empty() {
                let configured = PathBuf::from(configured);
                if Self::config_dir_is_usable(&configured) {
                    return Ok(configured);
                }
                tracing::warn!(
                    "configured config directory is not writable; ignoring override {}",
                    configured.display()
                );
            }
        }
        let portable = Self::default_config_dir()?;
        if Self::config_dir_is_usable(&portable) {
            return Ok(portable);
        }
        let fallback = Self::project_config_dir()?;
        tracing::warn!(
            "portable config directory is not writable; falling back to {}",
            fallback.display()
        );
        Ok(fallback)
    }

    fn config_dir_is_usable(config_dir: &Path) -> bool {
        if fs::create_dir_all(config_dir).is_err() {
            return false;
        }
        let probe = config_dir.join(".write-test");
        match fs::write(&probe, b"ok") {
            Ok(()) => {
                let _ = fs::remove_file(probe);
                true
            }
            Err(_) => false,
        }
    }

    fn write_config_dir_override(config_dir: &Path) -> Result<()> {
        let override_path = Self::config_dir_override_path()?;
        if let Some(parent) = override_path.parent() {
            fs::create_dir_all(parent).with_context(|| {
                format!("failed to create config dir {}", parent.display())
            })?;
        }
        fs::write(&override_path, config_dir.to_string_lossy().as_bytes())
            .with_context(|| format!("failed to write {}", override_path.display()))?;
        Ok(())
    }

    fn migrate_legacy_config_if_needed(config_dir: &Path) -> Result<()> {
        let legacy_dir = Self::project_config_dir()?;
        if legacy_dir == config_dir {
            return Ok(());
        }
        let target_config = config_dir.join(CONFIG_FILE_NAME);
        let legacy_config = legacy_dir.join(CONFIG_FILE_NAME);
        if target_config.exists() || !legacy_config.exists() {
            return Ok(());
        }

        fs::create_dir_all(config_dir).with_context(|| {
            format!("failed to create config dir {}", config_dir.display())
        })?;
        fs::copy(&legacy_config, &target_config).with_context(|| {
            format!(
                "failed to migrate config from {} to {}",
                legacy_config.display(),
                target_config.display()
            )
        })?;

        let legacy_key = legacy_dir.join(SECRET_KEY_FILE_NAME);
        let target_key = config_dir.join(SECRET_KEY_FILE_NAME);
        if legacy_key.exists() && !target_key.exists() {
            let _ = fs::copy(&legacy_key, &target_key);
        }
        Ok(())
    }

    pub fn sessions(&self) -> &[Session] {
        &self.cache.sessions
    }

    #[allow(dead_code)] // reserved for an upcoming reorder/drag-drop feature
    pub fn sessions_mut(&mut self) -> &mut Vec<Session> {
        &mut self.cache.sessions
    }

    pub fn upsert(&mut self, session: Session) {
        let mut session = session;
        session.group = self.connection_folder_or_default(&session.group);
        if let Some(existing) = self
            .cache
            .sessions
            .iter_mut()
            .find(|s| s.id == session.id)
        {
            *existing = session;
        } else {
            self.cache.sessions.push(session);
        }
    }

    pub fn remove(&mut self, id: &str) {
        self.cache.sessions.retain(|s| s.id != id);
    }

    pub fn get(&self, id: &str) -> Option<&Session> {
        self.cache.sessions.iter().find(|s| s.id == id)
    }

    pub fn config_dir(&self) -> &Path {
        self.path.parent().unwrap_or_else(|| Path::new("."))
    }

    pub fn set_config_dir(&mut self, dir: PathBuf) -> Result<()> {
        let dir = if dir.as_os_str().is_empty() {
            Self::default_config_dir()?
        } else {
            dir
        };
        fs::create_dir_all(&dir).with_context(|| {
            format!("failed to create config dir {}", dir.display())
        })?;
        Self::write_key(&dir, &self.key)?;
        self.path = dir.join(CONFIG_FILE_NAME);
        Self::write_config_dir_override(&dir)?;
        Ok(())
    }

    pub fn connection_folders(&self) -> &[String] {
        &self.cache.connection_folders
    }

    pub fn connection_folder_expanded(&self, name: &str) -> bool {
        let name = clean_name(name, &default_group());
        !self
            .cache
            .connection_folder_collapsed
            .iter()
            .any(|folder| folder == &name)
    }

    pub fn toggle_connection_folder(&mut self, name: &str) -> bool {
        let name = self.connection_folder_or_default(name);
        if let Some(index) = self
            .cache
            .connection_folder_collapsed
            .iter()
            .position(|folder| folder == &name)
        {
            self.cache.connection_folder_collapsed.remove(index);
            true
        } else {
            self.cache.connection_folder_collapsed.push(name);
            false
        }
    }

    pub fn add_connection_folder(&mut self, name: String) -> String {
        let name = clean_name(&name, &default_group());
        if !self.cache.connection_folders.iter().any(|f| f == &name) {
            self.cache.connection_folders.push(name.clone());
        }
        self.cache
            .connection_folder_collapsed
            .retain(|folder| folder != &name);
        name
    }

    pub fn connection_folder_or_default(&self, name: &str) -> String {
        let name = clean_name(name, &default_group());
        if self.cache.connection_folders.iter().any(|f| f == &name) {
            name
        } else {
            self.cache
                .connection_folders
                .first()
                .cloned()
                .unwrap_or_else(default_group)
        }
    }

    pub fn download_dir(&self) -> &str {
        &self.cache.download_dir
    }

    pub fn set_download_dir(&mut self, dir: String) {
        self.cache.download_dir = dir;
    }

    /// UI language code ("zh" default / "en").
    pub fn language(&self) -> &str {
        if self.cache.language.is_empty() {
            "zh"
        } else {
            &self.cache.language
        }
    }

    pub fn set_language(&mut self, lang: String) {
        self.cache.language = lang;
    }

    /// Theme preference: "system" (default) | "dark" | "light".
    pub fn theme_pref(&self) -> &str {
        if self.cache.theme_pref.is_empty() {
            "system"
        } else {
            &self.cache.theme_pref
        }
    }

    pub fn set_theme_pref(&mut self, pref: String) {
        self.cache.theme_pref = pref;
    }

    pub fn max_terminal_tabs(&self) -> u32 {
        self.cache.max_terminal_tabs.clamp(1, 50)
    }

    pub fn set_max_terminal_tabs(&mut self, max_tabs: u32) {
        self.cache.max_terminal_tabs = max_tabs.clamp(1, 50);
    }

    pub fn terminal_font_size(&self) -> u32 {
        self.cache.terminal_font_size.clamp(10, 28)
    }

    pub fn set_terminal_font_size(&mut self, font_size: u32) {
        self.cache.terminal_font_size = font_size.clamp(10, 28);
    }

    pub fn sftp_file_font_size(&self) -> u32 {
        self.cache.sftp_file_font_size.clamp(10, 28)
    }

    pub fn set_sftp_file_font_size(&mut self, font_size: u32) {
        self.cache.sftp_file_font_size = font_size.clamp(10, 28);
    }

    pub fn background_path(&self) -> &str {
        &self.cache.background_path
    }

    pub fn set_background_path(&mut self, path: String) {
        self.cache.background_path = path;
    }

    pub fn background_mode(&self) -> u8 {
        self.cache.background_mode.min(2)
    }

    pub fn set_background_mode(&mut self, mode: u8) {
        self.cache.background_mode = mode.min(2);
    }

    pub fn background_blur_px(&self) -> u32 {
        self.cache.background_blur_px.clamp(0, 20)
    }

    pub fn set_background_blur_px(&mut self, blur_px: u32) {
        self.cache.background_blur_px = blur_px.clamp(0, 20);
    }

    pub fn terminal_opacity_percent(&self) -> u32 {
        self.cache.terminal_opacity_percent.clamp(30, 100)
    }

    pub fn set_terminal_opacity_percent(&mut self, opacity: u32) {
        self.cache.terminal_opacity_percent = opacity.clamp(30, 100);
    }

    pub fn ui_opacity_percent(&self) -> u32 {
        self.cache.ui_opacity_percent.clamp(30, 100)
    }

    pub fn set_ui_opacity_percent(&mut self, opacity: u32) {
        self.cache.ui_opacity_percent = opacity.clamp(30, 100);
    }

    pub fn ui_debug_ids(&self) -> bool {
        self.cache.ui_debug_ids
    }

    pub fn set_ui_debug_ids(&mut self, enabled: bool) {
        self.cache.ui_debug_ids = enabled;
    }

    pub fn rename_connection_folder(&mut self, old_name: &str, new_name: String) -> String {
        let old_name = clean_name(old_name, &default_group());
        let new_name = clean_name(&new_name, &default_group());
        if old_name == new_name {
            return new_name;
        }
        if let Some(folder) = self
            .cache
            .connection_folders
            .iter_mut()
            .find(|folder| **folder == old_name)
        {
            *folder = new_name.clone();
        } else {
            self.cache.connection_folders.push(new_name.clone());
        }
        for folder in &mut self.cache.connection_folder_collapsed {
            if *folder == old_name {
                *folder = new_name.clone();
            }
        }
        for session in &mut self.cache.sessions {
            if session.group == old_name {
                session.group = new_name.clone();
            }
        }
        Self::normalise_config(&mut self.cache);
        new_name
    }

    pub fn remove_connection_folder(&mut self, name: &str) {
        let name = clean_name(name, &default_group());
        let fallback = self
            .cache
            .connection_folders
            .iter()
            .find(|folder| **folder != name)
            .cloned()
            .unwrap_or_else(default_group);
        self.cache.connection_folders.retain(|folder| *folder != name);
        self.cache
            .connection_folder_collapsed
            .retain(|folder| *folder != name);
        if self.cache.connection_folders.is_empty() {
            self.cache.connection_folders.push(fallback.clone());
        }
        for session in &mut self.cache.sessions {
            if session.group == name {
                session.group = fallback.clone();
            }
        }
        Self::normalise_config(&mut self.cache);
    }

    pub fn move_session_to_folder(&mut self, session_id: &str, folder: &str) -> Option<String> {
        let folder = self.connection_folder_or_default(folder);
        if let Some(session) = self.cache.sessions.iter_mut().find(|s| s.id == session_id) {
            session.group = folder.clone();
            Some(folder)
        } else {
            None
        }
    }

    pub fn sftp_name_column_width(&self) -> u32 {
        self.cache.sftp_name_column_width.clamp(96, 360)
    }

    pub fn sftp_name_column_manual(&self) -> bool {
        self.cache.sftp_name_column_manual
    }

    pub fn set_sftp_name_column_width(&mut self, width: u32, manual: bool) {
        self.cache.sftp_name_column_width = width.clamp(96, 360);
        self.cache.sftp_name_column_manual = manual;
    }

    #[allow(dead_code)] // kept for compatibility with existing config data
    pub fn command_history(&self) -> &[String] {
        &self.cache.command_history
    }

    pub fn remember_command(&mut self, command: String) {
        let command = command.trim();
        if command.is_empty() {
            return;
        }
        self.cache.command_history.retain(|c| c != command);
        self.cache.command_history.insert(0, command.to_string());
        const MAX_COMMAND_HISTORY: usize = 200;
        if self.cache.command_history.len() > MAX_COMMAND_HISTORY {
            self.cache.command_history.truncate(MAX_COMMAND_HISTORY);
        }
    }

    #[allow(dead_code)] // legacy snippets are migrated into commands on load
    pub fn snippets(&self) -> &[CommandSnippet] {
        &self.cache.snippets
    }

    #[allow(dead_code)] // legacy command bar API
    pub fn add_snippet(&mut self, command: String) {
        let command = command.trim();
        if command.is_empty() || self.cache.snippets.iter().any(|s| s.command == command) {
            return;
        }
        self.cache
            .snippets
            .push(CommandSnippet::new(command.to_string()));
    }

    #[allow(dead_code)] // legacy command bar API
    pub fn remove_snippet(&mut self, id: &str) {
        self.cache.snippets.retain(|s| s.id != id);
    }

    pub fn command_categories(&self) -> &[StoredCommandCategory] {
        &self.cache.command_categories
    }

    pub fn commands(&self) -> &[StoredCommand] {
        &self.cache.commands
    }

    pub fn add_command_category(&mut self, name: String) -> String {
        let id = Uuid::new_v4().to_string();
        let name = clean_name(&name, "未命名分类");
        self.cache.command_categories.push(StoredCommandCategory {
            id: id.clone(),
            name,
            builtin: false,
        });
        id
    }

    pub fn rename_command_category(&mut self, id: &str, name: String) {
        if let Some(cat) = self.cache.command_categories.iter_mut().find(|c| c.id == id) {
            cat.name = clean_name(&name, "未命名分类");
        }
    }

    pub fn remove_command_category(&mut self, id: &str) {
        if id == DEFAULT_COMMAND_CATEGORY_ID {
            return;
        }
        self.cache.command_categories.retain(|c| c.id != id);
        for cmd in &mut self.cache.commands {
            if cmd.category_id == id {
                cmd.category_id = DEFAULT_COMMAND_CATEGORY_ID.to_string();
            }
        }
    }

    pub fn upsert_command(
        &mut self,
        id: &str,
        category_id: &str,
        name: String,
        command: String,
        append_cr: bool,
    ) -> Option<String> {
        let command = command.trim();
        if command.is_empty() {
            return None;
        }
        let category_id = if self
            .cache
            .command_categories
            .iter()
            .any(|c| c.id == category_id)
        {
            category_id.to_string()
        } else {
            DEFAULT_COMMAND_CATEGORY_ID.to_string()
        };
        let name = clean_name(&name, "未命名命令");

        if let Some(existing) = self.cache.commands.iter_mut().find(|c| c.id == id) {
            existing.category_id = category_id;
            existing.name = name;
            existing.command = command.to_string();
            existing.append_cr = append_cr;
            return Some(existing.id.clone());
        }

        let id = Uuid::new_v4().to_string();
        self.cache.commands.push(StoredCommand {
            id: id.clone(),
            category_id,
            name,
            command: command.to_string(),
            append_cr,
        });
        Some(id)
    }

    pub fn remove_command(&mut self, id: &str) {
        self.cache.commands.retain(|c| c.id != id);
    }

    pub fn move_command(&mut self, id: &str, category_id: &str) {
        if !self
            .cache
            .command_categories
            .iter()
            .any(|c| c.id == category_id)
        {
            return;
        }
        if let Some(cmd) = self.cache.commands.iter_mut().find(|c| c.id == id) {
            cmd.category_id = category_id.to_string();
        }
    }

    pub fn save(&self) -> Result<()> {
        // Build a disk copy where every non-empty password is either migrated
        // into the OS keychain (preferred) or encrypted as a fallback.
        let mut disk = self.cache.clone();
        for session in &mut disk.sessions {
            if !session.password.is_empty()
                && !session.password.as_str().starts_with(Self::ENC_PREFIX)
            {
                let password_ref = if session.password_ref.is_empty() {
                    Self::keychain_ref(&session.id)
                } else {
                    session.password_ref.clone()
                };
                match crate::keychain::set_password(&password_ref, session.password.as_str()) {
                    Ok(()) => {
                        session.password_ref = password_ref;
                        session.password = Secret::default();
                    }
                    Err(err) => {
                        tracing::warn!(
                            "failed to write password to OS keychain; using encrypted config fallback: {err:#}"
                        );
                        let enc = Self::encrypt(&self.key, session.password.as_str())?;
                        session.password = Secret::new(enc);
                    }
                }
            }
        }
        let raw = serde_json::to_string_pretty(&disk)?;
        // Write to a sibling temp file then rename — cheap atomicity.
        let tmp = self.path.with_extension("json.tmp");
        fs::write(&tmp, &raw)
            .with_context(|| format!("failed to write {}", tmp.display()))?;
        fs::rename(&tmp, &self.path)
            .with_context(|| format!("failed to finalise {}", self.path.display()))?;
        Ok(())
    }

    fn keychain_ref(session_id: &str) -> String {
        format!("{}{}", Self::KEYCHAIN_PREFIX, session_id)
    }
}

fn clean_name(name: &str, fallback: &str) -> String {
    let name = name.trim();
    if name.is_empty() {
        fallback.to_string()
    } else {
        name.to_string()
    }
}
