mod catalog;
mod concepts;
mod dei;
mod facts;
mod mapper;
mod package;
mod presentation;

use crate::mappings::CustomConceptMapping;
use crate::terminal::types::AvailableXbrlConcept;
use crate::terminal::{
    EarningsPanelData, EarningsPeriod, EarningsPeriodOption, FilingRef, Frequency, SourceStatus,
};

use super::client::SecEdgarClient;
use super::facts::{company_name, default_earnings_period_limit};
use super::service::EdgarLookupError;
use super::types::{CompanySubmissions, EarningsQuery, ResolvedCompany};
use concepts::extract_available_concepts;
use mapper::{is_clearly_quarter_length, FilingEarningsRecord};

pub(crate) async fn load_concepts_for_ticker(
    client: &SecEdgarClient,
    ticker: &str,
) -> Result<Vec<AvailableXbrlConcept>, EdgarLookupError> {
    let company = client.resolve_company(ticker).await?;
    let submissions = client.load_submissions(&company.cik).await?;

    let mut concepts = Vec::<AvailableXbrlConcept>::new();

    for frequency in [Frequency::Annual, Frequency::Quarterly] {
        let filings = catalog::load_candidate_filings(client, &company.cik, &submissions, frequency)
            .await
            .unwrap_or_default();

        for filing in &filings {
            if concepts.len() >= 2000 {
                break;
            }

            let package = match package::load_filing_package(client, &company.cik, filing).await {
                Ok(pkg) => pkg,
                Err(_) => continue,
            };

            let filing_concepts = extract_available_concepts(&package.document, &filing.accession_number);
            concepts.extend(filing_concepts);

            if !concepts.is_empty() {
                break;
            }
        }

        if !concepts.is_empty() {
            break;
        }
    }

    let mut seen = std::collections::HashSet::<(String, String, String)>::new();
    concepts.retain(|c| seen.insert((c.taxonomy.clone(), c.concept.clone(), c.unit_family.clone())));

    Ok(concepts)
}

#[derive(Debug, Clone)]
pub(crate) struct FilingNativeEarningsData {
    pub periods: Vec<EarningsPeriod>,
    pub available_periods: Vec<EarningsPeriodOption>,
    pub selected_period_start: Option<String>,
    pub selected_period_end: Option<String>,
    pub latest_filing: Option<FilingRef>,
    pub source_status: SourceStatus,
    pub available_concepts: Vec<AvailableXbrlConcept>,
}

pub(crate) async fn load_earnings_data(
    client: &SecEdgarClient,
    company: &ResolvedCompany,
    submissions: &CompanySubmissions,
    query: &EarningsQuery,
    custom_mappings: &[CustomConceptMapping],
) -> Result<FilingNativeEarningsData, EdgarLookupError> {
    let filings =
        catalog::load_candidate_filings(client, &company.cik, submissions, query.frequency).await?;
    if filings.is_empty() {
        return Err(EdgarLookupError::NoEligibleFilings {
            ticker: company.ticker.clone(),
            frequency: query.frequency,
        });
    }

    let mut records = Vec::<FilingEarningsRecord>::new();
    let mut skipped_reasons = Vec::<String>::new();
    let mut seen_periods = std::collections::HashSet::<String>::new();
    let mut all_available_concepts = Vec::<AvailableXbrlConcept>::new();

    for filing in filings {
        let package = match package::load_filing_package(client, &company.cik, &filing).await {
            Ok(package) => package,
            Err(error) => {
                skipped_reasons.push(error.to_string());
                continue;
            }
        };
        let Some(identity) = dei::extract_period_identity(&package.document, query.frequency)
        else {
            skipped_reasons.push(format!(
                "Skipped {} because DEI fiscal period metadata was unavailable.",
                filing.accession_number
            ));
            continue;
        };
        let Some(record) = mapper::map_filing_to_record(
            &package.document,
            &filing,
            &identity,
            query.frequency,
            &company.ticker,
            custom_mappings,
        ) else {
            skipped_reasons.push(format!(
                "Skipped {} because no direct filing-native income statement facts matched the filing period.",
                filing.accession_number
            ));
            continue;
        };

        if filing.form.starts_with("6-K") && !is_clearly_quarter_length(&record.period) {
            skipped_reasons.push(format!(
                "Skipped {} because the filing duration was not a clear fiscal quarter.",
                filing.accession_number
            ));
            continue;
        }

        if seen_periods.insert(record.option.id.clone()) {
            // Extract available concepts from this filing
            let filing_concepts = extract_available_concepts(&package.document, &record.option.id);
            all_available_concepts.extend(filing_concepts);

            records.push(record);
        }
    }

    if records.is_empty() {
        let detail = if skipped_reasons.is_empty() {
            format!(
                "No eligible {} filings were found for {}.",
                query.frequency.as_str(),
                company.ticker
            )
        } else {
            format!(
                "No eligible {} filings were found for {}. {}",
                query.frequency.as_str(),
                company.ticker,
                summarize_skips(&skipped_reasons).unwrap_or_default()
            )
        };
        return Err(EdgarLookupError::InvalidPeriodRange { detail });
    }

    records.sort_by(|left, right| right.option.sort_key.cmp(&left.option.sort_key));

    let available_periods = records
        .iter()
        .rev()
        .map(|record| record.option.clone())
        .collect::<Vec<_>>();

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

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

    let mut selected = records
        .into_iter()
        .filter(|record| match requested_range.as_ref() {
            Some((start, end)) => record.option.id >= *start && record.option.id <= *end,
            None => true,
        })
        .collect::<Vec<_>>();

    if requested_range.is_none() {
        selected.truncate(default_earnings_period_limit(query.frequency));
    }

    apply_yoy_metrics(&mut selected, query.frequency);

    let periods = selected
        .iter()
        .map(|record| record.period.clone())
        .collect::<Vec<_>>();
    let selected_period_start = selected.last().map(|record| record.option.id.clone());
    let selected_period_end = selected.first().map(|record| record.option.id.clone());
    let latest_filing = selected
        .first()
        .map(|record| record.filing.clone())
        .or_else(|| available_latest_filing(&available_periods, &selected));

    Ok(FilingNativeEarningsData {
        periods,
        available_periods,
        selected_period_start,
        selected_period_end,
        latest_filing,
        source_status: SourceStatus {
            companyfacts_used: false,
            degraded_reason: summarize_skips(&skipped_reasons),
        },
        available_concepts: all_available_concepts,
    })
}

