//! El modelo `Note` — la unidad de khipu_app.

use serde::{Deserialize, Serialize};

use crate::links::parse_links;

/// Identificador de una nota. Lo asigna el almacén, monótono y estable.
pub type NoteId = u64;

/// Una nota: título, cuerpo, etiquetas y marcas de tiempo. Los enlaces
/// no se guardan aparte — se derivan del cuerpo bajo demanda.
///
/// `mass` y `last_access` son la señal temporal: la masa decae con el
/// tiempo y `last_access` registra cuándo fue la última lectura. La
/// física la aplica el caller (típicamente con `khipu-gravity`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Note {
    pub id: NoteId,
    pub title: String,
    pub body: String,
    pub tags: Vec<String>,
    /// Segundo Unix de creación.
    pub created_at: u64,
    /// Segundo Unix de la última edición.
    pub updated_at: u64,
    /// Segundo Unix del último acceso (lectura). Defecto: `created_at`
    /// en notas nuevas; en payloads viejos sin el campo, `0` y el
    /// caller decide cómo arrancar la masa.
    #[serde(default)]
    pub last_access: u64,
    /// Masa de la nota. 1.0 al crearse; decae con el tiempo, sube con
    /// cada acceso. Cuando cae bajo el umbral del horizonte la nota
    /// pasa al archivo (no se borra). En payloads viejos arranca en 1.0.
    #[serde(default = "default_mass")]
    pub mass: f32,
    /// Posición ancla en el lienzo del mapa, en coordenadas de mundo.
    /// Se fija **una sola vez** cuando la nota se coloca (por gravedad
    /// semántica, contra las notas ya asentadas) y se persiste: el mapa
    /// es estable — un pensamiento conserva su domicilio para que la
    /// memoria espacial pueda recordarlo. `None` = todavía sin colocar
    /// (el caller le asigna lugar la primera vez que la pinta). En
    /// payloads viejos arranca en `None` y se coloca al cargar.
    #[serde(default)]
    pub pos: Option<(f32, f32)>,
}

fn default_mass() -> f32 {
    1.0
}

impl Note {
    /// Destinos `[[...]]` que el cuerpo de la nota referencia.
    pub fn outgoing_links(&self) -> Vec<String> {
        parse_links(&self.body)
    }

    /// `true` si la nota lleva la etiqueta `tag` (sin distinguir mayúsculas).
    pub fn has_tag(&self, tag: &str) -> bool {
        self.tags.iter().any(|t| t.eq_ignore_ascii_case(tag))
    }

    /// `true` si `query` aparece en el título o el cuerpo (sin distinguir
    /// mayúsculas).
    pub fn matches(&self, query: &str) -> bool {
        let q = query.to_lowercase();
        self.title.to_lowercase().contains(&q) || self.body.to_lowercase().contains(&q)
    }
}

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

    fn note(title: &str, body: &str) -> Note {
        Note {
            id: 1,
            title: title.into(),
            body: body.into(),
            tags: vec!["casa".into()],
            created_at: 0,
            updated_at: 0,
            last_access: 0,
            mass: 1.0,
            pos: None,
        }
    }

    #[test]
    fn outgoing_links_reads_the_body() {
        let n = note("Cocina", "preparar con [[Horno]] y [[Cuchillos]]");
        assert_eq!(n.outgoing_links(), vec!["Horno", "Cuchillos"]);
    }

    #[test]
    fn has_tag_is_case_insensitive() {
        let n = note("x", "y");
        assert!(n.has_tag("CASA"));
        assert!(!n.has_tag("trabajo"));
    }

    #[test]
    fn matches_searches_title_and_body() {
        let n = note("Lista de mercado", "comprar pan");
        assert!(n.matches("MERCADO"));
        assert!(n.matches("pan"));
        assert!(!n.matches("ausente"));
    }
}
