//! `khipu-gravity` — dos físicas, ambas puras y deterministas.
//!
//! 1. **Masa temporal** ([`Gravity`] + [`Params`]): cada nota tiene una
//!    `mass: f32`. Con el tiempo `decay(mass, dt)` la apaga; con cada
//!    acceso `reinforce(mass, boost)` la enciende. Bajo un umbral la
//!    nota cae al «archivo» — no se borra, sólo deja el horizonte
//!    visible. El cálculo no toca el store; el caller decide qué hacer
//!    con el resultado.
//! 2. **Gravedad semántica** ([`SemanticField`]): afinidad coseno entre
//!    vectores, vecinos, clústeres por umbral y layout 2D dirigido por
//!    fuerzas. Las notas afines se atraen, todas se repelen.
//!
//! Las dos están desacopladas — un caller puede usar sólo una. El
//! crate compila sin alcanzar `std::f32::exp` (usa [`libm`]) para
//! mantenerse apto a futuros consumos `no_std` (kernel wawa, WASM).

#![forbid(unsafe_code)]

use khipu_core::NoteId;
use serde::{Deserialize, Serialize};

/// Parámetros de la física temporal de una nota.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Params {
    /// Vida media: el `dt` (en segundos) tras el cual una masa se
    /// reduce a la mitad sin acceso. Default 7 días.
    pub half_life_secs: f32,
    /// Incremento de masa por acceso. Default 0.4.
    pub boost: f32,
    /// Bajo este valor la nota cae al archivo (no se borra).
    /// Default 0.10.
    pub horizon: f32,
}

impl Default for Params {
    fn default() -> Self {
        Self {
            half_life_secs: 7.0 * 24.0 * 3600.0,
            boost: 0.4,
            horizon: 0.10,
        }
    }
}

/// Física temporal: decay con el tiempo, refuerzo con cada acceso.
/// Pura — no toca reloj ni store. El caller pasa `dt` y aplica el
/// resultado donde corresponda.
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
pub struct Gravity {
    pub params: Params,
}

impl Gravity {
    pub fn new(params: Params) -> Self {
        Self { params }
    }

    /// Masa decaída tras `dt` segundos sin acceso. `dt` negativo o
    /// `half_life_secs <= 0` devuelven la masa intacta.
    pub fn decay(&self, mass: f32, dt_secs: f32) -> f32 {
        if dt_secs <= 0.0 || self.params.half_life_secs <= 0.0 {
            return mass.max(0.0);
        }
        let k = core::f32::consts::LN_2 / self.params.half_life_secs;
        let factor = libm::expf(-k * dt_secs);
        (mass * factor).max(0.0)
    }

    /// Masa tras un acceso — suma el `boost` configurado.
    pub fn reinforce(&self, mass: f32) -> f32 {
        (mass + self.params.boost).max(0.0)
    }

    /// `true` si la masa está sobre el horizonte (visible). `false`
    /// significa archivo, no borrada.
    pub fn is_visible(&self, mass: f32) -> bool {
        mass >= self.params.horizon
    }
}

/// Una nube de notas con su vector semántico — el dominio de la gravedad.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SemanticField {
    /// `(id, vector)` en orden de inserción.
    entries: Vec<(NoteId, Vec<f32>)>,
}

/// Posición 2D resultante de una nota tras el layout por gravedad.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct NotePlacement {
    pub id: NoteId,
    pub x: f32,
    pub y: f32,
}

/// Parámetros del layout dirigido por fuerzas.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct GravityConfig {
    /// Pasos de relajación.
    pub iterations: usize,
    /// Fuerza de atracción entre notas afines.
    pub attraction: f32,
    /// Fuerza de repulsión entre todo par de notas.
    pub repulsion: f32,
    /// Radio del círculo de posiciones iniciales.
    pub radius: f32,
    /// Fracción de la fuerza neta que se aplica por paso (amortiguación).
    pub step: f32,
}

impl Default for GravityConfig {
    fn default() -> Self {
        Self {
            iterations: 120,
            attraction: 0.02,
            repulsion: 800.0,
            radius: 240.0,
            step: 0.85,
        }
    }
}

