use std::sync::Arc;
use std::time::Duration;

use crate::news::{NewsService, QueryNewsFeedRequest};
use crate::portfolio::{
    CashConfirmation, PortfolioCommandError, PortfolioManagement, PortfolioStats,
    PortfolioTransaction, TradeConfirmation, TransactionKind,
};
use crate::terminal::mock_data::load_mock_financial_data;
use crate::terminal::sec_edgar::{EdgarDataLookup, EdgarLookupError};
use crate::terminal::security_lookup::{
    SecurityKind, SecurityLookup, SecurityLookupError, SecurityMatch,
};
use crate::terminal::{
    ChatCommandRequest, ErrorPanel, ExecuteTerminalCommandRequest, Frequency, MockFinancialData,
    PanelPayload, TerminalCommandResponse,
};

/// Executes supported slash commands against live search plus shared local fixture data.
pub struct TerminalCommandService {
    mock_data: MockFinancialData,
    news_service: Arc<NewsService>,
    security_lookup: Arc<dyn SecurityLookup>,
    edgar_lookup: Arc<dyn EdgarDataLookup>,
    portfolio_service: Arc<dyn PortfolioManagement>,
    lookup_followup_delay: Duration,
}

impl TerminalCommandService {
    /// Creates a terminal command service with a custom security lookup backend.
    pub fn new(
        security_lookup: Arc<dyn SecurityLookup>,
        edgar_lookup: Arc<dyn EdgarDataLookup>,
        portfolio_service: Arc<dyn PortfolioManagement>,
        news_service: Arc<NewsService>,
    ) -> Self {
        Self::with_dependencies(
            load_mock_financial_data(),
            security_lookup,
            edgar_lookup,
            portfolio_service,
            news_service,
            DEFAULT_LOOKUP_FOLLOWUP_DELAY,
        )
    }

    fn with_dependencies(
        mock_data: MockFinancialData,
        security_lookup: Arc<dyn SecurityLookup>,
        edgar_lookup: Arc<dyn EdgarDataLookup>,
        portfolio_service: Arc<dyn PortfolioManagement>,
        news_service: Arc<NewsService>,
        lookup_followup_delay: Duration,
    ) -> Self {
        Self {
            mock_data,
            news_service,
            security_lookup,
            edgar_lookup,
            portfolio_service,
            lookup_followup_delay,
        }
    }

    /// Resolves a slash command into either a text reply or a structured panel payload.
    pub async fn execute(&self, request: ExecuteTerminalCommandRequest) -> TerminalCommandResponse {
        let command = parse_command(&request.input);

        match command.command.as_str() {
            "/search" => self.search(command.args.join(" ").trim()).await,
            "/portfolio" => self.portfolio_command(&command.args).await,
            "/buy" => self.buy_command(&command.args).await,
            "/sell" => self.sell_command(&command.args).await,
            "/cash" => self.cash_command(&command.args).await,
            "/fa" => {
                if command.args.len() > 2 {
                    TerminalCommandResponse::Text {
                        content: "Usage: /fa [ticker] [annual|quarterly]".to_string(),
                        portfolio: None,
                    }
                } else {
                    self.financials(command.args.first(), command.args.get(1), Frequency::Annual)
                        .await
                }
            }
            "/cf" => {
                if command.args.len() > 2 {
                    TerminalCommandResponse::Text {
                        content: "Usage: /cf [ticker] [annual|quarterly]".to_string(),
                        portfolio: None,
                    }
                } else {
                    self.cash_flow(command.args.first(), command.args.get(1), Frequency::Annual)
                        .await
                }
            }
            "/dvd" => {
                if command.args.len() > 1 {
                    TerminalCommandResponse::Text {
                        content: "Usage: /dvd [ticker]".to_string(),
                        portfolio: None,
                    }
                } else {
                    self.dividends(command.args.first(), command.args.get(1))
                        .await
                }
            }
            "/em" => {
                if command.args.len() > 2 {
                    TerminalCommandResponse::Text {
                        content: "Usage: /em [ticker] [annual|quarterly]".to_string(),
                        portfolio: None,
                    }
                } else {
                    self.earnings(
                        command.args.first(),
                        command.args.get(1),
                        Frequency::Quarterly,
                    )
                    .await
                }
            }
            "/news" => self.news(command.args.first().map(String::as_str)).await,
            "/analyze" => self.analyze(command.args.first().map(String::as_str)),
            "/help" => help_response(),
            _ => TerminalCommandResponse::Text {
                content: format!("Unknown command: {}\n\n{}", command.command, help_text()),
                portfolio: None,
            },
        }
    }

    /// Looks up a live company snapshot directly from the configured quote provider.
    pub async fn lookup_company(&self, symbol: &str) -> Result<crate::terminal::Company, String> {
        let normalized_symbol = symbol.trim().to_ascii_uppercase();

        if normalized_symbol.is_empty() {
            return Err("Ticker symbol required.".to_string());
        }

        let security_match = SecurityMatch {
            symbol: normalized_symbol.clone(),
            name: None,
            exchange: None,
            kind: SecurityKind::Equity,
        };

        self.security_lookup
            .load_company(&security_match)
            .await
            .map_err(|error| match error {
                SecurityLookupError::DetailUnavailable { symbol, detail } => format!(
                    "{} quote unavailable for {symbol}: {detail}",
                    self.security_lookup.provider_name()
                ),
                SecurityLookupError::SearchUnavailable { query, detail } => format!(
                    "{} quote unavailable for {query}: {detail}",
                    self.security_lookup.provider_name()
                ),
            })
    }

    async fn search(&self, query: &str) -> TerminalCommandResponse {
        let query = query.trim();

        if query.is_empty() {
            return search_error_response(
                "Search query required",
                "Enter a ticker or company name.",
                Some("Usage: /search [ticker or company name]".to_string()),
                self.security_lookup.provider_name(),
                None,
                None,
            );
        }

        if looks_like_symbol_query(query) {
            return self
                .load_search_match(
                    query,
                    SecurityMatch {
                        symbol: query.to_ascii_uppercase(),
                        name: None,
                        exchange: None,
                        kind: SecurityKind::Equity,
                    },
                    false,
                )
                .await;
        }

        let matches = match self.security_lookup.search(query).await {
            Ok(matches) => matches
                .into_iter()
                .filter(|security_match| security_match.kind.is_supported())
                .collect::<Vec<_>>(),
            Err(SecurityLookupError::SearchUnavailable { detail, .. }) => {
                return search_error_response(
                    &format!("{} search failed", self.security_lookup.provider_name()),
                    "The live search request did not complete.",
                    Some(detail),
                    self.security_lookup.provider_name(),
                    Some(query.to_string()),
                    None,
                )
            }
            Err(SecurityLookupError::DetailUnavailable { detail, .. }) => {
                return search_error_response(
                    &format!("{} search failed", self.security_lookup.provider_name()),
                    "The live search request did not complete.",
                    Some(detail),
                    self.security_lookup.provider_name(),
                    Some(query.to_string()),
                    None,
                )
            }
        };

        if matches.is_empty() {
            return search_error_response(
                "No supported search results",
                &format!(
                    "{} did not return any supported equities or funds.",
                    self.security_lookup.provider_name()
                ),
                None,
                self.security_lookup.provider_name(),
                Some(query.to_string()),
                None,
            );
        }

        let Some(selected_match) = select_best_match(query, &matches) else {
            return search_error_response(
                "No supported search results",
                &format!(
                    "{} did not return any supported equities or funds.",
                    self.security_lookup.provider_name()
                ),
                None,
                self.security_lookup.provider_name(),
                Some(query.to_string()),
                None,
            );
        };

        self.load_search_match(query, selected_match, true).await
    }

