use std::collections::HashMap;
use std::io::IsTerminal;
use std::str::FromStr;

use anyhow::Result;
use clap::Subcommand;
use owo_colors::OwoColorize;
use rust_decimal::Decimal;
use serde::Deserialize;

use bpx_api_types::order::{ExecuteOrderPayload, Order, OrderType, Side, TimeInForce};

use crate::auth;
use crate::client;
use crate::config::ResolvedConfig;
use crate::output::basis as out;
use crate::output::OutputFormat;
use crate::retry::RetryConfig;

/// Sort criteria for scan results.
#[derive(Debug, Clone, Copy, Default, clap::ValueEnum)]
pub enum ScanSort {
    #[default]
    Yield,
    Funding,
    Lending,
}

#[derive(Debug, Subcommand)]
pub enum BasisCommand {
    /// Scan markets for basis trade opportunities
    Scan {
        /// Minimum total yield (APR %) to display
        #[arg(long, default_value = "5")]
        min_yield: f64,
        /// Sort by: yield, funding, lending
        #[arg(long, default_value = "yield")]
        sort: ScanSort,
        /// Maximum number of results
        #[arg(long, default_value = "20")]
        limit: usize,
    },

    /// Enter a basis trade (buy spot + short perp)
    Enter {
        /// Asset symbol (e.g. SOL, BTC)
        asset: String,
        /// Size in base asset, or use "$" suffix for dollar amount (e.g. "1000$")
        size: String,
        /// Automatically lend purchased spot
        #[arg(long)]
        auto_lend: bool,
        /// Use limit orders instead of market
        #[arg(long)]
        limit: bool,
        /// Maximum spread in basis points between legs
        #[arg(long, default_value = "10")]
        max_spread_bps: f64,
        /// Show what would happen without executing
        #[arg(long)]
        dry_run: bool,
        /// Skip confirmation prompt
        #[arg(long, short)]
        yes: bool,
    },

    /// List active basis trades (detected from positions + balances)
    List,

    /// Show detailed status of a specific basis trade
    Status {
        /// Asset symbol (e.g. SOL)
        asset: String,
    },

    /// Exit a basis trade (close perp first, then sell spot)
    Exit {
        /// Asset symbol (e.g. SOL)
        asset: String,
        /// Percentage to exit (default 100)
        #[arg(long, default_value = "100")]
        percent: f64,
        /// Skip confirmation prompt
        #[arg(long, short)]
        yes: bool,
    },
}

/// Lending market data from /api/v1/borrowLend/markets.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LendingMarket {
    symbol: String,
    #[serde(default)]
    lend_rate: Option<f64>,
    #[serde(default)]
    lending_rate: Option<f64>,
    #[serde(default)]
    deposit_rate: Option<f64>,
}

impl LendingMarket {
    fn rate(&self) -> f64 {
        self.lend_rate
            .or(self.lending_rate)
            .or(self.deposit_rate)
            .unwrap_or(0.0)
    }
}

/// Fetch lending market rates via raw HTTP GET.
async fn fetch_lending_markets(base_url: &str) -> Result<Vec<LendingMarket>> {
    let url = format!(
        "{}/api/v1/borrowLend/markets",
        base_url.trim_end_matches('/')
    );
    let resp = reqwest::get(&url).await?;
    if !resp.status().is_success() {
        // Non-critical: return empty if endpoint unavailable
        tracing::warn!("Failed to fetch lending markets: {}", resp.status());
        return Ok(vec![]);
    }
    let markets: Vec<LendingMarket> = resp.json().await.unwrap_or_default();
    Ok(markets)
}

/// Extract the base asset from a perp symbol like "SOL_USDC_PERP" -> "SOL".
fn perp_base_asset(symbol: &str) -> &str {
    symbol.split('_').next().unwrap_or(symbol)
}

/// Resolve asset name to spot and perp symbols.
fn resolve_symbols(asset: &str) -> (String, String) {
    let asset = asset.to_uppercase();
    let spot = format!("{asset}_USDC");
    let perp = format!("{asset}_USDC_PERP");
    (spot, perp)
}

