use std::collections::{BTreeMap, HashMap};

use chrono::NaiveDate;

use crate::terminal::{
    CashFlowPeriod, DividendEvent, EarningsPeriod, FilingRef, Frequency, SourceStatus,
    StatementPeriod,
};

use super::service::EdgarLookupError;
use super::types::{
    CompanyConceptFacts, CompanyFactRecord, CompanyFactsResponse, ConceptCandidate, NormalizedFact,
    ParsedXbrlDocument, PeriodRow, ResolvedCompany, UnitFamily,
};

const ANNUAL_FORMS: &[&str] = &["10-K", "20-F", "40-F", "10-K/A", "20-F/A", "40-F/A"];
const QUARTERLY_FORMS: &[&str] = &["10-Q", "6-K", "10-Q/A", "6-K/A"];

pub(crate) const REVENUE_CONCEPTS: &[ConceptCandidate] = &[
    candidate(
        "us-gaap",
        "RevenueFromContractWithCustomerExcludingAssessedTax",
        UnitFamily::Currency,
    ),
    candidate("us-gaap", "SalesRevenueNet", UnitFamily::Currency),
    candidate("us-gaap", "Revenues", UnitFamily::Currency),
    candidate("ifrs-full", "Revenue", UnitFamily::Currency),
];
pub(crate) const GROSS_PROFIT_CONCEPTS: &[ConceptCandidate] =
    &[candidate("us-gaap", "GrossProfit", UnitFamily::Currency)];
