//! Env-derived RPC URLs and HTTP-loaded spline parameters per market.

use std::env;
use std::str::FromStr;
use std::sync::{OnceLock, RwLock};

use phoenix_eternal_types::program_ids;
use phoenix_rise::types::MarketStatus;
use phoenix_rise::ExchangeMarketConfig;
use solana_keypair::Keypair;
use solana_pubkey::Pubkey as PhoenixPubkey;
use tracing::warn;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Language {
    #[default]
    English,
    Chinese,
    Russian,
    Spanish,
}

impl Language {
    pub fn label(self) -> &'static str {
        match self {
            Self::English => "English",
            Self::Chinese => "中文",
            Self::Russian => "Русский",
            Self::Spanish => "Español",
        }
    }

    pub fn code(self) -> &'static str {
        match self {
            Self::English => "en",
            Self::Chinese => "cn",
            Self::Russian => "ru",
            Self::Spanish => "es",
        }
    }

    pub fn from_code(s: &str) -> Self {
        match s {
            "cn" | "zh" | "zh-CN" | "zh_CN" => Self::Chinese,
            "ru" | "ru-RU" | "ru_RU" => Self::Russian,
            "es" | "es-ES" | "es_ES" | "es-419" | "es_419" => Self::Spanish,
            _ => Self::English,
        }
    }

    pub fn toggle(self) -> Self {
        match self {
            Self::English => Self::Chinese,
            Self::Chinese => Self::Russian,
            Self::Russian => Self::Spanish,
            Self::Spanish => Self::English,
        }
    }
}

/// Built-in default for `SetComputeUnitPrice` (microlamports per CU). Used
/// when neither the user config nor the env var overrides it.
pub const DEFAULT_COMPUTE_UNIT_PRICE_MICRO_LAMPORTS: u64 = 111;

/// Built-in default for `SetComputeUnitLimit` per trader position touched by
/// a tx. Used when neither the user config nor the env var overrides it.
pub const DEFAULT_COMPUTE_UNIT_LIMIT_PER_POSITION: u32 = 275_000;

/// User-facing settings persisted to `~/.config/phoenix-cinder/config.json`.
/// Empty `rpc_url` = not overridden; fall back to env/default.
#[derive(Debug, Clone)]
pub struct UserConfig {
    pub rpc_url: String,
    pub language: Language,
    /// Whether to subscribe to and display CLOB L2 order data. Defaults to
    /// `true`. When `false`, no websocket is opened for the CLOB feed and
    /// the order book shows only spline rows.
    pub show_clob: bool,
    /// When `true` (default), every signed transaction is also fanned out to
    /// the public mainnet-beta RPC for delivery reliability — even if the
    /// primary RPC is a private/paid endpoint. The primary RPC remains
    /// authoritative for confirmation. Turn off if you want submissions to
    /// stay solely on your configured RPC.
    pub fanout_public_rpc: bool,
    /// Override for `SetComputeUnitPrice` (microlamports per CU). `None` =
    /// fall back to env / built-in default.
    pub compute_unit_price_micro_lamports: Option<u64>,
    /// Override for `SetComputeUnitLimit` per trader position touched by a tx
    /// (the multiplier applied to position counts in CU-scaled flows). `None`
    /// = fall back to env / built-in default.
    pub compute_unit_limit_per_position: Option<u32>,
    /// Last keypair file path successfully loaded via the wallet modal. Used
    /// to pre-populate the modal on next open. Empty = never connected from
    /// a file (fall back to the discovery candidates).
    pub wallet_path: String,
    /// When `true`, place-order submissions skip the `[Y/N]` confirmation
    /// prompt and execute as soon as Enter is pressed in the trade panel.
    /// Defaults to `false` (Y prompt required) for safety.
    pub skip_order_confirmation: bool,
    /// When `true`, every transaction is sent with `skip_preflight: true`,
    /// telling the RPC to broadcast without a local simulation pass. Faster on
    /// congested or slow RPCs, but loses the early "sim says this will fail"
    /// signal — bad transactions still land and burn fees. Defaults to
    /// `false`.
    pub skip_preflight: bool,
}

