+use std::str::FromStr;
+use std::sync::Arc;
+
+use base64::{engine::general_purpose, Engine};
+use chrono::{DateTime, Utc};
+use public_suffix::{EffectiveTLDProvider, DEFAULT_PROVIDER};
+use thiserror::Error;
+use url::Url;
+
+use crate::constants::{
+ COOKIELESS_DISTINCT_ID_PREFIX, IDENTIFIES_TTL_SECONDS, SALT_TTL_SECONDS, SESSION_INACTIVITY_MS,
+ SESSION_TTL_SECONDS, TIMEZONE_FALLBACK,
+};
+use crate::hash::{do_hash, HashError};
+use crate::salt_cache::{SaltCache, SaltCacheError};
+use common_redis::Client as RedisClient;
+
+#[derive(Debug, Error)]
+pub enum CookielessManagerError {
+ #[error("Salt cache error: {0}")]
+ SaltCacheError(#[from] SaltCacheError),
+
+ #[error("Hash error: {0}")]
+ HashError(#[from] HashError),
+
+ #[error("Invalid URL: {0}")]
+ UrlParseError(#[from] url::ParseError),
+
+ #[error("Cookieless mode is disabled")]
+ Disabled,
+
+ #[error("Missing required property: {0}")]
+ MissingProperty(String),
+
+ #[error("Invalid timestamp: {0}")]
+ InvalidTimestamp(String),
+
+ #[error("Chrono error: {0}")]
+ ChronoError(#[from] chrono::ParseError),
+
+ #[error("Timezone error: {0}")]
+ TimezoneError(String),
+
+ #[error("Invalid identify count: {0}")]
+ InvalidIdentifyCount(String),
+
+ #[error("Redis error: {0}")]
+ RedisError(String),
+}
+
+/// Configuration for the CookielessManager
+#[derive(Debug, Clone)]
+pub struct CookielessConfig {
+ /// Whether cookieless mode is disabled
+ pub disabled: bool,
+ /// Whether to force stateless mode
+ pub force_stateless_mode: bool,
+ /// TTL for identifies (in seconds)
+ pub identifies_ttl_seconds: u64,
+ /// TTL for sessions (in seconds)
+ pub session_ttl_seconds: u64,
+ /// TTL for salts (in seconds)
+ pub salt_ttl_seconds: u64,
+ /// Session inactivity timeout (in milliseconds)
+ pub session_inactivity_ms: u64,
+}
+
+impl Default for CookielessConfig {
+ fn default() -> Self {
+ Self {
+ disabled: false,
+ force_stateless_mode: false,
+ identifies_ttl_seconds: IDENTIFIES_TTL_SECONDS,
+ session_ttl_seconds: SESSION_TTL_SECONDS,
+ salt_ttl_seconds: SALT_TTL_SECONDS,
+ session_inactivity_ms: SESSION_INACTIVITY_MS,
+ }
+ }
+}
+
+/// Parameters for computing a hash
+#[derive(Debug, Clone)]
+pub struct HashParams<'a> {
+ /// Timestamp in milliseconds
+ pub timestamp_ms: u64,
+ /// Event timezone
+ pub event_time_zone: Option<&'a str>,
+ /// Team timezone
+ pub team_time_zone: &'a str,
+ /// Team ID
+ pub team_id: u64,
+ /// IP address
+ pub ip: &'a str,
+ /// Host
+ pub host: &'a str,
+ /// User agent
+ pub user_agent: &'a str,
+ /// Counter value
+ pub n: u64,
+ /// Additional data to include in the hash
+ pub hash_extra: &'a str,
+}
+
+/// Data for an event to be processed by the cookieless manager
+#[derive(Debug, Clone)]
+pub struct EventData<'a> {
+ /// IP address
+ pub ip: &'a str,
+ /// Timestamp in milliseconds
+ pub timestamp_ms: u64,
+ /// Host
+ pub host: &'a str,
+ /// User agent
+ pub user_agent: &'a str,
+ /// Event timezone (optional)
+ pub event_time_zone: Option<&'a str>,
+ /// Additional data to include in the hash (optional)
+ pub hash_extra: Option<&'a str>,
+ /// Team ID
+ pub team_id: u64,
+ /// Team timezone (optional)
+ pub team_time_zone: Option<&'a str>,
+}
+
+/// Manager for cookieless tracking
+pub struct CookielessManager {
+ /// Configuration for the manager
+ pub config: CookielessConfig,
+ /// Salt cache for retrieving and storing salts
+ salt_cache: SaltCache,
+ /// Redis client for direct access
+ redis_client: Arc<dyn RedisClient + Send + Sync>,
+}
+
+impl CookielessManager {
+ /// Create a new CookielessManager
+ pub fn new(config: CookielessConfig, redis_client: Arc<dyn RedisClient + Send + Sync>) -> Self {
+ let salt_cache = SaltCache::new(redis_client.clone(), Some(config.salt_ttl_seconds));
+
+ Self {
+ config,
+ salt_cache,
+ redis_client,
+ }
+ }
+
+ /// Get the salt for a specific day (YYYY-MM-DD format)
+ pub async fn get_salt_for_day(
+ &self,
+ yyyymmdd: &str,
+ ) -> Result<Vec<u8>, CookielessManagerError> {
+ Ok(self.salt_cache.get_salt_for_day(yyyymmdd).await?)
+ }
+
+ /// Clear the salt cache
+ pub fn clear_cache(&self) {
+ self.salt_cache.clear_cache();
+ }
+
+ /// Compute a cookieless distinct ID for an event
+ pub async fn compute_cookieless_distinct_id(
+ &self,
+ event_data: EventData<'_>,
+ ) -> Result<String, CookielessManagerError> {
+ // If cookieless mode is disabled, return an error
+ if self.config.disabled {
+ return Err(CookielessManagerError::Disabled);
+ }
+
+ // Validate required fields
+ if event_data.ip.is_empty() {
+ return Err(CookielessManagerError::MissingProperty("ip".to_string()));
+ }
+ if event_data.host.is_empty() {
+ return Err(CookielessManagerError::MissingProperty("host".to_string()));
+ }
+ if event_data.user_agent.is_empty() {
+ return Err(CookielessManagerError::MissingProperty(
+ "user_agent".to_string(),
+ ));
+ }
+
+ // Get the team timezone or use UTC as fallback
+ let team_time_zone = event_data.team_time_zone.unwrap_or(TIMEZONE_FALLBACK);
+
+ // First, compute the hash with n=0 to get the base hash
+ let hash_params = HashParams {
+ timestamp_ms: event_data.timestamp_ms,
+ event_time_zone: event_data.event_time_zone,
+ team_time_zone,
+ team_id: event_data.team_id,
+ ip: event_data.ip,
+ host: event_data.host,
+ user_agent: event_data.user_agent,
+ n: 0,
+ hash_extra: event_data.hash_extra.unwrap_or(""),
+ };
+
+ // Compute the base hash
+ let base_hash = self.do_hash_for_day(hash_params.clone()).await?;
+
+ // If we're in stateless mode, use the base hash directly
+ if self.config.force_stateless_mode {
+ return Ok(Self::hash_to_distinct_id(&base_hash));
+ }
+
+ // Get the number of identify events for this hash
+ let n = self
+ .get_identify_count(&base_hash, event_data.team_id)
+ .await?;
+
+ // If n is 0, we can use the base hash
+ if n == 0 {
+ return Ok(Self::hash_to_distinct_id(&base_hash));
+ }
+
+ // Otherwise, recompute the hash with the correct n value
+ let hash_params_with_n = HashParams { n, ..hash_params };
+
+ // Compute the final hash
+ let final_hash = self.do_hash_for_day(hash_params_with_n).await?;
+
+ // Convert the hash to a distinct ID
+ Ok(Self::hash_to_distinct_id(&final_hash))
+ }
+
+ /// Compute a hash for a specific day
+ pub async fn do_hash_for_day(
+ &self,
+ params: HashParams<'_>,
+ ) -> Result<Vec<u8>, CookielessManagerError> {
+ let yyyymmdd = to_yyyy_mm_dd_in_timezone_safe(
+ params.timestamp_ms,
+ params.event_time_zone,
+ params.team_time_zone,
+ )?;
+ let salt = self.get_salt_for_day(&yyyymmdd).await?;
+
+ // Extract the root domain from the host
+ let root_domain = extract_root_domain(params.host)?;
+
+ // Compute the hash
+ Ok(do_hash(
+ &salt,
+ params.team_id,
+ params.ip,
+ &root_domain,
+ params.user_agent,
+ params.n,
+ params.hash_extra,
+ )?)
+ }
+
+ /// Convert a hash to a distinct ID
+ pub fn hash_to_distinct_id(hash: &[u8]) -> String {
+ format!(
+ "{}_{}",
+ COOKIELESS_DISTINCT_ID_PREFIX,
+ general_purpose::STANDARD.encode(hash).trim_end_matches('=')
+ )
+ }
+
+ /// Get the number of identify events for a specific hash
+ /// This is used to ensure that a user that logs in and out doesn't collide with themselves
+ pub async fn get_identify_count(
+ &self,
+ hash: &[u8],
+ team_id: u64,
+ ) -> Result<u64, CookielessManagerError> {
+ // If we're in stateless mode, always return 0
+ if self.config.force_stateless_mode {
+ return Ok(0);
+ }
+
+ // Get the Redis key for the identify count
+ let redis_key = get_redis_identifies_key(hash, team_id);
+
+ // Try to get the count from Redis
+ match self.redis_client.get(redis_key).await {
+ Ok(count_str) => {
+ // Parse the count string to a u64
+ count_str
+ .parse::<u64>()
+ .map_err(|e| CookielessManagerError::InvalidIdentifyCount(e.to_string()))
+ }
+ Err(common_redis::CustomRedisError::NotFound) => {
+ // If the key doesn't exist, the count is 0
+ Ok(0)
+ }
+ Err(e) => {
+ // If there's a Redis error, propagate it
+ Err(CookielessManagerError::RedisError(e.to_string()))
+ }
+ }
+ }
+}
+
+/// Extract the root domain from a host
+fn extract_root_domain(host: &str) -> Result<String, CookielessManagerError> {
+ // If the host contains a protocol, extract just the host part
+ let host_str = if host.contains("://") {
+ // Parse the URL to extract just the host
+ let url = Url::parse(host)?;
+ url.host_str().unwrap_or(host).to_string()