//! Bridge real: `tahuantinsuyu_model::Chart` → eternal_astrology → [`RenderModel`].
//!
//! La sesión de efemérides VSOP2013 es **compartida globalmente** vía
//! `OnceLock` — abrirla cuesta unos cuantos ms (carga de las series en
//! memoria), y como es read-only se puede leer en paralelo desde varios
//! cómputos.

use std::sync::OnceLock;
use std::time::Instant;

use eternal_astrology::{
    find_aspects, find_synastry_aspects, next_return, secondary_progression, solar_arc_true,
    Aspect, AspectKind as EAspectKind, BirthData, BodySet, ChartConfig, HouseSystem as EHouseSystem,
    NatalChart, OrbTable, Zodiac as EZodiac,
};
use eternal_sky::{Ayanamsha, Body, EphemerisSession, Instant as ESInstant, Observer, SessionConfig};

use tahuantinsuyu_model::{Chart, HouseSystem, StoredChartConfig, Zodiac};

use crate::{EngineError, Geometry, Glyph, Layer, LayerKind, LineSeg, RenderModel};

// =====================================================================
// Sesión global cacheada
// =====================================================================

static SESSION: OnceLock<EphemerisSession> = OnceLock::new();

fn session() -> Result<&'static EphemerisSession, EngineError> {
    if let Some(s) = SESSION.get() {
        return Ok(s);
    }
    let opened = EphemerisSession::open(SessionConfig::vsop2013())
        .map_err(|e| EngineError::Eternal(format!("EphemerisSession::open: {:?}", e)))?;
    // Si otro thread ya pobló la celda mientras abríamos, el set_once
    // falla silenciosamente — usamos el que quedó dentro.
    let _ = SESSION.set(opened);
    Ok(SESSION.get().expect("session was just set"))
}

// =====================================================================
// Traducciones Stored* → eternal
// =====================================================================

fn map_house_system(h: HouseSystem) -> EHouseSystem {
    match h {
        HouseSystem::Placidus => EHouseSystem::Placidus,
        HouseSystem::Koch => EHouseSystem::Koch,
        HouseSystem::Regiomontanus => EHouseSystem::Regiomontanus,
        HouseSystem::Campanus => EHouseSystem::Campanus,
        HouseSystem::Porphyry => EHouseSystem::Porphyry,
        HouseSystem::Equal => EHouseSystem::Equal,
        HouseSystem::WholeSign => EHouseSystem::WholeSign,
    }
}

fn map_zodiac(z: Zodiac, ayanamsha_hint: Option<&str>) -> EZodiac {
    match z {
        Zodiac::Tropical => EZodiac::Tropical,
        Zodiac::Sidereal => {
            let mode = match ayanamsha_hint.unwrap_or("lahiri").to_ascii_lowercase().as_str() {
                "fagan_bradley" | "fagan-bradley" | "faganbradley" => Ayanamsha::FaganBradley,
                "raman" => Ayanamsha::Raman,
                "krishnamurti" => Ayanamsha::Krishnamurti,
                "de_luce" | "deluce" => Ayanamsha::DeLuce,
                "djwhal_khul" | "djwhalkhul" => Ayanamsha::DjwhalKhul,
                "ushashashi" => Ayanamsha::Ushashashi,
                "yukteshwar" => Ayanamsha::Yukteshwar,
                _ => Ayanamsha::Lahiri,
            };
            EZodiac::Sidereal(mode)
        }
        // Dracónico aún no soportado en eternal — caemos a tropical por
        // ahora; cuando eternal lo agregue, lo cableamos acá.
        Zodiac::Draconic => EZodiac::Tropical,
    }
}

fn map_body_set(cfg: &StoredChartConfig) -> BodySet {
    let mut bodies: Vec<Body> = Vec::new();
    for name in &cfg.bodies {
        if let Some(b) = map_body(name) {
            bodies.push(b);
        }
    }
    if bodies.is_empty() {
        // Default razonable si el config vino vacío.
        return BodySet::classical_modern();
    }
    let mut set = BodySet {
        bodies,
        include_south_node: cfg.include_south_node,
    };
    if cfg.include_lilith {
        set = set.with_lilith();
    }
    if cfg.include_main_belt_asteroids {
        set = set.with_main_belt_asteroids();
    }
    set
}