/// Parse size string: plain number or "1000$" dollar notation.
fn parse_size(size: &str, price: Decimal) -> Result<Decimal> {
    if let Some(dollars) = size.strip_suffix('$') {
        let usd = Decimal::from_str(dollars)
            .map_err(|_| anyhow::anyhow!("Invalid dollar amount: {dollars}"))?;
        if price == Decimal::ZERO {
            anyhow::bail!("Cannot convert dollar amount: price is zero");
        }
        Ok((usd / price).round_dp(4))
    } else {
        Decimal::from_str(size).map_err(|_| anyhow::anyhow!("Invalid size: {size}"))
    }
}

/// Extract executed quantity from an order response.
fn executed_qty(order: &Order) -> Decimal {
    match order {
        Order::Market(o) => o.executed_quantity,
        Order::Limit(o) => o.executed_quantity,
    }
}

pub async fn run(
    cmd: &BasisCommand,
    config: &ResolvedConfig,
    secret: Option<&str>,
    base_url: Option<&str>,
    format: OutputFormat,
    retry_config: &RetryConfig,
) -> Result<()> {
    match cmd {
        BasisCommand::Scan {
            min_yield,
            sort,
            limit,
        } => run_scan(config, base_url, format, *min_yield, *sort, *limit).await,

        BasisCommand::Enter {
            asset,
            size,
            auto_lend,
            limit,
            max_spread_bps,
            dry_run,
            yes,
        } => {
            run_enter(
                config,
                secret,
                base_url,
                format,
                retry_config,
                asset,
                size,
                *auto_lend,
                *limit,
                *max_spread_bps,
                *dry_run,
                *yes,
            )
            .await
        }

        BasisCommand::List => run_list(config, secret, base_url, format).await,

        BasisCommand::Status { asset } => run_status(config, secret, base_url, format, asset).await,

        BasisCommand::Exit {
            asset,
            percent,
            yes,
        } => {
            run_exit(
                config,
                secret,
                base_url,
                format,
                retry_config,
                asset,
                *percent,
                *yes,
            )
            .await
        }
    }
}

// ---------------------------------------------------------------------------
// Scan
// ---------------------------------------------------------------------------

async fn run_scan(
    config: &ResolvedConfig,
    base_url_flag: Option<&str>,
    format: OutputFormat,
    min_yield: f64,
    sort: ScanSort,
    limit: usize,
) -> Result<()> {
    let base_url = auth::resolve_base_url(base_url_flag, config);
    let c = client::public_client(config, base_url_flag)?;

    let sp = client::spinner_for("Scanning basis opportunities...", format);

    let (mark_prices_res, tickers_res, lending_res) = tokio::join!(
        c.get_all_mark_prices(),
        c.get_tickers(),
        fetch_lending_markets(&base_url),
    );
    sp.finish_and_clear();

    let mark_prices = mark_prices_res.map_err(|e| anyhow::anyhow!("{e}"))?;
    let tickers = tickers_res.map_err(|e| anyhow::anyhow!("{e}"))?;
    let lending_markets = lending_res.unwrap_or_default();

    // Build lookup maps
    let ticker_map: HashMap<&str, &bpx_api_types::markets::Ticker> =
        tickers.iter().map(|t| (t.symbol.as_str(), t)).collect();

    let lending_map: HashMap<&str, f64> = lending_markets
        .iter()
        .map(|m| (m.symbol.as_str(), m.rate()))
        .collect();

    let mut rows: Vec<out::ScanRow> = Vec::new();

    for mp in &mark_prices {
        // Only perp markets
        if !mp.symbol.ends_with("_PERP") {
            continue;
        }

        let asset = perp_base_asset(&mp.symbol);

        // Funding APR: rate * 24 * 365 (1-hour intervals)
        let funding_rate_f64 = decimal_to_f64(mp.funding_rate);
        let funding_apr = funding_rate_f64 * 24.0 * 365.0 * 100.0;

        // Lending APR (API returns annualized rate already)
        let lending_rate = lending_map.get(asset).copied().unwrap_or(0.0);
        let lending_apr = lending_rate * 100.0;

        let total_apr = funding_apr + lending_apr;

        // Basis (mark - index) / index * 10000
        let index_f64 = decimal_to_f64(mp.index_price);
        let mark_f64 = decimal_to_f64(mp.mark_price);
        let basis_bps = if index_f64 != 0.0 {
            (mark_f64 - index_f64) / index_f64 * 10000.0
        } else {
            0.0
        };

        // Volume from perp ticker
        let volume_24h = ticker_map
            .get(mp.symbol.as_str())
            .map(|t| decimal_to_f64(t.volume) * mark_f64)
            .unwrap_or(0.0);

        // OI: use net_exposure from position if available, else volume as proxy
        let oi = volume_24h * 0.5; // Rough proxy; real OI not in public API

        // Next funding time
        let now_secs = chrono::Utc::now().timestamp() as u64;
        let next_ts = mp.next_funding_timestamp;
        // Timestamp could be in seconds or milliseconds
        let next_secs = if next_ts > 1_000_000_000_000 {
            next_ts / 1000
        } else {
            next_ts
        };
        let next_mins = if next_secs > now_secs {
            ((next_secs - now_secs) / 60) as i64
        } else {
            0
        };

        rows.push(out::ScanRow {
            asset: asset.to_string(),
            basis_bps,
            funding_apr,
            lending_apr,
            total_apr,
            volume_24h,
            open_interest: oi,
            next_funding_mins: next_mins,
        });
    }

    // Filter by minimum yield
    rows.retain(|r| r.total_apr >= min_yield);

    // Sort
    match sort {
        ScanSort::Yield => rows.sort_by(|a, b| b.total_apr.partial_cmp(&a.total_apr).unwrap()),
        ScanSort::Funding => {
            rows.sort_by(|a, b| b.funding_apr.partial_cmp(&a.funding_apr).unwrap())
        }
        ScanSort::Lending => {
            rows.sort_by(|a, b| b.lending_apr.partial_cmp(&a.lending_apr).unwrap())
        }
    }

    // Limit
    rows.truncate(limit);

    out::print_scan(&rows, format);
    Ok(())
}