    async fn news(&self, ticker: Option<&str>) -> TerminalCommandResponse {
        let normalized_ticker = ticker.map(|value| value.trim().to_uppercase());
        let response = self
            .news_service
            .query_feed(QueryNewsFeedRequest {
                ticker: normalized_ticker.clone().filter(|value| !value.is_empty()),
                search: None,
                only_highlighted: None,
                only_saved: None,
                only_unread: None,
                limit: Some(50),
                offset: Some(0),
            })
            .await;

        let news_items = match response {
            Ok(response) => response.articles,
            Err(error) => {
                return TerminalCommandResponse::Text {
                    content: format!("News feed unavailable: {error}"),
                    portfolio: None,
                };
            }
        };

        TerminalCommandResponse::panel(PanelPayload::News {
            data: news_items,
            ticker: normalized_ticker.filter(|value| !value.is_empty()),
        })
    }

    fn analyze(&self, ticker: Option<&str>) -> TerminalCommandResponse {
        let Some(ticker) = ticker.map(str::trim).filter(|value| !value.is_empty()) else {
            return TerminalCommandResponse::Text {
                content: "Usage: /analyze [ticker]".to_string(),
                portfolio: None,
            };
        };

        match self.mock_data.analyses.get(&ticker.to_uppercase()) {
            Some(analysis) => TerminalCommandResponse::panel(PanelPayload::Analysis {
                data: analysis.clone(),
            }),
            None => TerminalCommandResponse::Text {
                content: format!("Analysis not available for \"{ticker}\"."),
                portfolio: None,
            },
        }
    }

    async fn portfolio_command(&self, args: &[String]) -> TerminalCommandResponse {
        match args {
            [] => match self.portfolio_service.portfolio().await {
                Ok(portfolio) => {
                    TerminalCommandResponse::panel(PanelPayload::Portfolio { data: portfolio })
                }
                Err(error) => portfolio_error_response(error),
            },
            [subcommand] if subcommand.eq_ignore_ascii_case("stats") => {
                match self.portfolio_service.stats().await {
                    Ok(stats) => TerminalCommandResponse::Text {
                        content: format_portfolio_stats(&stats),
                        portfolio: None,
                    },
                    Err(error) => portfolio_error_response(error),
                }
            }
            [subcommand] if subcommand.eq_ignore_ascii_case("history") => {
                self.portfolio_history_response(10).await
            }
            [subcommand, limit] if subcommand.eq_ignore_ascii_case("history") => {
                let Some(limit) = parse_positive_usize(limit) else {
                    return TerminalCommandResponse::Text {
                        content: "Usage: /portfolio history [limit]".to_string(),
                        portfolio: None,
                    };
                };
                self.portfolio_history_response(limit).await
            }
            _ => TerminalCommandResponse::Text {
                content: "Usage: /portfolio [stats|history [limit]]".to_string(),
                portfolio: None,
            },
        }
    }

    async fn portfolio_history_response(&self, limit: usize) -> TerminalCommandResponse {
        match self.portfolio_service.history(limit).await {
            Ok(history) if history.is_empty() => TerminalCommandResponse::Text {
                content: "Portfolio history is empty.".to_string(),
                portfolio: None,
            },
            Ok(history) => TerminalCommandResponse::Text {
                content: format_portfolio_history(&history),
                portfolio: None,
            },
            Err(error) => portfolio_error_response(error),
        }
    }

    async fn buy_command(&self, args: &[String]) -> TerminalCommandResponse {
        let Some((symbol, quantity, price_override)) = parse_trade_args("/buy", args) else {
            return TerminalCommandResponse::Text {
                content: "Usage: /buy [ticker] [quantity] [price?]".to_string(),
                portfolio: None,
            };
        };

        match self
            .portfolio_service
            .buy(symbol.as_str(), quantity, price_override)
            .await
        {
            Ok(confirmation) => {
                self.text_response_with_portfolio(format_buy_confirmation(&confirmation))
                    .await
            }
            Err(error) => portfolio_error_response(error),
        }
    }

    async fn sell_command(&self, args: &[String]) -> TerminalCommandResponse {
        let Some((symbol, quantity, price_override)) = parse_trade_args("/sell", args) else {
            return TerminalCommandResponse::Text {
                content: "Usage: /sell [ticker] [quantity] [price?]".to_string(),
                portfolio: None,
            };
        };

        match self
            .portfolio_service
            .sell(symbol.as_str(), quantity, price_override)
            .await
        {
            Ok(confirmation) => {
                self.text_response_with_portfolio(format_sell_confirmation(&confirmation))
                    .await
            }
            Err(error) => portfolio_error_response(error),
        }
    }

    async fn cash_command(&self, args: &[String]) -> TerminalCommandResponse {
        let Some((subcommand, amount)) = parse_cash_args(args) else {
            return TerminalCommandResponse::Text {
                content: "Usage: /cash [deposit|withdraw] [amount]".to_string(),
                portfolio: None,
            };
        };

        let result = if subcommand.eq_ignore_ascii_case("deposit") {
            self.portfolio_service.deposit_cash(amount).await
        } else {
            self.portfolio_service.withdraw_cash(amount).await
        };

        match result {
            Ok(confirmation) => {
                self.text_response_with_portfolio(format_cash_confirmation(&confirmation))
                    .await
            }
            Err(error) => portfolio_error_response(error),
        }
    }

    async fn text_response_with_portfolio(&self, content: String) -> TerminalCommandResponse {
        let portfolio = self.portfolio_service.portfolio().await.ok();

        TerminalCommandResponse::Text { content, portfolio }
    }

    async fn financials(
        &self,
        ticker: Option<&String>,
        period: Option<&String>,
        default_frequency: Frequency,
    ) -> TerminalCommandResponse {
        let (ticker, frequency) =
            match parse_symbol_and_frequency("/fa", ticker, period, default_frequency) {
                Ok(value) => value,
                Err(response) => return *response,
            };

        match self.edgar_lookup.financials(&ticker, frequency).await {
            Ok(data) => TerminalCommandResponse::panel(PanelPayload::Financials { data }),
            Err(error) => sec_error_response("SEC financials unavailable", &ticker, error),
        }
    }

    async fn cash_flow(
        &self,
        ticker: Option<&String>,
        period: Option<&String>,
        default_frequency: Frequency,
    ) -> TerminalCommandResponse {
        let (ticker, frequency) =
            match parse_symbol_and_frequency("/cf", ticker, period, default_frequency) {
                Ok(value) => value,
                Err(response) => return *response,
            };

        match self.edgar_lookup.cash_flow(&ticker, frequency).await {
            Ok(data) => TerminalCommandResponse::panel(PanelPayload::CashFlow { data }),
            Err(error) => sec_error_response("SEC cash flow unavailable", &ticker, error),
        }
    }

    async fn dividends(
        &self,
        ticker: Option<&String>,
        extra_arg: Option<&String>,
    ) -> TerminalCommandResponse {
        let ticker = match ticker
            .map(|value| value.trim())
            .filter(|value| !value.is_empty())
        {
            Some(value) if extra_arg.is_none() => value.to_ascii_uppercase(),
            Some(_) => {
                return TerminalCommandResponse::Text {
                    content: "Usage: /dvd [ticker]".to_string(),
                    portfolio: None,
                }
            }
            None => {
                return TerminalCommandResponse::Text {
                    content: "Usage: /dvd [ticker]".to_string(),
                    portfolio: None,
                }
            }
        };

        match self.edgar_lookup.dividends(&ticker).await {
            Ok(data) => TerminalCommandResponse::panel(PanelPayload::Dividends { data }),
            Err(error) => sec_error_response("SEC dividends unavailable", &ticker, error),
        }
    }

