use std::fmt::{Display, Formatter};
use std::sync::Arc;
use std::{collections::HashMap, collections::HashSet};

use futures::future::BoxFuture;

use crate::mappings::{CustomConceptMapping, MappingsService, TargetField, XbrlConceptResolver};
use crate::terminal::{
    BalanceSheetPanelData, CashFlowPanelData, CashFlowPeriod, DividendsPanelData,
    EarningsPanelData, EarningsPeriodOption, FilingRef, FilingsPanelData, FinancialsPanelData,
    Frequency, StatementPeriod,
};

use super::client::SecEdgarClient;
use super::earnings::concepts::extract_available_concepts;
use super::earnings::facts::{
    classify_fact_unit_family, filing_fact_currency_code, FilingXbrlDocument,
};
use super::earnings::package::load_filing_package;
use super::earnings::{build_panel_data as build_earnings_panel_data, load_earnings_data};
use super::facts::{
    build_cash_flow_periods_from_rows, build_dividend_events, build_source_status,
    build_statement_period, build_statement_rows, company_name, default_earnings_period_limit,
    fact_matches_period_row, limit_dividend_ttm, matches_requested_currency, normalize_all_facts,
    select_latest_filing, ACCOUNTS_PAYABLE_CONCEPTS, ACCOUNTS_RECEIVABLE_CONCEPTS,
    ACCRUED_CURRENT_LIABILITIES_CONCEPTS, AOCI_CONCEPTS, ASSET_CONCEPTS, CAPEX_CONCEPTS,
    CASH_CONCEPTS, CFF_CONCEPTS, CFI_CONCEPTS, CFO_CONCEPTS, CURRENT_ASSETS_CONCEPTS,
    CURRENT_LIABILITIES_CONCEPTS, EQUITY_CONCEPTS, GOODWILL_CONCEPTS, INTANGIBLE_ASSETS_CONCEPTS,
    INVENTORY_CONCEPTS, LIABILITY_CONCEPTS, LONG_TERM_DEBT_CONCEPTS, OTHER_CURRENT_ASSETS_CONCEPTS,
    OTHER_NON_CURRENT_ASSETS_CONCEPTS, OTHER_NON_CURRENT_LIABILITIES_CONCEPTS, PPE_NET_CONCEPTS,
    RETAINED_EARNINGS_CONCEPTS, SHARES_CONCEPTS, SHORT_TERM_DEBT_CONCEPTS,
    SHORT_TERM_INVESTMENTS_CONCEPTS,
};
use super::filings::build_panel_data as build_filings_panel_data;
use super::mapping_support::{
    collect_available_concepts as collect_resolver_concepts, collect_formula_mappings,
    evaluate_formulas, formula_check_targets, select_numeric_value_with_custom,
    FormulaCheckTargets,
};
use super::types::{
    BalanceSheetQuery, CashFlowQuery, CompanySubmissions, EarningsQuery, FilingsQuery,
    ParsedXbrlDocument, PeriodRow,
};
use super::xbrl::pick_instance_document;

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum EdgarLookupError {
    MissingUserAgent,
    UnknownTicker {
        ticker: String,
    },
    NoFactsAvailable,
    NoEligibleFilings {
        ticker: String,
        frequency: Frequency,
    },
    InvalidPeriodRange {
        detail: String,
    },
    InvalidCurrencySelection {
        detail: String,
    },
    RequestFailed {
        provider: &'static str,
        detail: String,
    },
    InvalidResponse {
        provider: &'static str,
        detail: String,
    },
    XbrlParseFailed {
        detail: String,
    },
}

impl Display for EdgarLookupError {
    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::MissingUserAgent => formatter.write_str(
                "Set SEC_EDGAR_USER_AGENT to a value like `MosaicIQ admin@example.com`.",
            ),
            Self::UnknownTicker { ticker } => {
                write!(formatter, "No SEC CIK mapping found for {ticker}.")
            }
            Self::NoFactsAvailable => {
                formatter.write_str("SEC companyfacts did not contain matching disclosures.")
            }
            Self::InvalidPeriodRange { detail } | Self::InvalidCurrencySelection { detail } => {
                formatter.write_str(detail)
            }
            Self::NoEligibleFilings { ticker, frequency } => write!(
                formatter,
                "No eligible {} filings were found for {ticker}.",
                frequency.as_str()
            ),
            Self::RequestFailed { detail, .. }
            | Self::InvalidResponse { detail, .. }
            | Self::XbrlParseFailed { detail } => formatter.write_str(detail),
        }
    }
}

pub(crate) trait EdgarDataLookup: Send + Sync {
    fn financials<'a>(
        &'a self,
        ticker: &'a str,
        frequency: Frequency,
    ) -> BoxFuture<'a, Result<FinancialsPanelData, EdgarLookupError>>;

    fn cash_flow<'a>(
        &'a self,
        ticker: &'a str,
        query: CashFlowQuery,
    ) -> BoxFuture<'a, Result<CashFlowPanelData, EdgarLookupError>>;

    fn balance_sheet<'a>(
        &'a self,
        ticker: &'a str,
        query: BalanceSheetQuery,
    ) -> BoxFuture<'a, Result<BalanceSheetPanelData, EdgarLookupError>>;

    fn dividends<'a>(
        &'a self,
        ticker: &'a str,
    ) -> BoxFuture<'a, Result<DividendsPanelData, EdgarLookupError>>;

    fn earnings<'a>(
        &'a self,
        ticker: &'a str,
        query: EarningsQuery,
    ) -> BoxFuture<'a, Result<EarningsPanelData, EdgarLookupError>>;

    fn filings<'a>(
        &'a self,
        ticker: &'a str,
        query: &'a FilingsQuery,
    ) -> BoxFuture<'a, Result<FilingsPanelData, EdgarLookupError>>;
}

pub(crate) struct SecEdgarLookup {
    client: Arc<SecEdgarClient>,
    mappings_service: Arc<MappingsService>,
}

impl SecEdgarLookup {
    pub(crate) fn new(client: Arc<SecEdgarClient>, mappings_service: Arc<MappingsService>) -> Self {
        Self {
            client,
            mappings_service,
        }
    }

    pub(crate) async fn load_concepts_for_ticker(
        &self,
        ticker: &str,
    ) -> Result<Vec<crate::terminal::types::AvailableXbrlConcept>, EdgarLookupError> {
        super::earnings::load_concepts_for_ticker(&self.client, ticker).await
    }

    async fn context_for(
        &self,
        ticker: &str,
        frequency: Frequency,
    ) -> Result<LookupContext, EdgarLookupError> {
        let company = self.client.resolve_company(ticker).await?;
        let submissions = self.client.load_submissions(&company.cik).await?;
        let companyfacts = self.client.load_companyfacts(&company.cik).await?;
        let latest_filing = select_latest_filing(&submissions.filings.recent.rows(), frequency)
            .ok_or_else(|| EdgarLookupError::NoEligibleFilings {
                ticker: company.ticker.clone(),
                frequency,
            })?;

        Ok(LookupContext {
            company,
            submissions,
            companyfacts,
            latest_filing,
        })
    }

    async fn latest_xbrl(
        &self,
        cik: &str,
        filing: &FilingRef,
    ) -> Result<Option<super::types::ParsedXbrlDocument>, EdgarLookupError> {
        let index = self
            .client
            .load_filing_index(cik, &filing.accession_number)
            .await?;
        let filenames = index
            .directory
            .item
            .into_iter()
            .map(|item| item.name)
            .collect::<Vec<_>>();
        let Some(filename) = pick_instance_document(&filenames) else {
            return Ok(None);
        };
        let parsed = self
            .client
            .load_parsed_xbrl(cik, &filing.accession_number, &filename)
            .await?;
        Ok(Some(parsed))
    }
}

impl EdgarDataLookup for SecEdgarLookup {
    fn financials<'a>(
        &'a self,
        ticker: &'a str,
        frequency: Frequency,
    ) -> BoxFuture<'a, Result<FinancialsPanelData, EdgarLookupError>> {
        Box::pin(async move {
            let context = self.context_for(ticker, frequency).await?;
            Ok(
                load_financials_data(self, &context, frequency, None, false, None)
                    .await?
                    .data,
            )
        })
    }

    fn cash_flow<'a>(
        &'a self,
        ticker: &'a str,
        query: CashFlowQuery,
    ) -> BoxFuture<'a, Result<CashFlowPanelData, EdgarLookupError>> {
        Box::pin(async move {
            let context = self.context_for(ticker, query.frequency).await?;
            let facts = normalize_all_facts(&context.companyfacts)?;
            let latest_xbrl = self
                .latest_xbrl(&context.company.cik, &context.latest_filing)
                .await;
            let status = build_source_status(latest_xbrl.as_ref().map(|value| value.as_ref()));

            let all_period_rows = build_statement_rows(&facts, query.frequency, usize::MAX);
            let all_period_options =
                build_period_options_from_rows(&all_period_rows, query.frequency);

            let requested_range = query
                .range
                .as_ref()
                .map(|range| (range.start.token(), range.end.token()));

            if let Some((start, end)) = requested_range.as_ref() {
                let has_start = all_period_options.iter().any(|p| p.id == *start);
                let has_end = all_period_options.iter().any(|p| p.id == *end);
                if !has_start || !has_end {
                    let oldest = all_period_options.first().map(|p| p.id.as_str());
                    let newest = all_period_options.last().map(|p| p.id.as_str());
                    return Err(EdgarLookupError::InvalidPeriodRange {
                        detail: match (oldest, newest) {
                            (Some(oldest), Some(newest)) => format!(
                                "Requested /cf range is unavailable. Try a reported range between {oldest} and {newest}."
                            ),
                            _ => "Requested /cf range is unavailable for this company.".to_string(),
                        },
                    });
                }
            }

            let filtered_rows: Vec<_> = all_period_rows
                .into_iter()
                .filter(|row| match requested_range.as_ref() {
                    Some((start, end)) => {
                        let key = period_row_token(row, query.frequency);
                        key >= *start && key <= *end
                    }
                    None => true,
                })
                .collect();

            let display_rows = if requested_range.is_none() {
                let mut rows = filtered_rows;
                rows.truncate(default_earnings_period_limit(query.frequency));
                rows
            } else {
                filtered_rows
            };

            let selected_period_start = requested_range
                .as_ref()
                .map(|(start, _)| start.clone())
                .or_else(|| {
                    display_rows
                        .last()
                        .map(|row| period_row_token(row, query.frequency))
                });
            let selected_period_end = requested_range
                .as_ref()
                .map(|(_, end)| end.clone())
                .or_else(|| {
                    display_rows
                        .first()
                        .map(|row| period_row_token(row, query.frequency))
                });

            let mut periods = build_cash_flow_periods_from_rows(
                &facts,
                display_rows.clone(),
                latest_xbrl.as_ref().ok().and_then(|value| value.as_ref()),
            );
            let custom_mappings =
                self.mappings_service
                    .list_mappings()
                    .await
                    .map_err(|source| EdgarLookupError::RequestFailed {
                        provider: "custom mappings",
                        detail: source.to_string(),
                    })?;
            let mapping_warnings = apply_cash_flow_mappings(
                &mut periods,
                &display_rows,
                latest_xbrl.as_ref().ok().and_then(|value| value.as_ref()),
                &custom_mappings,
                &context.company.ticker,
            );

            periods.reverse();

            Ok(CashFlowPanelData {
                symbol: context.company.ticker.clone(),
                company_name: company_name(
                    &context.company,
                    Some(&context.submissions.name),
                    Some(&context.companyfacts.entity_name),
                ),
                cik: context.company.cik.clone(),
                frequency: query.frequency,
                periods,
                latest_filing: Some(context.latest_filing),
                source_status: status,
                available_periods: all_period_options,
                selected_period_start,
                selected_period_end,
                mapping_warnings,
            })
        })
    }

    fn balance_sheet<'a>(
        &'a self,
        ticker: &'a str,
        query: BalanceSheetQuery,
    ) -> BoxFuture<'a, Result<BalanceSheetPanelData, EdgarLookupError>> {
        Box::pin(async move {
            let context = self.context_for(ticker, query.frequency).await?;
            let data = load_financials_data(
                self,
                &context,
                query.frequency,
                query.requested_currency.as_deref(),
                true,
                query.range.as_ref(),
            )
            .await?;

            let all_period_rows = build_statement_rows(
                &normalize_all_facts(&context.companyfacts)?,
                query.frequency,
                usize::MAX,
            );
            let all_period_options =
                build_period_options_from_rows(&all_period_rows, query.frequency);

            let requested_range = query
                .range
                .as_ref()
                .map(|range| (range.start.token(), range.end.token()));

            if let Some((start, end)) = requested_range.as_ref() {
                let has_start = all_period_options.iter().any(|p| p.id == *start);
                let has_end = all_period_options.iter().any(|p| p.id == *end);
                if !has_start || !has_end {
                    let oldest = all_period_options.first().map(|p| p.id.as_str());
                    let newest = all_period_options.last().map(|p| p.id.as_str());
                    return Err(EdgarLookupError::InvalidPeriodRange {
                        detail: match (oldest, newest) {
                            (Some(oldest), Some(newest)) => format!(
                                "Requested /bs range is unavailable. Try a reported range between {oldest} and {newest}."
                            ),
                            _ => "Requested /bs range is unavailable for this company.".to_string(),
                        },
                    });
                }
            }

            let selected_period_start = requested_range
                .as_ref()
                .map(|(start, _)| start.clone())
                .or_else(|| {
                    data.data.periods.last().and_then(|p| {
                        let row = PeriodRow {
                            label: p.label.clone(),
                            fiscal_year: p.fiscal_year.clone(),
                            fiscal_period: p.fiscal_period.clone(),
                            period_start: p.period_start.clone(),
                            period_end: p.period_end.clone(),
                            filed_date: p.filed_date.clone(),
                            form: p.form.clone(),
                            accession_number: None,
                        };
                        Some(period_row_token(&row, query.frequency))
                    })
                });
            let selected_period_end = requested_range
                .as_ref()
                .map(|(_, end)| end.clone())
                .or_else(|| {
                    data.data.periods.first().and_then(|p| {
                        let row = PeriodRow {
                            label: p.label.clone(),
                            fiscal_year: p.fiscal_year.clone(),
                            fiscal_period: p.fiscal_period.clone(),
                            period_start: p.period_start.clone(),
                            period_end: p.period_end.clone(),
                            filed_date: p.filed_date.clone(),
                            form: p.form.clone(),
                            accession_number: None,
                        };
                        Some(period_row_token(&row, query.frequency))
                    })
                });

            let mut periods = data.data.periods;
            periods.reverse();

            Ok(BalanceSheetPanelData {
                symbol: data.data.symbol,
                company_name: data.data.company_name,
                cik: data.data.cik,
                frequency: data.data.frequency,
                periods,
                latest_filing: data.data.latest_filing,
                source_status: data.data.source_status,
                available_concepts: data.data.available_concepts,
                mapping_warnings: data.data.mapping_warnings,
                selected_currency: data.selected_currency,
                available_currencies: data.available_currencies,
                available_periods: all_period_options,
                selected_period_start,
                selected_period_end,
            })
        })
    }

    fn dividends<'a>(
        &'a self,
        ticker: &'a str,
    ) -> BoxFuture<'a, Result<DividendsPanelData, EdgarLookupError>> {
        Box::pin(async move {
            let context = self.context_for(ticker, Frequency::Quarterly).await?;
            let facts = normalize_all_facts(&context.companyfacts)?;
            let latest_xbrl = self
                .latest_xbrl(&context.company.cik, &context.latest_filing)
                .await;
            let status = build_source_status(latest_xbrl.as_ref().map(|value| value.as_ref()));
            let events = build_dividend_events(
                &facts,
                latest_xbrl.as_ref().ok().and_then(|value| value.as_ref()),
            );
            let (ttm_dividends_per_share, ttm_common_dividends_paid) = limit_dividend_ttm(&events);

            Ok(DividendsPanelData {
                symbol: context.company.ticker.clone(),
                company_name: company_name(
                    &context.company,
                    Some(&context.submissions.name),
                    Some(&context.companyfacts.entity_name),
                ),
                cik: context.company.cik.clone(),
                ttm_dividends_per_share,
                ttm_common_dividends_paid,
                latest_event: events.first().cloned(),
                events,
                latest_filing: Some(context.latest_filing),
                source_status: status,
            })
        })
    }

    fn earnings<'a>(
        &'a self,
        ticker: &'a str,
        query: EarningsQuery,
    ) -> BoxFuture<'a, Result<EarningsPanelData, EdgarLookupError>> {
        Box::pin(async move {
            let company = self.client.resolve_company(ticker).await?;
            let submissions = self.client.load_submissions(&company.cik).await?;
            let custom_mappings = self
                .mappings_service
                .list_mappings()
                .await
                .map_err(|error| EdgarLookupError::InvalidResponse {
                    provider: "custom mappings",
                    detail: error.to_string(),
                })?;
            let data = load_earnings_data(
                &self.client,
                &company,
                &submissions,
                &query,
                &custom_mappings,
            )
            .await?;
            Ok(build_earnings_panel_data(
                &company,
                &submissions,
                &query,
                data,
            ))
        })
    }

    fn filings<'a>(
        &'a self,
        ticker: &'a str,
        query: &'a FilingsQuery,
    ) -> BoxFuture<'a, Result<FilingsPanelData, EdgarLookupError>> {
        Box::pin(async move { build_filings_panel_data(&self.client, ticker, query).await })
    }
}