// ---------------------------------------------------------------------------
// Enter
// ---------------------------------------------------------------------------

#[allow(clippy::too_many_arguments)]
async fn run_enter(
    config: &ResolvedConfig,
    secret: Option<&str>,
    base_url: Option<&str>,
    format: OutputFormat,
    retry_config: &RetryConfig,
    asset: &str,
    size: &str,
    auto_lend: bool,
    use_limit: bool,
    max_spread_bps: f64,
    dry_run: bool,
    skip_confirm: bool,
) -> Result<()> {
    let _ = use_limit; // reserved for future limit-order entry
    let c = client::authenticated_client(config, secret, base_url)?;
    let resolved_base = auth::resolve_base_url(base_url, config);

    let (spot_symbol, perp_symbol) = resolve_symbols(asset);

    // Pre-flight: fetch mark prices, tickers, lending rates
    let sp = client::spinner_for("Preparing basis trade...", format);
    let (mark_res, ticker_res, lending_res) = tokio::join!(
        c.get_all_mark_prices(),
        c.get_ticker(&spot_symbol),
        fetch_lending_markets(&resolved_base),
    );
    sp.finish_and_clear();

    let mark_prices = mark_res.map_err(|e| anyhow::anyhow!("{e}"))?;
    let spot_ticker = ticker_res.map_err(|e| anyhow::anyhow!("{e}"))?;
    let lending_markets = lending_res.unwrap_or_default();

    let spot_price = spot_ticker.last_price;
    let qty = parse_size(size, spot_price)?;

    // Find perp mark price data
    let perp_mp = mark_prices
        .iter()
        .find(|p| p.symbol == perp_symbol)
        .ok_or_else(|| anyhow::anyhow!("Perp market {perp_symbol} not found in mark prices"))?;

    // Check spread
    let index_f64 = decimal_to_f64(perp_mp.index_price);
    let mark_f64 = decimal_to_f64(perp_mp.mark_price);
    let spread_bps = if index_f64 != 0.0 {
        ((mark_f64 - index_f64) / index_f64 * 10000.0).abs()
    } else {
        0.0
    };
    if spread_bps > max_spread_bps {
        anyhow::bail!(
            "Spread ({:.1}bp) exceeds maximum ({:.1}bp). Use --max-spread-bps to adjust.",
            spread_bps,
            max_spread_bps
        );
    }

    // Compute estimated APRs
    let funding_rate_f64 = decimal_to_f64(perp_mp.funding_rate);
    let est_funding_apr = funding_rate_f64 * 24.0 * 365.0 * 100.0;

    let lending_rate = lending_markets
        .iter()
        .find(|m| m.symbol == asset.to_uppercase())
        .map(|m| m.rate())
        .unwrap_or(0.0);
    let est_lending_apr = if auto_lend { lending_rate * 100.0 } else { 0.0 };
    let est_total_apr = est_funding_apr + est_lending_apr;

    let spot_price_str = spot_price.to_string();
    let notional = qty * spot_price;

    let plan = out::EnterPlan {
        asset: asset.to_uppercase(),
        spot_symbol: spot_symbol.clone(),
        perp_symbol: perp_symbol.clone(),
        spot_qty: qty.to_string(),
        spot_price: spot_price_str,
        spot_notional: notional.round_dp(2).to_string(),
        perp_qty: qty.to_string(),
        auto_lend,
        estimated_funding_apr: est_funding_apr,
        estimated_lending_apr: est_lending_apr,
        estimated_total_apr: est_total_apr,
    };

    if dry_run {
        out::print_enter_plan(&plan, format);
        return Ok(());
    }

    out::print_enter_plan(&plan, format);

    // Confirmation
    if !skip_confirm && format == OutputFormat::Table && std::io::stdin().is_terminal() {
        use dialoguer::Confirm;
        let confirmed = Confirm::new()
            .with_prompt("Execute this basis trade?")
            .default(false)
            .interact()?;
        if !confirmed {
            println!("Cancelled.");
            return Ok(());
        }
    }

    let json_mode = format == OutputFormat::Json;

    // Leg 1: Buy spot
    let spot_payload = ExecuteOrderPayload {
        symbol: spot_symbol.clone(),
        side: Side::Bid,
        order_type: OrderType::Market,
        time_in_force: Some(TimeInForce::GTC),
        quantity: Some(qty),
        auto_lend: if auto_lend { Some(true) } else { None },
        ..Default::default()
    };

    let sp = client::spinner_for("Buying spot...", format);
    let spot_payload_clone = spot_payload.clone();
    let spot_result = crate::retry::with_retry(retry_config, json_mode, || {
        let p = spot_payload_clone.clone();
        let client = &c;
        async move {
            client
                .execute_order(p)
                .await
                .map_err(|e| anyhow::anyhow!("{e}"))
        }
    })
    .await;
    sp.finish_and_clear();

    let spot_order = spot_result?;
    let spot_fill_qty = executed_qty(&spot_order);

    if spot_fill_qty == Decimal::ZERO {
        anyhow::bail!("Spot buy received zero fill. No perp leg will be opened.");
    }

    // Leg 2: Short perp, sized to actual spot fill
    let perp_payload = ExecuteOrderPayload {
        symbol: perp_symbol.clone(),
        side: Side::Ask,
        order_type: OrderType::Market,
        time_in_force: Some(TimeInForce::GTC),
        quantity: Some(spot_fill_qty),
        ..Default::default()
    };

    let sp = client::spinner_for("Shorting perp...", format);
    let perp_payload_clone = perp_payload.clone();
    let perp_result = crate::retry::with_retry(retry_config, json_mode, || {
        let p = perp_payload_clone.clone();
        let client = &c;
        async move {
            client
                .execute_order(p)
                .await
                .map_err(|e| anyhow::anyhow!("{e}"))
        }
    })
    .await;
    sp.finish_and_clear();

    match perp_result {
        Ok(perp_order) => {
            let perp_fill_qty = executed_qty(&perp_order);
            out::print_enter_success(
                &asset.to_uppercase(),
                &spot_fill_qty.to_string(),
                &perp_fill_qty.to_string(),
                format,
            );
            if perp_fill_qty < spot_fill_qty {
                let s = crate::style::Styles::new();
                eprintln!(
                    "{} Perp fill ({}) < spot fill ({}). Position is partially unhedged.",
                    "!".style(s.yellow),
                    perp_fill_qty,
                    spot_fill_qty,
                );
            }
        }
        Err(e) => {
            let s = crate::style::Styles::new();
            eprintln!(
                "{} Perp short FAILED after spot buy succeeded. You have an unhedged spot position of {} {}.",
                "WARNING".style(s.red),
                spot_fill_qty,
                spot_symbol,
            );
            return Err(e);
        }
    }

    Ok(())
}

