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

use serde::Deserialize;

use crate::mappings::{
    CreateCustomConceptMappingRequest, CustomConceptMapping, FormulaMapping,
    FormulaValidationResult, MappingChangeKind, MappingKind, MappingScope, MappingsEventEmitter,
    MappingsService, MappingsUpdatedEvent, TargetField,
};
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::{
    BalanceSheetQuery, CashFlowQuery, EarningsPeriodKey, EarningsQuery, EarningsRange,
    EdgarDataLookup, EdgarLookupError, FilingsQuery,
};
use crate::terminal::security_lookup::{
    SecurityKind, SecurityLookup, SecurityLookupError, SecurityMatch,
};
use crate::terminal::types::AvailableXbrlConcept;
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>,
    mappings_service: Arc<MappingsService>,
    mappings_event_emitter: Arc<dyn MappingsEventEmitter>,
    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>,
        mappings_service: Arc<MappingsService>,
        mappings_event_emitter: Arc<dyn MappingsEventEmitter>,
    ) -> Self {
        Self {
            mock_data: load_mock_financial_data(),
            news_service,
            security_lookup,
            edgar_lookup,
            portfolio_service,
            mappings_service,
            mappings_event_emitter,
            lookup_followup_delay: DEFAULT_LOOKUP_FOLLOWUP_DELAY,
        }
    }

    #[cfg(test)]
    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>,
        mappings_service: Arc<MappingsService>,
        lookup_followup_delay: Duration,
    ) -> Self {
        Self {
            mock_data,
            news_service,
            security_lookup,
            edgar_lookup,
            portfolio_service,
            mappings_service,
            lookup_followup_delay,
            mappings_event_emitter: Arc::new(crate::mappings::NoopMappingsEventEmitter),
        }
    }

    #[cfg(test)]
    fn with_dependencies_and_event_emitter(
        mock_data: MockFinancialData,
        security_lookup: Arc<dyn SecurityLookup>,
        edgar_lookup: Arc<dyn EdgarDataLookup>,
        portfolio_service: Arc<dyn PortfolioManagement>,
        news_service: Arc<NewsService>,
        mappings_service: Arc<MappingsService>,
        mappings_event_emitter: Arc<dyn MappingsEventEmitter>,
    ) -> Self {
        Self {
            mock_data,
            news_service,
            security_lookup,
            edgar_lookup,
            portfolio_service,
            mappings_service,
            mappings_event_emitter,
            lookup_followup_delay: Duration::ZERO,
        }
    }

    /// 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" => self.cash_flow(&command.args, Frequency::Annual).await,
            "/bs" => self.balance_sheet(&command.args, 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
                }
            }
            "/concepts" => {
                if command.args.len() > 2 {
                    TerminalCommandResponse::Text {
                        content: "Usage: /concepts [ticker] [annual|quarterly]".to_string(),
                        portfolio: None,
                    }
                } else {
                    self.concepts(command.args.first(), command.args.get(1), Frequency::Annual)
                        .await
                }
            }
            "/mappings" => self.mappings_command(&command, Frequency::Annual).await,
            "/formula" => self.formula_command(&command).await,
            "/em" => self.earnings(&command.args, Frequency::Annual).await,
            "/filings" => self.filings(&command.raw_input).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,
            };
        };

        let ticker_upper = ticker.to_uppercase();

        match self.mock_data.analyses.get(&ticker_upper) {
            Some(analysis) => TerminalCommandResponse::panel(PanelPayload::Analysis {
                data: analysis.clone(),
            }),
            None => {
                let summary = format!(
                    "No pre-built analysis available for \"{ticker_upper}\". \
                     Try asking the AI assistant directly: \"Analyze {ticker_upper}\" \
                     or use /search {ticker_upper}, /em {ticker_upper}, /fa {ticker_upper} \
                     to gather real financial data first."
                );
                TerminalCommandResponse::Text {
                    content: summary,
                    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,
        args: &[String],
        default_frequency: Frequency,
    ) -> TerminalCommandResponse {
        let (ticker, query) = match parse_cash_flow_query("/cf", args, default_frequency) {
            Ok(value) => value,
            Err(response) => return *response,
        };

        match self.edgar_lookup.cash_flow(&ticker, query).await {
            Ok(data) => TerminalCommandResponse::panel(PanelPayload::CashFlow { data }),
            Err(EdgarLookupError::InvalidPeriodRange { detail }) => TerminalCommandResponse::Text {
                content: detail,
                portfolio: None,
            },
            Err(error) => sec_error_response("SEC cash flow unavailable", &ticker, error),
        }
    }

    async fn balance_sheet(
        &self,
        args: &[String],
        default_frequency: Frequency,
    ) -> TerminalCommandResponse {
        let (ticker, query) = match parse_balance_sheet_query("/bs", args, default_frequency) {
            Ok(value) => value,
            Err(response) => return *response,
        };

        match self.edgar_lookup.balance_sheet(&ticker, query).await {
            Ok(data) => TerminalCommandResponse::panel(PanelPayload::BalanceSheet { data }),
            Err(EdgarLookupError::InvalidCurrencySelection { detail }) => {
                TerminalCommandResponse::Text {
                    content: detail,
                    portfolio: None,
                }
            }
            Err(error) => sec_error_response("SEC balance sheet 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,
        args: &[String],
        default_frequency: Frequency,
    ) -> TerminalCommandResponse {
        let (ticker, query) = match parse_earnings_query("/em", args, default_frequency) {
            Ok(value) => value,
            Err(response) => return *response,
        };

        match self.edgar_lookup.earnings(&ticker, query).await {
            Ok(data) => TerminalCommandResponse::panel(PanelPayload::Earnings { data }),
            Err(EdgarLookupError::InvalidPeriodRange { detail }) => TerminalCommandResponse::Text {
                content: detail,
                portfolio: None,
            },
            Err(EdgarLookupError::InvalidCurrencySelection { detail }) => {
                TerminalCommandResponse::Text {
                    content: detail,
                    portfolio: None,
                }
            }
            Err(error) => sec_error_response("SEC earnings unavailable", &ticker, error),
        }
    }

    async fn filings(&self, raw_input: &str) -> TerminalCommandResponse {
        let (ticker, query) = match parse_filings_query(raw_input) {
            Ok(value) => value,
            Err(response) => return *response,
        };

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

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

        match self
            .edgar_lookup
            .earnings(
                &ticker,
                EarningsQuery {
                    frequency,
                    range: None,
                    requested_currency: None,
                },
            )
            .await
        {
            Ok(data) => TerminalCommandResponse::Text {
                content: format_available_concepts(&ticker, frequency, &data.available_concepts),
                portfolio: None,
            },
            Err(error) => sec_error_response("SEC concepts unavailable", &ticker, error),
        }
    }

    async fn mappings_command(
        &self,
        command: &ChatCommandRequest,
        default_frequency: Frequency,
    ) -> TerminalCommandResponse {
        let first_arg = command.args.first().map(|s| s.as_str()).unwrap_or("");

        if first_arg.eq_ignore_ascii_case("add") {
            return self.mappings_add_command(&command.raw_input).await;
        }

        if first_arg.eq_ignore_ascii_case("update") {
            return self.mappings_update_command(&command.raw_input).await;
        }

        if first_arg.eq_ignore_ascii_case("delete") {
            return self.mappings_delete_command(&command.raw_input).await;
        }

        if command.args.len() > 2 {
            return TerminalCommandResponse::Text {
                content: mappings_usage().to_string(),
                portfolio: None,
            };
        }

        let mappings = match self.mappings_service.list_mappings().await {
            Ok(mappings) => mappings,
            Err(error) => {
                return TerminalCommandResponse::Text {
                    content: format!("Custom mappings unavailable: {error}"),
                    portfolio: None,
                }
            }
        };

        let Some(first_arg) = command.args.first() else {
            return TerminalCommandResponse::Text {
                content: format_mappings_overview(&mappings),
                portfolio: None,
            };
        };

        if is_reserved_mappings_subcommand(first_arg) {
            return TerminalCommandResponse::Text {
                content: format!(
                    "{}\nOnly `/mappings add`, `/mappings update`, and `/mappings delete` are supported for mutating terminal actions.",
                    mappings_usage()
                ),
                portfolio: None,
            };
        }

        if command.args.get(1).is_none() && is_frequency_token(first_arg) {
            return TerminalCommandResponse::Text {
                content: mappings_usage().to_string(),
                portfolio: None,
            };
        }

        let (ticker, frequency) = match parse_symbol_and_frequency(
            "/mappings",
            command.args.first(),
            command.args.get(1),
            default_frequency,
        ) {
            Ok(value) => value,
            Err(response) => return *response,
        };

        match self
            .edgar_lookup
            .earnings(
                &ticker,
                EarningsQuery {
                    frequency,
                    range: None,
                    requested_currency: None,
                },
            )
            .await
        {
            Ok(data) => TerminalCommandResponse::Text {
                content: format_mapping_assistant(
                    &ticker,
                    frequency,
                    &mappings,
                    &data.available_concepts,
                ),
                portfolio: None,
            },
            Err(error) => sec_error_response("SEC mapping assistant unavailable", &ticker, error),
        }
    }

    async fn mappings_add_command(&self, raw_input: &str) -> TerminalCommandResponse {
        let Some(payload) = raw_input.strip_prefix("/mappings add").map(str::trim) else {
            return TerminalCommandResponse::Text {
                content: mappings_add_usage().to_string(),
                portfolio: None,
            };
        };

        if payload.is_empty() {
            return TerminalCommandResponse::Text {
                content: mappings_add_usage().to_string(),
                portfolio: None,
            };
        }

        let request = match serde_json::from_str::<CreateCustomConceptMappingRequest>(payload) {
            Ok(request) => request,
            Err(error) => {
                return TerminalCommandResponse::Text {
                    content: format!("{}\nInvalid JSON payload: {error}", mappings_add_usage()),
                    portfolio: None,
                };
            }
        };

        match self
            .mappings_service
            .create_or_update_mapping(request)
            .await
        {
            Ok((mapping, was_update)) => {
                let change_kind = if was_update {
                    MappingChangeKind::Updated
                } else {
                    MappingChangeKind::Created
                };
                let event = MappingsUpdatedEvent::for_mapping(change_kind, &mapping);
                if let Err(error) = self.mappings_event_emitter.emit_updated(&event) {
                    eprintln!("Failed to emit mappings_updated event: {error}");
                }

                TerminalCommandResponse::Text {
                    content: if was_update {
                        format_updated_mapping(&mapping)
                    } else {
                        format_created_mapping(&mapping)
                    },
                    portfolio: None,
                }
            }
            Err(error) => TerminalCommandResponse::Text {
                content: error.to_string(),
                portfolio: None,
            },
        }
    }

    async fn mappings_update_command(&self, raw_input: &str) -> TerminalCommandResponse {
        let Some(rest) = raw_input.strip_prefix("/mappings update").map(str::trim) else {
            return TerminalCommandResponse::Text {
                content: mappings_update_usage().to_string(),
                portfolio: None,
            };
        };

        let Some((id, json_payload)) = split_id_and_json(rest) else {
            return TerminalCommandResponse::Text {
                content: mappings_update_usage().to_string(),
                portfolio: None,
            };
        };

        let request = match serde_json::from_str::<CreateCustomConceptMappingRequest>(json_payload)
        {
            Ok(request) => request,
            Err(error) => {
                return TerminalCommandResponse::Text {
                    content: format!("{}\nInvalid JSON payload: {error}", mappings_update_usage()),
                    portfolio: None,
                };
            }
        };

        match self
            .mappings_service
            .update_mapping_from_request(id, request)
            .await
        {
            Ok(updated) => {
                let event = MappingsUpdatedEvent::for_mapping(MappingChangeKind::Updated, &updated);
                if let Err(error) = self.mappings_event_emitter.emit_updated(&event) {
                    eprintln!("Failed to emit mappings_updated event: {error}");
                }
                TerminalCommandResponse::Text {
                    content: format_updated_mapping(&updated),
                    portfolio: None,
                }
            }
            Err(error) => TerminalCommandResponse::Text {
                content: error.to_string(),
                portfolio: None,
            },
        }
    }

    async fn formula_command(&self, command: &ChatCommandRequest) -> TerminalCommandResponse {
        let Some(subcommand) = command.args.first().map(String::as_str) else {
            return TerminalCommandResponse::Text {
                content: formula_usage().to_string(),
                portfolio: None,
            };
        };

        match subcommand {
            value if value.eq_ignore_ascii_case("validate") => {
                self.formula_validate_command(&command.raw_input).await
            }
            value if value.eq_ignore_ascii_case("create") => {
                self.formula_create_command(&command.raw_input).await
            }
            value if value.eq_ignore_ascii_case("list") => {
                self.formula_list_command(command.args.get(1).map(String::as_str))
                    .await
            }
            _ => TerminalCommandResponse::Text {
                content: formula_usage().to_string(),
                portfolio: None,
            },
        }
    }

    async fn formula_validate_command(&self, raw_input: &str) -> TerminalCommandResponse {
        let Some(payload) = raw_input.strip_prefix("/formula validate").map(str::trim) else {
            return TerminalCommandResponse::Text {
                content: formula_validate_usage().to_string(),
                portfolio: None,
            };
        };

        if payload.is_empty() {
            return TerminalCommandResponse::Text {
                content: formula_validate_usage().to_string(),
                portfolio: None,
            };
        }

        let expression =
            serde_json::from_str::<String>(payload).unwrap_or_else(|_| payload.to_string());
        match self.mappings_service.validate_formula(&expression) {
            Ok(result) => TerminalCommandResponse::Text {
                content: format_formula_validation(&result),
                portfolio: None,
            },
            Err(error) => TerminalCommandResponse::Text {
                content: error.to_string(),
                portfolio: None,
            },
        }
    }

    async fn formula_create_command(&self, raw_input: &str) -> TerminalCommandResponse {
        let Some(payload) = raw_input.strip_prefix("/formula create").map(str::trim) else {
            return TerminalCommandResponse::Text {
                content: formula_create_usage().to_string(),
                portfolio: None,
            };
        };

        if payload.is_empty() {
            return TerminalCommandResponse::Text {
                content: formula_create_usage().to_string(),
                portfolio: None,
            };
        }

        let request = match serde_json::from_str::<FormulaCreateRequest>(payload) {
            Ok(payload) => CreateCustomConceptMappingRequest {
                target_field: payload.target_field,
                mapping_kind: MappingKind::Formula(FormulaMapping {
                    expression: payload.expression,
                    cached_ast: None,
                }),
                scope: payload.scope.unwrap_or_default(),
                notes: payload.notes,
            },
            Err(error) => {
                return TerminalCommandResponse::Text {
                    content: format!("{}\nInvalid JSON payload: {error}", formula_create_usage()),
                    portfolio: None,
                };
            }
        };

        match self
            .mappings_service
            .create_or_update_mapping(request)
            .await
        {
            Ok((mapping, was_update)) => {
                let change_kind = if was_update {
                    MappingChangeKind::Updated
                } else {
                    MappingChangeKind::Created
                };
                let event = MappingsUpdatedEvent::for_mapping(change_kind, &mapping);
                if let Err(error) = self.mappings_event_emitter.emit_updated(&event) {
                    eprintln!("Failed to emit mappings_updated event: {error}");
                }

                TerminalCommandResponse::Text {
                    content: if was_update {
                        format_updated_mapping(&mapping)
                    } else {
                        format_created_mapping(&mapping)
                    },
                    portfolio: None,
                }
            }
            Err(error) => TerminalCommandResponse::Text {
                content: error.to_string(),
                portfolio: None,
            },
        }
    }

    async fn formula_list_command(&self, ticker: Option<&str>) -> TerminalCommandResponse {
        let mappings = match self.mappings_service.list_mappings().await {
            Ok(mappings) => mappings,
            Err(error) => {
                return TerminalCommandResponse::Text {
                    content: format!("Formula mappings unavailable: {error}"),
                    portfolio: None,
                }
            }
        };

        let ticker = ticker
            .map(|value| value.trim())
            .filter(|value| !value.is_empty());
        let formulas = mappings
            .into_iter()
            .filter(|mapping| mapping.formula().is_some())
            .filter(|mapping| {
                ticker.is_none_or(|ticker| {
                    mapping
                        .scope
                        .ticker
                        .as_deref()
                        .is_some_and(|scope_ticker| scope_ticker.eq_ignore_ascii_case(ticker))
                })
            })
            .collect::<Vec<_>>();

        TerminalCommandResponse::Text {
            content: format_formula_mappings(ticker, &formulas),
            portfolio: None,
        }
    }

    async fn mappings_delete_command(&self, raw_input: &str) -> TerminalCommandResponse {
        let Some(id) = raw_input
            .strip_prefix("/mappings delete")
            .map(str::trim)
            .filter(|s| !s.is_empty())
        else {
            return TerminalCommandResponse::Text {
                content: mappings_delete_usage().to_string(),
                portfolio: None,
            };
        };

        match self.mappings_service.get_mapping(id) {
            Ok(existing) => {
                let event =
                    MappingsUpdatedEvent::for_mapping(MappingChangeKind::Deleted, &existing);
                match self.mappings_service.delete_mapping(id).await {
                    Ok(()) => {
                        if let Err(error) = self.mappings_event_emitter.emit_updated(&event) {
                            eprintln!("Failed to emit mappings_updated event: {error}");
                        }
                        TerminalCommandResponse::Text {
                            content: format_deleted_mapping(&existing),
                            portfolio: None,
                        }
                    }
                    Err(error) => TerminalCommandResponse::Text {
                        content: error.to_string(),
                        portfolio: None,
                    },
                }
            }
            Err(error) => TerminalCommandResponse::Text {
                content: error.to_string(),
                portfolio: None,
            },
        }
    }

    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 earnings_usage() -> &'static str {
    "Usage: /em [ticker] [annual|quarterly] [start] [end] [--currency <ISO4217>]\nExamples: /em AAPL | /em AAPL annual 2021 2024 --currency USD | /em AAPL quarterly 2024Q1 2025Q2 --currency EUR"
}

fn balance_sheet_usage() -> &'static str {
    "Usage: /bs [ticker] [annual|quarterly] [start] [end] [--currency <ISO4217>]\nExamples: /bs AAPL | /bs AAPL quarterly --currency USD | /bs AAPL annual 2021 2024 --currency EUR"
}

fn cash_flow_usage() -> &'static str {
    "Usage: /cf [ticker] [annual|quarterly] [start] [end]\nExamples: /cf AAPL | /cf AAPL quarterly | /cf AAPL annual 2021 2024"
}

fn filings_usage() -> &'static str {
    "Usage: /filings <TICKER> [--page <N>] [--page-size <25|50|100>] [--query <TERM>]"
}