fn map_body(name: &str) -> Option<Body> {
    Some(match name.to_ascii_lowercase().as_str() {
        "sun" => Body::Sun,
        "moon" => Body::Moon,
        "mercury" => Body::Mercury,
        "venus" => Body::Venus,
        "mars" => Body::Mars,
        "jupiter" => Body::Jupiter,
        "saturn" => Body::Saturn,
        "uranus" => Body::Uranus,
        "neptune" => Body::Neptune,
        "pluto" => Body::Pluto,
        "mean_node" | "meannode" => Body::MeanNode,
        "true_node" | "truenode" => Body::TrueNode,
        "mean_lilith" | "lilith" => Body::MeanLilith,
        "true_lilith" => Body::TrueLilith,
        "ceres" => Body::Ceres,
        "pallas" => Body::Pallas,
        "juno" => Body::Juno,
        "vesta" => Body::Vesta,
        _ => return None,
    })
}

fn body_symbol(b: Body) -> &'static str {
    match b {
        Body::Sun => "sun",
        Body::Moon => "moon",
        Body::Mercury => "mercury",
        Body::Venus => "venus",
        Body::Mars => "mars",
        Body::Jupiter => "jupiter",
        Body::Saturn => "saturn",
        Body::Uranus => "uranus",
        Body::Neptune => "neptune",
        Body::Pluto => "pluto",
        Body::MeanNode => "north_node",
        Body::TrueNode => "north_node",
        Body::MeanLilith => "lilith",
        Body::TrueLilith => "lilith",
        Body::Ceres => "ceres",
        Body::Pallas => "pallas",
        Body::Juno => "juno",
        Body::Vesta => "vesta",
        Body::Chiron => "chiron",
        Body::Pholus => "chiron",
        Body::Eris => "chiron",
        Body::Sedna => "chiron",
        // `Body` es `#[non_exhaustive]` — cualquier cuerpo nuevo
        // upstream cae al símbolo de fallback hasta que lo cableemos.
        _ => "custom",
    }
}

fn aspect_kind_id(k: EAspectKind) -> &'static str {
    match k {
        EAspectKind::Conjunction => "conjunction",
        EAspectKind::Opposition => "opposition",
        EAspectKind::Trine => "trine",
        EAspectKind::Square => "square",
        EAspectKind::Sextile => "sextile",
        EAspectKind::Quincunx => "quincunx",
        EAspectKind::SemiSextile => "semi_sextile",
        EAspectKind::SemiSquare => "semi_square",
        EAspectKind::Sesquiquadrate => "sesquiquadrate",
        EAspectKind::Quintile => "quintile",
        EAspectKind::BiQuintile => "biquintile",
        EAspectKind::Septile => "septile",
    }
}

// =====================================================================
// compute()
// =====================================================================

/// Construye los tipos eternales (`BirthData`, `ChartConfig`) desde el
/// `Chart` agnóstico, aplicando el offset temporal. Devuelve también el
/// `Observer` y la `ChartConfig` para reusar en pipelines extendidas
/// (transits, sinastría) sin re-traducir.
fn build_eternal_inputs(
    chart: &Chart,
    offset_minutes: i64,
) -> Result<(BirthData, ChartConfig, Observer), EngineError> {
    chart.validate()?;
    let bd = &chart.birth_data;
    let base_instant = ESInstant::from_civil_local(
        bd.year,
        u8::try_from(bd.month).map_err(|_| {
            EngineError::Eternal(format!("mes fuera de u8: {}", bd.month))
        })?,
        u8::try_from(bd.day).map_err(|_| {
            EngineError::Eternal(format!("día fuera de u8: {}", bd.day))
        })?,
        u8::try_from(bd.hour).map_err(|_| {
            EngineError::Eternal(format!("hora fuera de u8: {}", bd.hour))
        })?,
        u8::try_from(bd.minute).map_err(|_| {
            EngineError::Eternal(format!("minuto fuera de u8: {}", bd.minute))
        })?,
        bd.second,
        bd.tz_offset_minutes,
    )
    .map_err(|e| EngineError::Eternal(format!("Instant::from_civil_local: {:?}", e)))?;

    let instant = if offset_minutes == 0 {
        base_instant
    } else {
        let shifted_utc = base_instant.utc().add_seconds((offset_minutes as f64) * 60.0);
        ESInstant::from_utc(shifted_utc)
    };

    let observer = Observer::from_degrees(bd.latitude_deg, bd.longitude_deg, bd.altitude_m);
    let mut birth_e = BirthData::new(instant, observer);
    if let Some(name) = &bd.subject_name {
        birth_e = birth_e.with_name(name.clone());
    }
    let config_e = ChartConfig {
        house_system: map_house_system(chart.config.house_system),
        zodiac: map_zodiac(chart.config.zodiac, chart.config.ayanamsha.as_deref()),
        bodies: map_body_set(&chart.config),
        include_horizon: false,
    };
    Ok((birth_e, config_e, observer))
}