struct LookupContext {
    company: super::types::ResolvedCompany,
    submissions: CompanySubmissions,
    companyfacts: super::types::CompanyFactsResponse,
    latest_filing: FilingRef,
}

struct FinancialsLoadResult {
    data: FinancialsPanelData,
    selected_currency: Option<String>,
    available_currencies: Vec<String>,
}

async fn load_financials_data(
    lookup: &SecEdgarLookup,
    context: &LookupContext,
    frequency: Frequency,
    requested_currency: Option<&str>,
    enable_currency_selection: bool,
    range: Option<&super::types::EarningsRange>,
) -> Result<FinancialsLoadResult, EdgarLookupError> {
    let facts = normalize_all_facts(&context.companyfacts)?;
    let latest_xbrl = lookup
        .latest_xbrl(&context.company.cik, &context.latest_filing)
        .await;
    let status = build_source_status(latest_xbrl.as_ref().map(|value| value.as_ref()));
    let custom_mappings = lookup
        .mappings_service
        .list_mappings()
        .await
        .map_err(|error| EdgarLookupError::InvalidResponse {
            provider: "custom mappings",
            detail: error.to_string(),
        })?;
    let filing_rows = context.submissions.filings.recent.rows();
    let mut filing_cache = HashMap::<String, Option<FilingXbrlDocument>>::new();

    let effective_limit = if range.is_some() {
        usize::MAX
    } else {
        default_earnings_period_limit(frequency)
    };
    let base_period_rows = build_statement_rows(&facts, frequency, effective_limit);

    let requested_range = range.map(|r| (r.start.token(), r.end.token()));

    let base_period_rows: Vec<_> = base_period_rows
        .into_iter()
        .filter(|row| match requested_range.as_ref() {
            Some((start, end)) => {
                let key = period_row_token(row, frequency);
                key >= *start && key <= *end
            }
            None => true,
        })
        .collect();

    let mut base_row_documents = Vec::with_capacity(base_period_rows.len());

    for row in &base_period_rows {
        let document = if let Some(filing) = filing_for_period(row, &filing_rows) {
            load_filing_document_cached(lookup, &context.company.cik, &filing, &mut filing_cache)
                .await?
        } else {
            None
        };
        base_row_documents.push(document);
    }

    let available_currencies = if enable_currency_selection {
        collect_statement_available_currencies(
            &facts,
            frequency,
            &base_period_rows,
            &base_row_documents,
        )
    } else {
        Vec::new()
    };
    let selected_currency = if enable_currency_selection {
        resolve_statement_currency(
            requested_currency,
            &available_currencies,
            &facts,
            &base_period_rows,
            &base_row_documents,
            &context.company.ticker,
            frequency,
        )?
    } else {
        None
    };
    let filtered_facts = selected_currency
        .as_deref()
        .map(|currency| filter_facts_for_currency(&facts, currency))
        .unwrap_or_else(|| facts.clone());
    let period_rows = if selected_currency.is_some() {
        let mut rows = build_statement_rows(&filtered_facts, frequency, effective_limit);
        if let Some((start, end)) = requested_range.as_ref() {
            rows.retain(|row| {
                let key = period_row_token(row, frequency);
                key >= *start && key <= *end
            });
        }
        rows
    } else {
        base_period_rows
    };
    let mut available_concepts = Vec::new();
    let mut seen_concepts = HashSet::<(String, String, String)>::new();
    let mut mapping_warnings = Vec::new();
    let mut periods = Vec::with_capacity(period_rows.len());

    for row in &period_rows {
        let filing = filing_for_period(row, &filing_rows);
        let latest_period_xbrl = filing
            .as_ref()
            .filter(|filing| filing.accession_number == context.latest_filing.accession_number)
            .and_then(|_| latest_xbrl.as_ref().ok().and_then(|value| value.as_ref()));
        let mut period = build_statement_period(
            &filtered_facts,
            row,
            selected_currency.as_deref(),
            latest_period_xbrl,
        );

        if let Some(filing) = filing {
            if let Some(document) = load_filing_document_cached(
                lookup,
                &context.company.cik,
                &filing,
                &mut filing_cache,
            )
            .await?
            {
                for concept in extract_available_concepts(&document, &filing.accession_number) {
                    if seen_concepts.insert((
                        concept.taxonomy.clone(),
                        concept.concept.clone(),
                        concept.unit_family.clone(),
                    )) {
                        available_concepts.push(concept);
                    }
                }

                let warnings = apply_balance_sheet_mappings(
                    &mut period,
                    &document,
                    &custom_mappings,
                    &context.company.ticker,
                    &row.period_end,
                    selected_currency.as_deref(),
                );
                mapping_warnings.extend(warnings);
            }
        }

        periods.push(period);
    }

    Ok(FinancialsLoadResult {
        data: FinancialsPanelData {
            symbol: context.company.ticker.clone(),
            company_name: company_name(
                &context.company,
                Some(&context.submissions.name),
                Some(&context.companyfacts.entity_name),
            ),
            cik: context.company.cik.clone(),
            frequency,
            periods,
            latest_filing: Some(context.latest_filing.clone()),
            source_status: status,
            available_concepts,
            mapping_warnings,
        },
        selected_currency,
        available_currencies,
    })
}

fn filing_for_period(row: &super::types::PeriodRow, filings: &[FilingRef]) -> Option<FilingRef> {
    if let Some(accession_number) = row.accession_number.as_deref() {
        if let Some(filing) = filings
            .iter()
            .find(|filing| filing.accession_number == accession_number)
        {
            return Some(filing.clone());
        }
    }

    filings
        .iter()
        .find(|filing| {
            filing.form == row.form
                && filing.filing_date == row.filed_date
                && filing.report_date.as_deref() == Some(row.period_end.as_str())
        })
        .or_else(|| {
            filings
                .iter()
                .find(|filing| filing.form == row.form && filing.filing_date == row.filed_date)
        })
        .cloned()
}

async fn load_filing_document_cached(
    lookup: &SecEdgarLookup,
    cik: &str,
    filing: &FilingRef,
    cache: &mut HashMap<String, Option<FilingXbrlDocument>>,
) -> Result<Option<FilingXbrlDocument>, EdgarLookupError> {
    if let Some(document) = cache.get(&filing.accession_number) {
        return Ok(document.clone());
    }

    let loaded = match load_filing_package(&lookup.client, cik, filing).await {
        Ok(package) => Some(package.document),
        Err(_) => None,
    };
    cache.insert(filing.accession_number.clone(), loaded.clone());
    Ok(loaded)
}

fn filter_facts_for_currency(
    facts: &[super::types::NormalizedFact],
    selected_currency: &str,
) -> Vec<super::types::NormalizedFact> {
    facts
        .iter()
        .filter(|fact| {
            matches_requested_currency(
                fact.unit_family,
                fact.currency_code.as_deref(),
                Some(selected_currency),
            )
        })
        .cloned()
        .collect()
}

fn collect_statement_available_currencies(
    facts: &[super::types::NormalizedFact],
    frequency: Frequency,
    period_rows: &[super::types::PeriodRow],
    row_documents: &[Option<FilingXbrlDocument>],
) -> Vec<String> {
    let mut currencies = HashSet::<String>::new();

    for fact in facts
        .iter()
        .filter(|fact| super::facts::is_fact_for_frequency(fact, frequency))
    {
        if fact.unit_family.uses_currency() {
            if let Some(currency_code) = fact.currency_code.as_ref() {
                currencies.insert(currency_code.clone());
            }
        }
    }

    for (row, document) in period_rows.iter().zip(row_documents.iter()) {
        let Some(document) = document.as_ref() else {
            continue;
        };

        for fact in &document.facts {
            let Some(context_ref) = fact.context_ref.as_deref() else {
                continue;
            };
            let Some(context) = document.contexts.get(context_ref) else {
                continue;
            };
            if context.has_dimensions || context.instant.as_deref() != Some(row.period_end.as_str())
            {
                continue;
            }
            let Some(unit_family) = classify_fact_unit_family(document, fact) else {
                continue;
            };
            if !unit_family.uses_currency() {
                continue;
            }
            if let Some(currency_code) = filing_fact_currency_code(document, fact) {
                currencies.insert(currency_code);
            }
        }
    }

    let mut values = currencies.into_iter().collect::<Vec<_>>();
    values.sort();
    values
}

fn resolve_statement_currency(
    requested_currency: Option<&str>,
    available_currencies: &[String],
    facts: &[super::types::NormalizedFact],
    period_rows: &[super::types::PeriodRow],
    row_documents: &[Option<FilingXbrlDocument>],
    ticker: &str,
    frequency: Frequency,
) -> Result<Option<String>, EdgarLookupError> {
    if available_currencies.is_empty() {
        if let Some(requested_currency) = requested_currency {
            return Err(EdgarLookupError::InvalidCurrencySelection {
                detail: format!(
                    "Currency {requested_currency} is unavailable for /bs {ticker} {}. Available currencies: none.",
                    frequency.as_str()
                ),
            });
        }
        return Ok(None);
    }

    if let Some(requested_currency) = requested_currency {
        if available_currencies
            .iter()
            .any(|currency| currency == requested_currency)
        {
            return Ok(Some(requested_currency.to_string()));
        }

        return Err(EdgarLookupError::InvalidCurrencySelection {
            detail: format!(
                "Currency {requested_currency} is unavailable for /bs {ticker} {}. Available currencies: {}.",
                frequency.as_str(),
                available_currencies.join(", ")
            ),
        });
    }

    if available_currencies.len() == 1 {
        return Ok(available_currencies.first().cloned());
    }

    let dominant = period_rows
        .iter()
        .zip(row_documents.iter())
        .find_map(|(row, document)| dominant_currency_for_period(facts, row, document.as_ref()));

    Ok(dominant.or_else(|| available_currencies.first().cloned()))
}

fn dominant_currency_for_period(
    facts: &[super::types::NormalizedFact],
    row: &super::types::PeriodRow,
    document: Option<&FilingXbrlDocument>,
) -> Option<String> {
    let mut counts = HashMap::<String, usize>::new();

    for fact in facts
        .iter()
        .filter(|fact| fact.unit_family.uses_currency() && fact_matches_period_row(fact, row))
    {
        if let Some(currency_code) = fact.currency_code.as_ref() {
            *counts.entry(currency_code.clone()).or_insert(0) += 1;
        }
    }

    if let Some(document) = document {
        for fact in &document.facts {
            let Some(context_ref) = fact.context_ref.as_deref() else {
                continue;
            };
            let Some(context) = document.contexts.get(context_ref) else {
                continue;
            };
            if context.has_dimensions || context.instant.as_deref() != Some(row.period_end.as_str())
            {
                continue;
            }
            let Some(unit_family) = classify_fact_unit_family(document, fact) else {
                continue;
            };
            if !unit_family.uses_currency() {
                continue;
            }
            if let Some(currency_code) = filing_fact_currency_code(document, fact) {
                *counts.entry(currency_code).or_insert(0) += 1;
            }
        }
    }

    counts
        .into_iter()
        .max_by(|left, right| left.1.cmp(&right.1).then_with(|| right.0.cmp(&left.0)))
        .map(|(currency, _)| currency)
}