impl Default for UserConfig {
    fn default() -> Self {
        Self {
            rpc_url: String::new(),
            language: Language::default(),
            show_clob: true,
            fanout_public_rpc: default_fanout_public_rpc_from_env(),
            compute_unit_price_micro_lamports: None,
            compute_unit_limit_per_position: None,
            wallet_path: String::new(),
            skip_order_confirmation: default_skip_order_confirmation_from_env(),
            skip_preflight: default_skip_preflight_from_env(),
        }
    }
}

/// Reads `CINDER_COMPUTE_UNIT_PRICE` (microlamports per CU). Returns `None`
/// when the env var is unset, empty, or fails to parse as `u64`.
fn compute_unit_price_from_env() -> Option<u64> {
    env::var("CINDER_COMPUTE_UNIT_PRICE")
        .ok()
        .and_then(|s| s.trim().parse::<u64>().ok())
}

/// Reads `CINDER_COMPUTE_UNIT_LIMIT` (CUs per position). Returns `None` when
/// the env var is unset, empty, or fails to parse as `u32`.
fn compute_unit_limit_from_env() -> Option<u32> {
    env::var("CINDER_COMPUTE_UNIT_LIMIT")
        .ok()
        .and_then(|s| s.trim().parse::<u32>().ok())
}

/// Resolved `SetComputeUnitPrice` value:
/// user override → env override → auto-derived (p90 of recent network fees) →
/// built-in default. The auto value is `None` until the background refresh
/// task in `tui::tx::priority_fees` produces its first sample.
pub fn current_compute_unit_price_micro_lamports() -> u64 {
    current_user_config()
        .compute_unit_price_micro_lamports
        .or_else(compute_unit_price_from_env)
        .or_else(super::tx::current_auto_priority_fee)
        .unwrap_or(DEFAULT_COMPUTE_UNIT_PRICE_MICRO_LAMPORTS)
}

/// Resolved `SetComputeUnitLimit` per-position multiplier: user override →
/// env → built-in default.
pub fn current_compute_unit_limit_per_position() -> u32 {
    current_user_config()
        .compute_unit_limit_per_position
        .or_else(compute_unit_limit_from_env)
        .unwrap_or(DEFAULT_COMPUTE_UNIT_LIMIT_PER_POSITION)
}

/// Reads the env-var override for the public-RPC fan-out toggle.
/// `CINDER_FANOUT_PUBLIC_RPC=0|false|off|no` disables; anything else (or unset)
/// keeps the default `true`. The env var is only consulted as the seed for a
/// fresh `UserConfig::default()`; once the user toggles it in the config modal,
/// the persisted value wins.
fn default_fanout_public_rpc_from_env() -> bool {
    match env::var("CINDER_FANOUT_PUBLIC_RPC") {
        Ok(v) => !matches!(
            v.trim().to_ascii_lowercase().as_str(),
            "0" | "false" | "off" | "no"
        ),
        Err(_) => true,
    }
}

/// Reads the env-var override for the place-order confirmation bypass.
/// `CINDER_SKIP_ORDER_CONFIRMATION=1|true|on|yes` enables the bypass; anything
/// else (or unset) keeps the default `false` (Y prompt required). The env var
/// is only consulted as the seed for a fresh `UserConfig::default()`; once the
/// user toggles it in the config modal, the persisted value wins.
fn default_skip_order_confirmation_from_env() -> bool {
    match env::var("CINDER_SKIP_ORDER_CONFIRMATION") {
        Ok(v) => matches!(
            v.trim().to_ascii_lowercase().as_str(),
            "1" | "true" | "on" | "yes"
        ),
        Err(_) => false,
    }
}