pub(crate) fn build_panel_data(
    company: &ResolvedCompany,
    submissions: &CompanySubmissions,
    query: &EarningsQuery,
    data: FilingNativeEarningsData,
) -> EarningsPanelData {
    EarningsPanelData {
        symbol: company.ticker.clone(),
        company_name: company_name(company, Some(&submissions.name), None),
        cik: company.cik.clone(),
        frequency: query.frequency,
        periods: data.periods,
        available_periods: data.available_periods,
        selected_period_start: data.selected_period_start,
        selected_period_end: data.selected_period_end,
        latest_filing: data.latest_filing,
        source_status: data.source_status,
        available_concepts: data.available_concepts,
    }
}

fn apply_yoy_metrics(records: &mut [FilingEarningsRecord], frequency: Frequency) {
    let offset = match frequency {
        Frequency::Annual => 1,
        Frequency::Quarterly => 4,
    };

    for index in 0..records.len() {
        let Some(previous) = records
            .get(index + offset)
            .map(|record| (record.period.total_revenues, record.period.diluted_eps))
        else {
            continue;
        };
        let Some(current) = records.get_mut(index).map(|record| &mut record.period) else {
            continue;
        };

        current.total_revenues_yoy_change_percent =
            calculate_change_percent(current.total_revenues, previous.0);
        current.revenue_yoy_change_percent = current.total_revenues_yoy_change_percent;
        current.diluted_eps_yoy_change_percent =
            calculate_change_percent(current.diluted_eps, previous.1);
    }
}

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

fn summarize_skips(skipped_reasons: &[String]) -> Option<String> {
    if skipped_reasons.is_empty() {
        return None;
    }

    let preview = skipped_reasons
        .iter()
        .take(2)
        .cloned()
        .collect::<Vec<_>>()
        .join(" ");
    let suffix = if skipped_reasons.len() > 2 {
        format!(
            " {} additional filing(s) were skipped.",
            skipped_reasons.len() - 2
        )
    } else {
        String::new()
    };

    Some(format!(
        "Filing-native earnings skipped {} filing(s). {preview}{suffix}",
        skipped_reasons.len()
    ))
}

fn available_latest_filing(
    _available_periods: &[EarningsPeriodOption],
    selected: &[FilingEarningsRecord],
) -> Option<FilingRef> {
    selected.first().map(|record| record.filing.clone())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn calculate_change_percent_should_only_use_direct_values() {
        assert_eq!(
            calculate_change_percent(Some(120.0), Some(100.0)),
            Some(20.0)
        );
        assert_eq!(calculate_change_percent(Some(120.0), None), None);
    }
}
