#![allow(clippy::unwrap_used)]
//! tidalDB short-video "For You" feed: the TikTok/Reels-shaped use case.
//!
//! Demonstrates the full swipe-loop on the embedded engine — the same shape a
//! short-video app's home feed would drive:
//!
//! 1. Define a schema modeling short-video signals: `view`, `completion`,
//!    `like`, `share`, `skip` (the swipe-away negative), and `replay`.
//! 2. Open an ephemeral database and ingest ~24 short videos across 5 creators.
//! 3. Simulate one user's swipe session: completions + likes on music videos,
//!    skips on the rest, and a couple of shares — fed back through
//!    `signal_with_context` so the engine updates *that user's* taste vector.
//! 4. Retrieve the personalized `for_you` feed for the user (diversity-capped,
//!    with a 10% exploration budget), then the global `trending` feed for
//!    contrast.
//!
//! The point: the ranking outcome is driven by the *signals*, not by the random
//! embeddings. The user who completes and likes music videos and swipes past
//! everything else gets a music-leaning personalized feed — deterministically,
//! because the feedback loop is real.
//!
//! # Running
//!
//! ```bash
//! cargo run -p tidaldb --example foryou_feed
//! ```

use std::{collections::HashMap, time::Duration};

use rand::Rng;
use tidaldb::{
    TidalDb,
    schema::{DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp, Window},
};

/// Generate a random unit-normalized embedding of the given dimensionality.
///
/// A real short-video app does NOT compute embeddings this way — it runs the
/// video (frames + audio + caption) through a multimodal model and stores the
/// resulting vector here. tidalDB *retrieves and ranks over* embeddings; it
/// does not generate them. See `docs/guides/embeddings.md`.
fn random_unit_vector(dim: usize, rng: &mut impl Rng) -> Vec<f32> {
    let v: Vec<f32> = (0..dim).map(|_| rng.random::<f32>() - 0.5).collect();
    let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
    if norm < f32::EPSILON {
        // Degenerate case: return a unit vector along the first axis.
        let mut unit = vec![0.0_f32; dim];
        unit[0] = 1.0;
        return unit;
    }
    v.iter().map(|x| x / norm).collect()
}