/// Reads the env-var override for the RPC `skip_preflight` toggle.
/// `CINDER_SKIP_PREFLIGHT=1|true|on|yes` enables; anything else (or unset)
/// keeps the default `false`. The env var is only consulted as the seed for a
/// fresh `UserConfig::default()`; once the user toggles it in the config
/// modal, the persisted value wins.
fn default_skip_preflight_from_env() -> bool {
    match env::var("CINDER_SKIP_PREFLIGHT") {
        Ok(v) => matches!(
            v.trim().to_ascii_lowercase().as_str(),
            "1" | "true" | "on" | "yes"
        ),
        Err(_) => false,
    }
}

/// Candidate wallet paths in priority order: `phoenix.json` next to the
/// binary, then `PHX_WALLET_PATH` / `KEYPAIR_PATH`, then the standard
/// Solana CLI location. The first path that exists and decodes is used.
fn wallet_path_candidates() -> Vec<String> {
    let home = env::var("USERPROFILE").unwrap_or_else(|_| env::var("HOME").unwrap_or_default());
    let env_path = env::var("PHX_WALLET_PATH").or_else(|_| env::var("KEYPAIR_PATH"));
    let solana_default = std::path::PathBuf::from(home)
        .join(".config")
        .join("solana")
        .join("id.json")
        .to_string_lossy()
        .into_owned();
    [
        Some("phoenix.json".to_string()),
        env_path.ok(),
        Some(solana_default),
    ]
    .into_iter()
    .flatten()
    .collect()
}

/// Best-guess wallet path to seed the load-wallet modal: prefer the path the
/// user previously connected with (persisted in the user config), else the
/// first discovery candidate that already exists on disk; otherwise fall back
/// to the standard `~/.config/solana/id.json` location so the user can edit
/// from a sensible default.
pub fn default_wallet_path() -> String {
    let saved = current_user_config().wallet_path;
    if !saved.trim().is_empty() {
        return saved;
    }
    let candidates = wallet_path_candidates();
    for p in &candidates {
        if std::path::Path::new(p).exists() {
            return p.clone();
        }
    }
    candidates
        .into_iter()
        .last()
        .unwrap_or_else(|| "id.json".to_string())
}

/// True when `s` should be read as a filesystem path rather than parsed as
/// inline base58. Solana keypair base58 never contains `/` or `\`; without this,
/// a missing file (e.g. unset Docker volume) falls through to base58 decoding
/// and can panic on `/`.
fn looks_like_filesystem_path(s: &str) -> bool {
    if s.starts_with('/') {
        return true;
    }
    if s.starts_with("./") || s.starts_with("../") {
        return true;
    }
    if s.contains('/') {
        return true;
    }
    if s.contains('\\') {
        return true;
    }
    let mut chars = s.chars();
    let Some(letter) = chars.next() else {
        return false;
    };
    let Some(':') = chars.next() else {
        return false;
    };
    if !letter.is_ascii_alphabetic() {
        return false;
    }
    matches!(chars.next(), Some('/' | '\\'))
}

/// Resolves the "Load wallet" field: JSON `[…]` bytes, path to a keypair file
/// on disk, or a base58 secret when the string does not look like a path.
pub fn resolve_wallet_modal_input(input: &str) -> Result<Keypair, String> {
    let trimmed = input.trim();
    if trimmed.is_empty() {
        return Err("input is empty".to_string());
    }
    if trimmed.starts_with('[') {
        return parse_keypair_text(trimmed);
    }
    let path = std::path::Path::new(trimmed);
    if path.is_file() || looks_like_filesystem_path(trimmed) {
        return load_keypair_from_path(trimmed);
    }
    parse_keypair_text(trimmed)
}