/// Computa solo la `NatalChart` (sin construir RenderModel). Útil para
/// pipelines compuestas (transits, sinastría) que necesitan el natal
/// crudo para correr `find_synastry_aspects`.
fn compute_natal_chart(
    chart: &Chart,
    offset_minutes: i64,
) -> Result<(NatalChart, ChartConfig, Observer), EngineError> {
    let (birth_e, config_e, observer) = build_eternal_inputs(chart, offset_minutes)?;
    let session = session()?;
    let natal = NatalChart::compute(&birth_e, &config_e, session)
        .map_err(|e| EngineError::Eternal(format!("NatalChart::compute: {:?}", e)))?;
    Ok((natal, config_e, observer))
}

/// Composición principal: natal + overlays pedidos. Es la función que
/// `lib::compose` delega cuando el feature `eternal-bridge` está activo.
pub fn compose(
    chart: &Chart,
    offset_minutes: i64,
    requests: &[crate::PipelineRequest],
) -> Result<RenderModel, EngineError> {
    let t0 = Instant::now();
    let (natal, config_e, observer) = compute_natal_chart(chart, offset_minutes)?;
    let aspects = find_aspects(&natal, &OrbTable::modern_western());
    let mut render = build_render_model(chart, &natal, &aspects, t0);

    for req in requests {
        match req {
            crate::PipelineRequest::Transit => {
                build_transit_overlay(&natal, &config_e, observer, ESInstant::now(), &mut render)?;
            }
            crate::PipelineRequest::SecondaryProgression { target_age_years } => {
                build_progression_overlay(&natal, *target_age_years, &mut render)?;
            }
            crate::PipelineRequest::SolarArc { target_age_years } => {
                build_solar_arc_overlay(&natal, *target_age_years, &mut render)?;
            }
            crate::PipelineRequest::Synastry { partner_chart } => {
                build_synastry_overlay(&natal, partner_chart, &mut render)?;
            }
            crate::PipelineRequest::SolarReturn { target_age_years } => {
                build_solar_return_overlay(
                    &natal,
                    &config_e,
                    observer,
                    *target_age_years,
                    &mut render,
                )?;
            }
        }
    }

    render.compute_ms = t0.elapsed().as_millis() as u64;
    Ok(render)
}

/// Helper: agrega al `RenderModel` las dos capas del overlay de
/// tránsitos (Outer + cross Aspects).
fn build_transit_overlay(
    natal: &NatalChart,
    config_e: &ChartConfig,
    observer: Observer,
    transit_at: ESInstant,
    render: &mut RenderModel,
) -> Result<(), EngineError> {
    let transit_birth = BirthData::new(transit_at, observer);
    let session = session()?;
    let transit = NatalChart::compute(&transit_birth, config_e, session).map_err(|e| {
        EngineError::Eternal(format!("NatalChart::compute (transit): {:?}", e))
    })?;

    let outer_glyphs: Vec<Glyph> = transit
        .placements
        .iter()
        .map(|p| Glyph {
            deg: p.longitude.longitude_deg() as f32,
            symbol: body_symbol(p.body).into(),
            annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
            retrograde: p.longitude_rate_rad_per_day < 0.0,
            house: None,
        })
        .collect();
    render.layers.push(Layer {
        module_id: "transit".into(),
        kind: LayerKind::Outer,
        ring: 0.82,
        z: 4,
        geometry: Geometry::GlyphsOnly,
        glyphs: outer_glyphs,
    });

    let cross = find_synastry_aspects(
        natal,
        &transit,
        &OrbTable::modern_western(),
        EAspectKind::MAJORS,
    );
    let cross_lines: Vec<LineSeg> = cross
        .iter()
        .filter_map(|a| {
            let natal_p = natal.placement(a.person_a_body)?;
            let transit_p = transit.placement(a.person_b_body)?;
            let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind);
            Some(LineSeg {
                from_deg: natal_p.longitude.longitude_deg() as f32,
                to_deg: transit_p.longitude.longitude_deg() as f32,
                kind: aspect_kind_id(a.kind).into(),
                opacity: opacity * 0.75,
            })
        })
        .collect();
    render.layers.push(Layer {
        module_id: "transit".into(),
        kind: LayerKind::Aspects,
        ring: 0.0,
        z: 5,
        geometry: Geometry::Lines(cross_lines),
        glyphs: Vec::new(),
    });
    Ok(())
}