    async fn earnings(
        &self,
        ticker: Option<&String>,
        period: Option<&String>,
        default_frequency: Frequency,
    ) -> TerminalCommandResponse {
        let (ticker, frequency) =
            match parse_symbol_and_frequency("/em", ticker, period, default_frequency) {
                Ok(value) => value,
                Err(response) => return *response,
            };

        match self.edgar_lookup.earnings(&ticker, frequency).await {
            Ok(data) => TerminalCommandResponse::panel(PanelPayload::Earnings { data }),
            Err(error) => sec_error_response("SEC earnings unavailable", &ticker, error),
        }
    }

    async fn load_search_match(
        &self,
        query: &str,
        security_match: SecurityMatch,
        apply_followup_delay: bool,
    ) -> TerminalCommandResponse {
        if apply_followup_delay && !self.lookup_followup_delay.is_zero() {
            tokio::time::sleep(self.lookup_followup_delay).await;
        }

        match self.security_lookup.load_company(&security_match).await {
            Ok(company) => TerminalCommandResponse::panel(PanelPayload::Company { data: company }),
            Err(SecurityLookupError::DetailUnavailable { symbol, detail }) => {
                search_error_response(
                    &format!("{} quote unavailable", self.security_lookup.provider_name()),
                    "The selected result could not be expanded into a stock overview card.",
                    Some(detail),
                    self.security_lookup.provider_name(),
                    Some(query.to_string()),
                    Some(symbol),
                )
            }
            Err(SecurityLookupError::SearchUnavailable { detail, .. }) => search_error_response(
                &format!("{} quote unavailable", self.security_lookup.provider_name()),
                "The selected result could not be expanded into a stock overview card.",
                Some(detail),
                self.security_lookup.provider_name(),
                Some(query.to_string()),
                Some(security_match.symbol),
            ),
        }
    }
}

const DEFAULT_LOOKUP_FOLLOWUP_DELAY: Duration = Duration::ZERO;

fn looks_like_symbol_query(query: &str) -> bool {
    !query.is_empty()
        && !query.contains(char::is_whitespace)
        && query.len() <= 10
        && query == query.to_ascii_uppercase()
        && query.chars().all(|character| {
            character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '^' | '=')
        })
}

fn select_best_match(query: &str, matches: &[SecurityMatch]) -> Option<SecurityMatch> {
    if let Some(exact_symbol_match) = select_exact_symbol_match(query, matches) {
        return Some(exact_symbol_match);
    }

    if let Some(exact_name_match) = matches.iter().find(|security_match| {
        security_match
            .name
            .as_deref()
            .is_some_and(|name| name.eq_ignore_ascii_case(query))
    }) {
        return Some(exact_name_match.clone());
    }

    matches.first().cloned()
}

fn select_exact_symbol_match(query: &str, matches: &[SecurityMatch]) -> Option<SecurityMatch> {
    matches
        .iter()
        .enumerate()
        .filter(|(_, security_match)| security_match.symbol.eq_ignore_ascii_case(query))
        .min_by_key(|(index, security_match)| {
            (
                exchange_priority(security_match.exchange.as_deref()),
                *index,
            )
        })
        .map(|(_, security_match)| security_match.clone())
}

fn exchange_priority(exchange: Option<&str>) -> usize {
    match exchange {
        Some("NASDAQ") => 0,
        Some("NYSE") => 1,
        Some("AMEX") => 2,
        Some("NYSEARCA") => 3,
        Some("BATS") => 4,
        _ => 5,
    }
}

fn search_error_response(
    title: &str,
    message: &str,
    detail: Option<String>,
    provider: &str,
    query: Option<String>,
    symbol: Option<String>,
) -> TerminalCommandResponse {
    TerminalCommandResponse::panel(PanelPayload::Error {
        data: ErrorPanel {
            title: title.to_string(),
            message: message.to_string(),
            detail,
            provider: Some(provider.to_string()),
            query,
            symbol,
        },
    })
}

fn sec_error_response(
    title: &str,
    ticker: &str,
    error: EdgarLookupError,
) -> TerminalCommandResponse {
    TerminalCommandResponse::panel(PanelPayload::Error {
        data: ErrorPanel {
            title: title.to_string(),
            message: "The SEC EDGAR request did not complete.".to_string(),
            detail: Some(error.to_string()),
            provider: Some("SEC EDGAR".to_string()),
            query: Some(ticker.to_string()),
            symbol: Some(ticker.to_string()),
        },
    })
}

fn parse_symbol_and_frequency(
    command: &str,
    ticker: Option<&String>,
    period: Option<&String>,
    default_frequency: Frequency,
) -> Result<(String, Frequency), Box<TerminalCommandResponse>> {
    let Some(ticker) = ticker
        .map(|value| value.trim())
        .filter(|value| !value.is_empty())
    else {
        return Err(Box::new(TerminalCommandResponse::Text {
            content: format!("Usage: {command} [ticker] [annual|quarterly]"),
            portfolio: None,
        }));
    };

    let frequency = match period.map(|value| value.trim().to_ascii_lowercase()) {
        None => default_frequency,
        Some(value) if value == "annual" => Frequency::Annual,
        Some(value) if value == "quarterly" => Frequency::Quarterly,
        Some(_) => {
            return Err(Box::new(TerminalCommandResponse::Text {
                content: format!("Usage: {command} [ticker] [annual|quarterly]"),
                portfolio: None,
            }))
        }
    };

    Ok((ticker.to_ascii_uppercase(), frequency))
}

fn parse_trade_args(command: &str, args: &[String]) -> Option<(String, f64, Option<f64>)> {
    if args.len() < 2 || args.len() > 3 {
        return None;
    }

    let symbol = args.first()?.trim();
    if symbol.is_empty() {
        return None;
    }

    let quantity = parse_positive_f64(args.get(1)?)?;
    let price_override = match args.get(2) {
        Some(value) => Some(parse_positive_f64(value)?),
        None => None,
    };

    let _ = command;
    Some((symbol.to_ascii_uppercase(), quantity, price_override))
}

fn parse_cash_args(args: &[String]) -> Option<(String, f64)> {
    if args.len() != 2 {
        return None;
    }

    let subcommand = args.first()?.trim().to_ascii_lowercase();
    if !matches!(subcommand.as_str(), "deposit" | "withdraw") {
        return None;
    }

    Some((subcommand, parse_positive_f64(args.get(1)?)?))
}

fn parse_positive_f64(value: &str) -> Option<f64> {
    let parsed = value.trim().parse::<f64>().ok()?;
    (parsed.is_finite() && parsed > 0.0).then_some(parsed)
}

fn parse_positive_usize(value: &str) -> Option<usize> {
    let parsed = value.trim().parse::<usize>().ok()?;
    (parsed > 0).then_some(parsed)
}

/// Parses raw slash-command input into a normalized command plus positional arguments.
fn parse_command(input: &str) -> ChatCommandRequest {
    let trimmed = input.trim();
    let mut parts = trimmed.split_whitespace();
    let command = parts.next().unwrap_or_default().to_ascii_lowercase();
    let args = parts.map(ToString::to_string).collect();

    ChatCommandRequest {
        command,
        args,
        raw_input: trimmed.to_string(),
    }
}