// ---------------------------------------------------------------------------
// List
// ---------------------------------------------------------------------------

async fn run_list(
    config: &ResolvedConfig,
    secret: Option<&str>,
    base_url: Option<&str>,
    format: OutputFormat,
) -> Result<()> {
    let c = client::authenticated_client(config, secret, base_url)?;

    let sp = client::spinner_for("Detecting basis trades...", format);
    let (positions_res, balances_res, marks_res) = tokio::join!(
        c.get_open_future_positions(),
        c.get_balances(),
        c.get_all_mark_prices(),
    );
    sp.finish_and_clear();

    let positions = positions_res.map_err(|e| anyhow::anyhow!("{e}"))?;
    let balances = balances_res.map_err(|e| anyhow::anyhow!("{e}"))?;
    let mark_prices = marks_res.map_err(|e| anyhow::anyhow!("{e}"))?;

    let mark_map: HashMap<&str, &bpx_api_types::markets::MarkPrice> =
        mark_prices.iter().map(|p| (p.symbol.as_str(), p)).collect();

    let mut rows: Vec<out::ListRow> = Vec::new();

    for pos in &positions {
        if !pos.symbol.ends_with("_PERP") {
            continue;
        }
        let net_qty = Decimal::from_str(&pos.net_quantity).unwrap_or(Decimal::ZERO);
        if net_qty >= Decimal::ZERO {
            continue; // Only short perps are basis trades
        }

        let asset = perp_base_asset(&pos.symbol);

        // Check if there's a corresponding spot balance
        let spot_balance = balances.get(asset);
        let spot_qty = spot_balance
            .map(|b| b.available + b.locked + b.staked)
            .unwrap_or(Decimal::ZERO);

        if spot_qty <= Decimal::ZERO {
            continue; // No spot to hedge against
        }

        let perp_qty_abs = net_qty.abs();
        let hedge_ratio = if spot_qty > Decimal::ZERO {
            (perp_qty_abs / spot_qty * Decimal::from(100)).round_dp(0)
        } else {
            Decimal::ZERO
        };

        // PnL
        let pnl_unreal = Decimal::from_str(&pos.pnl_unrealized).unwrap_or(Decimal::ZERO);
        let pnl_real = Decimal::from_str(&pos.pnl_realized).unwrap_or(Decimal::ZERO);
        let net_pnl = pnl_unreal + pnl_real;
        let funding_earned =
            Decimal::from_str(&pos.cumulative_funding_payment).unwrap_or(Decimal::ZERO);

        // Current APR from mark prices
        let total_apr = mark_map
            .get(pos.symbol.as_str())
            .map(|mp| {
                let rate = decimal_to_f64(mp.funding_rate);
                rate * 24.0 * 365.0 * 100.0
            })
            .unwrap_or(0.0);

        let net_pnl_str = if net_pnl >= Decimal::ZERO {
            format!("+${}", net_pnl.round_dp(2))
        } else {
            format!("-${}", net_pnl.abs().round_dp(2))
        };

        rows.push(out::ListRow {
            asset: asset.to_string(),
            spot_qty: spot_qty.round_dp(4).to_string(),
            perp_qty: perp_qty_abs.round_dp(4).to_string(),
            hedge_pct: format!("{}%", hedge_ratio),
            net_pnl: net_pnl_str,
            total_apr,
            earned: format!("${}", funding_earned.abs().round_dp(2)),
        });
    }

    out::print_list(&rows, format);
    Ok(())
}

