use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

use rand::Rng;

/// Short-lived single-use tickets that let `EventSource` clients open the SSE
/// stream without putting the JWT in the URL (where it would leak via access
/// logs / referer / browser history).
///
/// Flow: client `POST /api/v1/stream/ticket` with `Authorization: Bearer <jwt>`,
/// server returns an opaque ticket, client passes it as `?ticket=...` on the
/// stream open. Tickets are consumed on use and expire after `TTL`.
const TTL: Duration = Duration::from_secs(30);

#[derive(Clone)]
pub struct SseTicketStore {
    inner: Arc<Mutex<HashMap<String, Entry>>>,
}

#[derive(Clone)]
struct Entry {
    token_hash: String,
    issued_at: Instant,
}

impl SseTicketStore {
    pub fn new() -> Self {
        Self {
            inner: Arc::new(Mutex::new(HashMap::new())),
        }
    }

    /// Drop every outstanding ticket. Used by the e2e TRUNCATE endpoint: tickets are bound to a
    /// session token hash, and TRUNCATE deletes the sessions out from under them, so anything left
    /// here is a dangling reference to a user that no longer exists.
    pub fn clear(&self) {
        self.inner.lock().unwrap().clear();
    }

    /// Mint a new ticket bound to the caller's session (identified by token hash).
    pub fn issue(&self, token_hash: String) -> String {
        let ticket = random_ticket();
        let mut map = self.inner.lock().unwrap();
        map.insert(
            ticket.clone(),
            Entry {
                token_hash,
                issued_at: Instant::now(),
            },
        );
        ticket
    }

    /// Consume a ticket. Returns `Some(token_hash)` if the ticket exists and is
    /// not expired. Single-use: the ticket is removed regardless of whether it
    /// was still fresh, so a replay can't slip through after expiry.
    pub fn consume(&self, ticket: &str) -> Option<String> {
        let mut map = self.inner.lock().unwrap();
        let entry = map.remove(ticket)?;
        if entry.issued_at.elapsed() > TTL {
            return None;
        }
        Some(entry.token_hash)
    }

    /// Drop expired entries — called from the background maintenance task so a
    /// long-running process doesn't accumulate stale tickets.
    pub fn prune(&self) {
        let mut map = self.inner.lock().unwrap();
        map.retain(|_, e| e.issued_at.elapsed() <= TTL);
    }
}

fn random_ticket() -> String {
    // 192 bits of randomness, base32-ish hex. Plenty of entropy and URL-safe.
    let mut rng = rand::rng();
    let mut bytes = [0u8; 24];
    rng.fill(&mut bytes);
    bytes.iter().map(|b| format!("{b:02x}")).collect()
}

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

    #[test]
    fn issue_then_consume_returns_the_hash_exactly_once() {
        let store = SseTicketStore::new();
        let ticket = store.issue("hash-1".into());
        assert_eq!(store.consume(&ticket).as_deref(), Some("hash-1"));
        // Single-use: a replay of the same ticket is rejected.
        assert_eq!(
            store.consume(&ticket),
            None,
            "a consumed ticket must not be reusable"
        );
    }

    #[test]
    fn unknown_ticket_consumes_to_none() {
        let store = SseTicketStore::new();
        assert_eq!(store.consume("never-issued"), None);
    }

    #[test]
    fn issued_tickets_are_unique_and_hex() {
        let store = SseTicketStore::new();
        let a = store.issue("h".into());
        let b = store.issue("h".into());
        assert_ne!(a, b, "each ticket must be unique");
        assert_eq!(a.len(), 48, "24 random bytes → 48 hex chars");
        assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
    }

    #[test]
    fn fresh_ticket_survives_prune() {
        let store = SseTicketStore::new();
        let ticket = store.issue("h".into());
        store.prune(); // not expired → kept
        assert_eq!(store.consume(&ticket).as_deref(), Some("h"));
    }

    #[test]
    fn expired_ticket_consumes_to_none() {
        // Construct an entry that is already past the TTL and confirm consume() rejects it.
        let store = SseTicketStore::new();
        let stale = "stale-ticket".to_string();
        store.inner.lock().unwrap().insert(
            stale.clone(),
            Entry {
                token_hash: "h".into(),
                issued_at: Instant::now()
                    .checked_sub(TTL + Duration::from_secs(1))
                    .expect("host uptime should exceed the ticket TTL"),
            },
        );
        assert_eq!(
            store.consume(&stale),
            None,
            "an expired ticket must not authenticate"
        );
    }
}