/// Human-readable help text returned for `/help` and unknown commands.
fn help_text() -> &'static str {
    "Available Commands:\n\n  /search [ticker]                 - Search live security data\n  /buy [ticker] [quantity] [price?] - Buy a company into the portfolio\n  /sell [ticker] [quantity] [price?] - Sell a company from the portfolio\n  /cash [deposit|withdraw] [amount] - Adjust portfolio cash\n  /portfolio                       - Show your portfolio\n  /portfolio stats                 - Show portfolio statistics\n  /portfolio history [limit]       - Show recent transactions\n  /fa [ticker] [annual|quarterly]  - SEC financial statements\n  /cf [ticker] [annual|quarterly]  - SEC cash flow summary\n  /dvd [ticker]                    - SEC dividends history\n  /em [ticker] [annual|quarterly]  - SEC earnings history\n  /news [ticker?]                  - Show market news\n  /analyze [ticker]                - Get stock analysis\n  /help                            - Show this help text\n  /clear                           - Clear terminal locally"
}

/// Wraps the shared help text into the terminal command response envelope.
fn help_response() -> TerminalCommandResponse {
    TerminalCommandResponse::Text {
        content: help_text().to_string(),
        portfolio: None,
    }
}

fn portfolio_error_response(error: PortfolioCommandError) -> TerminalCommandResponse {
    TerminalCommandResponse::Text {
        content: error.to_string(),
        portfolio: None,
    }
}

fn format_portfolio_stats(stats: &PortfolioStats) -> String {
    let stale_pricing = if stats.stale_pricing_symbols.is_empty() {
        "none".to_string()
    } else {
        stats.stale_pricing_symbols.join(", ")
    };

    format!(
        "Portfolio Stats\n\nCash Balance: {}\nHoldings Count: {}\nInvested Cost Basis: {}\nEquities Market Value: {}\nTotal Portfolio Value: {}\nUnrealized Gain/Loss: {}\nRealized Gain/Loss: {}\nDay Change: {}\nStale Pricing: {}",
        format_currency(stats.cash_balance),
        stats.holdings_count,
        format_currency(stats.invested_cost_basis),
        format_currency(stats.equities_market_value),
        format_currency(stats.total_portfolio_value),
        format_currency(stats.unrealized_gain),
        format_currency(stats.realized_gain),
        format_currency(stats.day_change),
        stale_pricing,
    )
}

fn format_portfolio_history(transactions: &[PortfolioTransaction]) -> String {
    let rows = transactions
        .iter()
        .map(|transaction| {
            let subject = transaction.symbol.as_deref().unwrap_or("CASH");
            let quantity = transaction
                .quantity
                .map(|value| format!("qty {}", format_quantity(value)))
                .unwrap_or_else(|| "qty -".to_string());
            let price = transaction
                .price
                .map(format_currency)
                .unwrap_or_else(|| "-".to_string());

            format!(
                "{} | {} | {} | {} | price {} | gross {}",
                transaction.executed_at,
                transaction.kind.as_label(),
                subject,
                quantity,
                price,
                format_currency(transaction.gross_amount),
            )
        })
        .collect::<Vec<_>>()
        .join("\n");

    format!("Portfolio History\n\n{rows}")
}

fn format_buy_confirmation(confirmation: &TradeConfirmation) -> String {
    format!(
        "Bought {} {} @ {} for {}. Cash balance: {}.",
        format_quantity(confirmation.quantity),
        confirmation.symbol,
        format_currency(confirmation.price),
        format_currency(confirmation.gross_amount),
        format_currency(confirmation.cash_balance),
    )
}

fn format_sell_confirmation(confirmation: &TradeConfirmation) -> String {
    let realized_gain = confirmation.realized_gain.unwrap_or(0.0);
    format!(
        "Sold {} {} @ {} for {}. Realized P/L: {}. Cash balance: {}.",
        format_quantity(confirmation.quantity),
        confirmation.symbol,
        format_currency(confirmation.price),
        format_currency(confirmation.gross_amount),
        format_currency(realized_gain),
        format_currency(confirmation.cash_balance),
    )
}

fn format_cash_confirmation(confirmation: &CashConfirmation) -> String {
    let action = match confirmation.kind {
        TransactionKind::CashDeposit => "Deposited",
        TransactionKind::CashWithdrawal => "Withdrew",
        TransactionKind::Buy | TransactionKind::Sell => "Adjusted",
    };

    format!(
        "{action} {}. Cash balance: {}.",
        format_currency(confirmation.amount),
        format_currency(confirmation.cash_balance),
    )
}

fn format_currency(value: f64) -> String {
    let sign = if value < 0.0 { "-" } else { "" };
    format!("{sign}${:.2}", value.abs())
}

fn format_quantity(value: f64) -> String {
    let formatted = format!("{value:.4}");
    formatted
        .trim_end_matches('0')
        .trim_end_matches('.')
        .to_string()
}

#[cfg(test)]
mod tests {
    use std::fs;
    use std::path::PathBuf;
    use std::sync::atomic::{AtomicUsize, Ordering};
    use std::sync::Arc;
    use std::time::Duration;
    use std::time::{SystemTime, UNIX_EPOCH};

    use futures::future::BoxFuture;
    use wiremock::matchers::{method, path};
    use wiremock::{Mock, MockServer, ResponseTemplate};

    use super::TerminalCommandService;
    use crate::news::{NewsService, RefreshNewsFeedRequest};
    use crate::portfolio::{
        CashConfirmation, PortfolioCommandError, PortfolioManagement, PortfolioStats,
        PortfolioTransaction, TradeConfirmation, TransactionKind,
    };
    use crate::terminal::mock_data::load_mock_financial_data;
    use crate::terminal::sec_edgar::{EdgarDataLookup, EdgarLookupError};
    use crate::terminal::security_lookup::{
        SecurityKind, SecurityLookup, SecurityLookupError, SecurityMatch,
    };
    use crate::terminal::{
        CashFlowPanelData, Company, DividendsPanelData, EarningsPanelData,
        ExecuteTerminalCommandRequest, FinancialsPanelData, Frequency, Holding, PanelPayload,
        Portfolio, SourceStatus, TerminalCommandResponse,
    };

    struct FakeSecurityLookup {
        search_result: Result<Vec<SecurityMatch>, SecurityLookupError>,
        fail_detail: bool,
        search_calls: AtomicUsize,
        detail_calls: AtomicUsize,
    }

    impl FakeSecurityLookup {
        fn successful(matches: Vec<SecurityMatch>) -> Self {
            Self {
                search_result: Ok(matches),
                fail_detail: false,
                search_calls: AtomicUsize::new(0),
                detail_calls: AtomicUsize::new(0),
            }
        }
    }

    impl SecurityLookup for FakeSecurityLookup {
        fn provider_name(&self) -> &'static str {
            "Google Finance"
        }

