//! BLAKE3-based schema fingerprint: deterministic hash of signal definitions.
//!
//! On first open with a persistent data directory the fingerprint is written.
//! On subsequent opens it is compared; a mismatch returns `TidalError::SchemaMismatch`.

// ── Fingerprint computation ──────────────────────────────────────────────────

/// Compute a deterministic BLAKE3 fingerprint of the schema's signal definitions.
///
/// The fingerprint captures:
/// - Signal names (sorted alphabetically for deterministic ordering)
/// - Entity kind per signal (Item=0, User=1, Creator=2)
/// - Decay model discriminant and parameters:
///   - Exponential: `half_life` as `u128` nanos (avoids float hash instability)
///   - Linear: lifetime as `u128` nanos
///   - Permanent: sentinel byte 0xFF
///
/// Hashing `Duration::as_nanos()` (u128) instead of the derived `f64` lambda
/// avoids IEEE 754 representation differences across platforms.
pub fn compute_schema_fingerprint(schema: &crate::schema::Schema) -> [u8; 32] {
    use blake3::Hasher;

    let mut hasher = Hasher::new();

    // Collect signal definitions and sort by name for deterministic ordering.
    // HashMap iteration order is nondeterministic; sorting by name makes the
    // fingerprint stable regardless of insertion order.
    let mut signals: Vec<_> = schema.signals().collect();
    signals.sort_by_key(|s| s.name().to_owned());

    for sig in &signals {
        // Signal name.
        hasher.update(sig.name().as_bytes());

        // Entity kind.
        let kind_byte = match sig.target() {
            crate::schema::EntityKind::Item => 0u8,
            crate::schema::EntityKind::User => 1u8,
            crate::schema::EntityKind::Creator => 2u8,
        };
        hasher.update(&[kind_byte]);

        // Decay model.
        match sig.decay() {
            crate::schema::DecayModel::Exponential { half_life, .. } => {
                hasher.update(&[0x01]); // discriminant
                hasher.update(&half_life.as_nanos().to_le_bytes());
            }
            crate::schema::DecayModel::Linear { lifetime } => {
                hasher.update(&[0x02]); // discriminant
                hasher.update(&lifetime.as_nanos().to_le_bytes());
            }
            crate::schema::DecayModel::Permanent => {
                hasher.update(&[0xFF]); // discriminant
            }
        }
    }

    *hasher.finalize().as_bytes()
}