#[allow(clippy::too_many_lines)] // Linear walkthrough script; splitting would hurt readability.
fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Initialize tracing so spans emitted by tidalDB are visible.
    tracing_subscriber::fmt()
        .with_env_filter("tidaldb=info")
        .init();

    // ── 1. Define the short-video schema ────────────────────────────────
    //
    // Each signal carries native temporal semantics. The half-lives encode how
    // long each kind of engagement should keep mattering to ranking:
    //   - `view`       decays over a week; this is the raw impression.
    //   - `completion` barely decays — finishing a short video is a strong,
    //     durable taste signal. Weight is the fraction watched (0..1), and it is
    //     a *positive-engagement* signal: it pulls the user's preference vector
    //     toward the video's content embedding.
    //   - `like`       decays over a month and is also positive-engagement.
    //   - `share`      decays fast (3d) and tracks velocity — shares are bursty.
    //   - `skip`       the swipe-away. Fast 1d decay; NOT positive-engagement
    //     (a swipe must never pull taste toward the skipped video). Routed
    //     through `signal_with_context` it also marks the item as a hard
    //     negative / seen for that user.
    //   - `replay`     medium decay; re-watching is a meaningful positive.

    let mut schema = SchemaBuilder::new();

    // view: 7-day half-life, velocity + 1h/24h/AllTime windows.
    let _ = schema
        .signal(
            "view",
            EntityKind::Item,
            DecaySpec::Exponential {
                half_life: Duration::from_secs(7 * 24 * 3600),
            },
        )
        .windows(&[Window::OneHour, Window::TwentyFourHours, Window::AllTime])
        .velocity(true)
        .add();

    // completion: very slow decay (1 year), positive-engagement.
    // Weight = fraction of the video watched, in [0.0, 1.0].
    let _ = schema
        .signal(
            "completion",
            EntityKind::Item,
            DecaySpec::Exponential {
                half_life: Duration::from_secs(365 * 24 * 3600),
            },
        )
        .windows(&[Window::AllTime])
        .velocity(false)
        .positive_engagement(true)
        .add();

    // like: 30-day half-life, positive-engagement.
    let _ = schema
        .signal(
            "like",
            EntityKind::Item,
            DecaySpec::Exponential {
                half_life: Duration::from_secs(30 * 24 * 3600),
            },
        )
        .windows(&[Window::AllTime])
        .velocity(false)
        .positive_engagement(true)
        .add();

    // share: 3-day half-life, velocity (bursty), 24h + AllTime windows.
    let _ = schema
        .signal(
            "share",
            EntityKind::Item,
            DecaySpec::Exponential {
                half_life: Duration::from_secs(3 * 24 * 3600),
            },
        )
        .windows(&[Window::TwentyFourHours, Window::AllTime])
        .velocity(true)
        .add();

    // skip: fast 1-day decay — the swipe-away negative. NOT positive-engagement.
    let _ = schema
        .signal(
            "skip",
            EntityKind::Item,
            DecaySpec::Exponential {
                half_life: Duration::from_secs(24 * 3600),
            },
        )
        .windows(&[Window::OneHour, Window::TwentyFourHours])
        .velocity(false)
        .add();

    // replay: medium decay (14 days), positive-engagement.
    let _ = schema
        .signal(
            "replay",
            EntityKind::Item,
            DecaySpec::Exponential {
                half_life: Duration::from_secs(14 * 24 * 3600),
            },
        )
        .windows(&[Window::AllTime])
        .velocity(false)
        .positive_engagement(true)
        .add();

    // Embedding slot: 128-dim content vectors for items (the "content" model).
    let _ = schema.embedding_slot("content", EntityKind::Item, 128);

    let schema = schema.build()?;

    // ── 2. Open an ephemeral database ───────────────────────────────────

    let db = TidalDb::builder().ephemeral().with_schema(schema).open()?;

    db.health_check()?;

    println!(
        "tidalDB opened (ephemeral, build: {}) — short-video For You demo",
        tidaldb::BUILD_HASH
    );
    println!();

    // ── 3. Ingest ~24 short videos across 5 creators ────────────────────
    //
    // NOTE: embeddings here are random unit vectors purely so the example is
    // self-contained. A production app computes them from the actual video
    // (frames/audio/caption) with a multimodal model — tidalDB only retrieves
    // and ranks over them. See `docs/guides/embeddings.md`.

    let mut rng = rand::rng();
    let dim = 128;

    // Five creators, each leaning into one category so the feedback loop has a
    // clear "taste" to discover. Creator 1 == music, 3 == music; the rest are
    // tech / cooking / comedy.
    let categories = ["music", "tech", "cooking", "comedy", "music"];
    let now = Timestamp::now();

    let mut music_items: Vec<u64> = Vec::new();
    let mut nonmusic_items: Vec<u64> = Vec::new();

    for i in 1..=24u64 {
        // Round-robin items across the 5 creators / categories.
        let slot = (i as usize - 1) % categories.len();
        let category = categories[slot];
        let creator_id = (slot + 1) as u64; // creator ids 1..=5

        let mut metadata = HashMap::new();
        metadata.insert("title".to_string(), format!("Short #{i} — {category}"));
        metadata.insert("category".to_string(), category.to_string());
        metadata.insert("format".to_string(), "short".to_string());
        metadata.insert("creator_id".to_string(), creator_id.to_string());
        // Durations typical of short-form video: 15–45s.
        metadata.insert("duration".to_string(), format!("{}", 15 + (i % 4) * 10));
        metadata.insert("created_at".to_string(), now.as_nanos().to_string());

        db.write_item_with_metadata(EntityId::new(i), &metadata)?;

        let embedding = random_unit_vector(dim, &mut rng);
        db.write_item_embedding(EntityId::new(i), &embedding)?;

        if category == "music" {
            music_items.push(i);
        } else {
            nonmusic_items.push(i);
        }
    }

    println!(
        "Ingested {} short videos across 5 creators ({} music, {} other), {dim}D embeddings.",
        db.item_count(),
        music_items.len(),
        nonmusic_items.len()
    );
    println!("  music videos:     {music_items:?}\n  non-music videos: {nonmusic_items:?}");
    println!();

    // ── 4. Simulate one user's swipe session ────────────────────────────
    //
    // We route every engagement through `signal_with_context` with the user id
    // (1001) and the video's creator id. That is what makes this a *feedback
    // loop*: positive-engagement signals (completion, like, replay) fold the
    // video's content embedding into user 1001's preference vector, while skips
    // mark videos as hard negatives for that user.
    //
    // IMPORTANT — a session is a *slice* of the catalog, not the whole thing.
    // Any item the user engages with (positively OR negatively) is marked SEEN
    // for that user, and the personalized `for_you` feed deliberately excludes
    // seen + hard-negative items: a feed must surface FRESH content, never
    // re-show what was just watched. So we touch only part of the catalog and
    // leave fresh videos behind. The taste the user reveals on the watched slice
    // (music) is what ranks those fresh videos.

    let user_id: u64 = 1001;

    // Helper to recover a video's creator id from its round-robin slot.
    let creator_of = |item: u64| -> u64 { ((item - 1) % categories.len() as u64) + 1 };

    // Split each category into a "watched this session" slice and a "fresh"
    // remainder. The watched slice trains taste; the fresh remainder is what the
    // For You feed gets to rank.
    let watched_music: Vec<u64> = music_items.iter().take(5).copied().collect();
    let fresh_music: Vec<u64> = music_items.iter().skip(5).copied().collect();
    let skipped_nonmusic: Vec<u64> = nonmusic_items.iter().take(6).copied().collect();
    let fresh_nonmusic: Vec<u64> = nonmusic_items.iter().skip(6).copied().collect();

    let mut completions = 0u32;
    let mut likes = 0u32;
    let mut skips = 0u32;
    let mut shares = 0u32;
    let mut replays = 0u32;

    // The user LOVES music: watches the session's music videos to completion and
    // likes them. (Positive-engagement → folds each video's embedding into the
    // user's preference vector, and strengthens the (user, music-creator) edge.)
    for &item in &watched_music {
        // completion weight is the fraction watched — these get watched fully.
        db.signal_with_context(
            "completion",
            EntityId::new(item),
            1.0,
            now,
            Some(user_id),
            Some(creator_of(item)),
        )?;
        completions += 1;

        db.signal_with_context(
            "like",
            EntityId::new(item),
            1.0,
            now,
            Some(user_id),
            Some(creator_of(item)),
        )?;
        likes += 1;
    }

    // Two shares + one replay on the first couple of watched music videos.
    for &item in watched_music.iter().take(2) {
        db.signal_with_context(
            "share",
            EntityId::new(item),
            1.0,
            now,
            Some(user_id),
            Some(creator_of(item)),
        )?;
        shares += 1;
    }
    if let Some(&item) = watched_music.first() {
        db.signal_with_context(
            "replay",
            EntityId::new(item),
            1.0,
            now,
            Some(user_id),
            Some(creator_of(item)),
        )?;
        replays += 1;
    }

    // The user SWIPES PAST the session's non-music videos: a brief view, then a
    // skip. The skip is the swipe-away negative — it must NOT pull taste toward
    // the skipped content (skip is not positive-engagement), and it marks the
    // video as a hard negative so the feed never re-surfaces it.
    for &item in &skipped_nonmusic {
        // A partial view (watched ~10% — a glance) before swiping.
        db.signal_with_context(
            "view",
            EntityId::new(item),
            0.1,
            now,
            Some(user_id),
            Some(creator_of(item)),
        )?;
        db.signal_with_context(
            "skip",
            EntityId::new(item),
            1.0,
            now,
            Some(user_id),
            Some(creator_of(item)),
        )?;
        skips += 1;
    }

    println!(
        "User {user_id} swipe session: {completions} completions, {likes} likes, \
         {shares} shares, {replays} replay, {skips} skips."
    );
    println!(
        "  watched (now seen):  music {watched_music:?}, skipped non-music {skipped_nonmusic:?}"
    );
    println!("  FRESH (feed pool):   music {fresh_music:?}, non-music {fresh_nonmusic:?}");

    // Show that the feedback loop landed: the most-completed music video has a
    // live completion decay score.
    if let Some(&top_music) = watched_music.first() {
        let score = db.read_decay_score(EntityId::new(top_music), "completion", 0)?;
        println!(
            "  completion decay score for music video #{top_music}: {:.4}",
            score.unwrap_or(0.0)
        );
    }
    println!();

    // ── 5. Retrieve the personalized For You feed ───────────────────────
    //
    // `for_you` blends interaction-weighted decay scores (view + 2*like +
    // 1.5*share velocity) with the user's preference vector, then enforces
    // diversity: `max_per_creator = 2` so a single creator cannot dominate the
    // feed, plus a 10% exploration budget (`FOR_YOU_EXPLORATION`) that injects
    // unseen/random items to avoid a filter bubble. Skipped videos are filtered
    // out as hard negatives for this user.

    let for_you = tidaldb::query::retrieve::Retrieve::builder()
        .for_user(user_id)
        .profile("for_you")
        .limit(10)
        .build()?;

    let results = db.retrieve(&for_you)?;

    println!(
        "RETRIEVE profile=for_you (user={user_id}): {} results from {} candidates",
        results.items.len(),
        results.total_candidates
    );
    print_feed(&results);

    // The payoff: the candidate pool is the FRESH videos only (the 11 watched /
    // skipped videos are excluded), and the top of the feed is the fresh MUSIC
    // videos — surfaced because the user's session built a strong interaction
    // edge with the music creators. The taste was learned from signals, not from
    // the (random) embeddings, so this ordering is stable across runs.
    let top_ids: Vec<u64> = results
        .items
        .iter()
        .take(fresh_music.len())
        .map(|i| i.entity_id.as_u64())
        .collect();
    println!(
        "  top {} For You slots are the fresh music videos {top_ids:?} (taste leaned music)",
        fresh_music.len()
    );
    println!();

    // ── 6. Retrieve the global Trending feed for contrast ───────────────
    //
    // `trending` is NOT personalized: it ranks every item globally by
    // velocity (view_vel + 2*share_vel over the 24h window), also capped per
    // creator. This is what a brand-new visitor with no taste history sees.

    let trending = tidaldb::query::retrieve::Retrieve::builder()
        .profile("trending")
        .limit(10)
        .build()?;

    let trending_results = db.retrieve(&trending)?;

    println!(
        "RETRIEVE profile=trending (global, no user): {} results from {} candidates",
        trending_results.items.len(),
        trending_results.total_candidates
    );
    print_feed(&trending_results);
    println!();

    // ── 7. Clean up ─────────────────────────────────────────────────────

    db.close()?;
    println!("tidalDB closed. For You demo complete.");

    // ── What tidalDB owns vs what the app owns ──────────────────────────
    //
    // tidalDB owns: RANKING. Signal ledgers (decay/velocity/windows), per-user
    // preference vectors, candidate retrieval, filtering, diversity, and the
    // For You / Trending ordering — one process, one query.
    //
    // The app owns: everything else. Video storage + CDN + transcoding,
    // embedding generation (run the video through your multimodal model),
    // authentication/identity, and the player UI. The app writes signals as
    // users swipe and reads back ranked feeds.
    //
    // To build the real thing end-to-end, see `docs/guides/build-a-feed-app.md`.

    Ok(())
}

/// Print a ranked feed: rank, entity id, score, and per-item signal summary.
fn print_feed(results: &tidaldb::query::Results) {
    println!("{:<6} {:<12} {:<10} Signals", "Rank", "Video ID", "Score");
    println!("{}", "-".repeat(64));

    for item in &results.items {
        let signal_summary: String = item
            .signals
            .iter()
            .map(|s| format!("{}={:.3}", s.name, s.value))
            .collect::<Vec<_>>()
            .join(", ");

        println!(
            "{:<6} {:<12} {:<10.4} {}",
            item.rank,
            item.entity_id.as_u64(),
            item.score,
            if signal_summary.is_empty() {
                "-".to_string()
            } else {
                signal_summary
            }
        );
    }
}