/// Helper: agrega al `RenderModel` las capas del overlay de progresión
/// secundaria. La carta progresada se computa con el mismo observer y
/// config que la natal pero al instante natal+(age_years/period_years)
/// días.
fn build_progression_overlay(
    natal: &NatalChart,
    target_age_years: f64,
    render: &mut RenderModel,
) -> Result<(), EngineError> {
    let session = session()?;
    let prog = secondary_progression(natal, session, target_age_years)
        .map_err(|e| EngineError::Eternal(format!("secondary_progression: {:?}", e)))?;
    let progressed = &prog.progressed;

    // Glifos de los cuerpos progresados — anillo interno (radio 0.48).
    let glyphs: Vec<Glyph> = progressed
        .placements
        .iter()
        .map(|p| Glyph {
            deg: p.longitude.longitude_deg() as f32,
            symbol: body_symbol(p.body).into(),
            annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
            retrograde: p.longitude_rate_rad_per_day < 0.0,
            house: Some(p.house_number),
        })
        .collect();
    render.layers.push(Layer {
        module_id: "progression".into(),
        kind: LayerKind::Bodies,
        ring: 0.48,
        z: 6,
        geometry: Geometry::GlyphsOnly,
        glyphs,
    });

    // Cross aspects natal × progresada (sólo mayores).
    let cross = find_synastry_aspects(
        natal,
        progressed,
        &OrbTable::modern_western(),
        EAspectKind::MAJORS,
    );
    let cross_lines: Vec<LineSeg> = cross
        .iter()
        .filter_map(|a| {
            let natal_p = natal.placement(a.person_a_body)?;
            let prog_p = progressed.placement(a.person_b_body)?;
            let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind);
            Some(LineSeg {
                from_deg: natal_p.longitude.longitude_deg() as f32,
                to_deg: prog_p.longitude.longitude_deg() as f32,
                kind: aspect_kind_id(a.kind).into(),
                opacity: opacity * 0.7,
            })
        })
        .collect();
    render.layers.push(Layer {
        module_id: "progression".into(),
        kind: LayerKind::Aspects,
        ring: 0.0,
        z: 7,
        geometry: Geometry::Lines(cross_lines),
        glyphs: Vec::new(),
    });
    Ok(())
}