// ---------------------------------------------------------------------------
// Status
// ---------------------------------------------------------------------------

async fn run_status(
    config: &ResolvedConfig,
    secret: Option<&str>,
    base_url: Option<&str>,
    format: OutputFormat,
    asset: &str,
) -> Result<()> {
    let c = client::authenticated_client(config, secret, base_url)?;
    let resolved_base = auth::resolve_base_url(base_url, config);

    let (spot_symbol, perp_symbol) = resolve_symbols(asset);

    let sp = client::spinner_for("Fetching trade status...", format);
    let (positions_res, balances_res, marks_res, collateral_res, lending_res) = tokio::join!(
        c.get_open_future_positions(),
        c.get_balances(),
        c.get_all_mark_prices(),
        c.get_collateral(),
        fetch_lending_markets(&resolved_base),
    );
    sp.finish_and_clear();

    let positions = positions_res.map_err(|e| anyhow::anyhow!("{e}"))?;
    let balances = balances_res.map_err(|e| anyhow::anyhow!("{e}"))?;
    let mark_prices = marks_res.map_err(|e| anyhow::anyhow!("{e}"))?;
    let collateral = collateral_res.map_err(|e| anyhow::anyhow!("{e}"))?;
    let lending_markets = lending_res.unwrap_or_default();

    let asset_upper = asset.to_uppercase();

    // Find perp position
    let pos = positions
        .iter()
        .find(|p| p.symbol == perp_symbol)
        .ok_or_else(|| anyhow::anyhow!("No perp position found for {perp_symbol}"))?;

    let net_qty = Decimal::from_str(&pos.net_quantity).unwrap_or(Decimal::ZERO);
    if net_qty >= Decimal::ZERO {
        anyhow::bail!(
            "Position on {perp_symbol} is not short (net_qty: {net_qty}). Not a basis trade."
        );
    }

    // Spot balance
    let spot_balance = balances.get(&asset_upper);
    let spot_qty = spot_balance
        .map(|b| b.available + b.locked + b.staked)
        .unwrap_or(Decimal::ZERO);

    let perp_qty_abs = net_qty.abs();
    let hedge_ratio = if spot_qty > Decimal::ZERO {
        format!(
            "{}%",
            (perp_qty_abs / spot_qty * Decimal::from(100)).round_dp(1)
        )
    } else {
        "N/A".to_string()
    };

    // Mark price data
    let perp_mp = mark_prices.iter().find(|p| p.symbol == perp_symbol);

    let mark_price_str = perp_mp
        .map(|p| p.mark_price.to_string())
        .unwrap_or_else(|| pos.mark_price.clone());

    let spot_price = perp_mp.map(|p| p.index_price).unwrap_or(Decimal::ZERO);
    let spot_value = (spot_qty * spot_price).round_dp(2);

    // Funding APR
    let funding_rate = perp_mp
        .map(|p| decimal_to_f64(p.funding_rate))
        .unwrap_or(0.0);
    let funding_apr = funding_rate * 24.0 * 365.0 * 100.0;

    // Lending APR
    let lending_rate = lending_markets
        .iter()
        .find(|m| m.symbol == asset_upper)
        .map(|m| m.rate())
        .unwrap_or(0.0);
    let lending_apr = lending_rate * 100.0;

    let total_apr = funding_apr + lending_apr;

    // Margin fraction
    let mf = collateral
        .margin_fraction
        .map(|m| {
            let pct = m * Decimal::from(100);
            format!("{:.1}%", pct)
        })
        .unwrap_or_else(|| "N/A".to_string());

    let pnl_unreal = &pos.pnl_unrealized;
    let pnl_real = &pos.pnl_realized;
    let funding_earned = &pos.cumulative_funding_payment;

    // Lending interest from borrow/lend positions (cumulative_interest)
    let lending_earned = "N/A".to_string(); // Would require borrow/lend position lookup

    let data = out::StatusData {
        asset: asset_upper,
        spot_symbol,
        perp_symbol,
        spot_qty: spot_qty.round_dp(4).to_string(),
        spot_value: spot_value.to_string(),
        perp_qty: format!("-{}", perp_qty_abs.round_dp(4)),
        perp_entry: pos.entry_price.clone(),
        perp_mark: mark_price_str,
        perp_pnl_unrealized: format_signed_decimal(pnl_unreal),
        perp_pnl_realized: format_signed_decimal(pnl_real),
        funding_earned: format_signed_decimal(funding_earned),
        lending_earned,
        hedge_ratio,
        current_funding_apr: funding_apr,
        current_lending_apr: lending_apr,
        total_apr,
        margin_fraction: mf,
    };

    out::print_status(&data, format);
    Ok(())
}