/// Parses a `Keypair` from raw text — accepts either a JSON byte array
/// (`[1,2,3,…]`, the Solana CLI format) or a base58-encoded 64-byte
/// keypair string. The error string is suitable for the TUI modal.
pub fn parse_keypair_text(text: &str) -> Result<Keypair, String> {
    let trimmed = text.trim();
    if trimmed.is_empty() {
        return Err("input is empty".to_string());
    }
    if trimmed.starts_with('[') {
        let bytes: Vec<u8> =
            serde_json::from_str(trimmed).map_err(|_| "invalid JSON byte array".to_string())?;
        if bytes.len() < 32 {
            return Err(format!(
                "keypair too short ({} bytes; need 32)",
                bytes.len()
            ));
        }
        let mut secret_key = [0u8; 32];
        secret_key.copy_from_slice(&bytes[..32]);
        return Ok(Keypair::new_from_array(secret_key));
    }
    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
        Keypair::from_base58_string(trimmed)
    })) {
        Ok(kp) => Ok(kp),
        Err(_) => Err("invalid base58 keypair string".to_string()),
    }
}

/// Reads a Solana keypair from `path`. Accepts files containing either a
/// JSON byte array (Solana CLI format) or a base58-encoded keypair string.
pub fn load_keypair_from_path(path: &str) -> Result<Keypair, String> {
    let trimmed = path.trim();
    if trimmed.is_empty() {
        return Err("wallet path is empty".to_string());
    }
    let content = std::fs::read_to_string(trimmed).map_err(|e| match e.kind() {
        std::io::ErrorKind::NotFound => format!("file not found: {trimmed}"),
        std::io::ErrorKind::PermissionDenied => format!("permission denied: {trimmed}"),
        _ => format!("read error: {e}"),
    })?;
    parse_keypair_text(&content)
}

fn home_dir() -> String {
    env::var("USERPROFILE").unwrap_or_else(|_| env::var("HOME").unwrap_or_default())
}

fn config_dir_path() -> String {
    format!("{}/.config/phoenix-cinder", home_dir())
}

pub fn user_config_path() -> String {
    format!("{}/config.json", config_dir_path())
}

fn load_user_config_from_disk() -> UserConfig {
    let Ok(content) = std::fs::read_to_string(user_config_path()) else {
        return UserConfig::default();
    };
    let Ok(v) = serde_json::from_str::<serde_json::Value>(&content) else {
        return UserConfig::default();
    };
    UserConfig {
        rpc_url: v
            .get("rpc_url")
            .and_then(|x| x.as_str())
            .unwrap_or("")
            .to_string(),
        language: Language::from_code(v.get("language").and_then(|x| x.as_str()).unwrap_or("en")),
        show_clob: v.get("show_clob").and_then(|x| x.as_bool()).unwrap_or(true),
        fanout_public_rpc: v
            .get("fanout_public_rpc")
            .and_then(|x| x.as_bool())
            .unwrap_or_else(default_fanout_public_rpc_from_env),
        compute_unit_price_micro_lamports: v
            .get("compute_unit_price_micro_lamports")
            .and_then(|x| x.as_u64()),
        compute_unit_limit_per_position: v
            .get("compute_unit_limit_per_position")
            .and_then(|x| x.as_u64())
            .and_then(|n| u32::try_from(n).ok()),
        wallet_path: v
            .get("wallet_path")
            .and_then(|x| x.as_str())
            .unwrap_or("")
            .to_string(),
        skip_order_confirmation: v
            .get("skip_order_confirmation")
            .and_then(|x| x.as_bool())
            .unwrap_or_else(default_skip_order_confirmation_from_env),
        skip_preflight: v
            .get("skip_preflight")
            .and_then(|x| x.as_bool())
            .unwrap_or_else(default_skip_preflight_from_env),
    }
}

fn user_config_cache() -> &'static RwLock<UserConfig> {
    static CACHE: OnceLock<RwLock<UserConfig>> = OnceLock::new();
    CACHE.get_or_init(|| RwLock::new(load_user_config_from_disk()))
}