pub(crate) const OPERATING_INCOME_CONCEPTS: &[ConceptCandidate] = &[
    candidate("us-gaap", "OperatingIncomeLoss", UnitFamily::Currency),
    candidate(
        "ifrs-full",
        "ProfitLossFromOperatingActivities",
        UnitFamily::Currency,
    ),
];
pub(crate) const NET_INCOME_CONCEPTS: &[ConceptCandidate] = &[
    candidate("us-gaap", "NetIncomeLoss", UnitFamily::Currency),
    candidate("ifrs-full", "ProfitLoss", UnitFamily::Currency),
];
pub(crate) const DILUTED_EPS_CONCEPTS: &[ConceptCandidate] = &[
    candidate(
        "us-gaap",
        "EarningsPerShareDiluted",
        UnitFamily::CurrencyPerShare,
    ),
    candidate(
        "ifrs-full",
        "BasicAndDilutedEarningsLossPerShare",
        UnitFamily::CurrencyPerShare,
    ),
    candidate(
        "ifrs-full",
        "DilutedEarningsLossPerShare",
        UnitFamily::CurrencyPerShare,
    ),
];
pub(crate) const BASIC_EPS_CONCEPTS: &[ConceptCandidate] = &[
    candidate(
        "us-gaap",
        "EarningsPerShareBasic",
        UnitFamily::CurrencyPerShare,
    ),
    candidate(
        "ifrs-full",
        "BasicEarningsLossPerShare",
        UnitFamily::CurrencyPerShare,
    ),
];
pub(crate) const CASH_CONCEPTS: &[ConceptCandidate] = &[
    candidate(
        "us-gaap",
        "CashAndCashEquivalentsAtCarryingValue",
        UnitFamily::Currency,
    ),
    candidate("ifrs-full", "CashAndCashEquivalents", UnitFamily::Currency),
];
pub(crate) const ASSET_CONCEPTS: &[ConceptCandidate] = &[
    candidate("us-gaap", "Assets", UnitFamily::Currency),
    candidate("ifrs-full", "Assets", UnitFamily::Currency),
];
pub(crate) const LIABILITY_CONCEPTS: &[ConceptCandidate] = &[
    candidate("us-gaap", "Liabilities", UnitFamily::Currency),
    candidate("ifrs-full", "Liabilities", UnitFamily::Currency),
];
pub(crate) const EQUITY_CONCEPTS: &[ConceptCandidate] = &[
    candidate(
        "us-gaap",
        "StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest",
        UnitFamily::Currency,
    ),
    candidate("us-gaap", "StockholdersEquity", UnitFamily::Currency),
    candidate("ifrs-full", "Equity", UnitFamily::Currency),
];
pub(crate) const SHARES_CONCEPTS: &[ConceptCandidate] = &[
    candidate(
        "dei",
        "EntityCommonStockSharesOutstanding",
        UnitFamily::Shares,
    ),
    candidate(
        "us-gaap",
        "CommonStockSharesOutstanding",
        UnitFamily::Shares,
    ),
    candidate("ifrs-full", "NumberOfSharesOutstanding", UnitFamily::Shares),
];
pub(crate) const CFO_CONCEPTS: &[ConceptCandidate] = &[
    candidate(
        "us-gaap",
        "NetCashProvidedByUsedInOperatingActivities",
        UnitFamily::Currency,
    ),
    candidate(
        "ifrs-full",
        "CashFlowsFromUsedInOperatingActivities",
        UnitFamily::Currency,
    ),
];
pub(crate) const CFI_CONCEPTS: &[ConceptCandidate] = &[
    candidate(
        "us-gaap",
        "NetCashProvidedByUsedInInvestingActivities",
        UnitFamily::Currency,
    ),
    candidate(
        "ifrs-full",
        "CashFlowsFromUsedInInvestingActivities",
        UnitFamily::Currency,
    ),
];
pub(crate) const CFF_CONCEPTS: &[ConceptCandidate] = &[
    candidate(
        "us-gaap",
        "NetCashProvidedByUsedInFinancingActivities",
        UnitFamily::Currency,
    ),
    candidate(
        "ifrs-full",
        "CashFlowsFromUsedInFinancingActivities",
        UnitFamily::Currency,
    ),
];
pub(crate) const CAPEX_CONCEPTS: &[ConceptCandidate] = &[
    candidate(
        "us-gaap",
        "PaymentsToAcquirePropertyPlantAndEquipment",
        UnitFamily::Currency,
    ),
    candidate(
        "us-gaap",
        "PropertyPlantAndEquipmentAdditions",
        UnitFamily::Currency,
    ),
    candidate(
        "ifrs-full",
        "PurchaseOfPropertyPlantAndEquipmentClassifiedAsInvestingActivities",
        UnitFamily::Currency,
    ),
];
pub(crate) const ENDING_CASH_CONCEPTS: &[ConceptCandidate] = &[
    candidate(
        "us-gaap",
        "CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents",
        UnitFamily::Currency,
    ),
    candidate(
        "us-gaap",
        "CashAndCashEquivalentsAtCarryingValue",
        UnitFamily::Currency,
    ),
    candidate("ifrs-full", "CashAndCashEquivalents", UnitFamily::Currency),
];
pub(crate) const DIVIDEND_PER_SHARE_CONCEPTS: &[ConceptCandidate] = &[
    candidate(
        "us-gaap",
        "CommonStockDividendsPerShareDeclared",
        UnitFamily::CurrencyPerShare,
    ),
    candidate(
        "us-gaap",
        "CommonStockDividendsPerShareCashPaid",
        UnitFamily::CurrencyPerShare,
    ),
];
pub(crate) const DIVIDEND_CASH_CONCEPTS: &[ConceptCandidate] = &[
    candidate(
        "us-gaap",
        "PaymentsOfDividendsCommonStock",
        UnitFamily::Currency,
    ),
    candidate("us-gaap", "DividendsCash", UnitFamily::Currency),
    candidate(
        "ifrs-full",
        "DividendsPaidClassifiedAsFinancingActivities",
        UnitFamily::Currency,
    ),
];
pub(crate) const DILUTED_SHARES_CONCEPTS: &[ConceptCandidate] = &[
    candidate(
        "us-gaap",
        "WeightedAverageNumberOfDilutedSharesOutstanding",
        UnitFamily::Shares,
    ),
    candidate(
        "ifrs-full",
        "WeightedAverageNumberOfSharesOutstandingDiluted",
        UnitFamily::Shares,
    ),
];

const fn candidate(
    taxonomy: &'static str,
    concept: &'static str,
    unit_family: UnitFamily,
) -> ConceptCandidate {
    ConceptCandidate {
        taxonomy,
        concept,
        unit_family,
    }
}

pub(crate) fn select_latest_filing(
    filings: &[FilingRef],
    frequency: Frequency,
) -> Option<FilingRef> {
    filings
        .iter()
        .filter(|filing| matches_frequency_form(&filing.form, frequency))
        .cloned()
        .max_by(compare_filing_priority)
}