fn mappings_usage() -> &'static str {
    "Usage: /mappings [ticker] [annual|quarterly]\nMutating: /mappings add <json> | /mappings update <id> <json> | /mappings delete <id>\nExamples: /mappings | /mappings AAPL | /mappings AAPL quarterly"
}

fn mappings_add_usage() -> &'static str {
    "Usage: /mappings add {\"targetField\":\"...\",\"customConcept\":{\"taxonomy\":\"...\",\"concept\":\"...\",\"unitFamily\":\"...\"},\"scope\":{\"ticker\":\"AAPL\"},\"notes\":\"...\"}"
}

fn mappings_update_usage() -> &'static str {
    "Usage: /mappings update <mapping-id> {\"targetField\":\"...\",\"customConcept\":{\"taxonomy\":\"...\",\"concept\":\"...\",\"unitFamily\":\"...\"},\"scope\":{\"ticker\":\"AAPL\"},\"notes\":\"...\"}"
}

fn mappings_delete_usage() -> &'static str {
    "Usage: /mappings delete <mapping-id>"
}

fn formula_usage() -> &'static str {
    "Usage: /formula validate <expression-or-json-string> | /formula create <json> | /formula list [ticker]"
}

fn formula_validate_usage() -> &'static str {
    "Usage: /formula validate \"$gross_profit + $cost_of_sales\""
}