fn apply_balance_sheet_mappings(
    period: &mut StatementPeriod,
    document: &FilingXbrlDocument,
    custom_mappings: &[CustomConceptMapping],
    ticker: &str,
    period_end: &str,
    selected_currency: Option<&str>,
) -> Vec<String> {
    let mut field_values = HashMap::<TargetField, f64>::new();

    insert_field_value(
        &mut field_values,
        TargetField::TotalRevenues,
        period.revenue,
    );
    insert_field_value(
        &mut field_values,
        TargetField::GrossProfit,
        period.gross_profit,
    );
    insert_field_value(
        &mut field_values,
        TargetField::OperatingProfit,
        period.operating_income,
    );
    insert_field_value(&mut field_values, TargetField::NetIncome, period.net_income);
    insert_field_value(
        &mut field_values,
        TargetField::DilutedEps,
        period.diluted_eps,
    );
    for &field in BALANCE_SHEET_TARGET_FIELDS {
        insert_field_value(
            &mut field_values,
            field,
            balance_sheet_period_value(period, field),
        );
    }

    let instant_match = |context: &super::earnings::facts::FilingContext| {
        !context.has_dimensions && context.instant.as_deref() == Some(period_end)
    };

    for &field in BALANCE_SHEET_TARGET_FIELDS {
        insert_selected_value(
            &mut field_values,
            field,
            select_numeric_value_with_custom(
                document,
                balance_sheet_candidates(field),
                field,
                custom_mappings,
                ticker,
                period_end,
                selected_currency,
                instant_match,
            ),
        );
    }

    let concept_resolver = XbrlConceptResolver::new(collect_resolver_concepts(document));
    let formula_mappings = collect_formula_mappings(custom_mappings, ticker, period_end);
    let formula_check_targets = formula_check_targets(&formula_mappings);
    let (formula_values, formula_warnings) =
        evaluate_formulas(formula_mappings, &field_values, &concept_resolver);

    for (field, value) in formula_values {
        field_values.insert(field, value);
    }

    for &field in BALANCE_SHEET_TARGET_FIELDS {
        set_balance_sheet_period_value(period, field, field_values.get(&field).copied());
    }

    period.book_value_per_share = period
        .total_equity
        .zip(period.shares_outstanding)
        .and_then(|(equity, shares)| (shares != 0.0).then_some(equity / shares));

    let mut warnings = formula_warnings;
    warnings.extend(validate_balance_sheet_consistency(
        period,
        &formula_check_targets,
    ));
    warnings
}

fn apply_cash_flow_mappings(
    periods: &mut [CashFlowPeriod],
    rows: &[PeriodRow],
    document: Option<&ParsedXbrlDocument>,
    custom_mappings: &[CustomConceptMapping],
    ticker: &str,
) -> Vec<String> {
    let mut warnings = Vec::new();

    for (period, row) in periods.iter_mut().zip(rows.iter()) {
        let mut field_values = HashMap::<TargetField, f64>::new();
        insert_field_value(
            &mut field_values,
            TargetField::OperatingCashFlow,
            period.operating_cash_flow,
        );
        insert_field_value(
            &mut field_values,
            TargetField::CapitalExpenditure,
            period.capex,
        );
        insert_field_value(
            &mut field_values,
            TargetField::InvestingCashFlow,
            period.investing_cash_flow,
        );
        insert_field_value(
            &mut field_values,
            TargetField::FinancingCashFlow,
            period.financing_cash_flow,
        );
        insert_field_value(
            &mut field_values,
            TargetField::FreeCashFlow,
            period.free_cash_flow,
        );

        if let Some(document) = document {
            let cash_flow_targets: &[(TargetField, &[super::types::ConceptCandidate])] = &[
                (TargetField::OperatingCashFlow, CFO_CONCEPTS),
                (TargetField::CapitalExpenditure, CAPEX_CONCEPTS),
                (TargetField::InvestingCashFlow, CFI_CONCEPTS),
                (TargetField::FinancingCashFlow, CFF_CONCEPTS),
            ];

            for &(field, candidates) in cash_flow_targets {
                insert_selected_value(
                    &mut field_values,
                    field,
                    select_latest_xbrl_value_with_custom(
                        document,
                        candidates,
                        field,
                        custom_mappings,
                        ticker,
                        &row.period_end,
                        row.period_start.as_deref(),
                    ),
                );
            }
        }

        let concept_resolver = cash_flow_concept_resolver(document, row);
        let (formula_values, formula_warnings) = evaluate_formulas(
            collect_formula_mappings(custom_mappings, ticker, &row.period_end),
            &field_values,
            &concept_resolver,
        );

        for (field, value) in formula_values {
            field_values.insert(field, value);
        }

        period.operating_cash_flow = field_values.get(&TargetField::OperatingCashFlow).copied();
        period.capex = field_values.get(&TargetField::CapitalExpenditure).copied();
        period.investing_cash_flow = field_values.get(&TargetField::InvestingCashFlow).copied();
        period.financing_cash_flow = field_values.get(&TargetField::FinancingCashFlow).copied();
        period.free_cash_flow = field_values
            .get(&TargetField::FreeCashFlow)
            .copied()
            .or_else(|| match (period.operating_cash_flow, period.capex) {
                (Some(cfo), Some(capex)) => Some(cfo - capex.abs()),
                _ => None,
            });

        warnings.extend(formula_warnings);
    }

    warnings
}

fn select_latest_xbrl_value_with_custom(
    document: &ParsedXbrlDocument,
    standard_candidates: &[super::types::ConceptCandidate],
    target_field: TargetField,
    custom_mappings: &[CustomConceptMapping],
    ticker: &str,
    period_end: &str,
    period_start: Option<&str>,
) -> Option<f64> {
    custom_mappings
        .iter()
        .filter(|mapping| mapping.enabled && mapping.target_field == target_field)
        .filter(|mapping| {
            mapping
                .scope
                .ticker
                .as_deref()
                .is_none_or(|scope_ticker| scope_ticker.eq_ignore_ascii_case(ticker))
                && mapping
                    .scope
                    .period_start
                    .as_deref()
                    .is_none_or(|start| start <= period_end)
                && mapping
                    .scope
                    .period_end
                    .as_deref()
                    .is_none_or(|end| end >= period_end)
        })
        .filter_map(CustomConceptMapping::custom_concept)
        .find_map(|concept| {
            select_latest_xbrl_custom_concept(document, concept, period_end, period_start)
        })
        .or_else(|| {
            select_latest_xbrl_value(document, standard_candidates, period_end, period_start)
        })
}

fn select_latest_xbrl_value(
    document: &ParsedXbrlDocument,
    candidates: &[super::types::ConceptCandidate],
    period_end: &str,
    period_start: Option<&str>,
) -> Option<f64> {
    document
        .facts
        .iter()
        .filter(|fact| !fact.has_dimensions)
        .filter(|fact| fact.period_end.as_deref() == Some(period_end))
        .filter(|fact| period_start.is_none_or(|start| fact.period_start.as_deref() == Some(start)))
        .find(|fact| {
            candidates.iter().any(|candidate| {
                fact.unit_family
                    .is_none_or(|unit_family| unit_family == candidate.unit_family)
                    && matches_requested_currency(
                        candidate.unit_family,
                        fact.currency_code.as_deref(),
                        None,
                    )
                    && (fact.concept.eq_ignore_ascii_case(&format!(
                        "{}:{}",
                        candidate.prefix, candidate.local_name
                    )) || fact.concept.eq_ignore_ascii_case(candidate.local_name))
            })
        })
        .map(|fact| fact.value)
}

fn select_latest_xbrl_custom_concept(
    document: &ParsedXbrlDocument,
    concept: &crate::mappings::CustomConcept,
    period_end: &str,
    period_start: Option<&str>,
) -> Option<f64> {
    document
        .facts
        .iter()
        .filter(|fact| !fact.has_dimensions)
        .filter(|fact| fact.period_end.as_deref() == Some(period_end))
        .filter(|fact| period_start.is_none_or(|start| fact.period_start.as_deref() == Some(start)))
        .find(|fact| {
            fact.unit_family
                .is_none_or(|unit_family| unit_family == concept.unit_family)
                && matches_requested_currency(
                    concept.unit_family,
                    fact.currency_code.as_deref(),
                    None,
                )
                && (fact
                    .concept
                    .eq_ignore_ascii_case(&format!("{}:{}", concept.taxonomy, concept.concept))
                    || fact.concept.eq_ignore_ascii_case(&concept.concept))
        })
        .map(|fact| fact.value)
}

fn cash_flow_concept_resolver(
    document: Option<&ParsedXbrlDocument>,
    row: &PeriodRow,
) -> XbrlConceptResolver {
    let mut resolver = XbrlConceptResolver::empty();
    let Some(document) = document else {
        return resolver;
    };

    for fact in document
        .facts
        .iter()
        .filter(|fact| !fact.has_dimensions)
        .filter(|fact| fact.period_end.as_deref() == Some(row.period_end.as_str()))
        .filter(|fact| {
            row.period_start
                .as_deref()
                .is_none_or(|start| fact.period_start.as_deref() == Some(start))
        })
    {
        let Some(unit_family) = fact.unit_family else {
            continue;
        };
        let (taxonomy, concept) = fact
            .concept
            .split_once(':')
            .map(|(taxonomy, concept)| (taxonomy.to_string(), concept.to_string()))
            .unwrap_or_else(|| ("".to_string(), fact.concept.clone()));
        resolver.add_concept(taxonomy, concept, unit_family, fact.value);
    }

    resolver
}

const BALANCE_SHEET_TARGET_FIELDS: &[TargetField] = &[
    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::TotalSharesOutstanding,
];

fn balance_sheet_candidates(
    target_field: TargetField,
) -> &'static [crate::terminal::sec_edgar::types::ConceptCandidate] {
    match target_field {
        TargetField::CashAndEquivalents => CASH_CONCEPTS,
        TargetField::ShortTermInvestments => SHORT_TERM_INVESTMENTS_CONCEPTS,
        TargetField::AccountsReceivableNet => ACCOUNTS_RECEIVABLE_CONCEPTS,
        TargetField::Inventory => INVENTORY_CONCEPTS,
        TargetField::OtherCurrentAssets => OTHER_CURRENT_ASSETS_CONCEPTS,
        TargetField::TotalCurrentAssets => CURRENT_ASSETS_CONCEPTS,
        TargetField::PropertyPlantAndEquipmentNet => PPE_NET_CONCEPTS,
        TargetField::Goodwill => GOODWILL_CONCEPTS,
        TargetField::IntangibleAssets => INTANGIBLE_ASSETS_CONCEPTS,
        TargetField::OtherNonCurrentAssets => OTHER_NON_CURRENT_ASSETS_CONCEPTS,
        TargetField::TotalAssets => ASSET_CONCEPTS,
        TargetField::AccountsPayable => ACCOUNTS_PAYABLE_CONCEPTS,
        TargetField::AccruedExpensesAndOtherCurrentLiabilities => {
            ACCRUED_CURRENT_LIABILITIES_CONCEPTS
        }
        TargetField::ShortTermDebt => SHORT_TERM_DEBT_CONCEPTS,
        TargetField::TotalCurrentLiabilities => CURRENT_LIABILITIES_CONCEPTS,
        TargetField::LongTermDebt => LONG_TERM_DEBT_CONCEPTS,
        TargetField::OtherNonCurrentLiabilities => OTHER_NON_CURRENT_LIABILITIES_CONCEPTS,
        TargetField::TotalLiabilities => LIABILITY_CONCEPTS,
        TargetField::RetainedEarnings => RETAINED_EARNINGS_CONCEPTS,
        TargetField::AccumulatedOtherComprehensiveIncomeLoss => AOCI_CONCEPTS,
        TargetField::TotalEquity => EQUITY_CONCEPTS,
        TargetField::TotalSharesOutstanding => SHARES_CONCEPTS,
        _ => &[],
    }
}

fn balance_sheet_period_value(period: &StatementPeriod, target_field: TargetField) -> Option<f64> {
    match target_field {
        TargetField::CashAndEquivalents => period.cash_and_equivalents,
        TargetField::ShortTermInvestments => period.short_term_investments,
        TargetField::AccountsReceivableNet => period.accounts_receivable_net,
        TargetField::Inventory => period.inventory,
        TargetField::OtherCurrentAssets => period.other_current_assets,
        TargetField::TotalCurrentAssets => period.total_current_assets,
        TargetField::PropertyPlantAndEquipmentNet => period.property_plant_and_equipment_net,
        TargetField::Goodwill => period.goodwill,
        TargetField::IntangibleAssets => period.intangible_assets,
        TargetField::OtherNonCurrentAssets => period.other_non_current_assets,
        TargetField::TotalAssets => period.total_assets,
        TargetField::AccountsPayable => period.accounts_payable,
        TargetField::AccruedExpensesAndOtherCurrentLiabilities => {
            period.accrued_expenses_and_other_current_liabilities
        }
        TargetField::ShortTermDebt => period.short_term_debt,
        TargetField::TotalCurrentLiabilities => period.total_current_liabilities,
        TargetField::LongTermDebt => period.long_term_debt,
        TargetField::OtherNonCurrentLiabilities => period.other_non_current_liabilities,
        TargetField::TotalLiabilities => period.total_liabilities,
        TargetField::RetainedEarnings => period.retained_earnings,
        TargetField::AccumulatedOtherComprehensiveIncomeLoss => {
            period.accumulated_other_comprehensive_income_loss
        }
        TargetField::TotalEquity => period.total_equity,
        TargetField::TotalSharesOutstanding => period.shares_outstanding,
        _ => None,
    }
}

fn set_balance_sheet_period_value(
    period: &mut StatementPeriod,
    target_field: TargetField,
    value: Option<f64>,
) {
    match target_field {
        TargetField::CashAndEquivalents => period.cash_and_equivalents = value,
        TargetField::ShortTermInvestments => period.short_term_investments = value,
        TargetField::AccountsReceivableNet => period.accounts_receivable_net = value,
        TargetField::Inventory => period.inventory = value,
        TargetField::OtherCurrentAssets => period.other_current_assets = value,
        TargetField::TotalCurrentAssets => period.total_current_assets = value,
        TargetField::PropertyPlantAndEquipmentNet => {
            period.property_plant_and_equipment_net = value;
        }
        TargetField::Goodwill => period.goodwill = value,
        TargetField::IntangibleAssets => period.intangible_assets = value,
        TargetField::OtherNonCurrentAssets => period.other_non_current_assets = value,
        TargetField::TotalAssets => period.total_assets = value,
        TargetField::AccountsPayable => period.accounts_payable = value,
        TargetField::AccruedExpensesAndOtherCurrentLiabilities => {
            period.accrued_expenses_and_other_current_liabilities = value;
        }
        TargetField::ShortTermDebt => period.short_term_debt = value,
        TargetField::TotalCurrentLiabilities => period.total_current_liabilities = value,
        TargetField::LongTermDebt => period.long_term_debt = value,
        TargetField::OtherNonCurrentLiabilities => period.other_non_current_liabilities = value,
        TargetField::TotalLiabilities => period.total_liabilities = value,
        TargetField::RetainedEarnings => period.retained_earnings = value,
        TargetField::AccumulatedOtherComprehensiveIncomeLoss => {
            period.accumulated_other_comprehensive_income_loss = value;
        }
        TargetField::TotalEquity => period.total_equity = value,
        TargetField::TotalSharesOutstanding => period.shares_outstanding = value,
        _ => {}
    }
}