pub(crate) fn normalize_all_facts(
    company_facts: &CompanyFactsResponse,
) -> Result<Vec<NormalizedFact>, EdgarLookupError> {
    let mut normalized = Vec::new();

    for concept in flatten_concepts(company_facts) {
        for (unit, records) in concept.units {
            let unit_family = classify_unit(unit);
            for record in records {
                let Some(value) = json_number(&record.val) else {
                    continue;
                };

                normalized.push(NormalizedFact {
                    taxonomy: concept.taxonomy,
                    concept: concept.concept_name.clone(),
                    unit_family,
                    value,
                    filed: record.filed.clone(),
                    form: record.form.clone(),
                    fiscal_year: record.fy.as_ref().map(fiscal_year_to_string),
                    fiscal_period: record.fp.clone(),
                    period_start: record.start.clone(),
                    period_end: record.end.clone(),
                    accession_number: record.accn.clone(),
                });
            }
        }
    }

    if normalized.is_empty() {
        return Err(EdgarLookupError::NoFactsAvailable);
    }

    Ok(normalized)
}

pub(crate) fn build_statement_periods(
    facts: &[NormalizedFact],
    frequency: Frequency,
    period_limit: usize,
    latest_xbrl: Option<&ParsedXbrlDocument>,
) -> Vec<StatementPeriod> {
    let rows = build_period_rows(facts, frequency, REVENUE_CONCEPTS, period_limit);

    rows.into_iter()
        .enumerate()
        .map(|(index, row)| {
            let latest_xbrl = (index == 0).then_some(latest_xbrl).flatten();
            StatementPeriod {
                label: row.label.clone(),
                fiscal_year: row.fiscal_year.clone(),
                fiscal_period: row.fiscal_period.clone(),
                period_start: row.period_start.clone(),
                period_end: row.period_end.clone(),
                filed_date: row.filed_date.clone(),
                form: row.form.clone(),
                revenue: value_for_period(facts, &row, REVENUE_CONCEPTS, latest_xbrl),
                gross_profit: value_for_period(facts, &row, GROSS_PROFIT_CONCEPTS, latest_xbrl),
                operating_income: value_for_period(
                    facts,
                    &row,
                    OPERATING_INCOME_CONCEPTS,
                    latest_xbrl,
                ),
                net_income: value_for_period(facts, &row, NET_INCOME_CONCEPTS, latest_xbrl),
                diluted_eps: value_for_period(facts, &row, DILUTED_EPS_CONCEPTS, latest_xbrl),
                cash_and_equivalents: value_for_period(facts, &row, CASH_CONCEPTS, latest_xbrl),
                total_assets: value_for_period(facts, &row, ASSET_CONCEPTS, latest_xbrl),
                total_liabilities: value_for_period(facts, &row, LIABILITY_CONCEPTS, latest_xbrl),
                total_equity: value_for_period(facts, &row, EQUITY_CONCEPTS, latest_xbrl),
                shares_outstanding: value_for_period(facts, &row, SHARES_CONCEPTS, latest_xbrl),
            }
        })
        .collect()
}

pub(crate) fn build_cash_flow_periods(
    facts: &[NormalizedFact],
    frequency: Frequency,
    latest_xbrl: Option<&ParsedXbrlDocument>,
) -> Vec<CashFlowPeriod> {
    build_period_rows(facts, frequency, CFO_CONCEPTS, 4)
        .into_iter()
        .enumerate()
        .map(|(index, row)| {
            let latest_xbrl = (index == 0).then_some(latest_xbrl).flatten();
            let capex = value_for_period(facts, &row, CAPEX_CONCEPTS, latest_xbrl);
            let operating_cash_flow = value_for_period(facts, &row, CFO_CONCEPTS, latest_xbrl);
            CashFlowPeriod {
                label: row.label.clone(),
                fiscal_year: row.fiscal_year.clone(),
                fiscal_period: row.fiscal_period.clone(),
                period_start: row.period_start.clone(),
                period_end: row.period_end.clone(),
                filed_date: row.filed_date.clone(),
                form: row.form.clone(),
                operating_cash_flow,
                investing_cash_flow: value_for_period(facts, &row, CFI_CONCEPTS, latest_xbrl),
                financing_cash_flow: value_for_period(facts, &row, CFF_CONCEPTS, latest_xbrl),
                capex,
                free_cash_flow: match (operating_cash_flow, capex) {
                    (Some(cfo), Some(capex)) => Some(cfo - capex.abs()),
                    _ => None,
                },
                ending_cash: value_for_period(facts, &row, ENDING_CASH_CONCEPTS, latest_xbrl),
            }
        })
        .collect()
}