/// Current user config (from in-memory cache; first call reads from disk).
pub fn current_user_config() -> UserConfig {
    user_config_cache()
        .read()
        .map(|g| g.clone())
        .unwrap_or_default()
}

/// Persist `cfg` to disk and update the in-memory cache so subsequent
/// `rpc_http_url_from_env` / `current_user_config` calls return the new values.
///
/// Note: clients/streams established with a prior RPC URL keep that URL —
/// changes fully take effect after a restart.
pub fn save_user_config(cfg: &UserConfig) -> std::io::Result<()> {
    std::fs::create_dir_all(config_dir_path())?;
    let value = serde_json::json!({
        "rpc_url": cfg.rpc_url,
        "language": cfg.language.code(),
        "show_clob": cfg.show_clob,
        "fanout_public_rpc": cfg.fanout_public_rpc,
        "compute_unit_price_micro_lamports": cfg.compute_unit_price_micro_lamports,
        "compute_unit_limit_per_position": cfg.compute_unit_limit_per_position,
        "wallet_path": cfg.wallet_path,
        "skip_order_confirmation": cfg.skip_order_confirmation,
        "skip_preflight": cfg.skip_preflight,
    });
    let content = serde_json::to_string_pretty(&value).map_err(std::io::Error::other)?;
    std::fs::write(user_config_path(), content)?;
    if let Ok(mut w) = user_config_cache().write() {
        *w = cfg.clone();
    }
    Ok(())
}

/// Holds configuration and derivation parameters for a specific market's spline
/// chart.
#[derive(Debug, Clone)]
pub struct SplineConfig {
    pub tick_size: u64,
    pub base_lot_decimals: i8,
    pub spline_collection: String,
    pub market_pubkey: String,
    pub symbol: String,
    pub max_leverage: f64,
    pub isolated_only: bool,
    /// Global asset index used as the key in on-chain per-market tables
    /// (e.g. ActiveTraderBuffer position ids). Needed to map an arbitrary
    /// on-chain position back to its market symbol.
    pub asset_id: u32,
    /// Number of decimal places to display for prices, derived from tick_size.
    pub price_decimals: usize,
    /// Number of decimal places for base-asset quantities, derived from
    /// base_lot_decimals.
    pub size_decimals: usize,
}

/// Reads the main HTTP RPC URL, preferring the user config file, then `RPC_URL`
/// / `SOLANA_RPC_URL` env vars, falling back to Mainnet Beta.
pub fn rpc_http_url_from_env() -> String {
    let cfg = current_user_config();
    if !cfg.rpc_url.trim().is_empty() {
        return cfg.rpc_url;
    }
    env::var("RPC_URL")
        .or_else(|_| env::var("SOLANA_RPC_URL"))
        .unwrap_or_else(|_| {
            warn!(
                "Using default mainnet-beta RPC_URL (https://api.mainnet-beta.solana.com) because \
                 neither RPC_URL nor SOLANA_RPC_URL is set."
            );
            "https://api.mainnet-beta.solana.com".to_string()
        })
}

/// Reads the main WebSocket JSON-RPC URL from the environment, or derives it
/// from the HTTP URL.
pub fn ws_url_from_env() -> String {
    env::var("RPC_WS_URL")
        .or_else(|_| env::var("SOLANA_WS_URL"))
        .unwrap_or_else(|_| http_rpc_url_to_ws(&rpc_http_url_from_env()))
}

/// Converts an HTTP(S) Solana RPC endpoint string to its equivalent WSS
/// endpoint.
pub fn http_rpc_url_to_ws(http: &str) -> String {
    let http = http.trim();
    if http.starts_with("wss://") || http.starts_with("ws://") {
        return http.to_string();
    }
    if let Some(rest) = http.strip_prefix("https://") {
        return format!("wss://{rest}");
    }
    if let Some(rest) = http.strip_prefix("http://") {
        if rest == "127.0.0.1:8899" {
            return "ws://127.0.0.1:8900".to_string();
        }
        if rest == "localhost:8899" {
            return "ws://localhost:8900".to_string();
        }
        return format!("ws://{rest}");
    }
    format!("wss://{http}")
}