/// Helper: agrega al `RenderModel` las capas del overlay de Solar Arc
/// (método true-progressed-Sun por default). Cada cuerpo natal se
/// desplaza por el mismo arco — preserva las relaciones angulares y
/// las posiciones relativas en casas se mantienen.
fn build_solar_arc_overlay(
    natal: &NatalChart,
    target_age_years: f64,
    render: &mut RenderModel,
) -> Result<(), EngineError> {
    let session = session()?;
    let arc = solar_arc_true(natal, session, target_age_years)
        .map_err(|e| EngineError::Eternal(format!("solar_arc_true: {:?}", e)))?;
    let directed = &arc.directed;

    let glyphs: Vec<Glyph> = directed
        .placements
        .iter()
        .map(|p| Glyph {
            deg: p.longitude.longitude_deg() as f32,
            symbol: body_symbol(p.body).into(),
            annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
            retrograde: p.longitude_rate_rad_per_day < 0.0,
            house: Some(p.house_number),
        })
        .collect();
    render.layers.push(Layer {
        module_id: "solar_arc".into(),
        kind: LayerKind::Bodies,
        ring: 0.43,
        z: 8,
        geometry: Geometry::GlyphsOnly,
        glyphs,
    });

    let cross = find_synastry_aspects(
        natal,
        directed,
        &OrbTable::modern_western(),
        EAspectKind::MAJORS,
    );
    let cross_lines: Vec<LineSeg> = cross
        .iter()
        .filter_map(|a| {
            let natal_p = natal.placement(a.person_a_body)?;
            let dir_p = directed.placement(a.person_b_body)?;
            let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind);
            Some(LineSeg {
                from_deg: natal_p.longitude.longitude_deg() as f32,
                to_deg: dir_p.longitude.longitude_deg() as f32,
                kind: aspect_kind_id(a.kind).into(),
                opacity: opacity * 0.7,
            })
        })
        .collect();
    render.layers.push(Layer {
        module_id: "solar_arc".into(),
        kind: LayerKind::Aspects,
        ring: 0.0,
        z: 9,
        geometry: Geometry::Lines(cross_lines),
        glyphs: Vec::new(),
    });
    Ok(())
}

/// Helper: agrega al `RenderModel` las capas del overlay de sinastría
/// con otra carta natal completa. La carta partner se computa con su
/// propio observer/config (no comparte con la natal). El outer ring
/// se comparte con Transit — mutuamente excluyentes a nivel de Shell.
fn build_synastry_overlay(
    natal: &NatalChart,
    partner_chart: &Chart,
    render: &mut RenderModel,
) -> Result<(), EngineError> {
    let (partner, _config, _observer) = compute_natal_chart(partner_chart, 0)?;

    let glyphs: Vec<Glyph> = partner
        .placements
        .iter()
        .map(|p| Glyph {
            deg: p.longitude.longitude_deg() as f32,
            symbol: body_symbol(p.body).into(),
            annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
            retrograde: p.longitude_rate_rad_per_day < 0.0,
            house: Some(p.house_number),
        })
        .collect();
    render.layers.push(Layer {
        module_id: "synastry".into(),
        kind: LayerKind::Outer,
        ring: 0.82,
        z: 10,
        geometry: Geometry::GlyphsOnly,
        glyphs,
    });

    let cross = find_synastry_aspects(
        natal,
        &partner,
        &OrbTable::modern_western(),
        EAspectKind::MAJORS,
    );
    let cross_lines: Vec<LineSeg> = cross
        .iter()
        .filter_map(|a| {
            let natal_p = natal.placement(a.person_a_body)?;
            let partner_p = partner.placement(a.person_b_body)?;
            let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind);
            Some(LineSeg {
                from_deg: natal_p.longitude.longitude_deg() as f32,
                to_deg: partner_p.longitude.longitude_deg() as f32,
                kind: aspect_kind_id(a.kind).into(),
                opacity: opacity * 0.85,
            })
        })
        .collect();
    render.layers.push(Layer {
        module_id: "synastry".into(),
        kind: LayerKind::Aspects,
        ring: 0.0,
        z: 11,
        geometry: Geometry::Lines(cross_lines),
        glyphs: Vec::new(),
    });
    Ok(())
}