/// Check the stored schema fingerprint against the current schema.
///
/// - If no fingerprint is stored (first boot), write it and succeed.
/// - If the stored fingerprint matches, succeed.
/// - If the stored fingerprint differs, return `TidalError::SchemaMismatch`.
pub fn verify_or_store_schema_fingerprint(
    schema: &crate::schema::Schema,
    engine: &dyn crate::storage::StorageEngine,
) -> crate::Result<()> {
    use crate::storage::keys::{Tag, encode_key};

    let fingerprint = compute_schema_fingerprint(schema);

    // Sentinel entity ID 0 + SchemaFingerprint tag, no suffix.
    let key = encode_key(crate::schema::EntityId::new(0), Tag::SchemaFingerprint, &[]);

    match engine.get(&key)? {
        Some(stored) if stored.as_slice() == fingerprint => {
            // Fingerprints match -- the schema is unchanged.
            Ok(())
        }
        Some(_) => {
            // Fingerprints differ -- the schema has changed.
            Err(crate::TidalError::schema_mismatch(
                "schema has changed since this data directory was last opened. \
                 Signal decay parameters, signal names, or entity kinds differ from \
                 the stored schema fingerprint. To proceed: (1) revert to the \
                 original schema, (2) use a fresh data directory, or (3) delete \
                 the existing data to rebuild from scratch.",
            ))
        }
        None => {
            // First boot -- store the fingerprint.
            engine.put(&key, &fingerprint)?;
            Ok(())
        }
    }
}

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

    /// Helper: build a schema with a single "view" signal using the given
    /// half-life in seconds.
    fn make_schema_with_half_life(secs: u64) -> crate::schema::Schema {
        use crate::schema::validation::DecaySpec;
        use crate::schema::validation::builders::SchemaBuilder;
        use crate::schema::{EntityKind, Window};
        use std::time::Duration;

        let mut b = SchemaBuilder::new();
        let _ = b
            .signal(
                "view",
                EntityKind::Item,
                DecaySpec::Exponential {
                    half_life: Duration::from_secs(secs),
                },
            )
            .windows(&[Window::OneHour])
            .velocity(false)
            .add();
        b.build().expect("valid schema")
    }

    #[test]
    fn schema_fingerprint_deterministic() {
        let s1 = make_schema_with_half_life(7 * 24 * 3600);
        let s2 = make_schema_with_half_life(7 * 24 * 3600);
        let fp1 = compute_schema_fingerprint(&s1);
        let fp2 = compute_schema_fingerprint(&s2);
        assert_eq!(
            fp1, fp2,
            "same schema should produce identical fingerprints"
        );
    }

    #[test]
    fn schema_fingerprint_differs_on_half_life_change() {
        let s1 = make_schema_with_half_life(7 * 24 * 3600); // 7 days
        let s2 = make_schema_with_half_life(3 * 24 * 3600); // 3 days
        let fp1 = compute_schema_fingerprint(&s1);
        let fp2 = compute_schema_fingerprint(&s2);
        assert_ne!(
            fp1, fp2,
            "different half-lives should produce different fingerprints"
        );
    }

    #[test]
    fn schema_fingerprint_mismatch_returns_error() {
        use crate::db::TidalDb;

        let dir = tempfile::tempdir().expect("tempdir");

        // First open with 7-day half-life.
        {
            let schema = make_schema_with_half_life(7 * 24 * 3600);
            let db = TidalDb::builder()
                .with_data_dir(dir.path().to_path_buf())
                .with_schema(schema)
                .open()
                .expect("first open should succeed");
            db.close().expect("close");
        }

        // Second open with a different half-life (3 days).
        {
            let schema = make_schema_with_half_life(3 * 24 * 3600);
            let result = TidalDb::builder()
                .with_data_dir(dir.path().to_path_buf())
                .with_schema(schema)
                .open();
            assert!(result.is_err(), "expected SchemaMismatch error");
            let err = result.expect_err("should fail");
            let err_msg = err.to_string();
            assert!(
                err_msg.contains("schema"),
                "expected schema mismatch, got: {err_msg}"
            );
        }
    }

    #[test]
    fn schema_fingerprint_same_schema_succeeds() {
        use crate::db::TidalDb;

        let dir = tempfile::tempdir().expect("tempdir");
        let half_life = 7 * 24 * 3600u64;

        for i in 0..2 {
            let schema = make_schema_with_half_life(half_life);
            let db = TidalDb::builder()
                .with_data_dir(dir.path().to_path_buf())
                .with_schema(schema)
                .open()
                .unwrap_or_else(|e| panic!("open #{i} should succeed: {e}"));
            db.close().expect("close");
        }
    }

    #[test]
    fn schema_fingerprint_skipped_for_ephemeral() {
        use crate::db::TidalDb;

        // Ephemeral mode should never check fingerprints. Two different
        // schemas can open ephemeral databases without error.
        let s1 = make_schema_with_half_life(7 * 24 * 3600);
        let db1 = TidalDb::builder()
            .ephemeral()
            .with_schema(s1)
            .open()
            .expect("ephemeral open 1");
        drop(db1);

        let s2 = make_schema_with_half_life(3 * 24 * 3600);
        let db2 = TidalDb::builder()
            .ephemeral()
            .with_schema(s2)
            .open()
            .expect("ephemeral open 2");
        drop(db2);
    }
}