pub(crate) fn build_dividend_events(
    facts: &[NormalizedFact],
    latest_xbrl: Option<&ParsedXbrlDocument>,
) -> Vec<DividendEvent> {
    let rows = build_period_rows(facts, Frequency::Quarterly, DIVIDEND_PER_SHARE_CONCEPTS, 8);

    let mut events = rows
        .into_iter()
        .enumerate()
        .map(|(index, row)| {
            let latest_xbrl = (index == 0).then_some(latest_xbrl).flatten();
            DividendEvent {
                end_date: row.period_end.clone(),
                filed_date: row.filed_date.clone(),
                form: row.form.clone(),
                frequency_guess: "unknown".to_string(),
                dividend_per_share: value_for_period(
                    facts,
                    &row,
                    DIVIDEND_PER_SHARE_CONCEPTS,
                    latest_xbrl,
                ),
                total_cash_dividends: value_for_period(
                    facts,
                    &row,
                    DIVIDEND_CASH_CONCEPTS,
                    latest_xbrl,
                ),
            }
        })
        .collect::<Vec<_>>();

    for index in 0..events.len() {
        let frequency_guess = classify_dividend_frequency(
            events.get(index).map(|value| value.end_date.as_str()),
            events.get(index + 1).map(|value| value.end_date.as_str()),
        );
        if let Some(event) = events.get_mut(index) {
            event.frequency_guess = frequency_guess;
        }
    }

    events
}

pub(crate) fn build_earnings_periods(
    facts: &[NormalizedFact],
    frequency: Frequency,
    latest_xbrl: Option<&ParsedXbrlDocument>,
) -> Vec<EarningsPeriod> {
    let limit = match frequency {
        Frequency::Annual => 4,
        Frequency::Quarterly => 8,
    };

    let rows = build_period_rows(facts, frequency, REVENUE_CONCEPTS, limit);

    let mut periods = rows
        .into_iter()
        .enumerate()
        .map(|(index, row)| {
            let latest_xbrl = (index == 0).then_some(latest_xbrl).flatten();
            EarningsPeriod {
                label: row.label.clone(),
                fiscal_year: row.fiscal_year.clone(),
                fiscal_period: row.fiscal_period.clone(),
                period_start: row.period_start.clone(),
                period_end: row.period_end.clone(),
                filed_date: row.filed_date.clone(),
                form: row.form.clone(),
                revenue: value_for_period(facts, &row, REVENUE_CONCEPTS, latest_xbrl),
                net_income: value_for_period(facts, &row, NET_INCOME_CONCEPTS, latest_xbrl),
                basic_eps: value_for_period(facts, &row, BASIC_EPS_CONCEPTS, latest_xbrl),
                diluted_eps: value_for_period(facts, &row, DILUTED_EPS_CONCEPTS, latest_xbrl),
                diluted_weighted_average_shares: value_for_period(
                    facts,
                    &row,
                    DILUTED_SHARES_CONCEPTS,
                    latest_xbrl,
                ),
                revenue_yoy_change_percent: None,
                diluted_eps_yoy_change_percent: None,
            }
        })
        .collect::<Vec<_>>();

    let offset = match frequency {
        Frequency::Annual => 1,
        Frequency::Quarterly => 4,
    };

    for index in 0..periods.len() {
        let reference = periods.get(index + offset).cloned();
        if let (Some(current), Some(previous)) = (periods.get(index).cloned(), reference) {
            if let Some(period) = periods.get_mut(index) {
                period.revenue_yoy_change_percent =
                    calculate_change_percent(current.revenue, previous.revenue);
                period.diluted_eps_yoy_change_percent =
                    calculate_change_percent(current.diluted_eps, previous.diluted_eps);
            }
        }
    }

    periods
}

pub(crate) fn build_source_status(
    latest_xbrl: Result<Option<&ParsedXbrlDocument>, &EdgarLookupError>,
) -> SourceStatus {
    match latest_xbrl {
        Ok(Some(_)) => SourceStatus {
            companyfacts_used: true,
            latest_xbrl_parsed: true,
            degraded_reason: None,
        },
        Ok(None) => SourceStatus {
            companyfacts_used: true,
            latest_xbrl_parsed: false,
            degraded_reason: Some("Latest filing XBRL instance was unavailable.".to_string()),
        },
        Err(error) => SourceStatus {
            companyfacts_used: true,
            latest_xbrl_parsed: false,
            degraded_reason: Some(error.to_string()),
        },
    }
}