/// Helper: agrega al `RenderModel` las capas del overlay de Solar
/// Return — la carta natal completa computada al instante en que el
/// Sol vuelve a su posición natal en el año pedido. Esa nueva carta
/// va en el anillo externo (compartido con Transit/Synastry —
/// mutuamente excluyentes a nivel de Shell). Cross aspects natal ×
/// return.
fn build_solar_return_overlay(
    natal: &NatalChart,
    config_e: &ChartConfig,
    observer: Observer,
    target_age_years: f64,
    render: &mut RenderModel,
) -> Result<(), EngineError> {
    let session = session()?;
    let natal_sun = natal.placement(Body::Sun).ok_or_else(|| {
        EngineError::Eternal("natal chart sin Sol — Solar Return imposible".into())
    })?;
    let natal_sun_lon = natal_sun.longitude.longitude_rad();

    // Buscar el próximo retorno desde un punto razonable antes del
    // cumpleaños del año target. Restamos 30 días para garantizar que
    // el retorno (que cae ~en la fecha de nacimiento) quede DENTRO de
    // la ventana de búsqueda.
    const TROPICAL_YEAR_SECS: f64 = 365.242190 * 86400.0;
    let after_seconds = (target_age_years * 365.242190 - 30.0) * 86400.0;
    let after_utc = natal
        .birth
        .instant
        .utc()
        .add_seconds(after_seconds.max(-TROPICAL_YEAR_SECS * 2.0));
    let after = ESInstant::from_utc(after_utc);

    let return_instant = next_return(session, Body::Sun, natal_sun_lon, after, None)
        .map_err(|e| EngineError::Eternal(format!("next_return Sun: {:?}", e)))?;

    // La carta del retorno se computa al return_instant con el mismo
    // observer y config natales (convención clásica: solar return
    // tropical en la ciudad de nacimiento).
    let return_birth = BirthData::new(return_instant, observer);
    let return_chart = NatalChart::compute(&return_birth, config_e, session).map_err(|e| {
        EngineError::Eternal(format!("NatalChart::compute (solar return): {:?}", e))
    })?;

    let glyphs: Vec<Glyph> = return_chart
        .placements
        .iter()
        .map(|p| Glyph {
            deg: p.longitude.longitude_deg() as f32,
            symbol: body_symbol(p.body).into(),
            annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
            retrograde: p.longitude_rate_rad_per_day < 0.0,
            house: Some(p.house_number),
        })
        .collect();
    render.layers.push(Layer {
        module_id: "solar_return".into(),
        kind: LayerKind::Outer,
        ring: 0.82,
        z: 12,
        geometry: Geometry::GlyphsOnly,
        glyphs,
    });

    let cross = find_synastry_aspects(
        natal,
        &return_chart,
        &OrbTable::modern_western(),
        EAspectKind::MAJORS,
    );
    let cross_lines: Vec<LineSeg> = cross
        .iter()
        .filter_map(|a| {
            let n_p = natal.placement(a.person_a_body)?;
            let r_p = return_chart.placement(a.person_b_body)?;
            let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind);
            Some(LineSeg {
                from_deg: n_p.longitude.longitude_deg() as f32,
                to_deg: r_p.longitude.longitude_deg() as f32,
                kind: aspect_kind_id(a.kind).into(),
                opacity: opacity * 0.8,
            })
        })
        .collect();
    render.layers.push(Layer {
        module_id: "solar_return".into(),
        kind: LayerKind::Aspects,
        ring: 0.0,
        z: 13,
        geometry: Geometry::Lines(cross_lines),
        glyphs: Vec::new(),
    });
    Ok(())
}

// =====================================================================
// NatalChart → RenderModel
// =====================================================================

