use std::{
  path::PathBuf,
  sync::{
    Arc,
    atomic::{AtomicU64, Ordering},
  },
  time::Instant,
};

use circus_binary_cache::StoreDir;
use circus_common::{
  models::{ApiKey, User},
  roles::GlobalRole,
};
use circus_config::Config;
use dashmap::DashMap;
use hmac::KeyInit;
use moka::sync::Cache;
use regex::Regex;
use sqlx::{ConnectOptions as _, PgPool, SqlitePool};
use tokio::sync::OnceCell;

/// How often the background cleanup task runs (every 5 minutes).
const SESSION_CLEANUP_INTERVAL: std::time::Duration =
  std::time::Duration::from_mins(5);

/// How long a cached narinfo stays in memory before eviction.
const NARINFO_CACHE_TTL: std::time::Duration =
  std::time::Duration::from_hours(1);

/// Hard cap on the number of cached narinfos.
const NARINFO_CACHE_MAX_ENTRIES: usize = 50_000;

/// How often accumulated cache-serving counters are drained to the
/// `cache_traffic` table (every 60 seconds).
const CACHE_TRAFFIC_FLUSH_INTERVAL: std::time::Duration =
  std::time::Duration::from_mins(1);

/// In-memory serving counters for one cache, accumulated by the cache handlers
/// and drained to the `cache_traffic` table by the flush worker. Atomic so
/// hot-path increments never take the `DashMap` write lock beyond entry lookup.
#[derive(Default)]
pub struct TrafficCounter {
  pub requests:     AtomicU64,
  pub bytes_served: AtomicU64,
}

impl TrafficCounter {
  /// Record one served response of `bytes` length.
  pub fn record(&self, bytes: u64) {
    self.requests.fetch_add(1, Ordering::Relaxed);
    self.bytes_served.fetch_add(bytes, Ordering::Relaxed);
  }
}

/// Per-cache serving counters keyed by cache name (`global` or project name).
pub type CacheTrafficCounters = Arc<DashMap<String, TrafficCounter>>;

/// Session data supporting both API key and user authentication
#[derive(Clone)]
pub struct SessionData {
  pub api_key:    Option<ApiKey>,
  pub user:       Option<User>,
  pub created_at: Instant,
}

impl SessionData {
  /// Check if the session has admin role
  #[must_use]
  pub fn is_admin(&self) -> bool {
    self.user.as_ref().map_or_else(
      || {
        self
          .api_key
          .as_ref()
          .is_some_and(|key| key.role == GlobalRole::Admin)
      },
      |user| user.role == GlobalRole::Admin,
    )
  }

  /// Check if the session has a specific role
  #[must_use]
  pub fn has_role(&self, role: GlobalRole) -> bool {
    if self.is_admin() {
      return true;
    }
    self.user.as_ref().map_or_else(
      || self.api_key.as_ref().is_some_and(|key| key.role == role),
      |user| user.role == role,
    )
  }

  /// Get the display name for the session (username or api key name)
  #[must_use]
  pub fn display_name(&self) -> String {
    self.user.as_ref().map_or_else(
      || {
        self
          .api_key
          .as_ref()
          .map_or_else(|| "Anonymous".to_string(), |key| key.name.clone())
      },
      |user| user.username.clone(),
    )
  }

  /// Check if this is a user session (not just API key)
  #[must_use]
  pub const fn is_user_session(&self) -> bool {
    self.user.is_some()
  }
}

pub type NarinfoCache = Cache<String, String>;

#[derive(Clone)]
pub struct NixStore {
  store_dir_path: PathBuf,
  store_dir:      StoreDir,
  db:             Arc<OnceCell<SqlitePool>>,
}

impl NixStore {
  /// Create a local Nix store handle.
  ///
  /// # Errors
  ///
  /// Returns an error if the configured path does not point to a valid
  /// Nix store root.
  pub fn new(store_dir: PathBuf) -> Result<Self, String> {
    Ok(Self {
      store_dir:      StoreDir::new(store_dir.clone())
        .map_err(|e| e.to_string())?,
      store_dir_path: store_dir,
      db:             Arc::new(OnceCell::new()),
    })
  }

  /// # Returns
  ///
  /// Returns the Nix store directory.
  #[must_use]
  pub fn store_dir(&self) -> StoreDir {
    self.store_dir.clone()
  }

  fn db_path(&self) -> Option<PathBuf> {
    self
      .store_dir_path
      .parent()
      .map(|root| root.join("var/nix/db/db.sqlite"))
  }

  /// Open the local Nix store database lazily for binary-cache serving.
  /// Missing or failed databases are not cached, so later cache requests retry.
  ///
  /// # Errors
  ///
  /// Returns a [`sqlx::Error`] if the database file exists but cannot be
  /// opened (e.g. due to permissions or corruption).
  pub async fn open_db(&self) -> Result<Option<&SqlitePool>, sqlx::Error> {
    let Some(db_path) = self.db_path() else {
      return Ok(None);
    };
    if !db_path.exists() {
      return Ok(None);
    }

    let options = sqlx::sqlite::SqliteConnectOptions::new()
      .filename(&db_path)
      .read_only(true)
      .create_if_missing(false)
      .disable_statement_logging();

    self
      .db
      .get_or_try_init(|| async { SqlitePool::connect_with(options).await })
      .await
      .map(Some)
  }
}