/// Similitud coseno de dos vectores. `None` si difieren de largo.
fn cosine(a: &[f32], b: &[f32]) -> Option<f32> {
    if a.len() != b.len() {
        return None;
    }
    let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
    let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
    let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
    if na == 0.0 || nb == 0.0 {
        return Some(0.0);
    }
    Some((dot / (na * nb)).clamp(-1.0, 1.0))
}

impl SemanticField {
    pub fn new() -> Self {
        Self::default()
    }

    /// Cantidad de notas en el campo.
    pub fn len(&self) -> usize {
        self.entries.len()
    }

    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }

    /// Inserta o reemplaza el vector de una nota.
    pub fn insert(&mut self, id: NoteId, vector: Vec<f32>) {
        if let Some(slot) = self.entries.iter_mut().find(|(eid, _)| *eid == id) {
            slot.1 = vector;
        } else {
            self.entries.push((id, vector));
        }
    }

    /// Saca la nota del campo. No-op si no estaba. `true` si se removió.
    pub fn remove(&mut self, id: NoteId) -> bool {
        let before = self.entries.len();
        self.entries.retain(|(eid, _)| *eid != id);
        self.entries.len() != before
    }

    /// Itera `(id, &vector)` en orden de inserción. Útil para serializar
    /// el campo completo (persistencia, exportar).
    pub fn iter(&self) -> impl Iterator<Item = (NoteId, &[f32])> {
        self.entries.iter().map(|(id, v)| (*id, v.as_slice()))
    }

    fn vector_of(&self, id: NoteId) -> Option<&[f32]> {
        self.entries
            .iter()
            .find(|(eid, _)| *eid == id)
            .map(|(_, v)| v.as_slice())
    }

    /// Afinidad (similitud coseno) entre dos notas. `None` si alguna no
    /// existe o los vectores difieren de largo.
    pub fn affinity(&self, a: NoteId, b: NoteId) -> Option<f32> {
        cosine(self.vector_of(a)?, self.vector_of(b)?)
    }

    /// Las `k` notas más afines a `id`, de mayor a menor afinidad.
    /// Empata por id ascendente para que el orden sea determinista.
    pub fn nearest(&self, id: NoteId, k: usize) -> Vec<(NoteId, f32)> {
        let Some(base) = self.vector_of(id) else {
            return Vec::new();
        };
        let mut scored: Vec<(NoteId, f32)> = self
            .entries
            .iter()
            .filter(|(eid, _)| *eid != id)
            .filter_map(|(eid, v)| cosine(base, v).map(|s| (*eid, s)))
            .collect();
        scored.sort_by(|a, b| {
            b.1.partial_cmp(&a.1)
                .unwrap_or(core::cmp::Ordering::Equal)
                .then(a.0.cmp(&b.0))
        });
        scored.truncate(k);
        scored
    }

    /// Agrupa las notas en clústeres: dos notas quedan en el mismo grupo
    /// si su afinidad alcanza `threshold` (transitivamente). Cada
    /// clúster viene ordenado por id, y la lista de clústeres también.
    pub fn clusters(&self, threshold: f32) -> Vec<Vec<NoteId>> {
        let n = self.entries.len();
        let mut parent: Vec<usize> = (0..n).collect();

        fn find(parent: &mut [usize], i: usize) -> usize {
            let mut root = i;
            while parent[root] != root {
                root = parent[root];
            }
            let mut cur = i;
            while parent[cur] != root {
                let next = parent[cur];
                parent[cur] = root;
                cur = next;
            }
            root
        }

        for i in 0..n {
            for j in (i + 1)..n {
                let sim = cosine(&self.entries[i].1, &self.entries[j].1).unwrap_or(0.0);
                if sim >= threshold {
                    let (ri, rj) = (find(&mut parent, i), find(&mut parent, j));
                    if ri != rj {
                        parent[ri] = rj;
                    }
                }
            }
        }

        let mut groups: std::collections::BTreeMap<usize, Vec<NoteId>> = Default::default();
        for i in 0..n {
            let root = find(&mut parent, i);
            groups.entry(root).or_default().push(self.entries[i].0);
        }
        let mut out: Vec<Vec<NoteId>> = groups.into_values().collect();
        for c in &mut out {
            c.sort_unstable();
        }
        out.sort_by(|a, b| a.first().cmp(&b.first()));
        out
    }

    /// Layout 2D por gravedad: las notas afines se atraen, todas se
    /// repelen. Determinista — posiciones iniciales en círculo, sin RNG.
    pub fn gravity_layout(&self, cfg: &GravityConfig) -> Vec<NotePlacement> {
        let n = self.entries.len();
        if n == 0 {
            return Vec::new();
        }
        if n == 1 {
            return vec![NotePlacement { id: self.entries[0].0, x: 0.0, y: 0.0 }];
        }

        // Posiciones iniciales repartidas en un círculo.
        let mut pos: Vec<(f32, f32)> = (0..n)
            .map(|i| {
                let a = core::f32::consts::TAU * i as f32 / n as f32;
                (cfg.radius * a.cos(), cfg.radius * a.sin())
            })
            .collect();

        // Afinidades precomputadas (no cambian entre pasos).
        let mut aff = vec![0.0f32; n * n];
        for i in 0..n {
            for j in (i + 1)..n {
                let s = cosine(&self.entries[i].1, &self.entries[j].1)
                    .unwrap_or(0.0)
                    .max(0.0);
                aff[i * n + j] = s;
                aff[j * n + i] = s;
            }
        }

        const EPS: f32 = 0.001;
        for _ in 0..cfg.iterations {
            let mut force = vec![(0.0f32, 0.0f32); n];
            for i in 0..n {
                for j in (i + 1)..n {
                    let dx = pos[j].0 - pos[i].0;
                    let dy = pos[j].1 - pos[i].1;
                    let dist = (dx * dx + dy * dy).sqrt().max(EPS);
                    let (ux, uy) = (dx / dist, dy / dist);
                    // Atracción crece con la distancia y la afinidad;
                    // repulsión cae con el cuadrado de la distancia.
                    let attract = cfg.attraction * aff[i * n + j] * dist;
                    let repel = cfg.repulsion / (dist * dist);
                    let net = attract - repel; // >0 → acercar
                    force[i].0 += net * ux;
                    force[i].1 += net * uy;
                    force[j].0 -= net * ux;
                    force[j].1 -= net * uy;
                }
            }
            for i in 0..n {
                pos[i].0 += force[i].0 * cfg.step;
                pos[i].1 += force[i].1 * cfg.step;
            }
        }

        self.entries
            .iter()
            .zip(pos)
            .map(|((id, _), (x, y))| NotePlacement { id: *id, x, y })
            .collect()
    }
}

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

    /// Tres vectores: 1 y 2 casi paralelos, 3 ortogonal.
    fn field() -> SemanticField {
        let mut f = SemanticField::new();
        f.insert(1, vec![1.0, 0.0, 0.0]);
        f.insert(2, vec![0.9, 0.1, 0.0]);
        f.insert(3, vec![0.0, 0.0, 1.0]);
        f
    }

    #[test]
    fn affinity_is_high_for_aligned_vectors() {
        let f = field();
        let near = f.affinity(1, 2).unwrap();
        let far = f.affinity(1, 3).unwrap();
        assert!(near > 0.95);
        assert!(far.abs() < 1e-6);
        assert!(near > far);
    }

    #[test]
    fn affinity_missing_note_is_none() {
        assert!(field().affinity(1, 99).is_none());
    }

    #[test]
    fn nearest_ranks_by_affinity() {
        let f = field();
        let near = f.nearest(1, 2);
        assert_eq!(near[0].0, 2); // el más afín a 1
        assert_eq!(near.len(), 2);
        assert!(near[0].1 > near[1].1);
    }

    #[test]
    fn insert_replaces_existing_vector() {
        let mut f = SemanticField::new();
        f.insert(1, vec![1.0, 0.0]);
        f.insert(1, vec![0.0, 1.0]);
        assert_eq!(f.len(), 1);
        assert_eq!(f.vector_of(1), Some([0.0, 1.0].as_slice()));
    }

    #[test]
    fn clusters_group_affine_notes() {
        let f = field();
        // Umbral alto: 1 y 2 juntos, 3 solo.
        let cs = f.clusters(0.8);
        assert_eq!(cs, vec![vec![1, 2], vec![3]]);
    }

    #[test]
    fn low_threshold_merges_everything() {
        let cs = field().clusters(-1.0);
        assert_eq!(cs, vec![vec![1, 2, 3]]);
    }

    #[test]
    fn gravity_layout_places_every_note() {
        let placements = field().gravity_layout(&GravityConfig::default());
        assert_eq!(placements.len(), 3);
        let ids: Vec<_> = placements.iter().map(|p| p.id).collect();
        assert_eq!(ids, vec![1, 2, 3]);
    }

    #[test]
    fn gravity_pulls_affine_notes_closer() {
        let f = field();
        let p = f.gravity_layout(&GravityConfig::default());
        let dist = |a: NoteId, b: NoteId| {
            let pa = p.iter().find(|x| x.id == a).unwrap();
            let pb = p.iter().find(|x| x.id == b).unwrap();
            ((pa.x - pb.x).powi(2) + (pa.y - pb.y).powi(2)).sqrt()
        };
        // Las notas afines (1,2) terminan más cerca que las disímiles (1,3).
        assert!(dist(1, 2) < dist(1, 3));
    }

    #[test]
    fn gravity_layout_is_deterministic() {
        let f = field();
        let a = f.gravity_layout(&GravityConfig::default());
        let b = f.gravity_layout(&GravityConfig::default());
        assert_eq!(a, b);
    }

    #[test]
    fn remove_drops_a_note() {
        let mut f = field();
        assert!(f.remove(2));
        assert_eq!(f.len(), 2);
        assert!(!f.remove(99));
    }

    #[test]
    fn iter_returns_vectors_in_insertion_order() {
        let f = field();
        let collected: Vec<_> = f.iter().map(|(id, v)| (id, v.to_vec())).collect();
        assert_eq!(collected.len(), 3);
        assert_eq!(collected[0].0, 1);
        assert_eq!(collected[2].0, 3);
    }

    #[test]
    fn empty_and_single_fields_are_handled() {
        assert!(SemanticField::new().gravity_layout(&GravityConfig::default()).is_empty());
        let mut one = SemanticField::new();
        one.insert(7, vec![1.0, 1.0]);
        let p = one.gravity_layout(&GravityConfig::default());
        assert_eq!(p, vec![NotePlacement { id: 7, x: 0.0, y: 0.0 }]);
    }
}

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

    #[test]
    fn decay_halves_mass_after_one_half_life() {
        let g = Gravity::new(Params {
            half_life_secs: 1000.0,
            ..Params::default()
        });
        let after = g.decay(1.0, 1000.0);
        assert!((after - 0.5).abs() < 1e-3);
    }

    #[test]
    fn decay_with_zero_dt_is_a_no_op() {
        let g = Gravity::default();
        assert!((g.decay(0.7, 0.0) - 0.7).abs() < 1e-6);
    }

    #[test]
    fn decay_negative_dt_returns_input() {
        let g = Gravity::default();
        assert!((g.decay(0.7, -10.0) - 0.7).abs() < 1e-6);
    }

    #[test]
    fn reinforce_adds_boost() {
        let g = Gravity::new(Params { boost: 0.25, ..Params::default() });
        assert!((g.reinforce(0.5) - 0.75).abs() < 1e-6);
    }

    #[test]
    fn is_visible_uses_horizon() {
        let g = Gravity::new(Params { horizon: 0.1, ..Params::default() });
        assert!(g.is_visible(0.10));
        assert!(g.is_visible(0.99));
        assert!(!g.is_visible(0.05));
    }

    #[test]
    fn long_decay_keeps_mass_non_negative() {
        let g = Gravity::default();
        assert!(g.decay(1.0, 1_000_000_000.0) >= 0.0);
    }
}