fn build_render_model(
    chart: &Chart,
    natal: &NatalChart,
    aspects: &[Aspect],
    started: Instant,
) -> RenderModel {
    let ascendant_deg = natal.ascendant().longitude_deg() as f32;
    let midheaven_deg = natal.midheaven().longitude_deg() as f32;
    let descendant_deg = natal.descendant().longitude_deg() as f32;
    let imum_coeli_deg = natal.imum_coeli().longitude_deg() as f32;

    // ─── Capa 0: Sign Dial ────────────────────────────────────────────
    let sign_dial = Layer {
        module_id: "natal".into(),
        kind: LayerKind::SignDial,
        ring: 1.0,
        z: 0,
        geometry: Geometry::Ring {
            cusps_deg: (0..12).map(|i| (i as f32) * 30.0).collect(),
        },
        glyphs: (0..12)
            .map(|i| Glyph {
                deg: (i as f32) * 30.0 + 15.0,
                symbol: ZODIAC_SYMBOLS[i].into(),
                annotation: None,
                retrograde: false,
                house: None,
            })
            .collect(),
    };

    // ─── Capa 1: Houses ───────────────────────────────────────────────
    let cusps_deg: Vec<f32> = natal
        .houses
        .cusps
        .iter()
        .map(|c| c.to_degrees() as f32)
        .collect();
    let houses = Layer {
        module_id: "natal".into(),
        kind: LayerKind::Houses,
        ring: 0.86,
        z: 1,
        geometry: Geometry::Ring {
            cusps_deg: cusps_deg.clone(),
        },
        glyphs: cusps_deg
            .iter()
            .enumerate()
            .map(|(i, c)| Glyph {
                deg: *c + 4.0,
                symbol: format!("h{}", i + 1),
                annotation: None,
                retrograde: false,
                house: Some((i as u8) + 1),
            })
            .collect(),
    };

    // ─── Capa 2: Bodies ───────────────────────────────────────────────
    let body_glyphs: Vec<Glyph> = natal
        .placements
        .iter()
        .map(|p| Glyph {
            deg: p.longitude.longitude_deg() as f32,
            symbol: body_symbol(p.body).into(),
            annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
            // `BodyPlacement` cambió entre versiones de eternal entre
            // `pub fn is_retrograde(&self) -> bool` y `pub
            // is_retrograde: bool` — leemos el campo crudo
            // `longitude_rate_rad_per_day` (estable en ambas) para no
            // depender del wrapper.
            retrograde: p.longitude_rate_rad_per_day < 0.0,
            house: Some(p.house_number),
        })
        .collect();
    let bodies = Layer {
        module_id: "natal".into(),
        kind: LayerKind::Bodies,
        ring: 0.72,
        z: 2,
        geometry: Geometry::Points(
            natal
                .placements
                .iter()
                .map(|p| crate::PointMark {
                    deg: p.longitude.longitude_deg() as f32,
                    label: p.body.name().into(),
                    tag: body_symbol(p.body).into(),
                })
                .collect(),
        ),
        glyphs: body_glyphs,
    };

    // ─── Capa 3: Aspects ──────────────────────────────────────────────
    let mut aspect_lines: Vec<LineSeg> = Vec::with_capacity(aspects.len());
    for a in aspects {
        // Solo los aspectos mayores se pintan en este pase — los menores
        // saturan visualmente. Fase 4 pondrá un toggle para mostrarlos.
        if !EAspectKind::MAJORS.contains(&a.kind) {
            continue;
        }
        let pa = natal.placement(a.a);
        let pb = natal.placement(a.b);
        if let (Some(pa), Some(pb)) = (pa, pb) {
            let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind);
            aspect_lines.push(LineSeg {
                from_deg: pa.longitude.longitude_deg() as f32,
                to_deg: pb.longitude.longitude_deg() as f32,
                kind: aspect_kind_id(a.kind).into(),
                opacity,
            });
        }
    }
    let aspects_layer = Layer {
        module_id: "natal".into(),
        kind: LayerKind::Aspects,
        ring: 0.58,
        z: 3,
        geometry: Geometry::Lines(aspect_lines),
        glyphs: Vec::new(),
    };

    let subtitle = chart
        .birth_data
        .birthplace_label
        .clone()
        .or_else(|| {
            Some(format!(
                "{:04}-{:02}-{:02} · lat {:+.2}° · lon {:+.2}°",
                chart.birth_data.year,
                chart.birth_data.month,
                chart.birth_data.day,
                chart.birth_data.latitude_deg,
                chart.birth_data.longitude_deg,
            ))
        });

    RenderModel {
        chart_id: chart.id,
        chart_kind: chart.kind,
        title: chart.label.clone(),
        subtitle,
        compute_ms: started.elapsed().as_millis() as u64,
        ascendant_deg,
        midheaven_deg,
        descendant_deg,
        imum_coeli_deg,
        layers: vec![sign_dial, houses, bodies, aspects_layer],
    }
}

/// Mapea el orb absoluto a una opacidad — los aspectos más exactos se
/// pintan más fuerte, los flojos casi se desvanecen.
fn orb_to_opacity(orb_deg: f64, kind: EAspectKind) -> f32 {
    let max = match kind {
        EAspectKind::Conjunction | EAspectKind::Opposition => 8.0,
        EAspectKind::Trine | EAspectKind::Square => 7.0,
        EAspectKind::Sextile => 5.0,
        _ => 3.0,
    };
    let t = (1.0 - (orb_deg / max).min(1.0)).max(0.25);
    t as f32
}

const ZODIAC_SYMBOLS: [&str; 12] = [
    "aries",
    "taurus",
    "gemini",
    "cancer",
    "leo",
    "virgo",
    "libra",
    "scorpio",
    "sagittarius",
    "capricorn",
    "aquarius",
    "pisces",
];