        fn search<'a>(
            &'a self,
            _query: &'a str,
        ) -> BoxFuture<'a, Result<Vec<SecurityMatch>, SecurityLookupError>> {
            self.search_calls.fetch_add(1, Ordering::Relaxed);
            Box::pin(async move { self.search_result.clone() })
        }

        fn load_company<'a>(
            &'a self,
            security_match: &'a SecurityMatch,
        ) -> BoxFuture<'a, Result<Company, SecurityLookupError>> {
            self.detail_calls.fetch_add(1, Ordering::Relaxed);
            Box::pin(async move {
                if self.fail_detail {
                    return Err(SecurityLookupError::DetailUnavailable {
                        symbol: security_match.symbol.clone(),
                        detail: "quote endpoint timed out".to_string(),
                    });
                }

                Ok(Company {
                    symbol: security_match.symbol.clone(),
                    name: security_match
                        .exchange
                        .clone()
                        .unwrap_or_else(|| "N/A".to_string()),
                    price: 100.0,
                    change: 1.0,
                    change_percent: 1.0,
                    market_cap: 1_000_000.0,
                    volume: Some(10_000),
                    volume_label: None,
                    pe: Some(20.0),
                    eps: Some(2.0),
                    high52_week: Some(110.0),
                    low52_week: Some(80.0),
                    profile: None,
                    price_chart: None,
                    price_chart_ranges: None,
                })
            })
        }
    }

    struct FakeEdgarLookup;

    #[derive(Clone)]
    struct FakePortfolioService {
        portfolio: Result<Portfolio, PortfolioCommandError>,
        stats: Result<PortfolioStats, PortfolioCommandError>,
        history: Result<Vec<PortfolioTransaction>, PortfolioCommandError>,
        buy: Result<TradeConfirmation, PortfolioCommandError>,
        sell: Result<TradeConfirmation, PortfolioCommandError>,
        deposit_cash: Result<CashConfirmation, PortfolioCommandError>,
        withdraw_cash: Result<CashConfirmation, PortfolioCommandError>,
    }

    impl Default for FakePortfolioService {
        fn default() -> Self {
            Self {
                portfolio: Ok(Portfolio {
                    holdings: Vec::new(),
                    total_value: 0.0,
                    day_change: 0.0,
                    day_change_percent: 0.0,
                    total_gain: 0.0,
                    total_gain_percent: 0.0,
                    cash_balance: Some(0.0),
                    invested_cost_basis: Some(0.0),
                    realized_gain: Some(0.0),
                    unrealized_gain: Some(0.0),
                    holdings_count: Some(0),
                    stale_pricing_symbols: Some(Vec::new()),
                }),
                stats: Ok(PortfolioStats {
                    cash_balance: 0.0,
                    holdings_count: 0,
                    invested_cost_basis: 0.0,
                    equities_market_value: 0.0,
                    total_portfolio_value: 0.0,
                    realized_gain: 0.0,
                    unrealized_gain: 0.0,
                    day_change: 0.0,
                    stale_pricing_symbols: Vec::new(),
                }),
                history: Ok(Vec::new()),
                buy: Ok(TradeConfirmation {
                    symbol: "AAPL".to_string(),
                    company_name: "Apple Inc.".to_string(),
                    quantity: 1.0,
                    price: 100.0,
                    gross_amount: 100.0,
                    cash_balance: 900.0,
                    realized_gain: None,
                }),
                sell: Ok(TradeConfirmation {
                    symbol: "AAPL".to_string(),
                    company_name: "Apple Inc.".to_string(),
                    quantity: 1.0,
                    price: 110.0,
                    gross_amount: 110.0,
                    cash_balance: 1_010.0,
                    realized_gain: Some(10.0),
                }),
                deposit_cash: Ok(CashConfirmation {
                    amount: 1_000.0,
                    cash_balance: 1_000.0,
                    kind: TransactionKind::CashDeposit,
                }),
                withdraw_cash: Ok(CashConfirmation {
                    amount: 100.0,
                    cash_balance: 900.0,
                    kind: TransactionKind::CashWithdrawal,
                }),
            }
        }
    }

    impl PortfolioManagement for FakePortfolioService {
        fn portfolio<'a>(&'a self) -> BoxFuture<'a, Result<Portfolio, PortfolioCommandError>> {
            Box::pin(async move { self.portfolio.clone() })
        }

        fn stats<'a>(&'a self) -> BoxFuture<'a, Result<PortfolioStats, PortfolioCommandError>> {
            Box::pin(async move { self.stats.clone() })
        }

        fn history<'a>(
            &'a self,
            limit: usize,
        ) -> BoxFuture<'a, Result<Vec<PortfolioTransaction>, PortfolioCommandError>> {
            Box::pin(async move {
                let mut transactions = self.history.clone()?;
                transactions.truncate(limit);
                Ok(transactions)
            })
        }

        fn buy<'a>(
            &'a self,
            _symbol: &'a str,
            _quantity: f64,
            _price_override: Option<f64>,
        ) -> BoxFuture<'a, Result<TradeConfirmation, PortfolioCommandError>> {
            Box::pin(async move { self.buy.clone() })
        }

        fn sell<'a>(
            &'a self,
            _symbol: &'a str,
            _quantity: f64,
            _price_override: Option<f64>,
        ) -> BoxFuture<'a, Result<TradeConfirmation, PortfolioCommandError>> {
            Box::pin(async move { self.sell.clone() })
        }

        fn deposit_cash<'a>(
            &'a self,
            _amount: f64,
        ) -> BoxFuture<'a, Result<CashConfirmation, PortfolioCommandError>> {
            Box::pin(async move { self.deposit_cash.clone() })
        }

        fn withdraw_cash<'a>(
            &'a self,
            _amount: f64,
        ) -> BoxFuture<'a, Result<CashConfirmation, PortfolioCommandError>> {
            Box::pin(async move { self.withdraw_cash.clone() })
        }
    }

    impl EdgarDataLookup for FakeEdgarLookup {
        fn financials<'a>(
            &'a self,
            ticker: &'a str,
            frequency: Frequency,
        ) -> BoxFuture<'a, Result<FinancialsPanelData, EdgarLookupError>> {
            Box::pin(async move {
                Ok(FinancialsPanelData {
                    symbol: ticker.to_string(),
                    company_name: "Example Co".to_string(),
                    cik: "0000000001".to_string(),
                    frequency,
                    periods: Vec::new(),
                    latest_filing: None,
                    source_status: SourceStatus {
                        companyfacts_used: true,
                        latest_xbrl_parsed: false,
                        degraded_reason: None,
                    },
                })
            })
        }

        fn cash_flow<'a>(
            &'a self,
            ticker: &'a str,
            frequency: Frequency,
        ) -> BoxFuture<'a, Result<CashFlowPanelData, EdgarLookupError>> {
            Box::pin(async move {
                Ok(CashFlowPanelData {
                    symbol: ticker.to_string(),
                    company_name: "Example Co".to_string(),
                    cik: "0000000001".to_string(),
                    frequency,
                    periods: Vec::new(),
                    latest_filing: None,
                    source_status: SourceStatus {
                        companyfacts_used: true,
                        latest_xbrl_parsed: false,
                        degraded_reason: None,
                    },
                })
            })
        }

        fn dividends<'a>(
            &'a self,
            ticker: &'a str,
        ) -> BoxFuture<'a, Result<DividendsPanelData, EdgarLookupError>> {
            Box::pin(async move {
                Ok(DividendsPanelData {
                    symbol: ticker.to_string(),
                    company_name: "Example Co".to_string(),
                    cik: "0000000001".to_string(),
                    ttm_dividends_per_share: Some(1.0),
                    ttm_common_dividends_paid: Some(100.0),
                    latest_event: None,
                    events: Vec::new(),
                    latest_filing: None,
                    source_status: SourceStatus {
                        companyfacts_used: true,
                        latest_xbrl_parsed: false,
                        degraded_reason: None,
                    },
                })
            })
        }

        fn earnings<'a>(
            &'a self,
            ticker: &'a str,
            frequency: Frequency,
        ) -> BoxFuture<'a, Result<EarningsPanelData, EdgarLookupError>> {
            Box::pin(async move {
                Ok(EarningsPanelData {
                    symbol: ticker.to_string(),
                    company_name: "Example Co".to_string(),
                    cik: "0000000001".to_string(),
                    frequency,
                    periods: Vec::new(),
                    latest_filing: None,
                    source_status: SourceStatus {
                        companyfacts_used: true,
                        latest_xbrl_parsed: false,
                        degraded_reason: None,
                    },
                })
            })
        }
    }

    fn build_service(
        search_result: Result<Vec<SecurityMatch>, SecurityLookupError>,
    ) -> (TerminalCommandService, Arc<FakeSecurityLookup>) {
        build_service_with_portfolio(search_result, Arc::new(FakePortfolioService::default()))
    }

    fn build_service_with_portfolio(
        search_result: Result<Vec<SecurityMatch>, SecurityLookupError>,
        portfolio_service: Arc<dyn PortfolioManagement>,
    ) -> (TerminalCommandService, Arc<FakeSecurityLookup>) {
        let lookup = Arc::new(FakeSecurityLookup {
            search_result,
            fail_detail: false,
            search_calls: AtomicUsize::new(0),
            detail_calls: AtomicUsize::new(0),
        });

        (
            TerminalCommandService::with_dependencies(
                load_mock_financial_data(),
                lookup.clone(),
                Arc::new(FakeEdgarLookup),
                portfolio_service,
                test_news_service(),
                Duration::ZERO,
            ),
            lookup,
        )
    }

    fn build_failing_service(
        search_result: Result<Vec<SecurityMatch>, SecurityLookupError>,
    ) -> TerminalCommandService {
        TerminalCommandService::with_dependencies(
            load_mock_financial_data(),
            Arc::new(FakeSecurityLookup {
                search_result,
                fail_detail: true,
                search_calls: AtomicUsize::new(0),
                detail_calls: AtomicUsize::new(0),
            }),
            Arc::new(FakeEdgarLookup),
            Arc::new(FakePortfolioService::default()),
            test_news_service(),
            Duration::ZERO,
        )
    }

    fn execute(service: &TerminalCommandService, input: &str) -> TerminalCommandResponse {
        futures::executor::block_on(service.execute(ExecuteTerminalCommandRequest {
            workspace_id: "workspace-1".to_string(),
            input: input.to_string(),
        }))
    }

    fn lookup_company(
        service: &TerminalCommandService,
        symbol: &str,
    ) -> Result<crate::terminal::Company, String> {
        futures::executor::block_on(service.lookup_company(symbol))
    }

    fn test_news_service() -> Arc<NewsService> {
        let root = unique_test_directory("terminal-news-service");
        Arc::new(
            NewsService::new(
                root.join("news.sqlite"),
                root.join("news-feeds.json"),
                br#"{"feeds":[{"id":"sample","name":"Sample Feed","url":"https://example.com/feed.xml","refreshMinutes":15}]}"#,
            )
            .unwrap(),
        )
    }

    fn unique_test_directory(prefix: &str) -> PathBuf {
        let suffix = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let path = std::env::temp_dir().join(format!("{prefix}-{suffix}"));
        fs::create_dir_all(&path).unwrap();
        path
    }

    #[tokio::test]
    async fn news_command_returns_articles_from_local_database() {
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/news.atom"))
            .respond_with(
                ResponseTemplate::new(200)
                    .insert_header("content-type", "application/atom+xml")
                    .set_body_string(include_str!("../../tests/fixtures/news/sample.atom")),
            )
            .mount(&server)
            .await;

        let root = unique_test_directory("terminal-news-command");
        let config = format!(
            r#"{{"feeds":[{{"id":"sample","name":"Sample Feed","url":"{}/news.atom","refreshMinutes":15}}]}}"#,
            server.uri(),
        );
        let news_service = Arc::new(
            NewsService::new(
                root.join("news.sqlite"),
                root.join("news-feeds.json"),
                config.as_bytes(),
            )
            .unwrap(),
        );
        news_service
            .refresh_feed(RefreshNewsFeedRequest { force: Some(true) })
            .await
            .unwrap();
        server.reset().await;

        let service = TerminalCommandService::with_dependencies(
            load_mock_financial_data(),
            Arc::new(FakeSecurityLookup::successful(vec![])),
            Arc::new(FakeEdgarLookup),
            Arc::new(FakePortfolioService::default()),
            news_service,
            Duration::ZERO,
        );
        let response = service
            .execute(ExecuteTerminalCommandRequest {
                workspace_id: "workspace-1".to_string(),
                input: "/news NVDA".to_string(),
            })
            .await;

        match response {
            TerminalCommandResponse::Panel { panel } => match panel.as_ref() {
                PanelPayload::News { data, ticker } => {
                    assert_eq!(ticker.as_deref(), Some("NVDA"));
                    assert_eq!(data.len(), 1);
                    assert_eq!(data[0].tickers, vec!["NVDA".to_string()]);
                }
                other => panic!("expected news panel, got {other:?}"),
            },
            other => panic!("expected panel response, got {other:?}"),
        }
    }

    #[test]
    fn returns_company_panel_for_exact_search_match() {
        let (service, lookup) = build_service(Ok(vec![SecurityMatch {
            symbol: "AAPL".to_string(),
            name: Some("Apple Inc.".to_string()),
            exchange: Some("NASDAQ".to_string()),
            kind: SecurityKind::Equity,
        }]));

        let response = execute(&service, "/search AAPL");

        match response {
            TerminalCommandResponse::Panel { panel } => match panel.as_ref() {
                PanelPayload::Company { data } => assert_eq!(data.symbol, "AAPL"),
                other => panic!("expected company panel, got {other:?}"),
            },
            other => panic!("expected company panel, got {other:?}"),
        }

        assert_eq!(lookup.search_calls.load(Ordering::Relaxed), 0);
        assert_eq!(lookup.detail_calls.load(Ordering::Relaxed), 1);
    }

    #[test]
    fn returns_company_panel_for_name_search() {
        let (service, lookup) = build_service(Ok(vec![
            SecurityMatch {
                symbol: "AAPL".to_string(),
                name: Some("Apple Inc.".to_string()),
                exchange: Some("NASDAQ".to_string()),
                kind: SecurityKind::Equity,
            },
            SecurityMatch {
                symbol: "APLE".to_string(),
                name: Some("Apple Hospitality REIT".to_string()),
                exchange: Some("NYSE".to_string()),
                kind: SecurityKind::Equity,
            },
        ]));

        let response = execute(&service, "/search apple");

        match response {
            TerminalCommandResponse::Panel { panel } => match panel.as_ref() {
                PanelPayload::Company { data } => assert_eq!(data.symbol, "AAPL"),
                other => panic!("expected company panel, got {other:?}"),
            },
            other => panic!("expected text response, got {other:?}"),
        }

        assert_eq!(lookup.search_calls.load(Ordering::Relaxed), 1);
        assert_eq!(lookup.detail_calls.load(Ordering::Relaxed), 1);
    }

    #[test]
    fn filters_unsupported_assets() {
        let (service, _) = build_service(Ok(vec![SecurityMatch {
            symbol: "BTC-USD".to_string(),
            name: Some("Bitcoin USD".to_string()),
            exchange: None,
            kind: SecurityKind::Other("Crypto".to_string()),
        }]));

        let response = execute(&service, "/search bitcoin");

        match response {
            TerminalCommandResponse::Panel { panel } => match panel.as_ref() {
                PanelPayload::Error { data } => {
                    assert_eq!(data.title, "No supported search results");
                    assert_eq!(data.query.as_deref(), Some("bitcoin"));
                }
                other => panic!("expected error panel, got {other:?}"),
            },
            other => panic!("expected text response, got {other:?}"),
        }
    }

    #[test]
    fn prefers_us_exchange_priority_for_exact_matches() {
        let (service, _) = build_service(Ok(vec![
            SecurityMatch {
                symbol: "ABC".to_string(),
                name: Some("ABC Ltd".to_string()),
                exchange: Some("NYSE".to_string()),
                kind: SecurityKind::Equity,
            },
            SecurityMatch {
                symbol: "ABC".to_string(),
                name: Some("ABC Ltd".to_string()),
                exchange: Some("NASDAQ".to_string()),
                kind: SecurityKind::Equity,
            },
        ]));

        let response = execute(&service, "/search abc");

        match response {
            TerminalCommandResponse::Panel { panel } => match panel.as_ref() {
                PanelPayload::Company { data } => assert_eq!(data.name, "NASDAQ"),
                other => panic!("expected company panel, got {other:?}"),
            },
            other => panic!("expected company panel, got {other:?}"),
        }
    }

    #[test]
    fn returns_live_search_error_when_provider_search_fails() {
        let (service, _) = build_service(Err(SecurityLookupError::SearchUnavailable {
            query: "apple".to_string(),
            detail: "429 Too Many Requests".to_string(),
        }));

        let response = execute(&service, "/search apple");

        match response {
            TerminalCommandResponse::Panel { panel } => match panel.as_ref() {
                PanelPayload::Error { data } => {
                    assert_eq!(data.title, "Google Finance search failed");
                    assert_eq!(data.detail.as_deref(), Some("429 Too Many Requests"));
                    assert_eq!(data.query.as_deref(), Some("apple"));
                }
                other => panic!("expected error panel, got {other:?}"),
            },
            other => panic!("expected text response, got {other:?}"),
        }
    }

    #[test]
    fn returns_live_detail_error_when_exact_resolution_fails() {
        let service = build_failing_service(Ok(vec![SecurityMatch {
            symbol: "AAPL".to_string(),
            name: Some("Apple Inc.".to_string()),
            exchange: Some("NASDAQ".to_string()),
            kind: SecurityKind::Equity,
        }]));

        let response = execute(&service, "/search AAPL");

        match response {
            TerminalCommandResponse::Panel { panel } => match panel.as_ref() {
                PanelPayload::Error { data } => {
                    assert_eq!(data.title, "Google Finance quote unavailable");
                    assert_eq!(data.symbol.as_deref(), Some("AAPL"));
                    assert_eq!(data.detail.as_deref(), Some("quote endpoint timed out"));
                }
                other => panic!("expected error panel, got {other:?}"),
            },
            other => panic!("expected text response, got {other:?}"),
        }
    }

    #[test]
    fn returns_usage_for_search_without_query() {
        let service = TerminalCommandService::with_dependencies(
            load_mock_financial_data(),
            Arc::new(FakeSecurityLookup::successful(vec![])),
            Arc::new(FakeEdgarLookup),
            Arc::new(FakePortfolioService::default()),
            test_news_service(),
            Duration::ZERO,
        );

        let response = execute(&service, "/search");

        match response {
            TerminalCommandResponse::Panel { panel } => match panel.as_ref() {
                PanelPayload::Error { data } => {
                    assert_eq!(data.title, "Search query required");
                    assert_eq!(
                        data.detail.as_deref(),
                        Some("Usage: /search [ticker or company name]")
                    );
                }
                other => panic!("expected error panel, got {other:?}"),
            },
            other => panic!("expected text response, got {other:?}"),
        }
    }

    #[test]
    fn exact_symbol_search_skips_live_search_lookup() {
        let (service, lookup) = build_service(Ok(vec![]));

        let response = execute(&service, "/search CASY");

        match response {
            TerminalCommandResponse::Panel { panel } => match panel.as_ref() {
                PanelPayload::Company { data } => assert_eq!(data.symbol, "CASY"),
                other => panic!("expected company panel, got {other:?}"),
            },
            other => panic!("expected company panel, got {other:?}"),
        }

        assert_eq!(lookup.search_calls.load(Ordering::Relaxed), 0);
        assert_eq!(lookup.detail_calls.load(Ordering::Relaxed), 1);
    }

    #[test]
    fn returns_text_for_unknown_command() {
        let service = TerminalCommandService::with_dependencies(
            load_mock_financial_data(),
            Arc::new(FakeSecurityLookup::successful(vec![])),
            Arc::new(FakeEdgarLookup),
            Arc::new(FakePortfolioService::default()),
            test_news_service(),
            Duration::ZERO,
        );

        let response = execute(&service, "/wat");

        match response {
            TerminalCommandResponse::Text { content, .. } => {
                assert!(content.contains("Unknown command"));
            }
            other => panic!("expected text response, got {other:?}"),
        }
    }

    #[test]
    fn returns_usage_for_analyze_without_ticker() {
        let service = TerminalCommandService::with_dependencies(
            load_mock_financial_data(),
            Arc::new(FakeSecurityLookup::successful(vec![])),
            Arc::new(FakeEdgarLookup),
            Arc::new(FakePortfolioService::default()),
            test_news_service(),
            Duration::ZERO,
        );

        let response = execute(&service, "/analyze");

        match response {
            TerminalCommandResponse::Text { content, .. } => {
                assert_eq!(content, "Usage: /analyze [ticker]");
            }
            other => panic!("expected text response, got {other:?}"),
        }
    }

    #[test]
    fn routes_financials_command_to_sec_panel() {
        let service = TerminalCommandService::with_dependencies(
            load_mock_financial_data(),
            Arc::new(FakeSecurityLookup::successful(vec![])),
            Arc::new(FakeEdgarLookup),
            Arc::new(FakePortfolioService::default()),
            test_news_service(),
            Duration::ZERO,
        );

        let response = execute(&service, "/fa AAPL quarterly");

        match response {
            TerminalCommandResponse::Panel { panel } => match panel.as_ref() {
                PanelPayload::Financials { data } => {
                    assert_eq!(data.symbol, "AAPL");
                    assert_eq!(data.frequency, Frequency::Quarterly);
                }
                other => panic!("expected financials panel, got {other:?}"),
            },
            other => panic!("expected financials panel, got {other:?}"),
        }
    }

    #[test]
    fn buy_command_uses_provided_execution_price() {
        let portfolio_service = Arc::new(FakePortfolioService {
            buy: Ok(TradeConfirmation {
                symbol: "AAPL".to_string(),
                company_name: "Apple Inc.".to_string(),
                quantity: 2.0,
                price: 178.25,
                gross_amount: 356.5,
                cash_balance: 643.5,
                realized_gain: None,
            }),
            ..FakePortfolioService::default()
        });
        let (service, _) = build_service_with_portfolio(Ok(vec![]), portfolio_service);

        let response = execute(&service, "/buy AAPL 2 178.25");

        match response {
            TerminalCommandResponse::Text {
                content,
                portfolio: Some(portfolio),
            } => {
                assert!(content.contains("Bought 2 AAPL @ $178.25"));
                assert!(content.contains("Cash balance: $643.50"));
                assert_eq!(portfolio.holdings_count, Some(0));
            }
            other => panic!("expected text response, got {other:?}"),
        }
    }

    #[test]
    fn buy_command_falls_back_to_live_quote_when_price_is_omitted() {
        let (service, _) = build_service_with_portfolio(
            Ok(vec![]),
            Arc::new(FakePortfolioService {
                buy: Ok(TradeConfirmation {
                    symbol: "AAPL".to_string(),
                    company_name: "Apple Inc.".to_string(),
                    quantity: 2.0,
                    price: 100.0,
                    gross_amount: 200.0,
                    cash_balance: 800.0,
                    realized_gain: None,
                }),
                ..FakePortfolioService::default()
            }),
        );

        let response = execute(&service, "/buy AAPL 2");

        match response {
            TerminalCommandResponse::Text {
                content,
                portfolio: Some(portfolio),
            } => {
                assert!(content.contains("Bought 2 AAPL @ $100.00"));
                assert_eq!(portfolio.cash_balance, Some(0.0));
            }
            other => panic!("expected text response, got {other:?}"),
        }
    }

    #[test]
    fn buy_command_surfaces_quote_lookup_failure() {
        let (service, _) = build_service_with_portfolio(
            Ok(vec![]),
            Arc::new(FakePortfolioService {
                buy: Err(PortfolioCommandError::Quote(
                    crate::portfolio::PortfolioQuoteError::Unavailable {
                        symbol: "AAPL".to_string(),
                        detail: "quote endpoint timed out".to_string(),
                    },
                )),
                ..FakePortfolioService::default()
            }),
        );

        let response = execute(&service, "/buy AAPL 2");

        match response {
            TerminalCommandResponse::Text { content, .. } => {
                assert!(content.contains("quote unavailable for AAPL"));
            }
            other => panic!("expected text response, got {other:?}"),
        }
    }

    #[test]
    fn sell_command_returns_realized_gain_text() {
        let (service, _) = build_service_with_portfolio(
            Ok(vec![]),
            Arc::new(FakePortfolioService {
                sell: Ok(TradeConfirmation {
                    symbol: "AAPL".to_string(),
                    company_name: "Apple Inc.".to_string(),
                    quantity: 1.5,
                    price: 110.0,
                    gross_amount: 165.0,
                    cash_balance: 965.0,
                    realized_gain: Some(15.0),
                }),
                ..FakePortfolioService::default()
            }),
        );

        let response = execute(&service, "/sell AAPL 1.5 110");

        match response {
            TerminalCommandResponse::Text {
                content,
                portfolio: Some(portfolio),
            } => {
                assert!(content.contains("Sold 1.5 AAPL @ $110.00"));
                assert!(content.contains("Realized P/L: $15.00"));
                assert_eq!(portfolio.total_value, 0.0);
            }
            other => panic!("expected text response, got {other:?}"),
        }
    }

    #[test]
    fn portfolio_command_returns_panel_backed_by_portfolio_service() {
        let portfolio_service = Arc::new(FakePortfolioService {
            portfolio: Ok(Portfolio {
                holdings: vec![Holding {
                    symbol: "AAPL".to_string(),
                    name: "Apple Inc.".to_string(),
                    quantity: 3.0,
                    avg_cost: 90.0,
                    current_price: 100.0,
                    current_value: 300.0,
                    gain_loss: 30.0,
                    gain_loss_percent: 11.11,
                    cost_basis: Some(270.0),
                    unrealized_gain: Some(30.0),
                    latest_trade_at: Some("2026-01-01T10:00:00Z".to_string()),
                }],
                total_value: 450.0,
                day_change: 6.0,
                day_change_percent: 1.35,
                total_gain: 30.0,
                total_gain_percent: 11.11,
                cash_balance: Some(150.0),
                invested_cost_basis: Some(270.0),
                realized_gain: Some(10.0),
                unrealized_gain: Some(30.0),
                holdings_count: Some(1),
                stale_pricing_symbols: Some(Vec::new()),
            }),
            ..FakePortfolioService::default()
        });
        let (service, _) = build_service_with_portfolio(Ok(vec![]), portfolio_service);

        let response = execute(&service, "/portfolio");

        match response {
            TerminalCommandResponse::Panel { panel } => match panel.as_ref() {
                PanelPayload::Portfolio { data } => {
                    assert_eq!(data.total_value, 450.0);
                    assert_eq!(data.holdings.len(), 1);
                    assert_eq!(data.holdings[0].symbol, "AAPL");
                }
                other => panic!("expected portfolio panel, got {other:?}"),
            },
            other => panic!("expected portfolio panel, got {other:?}"),
        }
    }

    #[test]
    fn portfolio_history_limits_results() {
        let (service, _) = build_service_with_portfolio(
            Ok(vec![]),
            Arc::new(FakePortfolioService {
                history: Ok(vec![
                    PortfolioTransaction {
                        id: "3".to_string(),
                        kind: TransactionKind::Sell,
                        symbol: Some("AAPL".to_string()),
                        company_name: Some("Apple Inc.".to_string()),
                        quantity: Some(1.0),
                        price: Some(110.0),
                        gross_amount: 110.0,
                        fee: 0.0,
                        executed_at: "2026-01-03T10:00:00Z".to_string(),
                        note: None,
                    },
                    PortfolioTransaction {
                        id: "2".to_string(),
                        kind: TransactionKind::Buy,
                        symbol: Some("MSFT".to_string()),
                        company_name: Some("Microsoft".to_string()),
                        quantity: Some(2.0),
                        price: Some(200.0),
                        gross_amount: 400.0,
                        fee: 0.0,
                        executed_at: "2026-01-02T10:00:00Z".to_string(),
                        note: None,
                    },
                    PortfolioTransaction {
                        id: "1".to_string(),
                        kind: TransactionKind::CashDeposit,
                        symbol: None,
                        company_name: None,
                        quantity: None,
                        price: None,
                        gross_amount: 1_000.0,
                        fee: 0.0,
                        executed_at: "2026-01-01T10:00:00Z".to_string(),
                        note: None,
                    },
                ]),
                ..FakePortfolioService::default()
            }),
        );

        let response = execute(&service, "/portfolio history 2");

        match response {
            TerminalCommandResponse::Text { content, .. } => {
                assert!(content.contains("2026-01-03T10:00:00Z"));
                assert!(content.contains("2026-01-02T10:00:00Z"));
                assert!(!content.contains("2026-01-01T10:00:00Z"));
            }
            other => panic!("expected text response, got {other:?}"),
        }
    }

    #[test]
    fn invalid_portfolio_command_forms_return_usage_text() {
        let (service, _) = build_service(Ok(vec![]));

        let response = execute(&service, "/buy AAPL nope");

        match response {
            TerminalCommandResponse::Text { content, .. } => {
                assert_eq!(content, "Usage: /buy [ticker] [quantity] [price?]");
            }
            other => panic!("expected text response, got {other:?}"),
        }
    }

    #[test]
    fn direct_lookup_company_returns_live_company_snapshot() {
        let (service, lookup) = build_service(Ok(vec![]));

        let response = lookup_company(&service, "AAPL").expect("lookup should succeed");

        assert_eq!(response.symbol, "AAPL");
        assert_eq!(lookup.search_calls.load(Ordering::Relaxed), 0);
        assert_eq!(lookup.detail_calls.load(Ordering::Relaxed), 1);
    }

    #[test]
    fn direct_lookup_company_normalizes_symbol() {
        let (service, _) = build_service(Ok(vec![]));

        let response = lookup_company(&service, " aapl ").expect("lookup should succeed");

        assert_eq!(response.symbol, "AAPL");
    }

    #[test]
    fn direct_lookup_company_rejects_empty_symbol() {
        let (service, _) = build_service(Ok(vec![]));

        let error = lookup_company(&service, "   ").expect_err("lookup should fail");

        assert_eq!(error, "Ticker symbol required.");
    }

    #[test]
    fn direct_lookup_company_surfaces_provider_error() {
        let service = build_failing_service(Ok(vec![]));

        let error = lookup_company(&service, "BADSYMBOL").expect_err("lookup should fail");

        assert!(error.contains("Google Finance quote unavailable for BADSYMBOL"));
        assert!(error.contains("quote endpoint timed out"));
    }
}