// ---------------------------------------------------------------------------
// Exit
// ---------------------------------------------------------------------------

#[allow(clippy::too_many_arguments)]
async fn run_exit(
    config: &ResolvedConfig,
    secret: Option<&str>,
    base_url: Option<&str>,
    format: OutputFormat,
    retry_config: &RetryConfig,
    asset: &str,
    percent: f64,
    skip_confirm: bool,
) -> Result<()> {
    let c = client::authenticated_client(config, secret, base_url)?;
    let (spot_symbol, perp_symbol) = resolve_symbols(asset);

    // Fetch positions and balances
    let sp = client::spinner_for("Fetching position data...", format);
    let (positions_res, balances_res) =
        tokio::join!(c.get_open_future_positions(), c.get_balances(),);
    sp.finish_and_clear();

    let positions = positions_res.map_err(|e| anyhow::anyhow!("{e}"))?;
    let balances = balances_res.map_err(|e| anyhow::anyhow!("{e}"))?;

    let pos = positions
        .iter()
        .find(|p| p.symbol == perp_symbol)
        .ok_or_else(|| anyhow::anyhow!("No perp position found for {perp_symbol}"))?;

    let net_qty = Decimal::from_str(&pos.net_quantity).unwrap_or(Decimal::ZERO);
    if net_qty >= Decimal::ZERO {
        anyhow::bail!("Position on {perp_symbol} is not short. Cannot exit basis trade.");
    }

    let asset_upper = asset.to_uppercase();
    let spot_balance = balances.get(&asset_upper);
    let spot_qty = spot_balance
        .map(|b| b.available + b.locked + b.staked)
        .unwrap_or(Decimal::ZERO);

    let pct = Decimal::from_str(&format!("{percent}")).unwrap_or(Decimal::from(100));
    let pct_frac = pct / Decimal::from(100);

    let perp_close_qty = (net_qty.abs() * pct_frac).round_dp(4);
    let spot_sell_qty = (spot_qty * pct_frac).round_dp(4);

    if perp_close_qty == Decimal::ZERO {
        anyhow::bail!("Computed perp close quantity is zero.");
    }

    // Confirmation
    if !skip_confirm && format == OutputFormat::Table && std::io::stdin().is_terminal() {
        use dialoguer::Confirm;
        let msg = format!(
            "Exit basis trade on {}? (close perp: {}, sell spot: {}, {}%)",
            asset_upper, perp_close_qty, spot_sell_qty, percent
        );
        let confirmed = Confirm::new().with_prompt(msg).default(false).interact()?;
        if !confirmed {
            println!("Cancelled.");
            return Ok(());
        }
    }

    let json_mode = format == OutputFormat::Json;

    // Leg 1: Close perp first (buy to cover, reduce-only)
    let perp_payload = ExecuteOrderPayload {
        symbol: perp_symbol.clone(),
        side: Side::Bid,
        order_type: OrderType::Market,
        time_in_force: Some(TimeInForce::GTC),
        quantity: Some(perp_close_qty),
        reduce_only: Some(true),
        ..Default::default()
    };

    let sp = client::spinner_for("Closing perp position...", format);
    let perp_payload_clone = perp_payload.clone();
    let perp_result = crate::retry::with_retry(retry_config, json_mode, || {
        let p = perp_payload_clone.clone();
        let client = &c;
        async move {
            client
                .execute_order(p)
                .await
                .map_err(|e| anyhow::anyhow!("{e}"))
        }
    })
    .await;
    sp.finish_and_clear();
    perp_result?;

    // Leg 2: Sell spot
    if spot_sell_qty > Decimal::ZERO {
        let spot_payload = ExecuteOrderPayload {
            symbol: spot_symbol.clone(),
            side: Side::Ask,
            order_type: OrderType::Market,
            time_in_force: Some(TimeInForce::GTC),
            quantity: Some(spot_sell_qty),
            ..Default::default()
        };

        let sp = client::spinner_for("Selling spot...", format);
        let spot_payload_clone = spot_payload.clone();
        let spot_result = crate::retry::with_retry(retry_config, json_mode, || {
            let p = spot_payload_clone.clone();
            let client = &c;
            async move {
                client
                    .execute_order(p)
                    .await
                    .map_err(|e| anyhow::anyhow!("{e}"))
            }
        })
        .await;
        sp.finish_and_clear();
        spot_result?;
    }

    out::print_exit_success(&asset_upper, &format!("{:.0}", percent), format);
    Ok(())
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

fn decimal_to_f64(d: Decimal) -> f64 {
    d.to_string().parse::<f64>().unwrap_or(0.0)
}

fn format_signed_decimal(s: &str) -> String {
    match s.parse::<f64>() {
        Ok(v) if v > 0.0 => format!("+${:.2}", v),
        Ok(v) if v < 0.0 => format!("-${:.2}", v.abs()),
        _ => format!("${s}"),
    }
}