fn validate_balance_sheet_consistency(
    period: &StatementPeriod,
    formula_check_targets: &FormulaCheckTargets,
) -> Vec<String> {
    let mut warnings = Vec::new();

    if !formula_check_targets.contains(&TargetField::TotalCurrentAssets) {
        push_total_warning(
            &mut warnings,
            period,
            "Total Current Assets",
            period.total_current_assets,
            &[
                ("Cash & Equivalents", period.cash_and_equivalents),
                ("Short-Term Investments", period.short_term_investments),
                ("Accounts Receivable, Net", period.accounts_receivable_net),
                ("Inventory", period.inventory),
                ("Other Current Assets", period.other_current_assets),
            ],
        );
    }

    if !formula_check_targets.contains(&TargetField::TotalAssets) {
        push_total_warning(
            &mut warnings,
            period,
            "Total Assets",
            period.total_assets,
            &[
                ("Total Current Assets", period.total_current_assets),
                (
                    "Property, Plant & Equipment, Net",
                    period.property_plant_and_equipment_net,
                ),
                ("Goodwill", period.goodwill),
                ("Intangible Assets", period.intangible_assets),
                ("Other Non-Current Assets", period.other_non_current_assets),
            ],
        );
    }

    if !formula_check_targets.contains(&TargetField::TotalCurrentLiabilities) {
        push_total_warning(
            &mut warnings,
            period,
            "Total Current Liabilities",
            period.total_current_liabilities,
            &[
                ("Accounts Payable", period.accounts_payable),
                (
                    "Accrued Expenses & Other Current Liabilities",
                    period.accrued_expenses_and_other_current_liabilities,
                ),
                ("Short-Term Debt", period.short_term_debt),
            ],
        );
    }

    if !formula_check_targets.contains(&TargetField::TotalLiabilities) {
        push_total_warning(
            &mut warnings,
            period,
            "Total Liabilities",
            period.total_liabilities,
            &[
                (
                    "Total Current Liabilities",
                    period.total_current_liabilities,
                ),
                ("Long-Term Debt", period.long_term_debt),
                (
                    "Other Non-Current Liabilities",
                    period.other_non_current_liabilities,
                ),
            ],
        );
    }

    if !formula_check_targets.contains(&TargetField::TotalAssets) {
        if let (Some(total_assets), Some(total_liabilities), Some(total_equity)) = (
            period.total_assets,
            period.total_liabilities,
            period.total_equity,
        ) {
            let expected_total_assets = total_liabilities + total_equity;
            if !amounts_match(total_assets, expected_total_assets) {
                warnings.push(format!(
                    "{}: Total Assets ({}) does not equal Total Liabilities + Total Equity ({}).",
                    period.label,
                    format_accounting_amount(total_assets),
                    format_accounting_amount(expected_total_assets),
                ));
            }
        }
    }

    warnings
}

fn push_total_warning(
    warnings: &mut Vec<String>,
    period: &StatementPeriod,
    total_label: &str,
    total_value: Option<f64>,
    components: &[(&str, Option<f64>)],
) {
    let Some(total_value) = total_value else {
        return;
    };

    let mut expected_total = 0.0;
    for (_, value) in components {
        let Some(value) = value else {
            return;
        };
        expected_total += value;
    }

    if !amounts_match(total_value, expected_total) {
        warnings.push(format!(
            "{}: {} ({}) does not equal component sum ({}).",
            period.label,
            total_label,
            format_accounting_amount(total_value),
            format_accounting_amount(expected_total),
        ));
    }
}

fn amounts_match(actual: f64, expected: f64) -> bool {
    let tolerance = actual.abs().max(expected.abs()).mul_add(0.000_001, 1.0);
    (actual - expected).abs() <= tolerance
}

fn format_accounting_amount(value: f64) -> String {
    format!("{value:.2}")
}

fn insert_field_value(
    field_values: &mut HashMap<TargetField, f64>,
    target_field: TargetField,
    value: Option<f64>,
) {
    if let Some(value) = value {
        field_values.insert(target_field, value);
    }
}

fn insert_selected_value(
    field_values: &mut HashMap<TargetField, f64>,
    target_field: TargetField,
    value: Option<f64>,
) {
    if let Some(value) = value {
        field_values.insert(target_field, value);
    }
}

fn period_row_token(row: &super::types::PeriodRow, frequency: Frequency) -> String {
    match frequency {
        Frequency::Annual => row
            .fiscal_year
            .clone()
            .or_else(|| super::facts::period_end_year(&row.period_end))
            .unwrap_or_else(|| row.period_end.clone()),
        Frequency::Quarterly => {
            let year = row
                .fiscal_year
                .clone()
                .or_else(|| super::facts::period_end_year(&row.period_end));
            match (year, row.fiscal_period.as_deref()) {
                (Some(y), Some(p)) if p != "FY" => format!("{y}Q{}", p.trim_start_matches('Q')),
                (Some(y), _) => y,
                _ => row.period_end.clone(),
            }
        }
    }
}

fn build_period_options_from_rows(
    rows: &[super::types::PeriodRow],
    frequency: Frequency,
) -> Vec<EarningsPeriodOption> {
    let mut options: Vec<EarningsPeriodOption> = rows
        .iter()
        .map(|row| {
            let id = period_row_token(row, frequency);
            EarningsPeriodOption {
                label: row.label.clone(),
                frequency,
                fiscal_year: row.fiscal_year.clone(),
                fiscal_period: row.fiscal_period.clone(),
                period_end: row.period_end.clone(),
                sort_key: id.clone(),
                id,
            }
        })
        .collect();
    options.sort_by(|left, right| left.sort_key.cmp(&right.sort_key));
    options
}

#[cfg(test)]
mod tests {
    use std::collections::HashMap;
    use std::sync::Arc;

    use futures::future::BoxFuture;
    use tempfile::TempDir;

    use super::{
        validate_balance_sheet_consistency, CashFlowQuery, EdgarDataLookup, EdgarLookupError,
        FormulaCheckTargets, SecEdgarLookup,
    };
    use crate::mappings::{
        CustomConcept, CustomConceptMapping, MappingScope, MappingsService, MappingsStorage,
        TargetField,
    };
    use crate::terminal::sec_edgar::client::{SecEdgarClient, SecFetch};
    use crate::terminal::sec_edgar::{
        BalanceSheetQuery, EarningsPeriodKey, EarningsQuery, EarningsRange, UnitFamily,
    };
    use crate::terminal::{Frequency, StatementPeriod};

    const TICKERS_URL: &str = "https://www.sec.gov/files/company_tickers.json";

    fn test_statement_period() -> StatementPeriod {
        StatementPeriod {
            label: "2024".to_string(),
            fiscal_year: Some("2024".to_string()),
            fiscal_period: Some("FY".to_string()),
            period_start: Some("2024-01-01".to_string()),
            period_end: "2024-12-31".to_string(),
            filed_date: "2025-02-28".to_string(),
            form: "10-K".to_string(),
            revenue: None,
            gross_profit: None,
            operating_income: None,
            net_income: None,
            diluted_eps: None,
            cash_and_equivalents: None,
            short_term_investments: None,
            accounts_receivable_net: None,
            inventory: None,
            other_current_assets: None,
            total_current_assets: None,
            property_plant_and_equipment_net: None,
            goodwill: None,
            intangible_assets: None,
            other_non_current_assets: None,
            total_assets: None,
            accounts_payable: None,
            accrued_expenses_and_other_current_liabilities: None,
            short_term_debt: None,
            total_current_liabilities: None,
            long_term_debt: None,
            other_non_current_liabilities: None,
            total_liabilities: None,
            retained_earnings: None,
            accumulated_other_comprehensive_income_loss: None,
            total_equity: None,
            shares_outstanding: None,
            book_value_per_share: None,
        }
    }

    struct FixtureFetcher {
        text: HashMap<String, String>,
        bytes: HashMap<String, Vec<u8>>,
    }

    impl SecFetch for FixtureFetcher {
        fn get_text<'a>(&'a self, url: &'a str) -> BoxFuture<'a, Result<String, EdgarLookupError>> {
            Box::pin(async move {
                self.text
                    .get(url)
                    .cloned()
                    .ok_or_else(|| EdgarLookupError::RequestFailed {
                        provider: "SEC EDGAR",
                        detail: format!("missing fixture for {url}"),
                    })
            })
        }

