//! Opaque pagination cursor for keyset pagination.

use std::fmt;

use base64::Engine as _;

use super::errors::QueryError;
use crate::schema::EntityId;

// ── Cursor ──────────────────────────────────────────────────────────────────

/// Opaque pagination cursor for keyset pagination.
///
/// Encodes the last-seen entity ID and score as a base64 string. The cursor
/// is opaque to callers -- they receive it from `Results::next_cursor` and
/// pass it back to `RetrieveBuilder::cursor()`.
///
/// Internal format: `{score_bits_be}{entity_id_be}` (16 bytes), base64url-encoded
/// without padding.
#[derive(Debug, Clone, PartialEq)]
pub struct Cursor {
    /// The score of the last result on the previous page.
    pub last_score: f64,
    /// The entity ID of the last result on the previous page.
    pub last_id: EntityId,
}

impl Cursor {
    /// Create a new cursor from a score and entity ID.
    #[must_use]
    pub const fn new(last_score: f64, last_id: EntityId) -> Self {
        Self {
            last_score,
            last_id,
        }
    }

    /// Create a cursor from a simple offset (for offset-based pagination).
    ///
    /// The score is set to `0.0` and the entity ID encodes the offset.
    #[must_use]
    pub const fn from_offset(offset: usize) -> Self {
        Self::new(0.0, EntityId::new(offset as u64))
    }

    /// Extract the offset from a cursor created via `from_offset`.
    #[must_use]
    pub const fn offset(&self) -> usize {
        self.last_id.as_u64() as usize
    }

    /// Encode the cursor as an opaque base64url string.
    #[must_use]
    pub fn encode(&self) -> String {
        let mut buf = [0u8; 16];
        buf[..8].copy_from_slice(&self.last_score.to_be_bytes());
        buf[8..].copy_from_slice(&self.last_id.as_u64().to_be_bytes());
        base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(buf)
    }

    /// Decode a cursor from a base64url string.
    ///
    /// # Errors
    ///
    /// Returns `QueryError::InvalidCursor` if the string cannot be decoded or
    /// has the wrong length.
    ///
    /// # Panics
    ///
    /// Does not panic in practice. The `unwrap` calls on byte-slice conversion
    /// are unreachable because the length is verified to be exactly 16 bytes.
    pub fn decode(s: &str) -> Result<Self, QueryError> {
        let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
            .decode(s)
            .map_err(|e| QueryError::InvalidCursor(format!("base64 decode: {e}")))?;

        if bytes.len() != 16 {
            return Err(QueryError::InvalidCursor(format!(
                "expected 16 bytes, got {}",
                bytes.len()
            )));
        }

        // Safety: len == 16 verified above; slices are exactly 8 bytes.
        #[allow(clippy::unwrap_used)]
        let score_bytes = <[u8; 8]>::try_from(&bytes[..8]).unwrap();
        #[allow(clippy::unwrap_used)]
        let id_bytes = <[u8; 8]>::try_from(&bytes[8..16]).unwrap();

        Ok(Self {
            last_score: f64::from_be_bytes(score_bytes),
            last_id: EntityId::new(u64::from_be_bytes(id_bytes)),
        })
    }
}

impl fmt::Display for Cursor {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.encode())
    }
}
