//! Runtime-updatable crawler session (PHPSESSID).
//!
//! At startup the session comes from `CRAWLER_PHPSESSID`, but it expires
//! and previously needed a container restart to refresh. This controller
//! lets an admin push a fresh cookie at runtime: it rewrites the reqwest
//! cookie jar (CDN image fetches), updates the in-memory value the browser
//! `on_launch` hook reads, persists it to `crawler_state` (so it survives
//! a restart), and clears the sticky `session_expired` flag. A subsequent
//! coordinated browser restart re-runs `on_launch`, re-injecting the new
//! cookie into Chromium and re-probing.

use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;

use anyhow::Context;
use sqlx::PgPool;
use tokio::sync::RwLock;

use crate::repo;

pub struct SessionController {
    /// Current PHPSESSID — what `on_launch` injects into a fresh browser.
    phpsessid: RwLock<Option<String>>,
    /// The same `Arc<Jar>` handed to the reqwest client; updating it here
    /// updates the client's cookies (the jar is internally mutable).
    cookie_jar: Arc<reqwest::cookie::Jar>,
    cookie_domain: Option<String>,
    start_url: Option<String>,
    db: PgPool,
    session_expired: Arc<AtomicBool>,
}

impl SessionController {
    pub fn new(
        initial: Option<String>,
        cookie_jar: Arc<reqwest::cookie::Jar>,
        cookie_domain: Option<String>,
        start_url: Option<String>,
        db: PgPool,
        session_expired: Arc<AtomicBool>,
    ) -> Arc<Self> {
        Arc::new(Self {
            phpsessid: RwLock::new(initial),
            cookie_jar,
            cookie_domain,
            start_url,
            db,
            session_expired,
        })
    }

    /// The PHPSESSID a fresh browser should inject (None when unset).
    pub async fn current(&self) -> Option<String> {
        self.phpsessid.read().await.clone()
    }

    /// Whether the sticky session-expired flag is set (chapter workers
    /// idle while true).
    pub fn is_expired(&self) -> bool {
        self.session_expired.load(Ordering::Acquire)
    }

    /// Clear the session-expired flag without changing the cookie — used
    /// when the operator knows the session is fine and wants workers to
    /// resume immediately.
    pub fn clear_expired(&self) {
        self.session_expired.store(false, Ordering::Release);
    }

    /// Update the session everywhere: reqwest jar, in-memory value, and
    /// persisted `crawler_state`. Clears the session-expired flag. Does
    /// NOT relaunch the browser — the caller triggers a coordinated
    /// restart so `on_launch` re-injects + re-probes.
    pub async fn update(&self, sid: &str) -> anyhow::Result<()> {
        let sid = sid.trim().to_string();
        anyhow::ensure!(!sid.is_empty(), "PHPSESSID must not be empty");
        // The value is spliced into a cookie string and a CDP CookieParam.
        // PHPSESSID values produced by PHP are URL-safe base64 alphanumerics
        // plus a small set of punctuation depending on session.sid_bits_per_
        // character. An allow-list (rather than a blocklist of control chars
        // + `;,`) makes the check robust against future cookie syntax
        // extensions and forces a paste that includes whitespace, quotes,
        // backslashes, etc. — typical signs of a botched copy-paste — to
        // be rejected early.
        anyhow::ensure!(
            sid.chars().all(is_phpsessid_char),
            "PHPSESSID contains invalid characters"
        );

        if let (Some(domain), Some(start_url)) = (&self.cookie_domain, &self.start_url) {
            let cookie_str = format!("PHPSESSID={sid}; Domain={domain}; Path=/");
            let seed_url =
                reqwest::Url::parse(start_url).context("parse start_url for cookie seed")?;
            self.cookie_jar.add_cookie_str(&cookie_str, &seed_url);
        }
        *self.phpsessid.write().await = Some(sid.clone());
        repo::crawler::runtime_session_persist(&self.db, &sid)
            .await
            .context("persist runtime session")?;
        self.session_expired.store(false, Ordering::Release);
        tracing::info!("crawler session updated at runtime");
        Ok(())
    }

    /// Read a persisted runtime session (if any) from `crawler_state`.
    /// Called at startup so a mid-day refresh survives a restart.
    pub async fn load_persisted(db: &PgPool) -> Option<String> {
        repo::crawler::runtime_session_load(db).await.ok().flatten()
    }
}

/// Characters allowed in a PHPSESSID. PHP's session.sid_bits_per_character
/// produces alphanumerics plus `-` and `,` in the lowest-bit mode, but our
/// audit rejects `,` (cookie delimiter) — operators paste from a browser
/// devtools snapshot, which never embeds raw commas in the SID itself.
/// Underscore is allowed because some sources customise their session
/// alphabet.
fn is_phpsessid_char(c: char) -> bool {
    c.is_ascii_alphanumeric() || matches!(c, '-' | '_')
}

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

    fn controller(db: PgPool) -> Arc<SessionController> {
        SessionController::new(
            None,
            Arc::new(reqwest::cookie::Jar::default()),
            Some("example.com".into()),
            Some("https://example.com/".into()),
            db,
            Arc::new(AtomicBool::new(true)),
        )
    }

    #[sqlx::test(migrations = "./migrations")]
    async fn update_rejects_empty_and_control_chars(pool: PgPool) {
        let c = controller(pool);
        assert!(c.update("   ").await.is_err(), "empty rejected");
        assert!(c.update("abc\r\ndef").await.is_err(), "CRLF rejected");
        assert!(c.update("ab;Domain=evil").await.is_err(), "semicolon rejected");
        assert!(c.update("x,y").await.is_err(), "comma rejected");
    }

    #[sqlx::test(migrations = "./migrations")]
    async fn update_rejects_non_alphanumeric_pastes(pool: PgPool) {
        // Allow-list tightening (M6): pastes that include whitespace,
        // quotes, slashes, backslashes, `=`, etc. are typical signs of a
        // botched copy-paste and must be rejected outright.
        let c = controller(pool);
        for bad in ["ab cd", "ab\"cd", "ab=cd", "ab/cd", "ab\\cd", "ab+cd", "ab.cd"] {
            assert!(c.update(bad).await.is_err(), "{bad:?} should be rejected");
        }
        // Allowed cases (sanity): plain alphanumerics, '-' and '_'.
        assert!(c.update("abc_DEF-123").await.is_ok());
    }

    #[sqlx::test(migrations = "./migrations")]
    async fn update_persists_and_clears_expired_then_round_trips(pool: PgPool) {
        let c = controller(pool.clone());
        c.update("good-sid-123").await.unwrap();
        assert_eq!(c.current().await.as_deref(), Some("good-sid-123"));
        assert!(!c.is_expired(), "update clears the expired flag");
        // Persisted to crawler_state and readable by a fresh load.
        assert_eq!(
            SessionController::load_persisted(&pool).await.as_deref(),
            Some("good-sid-123")
        );
    }

    #[sqlx::test(migrations = "./migrations")]
    async fn clear_expired_flips_sticky_flag_without_touching_session(pool: PgPool) {
        // The flag starts `true` per `controller(pool)`'s test wiring.
        let c = controller(pool);
        assert!(c.is_expired(), "test fixture starts with the flag set");
        c.clear_expired();
        assert!(!c.is_expired(), "clear_expired flips the sticky flag to false");
        assert!(
            c.current().await.is_none(),
            "clear_expired does not invent a session"
        );
    }
}