        fn get_bytes<'a>(
            &'a self,
            url: &'a str,
        ) -> BoxFuture<'a, Result<Vec<u8>, EdgarLookupError>> {
            Box::pin(async move {
                self.bytes
                    .get(url)
                    .cloned()
                    .ok_or_else(|| EdgarLookupError::RequestFailed {
                        provider: "SEC EDGAR",
                        detail: format!("missing fixture for {url}"),
                    })
            })
        }
    }

    fn create_mappings_service() -> (Arc<MappingsService>, TempDir) {
        let temp_dir = tempfile::tempdir().unwrap();
        let storage = MappingsStorage::new(temp_dir.path().to_path_buf()).unwrap();
        let service = Arc::new(MappingsService::new(storage).unwrap());
        (service, temp_dir)
    }

    fn lookup() -> SecEdgarLookup {
        let mut text = HashMap::new();
        let mut bytes = HashMap::new();
        let (mappings_service, _temp_dir) = create_mappings_service();

        text.insert(
            TICKERS_URL.to_string(),
            include_str!("../../../tests/fixtures/sec/company_tickers.json").to_string(),
        );

        add_company(
            &mut text,
            &mut bytes,
            CompanyFixture {
                cik: "0000320193",
                accession_number: "0000320193-24-000123",
                submissions: include_str!("../../../tests/fixtures/sec/aapl/submissions.json"),
                companyfacts: include_str!("../../../tests/fixtures/sec/aapl/companyfacts.json"),
                index: include_str!("../../../tests/fixtures/sec/aapl/index_annual.json"),
                xml_name: "aapl-20240928_htm.xml",
                xml: include_str!("../../../tests/fixtures/sec/aapl/instance_annual.xml"),
            },
        );
        text.insert(
            "https://www.sec.gov/Archives/edgar/data/320193/000032019325000010/index.json"
                .to_string(),
            include_str!("../../../tests/fixtures/sec/aapl/index_quarterly.json").to_string(),
        );
        bytes.insert(
            "https://www.sec.gov/Archives/edgar/data/320193/000032019325000010/aapl-20241228_htm.xml".to_string(),
            include_bytes!("../../../tests/fixtures/sec/aapl/instance_quarterly.xml").to_vec(),
        );

        add_company(
            &mut text,
            &mut bytes,
            CompanyFixture {
                cik: "0000789019",
                accession_number: "0000950170-24-087843",
                submissions: include_str!("../../../tests/fixtures/sec/msft/submissions.json"),
                companyfacts: include_str!("../../../tests/fixtures/sec/msft/companyfacts.json"),
                index: include_str!("../../../tests/fixtures/sec/msft/index.json"),
                xml_name: "msft-20240630_htm.xml",
                xml: include_str!("../../../tests/fixtures/sec/msft/instance.xml"),
            },
        );
        add_company(
            &mut text,
            &mut bytes,
            CompanyFixture {
                cik: "0000021344",
                accession_number: "0000021344-25-000010",
                submissions: include_str!("../../../tests/fixtures/sec/ko/submissions.json"),
                companyfacts: include_str!("../../../tests/fixtures/sec/ko/companyfacts.json"),
                index: include_str!("../../../tests/fixtures/sec/ko/index.json"),
                xml_name: "ko-20250328_htm.xml",
                xml: include_str!("../../../tests/fixtures/sec/ko/instance.xml"),
            },
        );
        add_company(
            &mut text,
            &mut bytes,
            CompanyFixture {
                cik: "0001045810",
                accession_number: "0001045810-25-000020",
                submissions: include_str!("../../../tests/fixtures/sec/nvda/submissions.json"),
                companyfacts: include_str!("../../../tests/fixtures/sec/nvda/companyfacts.json"),
                index: include_str!("../../../tests/fixtures/sec/nvda/index.json"),
                xml_name: "nvda-20250427_htm.xml",
                xml: include_str!("../../../tests/fixtures/sec/nvda/instance.xml"),
            },
        );
        add_company(
            &mut text,
            &mut bytes,
            CompanyFixture {
                cik: "0001000184",
                accession_number: "0001000184-24-000001",
                submissions: include_str!("../../../tests/fixtures/sec/sap/submissions.json"),
                companyfacts: include_str!("../../../tests/fixtures/sec/sap/companyfacts.json"),
                index: include_str!("../../../tests/fixtures/sec/sap/index.json"),
                xml_name: "sap-20231231_htm.xml",
                xml: include_str!("../../../tests/fixtures/sec/sap/instance.xml"),
            },
        );

        SecEdgarLookup::new(
            Arc::new(SecEdgarClient::new(Box::new(FixtureFetcher {
                text,
                bytes,
            }))),
            mappings_service,
        )
    }

    fn earnings_lookup() -> SecEdgarLookup {
        let (mappings_service, _temp_dir) = create_mappings_service();
        earnings_lookup_with_mappings(mappings_service)
    }

    fn earnings_lookup_with_mappings(mappings_service: Arc<MappingsService>) -> SecEdgarLookup {
        let mut text = HashMap::new();
        let mut bytes = HashMap::new();

        text.insert(
            TICKERS_URL.to_string(),
            serde_json::json!({
                "0": {
                    "cik_str": 1234567,
                    "ticker": "EARN",
                    "title": "Earnings Annual Inc."
                },
                "1": {
                    "cik_str": 7654321,
                    "ticker": "QEM",
                    "title": "Quarterly Earnings Inc."
                },
                "2": {
                    "cik_str": 2468135,
                    "ticker": "CMAP",
                    "title": "Custom Mapping Earnings Inc."
                },
                "3": {
                    "cik_str": 1357924,
                    "ticker": "MCEM",
                    "title": "Multi Currency Earnings Inc."
                }
            })
            .to_string(),
        );

        add_earnings_company(
            &mut text,
            &mut bytes,
            "0001234567",
            "Earnings Annual Inc.",
            &annual_earnings_filings(),
        );
        add_earnings_company(
            &mut text,
            &mut bytes,
            "0007654321",
            "Quarterly Earnings Inc.",
            &quarterly_earnings_filings(),
        );
        add_earnings_company(
            &mut text,
            &mut bytes,
            "0002468135",
            "Custom Mapping Earnings Inc.",
            &custom_mapped_earnings_filings(),
        );
        add_earnings_company(
            &mut text,
            &mut bytes,
            "0001357924",
            "Multi Currency Earnings Inc.",
            &multi_currency_earnings_filings(),
        );

        SecEdgarLookup::new(
            Arc::new(SecEdgarClient::new(Box::new(FixtureFetcher {
                text,
                bytes,
            }))),
            mappings_service,
        )
    }

    fn financials_lookup_with_mappings(mappings_service: Arc<MappingsService>) -> SecEdgarLookup {
        let mut text = HashMap::new();
        let mut bytes = HashMap::new();

        text.insert(
            TICKERS_URL.to_string(),
            serde_json::json!({
                "0": {
                    "cik_str": 9753186,
                    "ticker": "BMAP",
                    "title": "Balance Mapping Inc."
                },
                "1": {
                    "cik_str": 8642097,
                    "ticker": "MCBS",
                    "title": "Multi Currency Balance Inc."
                }
            })
            .to_string(),
        );

        add_earnings_company(
            &mut text,
            &mut bytes,
            "0009753186",
            "Balance Mapping Inc.",
            &balance_sheet_filings(),
        );
        text.insert(
            "https://data.sec.gov/api/xbrl/companyfacts/CIK0009753186.json".to_string(),
            synthetic_balance_companyfacts().to_string(),
        );
        add_earnings_company(
            &mut text,
            &mut bytes,
            "0008642097",
            "Multi Currency Balance Inc.",
            &multi_currency_balance_filings(),
        );
        text.insert(
            "https://data.sec.gov/api/xbrl/companyfacts/CIK0008642097.json".to_string(),
            synthetic_multi_currency_balance_companyfacts().to_string(),
        );

        SecEdgarLookup::new(
            Arc::new(SecEdgarClient::new(Box::new(FixtureFetcher {
                text,
                bytes,
            }))),
            mappings_service,
        )
    }

    struct CompanyFixture<'a> {
        cik: &'a str,
        accession_number: &'a str,
        submissions: &'a str,
        companyfacts: &'a str,
        index: &'a str,
        xml_name: &'a str,
        xml: &'a str,
    }

    fn add_company(
        text: &mut HashMap<String, String>,
        bytes: &mut HashMap<String, Vec<u8>>,
        fixture: CompanyFixture<'_>,
    ) {
        text.insert(
            format!("https://data.sec.gov/submissions/CIK{}.json", fixture.cik),
            fixture.submissions.to_string(),
        );
        text.insert(
            format!(
                "https://data.sec.gov/api/xbrl/companyfacts/CIK{}.json",
                fixture.cik
            ),
            fixture.companyfacts.to_string(),
        );
        text.insert(
            format!(
                "https://www.sec.gov/Archives/edgar/data/{}/{}/index.json",
                fixture.cik.trim_start_matches('0'),
                fixture.accession_number.replace('-', "")
            ),
            fixture.index.to_string(),
        );
        bytes.insert(
            format!(
                "https://www.sec.gov/Archives/edgar/data/{}/{}/{}",
                fixture.cik.trim_start_matches('0'),
                fixture.accession_number.replace('-', ""),
                fixture.xml_name
            ),
            fixture.xml.as_bytes().to_vec(),
        );
    }

    #[derive(Clone)]
    struct SyntheticEarningsFiling {
        accession_number: &'static str,
        filing_date: &'static str,
        report_date: &'static str,
        form: &'static str,
        primary_document: &'static str,
        xml_name: &'static str,
        xml: String,
    }

    fn add_earnings_company(
        text: &mut HashMap<String, String>,
        bytes: &mut HashMap<String, Vec<u8>>,
        cik: &str,
        name: &str,
        filings: &[SyntheticEarningsFiling],
    ) {
        text.insert(
            format!("https://data.sec.gov/submissions/CIK{cik}.json"),
            serde_json::json!({
                "name": name,
                "filings": {
                    "recent": {
                        "accessionNumber": filings.iter().map(|filing| filing.accession_number).collect::<Vec<_>>(),
                        "filingDate": filings.iter().map(|filing| filing.filing_date).collect::<Vec<_>>(),
                        "reportDate": filings.iter().map(|filing| Some(filing.report_date)).collect::<Vec<_>>(),
                        "form": filings.iter().map(|filing| filing.form).collect::<Vec<_>>(),
                        "primaryDocument": filings.iter().map(|filing| filing.primary_document).collect::<Vec<_>>()
                    },
                    "files": []
                }
            })
            .to_string(),
        );

        for filing in filings {
            text.insert(
                format!(
                    "https://www.sec.gov/Archives/edgar/data/{}/{}/index.json",
                    cik.trim_start_matches('0'),
                    filing.accession_number.replace('-', "")
                ),
                serde_json::json!({
                    "directory": {
                        "item": [
                            {
                                "name": filing.xml_name
                            }
                        ]
                    }
                })
                .to_string(),
            );
            bytes.insert(
                format!(
                    "https://www.sec.gov/Archives/edgar/data/{}/{}/{}",
                    cik.trim_start_matches('0'),
                    filing.accession_number.replace('-', ""),
                    filing.xml_name
                ),
                filing.xml.as_bytes().to_vec(),
            );
        }
    }

    fn annual_earnings_filings() -> Vec<SyntheticEarningsFiling> {
        vec![
            synthetic_annual_filing(
                "0001234567-24-000004",
                "2024-11-01",
                "2024-09-28",
                2024,
                "2023-09-30",
                "2024-09-28",
                300.0,
                90.0,
                120.0,
                3.0,
            ),
            synthetic_annual_filing(
                "0001234567-23-000003",
                "2023-11-03",
                "2023-09-30",
                2023,
                "2022-10-01",
                "2023-09-30",
                280.0,
                84.0,
                110.0,
                2.8,
            ),
            synthetic_annual_filing(
                "0001234567-22-000002",
                "2022-11-04",
                "2022-09-24",
                2022,
                "2021-09-26",
                "2022-09-24",
                260.0,
                78.0,
                100.0,
                2.6,
            ),
            synthetic_annual_filing(
                "0001234567-21-000001",
                "2021-10-29",
                "2021-09-25",
                2021,
                "2020-09-27",
                "2021-09-25",
                240.0,
                72.0,
                96.0,
                2.4,
            ),
        ]
    }

    fn quarterly_earnings_filings() -> Vec<SyntheticEarningsFiling> {
        vec![
            synthetic_quarterly_filing(
                "0007654321-25-000008",
                "2025-05-30",
                "2025-04-27",
                2026,
                "Q1",
                "2025-01-27",
                "2025-04-27",
                Some("2024-10-28"),
                100.0,
                30.0,
                40.0,
                1.0,
            ),
            synthetic_quarterly_filing(
                "0007654321-25-000007",
                "2025-02-28",
                "2025-01-26",
                2025,
                "Q4",
                "2024-10-28",
                "2025-01-26",
                Some("2024-01-29"),
                92.0,
                28.0,
                36.0,
                0.92,
            ),
            synthetic_quarterly_filing(
                "0007654321-24-000006",
                "2024-11-27",
                "2024-10-27",
                2025,
                "Q3",
                "2024-07-29",
                "2024-10-27",
                Some("2024-01-29"),
                84.0,
                24.0,
                32.0,
                0.84,
            ),
            synthetic_quarterly_filing(
                "0007654321-24-000005",
                "2024-08-28",
                "2024-07-28",
                2025,
                "Q2",
                "2024-04-29",
                "2024-07-28",
                Some("2024-01-29"),
                76.0,
                22.0,
                29.0,
                0.76,
            ),
            synthetic_quarterly_filing(
                "0007654321-24-000004",
                "2024-05-29",
                "2024-04-28",
                2025,
                "Q1",
                "2024-01-29",
                "2024-04-28",
                Some("2023-10-30"),
                25.0,
                7.0,
                10.0,
                0.25,
            ),
            synthetic_quarterly_filing(
                "0007654321-24-000003",
                "2024-02-28",
                "2024-01-28",
                2024,
                "Q4",
                "2023-10-30",
                "2024-01-28",
                Some("2023-01-30"),
                23.0,
                6.5,
                9.0,
                0.23,
            ),
            synthetic_quarterly_filing(
                "0007654321-23-000002",
                "2023-11-29",
                "2023-10-29",
                2024,
                "Q3",
                "2023-07-31",
                "2023-10-29",
                Some("2023-01-30"),
                21.0,
                6.0,
                8.0,
                0.21,
            ),
            synthetic_quarterly_filing(
                "0007654321-23-000001",
                "2023-08-30",
                "2023-07-30",
                2024,
                "Q2",
                "2023-05-01",
                "2023-07-30",
                Some("2023-01-30"),
                19.0,
                5.0,
                7.0,
                0.19,
            ),
        ]
    }

    fn balance_sheet_filings() -> Vec<SyntheticEarningsFiling> {
        vec![SyntheticEarningsFiling {
            accession_number: "0009753186-24-000001",
            filing_date: "2024-11-05",
            report_date: "2024-09-30",
            form: "10-K",
            primary_document: "balance-map.htm",
            xml_name: "balance-map_htm.xml",
            xml: synthetic_balance_instance(),
        }]
    }

    fn custom_mapped_earnings_filings() -> Vec<SyntheticEarningsFiling> {
        vec![synthetic_custom_annual_filing(
            "0002468135-24-000001",
            "2024-11-05",
            "2024-09-30",
            2024,
            "2023-10-01",
            "2024-09-30",
            555.0,
        )]
    }

    fn multi_currency_earnings_filings() -> Vec<SyntheticEarningsFiling> {
        vec![
            SyntheticEarningsFiling {
                accession_number: "0001357924-24-000002",
                filing_date: "2024-11-07",
                report_date: "2024-09-30",
                form: "10-K",
                primary_document: "earn-multi.htm",
                xml_name: "earn-multi_htm.xml",
                xml: synthetic_multi_currency_earnings_instance(
                    2024,
                    "2023-10-01",
                    "2024-09-30",
                    true,
                ),
            },
            SyntheticEarningsFiling {
                accession_number: "0001357924-23-000001",
                filing_date: "2023-11-06",
                report_date: "2023-09-30",
                form: "10-K",
                primary_document: "earn-multi.htm",
                xml_name: "earn-multi_htm.xml",
                xml: synthetic_multi_currency_earnings_instance(
                    2023,
                    "2022-10-01",
                    "2023-09-30",
                    false,
                ),
            },
        ]
    }

    fn multi_currency_balance_filings() -> Vec<SyntheticEarningsFiling> {
        vec![SyntheticEarningsFiling {
            accession_number: "0008642097-24-000001",
            filing_date: "2024-11-08",
            report_date: "2024-09-30",
            form: "10-K",
            primary_document: "balance-multi.htm",
            xml_name: "balance-multi_htm.xml",
            xml: synthetic_multi_currency_balance_instance(),
        }]
    }

    #[expect(
        clippy::too_many_arguments,
        reason = "test fixture builder mirrors filing fields"
    )]
    fn synthetic_annual_filing(
        accession_number: &'static str,
        filing_date: &'static str,
        report_date: &'static str,
        fiscal_year: i32,
        period_start: &'static str,
        period_end: &'static str,
        revenue: f64,
        net_income: f64,
        operating_profit: f64,
        diluted_eps: f64,
    ) -> SyntheticEarningsFiling {
        SyntheticEarningsFiling {
            accession_number,
            filing_date,
            report_date,
            form: "10-K",
            primary_document: "earn-annual.htm",
            xml_name: "earn-annual_htm.xml",
            xml: synthetic_earnings_instance(
                fiscal_year,
                "FY",
                period_start,
                period_end,
                None,
                revenue,
                net_income,
                operating_profit,
                diluted_eps,
            ),
        }
    }

    #[expect(
        clippy::too_many_arguments,
        reason = "test fixture builder mirrors filing fields"
    )]
    fn synthetic_quarterly_filing(
        accession_number: &'static str,
        filing_date: &'static str,
        report_date: &'static str,
        fiscal_year: i32,
        fiscal_period: &'static str,
        period_start: &'static str,
        period_end: &'static str,
        ytd_start: Option<&'static str>,
        revenue: f64,
        net_income: f64,
        operating_profit: f64,
        diluted_eps: f64,
    ) -> SyntheticEarningsFiling {
        SyntheticEarningsFiling {
            accession_number,
            filing_date,
            report_date,
            form: "10-Q",
            primary_document: "earn-quarter.htm",
            xml_name: "earn-quarter_htm.xml",
            xml: synthetic_earnings_instance(
                fiscal_year,
                fiscal_period,
                period_start,
                period_end,
                ytd_start,
                revenue,
                net_income,
                operating_profit,
                diluted_eps,
            ),
        }
    }

    #[expect(
        clippy::too_many_arguments,
        reason = "test fixture builder mirrors filing facts"
    )]
    fn synthetic_earnings_instance(
        fiscal_year: i32,
        fiscal_period: &str,
        period_start: &str,
        period_end: &str,
        ytd_start: Option<&str>,
        revenue: f64,
        net_income: f64,
        operating_profit: f64,
        diluted_eps: f64,
    ) -> String {
        let ytd_context = ytd_start.map(|ytd_start| {
            format!(
                r#"<xbrli:context id="ytd"><xbrli:entity><xbrli:identifier scheme="http://www.sec.gov/CIK">1</xbrli:identifier></xbrli:entity><xbrli:period><xbrli:startDate>{ytd_start}</xbrli:startDate><xbrli:endDate>{period_end}</xbrli:endDate></xbrli:period></xbrli:context>
  <us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax contextRef="ytd" unitRef="usd">{}</us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax>
  <us-gaap:NetIncomeLoss contextRef="ytd" unitRef="usd">{}</us-gaap:NetIncomeLoss>"#,
                revenue * 2.0,
                net_income * 2.0
            )
        }).unwrap_or_default();

        format!(
            r#"<?xml version="1.0" encoding="UTF-8"?>
<xbrli:xbrl xmlns:xbrli="http://www.xbrl.org/2003/instance" xmlns:dei="http://xbrl.sec.gov/dei/2024" xmlns:us-gaap="http://fasb.org/us-gaap/2024" xmlns:iso4217="http://www.xbrl.org/2003/iso4217">
  <xbrli:context id="duration"><xbrli:entity><xbrli:identifier scheme="http://www.sec.gov/CIK">1</xbrli:identifier></xbrli:entity><xbrli:period><xbrli:startDate>{period_start}</xbrli:startDate><xbrli:endDate>{period_end}</xbrli:endDate></xbrli:period></xbrli:context>
  <xbrli:context id="instant"><xbrli:entity><xbrli:identifier scheme="http://www.sec.gov/CIK">1</xbrli:identifier></xbrli:entity><xbrli:period><xbrli:instant>{period_end}</xbrli:instant></xbrli:period></xbrli:context>
  {ytd_context}
  <xbrli:unit id="usd"><xbrli:measure>iso4217:USD</xbrli:measure></xbrli:unit>
  <xbrli:unit id="shares"><xbrli:measure>xbrli:shares</xbrli:measure></xbrli:unit>
  <xbrli:unit id="pure"><xbrli:measure>xbrli:pure</xbrli:measure></xbrli:unit>
  <xbrli:unit id="usdPerShare"><xbrli:divide><xbrli:unitNumerator><xbrli:measure>iso4217:USD</xbrli:measure></xbrli:unitNumerator><xbrli:unitDenominator><xbrli:measure>xbrli:shares</xbrli:measure></xbrli:unitDenominator></xbrli:divide></xbrli:unit>
  <dei:DocumentFiscalYearFocus contextRef="duration">{fiscal_year}</dei:DocumentFiscalYearFocus>
  <dei:DocumentFiscalPeriodFocus contextRef="duration">{fiscal_period}</dei:DocumentFiscalPeriodFocus>
  <dei:DocumentPeriodEndDate contextRef="duration">{period_end}</dei:DocumentPeriodEndDate>
  <us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax contextRef="duration" unitRef="usd">{revenue}</us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax>
  <us-gaap:OperatingIncomeLoss contextRef="duration" unitRef="usd">{operating_profit}</us-gaap:OperatingIncomeLoss>
  <us-gaap:NetIncomeLoss contextRef="duration" unitRef="usd">{net_income}</us-gaap:NetIncomeLoss>
  <us-gaap:EarningsPerShareBasic contextRef="duration" unitRef="usdPerShare">{diluted_eps}</us-gaap:EarningsPerShareBasic>
  <us-gaap:EarningsPerShareDiluted contextRef="duration" unitRef="usdPerShare">{diluted_eps}</us-gaap:EarningsPerShareDiluted>
  <us-gaap:WeightedAverageNumberOfSharesOutstandingBasic contextRef="duration" unitRef="shares">100</us-gaap:WeightedAverageNumberOfSharesOutstandingBasic>
  <us-gaap:WeightedAverageNumberOfDilutedSharesOutstanding contextRef="duration" unitRef="shares">100</us-gaap:WeightedAverageNumberOfDilutedSharesOutstanding>
  <dei:EntityCommonStockSharesOutstanding contextRef="instant" unitRef="shares">101</dei:EntityCommonStockSharesOutstanding>
  <us-gaap:EffectiveIncomeTaxRateContinuingOperations contextRef="duration" unitRef="pure">0.21</us-gaap:EffectiveIncomeTaxRateContinuingOperations>
</xbrli:xbrl>"#
        )
    }

    fn synthetic_custom_annual_filing(
        accession_number: &'static str,
        filing_date: &'static str,
        report_date: &'static str,
        fiscal_year: i32,
        period_start: &'static str,
        period_end: &'static str,
        revenue: f64,
    ) -> SyntheticEarningsFiling {
        SyntheticEarningsFiling {
            accession_number,
            filing_date,
            report_date,
            form: "10-K",
            primary_document: "earn-custom.htm",
            xml_name: "earn-custom_htm.xml",
            xml: synthetic_custom_earnings_instance(fiscal_year, period_start, period_end, revenue),
        }
    }

    fn synthetic_balance_companyfacts() -> serde_json::Value {
        serde_json::json!({
            "entityName": "Balance Mapping Inc.",
            "facts": {
                "us-gaap": {
                    "RevenueFromContractWithCustomerExcludingAssessedTax": {
                        "units": {
                            "USD": [{
                                "start": "2023-10-01",
                                "end": "2024-09-30",
                                "val": 300.0,
                                "filed": "2024-11-05",
                                "form": "10-K",
                                "fy": 2024,
                                "fp": "FY",
                                "accn": "0009753186-24-000001"
                            }]
                        }
                    },
                    "CashAndCashEquivalentsAtCarryingValue": {
                        "units": {
                            "USD": [{
                                "end": "2024-09-30",
                                "val": 100.0,
                                "filed": "2024-11-05",
                                "form": "10-K",
                                "fy": 2024,
                                "fp": "FY",
                                "accn": "0009753186-24-000001"
                            }]
                        }
                    },
                    "Assets": {
                        "units": {
                            "USD": [{
                                "end": "2024-09-30",
                                "val": 900.0,
                                "filed": "2024-11-05",
                                "form": "10-K",
                                "fy": 2024,
                                "fp": "FY",
                                "accn": "0009753186-24-000001"
                            }]
                        }
                    },
                    "Liabilities": {
                        "units": {
                            "USD": [{
                                "end": "2024-09-30",
                                "val": 400.0,
                                "filed": "2024-11-05",
                                "form": "10-K",
                                "fy": 2024,
                                "fp": "FY",
                                "accn": "0009753186-24-000001"
                            }]
                        }
                    },
                    "StockholdersEquity": {
                        "units": {
                            "USD": [{
                                "end": "2024-09-30",
                                "val": 500.0,
                                "filed": "2024-11-05",
                                "form": "10-K",
                                "fy": 2024,
                                "fp": "FY",
                                "accn": "0009753186-24-000001"
                            }]
                        }
                    }
                },
                "ifrs-full": {},
                "dei": {
                    "EntityCommonStockSharesOutstanding": {
                        "units": {
                            "shares": [{
                                "end": "2024-09-30",
                                "val": 101.0,
                                "filed": "2024-11-05",
                                "form": "10-K",
                                "fy": 2024,
                                "fp": "FY",
                                "accn": "0009753186-24-000001"
                            }]
                        }
                    }
                }
            }
        })
    }

    fn synthetic_multi_currency_balance_companyfacts() -> serde_json::Value {
        serde_json::json!({
            "entityName": "Multi Currency Balance Inc.",
            "facts": {
                "us-gaap": {
                    "RevenueFromContractWithCustomerExcludingAssessedTax": {
                        "units": {
                            "EUR": [{
                                "start": "2023-10-01",
                                "end": "2024-09-30",
                                "val": 700.0,
                                "filed": "2024-11-08",
                                "form": "10-K",
                                "fy": 2024,
                                "fp": "FY",
                                "accn": "0008642097-24-000001"
                            }],
                            "USD": [{
                                "start": "2023-10-01",
                                "end": "2024-09-30",
                                "val": 500.0,
                                "filed": "2024-11-08",
                                "form": "10-K",
                                "fy": 2024,
                                "fp": "FY",
                                "accn": "0008642097-24-000001"
                            }]
                        }
                    },
                    "CashAndCashEquivalentsAtCarryingValue": {
                        "units": {
                            "EUR": [{
                                "end": "2024-09-30",
                                "val": 220.0,
                                "filed": "2024-11-08",
                                "form": "10-K",
                                "fy": 2024,
                                "fp": "FY",
                                "accn": "0008642097-24-000001"
                            }],
                            "USD": [{
                                "end": "2024-09-30",
                                "val": 110.0,
                                "filed": "2024-11-08",
                                "form": "10-K",
                                "fy": 2024,
                                "fp": "FY",
                                "accn": "0008642097-24-000001"
                            }]
                        }
                    },
                    "Assets": {
                        "units": {
                            "EUR": [{
                                "end": "2024-09-30",
                                "val": 900.0,
                                "filed": "2024-11-08",
                                "form": "10-K",
                                "fy": 2024,
                                "fp": "FY",
                                "accn": "0008642097-24-000001"
                            }],
                            "USD": [{
                                "end": "2024-09-30",
                                "val": 750.0,
                                "filed": "2024-11-08",
                                "form": "10-K",
                                "fy": 2024,
                                "fp": "FY",
                                "accn": "0008642097-24-000001"
                            }]
                        }
                    },
                    "Liabilities": {
                        "units": {
                            "EUR": [{
                                "end": "2024-09-30",
                                "val": 400.0,
                                "filed": "2024-11-08",
                                "form": "10-K",
                                "fy": 2024,
                                "fp": "FY",
                                "accn": "0008642097-24-000001"
                            }],
                            "USD": [{
                                "end": "2024-09-30",
                                "val": 300.0,
                                "filed": "2024-11-08",
                                "form": "10-K",
                                "fy": 2024,
                                "fp": "FY",
                                "accn": "0008642097-24-000001"
                            }]
                        }
                    },
                    "StockholdersEquity": {
                        "units": {
                            "EUR": [{
                                "end": "2024-09-30",
                                "val": 500.0,
                                "filed": "2024-11-08",
                                "form": "10-K",
                                "fy": 2024,
                                "fp": "FY",
                                "accn": "0008642097-24-000001"
                            }],
                            "USD": [{
                                "end": "2024-09-30",
                                "val": 450.0,
                                "filed": "2024-11-08",
                                "form": "10-K",
                                "fy": 2024,
                                "fp": "FY",
                                "accn": "0008642097-24-000001"
                            }]
                        }
                    }
                },
                "ifrs-full": {},
                "dei": {
                    "EntityCommonStockSharesOutstanding": {
                        "units": {
                            "shares": [{
                                "end": "2024-09-30",
                                "val": 101.0,
                                "filed": "2024-11-08",
                                "form": "10-K",
                                "fy": 2024,
                                "fp": "FY",
                                "accn": "0008642097-24-000001"
                            }]
                        }
                    }
                }
            }
        })
    }

    fn synthetic_balance_instance() -> String {
        r#"<?xml version="1.0" encoding="UTF-8"?>
<xbrli:xbrl xmlns:xbrli="http://www.xbrl.org/2003/instance" xmlns:dei="http://xbrl.sec.gov/dei/2024" xmlns:us-gaap="http://fasb.org/us-gaap/2024" xmlns:custom="http://example.com/custom/2024" xmlns:iso4217="http://www.xbrl.org/2003/iso4217">
  <xbrli:context id="duration"><xbrli:entity><xbrli:identifier scheme="http://www.sec.gov/CIK">1</xbrli:identifier></xbrli:entity><xbrli:period><xbrli:startDate>2023-10-01</xbrli:startDate><xbrli:endDate>2024-09-30</xbrli:endDate></xbrli:period></xbrli:context>
  <xbrli:context id="instant"><xbrli:entity><xbrli:identifier scheme="http://www.sec.gov/CIK">1</xbrli:identifier></xbrli:entity><xbrli:period><xbrli:instant>2024-09-30</xbrli:instant></xbrli:period></xbrli:context>
  <xbrli:unit id="usd"><xbrli:measure>iso4217:USD</xbrli:measure></xbrli:unit>
  <xbrli:unit id="shares"><xbrli:measure>xbrli:shares</xbrli:measure></xbrli:unit>
  <dei:DocumentFiscalYearFocus contextRef="duration">2024</dei:DocumentFiscalYearFocus>
  <dei:DocumentFiscalPeriodFocus contextRef="duration">FY</dei:DocumentFiscalPeriodFocus>
  <dei:DocumentPeriodEndDate contextRef="duration">2024-09-30</dei:DocumentPeriodEndDate>
  <us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax contextRef="duration" unitRef="usd">300</us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax>
  <us-gaap:CashAndCashEquivalentsAtCarryingValue contextRef="instant" unitRef="usd">100</us-gaap:CashAndCashEquivalentsAtCarryingValue>
  <us-gaap:ShortTermInvestments contextRef="instant" unitRef="usd">50</us-gaap:ShortTermInvestments>
  <us-gaap:AccountsReceivableNetCurrent contextRef="instant" unitRef="usd">25</us-gaap:AccountsReceivableNetCurrent>
  <us-gaap:InventoryNet contextRef="instant" unitRef="usd">10</us-gaap:InventoryNet>
  <us-gaap:OtherAssetsCurrent contextRef="instant" unitRef="usd">15</us-gaap:OtherAssetsCurrent>
  <us-gaap:AssetsCurrent contextRef="instant" unitRef="usd">200</us-gaap:AssetsCurrent>
  <us-gaap:PropertyPlantAndEquipmentNet contextRef="instant" unitRef="usd">300</us-gaap:PropertyPlantAndEquipmentNet>
  <us-gaap:Goodwill contextRef="instant" unitRef="usd">150</us-gaap:Goodwill>
  <us-gaap:FiniteLivedIntangibleAssetsNet contextRef="instant" unitRef="usd">100</us-gaap:FiniteLivedIntangibleAssetsNet>
  <us-gaap:OtherAssetsNoncurrent contextRef="instant" unitRef="usd">150</us-gaap:OtherAssetsNoncurrent>
  <us-gaap:Assets contextRef="instant" unitRef="usd">900</us-gaap:Assets>
  <us-gaap:AccountsPayableCurrent contextRef="instant" unitRef="usd">80</us-gaap:AccountsPayableCurrent>
  <us-gaap:AccruedLiabilitiesCurrent contextRef="instant" unitRef="usd">20</us-gaap:AccruedLiabilitiesCurrent>
  <us-gaap:ShortTermBorrowings contextRef="instant" unitRef="usd">10</us-gaap:ShortTermBorrowings>
  <us-gaap:LiabilitiesCurrent contextRef="instant" unitRef="usd">110</us-gaap:LiabilitiesCurrent>
  <us-gaap:LongTermDebtNoncurrent contextRef="instant" unitRef="usd">200</us-gaap:LongTermDebtNoncurrent>
  <us-gaap:OtherLiabilitiesNoncurrent contextRef="instant" unitRef="usd">90</us-gaap:OtherLiabilitiesNoncurrent>
  <us-gaap:Liabilities contextRef="instant" unitRef="usd">400</us-gaap:Liabilities>
  <us-gaap:RetainedEarningsAccumulatedDeficit contextRef="instant" unitRef="usd">250</us-gaap:RetainedEarningsAccumulatedDeficit>
  <us-gaap:AccumulatedOtherComprehensiveIncomeLossNetOfTax contextRef="instant" unitRef="usd">-5</us-gaap:AccumulatedOtherComprehensiveIncomeLossNetOfTax>
  <us-gaap:StockholdersEquity contextRef="instant" unitRef="usd">500</us-gaap:StockholdersEquity>
  <dei:EntityCommonStockSharesOutstanding contextRef="instant" unitRef="shares">101</dei:EntityCommonStockSharesOutstanding>
  <custom:AlternativeCash contextRef="instant" unitRef="usd">321</custom:AlternativeCash>
  <custom:AlternativeSharesOutstanding contextRef="instant" unitRef="shares">999</custom:AlternativeSharesOutstanding>
</xbrli:xbrl>"#
            .to_string()
    }

    fn synthetic_multi_currency_balance_instance() -> String {
        r#"<?xml version="1.0" encoding="UTF-8"?>
<xbrli:xbrl xmlns:xbrli="http://www.xbrl.org/2003/instance" xmlns:dei="http://xbrl.sec.gov/dei/2024" xmlns:us-gaap="http://fasb.org/us-gaap/2024" xmlns:custom="http://example.com/custom/2024" xmlns:iso4217="http://www.xbrl.org/2003/iso4217">
  <xbrli:context id="duration"><xbrli:entity><xbrli:identifier scheme="http://www.sec.gov/CIK">1</xbrli:identifier></xbrli:entity><xbrli:period><xbrli:startDate>2023-10-01</xbrli:startDate><xbrli:endDate>2024-09-30</xbrli:endDate></xbrli:period></xbrli:context>
  <xbrli:context id="instant"><xbrli:entity><xbrli:identifier scheme="http://www.sec.gov/CIK">1</xbrli:identifier></xbrli:entity><xbrli:period><xbrli:instant>2024-09-30</xbrli:instant></xbrli:period></xbrli:context>
  <xbrli:unit id="eur"><xbrli:measure>iso4217:EUR</xbrli:measure></xbrli:unit>
  <xbrli:unit id="usd"><xbrli:measure>iso4217:USD</xbrli:measure></xbrli:unit>
  <xbrli:unit id="shares"><xbrli:measure>xbrli:shares</xbrli:measure></xbrli:unit>
  <dei:DocumentFiscalYearFocus contextRef="duration">2024</dei:DocumentFiscalYearFocus>
  <dei:DocumentFiscalPeriodFocus contextRef="duration">FY</dei:DocumentFiscalPeriodFocus>
  <dei:DocumentPeriodEndDate contextRef="duration">2024-09-30</dei:DocumentPeriodEndDate>
  <us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax contextRef="duration" unitRef="eur">700</us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax>
  <us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax contextRef="duration" unitRef="usd">500</us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax>
  <us-gaap:CashAndCashEquivalentsAtCarryingValue contextRef="instant" unitRef="eur">220</us-gaap:CashAndCashEquivalentsAtCarryingValue>
  <us-gaap:CashAndCashEquivalentsAtCarryingValue contextRef="instant" unitRef="usd">110</us-gaap:CashAndCashEquivalentsAtCarryingValue>
  <us-gaap:Assets contextRef="instant" unitRef="eur">900</us-gaap:Assets>
  <us-gaap:Assets contextRef="instant" unitRef="usd">750</us-gaap:Assets>
  <us-gaap:Liabilities contextRef="instant" unitRef="eur">400</us-gaap:Liabilities>
  <us-gaap:Liabilities contextRef="instant" unitRef="usd">300</us-gaap:Liabilities>
  <us-gaap:StockholdersEquity contextRef="instant" unitRef="eur">500</us-gaap:StockholdersEquity>
  <us-gaap:StockholdersEquity contextRef="instant" unitRef="usd">450</us-gaap:StockholdersEquity>
  <dei:EntityCommonStockSharesOutstanding contextRef="instant" unitRef="shares">101</dei:EntityCommonStockSharesOutstanding>
  <custom:AlternativeCash contextRef="instant" unitRef="eur">222</custom:AlternativeCash>
  <custom:AlternativeCash contextRef="instant" unitRef="usd">111</custom:AlternativeCash>
</xbrli:xbrl>"#
            .to_string()
    }

    fn synthetic_multi_currency_earnings_instance(
        fiscal_year: i32,
        period_start: &str,
        period_end: &str,
        include_usd: bool,
    ) -> String {
        let usd_facts = include_usd.then(|| {
            r#"
  <us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax contextRef="duration" unitRef="usd">500</us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax>
  <us-gaap:OperatingIncomeLoss contextRef="duration" unitRef="usd">140</us-gaap:OperatingIncomeLoss>
  <us-gaap:NetIncomeLoss contextRef="duration" unitRef="usd">120</us-gaap:NetIncomeLoss>
  <custom:AlternativeRevenue contextRef="duration" unitRef="usd">505</custom:AlternativeRevenue>
  <us-gaap:EarningsPerShareBasic contextRef="duration" unitRef="usdPerShare">1.20</us-gaap:EarningsPerShareBasic>
  <us-gaap:EarningsPerShareDiluted contextRef="duration" unitRef="usdPerShare">1.20</us-gaap:EarningsPerShareDiluted>"#
        }).unwrap_or_default();

        format!(
            r#"<?xml version="1.0" encoding="UTF-8"?>
<xbrli:xbrl xmlns:xbrli="http://www.xbrl.org/2003/instance" xmlns:dei="http://xbrl.sec.gov/dei/2024" xmlns:us-gaap="http://fasb.org/us-gaap/2024" xmlns:custom="http://example.com/custom/2024" xmlns:iso4217="http://www.xbrl.org/2003/iso4217">
  <xbrli:context id="duration"><xbrli:entity><xbrli:identifier scheme="http://www.sec.gov/CIK">1</xbrli:identifier></xbrli:entity><xbrli:period><xbrli:startDate>{period_start}</xbrli:startDate><xbrli:endDate>{period_end}</xbrli:endDate></xbrli:period></xbrli:context>
  <xbrli:context id="instant"><xbrli:entity><xbrli:identifier scheme="http://www.sec.gov/CIK">1</xbrli:identifier></xbrli:entity><xbrli:period><xbrli:instant>{period_end}</xbrli:instant></xbrli:period></xbrli:context>
  <xbrli:unit id="eur"><xbrli:measure>iso4217:EUR</xbrli:measure></xbrli:unit>
  <xbrli:unit id="usd"><xbrli:measure>iso4217:USD</xbrli:measure></xbrli:unit>
  <xbrli:unit id="shares"><xbrli:measure>xbrli:shares</xbrli:measure></xbrli:unit>
  <xbrli:unit id="eurPerShare"><xbrli:divide><xbrli:unitNumerator><xbrli:measure>iso4217:EUR</xbrli:measure></xbrli:unitNumerator><xbrli:unitDenominator><xbrli:measure>xbrli:shares</xbrli:measure></xbrli:unitDenominator></xbrli:divide></xbrli:unit>
  <xbrli:unit id="usdPerShare"><xbrli:divide><xbrli:unitNumerator><xbrli:measure>iso4217:USD</xbrli:measure></xbrli:unitNumerator><xbrli:unitDenominator><xbrli:measure>xbrli:shares</xbrli:measure></xbrli:unitDenominator></xbrli:divide></xbrli:unit>
  <dei:DocumentFiscalYearFocus contextRef="duration">{fiscal_year}</dei:DocumentFiscalYearFocus>
  <dei:DocumentFiscalPeriodFocus contextRef="duration">FY</dei:DocumentFiscalPeriodFocus>
  <dei:DocumentPeriodEndDate contextRef="duration">{period_end}</dei:DocumentPeriodEndDate>
  <us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax contextRef="duration" unitRef="eur">700</us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax>
  <us-gaap:OperatingIncomeLoss contextRef="duration" unitRef="eur">180</us-gaap:OperatingIncomeLoss>
  <us-gaap:NetIncomeLoss contextRef="duration" unitRef="eur">150</us-gaap:NetIncomeLoss>
  <custom:AlternativeRevenue contextRef="duration" unitRef="eur">705</custom:AlternativeRevenue>
  <us-gaap:EarningsPerShareBasic contextRef="duration" unitRef="eurPerShare">1.50</us-gaap:EarningsPerShareBasic>
  <us-gaap:EarningsPerShareDiluted contextRef="duration" unitRef="eurPerShare">1.50</us-gaap:EarningsPerShareDiluted>
  {usd_facts}
  <us-gaap:WeightedAverageNumberOfSharesOutstandingBasic contextRef="duration" unitRef="shares">100</us-gaap:WeightedAverageNumberOfSharesOutstandingBasic>
  <us-gaap:WeightedAverageNumberOfDilutedSharesOutstanding contextRef="duration" unitRef="shares">100</us-gaap:WeightedAverageNumberOfDilutedSharesOutstanding>
  <dei:EntityCommonStockSharesOutstanding contextRef="instant" unitRef="shares">101</dei:EntityCommonStockSharesOutstanding>
</xbrli:xbrl>"#
        )
    }

    fn synthetic_custom_earnings_instance(
        fiscal_year: i32,
        period_start: &str,
        period_end: &str,
        revenue: f64,
    ) -> String {
        format!(
            r#"<?xml version="1.0" encoding="UTF-8"?>
<xbrli:xbrl xmlns:xbrli="http://www.xbrl.org/2003/instance" xmlns:dei="http://xbrl.sec.gov/dei/2024" xmlns:custom="http://example.com/custom/2024" xmlns:iso4217="http://www.xbrl.org/2003/iso4217">
  <xbrli:context id="duration"><xbrli:entity><xbrli:identifier scheme="http://www.sec.gov/CIK">1</xbrli:identifier></xbrli:entity><xbrli:period><xbrli:startDate>{period_start}</xbrli:startDate><xbrli:endDate>{period_end}</xbrli:endDate></xbrli:period></xbrli:context>
  <xbrli:unit id="usd"><xbrli:measure>iso4217:USD</xbrli:measure></xbrli:unit>
  <dei:DocumentFiscalYearFocus contextRef="duration">{fiscal_year}</dei:DocumentFiscalYearFocus>
  <dei:DocumentFiscalPeriodFocus contextRef="duration">FY</dei:DocumentFiscalPeriodFocus>
  <dei:DocumentPeriodEndDate contextRef="duration">{period_end}</dei:DocumentPeriodEndDate>
  <custom:AlternativeRevenue contextRef="duration" unitRef="usd">{revenue}</custom:AlternativeRevenue>
</xbrli:xbrl>"#
        )
    }

    #[test]
    fn financials_returns_four_annual_periods() {
        let data = futures::executor::block_on(lookup().financials("AAPL", Frequency::Annual))
            .expect("financials should load");
        assert_eq!(data.periods.len(), 4);
        assert_eq!(data.periods[0].revenue, Some(395000000000.0));
    }

    #[test]
    fn financials_supports_quarterly_periods() {
        let data = futures::executor::block_on(lookup().financials("AAPL", Frequency::Quarterly))
            .expect("quarterly financials should load");
        assert_eq!(data.periods.len(), 4);
        assert_eq!(data.periods[0].revenue, Some(125000000000.0));
    }

    #[test]
    fn financials_apply_custom_cash_mapping_and_collect_available_concepts() {
        let (mappings_service, _temp_dir) = create_mappings_service();
        futures::executor::block_on(mappings_service.add_mapping(
            CustomConceptMapping::test_mapping(
                TargetField::CashAndEquivalents,
                CustomConcept {
                    taxonomy: "custom".to_string(),
                    concept: "AlternativeCash".to_string(),
                    unit_family: UnitFamily::Currency,
                },
                MappingScope {
                    ticker: Some("BMAP".to_string()),
                    period_start: None,
                    period_end: None,
                },
                None,
            ),
        ))
        .expect("custom balance-sheet mapping should persist");

        let data = futures::executor::block_on(
            financials_lookup_with_mappings(mappings_service).financials("BMAP", Frequency::Annual),
        )
        .expect("financials should load with custom cash mapping");

        assert_eq!(data.periods.len(), 1);
        assert_eq!(data.periods[0].cash_and_equivalents, Some(321.0));
        assert!(data
            .available_concepts
            .iter()
            .any(|concept| concept.concept == "AlternativeCash"));
    }

    #[test]
    fn financials_apply_formula_mapping_to_total_assets() {
        let (mappings_service, _temp_dir) = create_mappings_service();
        futures::executor::block_on(mappings_service.add_mapping(CustomConceptMapping {
            id: uuid::Uuid::new_v4().to_string(),
            target_field: TargetField::TotalAssets,
            mapping_kind: crate::mappings::MappingKind::Formula(crate::mappings::FormulaMapping {
                expression:
                    "$cash_and_equivalents + $total_liabilities + $total_equity".to_string(),
                cached_ast: None,
            }),
            scope: MappingScope {
                ticker: Some("BMAP".to_string()),
                period_start: None,
                period_end: None,
            },
            enabled: true,
            notes: None,
        }))
        .expect("formula mapping should persist");

        let data = futures::executor::block_on(
            financials_lookup_with_mappings(mappings_service).financials("BMAP", Frequency::Annual),
        )
        .expect("financials should load with formula mapping");

        assert_eq!(data.periods[0].total_assets, Some(1000.0));
    }

    #[test]
    fn financials_apply_formula_mapping_to_total_current_assets() {
        let (mappings_service, _temp_dir) = create_mappings_service();
        futures::executor::block_on(mappings_service.add_mapping(CustomConceptMapping {
            id: uuid::Uuid::new_v4().to_string(),
            target_field: TargetField::TotalCurrentAssets,
            mapping_kind: crate::mappings::MappingKind::Formula(crate::mappings::FormulaMapping {
                expression: "$cash_and_equivalents + $short_term_investments + $accounts_receivable_net + $inventory + $other_current_assets".to_string(),
                cached_ast: None,
            }),
            scope: MappingScope {
                ticker: Some("BMAP".to_string()),
                period_start: None,
                period_end: None,
            },
            enabled: true,
            notes: None,
        }))
        .expect("current assets formula mapping should persist");

        let data = futures::executor::block_on(
            financials_lookup_with_mappings(mappings_service).financials("BMAP", Frequency::Annual),
        )
        .expect("financials should load with current assets formula mapping");

        assert_eq!(data.periods[0].total_current_assets, Some(200.0));
    }

    #[test]
    fn financials_formula_defined_total_current_assets_suppresses_static_warning() {
        let (mappings_service, _temp_dir) = create_mappings_service();
        futures::executor::block_on(mappings_service.add_mapping(CustomConceptMapping {
            id: uuid::Uuid::new_v4().to_string(),
            target_field: TargetField::TotalCurrentAssets,
            mapping_kind: crate::mappings::MappingKind::Formula(crate::mappings::FormulaMapping {
                expression: "$cash_and_equivalents + $short_term_investments".to_string(),
                cached_ast: None,
            }),
            scope: MappingScope {
                ticker: Some("BMAP".to_string()),
                period_start: None,
                period_end: None,
            },
            enabled: true,
            notes: None,
        }))
        .expect("broken current assets formula mapping should persist");

        let data = futures::executor::block_on(
            financials_lookup_with_mappings(mappings_service).financials("BMAP", Frequency::Annual),
        )
        .expect("financials should load with balance sheet warnings");

        assert_eq!(data.periods[0].total_current_assets, Some(150.0));
        assert!(data.mapping_warnings.iter().all(|warning| {
            !(warning.contains("Total Current Assets")
                && warning.contains("does not equal component sum")
                && warning.starts_with("2024"))
        }));
    }

    #[test]
    fn balance_sheet_consistency_still_warns_without_formula_target() {
        let mut period = test_statement_period();
        period.cash_and_equivalents = Some(100.0);
        period.short_term_investments = Some(50.0);
        period.accounts_receivable_net = Some(25.0);
        period.inventory = Some(25.0);
        period.other_current_assets = Some(10.0);
        period.total_current_assets = Some(150.0);

        let warnings = validate_balance_sheet_consistency(&period, &FormulaCheckTargets::default());
        assert!(warnings.iter().any(|warning| {
            warning.contains("Total Current Assets")
                && warning.contains("does not equal component sum")
        }));
    }

    #[test]
    fn balance_sheet_consistency_keeps_parent_check_for_formula_defined_component() {
        let mut period = test_statement_period();
        period.cash_and_equivalents = Some(100.0);
        period.short_term_investments = Some(50.0);
        period.accounts_receivable_net = Some(25.0);
        period.inventory = Some(25.0);
        period.other_current_assets = Some(10.0);
        period.total_current_assets = Some(150.0);
        let formula_targets = FormulaCheckTargets::from([TargetField::OtherCurrentAssets]);

        let warnings = validate_balance_sheet_consistency(&period, &formula_targets);
        assert!(warnings.iter().any(|warning| {
            warning.contains("Total Current Assets")
                && warning.contains("does not equal component sum")
        }));
    }

    #[test]
    fn financials_surface_formula_mapping_warnings_and_reuse_total_shares_target() {
        let (mappings_service, _temp_dir) = create_mappings_service();
        futures::executor::block_on(mappings_service.add_mapping(CustomConceptMapping {
            id: uuid::Uuid::new_v4().to_string(),
            target_field: TargetField::TotalAssets,
            mapping_kind: crate::mappings::MappingKind::Formula(crate::mappings::FormulaMapping {
                expression: "#custom.MissingAssets[currency] + $cash_and_equivalents".to_string(),
                cached_ast: None,
            }),
            scope: MappingScope {
                ticker: Some("BMAP".to_string()),
                period_start: None,
                period_end: None,
            },
            enabled: true,
            notes: None,
        }))
        .expect("warning formula mapping should persist");
        futures::executor::block_on(mappings_service.add_mapping(
            CustomConceptMapping::test_mapping(
                TargetField::TotalSharesOutstanding,
                CustomConcept {
                    taxonomy: "custom".to_string(),
                    concept: "AlternativeSharesOutstanding".to_string(),
                    unit_family: UnitFamily::Shares,
                },
                MappingScope {
                    ticker: Some("BMAP".to_string()),
                    period_start: None,
                    period_end: None,
                },
                None,
            ),
        ))
        .expect("shares mapping should persist");

        let data = futures::executor::block_on(
            financials_lookup_with_mappings(mappings_service).financials("BMAP", Frequency::Annual),
        )
        .expect("financials should load with warning formula mapping");

        assert_eq!(data.periods[0].shares_outstanding, Some(999.0));
        assert!(data
            .mapping_warnings
            .iter()
            .any(|warning| warning.contains("MissingAssets")));
        assert!(data.mapping_warnings.iter().all(|warning| {
            !(warning.contains("Total Assets") && warning.contains("does not equal"))
        }));
    }

    #[test]
    fn cash_flow_computes_free_cash_flow() {
        let query = CashFlowQuery {
            frequency: Frequency::Annual,
            range: None,
        };
        let data = futures::executor::block_on(lookup().cash_flow("MSFT", query))
            .expect("cash flow should load");
        assert_eq!(
            data.periods.last().unwrap().operating_cash_flow,
            Some(120000000000.0)
        );
        assert_eq!(
            data.periods.last().unwrap().free_cash_flow,
            Some(75523000000.0)
        );
    }

    #[test]
    fn balance_sheet_enumerates_sorted_currencies_and_defaults_to_latest_currency() {
        let (mappings_service, _temp_dir) = create_mappings_service();
        let data = futures::executor::block_on(
            financials_lookup_with_mappings(mappings_service).balance_sheet(
                "MCBS",
                BalanceSheetQuery {
                    frequency: Frequency::Annual,
                    range: None,
                    requested_currency: None,
                },
            ),
        )
        .expect("balance sheet should load");

        assert_eq!(
            data.available_currencies,
            vec!["EUR".to_string(), "USD".to_string()]
        );
        assert_eq!(data.selected_currency.as_deref(), Some("EUR"));
        assert_eq!(data.periods[0].cash_and_equivalents, Some(220.0));
        assert_eq!(data.periods[0].total_assets, Some(900.0));
    }

    #[test]
    fn balance_sheet_rejects_unavailable_requested_currency() {
        let (mappings_service, _temp_dir) = create_mappings_service();
        let error = futures::executor::block_on(
            financials_lookup_with_mappings(mappings_service).balance_sheet(
                "MCBS",
                BalanceSheetQuery {
                    frequency: Frequency::Annual,
                    range: None,
                    requested_currency: Some("GBP".to_string()),
                },
            ),
        )
        .expect_err("missing currency should error");

        assert!(matches!(
            error,
            EdgarLookupError::InvalidCurrencySelection { .. }
        ));
        assert!(error.to_string().contains("Available currencies: EUR, USD"));
    }

    #[test]
    fn balance_sheet_filters_values_to_requested_currency() {
        let (mappings_service, _temp_dir) = create_mappings_service();
        let data = futures::executor::block_on(
            financials_lookup_with_mappings(mappings_service).balance_sheet(
                "MCBS",
                BalanceSheetQuery {
                    frequency: Frequency::Annual,
                    range: None,
                    requested_currency: Some("USD".to_string()),
                },
            ),
        )
        .expect("usd balance sheet should load");

        assert_eq!(data.selected_currency.as_deref(), Some("USD"));
        assert_eq!(data.periods[0].cash_and_equivalents, Some(110.0));
        assert_eq!(data.periods[0].total_assets, Some(750.0));
        assert_eq!(data.periods[0].total_liabilities, Some(300.0));
    }

    #[test]
    fn balance_sheet_custom_mappings_respect_selected_currency() {
        let (mappings_service, _temp_dir) = create_mappings_service();
        futures::executor::block_on(mappings_service.add_mapping(
            CustomConceptMapping::test_mapping(
                TargetField::CashAndEquivalents,
                CustomConcept {
                    taxonomy: "custom".to_string(),
                    concept: "AlternativeCash".to_string(),
                    unit_family: UnitFamily::Currency,
                },
                MappingScope {
                    ticker: Some("MCBS".to_string()),
                    period_start: None,
                    period_end: None,
                },
                None,
            ),
        ))
        .expect("custom balance mapping should persist");

        let eur = futures::executor::block_on(
            financials_lookup_with_mappings(mappings_service.clone()).balance_sheet(
                "MCBS",
                BalanceSheetQuery {
                    frequency: Frequency::Annual,
                    range: None,
                    requested_currency: Some("EUR".to_string()),
                },
            ),
        )
        .expect("eur balance sheet should load");
        let usd = futures::executor::block_on(
            financials_lookup_with_mappings(mappings_service).balance_sheet(
                "MCBS",
                BalanceSheetQuery {
                    frequency: Frequency::Annual,
                    range: None,
                    requested_currency: Some("USD".to_string()),
                },
            ),
        )
        .expect("usd balance sheet should load");

        assert_eq!(eur.periods[0].cash_and_equivalents, Some(222.0));
        assert_eq!(usd.periods[0].cash_and_equivalents, Some(111.0));
    }

    #[test]
    fn dividends_returns_history_and_ttm_summary() {
        let data =
            futures::executor::block_on(lookup().dividends("KO")).expect("dividends should load");
        assert_eq!(data.events.len(), 8);
        let ttm_dividends_per_share = data.ttm_dividends_per_share.unwrap_or_default();
        assert!((ttm_dividends_per_share - 1.985).abs() < 0.000_1);
        assert_eq!(
            data.latest_event
                .as_ref()
                .map(|value| value.frequency_guess.as_str()),
            Some("quarterly")
        );
    }

    #[test]
    fn earnings_returns_filing_native_quarterly_values() {
        let data = futures::executor::block_on(earnings_lookup().earnings(
            "QEM",
            EarningsQuery {
                frequency: Frequency::Quarterly,
                range: None,
                requested_currency: None,
            },
        ))
        .expect("earnings should load");
        assert_eq!(data.periods.len(), 8);
        assert_eq!(data.periods.last().unwrap().revenue, Some(100.0));
        assert_eq!(data.periods.last().unwrap().total_revenues, Some(100.0));
        assert_eq!(data.periods.last().unwrap().operating_profit, Some(40.0));
        assert!(!data.available_periods.is_empty());
        assert!(!data.source_status.companyfacts_used);
        assert_eq!(
            data.available_periods
                .last()
                .map(|period| period.id.as_str()),
            Some("2026Q1")
        );
        assert_eq!(data.selected_period_start.as_deref(), Some("2024Q2"));
        assert_eq!(data.selected_period_end.as_deref(), Some("2026Q1"));
        assert!(
            data.periods
                .last()
                .unwrap()
                .revenue_yoy_change_percent
                .unwrap_or_default()
                > 299.0
        );
        assert_eq!(
            data.periods.last().unwrap().revenue_yoy_change_percent,
            data.periods
                .last()
                .unwrap()
                .total_revenues_yoy_change_percent
        );
        assert_eq!(
            data.periods.last().unwrap().display_label,
            "FY2026 Q1 (Apr)"
        );
    }

    #[test]
    fn earnings_enumerate_currencies_and_default_to_latest_currency() {
        let data = futures::executor::block_on(earnings_lookup().earnings(
            "MCEM",
            EarningsQuery {
                frequency: Frequency::Annual,
                range: None,
                requested_currency: None,
            },
        ))
        .expect("earnings should load");

        assert_eq!(
            data.available_currencies,
            vec!["EUR".to_string(), "USD".to_string()]
        );
        assert_eq!(data.selected_currency.as_deref(), Some("EUR"));
        assert_eq!(data.periods.len(), 2);
        assert_eq!(data.periods[0].total_revenues, Some(700.0));
    }

    #[test]
    fn earnings_reject_unavailable_requested_currency() {
        let error = futures::executor::block_on(earnings_lookup().earnings(
            "MCEM",
            EarningsQuery {
                frequency: Frequency::Annual,
                range: None,
                requested_currency: Some("GBP".to_string()),
            },
        ))
        .expect_err("missing currency should error");

        assert!(matches!(
            error,
            EdgarLookupError::InvalidCurrencySelection { .. }
        ));
        assert!(error.to_string().contains("Available currencies: EUR, USD"));
    }

    #[test]
    fn earnings_skip_filings_without_requested_currency_and_preserve_ranges() {
        let data = futures::executor::block_on(earnings_lookup().earnings(
            "MCEM",
            EarningsQuery {
                frequency: Frequency::Annual,
                range: Some(EarningsRange {
                    start: EarningsPeriodKey::Annual { fiscal_year: 2023 },
                    end: EarningsPeriodKey::Annual { fiscal_year: 2024 },
                }),
                requested_currency: Some("USD".to_string()),
            },
        ))
        .expect("usd earnings should load");

        assert_eq!(data.available_periods.len(), 2);
        assert_eq!(data.selected_period_start.as_deref(), Some("2023"));
        assert_eq!(data.selected_period_end.as_deref(), Some("2024"));
        assert_eq!(data.periods.len(), 1);
        assert_eq!(data.periods[0].display_label, "FY2024 (Sep)");
        assert_eq!(data.periods[0].total_revenues, Some(500.0));
    }

    #[test]
    fn earnings_supports_explicit_annual_ranges() {
        let data = futures::executor::block_on(earnings_lookup().earnings(
            "EARN",
            EarningsQuery {
                frequency: Frequency::Annual,
                range: Some(EarningsRange {
                    start: EarningsPeriodKey::Annual { fiscal_year: 2022 },
                    end: EarningsPeriodKey::Annual { fiscal_year: 2024 },
                }),
                requested_currency: None,
            },
        ))
        .expect("earnings should load");

        assert_eq!(data.frequency, Frequency::Annual);
        assert_eq!(data.periods.len(), 3);
        assert_eq!(data.selected_period_start.as_deref(), Some("2022"));
        assert_eq!(data.selected_period_end.as_deref(), Some("2024"));
        assert_eq!(data.periods[0].display_label, "FY2022 (Sep)");
        assert_eq!(data.periods[0].period_end, "2022-09-24");
    }

    #[test]
    fn earnings_applies_custom_revenue_mapping() {
        let (mappings_service, _temp_dir) = create_mappings_service();
        futures::executor::block_on(mappings_service.add_mapping(
            CustomConceptMapping::test_mapping(
                TargetField::TotalRevenues,
                CustomConcept {
                    taxonomy: "custom".to_string(),
                    concept: "AlternativeRevenue".to_string(),
                    unit_family: UnitFamily::Currency,
                },
                MappingScope {
                    ticker: Some("CMAP".to_string()),
                    period_start: None,
                    period_end: None,
                },
                None,
            ),
        ))
        .expect("custom mapping should persist");

        let data =
            futures::executor::block_on(earnings_lookup_with_mappings(mappings_service).earnings(
                "CMAP",
                EarningsQuery {
                    frequency: Frequency::Annual,
                    range: None,
                    requested_currency: None,
                },
            ))
            .expect("earnings should load through the custom mapping");

        assert_eq!(data.periods.len(), 1);
        assert_eq!(data.periods[0].total_revenues, Some(555.0));
        assert_eq!(data.periods[0].revenue, Some(555.0));
    }

    #[test]
    fn earnings_custom_mappings_respect_selected_currency() {
        let (mappings_service, _temp_dir) = create_mappings_service();
        futures::executor::block_on(mappings_service.add_mapping(
            CustomConceptMapping::test_mapping(
                TargetField::TotalRevenues,
                CustomConcept {
                    taxonomy: "custom".to_string(),
                    concept: "AlternativeRevenue".to_string(),
                    unit_family: UnitFamily::Currency,
                },
                MappingScope {
                    ticker: Some("MCEM".to_string()),
                    period_start: None,
                    period_end: None,
                },
                None,
            ),
        ))
        .expect("custom multi-currency mapping should persist");

        let eur = futures::executor::block_on(
            earnings_lookup_with_mappings(mappings_service.clone()).earnings(
                "MCEM",
                EarningsQuery {
                    frequency: Frequency::Annual,
                    range: None,
                    requested_currency: Some("EUR".to_string()),
                },
            ),
        )
        .expect("eur earnings should load");
        let usd =
            futures::executor::block_on(earnings_lookup_with_mappings(mappings_service).earnings(
                "MCEM",
                EarningsQuery {
                    frequency: Frequency::Annual,
                    range: None,
                    requested_currency: Some("USD".to_string()),
                },
            ))
            .expect("usd earnings should load");

        assert_eq!(eur.periods[0].total_revenues, Some(705.0));
        assert_eq!(usd.periods[0].total_revenues, Some(505.0));
    }

    #[test]
    fn ifrs_financials_fallback_works() {
        let data = futures::executor::block_on(lookup().financials("SAP", Frequency::Annual))
            .expect("ifrs annual financials should load");
        assert_eq!(data.periods[0].revenue, Some(35200000000.0));
        assert_eq!(data.periods[0].net_income, Some(6200000000.0));
    }
}