/// Derive the number of decimal places to display for prices from tick_size and
/// base_lot_decimals.
pub fn compute_price_decimals(tick_size: u64, base_lots_decimals: i8) -> usize {
    let min_price_step = tick_size as f64 * 10_f64.powi(base_lots_decimals as i32) / 10_f64.powi(6); // QUOTE_LOT_DECIMALS = 6
    if min_price_step > 0.0 {
        let raw = (-min_price_step.log10()).ceil().max(0.0);
        // Clamp to a displayable range; pathological tick sizes (e.g. tick_size=1,
        // bld=18) would otherwise produce astronomically large values or
        // NaN-cast-to-usize.
        (raw as usize).min(18)
    } else {
        2
    }
}

/// Build a SplineConfig from an already-fetched ExchangeMarketConfig (no
/// network call).
pub fn build_spline_config(
    market: &ExchangeMarketConfig,
) -> Result<SplineConfig, Box<dyn std::error::Error>> {
    if !matches!(
        market.market_status,
        MarketStatus::Active | MarketStatus::PostOnly
    ) {
        warn!(
            market_status = ?market.market_status,
            symbol = %market.symbol,
            "market status is not Active/PostOnly; continuing with spline indexing"
        );
    }

    let market_pk = PhoenixPubkey::from_str(&market.market_pubkey)?;
    let api_spline_pk = PhoenixPubkey::from_str(&market.spline_pubkey)?;
    let (derived_spline_pk, _) = program_ids::get_spline_collection_address_default(&market_pk);
    if api_spline_pk != derived_spline_pk {
        warn!(
            api = %api_spline_pk,
            derived = %derived_spline_pk,
            symbol = %market.symbol,
            "spline pubkey mismatch; using derived address"
        );
    }

    let price_decimals = compute_price_decimals(market.tick_size, market.base_lots_decimals);
    let max_leverage = market
        .leverage_tiers
        .first()
        .map(|tier| tier.max_leverage)
        .unwrap_or(1.0);

    // base_lot_decimals encodes the exponent in base_lots_to_units:
    //   units = lots / 10^bld
    // A positive bld means fractional units (e.g. bld=2 → 0.01 minimum).
    // A negative bld means lots are > 1 unit each, so 0 decimals suffice.
    let size_decimals = market.base_lots_decimals.max(0) as usize;

    Ok(SplineConfig {
        tick_size: market.tick_size,
        base_lot_decimals: market.base_lots_decimals,
        spline_collection: derived_spline_pk.to_string(),
        market_pubkey: market.market_pubkey.clone(),
        symbol: market.symbol.clone(),
        max_leverage,
        isolated_only: market.isolated_only,
        asset_id: market.asset_id,
        price_decimals,
        size_decimals,
    })
}

#[cfg(test)]
mod tests {
    use solana_signer::Signer;

    use super::*;

    #[test]
    fn resolve_wallet_modal_input_treats_unix_paths_as_paths_not_base58() {
        let err = resolve_wallet_modal_input("/home/nonroot/.config/solana/id.json").unwrap_err();
        assert!(
            err.contains("not found") || err.contains("file not found"),
            "unexpected err: {err}"
        );
    }

    #[test]
    fn resolve_wallet_modal_input_reads_json_keypair_file() {
        let dir = std::env::temp_dir();
        let path = dir.join("cinder-test-keypair.json");
        let kp = Keypair::new();
        let bytes = kp.to_bytes();
        let json: Vec<u8> = bytes.to_vec();
        std::fs::write(&path, serde_json::to_string(&json).unwrap()).unwrap();
        let loaded = resolve_wallet_modal_input(path.to_str().unwrap()).unwrap();
        assert_eq!(loaded.pubkey(), kp.pubkey());
        let _ = std::fs::remove_file(&path);
    }