pub(crate) fn company_name<'a>(
    resolved_company: &'a ResolvedCompany,
    filings_name: Option<&'a str>,
    companyfacts_name: Option<&'a str>,
) -> String {
    filings_name
        .filter(|value| !value.trim().is_empty())
        .or(companyfacts_name.filter(|value| !value.trim().is_empty()))
        .unwrap_or(&resolved_company.company_name)
        .to_string()
}

pub(crate) fn limit_dividend_ttm(events: &[DividendEvent]) -> (Option<f64>, Option<f64>) {
    let total_per_share = events
        .iter()
        .take(4)
        .filter_map(|event| event.dividend_per_share)
        .reduce(|acc, value| acc + value);
    let total_cash = events
        .iter()
        .take(4)
        .filter_map(|event| event.total_cash_dividends)
        .reduce(|acc, value| acc + value);

    (total_per_share, total_cash)
}

fn flatten_concepts(company_facts: &CompanyFactsResponse) -> Vec<FlattenedConcept<'_>> {
    let mut concepts = Vec::new();
    concepts.extend(flatten_taxonomy("us-gaap", &company_facts.facts.us_gaap));
    concepts.extend(flatten_taxonomy(
        "ifrs-full",
        &company_facts.facts.ifrs_full,
    ));
    concepts.extend(flatten_taxonomy("dei", &company_facts.facts.dei));
    concepts
}

fn flatten_taxonomy<'a>(
    taxonomy: &'static str,
    facts: &'a HashMap<String, CompanyConceptFacts>,
) -> Vec<FlattenedConcept<'a>> {
    facts
        .iter()
        .map(|(concept_name, concept)| FlattenedConcept {
            taxonomy,
            concept_name: concept_name.clone(),
            units: &concept.units,
        })
        .collect()
}

struct FlattenedConcept<'a> {
    taxonomy: &'static str,
    concept_name: String,
    units: &'a HashMap<String, Vec<CompanyFactRecord>>,
}

fn classify_unit(unit: &str) -> UnitFamily {
    let normalized = unit.to_ascii_uppercase();
    if normalized.contains("USD/SHARE") || normalized.contains("USD-PER-SHARES") {
        UnitFamily::CurrencyPerShare
    } else if normalized == "SHARES" || normalized.ends_with(":SHARES") {
        UnitFamily::Shares
    } else if normalized == "PURE" {
        UnitFamily::Pure
    } else {
        UnitFamily::Currency
    }
}

fn json_number(value: &serde_json::Value) -> Option<f64> {
    match value {
        serde_json::Value::Number(number) => number.as_f64(),
        serde_json::Value::String(string) => string.parse::<f64>().ok(),
        _ => None,
    }
}

fn fiscal_year_to_string(value: &serde_json::Value) -> String {
    match value {
        serde_json::Value::Number(number) => number.to_string(),
        serde_json::Value::String(value) => value.clone(),
        _ => String::new(),
    }
}

fn matches_frequency_form(form: &str, frequency: Frequency) -> bool {
    match frequency {
        Frequency::Annual => ANNUAL_FORMS.contains(&form),
        Frequency::Quarterly => QUARTERLY_FORMS.contains(&form),
    }
}

fn compare_filing_priority(left: &FilingRef, right: &FilingRef) -> std::cmp::Ordering {
    left.filing_date
        .cmp(&right.filing_date)
        .then_with(|| amended_rank(&right.form).cmp(&amended_rank(&left.form)))
}

fn amended_rank(form: &str) -> usize {
    usize::from(form.contains("/A"))
}

fn build_period_rows(
    facts: &[NormalizedFact],
    frequency: Frequency,
    anchor_concepts: &[ConceptCandidate],
    period_limit: usize,
) -> Vec<PeriodRow> {
    let anchored = facts
        .iter()
        .filter(|fact| is_fact_for_frequency(fact, frequency))
        .filter(|fact| concept_match(anchor_concepts, fact))
        .map(PeriodRow::from_fact)
        .collect::<Vec<_>>();

    if !anchored.is_empty() {
        return dedupe_rows(anchored, period_limit);
    }

    dedupe_rows(
        facts
            .iter()
            .filter(|fact| is_fact_for_frequency(fact, frequency))
            .map(PeriodRow::from_fact)
            .collect(),
        period_limit,
    )
}