fn formula_create_usage() -> &'static str {
    "Usage: /formula create {\"targetField\":\"total_revenues\",\"expression\":\"$gross_profit + $cost_of_sales\",\"scope\":{\"ticker\":\"AAPL\"},\"notes\":\"optional\"}"
}

fn split_id_and_json(rest: &str) -> Option<(&str, &str)> {
    let rest = rest.trim();
    let id_end = rest.find(char::is_whitespace)?;
    let id = rest[..id_end].trim();
    let payload = rest[id_end..].trim_start();

    (!id.is_empty() && !payload.is_empty()).then_some((id, payload))
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct FormulaCreateRequest {
    target_field: TargetField,
    expression: String,
    #[serde(default)]
    scope: Option<MappingScope>,
    notes: Option<String>,
}

fn format_available_concepts(
    ticker: &str,
    frequency: Frequency,
    concepts: &[AvailableXbrlConcept],
) -> String {
    let mut grouped = BTreeMap::<String, Vec<(String, String, Option<String>)>>::new();

    for concept in concepts {
        grouped.entry(concept.period_id.clone()).or_default().push((
            concept.taxonomy.clone(),
            format!("{} [{}]", concept.concept, concept.unit_family),
            concept.label.clone(),
        ));
    }

    for entries in grouped.values_mut() {
        entries.sort();
        entries.dedup();
    }

    let total_concepts = grouped.values().map(Vec::len).sum::<usize>();

    if total_concepts == 0 {
        return format!(
            "No filing concepts found for {ticker} ({})",
            frequency.as_str()
        );
    }

    let mut lines = vec![
        format!(
            "Available filing concepts for {ticker} ({})",
            frequency.as_str()
        ),
        format!("Periods: {} | Concepts: {total_concepts}", grouped.len()),
        String::new(),
    ];

    for (period_id, entries) in grouped.iter().rev() {
        lines.push(period_id.clone());
        for (taxonomy, concept_with_unit, label) in entries {
            match label {
                Some(label) if !label.trim().is_empty() => {
                    lines.push(format!("- {taxonomy}:{concept_with_unit} | {label}",))
                }
                _ => lines.push(format!("- {taxonomy}:{concept_with_unit}")),
            }
        }
        lines.push(String::new());
    }

    while lines.last().is_some_and(String::is_empty) {
        lines.pop();
    }

    lines.join("\n")
}

fn format_mappings_overview(mappings: &[CustomConceptMapping]) -> String {
    let mut lines = vec![
        "Custom mappings".to_string(),
        format!("Count: {}", mappings.len()),
        String::new(),
    ];

    if mappings.is_empty() {
        lines.push("No custom mappings saved.".to_string());
    } else {
        for mapping in mappings {
            lines.push(format_mapping_line(mapping));
        }
    }

    lines.push(String::new());
    lines.push(mappings_usage().to_string());
    lines.push(mappings_add_usage().to_string());
    lines.push(
        "Add a ticker to inspect filing concepts and prepare a mapping template.".to_string(),
    );

    lines.join("\n")
}

fn format_created_mapping(mapping: &CustomConceptMapping) -> String {
    if let Some(custom_concept) = mapping.custom_concept() {
        format!(
            "Created custom mapping:\n- target: {}\n- concept: {}:{} [{}]\n- scope: {}\n- status: enabled",
            target_field_key(mapping.target_field),
            custom_concept.taxonomy,
            custom_concept.concept,
            unit_family_label(custom_concept.unit_family),
            format_mapping_scope(mapping),
        )
    } else if let Some(formula) = mapping.formula() {
        format!(
            "Created formula mapping:\n- target: {}\n- formula: {}\n- scope: {}\n- status: enabled",
            target_field_key(mapping.target_field),
            formula.expression,
            format_mapping_scope(mapping),
        )
    } else {
        format!(
            "Created custom mapping:\n- target: {}\n- scope: {}\n- status: enabled",
            target_field_key(mapping.target_field),
            format_mapping_scope(mapping),
        )
    }
}

fn format_updated_mapping(mapping: &CustomConceptMapping) -> String {
    if let Some(custom_concept) = mapping.custom_concept() {
        format!(
            "Updated mapping {}:\n- target: {}\n- concept: {}:{} [{}]\n- scope: {}",
            mapping.id,
            target_field_key(mapping.target_field),
            custom_concept.taxonomy,
            custom_concept.concept,
            unit_family_label(custom_concept.unit_family),
            format_mapping_scope(mapping),
        )
    } else if let Some(formula) = mapping.formula() {
        format!(
            "Updated mapping {}:\n- target: {}\n- formula: {}\n- scope: {}",
            mapping.id,
            target_field_key(mapping.target_field),
            formula.expression,
            format_mapping_scope(mapping),
        )
    } else {
        format!(
            "Updated mapping {}:\n- target: {}\n- scope: {}",
            mapping.id,
            target_field_key(mapping.target_field),
            format_mapping_scope(mapping),
        )
    }
}

fn format_deleted_mapping(mapping: &CustomConceptMapping) -> String {
    format!(
        "Deleted mapping {} (target: {}, scope: {})",
        mapping.id,
        target_field_key(mapping.target_field),
        format_mapping_scope(mapping),
    )
}

fn format_formula_validation(result: &FormulaValidationResult) -> String {
    if !result.is_valid {
        return format!(
            "Formula invalid: {}",
            result
                .error
                .as_deref()
                .unwrap_or("unknown validation error")
        );
    }

    let fields = result
        .field_refs
        .as_ref()
        .map(|fields| {
            fields
                .iter()
                .map(|field| target_field_key(*field))
                .collect::<Vec<_>>()
                .join(", ")
        })
        .filter(|value| !value.is_empty())
        .unwrap_or_else(|| "none".to_string());
    let functions = result
        .functions
        .as_ref()
        .map(|functions| functions.join(", "))
        .filter(|value| !value.is_empty())
        .unwrap_or_else(|| "none".to_string());

    format!("Formula valid\n- fields: {fields}\n- functions: {functions}")
}

fn format_formula_mappings(ticker: Option<&str>, mappings: &[CustomConceptMapping]) -> String {
    let mut lines = vec![match ticker {
        Some(ticker) => format!("Formula mappings for {ticker}"),
        None => "Formula mappings".to_string(),
    }];
    lines.push(format!("Count: {}", mappings.len()));
    lines.push(String::new());

    if mappings.is_empty() {
        lines.push("No formula mappings saved.".to_string());
    } else {
        for mapping in mappings {
            lines.push(format_mapping_line(mapping));
        }
    }

    lines.join("\n")
}

fn format_mapping_assistant(
    ticker: &str,
    frequency: Frequency,
    mappings: &[CustomConceptMapping],
    concepts: &[AvailableXbrlConcept],
) -> String {
    let relevant_mappings = mappings
        .iter()
        .filter(|mapping| mapping_applies_to_ticker(mapping, ticker))
        .collect::<Vec<_>>();

    let mut lines = vec![
        format!(
            "Custom mapping assistant for {ticker} ({})",
            frequency.as_str()
        ),
        String::new(),
        "Existing mappings already in scope:".to_string(),
    ];

    if relevant_mappings.is_empty() {
        lines.push("- none".to_string());
    } else {
        for mapping in relevant_mappings {
            lines.push(format_mapping_line(mapping));
        }
    }

    lines.push(String::new());
    lines.push("Target fields and expected unit families:".to_string());
    for field in [
        TargetField::TotalRevenues,
        TargetField::CostOfSales,
        TargetField::GrossProfit,
        TargetField::CashAndEquivalents,
        TargetField::ShortTermInvestments,
        TargetField::AccountsReceivableNet,
        TargetField::Inventory,
        TargetField::OtherCurrentAssets,
        TargetField::TotalCurrentAssets,
        TargetField::PropertyPlantAndEquipmentNet,
        TargetField::Goodwill,
        TargetField::IntangibleAssets,
        TargetField::OtherNonCurrentAssets,
        TargetField::TotalAssets,
        TargetField::AccountsPayable,
        TargetField::AccruedExpensesAndOtherCurrentLiabilities,
        TargetField::ShortTermDebt,
        TargetField::TotalCurrentLiabilities,
        TargetField::LongTermDebt,
        TargetField::OtherNonCurrentLiabilities,
        TargetField::TotalLiabilities,
        TargetField::RetainedEarnings,
        TargetField::AccumulatedOtherComprehensiveIncomeLoss,
        TargetField::TotalEquity,
        TargetField::SellingGeneralAndAdministrative,
        TargetField::ResearchAndDevelopment,
        TargetField::OtherOperatingExpenses,
        TargetField::OperatingProfit,
        TargetField::NetIncome,
        TargetField::NetIncomeAttributableToCommonShareholders,
        TargetField::NonOperatingIncome,
        TargetField::TotalNonOperatingIncome,
        TargetField::IncomeBeforeProvisionForIncomeTaxes,
        TargetField::ProvisionForIncomeTaxes,
        TargetField::BasicEps,
        TargetField::DilutedEps,
        TargetField::BasicWeightedAverageSharesOutstanding,
        TargetField::TotalSharesOutstanding,
        TargetField::DilutedWeightedAverageSharesOutstanding,
        TargetField::Ebitda,
        TargetField::EffectiveTaxRate,
        TargetField::FreeCashFlow,
        TargetField::CapitalExpenditure,
    ] {
        lines.push(format!(
            "- {} | {} | expected unit: {}",
            target_field_key(field),
            target_field_label(field),
            target_field_unit_hint(field),
        ));
    }

    lines.push(String::new());
    lines.push("Creation checklist:".to_string());
    lines.push("- Prefer a ticker-specific scope before creating a global mapping.".to_string());
    lines.push("- Choose a concept whose unit family matches the target field.".to_string());
    lines.push(
        "- If a mapping for the same target field and scope exists, it will be updated automatically.".to_string(),
    );
    lines.push("- Use notes to explain why the override is needed.".to_string());
    lines.push(String::new());
    lines.push("Starter template:".to_string());
    lines.push("{".to_string());
    lines.push(r#"  "targetField": "total_revenues","#.to_string());
    lines.push(r#"  "customConcept": {"#.to_string());
    lines.push(r#"    "taxonomy": "us-gaap","#.to_string());
    lines.push(
        r#"    "concept": "RevenueFromContractWithCustomerExcludingAssessedTax","#.to_string(),
    );
    lines.push(r#"    "unitFamily": "currency""#.to_string());
    lines.push("  },".to_string());
    lines.push(r#"  "scope": {"#.to_string());
    lines.push(format!(r#"    "ticker": "{ticker}""#));
    lines.push("  },".to_string());
    lines.push(r#"  "notes": "Optional reason for the override""#.to_string());
    lines.push("}".to_string());
    lines.push(String::new());
    lines.push(format_available_concepts(ticker, frequency, concepts));

    lines.join("\n")
}

fn format_mapping_line(mapping: &CustomConceptMapping) -> String {
    let status = if mapping.enabled {
        "enabled"
    } else {
        "disabled"
    };
    let notes = mapping
        .notes
        .as_deref()
        .filter(|value| !value.trim().is_empty())
        .map(|value| format!(" | notes: {value}"))
        .unwrap_or_default();

    if let Some(custom_concept) = mapping.custom_concept() {
        format!(
            "- [{status}] {} -> {}:{} [{}] | scope: {}{}",
            target_field_key(mapping.target_field),
            custom_concept.taxonomy,
            custom_concept.concept,
            unit_family_label(custom_concept.unit_family),
            format_mapping_scope(mapping),
            notes,
        )
    } else if let Some(formula) = mapping.formula() {
        format!(
            "- [{status}] {} -> formula: {} | scope: {}{}",
            target_field_key(mapping.target_field),
            formula.expression,
            format_mapping_scope(mapping),
            notes,
        )
    } else {
        format!(
            "- [{status}] {} -> unknown mapping type | scope: {}{}",
            target_field_key(mapping.target_field),
            format_mapping_scope(mapping),
            notes,
        )
    }
}

fn format_mapping_scope(mapping: &CustomConceptMapping) -> String {
    let mut parts = Vec::new();

    if let Some(ticker) = mapping
        .scope
        .ticker
        .as_deref()
        .filter(|value| !value.trim().is_empty())
    {
        parts.push(format!("ticker={ticker}"));
    }

    match (
        mapping.scope.period_start.as_deref(),
        mapping.scope.period_end.as_deref(),
    ) {
        (Some(start), Some(end)) => parts.push(format!("period={start}..{end}")),
        (Some(start), None) => parts.push(format!("period>={start}")),
        (None, Some(end)) => parts.push(format!("period<={end}")),
        (None, None) => {}
    }

    if parts.is_empty() {
        "global".to_string()
    } else {
        parts.join(", ")
    }
}

fn mapping_applies_to_ticker(mapping: &CustomConceptMapping, ticker: &str) -> bool {
    mapping
        .scope
        .ticker
        .as_deref()
        .is_none_or(|candidate| candidate.trim().eq_ignore_ascii_case(ticker))
}

fn is_frequency_token(value: &str) -> bool {
    matches!(
        value.trim().to_ascii_lowercase().as_str(),
        "annual" | "quarterly"
    )
}

fn is_reserved_mappings_subcommand(value: &str) -> bool {
    matches!(
        value.trim().to_ascii_lowercase().as_str(),
        "toggle" | "import" | "export"
    )
}

fn target_field_key(field: TargetField) -> &'static str {
    match field {
        TargetField::TotalRevenues => "total_revenues",
        TargetField::CostOfSales => "cost_of_sales",
        TargetField::GrossProfit => "gross_profit",
        TargetField::CashAndEquivalents => "cash_and_equivalents",
        TargetField::ShortTermInvestments => "short_term_investments",
        TargetField::AccountsReceivableNet => "accounts_receivable_net",
        TargetField::Inventory => "inventory",
        TargetField::OtherCurrentAssets => "other_current_assets",
        TargetField::TotalCurrentAssets => "total_current_assets",
        TargetField::PropertyPlantAndEquipmentNet => "property_plant_and_equipment_net",
        TargetField::Goodwill => "goodwill",
        TargetField::IntangibleAssets => "intangible_assets",
        TargetField::OtherNonCurrentAssets => "other_non_current_assets",
        TargetField::TotalAssets => "total_assets",
        TargetField::AccountsPayable => "accounts_payable",
        TargetField::AccruedExpensesAndOtherCurrentLiabilities => {
            "accrued_expenses_and_other_current_liabilities"
        }
        TargetField::ShortTermDebt => "short_term_debt",
        TargetField::TotalCurrentLiabilities => "total_current_liabilities",
        TargetField::LongTermDebt => "long_term_debt",
        TargetField::OtherNonCurrentLiabilities => "other_non_current_liabilities",
        TargetField::TotalLiabilities => "total_liabilities",
        TargetField::RetainedEarnings => "retained_earnings",
        TargetField::AccumulatedOtherComprehensiveIncomeLoss => {
            "accumulated_other_comprehensive_income_loss"
        }
        TargetField::TotalEquity => "total_equity",
        TargetField::SellingGeneralAndAdministrative => "selling_general_and_administrative",
        TargetField::ResearchAndDevelopment => "research_and_development",
        TargetField::OtherOperatingExpenses => "other_operating_expenses",
        TargetField::OperatingProfit => "operating_profit",
        TargetField::NetIncome => "net_income",
        TargetField::NetIncomeAttributableToCommonShareholders => {
            "net_income_attributable_to_common_shareholders"
        }
        TargetField::NonOperatingIncome => "non_operating_income",
        TargetField::TotalNonOperatingIncome => "total_non_operating_income",
        TargetField::IncomeBeforeProvisionForIncomeTaxes => {
            "income_before_provision_for_income_taxes"
        }
        TargetField::ProvisionForIncomeTaxes => "provision_for_income_taxes",
        TargetField::BasicEps => "basic_eps",
        TargetField::DilutedEps => "diluted_eps",
        TargetField::BasicWeightedAverageSharesOutstanding => {
            "basic_weighted_average_shares_outstanding"
        }
        TargetField::TotalSharesOutstanding => "total_shares_outstanding",
        TargetField::DilutedWeightedAverageSharesOutstanding => {
            "diluted_weighted_average_shares_outstanding"
        }
        TargetField::Ebitda => "ebitda",
        TargetField::EffectiveTaxRate => "effective_tax_rate",
        TargetField::FreeCashFlow => "free_cash_flow",
        TargetField::CapitalExpenditure => "capital_expenditure",
        TargetField::OperatingCashFlow => "operating_cash_flow",
        TargetField::InvestingCashFlow => "investing_cash_flow",
        TargetField::FinancingCashFlow => "financing_cash_flow",
    }
}

fn target_field_label(field: TargetField) -> &'static str {
    match field {
        TargetField::TotalRevenues => "Total Revenues",
        TargetField::CostOfSales => "Cost of Sales",
        TargetField::GrossProfit => "Gross Profit",
        TargetField::CashAndEquivalents => "Cash & Equivalents",
        TargetField::ShortTermInvestments => "Short-Term Investments",
        TargetField::AccountsReceivableNet => "Accounts Receivable, Net",
        TargetField::Inventory => "Inventory",
        TargetField::OtherCurrentAssets => "Other Current Assets",
        TargetField::TotalCurrentAssets => "Total Current Assets",
        TargetField::PropertyPlantAndEquipmentNet => "Property, Plant & Equipment, Net",
        TargetField::Goodwill => "Goodwill",
        TargetField::IntangibleAssets => "Intangible Assets",
        TargetField::OtherNonCurrentAssets => "Other Non-Current Assets",
        TargetField::TotalAssets => "Total Assets",
        TargetField::AccountsPayable => "Accounts Payable",
        TargetField::AccruedExpensesAndOtherCurrentLiabilities => {
            "Accrued Expenses & Other Current Liabilities"
        }
        TargetField::ShortTermDebt => "Short-Term Debt",
        TargetField::TotalCurrentLiabilities => "Total Current Liabilities",
        TargetField::LongTermDebt => "Long-Term Debt",
        TargetField::OtherNonCurrentLiabilities => "Other Non-Current Liabilities",
        TargetField::TotalLiabilities => "Total Liabilities",
        TargetField::RetainedEarnings => "Retained Earnings",
        TargetField::AccumulatedOtherComprehensiveIncomeLoss => {
            "Accumulated Other Comprehensive Income (Loss)"
        }
        TargetField::TotalEquity => "Total Equity",
        TargetField::SellingGeneralAndAdministrative => {
            "Selling, General and Administrative Expenses"
        }
        TargetField::ResearchAndDevelopment => "Research and Development Expenses",
        TargetField::OtherOperatingExpenses => "Other Operating Expenses",
        TargetField::OperatingProfit => "Operating Profit",
        TargetField::NetIncome => "Consolidated Net Income",
        TargetField::NetIncomeAttributableToCommonShareholders => {
            "Net Income Attributable to Common Shareholders"
        }
        TargetField::NonOperatingIncome => "Non-Operating Income",
        TargetField::TotalNonOperatingIncome => "Total Non-Operating Income",
        TargetField::IncomeBeforeProvisionForIncomeTaxes => {
            "Income Before Provision for Income Taxes"
        }
        TargetField::ProvisionForIncomeTaxes => "Provision for Income Taxes",
        TargetField::BasicEps => "Basic EPS",
        TargetField::DilutedEps => "Diluted EPS",
        TargetField::BasicWeightedAverageSharesOutstanding => {
            "Basic Weighted Average Shares Outstanding"
        }
        TargetField::TotalSharesOutstanding => "Total Shares Outstanding",
        TargetField::DilutedWeightedAverageSharesOutstanding => {
            "Diluted Weighted Average Shares Outstanding"
        }
        TargetField::Ebitda => "EBITDA",
        TargetField::EffectiveTaxRate => "Effective Tax Rate",
        TargetField::FreeCashFlow => "Free Cash Flow",
        TargetField::CapitalExpenditure => "Capital Expenditure",
        TargetField::OperatingCashFlow => "Operating Cash Flow",
        TargetField::InvestingCashFlow => "Investing Cash Flow",
        TargetField::FinancingCashFlow => "Financing Cash Flow",
    }
}

fn target_field_unit_hint(field: TargetField) -> &'static str {
    match field {
        TargetField::BasicEps | TargetField::DilutedEps => "currency_per_share",
        TargetField::BasicWeightedAverageSharesOutstanding
        | TargetField::TotalSharesOutstanding
        | TargetField::DilutedWeightedAverageSharesOutstanding => "shares",
        TargetField::EffectiveTaxRate => "pure",
        TargetField::TotalRevenues
        | TargetField::CostOfSales
        | TargetField::GrossProfit
        | TargetField::CashAndEquivalents
        | TargetField::ShortTermInvestments
        | TargetField::AccountsReceivableNet
        | TargetField::Inventory
        | TargetField::OtherCurrentAssets
        | TargetField::TotalCurrentAssets
        | TargetField::PropertyPlantAndEquipmentNet
        | TargetField::Goodwill
        | TargetField::IntangibleAssets
        | TargetField::OtherNonCurrentAssets
        | TargetField::TotalAssets
        | TargetField::AccountsPayable
        | TargetField::AccruedExpensesAndOtherCurrentLiabilities
        | TargetField::ShortTermDebt
        | TargetField::TotalCurrentLiabilities
        | TargetField::LongTermDebt
        | TargetField::OtherNonCurrentLiabilities
        | TargetField::TotalLiabilities
        | TargetField::RetainedEarnings
        | TargetField::AccumulatedOtherComprehensiveIncomeLoss
        | TargetField::TotalEquity
        | TargetField::SellingGeneralAndAdministrative
        | TargetField::ResearchAndDevelopment
        | TargetField::OtherOperatingExpenses
        | TargetField::OperatingProfit
        | TargetField::NetIncome
        | TargetField::NetIncomeAttributableToCommonShareholders
        | TargetField::NonOperatingIncome
        | TargetField::TotalNonOperatingIncome
        | TargetField::IncomeBeforeProvisionForIncomeTaxes
        | TargetField::ProvisionForIncomeTaxes
        | TargetField::Ebitda
        | TargetField::FreeCashFlow
        | TargetField::CapitalExpenditure
        | TargetField::OperatingCashFlow
        | TargetField::InvestingCashFlow
        | TargetField::FinancingCashFlow => "currency",
    }
}

fn unit_family_label(unit_family: crate::terminal::sec_edgar::UnitFamily) -> &'static str {
    match unit_family {
        crate::terminal::sec_edgar::UnitFamily::Currency => "currency",
        crate::terminal::sec_edgar::UnitFamily::CurrencyPerShare => "currency_per_share",
        crate::terminal::sec_edgar::UnitFamily::Shares => "shares",
        crate::terminal::sec_edgar::UnitFamily::Pure => "pure",
    }
}

fn parse_balance_sheet_query(
    command: &str,
    args: &[String],
    default_frequency: Frequency,
) -> Result<(String, BalanceSheetQuery), Box<TerminalCommandResponse>> {
    let Some(ticker) = args
        .first()
        .map(|value| value.trim())
        .filter(|value| !value.is_empty() && !value.starts_with('-'))
    else {
        return Err(Box::new(TerminalCommandResponse::Text {
            content: balance_sheet_usage().to_string(),
            portfolio: None,
        }));
    };

    let (frequency, remaining_args) =
        match args.get(1).map(|value| value.trim().to_ascii_lowercase()) {
            Some(value) if value == "annual" => (Frequency::Annual, &args[2..]),
            Some(value) if value == "quarterly" => (Frequency::Quarterly, &args[2..]),
            _ => (default_frequency, &args[1..]),
        };

    let (range_args, currency_args) = match remaining_args
        .iter()
        .position(|value| value.eq_ignore_ascii_case("--currency"))
    {
        Some(index) if index + 2 == remaining_args.len() => {
            (&remaining_args[..index], &remaining_args[index..])
        }
        Some(_) => {
            return Err(Box::new(TerminalCommandResponse::Text {
                content: balance_sheet_usage().to_string(),
                portfolio: None,
            }));
        }
        None => (remaining_args, &[][..]),
    };

    let range = match range_args {
        [] => None,
        [start, end] => Some(
            parse_earnings_range(start, end, frequency).map_err(|detail| {
                Box::new(TerminalCommandResponse::Text {
                    content: format!("{detail}\n{}", balance_sheet_usage()),
                    portfolio: None,
                })
            })?,
        ),
        _ => {
            return Err(Box::new(TerminalCommandResponse::Text {
                content: balance_sheet_usage().to_string(),
                portfolio: None,
            }));
        }
    };
    let requested_currency = parse_currency_flag(currency_args, balance_sheet_usage())?;

    let _ = command;

    Ok((
        ticker.to_ascii_uppercase(),
        BalanceSheetQuery {
            frequency,
            range,
            requested_currency,
        },
    ))
}

fn parse_earnings_query(
    command: &str,
    args: &[String],
    default_frequency: Frequency,
) -> Result<(String, EarningsQuery), Box<TerminalCommandResponse>> {
    let Some(ticker) = args
        .first()
        .map(|value| value.trim())
        .filter(|value| !value.is_empty())
    else {
        return Err(Box::new(TerminalCommandResponse::Text {
            content: earnings_usage().to_string(),
            portfolio: None,
        }));
    };

    let (frequency, remaining_args) =
        match args.get(1).map(|value| value.trim().to_ascii_lowercase()) {
            Some(value) if value == "annual" => (Frequency::Annual, &args[2..]),
            Some(value) if value == "quarterly" => (Frequency::Quarterly, &args[2..]),
            _ => (default_frequency, &args[1..]),
        };
    let (range_args, currency_args) = match remaining_args
        .iter()
        .position(|value| value.eq_ignore_ascii_case("--currency"))
    {
        Some(index) if index + 2 == remaining_args.len() => {
            (&remaining_args[..index], &remaining_args[index..])
        }
        Some(_) => {
            return Err(Box::new(TerminalCommandResponse::Text {
                content: earnings_usage().to_string(),
                portfolio: None,
            }))
        }
        None => (remaining_args, &[][..]),
    };

    let range = match range_args {
        [] => None,
        [start, end] => Some(
            parse_earnings_range(start, end, frequency).map_err(|detail| {
                Box::new(TerminalCommandResponse::Text {
                    content: format!("{detail}\n{}", earnings_usage()),
                    portfolio: None,
                })
            })?,
        ),
        _ => {
            return Err(Box::new(TerminalCommandResponse::Text {
                content: earnings_usage().to_string(),
                portfolio: None,
            }))
        }
    };
    let requested_currency = parse_currency_flag(currency_args, earnings_usage())?;

    let _ = command;

    Ok((
        ticker.to_ascii_uppercase(),
        EarningsQuery {
            frequency,
            range,
            requested_currency,
        },
    ))
}

fn parse_cash_flow_query(
    command: &str,
    args: &[String],
    default_frequency: Frequency,
) -> Result<(String, CashFlowQuery), Box<TerminalCommandResponse>> {
    let Some(ticker) = args
        .first()
        .map(|value| value.trim())
        .filter(|value| !value.is_empty() && !value.starts_with('-'))
    else {
        return Err(Box::new(TerminalCommandResponse::Text {
            content: cash_flow_usage().to_string(),
            portfolio: None,
        }));
    };

    let (frequency, remaining_args) =
        match args.get(1).map(|value| value.trim().to_ascii_lowercase()) {
            Some(value) if value == "annual" => (Frequency::Annual, &args[2..]),
            Some(value) if value == "quarterly" => (Frequency::Quarterly, &args[2..]),
            _ => (default_frequency, &args[1..]),
        };

    let range = match remaining_args {
        [] => None,
        [start, end] => Some(
            parse_earnings_range(start, end, frequency).map_err(|detail| {
                Box::new(TerminalCommandResponse::Text {
                    content: format!("{detail}\n{}", cash_flow_usage()),
                    portfolio: None,
                })
            })?,
        ),
        _ => {
            return Err(Box::new(TerminalCommandResponse::Text {
                content: cash_flow_usage().to_string(),
                portfolio: None,
            }));
        }
    };

    let _ = command;

    Ok((
        ticker.to_ascii_uppercase(),
        CashFlowQuery { frequency, range },
    ))
}

fn parse_currency_flag(
    args: &[String],
    usage: &str,
) -> Result<Option<String>, Box<TerminalCommandResponse>> {
    match args {
        [] => Ok(None),
        [flag, value] if flag.eq_ignore_ascii_case("--currency") => {
            let normalized = value.trim().to_ascii_uppercase();
            if normalized.is_empty() {
                return Err(Box::new(TerminalCommandResponse::Text {
                    content: usage.to_string(),
                    portfolio: None,
                }));
            }

            Ok(Some(normalized))
        }
        _ => Err(Box::new(TerminalCommandResponse::Text {
            content: usage.to_string(),
            portfolio: None,
        })),
    }
}

fn parse_filings_query(
    raw_input: &str,
) -> Result<(String, FilingsQuery), Box<TerminalCommandResponse>> {
    let tokens = tokenize_raw_command_input(raw_input).map_err(|message| {
        Box::new(TerminalCommandResponse::Text {
            content: format!("{message}\n{}", filings_usage()),
            portfolio: None,
        })
    })?;

    if !tokens
        .first()
        .is_some_and(|value| value.eq_ignore_ascii_case("/filings"))
    {
        return Err(Box::new(TerminalCommandResponse::Text {
            content: filings_usage().to_string(),
            portfolio: None,
        }));
    }

    let Some(ticker) = tokens
        .get(1)
        .map(|value| value.trim())
        .filter(|value| !value.is_empty() && !value.starts_with('-'))
    else {
        return Err(Box::new(TerminalCommandResponse::Text {
            content: filings_usage().to_string(),
            portfolio: None,
        }));
    };

    let mut page = 1;
    let mut page_size = 25;
    let mut query = None;
    let mut index = 2;

    while index < tokens.len() {
        match tokens[index].as_str() {
            "--page" => {
                let Some(value) = tokens.get(index + 1) else {
                    return Err(Box::new(TerminalCommandResponse::Text {
                        content: filings_usage().to_string(),
                        portfolio: None,
                    }));
                };
                let Some(parsed_page) = parse_positive_usize(value) else {
                    return Err(Box::new(TerminalCommandResponse::Text {
                        content: format!("Page must be >= 1.\n{}", filings_usage()),
                        portfolio: None,
                    }));
                };
                page = parsed_page;
                index += 2;
            }
            "--page-size" => {
                let Some(value) = tokens.get(index + 1) else {
                    return Err(Box::new(TerminalCommandResponse::Text {
                        content: filings_usage().to_string(),
                        portfolio: None,
                    }));
                };
                let Some(parsed_page_size) = parse_positive_usize(value) else {
                    return Err(Box::new(TerminalCommandResponse::Text {
                        content: format!(
                            "Page size must be one of: 25, 50, 100.\n{}",
                            filings_usage()
                        ),
                        portfolio: None,
                    }));
                };
                if !matches!(parsed_page_size, 25 | 50 | 100) {
                    return Err(Box::new(TerminalCommandResponse::Text {
                        content: format!(
                            "Page size must be one of: 25, 50, 100.\n{}",
                            filings_usage()
                        ),
                        portfolio: None,
                    }));
                }
                page_size = parsed_page_size;
                index += 2;
            }
            "--query" => {
                let Some(value) = tokens.get(index + 1) else {
                    return Err(Box::new(TerminalCommandResponse::Text {
                        content: filings_usage().to_string(),
                        portfolio: None,
                    }));
                };
                query = Some(value.trim().to_string());
                index += 2;
            }
            _ => {
                return Err(Box::new(TerminalCommandResponse::Text {
                    content: filings_usage().to_string(),
                    portfolio: None,
                }))
            }
        }
    }

    Ok((
        ticker.to_ascii_uppercase(),
        FilingsQuery {
            page,
            page_size,
            query: query.filter(|value| !value.trim().is_empty()),
        },
    ))
}

fn parse_earnings_range(
    start: &str,
    end: &str,
    frequency: Frequency,
) -> Result<EarningsRange, String> {
    let start = parse_earnings_period_key(start, frequency)?;
    let end = parse_earnings_period_key(end, frequency)?;

    if start.token() > end.token() {
        return Err("Earnings range start must be earlier than or equal to the end.".to_string());
    }

    Ok(EarningsRange { start, end })
}

fn parse_earnings_period_key(
    value: &str,
    frequency: Frequency,
) -> Result<EarningsPeriodKey, String> {
    let value = value.trim().to_ascii_uppercase();
    match frequency {
        Frequency::Annual => {
            if value.len() != 4 || !value.chars().all(|character| character.is_ascii_digit()) {
                return Err("Annual /em ranges must use YYYY tokens.".to_string());
            }

            let fiscal_year = value
                .parse::<i32>()
                .map_err(|_| "Annual /em ranges must use YYYY tokens.".to_string())?;
            Ok(EarningsPeriodKey::Annual { fiscal_year })
        }
        Frequency::Quarterly => {
            let (year, quarter) = value
                .split_once('Q')
                .ok_or_else(|| "Quarterly /em ranges must use YYYYQn tokens.".to_string())?;
            if year.len() != 4 || !year.chars().all(|character| character.is_ascii_digit()) {
                return Err("Quarterly /em ranges must use YYYYQn tokens.".to_string());
            }
            let fiscal_year = year
                .parse::<i32>()
                .map_err(|_| "Quarterly /em ranges must use YYYYQn tokens.".to_string())?;
            let quarter = quarter
                .parse::<u8>()
                .map_err(|_| "Quarterly /em ranges must use YYYYQn tokens.".to_string())?;
            if !(1..=4).contains(&quarter) {
                return Err("Quarterly /em ranges must use YYYYQn tokens.".to_string());
            }
            Ok(EarningsPeriodKey::Quarterly {
                fiscal_year,
                quarter,
            })
        }
    }
}

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)
}

fn tokenize_raw_command_input(input: &str) -> Result<Vec<String>, String> {
    let mut tokens = Vec::new();
    let mut current = String::new();
    let mut quote = None;
    let mut escaping = false;

    for character in input.trim().chars() {
        if escaping {
            current.push(character);
            escaping = false;
            continue;
        }

        if character == '\\' {
            escaping = true;
            continue;
        }

        match quote {
            Some(active_quote) if character == active_quote => {
                quote = None;
            }
            Some(_) => current.push(character),
            None if matches!(character, '"' | '\'') => {
                quote = Some(character);
            }
            None if character.is_whitespace() => {
                if !current.is_empty() {
                    tokens.push(std::mem::take(&mut current));
                }
            }
            None => current.push(character),
        }
    }

    if escaping {
        current.push('\\');
    }

    if quote.is_some() {
        return Err("Unterminated quoted value.".to_string());
    }

    if !current.is_empty() {
        tokens.push(current);
    }

    Ok(tokens)
}

/// 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  /bs [ticker] [annual|quarterly] [start] [end] [--currency <ISO4217>] - SEC balance sheet table\n  /cf [ticker] [annual|quarterly] [start] [end] - SEC cash flow summary\n  /dvd [ticker]                    - SEC dividends history\n  /filings <ticker> [--page <n>] [--page-size <n>] [--query <term>] - SEC filing history\n  /concepts [ticker] [annual|quarterly] - List filing concepts\n  /mappings [ticker] [annual|quarterly] - Assist with custom mappings\n  /mappings add <json>             - Create a custom mapping\n  /em [ticker] [annual|quarterly] [start] [end] [--currency <ISO4217>] - 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 futures::future::BoxFuture;
    use std::fs;
    use std::path::PathBuf;
    use std::sync::atomic::{AtomicUsize, Ordering};
    use std::sync::{Arc, Mutex};
    use std::time::Duration;
    use wiremock::matchers::{method, path};
    use wiremock::{Mock, MockServer, ResponseTemplate};

    use super::{
        balance_sheet_usage, earnings_usage, filings_usage, parse_filings_query,
        TerminalCommandService,
    };
    use crate::mappings::{
        CustomConcept, CustomConceptMapping, MappingChangeKind, MappingKind, MappingScope,
        MappingsEventEmitter, MappingsService, MappingsStorage, MappingsUpdatedEvent, TargetField,
    };
    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::{
        BalanceSheetQuery, CashFlowQuery, EarningsQuery, EdgarDataLookup, EdgarLookupError,
        FilingsQuery, UnitFamily,
    };
    use crate::terminal::security_lookup::{
        SecurityKind, SecurityLookup, SecurityLookupError, SecurityMatch,
    };
    use crate::terminal::types::AvailableXbrlConcept;
    use crate::terminal::{
        BalanceSheetPanelData, CashFlowPanelData, Company, DividendsPanelData, EarningsPanelData,
        ExecuteTerminalCommandRequest, FilingsPanelData, FinancialsPanelData, Frequency, Holding,
        PanelPayload, Portfolio, SourceStatus, TerminalCommandResponse,
    };

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

    #[derive(Default)]
    struct RecordingMappingsEventEmitter {
        events: Mutex<Vec<MappingsUpdatedEvent>>,
    }

    impl RecordingMappingsEventEmitter {
        fn recorded(&self) -> Vec<MappingsUpdatedEvent> {
            self.events.lock().unwrap().clone()
        }
    }

    impl MappingsEventEmitter for RecordingMappingsEventEmitter {
        fn emit_updated(&self, payload: &MappingsUpdatedEvent) -> Result<(), String> {
            self.events.lock().unwrap().push(payload.clone());
            Ok(())
        }
    }

    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,
                        degraded_reason: None,
                    },
                    available_concepts: Vec::new(),
                    mapping_warnings: Vec::new(),
                })
            })
        }

        fn cash_flow<'a>(
            &'a self,
            ticker: &'a str,
            query: CashFlowQuery,
        ) -> 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: query.frequency,
                    periods: Vec::new(),
                    latest_filing: None,
                    source_status: SourceStatus {
                        companyfacts_used: true,
                        degraded_reason: None,
                    },
                    available_periods: Vec::new(),
                    selected_period_start: query.range.as_ref().map(|range| range.start.token()),
                    selected_period_end: query.range.as_ref().map(|range| range.end.token()),
                    mapping_warnings: Vec::new(),
                })
            })
        }

        fn balance_sheet<'a>(
            &'a self,
            ticker: &'a str,
            query: BalanceSheetQuery,
        ) -> BoxFuture<'a, Result<BalanceSheetPanelData, EdgarLookupError>> {
            Box::pin(async move {
                Ok(BalanceSheetPanelData {
                    symbol: ticker.to_string(),
                    company_name: "Example Co".to_string(),
                    cik: "0000000001".to_string(),
                    frequency: query.frequency,
                    periods: Vec::new(),
                    latest_filing: None,
                    source_status: SourceStatus {
                        companyfacts_used: true,
                        degraded_reason: None,
                    },
                    available_concepts: Vec::new(),
                    selected_currency: query.requested_currency,
                    available_currencies: vec!["EUR".to_string(), "USD".to_string()],
                    mapping_warnings: Vec::new(),
                    available_periods: Vec::new(),
                    selected_period_start: query.range.as_ref().map(|range| range.start.token()),
                    selected_period_end: query.range.as_ref().map(|range| range.end.token()),
                })
            })
        }

        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,
                        degraded_reason: None,
                    },
                })
            })
        }

        fn earnings<'a>(
            &'a self,
            ticker: &'a str,
            query: EarningsQuery,
        ) -> 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: query.frequency,
                    periods: Vec::new(),
                    available_periods: Vec::new(),
                    selected_period_start: query.range.as_ref().map(|range| range.start.token()),
                    selected_period_end: query.range.as_ref().map(|range| range.end.token()),
                    selected_currency: query.requested_currency,
                    available_currencies: vec!["EUR".to_string(), "USD".to_string()],
                    latest_filing: None,
                    source_status: SourceStatus {
                        companyfacts_used: true,
                        degraded_reason: None,
                    },
                    available_concepts: vec![
                        AvailableXbrlConcept {
                            taxonomy: "us-gaap".to_string(),
                            concept: "RevenueFromContractWithCustomerExcludingAssessedTax"
                                .to_string(),
                            unit_family: "currency".to_string(),
                            label: Some("Revenue".to_string()),
                            period_id: "2024".to_string(),
                            value: None,
                        },
                        AvailableXbrlConcept {
                            taxonomy: "us-gaap".to_string(),
                            concept: "NetIncomeLoss".to_string(),
                            unit_family: "currency".to_string(),
                            label: Some("Net income".to_string()),
                            period_id: "2024".to_string(),
                            value: None,
                        },
                        AvailableXbrlConcept {
                            taxonomy: "dei".to_string(),
                            concept: "EntityCommonStockSharesOutstanding".to_string(),
                            unit_family: "shares".to_string(),
                            label: None,
                            period_id: "2023".to_string(),
                            value: None,
                        },
                    ],
                    mapping_warnings: Vec::new(),
                })
            })
        }

        fn filings<'a>(
            &'a self,
            ticker: &'a str,
            query: &'a FilingsQuery,
        ) -> BoxFuture<'a, Result<FilingsPanelData, EdgarLookupError>> {
            Box::pin(async move {
                Ok(FilingsPanelData {
                    symbol: ticker.to_string(),
                    company_name: "Example Co".to_string(),
                    cik: "0000000001".to_string(),
                    items: vec![crate::terminal::FilingListItem {
                        accession_number: "0000000001-25-000001".to_string(),
                        filing_date: "2025-02-01".to_string(),
                        report_date: Some("2024-12-31".to_string()),
                        form: "10-K".to_string(),
                        primary_document: "annual.htm".to_string(),
                        filing_url: "https://www.sec.gov/Archives/edgar/data/1/1/annual.htm"
                            .to_string(),
                        overview: "Annual report".to_string(),
                    }],
                    page: query.page,
                    page_size: query.page_size,
                    total_results: 1,
                    total_pages: 1,
                    query: query.query.clone(),
                    latest_filing: Some(crate::terminal::FilingListItem {
                        accession_number: "0000000001-25-000001".to_string(),
                        filing_date: "2025-02-01".to_string(),
                        report_date: Some("2024-12-31".to_string()),
                        form: "10-K".to_string(),
                        primary_document: "annual.htm".to_string(),
                        filing_url: "https://www.sec.gov/Archives/edgar/data/1/1/annual.htm"
                            .to_string(),
                        overview: "Annual report".to_string(),
                    }),
                })
            })
        }
    }

    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(),
                test_mappings_service(Vec::new()),
                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(),
            test_mappings_service(Vec::new()),
            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 test_mappings_service(initial_mappings: Vec<CustomConceptMapping>) -> Arc<MappingsService> {
        let root = unique_test_directory("terminal-mappings-service");
        let storage = MappingsStorage::new(root).unwrap();
        let service = Arc::new(MappingsService::new(storage).unwrap());

        for mapping in initial_mappings {
            futures::executor::block_on(service.add_mapping(mapping)).unwrap();
        }

        service
    }

    fn unique_test_directory(prefix: &str) -> PathBuf {
        let suffix = uuid::Uuid::new_v4();
        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,
            test_mappings_service(Vec::new()),
            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(),
            test_mappings_service(Vec::new()),
            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(),
            test_mappings_service(Vec::new()),
            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(),
            test_mappings_service(Vec::new()),
            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(),
            test_mappings_service(Vec::new()),
            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 routes_balance_sheet_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(),
            test_mappings_service(Vec::new()),
            Duration::ZERO,
        );

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

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

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

        let response = execute(&service, "/bs SAP annual --currency EUR");

        match response {
            TerminalCommandResponse::Panel { panel } => match panel.as_ref() {
                PanelPayload::BalanceSheet { data } => {
                    assert_eq!(data.symbol, "SAP");
                    assert_eq!(data.frequency, Frequency::Annual);
                    assert_eq!(data.selected_currency.as_deref(), Some("EUR"));
                }
                other => panic!("expected balance sheet panel, got {other:?}"),
            },
            other => panic!("expected balance sheet panel, got {other:?}"),
        }
    }

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

        let response = execute(&service, "/bs SAP annual --currency");

        match response {
            TerminalCommandResponse::Text { content, .. } => {
                assert_eq!(content, balance_sheet_usage());
            }
            other => panic!("expected text response, got {other:?}"),
        }
    }

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

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

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

    #[test]
    fn filings_command_returns_sec_panel_for_valid_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(),
            test_mappings_service(Vec::new()),
            Duration::ZERO,
        );

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

        match response {
            TerminalCommandResponse::Panel { panel } => match panel.as_ref() {
                PanelPayload::Filings { data } => {
                    assert_eq!(data.symbol, "AAPL");
                    assert_eq!(data.page, 1);
                    assert_eq!(data.page_size, 25);
                }
                other => panic!("expected filings panel, got {other:?}"),
            },
            other => panic!("expected filings panel, got {other:?}"),
        }
    }

    #[test]
    fn filings_command_requires_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(),
            test_mappings_service(Vec::new()),
            Duration::ZERO,
        );

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

        match response {
            TerminalCommandResponse::Text { content, .. } => {
                assert_eq!(content, filings_usage());
            }
            other => panic!("expected text response, got {other:?}"),
        }
    }

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

        let response = execute(&service, "/filings AAPL --page 0");

        match response {
            TerminalCommandResponse::Text { content, .. } => {
                assert_eq!(content, format!("Page must be >= 1.\n{}", filings_usage()));
            }
            other => panic!("expected text response, got {other:?}"),
        }
    }

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

        let response = execute(&service, "/filings AAPL --page-size 10");

        match response {
            TerminalCommandResponse::Text { content, .. } => {
                assert_eq!(
                    content,
                    format!(
                        "Page size must be one of: 25, 50, 100.\n{}",
                        filings_usage()
                    )
                );
            }
            other => panic!("expected text response, got {other:?}"),
        }
    }

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

        let response = execute(&service, "/filings AAPL --limit 5");

        match response {
            TerminalCommandResponse::Text { content, .. } => {
                assert_eq!(content, filings_usage());
            }
            other => panic!("expected text response, got {other:?}"),
        }
    }

    #[test]
    fn parse_filings_query_accepts_quoted_query_terms() {
        let (ticker, query) =
            parse_filings_query(r#"/filings aapl --page 2 --page-size 50 --query "annual report""#)
                .unwrap();

        assert_eq!(ticker, "AAPL");
        assert_eq!(
            query,
            FilingsQuery {
                page: 2,
                page_size: 50,
                query: Some("annual report".to_string()),
            }
        );
    }

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

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

        match response {
            TerminalCommandResponse::Text { content, .. } => {
                assert!(content.contains("Available filing concepts for AAPL (annual)"));
                assert!(content.contains("2024"));
                assert!(content.contains(
                    "us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax [currency] | Revenue"
                ));
                assert!(content.contains("dei:EntityCommonStockSharesOutstanding [shares]"));
            }
            other => panic!("expected text response, got {other:?}"),
        }
    }

    #[test]
    fn concepts_command_requires_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(),
            test_mappings_service(Vec::new()),
            Duration::ZERO,
        );

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

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

    #[test]
    fn mappings_command_lists_saved_mappings() {
        let service = TerminalCommandService::with_dependencies(
            load_mock_financial_data(),
            Arc::new(FakeSecurityLookup::successful(vec![])),
            Arc::new(FakeEdgarLookup),
            Arc::new(FakePortfolioService::default()),
            test_news_service(),
            test_mappings_service(vec![CustomConceptMapping::test_mapping(
                TargetField::TotalRevenues,
                CustomConcept {
                    taxonomy: "us-gaap".to_string(),
                    concept: "RevenueFromContractWithCustomerExcludingAssessedTax".to_string(),
                    unit_family: UnitFamily::Currency,
                },
                MappingScope {
                    ticker: Some("AAPL".to_string()),
                    period_start: None,
                    period_end: None,
                },
                Some("Use the company-specific revenue concept".to_string()),
            )]),
            Duration::ZERO,
        );

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

        match response {
            TerminalCommandResponse::Text { content, .. } => {
                assert!(content.contains("Custom mappings"));
                assert!(content.contains("Count: 1"));
                assert!(content.contains("[enabled] total_revenues"));
                assert!(content.contains("scope: ticker=AAPL"));
            }
            other => panic!("expected text response, got {other:?}"),
        }
    }

    #[test]
    fn mappings_command_builds_mapping_assistant_for_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(),
            test_mappings_service(vec![CustomConceptMapping::test_mapping(
                TargetField::NetIncome,
                CustomConcept {
                    taxonomy: "us-gaap".to_string(),
                    concept: "NetIncomeLoss".to_string(),
                    unit_family: UnitFamily::Currency,
                },
                MappingScope {
                    ticker: Some("AAPL".to_string()),
                    period_start: None,
                    period_end: None,
                },
                None,
            )]),
            Duration::ZERO,
        );

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

        match response {
            TerminalCommandResponse::Text { content, .. } => {
                assert!(content.contains("Custom mapping assistant for AAPL (quarterly)"));
                assert!(content.contains("Existing mappings already in scope:"));
                assert!(
                    content.contains("[enabled] net_income -> us-gaap:NetIncomeLoss [currency]")
                );
                assert!(content.contains("Target fields and expected unit families:"));
                assert!(
                    content.contains("- cost_of_sales | Cost of Sales | expected unit: currency")
                );
                assert!(content.contains(
                    "- cash_and_equivalents | Cash & Equivalents | expected unit: currency"
                ));
                assert!(content.contains("- total_assets | Total Assets | expected unit: currency"));
                assert!(content
                    .contains("- total_liabilities | Total Liabilities | expected unit: currency"));
                assert!(content.contains("- total_equity | Total Equity | expected unit: currency"));
                assert!(content.contains(
                    "- total_shares_outstanding | Total Shares Outstanding | expected unit: shares"
                ));
                assert!(content.contains("\"ticker\": \"AAPL\""));
                assert!(content.contains("Available filing concepts for AAPL (quarterly)"));
            }
            other => panic!("expected text response, got {other:?}"),
        }
    }

    #[test]
    fn mappings_add_command_creates_and_normalizes_mapping() {
        let mappings_service = test_mappings_service(Vec::new());
        let emitter = Arc::new(RecordingMappingsEventEmitter::default());
        let service = TerminalCommandService::with_dependencies_and_event_emitter(
            load_mock_financial_data(),
            Arc::new(FakeSecurityLookup::successful(vec![])),
            Arc::new(FakeEdgarLookup),
            Arc::new(FakePortfolioService::default()),
            test_news_service(),
            mappings_service.clone(),
            emitter.clone(),
        );

        let response = execute(
            &service,
            r#"/mappings add {"targetField":"total_revenues","customConcept":{"taxonomy":" us-gaap ","concept":" RevenueFromContractWithCustomerExcludingAssessedTax ","unitFamily":"currency"},"scope":{"ticker":" aapl ","periodStart":" 2024-01-01 ","periodEnd":" "},"notes":"  Use company-specific revenue concept  "}"#,
        );

        match response {
            TerminalCommandResponse::Text { content, .. } => {
                assert!(content.contains("Created custom mapping:"));
                assert!(content.contains("- target: total_revenues"));
                assert!(content.contains("- scope: ticker=AAPL, period>=2024-01-01"));
            }
            other => panic!("expected text response, got {other:?}"),
        }

        let mappings = futures::executor::block_on(mappings_service.list_mappings()).unwrap();
        assert_eq!(mappings.len(), 1);
        assert_eq!(mappings[0].scope.ticker.as_deref(), Some("AAPL"));
        assert_eq!(
            mappings[0].scope.period_start.as_deref(),
            Some("2024-01-01")
        );
        assert!(mappings[0].scope.period_end.is_none());
        assert_eq!(
            mappings[0].notes.as_deref(),
            Some("Use company-specific revenue concept")
        );

        let events = emitter.recorded();
        assert_eq!(events.len(), 1);
        assert_eq!(events[0].change_kind, MappingChangeKind::Created);
        assert_eq!(
            events[0].refresh_scope.affected_targets,
            vec![TargetField::TotalRevenues]
        );
    }

    #[test]
    fn mappings_add_command_rejects_invalid_json() {
        let emitter = Arc::new(RecordingMappingsEventEmitter::default());
        let service = TerminalCommandService::with_dependencies_and_event_emitter(
            load_mock_financial_data(),
            Arc::new(FakeSecurityLookup::successful(vec![])),
            Arc::new(FakeEdgarLookup),
            Arc::new(FakePortfolioService::default()),
            test_news_service(),
            test_mappings_service(Vec::new()),
            emitter.clone(),
        );

        let response = execute(&service, r#"/mappings add {"targetField":"total_revenues""#);

        match response {
            TerminalCommandResponse::Text { content, .. } => {
                assert!(content.contains("Usage: /mappings add"));
                assert!(content.contains("Invalid JSON payload:"));
            }
            other => panic!("expected text response, got {other:?}"),
        }

        assert!(emitter.recorded().is_empty());
    }

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

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

        match response {
            TerminalCommandResponse::Text { content, .. } => {
                assert_eq!(content, super::mappings_add_usage());
            }
            other => panic!("expected text response, got {other:?}"),
        }
    }

    #[test]
    fn mappings_add_command_surfaces_duplicate_mapping_errors() {
        let mappings_service = test_mappings_service(vec![CustomConceptMapping::test_mapping(
            TargetField::TotalRevenues,
            CustomConcept {
                taxonomy: "us-gaap".to_string(),
                concept: "RevenueFromContractWithCustomerExcludingAssessedTax".to_string(),
                unit_family: UnitFamily::Currency,
            },
            MappingScope {
                ticker: Some("AAPL".to_string()),
                period_start: None,
                period_end: None,
            },
            None,
        )]);
        let emitter = Arc::new(RecordingMappingsEventEmitter::default());
        let service = TerminalCommandService::with_dependencies_and_event_emitter(
            load_mock_financial_data(),
            Arc::new(FakeSecurityLookup::successful(vec![])),
            Arc::new(FakeEdgarLookup),
            Arc::new(FakePortfolioService::default()),
            test_news_service(),
            mappings_service,
            emitter.clone(),
        );

        let response = execute(
            &service,
            r#"/mappings add {"targetField":"total_revenues","customConcept":{"taxonomy":"us-gaap","concept":"RevenueFromContractWithCustomerExcludingAssessedTax","unitFamily":"currency"},"scope":{"ticker":"AAPL"}}"#,
        );

        match response {
            TerminalCommandResponse::Text { content, .. } => {
                assert!(content.contains("Updated mapping"));
                assert!(content.contains("- target: total_revenues"));
            }
            other => panic!("expected text response, got {other:?}"),
        }

        let events = emitter.recorded();
        assert_eq!(events.len(), 1);
        assert_eq!(events[0].change_kind, MappingChangeKind::Updated);
    }

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

        let created = execute(
            &service,
            r#"/formula create {"targetField":"free_cash_flow","expression":"$operating_cash_flow - ABS($capital_expenditure)","scope":{"ticker":"AAPL"}}"#,
        );
        assert!(matches!(created, TerminalCommandResponse::Text { .. }));

        let id = futures::executor::block_on(mappings_service.list_mappings())
            .unwrap()
            .first()
            .unwrap()
            .id
            .clone();

        let response = execute(
            &service,
            &format!(
                r#"/mappings update {id} {{"targetField":"free_cash_flow","mappingKind":{{"formula":{{"expression":"$operating_cash_flow - $capital_expenditure"}}}},"scope":{{"ticker":" aapl "}}}}"#
            ),
        );

        match response {
            TerminalCommandResponse::Text { content, .. } => {
                assert!(content.contains("Updated mapping"));
                assert!(content.contains("$operating_cash_flow - $capital_expenditure"));
            }
            other => panic!("expected text response, got {other:?}"),
        }

        let mappings = futures::executor::block_on(mappings_service.list_mappings()).unwrap();
        assert_eq!(mappings.len(), 1);
        assert_eq!(mappings[0].scope.ticker.as_deref(), Some("AAPL"));
        match &mappings[0].mapping_kind {
            MappingKind::Formula(formula) => {
                assert_eq!(
                    formula.expression,
                    "$operating_cash_flow - $capital_expenditure"
                );
                assert!(formula.cached_ast.is_some());
            }
            other => panic!("expected formula mapping, got {other:?}"),
        }
    }

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

        match execute(
            &service,
            r#"/formula validate "$gross_profit + $cost_of_sales""#,
        ) {
            TerminalCommandResponse::Text { content, .. } => {
                assert!(content.contains("Formula valid"));
                assert!(content.contains("gross_profit"));
                assert!(content.contains("cost_of_sales"));
            }
            other => panic!("expected text response, got {other:?}"),
        }

        match execute(
            &service,
            r#"/formula create {"targetField":"total_revenues","expression":"$gross_profit + $cost_of_sales","scope":{"ticker":"AAPL"},"notes":"derived revenue"}"#,
        ) {
            TerminalCommandResponse::Text { content, .. } => {
                assert!(content.contains("Created formula mapping"));
                assert!(content.contains("total_revenues"));
            }
            other => panic!("expected text response, got {other:?}"),
        }

        match execute(&service, "/formula list AAPL") {
            TerminalCommandResponse::Text { content, .. } => {
                assert!(content.contains("Formula mappings for AAPL"));
                assert!(content.contains("total_revenues -> formula"));
            }
            other => panic!("expected text response, got {other:?}"),
        }

        match execute(&service, "/formula remove whatever") {
            TerminalCommandResponse::Text { content, .. } => {
                assert_eq!(content, super::formula_usage());
            }
            other => panic!("expected text response, got {other:?}"),
        }
    }

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

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

        match response {
            TerminalCommandResponse::Text { content, .. } => {
                assert!(content.contains(
                    "Only `/mappings add`, `/mappings update`, and `/mappings delete` are supported"
                ));
            }
            other => panic!("expected text response, got {other:?}"),
        }
    }

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

        let response = execute(&service, "/em AAPL quarterly 2024Q1 2025Q2");

        match response {
            TerminalCommandResponse::Panel { panel } => match panel.as_ref() {
                PanelPayload::Earnings { data } => {
                    assert_eq!(data.frequency, Frequency::Quarterly);
                    assert_eq!(data.selected_period_start.as_deref(), Some("2024Q1"));
                    assert_eq!(data.selected_period_end.as_deref(), Some("2025Q2"));
                }
                other => panic!("expected earnings panel, got {other:?}"),
            },
            other => panic!("expected earnings panel, got {other:?}"),
        }
    }

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

        let response = execute(&service, "/em SAP quarterly 2024Q1 2025Q2 --currency EUR");

        match response {
            TerminalCommandResponse::Panel { panel } => match panel.as_ref() {
                PanelPayload::Earnings { data } => {
                    assert_eq!(data.frequency, Frequency::Quarterly);
                    assert_eq!(data.selected_period_start.as_deref(), Some("2024Q1"));
                    assert_eq!(data.selected_period_end.as_deref(), Some("2025Q2"));
                    assert_eq!(data.selected_currency.as_deref(), Some("EUR"));
                }
                other => panic!("expected earnings panel, got {other:?}"),
            },
            other => panic!("expected earnings panel, got {other:?}"),
        }
    }

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

        let response = execute(&service, "/em SAP --currency EUR quarterly");

        match response {
            TerminalCommandResponse::Text { content, .. } => {
                assert_eq!(content, earnings_usage());
            }
            other => panic!("expected text response, got {other:?}"),
        }
    }

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

        let response = execute(&service, "/em AAPL annual 2025 2024");

        match response {
            TerminalCommandResponse::Text { content, .. } => {
                assert!(content.contains("Earnings range start must be earlier"));
            }
            other => panic!("expected text response, 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"));
    }
}