    #[test]
    fn language_round_trips_through_code() {
        for lang in [
            Language::English,
            Language::Chinese,
            Language::Russian,
            Language::Spanish,
        ] {
            assert_eq!(Language::from_code(lang.code()), lang);
        }
    }

    #[test]
    fn language_from_code_accepts_zh_aliases() {
        assert_eq!(Language::from_code("zh"), Language::Chinese);
        assert_eq!(Language::from_code("zh-CN"), Language::Chinese);
        assert_eq!(Language::from_code("zh_CN"), Language::Chinese);
    }

    #[test]
    fn language_from_code_accepts_ru_aliases() {
        assert_eq!(Language::from_code("ru"), Language::Russian);
        assert_eq!(Language::from_code("ru-RU"), Language::Russian);
        assert_eq!(Language::from_code("ru_RU"), Language::Russian);
    }

    #[test]
    fn language_from_code_accepts_es_aliases() {
        assert_eq!(Language::from_code("es"), Language::Spanish);
        assert_eq!(Language::from_code("es-ES"), Language::Spanish);
        assert_eq!(Language::from_code("es_ES"), Language::Spanish);
        assert_eq!(Language::from_code("es-419"), Language::Spanish);
        assert_eq!(Language::from_code("es_419"), Language::Spanish);
    }

    #[test]
    fn language_from_code_falls_back_to_english() {
        assert_eq!(Language::from_code("fr"), Language::English);
        assert_eq!(Language::from_code(""), Language::English);
    }

    #[test]
    fn language_toggle_cycles() {
        assert_eq!(Language::English.toggle(), Language::Chinese);
        assert_eq!(Language::Chinese.toggle(), Language::Russian);
        assert_eq!(Language::Russian.toggle(), Language::Spanish);
        assert_eq!(Language::Spanish.toggle(), Language::English);
        assert_eq!(
            Language::English.toggle().toggle().toggle().toggle(),
            Language::English
        );
    }

    #[test]
    fn http_to_ws_promotes_https_scheme() {
        assert_eq!(
            http_rpc_url_to_ws("https://api.mainnet-beta.solana.com"),
            "wss://api.mainnet-beta.solana.com"
        );
    }

    #[test]
    fn http_to_ws_demotes_http_scheme() {
        assert_eq!(
            http_rpc_url_to_ws("http://example.com:8899"),
            "ws://example.com:8899"
        );
    }

    #[test]
    fn http_to_ws_remaps_localhost_default_ports() {
        assert_eq!(
            http_rpc_url_to_ws("http://127.0.0.1:8899"),
            "ws://127.0.0.1:8900"
        );
        assert_eq!(
            http_rpc_url_to_ws("http://localhost:8899"),
            "ws://localhost:8900"
        );
    }

    #[test]
    fn http_to_ws_defaults_schemeless_input_to_wss() {
        assert_eq!(
            http_rpc_url_to_ws("api.example.com"),
            "wss://api.example.com"
        );
    }

    #[test]
    fn compute_price_decimals_clamps_pathological_inputs() {
        // Astronomically tiny min step would otherwise produce an unbounded value.
        assert!(compute_price_decimals(1, 18) <= 18);
    }

    #[test]
    fn compute_price_decimals_returns_zero_when_step_is_invalid() {
        assert_eq!(compute_price_decimals(0, 0), 2);
    }

    #[test]
    fn compute_price_decimals_matches_known_step_sizes() {
        // tick_size=1, base_lot_decimals=0 → step = 1e-6 → 6 decimals.
        assert_eq!(compute_price_decimals(1, 0), 6);
        // tick_size=100, base_lot_decimals=0 → step = 1e-4 → 4 decimals.
        assert_eq!(compute_price_decimals(100, 0), 4);
    }
}