#[derive(Clone)]
pub struct AppState {
  pub pool:             PgPool,
  pub nix_store:        NixStore,
  pub config:           Config,
  pub sessions:         Arc<DashMap<String, SessionData>>,
  pub narinfo_cache:    NarinfoCache,
  pub http_client:      reqwest::Client,
  /// Per-process key used to derive CSRF tokens from session IDs via HMAC.
  /// Regenerated on every restart, which invalidates outstanding tokens; the
  /// dashboard re-issues them on the next page render so this is benign.
  pub csrf_secret:      Arc<[u8; 32]>,
  /// Compiled email validation regex from `server.email_validation_regex`.
  /// `None` means only structural checks (non-empty, contains `@`).
  pub email_regex:      Option<Arc<Regex>>,
  /// In-memory cache-serving counters, drained to `cache_traffic` every 60s
  /// by [`AppState::spawn_cache_traffic_flush`].
  pub cache_traffic:    CacheTrafficCounters,
  /// Our binary-cache public key.
  pub cache_public_key: Option<Arc<circus_binary_cache::PublicKey>>,
}

impl AppState {
  #[must_use]
  pub fn new_narinfo_cache() -> NarinfoCache {
    Cache::builder()
      .max_capacity(NARINFO_CACHE_MAX_ENTRIES as u64)
      .time_to_live(NARINFO_CACHE_TTL)
      .build()
  }

  /// Compute the CSRF token bound to a given session ID. Same input always
  /// produces the same output for the lifetime of the process; comparing
  /// with [`subtle::ConstantTimeEq`] avoids timing leaks.
  ///
  /// # Panics
  ///
  /// Panics if the HMAC key length is rejected by `Hmac::<Sha256>`.
  /// The key is always 32 bytes, which SHA-256 accepts.
  #[must_use]
  pub fn csrf_token_for(&self, session_id: &str) -> String {
    use hmac::{Hmac, Mac};
    use sha2::Sha256;
    #[expect(
      clippy::expect_used,
      reason = "32-byte key is always valid for HMAC-SHA256"
    )]
    let mut mac = Hmac::<Sha256>::new_from_slice(self.csrf_secret.as_ref())
      .expect("HMAC-SHA256 accepts any key length");
    mac.update(session_id.as_bytes());
    hex::encode(mac.finalize().into_bytes())
  }

  /// Record one served cache response against the named cache's in-memory
  /// counter. Cheap and lock-light; the flush worker persists the totals.
  pub fn record_cache_serve(&self, cache_name: &str, bytes: u64) {
    self
      .cache_traffic
      .entry(cache_name.to_owned())
      .or_default()
      .record(bytes);
  }
}

/// Marker placed in request extensions so dashboard handlers can render
/// the CSRF token in templates and validate it on POSTs without re-deriving
/// it from the session cookie themselves.
#[derive(Clone, Debug)]
pub struct CsrfToken(pub String);

impl AppState {
  /// Spawn a background task that periodically evicts expired legacy API-key
  /// dashboard sessions. User sessions are validated against `PostgreSQL`.
  pub fn spawn_session_cleanup(&self) {
    let sessions = Arc::clone(&self.sessions);
    tokio::spawn(async move {
      loop {
        tokio::time::sleep(SESSION_CLEANUP_INTERVAL).await;
        let before = sessions.len();
        sessions.retain(|_, session| {
          session.created_at.elapsed()
            < crate::session_cookie::API_KEY_SESSION_MAX_AGE
        });
        let evicted = before.saturating_sub(sessions.len());
        if evicted > 0 {
          tracing::debug!(
            evicted = evicted,
            remaining = sessions.len(),
            "Evicted expired sessions"
          );
        }
      }
    });
  }

  /// Spawn a background task that drains the in-memory cache-serving counters
  /// into the `cache_traffic` table once a minute, writing one row per cache
  /// that saw traffic in the interval.
  pub fn spawn_cache_traffic_flush(&self) {
    let counters = Arc::clone(&self.cache_traffic);
    let pool = self.pool.clone();
    tokio::spawn(async move {
      loop {
        tokio::time::sleep(CACHE_TRAFFIC_FLUSH_INTERVAL).await;
        let mut rows = Vec::new();
        for entry in counters.iter() {
          let requests = entry.requests.swap(0, Ordering::Relaxed);
          let bytes = entry.bytes_served.swap(0, Ordering::Relaxed);
          if requests > 0 || bytes > 0 {
            rows.push((
              entry.key().clone(),
              i64::try_from(requests).unwrap_or(i64::MAX),
              i64::try_from(bytes).unwrap_or(i64::MAX),
            ));
          }
        }
        if let Err(error) =
          circus_common::repo::cache_traffic::flush(&pool, &rows).await
        {
          tracing::warn!(%error, "Failed to flush cache traffic counters");
        }
      }
    });
  }
}