fn dedupe_rows(rows: Vec<PeriodRow>, period_limit: usize) -> Vec<PeriodRow> {
    let mut grouped = BTreeMap::<String, PeriodRow>::new();

    for row in rows {
        grouped
            .entry(format!(
                "{}:{}:{}",
                row.fiscal_year.clone().unwrap_or_default(),
                row.fiscal_period.clone().unwrap_or_default(),
                row.period_end
            ))
            .and_modify(|existing| {
                if row.filed_date > existing.filed_date {
                    *existing = row.clone();
                }
            })
            .or_insert(row);
    }

    let mut values = grouped.into_values().collect::<Vec<_>>();
    values.sort_by(|left, right| right.period_end.cmp(&left.period_end));
    values.truncate(period_limit);
    values
}

fn is_fact_for_frequency(fact: &NormalizedFact, frequency: Frequency) -> bool {
    if !matches_frequency_form(&fact.form, frequency) {
        return false;
    }

    match frequency {
        Frequency::Annual => {
            fact.fiscal_period.as_deref() == Some("FY")
                || duration_days(fact).is_none_or(|days| days >= 250)
                || fact.period_start.is_none()
        }
        Frequency::Quarterly => {
            fact.fiscal_period
                .as_deref()
                .is_some_and(|period| period.starts_with('Q'))
                || duration_days(fact).is_none_or(|days| days <= 120)
                || fact.period_start.is_none()
        }
    }
}

fn duration_days(fact: &NormalizedFact) -> Option<i64> {
    let start = fact.period_start.as_ref()?;
    let start = NaiveDate::parse_from_str(start, "%Y-%m-%d").ok()?;
    let end = NaiveDate::parse_from_str(&fact.period_end, "%Y-%m-%d").ok()?;
    Some((end - start).num_days().abs())
}

fn concept_match(candidates: &[ConceptCandidate], fact: &NormalizedFact) -> bool {
    candidates.iter().any(|candidate| {
        candidate.taxonomy == fact.taxonomy
            && candidate.concept == fact.concept
            && candidate.unit_family == fact.unit_family
    })
}

fn value_for_period(
    facts: &[NormalizedFact],
    row: &PeriodRow,
    concepts: &[ConceptCandidate],
    latest_xbrl: Option<&ParsedXbrlDocument>,
) -> Option<f64> {
    let mut best = facts
        .iter()
        .filter(|fact| concept_match(concepts, fact))
        .filter(|fact| fact.period_end == row.period_end)
        .filter(|fact| fact.fiscal_year == row.fiscal_year || row.fiscal_year.is_none())
        .max_by(|left, right| {
            left.filed
                .cmp(&right.filed)
                .then_with(|| left.accession_number.cmp(&right.accession_number))
        })
        .map(|fact| fact.value);

    if let Some(latest_xbrl) = latest_xbrl {
        if let Some(value) = overlay_xbrl_value(latest_xbrl, concepts, &row.period_end) {
            best = Some(value);
        }
    }

    best
}

fn overlay_xbrl_value(
    latest_xbrl: &ParsedXbrlDocument,
    concepts: &[ConceptCandidate],
    period_end: &str,
) -> Option<f64> {
    latest_xbrl
        .facts
        .iter()
        .filter(|fact| fact.period_end.as_deref() == Some(period_end))
        .find(|fact| {
            concepts.iter().any(|candidate| {
                fact.concept
                    .eq_ignore_ascii_case(&format!("{}:{}", candidate.taxonomy, candidate.concept))
                    || fact.concept.eq_ignore_ascii_case(candidate.concept)
            })
        })
        .map(|fact| fact.value)
}

fn classify_dividend_frequency(current_end: Option<&str>, previous_end: Option<&str>) -> String {
    let Some(current_end) = current_end else {
        return "unknown".to_string();
    };
    let Some(previous_end) = previous_end else {
        return "unknown".to_string();
    };
    let Ok(current) = NaiveDate::parse_from_str(current_end, "%Y-%m-%d") else {
        return "unknown".to_string();
    };
    let Ok(previous) = NaiveDate::parse_from_str(previous_end, "%Y-%m-%d") else {
        return "unknown".to_string();
    };
    let spacing = (current - previous).num_days().abs();
    if spacing <= 120 {
        "quarterly".to_string()
    } else if spacing <= 220 {
        "semiannual".to_string()
    } else if spacing <= 420 {
        "annual".to_string()
    } else {
        "special".to_string()
    }
}

fn calculate_change_percent(current: Option<f64>, previous: Option<f64>) -> Option<f64> {
    let current = current?;
    let previous = previous?;
    if previous.abs() < f64::EPSILON {
        return None;
    }
    Some(((current - previous) / previous.abs()) * 100.0)
}
