//! Operator authentication — Sonarr/Radarr-style single-admin Forms model.
//!
//! On a fresh install, no admin account exists; the WebUI's first-run
//! flow prompts the operator to create one. After that the chosen
//! credentials gate `/api/*` access. The admin can rotate username +
//! password from Settings → Account.
//!
//! Multi-user RBAC isn't a goal for OpenPXE — the user explicitly asked
//! for "you have access or you don't". When SSO is configured, additional
//! users come in through the IdP; the locally-stored admin is the
//! fallback owner who can change SSO config or the seal-breaker for an
//! IdP outage. So one record is enough.
//!
//! Storage policy mirrors [`crate::host_bindings::HostBindings`] and
//! [`crate::boot_log::BootLog`]: in-memory authoritative; disk is the
//! crash-survival cache; a corrupt `auth.json` falls back to "no admin
//! configured" rather than blocking startup, which puts the UI back
//! into setup mode rather than locking the operator out.

use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use time::OffsetDateTime;

use crate::{Error, Result};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdminAccount {
    pub username: String,
    /// bcrypt hash (cost 10). The plaintext password never leaves the
    /// request that set it — same discipline as the per-ISO boot password.
    pub password_hash: String,
    #[serde(with = "time::serde::rfc3339")]
    pub created_at: OffsetDateTime,
    #[serde(with = "time::serde::rfc3339")]
    pub updated_at: OffsetDateTime,
}

/// Public projection — no hash, safe to ship to the WebUI.
#[derive(Debug, Clone, Serialize)]
pub struct AdminPublic {
    pub username: String,
    #[serde(with = "time::serde::rfc3339")]
    pub created_at: OffsetDateTime,
    #[serde(with = "time::serde::rfc3339")]
    pub updated_at: OffsetDateTime,
}

impl From<&AdminAccount> for AdminPublic {
    fn from(a: &AdminAccount) -> Self {
        Self {
            username: a.username.clone(),
            created_at: a.created_at,
            updated_at: a.updated_at,
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct Inner {
    admin: Option<AdminAccount>,
}

/// In-memory + on-disk admin registry. Cheap to clone.
#[derive(Debug, Clone)]
pub struct AdminStore {
    path: Arc<PathBuf>,
    inner: Arc<RwLock<Inner>>,
}

impl AdminStore {
    /// Load from `<work_dir>/auth.json`, or start empty. A bad file
    /// logs a warning and falls back to "no admin configured" — better
    /// to surface the setup flow than lock the operator out of their
    /// own install.
    #[must_use]
    pub fn load_or_default(work_dir: &std::path::Path) -> Self {
        let path = work_dir.join("auth.json");
        let inner = match std::fs::read_to_string(&path) {
            Ok(text) => match serde_json::from_str::<Inner>(&text) {
                Ok(parsed) => parsed,
                Err(e) => {
                    tracing::warn!(
                        target: "openpxe::auth",
                        "auth.json present but unreadable ({e}); starting in setup mode"
                    );
                    Inner::default()
                }
            },
            Err(_) => Inner::default(),
        };
        Self {
            path: Arc::new(path),
            inner: Arc::new(RwLock::new(inner)),
        }
    }

    /// Has an admin been bootstrapped? Drives the first-run / login
    /// fork in the HTTP layer.
    #[must_use]
    pub fn is_configured(&self) -> bool {
        self.inner.read().admin.is_some()
    }

    /// Public-safe snapshot for the WebUI.
    #[must_use]
    pub fn snapshot(&self) -> Option<AdminPublic> {
        self.inner.read().admin.as_ref().map(AdminPublic::from)
    }

    /// First-run setup: create the admin account. Fails if one already
    /// exists — the HTTP layer surfaces that as 409.
    pub fn bootstrap(&self, username: &str, password: &str) -> Result<AdminPublic> {
        validate_username(username)?;
        validate_password(password)?;
        let hash = bcrypt_hash(password)?;
        let now = OffsetDateTime::now_utc();
        let admin = AdminAccount {
            username: username.trim().to_string(),
            password_hash: hash,
            created_at: now,
            updated_at: now,
        };
        {
            let mut g = self.inner.write();
            if g.admin.is_some() {
                return Err(Error::Invalid(
                    "admin account already configured".into(),
                ));
            }
            g.admin = Some(admin.clone());
        }
        self.persist();
        tracing::info!(
            target: "openpxe::auth",
            username = %admin.username,
            "admin account created (first-run setup)"
        );
        Ok((&admin).into())
    }

    /// Verify credentials. Returns the admin record (public projection)
    /// on success, `Ok(None)` on mismatch, `Err` on systemic bcrypt
    /// failure (treated as "auth not available right now" by callers).
    pub fn verify(&self, username: &str, password: &str) -> Result<Option<AdminPublic>> {
        let Some(admin) = self.inner.read().admin.clone() else {
            return Ok(None);
        };
        if username.trim() != admin.username {
            return Ok(None);
        }
        // bcrypt compares in constant time relative to the same hash.
        // Doing the username check first is fine — a username mismatch
        // returns immediately, but the only thing leaked is "this isn't
        // the admin's username" which the operator already knows.
        match bcrypt::verify(password, &admin.password_hash) {
            Ok(true) => Ok(Some((&admin).into())),
            Ok(false) => Ok(None),
            Err(e) => Err(Error::Other(e.into())),
        }
    }

    /// Rotate username and/or password. `current_password` must match
    /// the *existing* hash — same flow as Sonarr's "current password
    /// required to change". `new_username`/`new_password` are optional:
    /// pass only what you want to change.
    pub fn update_credentials(
        &self,
        current_password: &str,
        new_username: Option<&str>,
        new_password: Option<&str>,
    ) -> Result<AdminPublic> {
        // Re-check ownership before any state mutation.
        let existing = self
            .inner
            .read()
            .admin
            .clone()
            .ok_or_else(|| Error::Invalid("no admin configured".into()))?;
        match bcrypt::verify(current_password, &existing.password_hash) {
            Ok(true) => {}
            Ok(false) => return Err(Error::Invalid("current password is incorrect".into())),
            Err(e) => return Err(Error::Other(e.into())),
        }

        let mut updated = existing.clone();
        if let Some(u) = new_username {
            validate_username(u)?;
            updated.username = u.trim().to_string();
        }
        if let Some(p) = new_password {
            validate_password(p)?;
            updated.password_hash = bcrypt_hash(p)?;
        }
        updated.updated_at = OffsetDateTime::now_utc();

        {
            let mut g = self.inner.write();
            g.admin = Some(updated.clone());
        }
        self.persist();
        tracing::info!(
            target: "openpxe::auth",
            username = %updated.username,
            "admin credentials updated"
        );
        Ok((&updated).into())
    }

    fn persist(&self) {
        let snap = self.inner.read().clone();
        let body = match serde_json::to_vec_pretty(&snap) {
            Ok(b) => b,
            Err(e) => {
                tracing::warn!(target: "openpxe::auth", "serialize auth.json: {e}");
                return;
            }
        };
        if let Some(parent) = self.path.parent() {
            let _ = std::fs::create_dir_all(parent);
        }
        let tmp = self.path.with_extension("json.tmp");
        if let Err(e) = std::fs::write(&tmp, body) {
            tracing::warn!(target: "openpxe::auth", "write auth.json tmp: {e}");
            return;
        }
        if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) {
            tracing::warn!(target: "openpxe::auth", "rename auth.json: {e}");
        }
    }
}

fn validate_username(u: &str) -> Result<()> {
    let u = u.trim();
    if u.is_empty() {
        return Err(Error::Invalid("username must not be empty".into()));
    }
    if u.len() > 64 {
        return Err(Error::Invalid("username must be 64 chars or fewer".into()));
    }
    if !u.chars().all(|c| c.is_ascii_graphic() && c != ':') {
        return Err(Error::Invalid(
            "username must be ASCII printable with no ':' character".into(),
        ));
    }
    Ok(())
}

fn validate_password(p: &str) -> Result<()> {
    if p.len() < 8 {
        return Err(Error::Invalid(
            "password must be at least 8 characters".into(),
        ));
    }
    if p.len() > 256 {
        return Err(Error::Invalid(
            "password must be 256 characters or fewer".into(),
        ));
    }
    Ok(())
}

fn bcrypt_hash(password: &str) -> Result<String> {
    bcrypt::hash(password, bcrypt::DEFAULT_COST).map_err(|e| Error::Other(e.into()))
}

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

    #[test]
    fn empty_after_load_when_no_file() {
        let dir = tempdir().unwrap();
        let s = AdminStore::load_or_default(dir.path());
        assert!(!s.is_configured());
        assert!(s.snapshot().is_none());
    }

    #[test]
    fn bootstrap_then_verify_round_trip() {
        let dir = tempdir().unwrap();
        let s = AdminStore::load_or_default(dir.path());
        let pub_ = s.bootstrap("admin", "hunter2hunter2").unwrap();
        assert_eq!(pub_.username, "admin");
        assert!(s.is_configured());

        // Correct creds match; wrong creds don't.
        assert!(s.verify("admin", "hunter2hunter2").unwrap().is_some());
        assert!(s.verify("admin", "wrong").unwrap().is_none());
        assert!(s.verify("nobody", "hunter2hunter2").unwrap().is_none());
    }

    #[test]
    fn bootstrap_rejects_second_call() {
        let dir = tempdir().unwrap();
        let s = AdminStore::load_or_default(dir.path());
        s.bootstrap("admin", "hunter2hunter2").unwrap();
        let r = s.bootstrap("other", "anotherpass1");
        assert!(matches!(r, Err(Error::Invalid(_))));
    }

    #[test]
    fn round_trip_survives_disk_reload() {
        let dir = tempdir().unwrap();
        let s = AdminStore::load_or_default(dir.path());
        s.bootstrap("admin", "hunter2hunter2").unwrap();
        drop(s);
        let s2 = AdminStore::load_or_default(dir.path());
        assert!(s2.is_configured());
        assert!(s2.verify("admin", "hunter2hunter2").unwrap().is_some());
    }

    #[test]
    fn update_credentials_requires_current_password() {
        let dir = tempdir().unwrap();
        let s = AdminStore::load_or_default(dir.path());
        s.bootstrap("admin", "hunter2hunter2").unwrap();
        // Wrong current password → no change.
        let r = s.update_credentials("nope", None, Some("newpassword1"));
        assert!(matches!(r, Err(Error::Invalid(_))));
        assert!(s.verify("admin", "hunter2hunter2").unwrap().is_some());

        // Correct current password rotates only what's supplied.
        s.update_credentials("hunter2hunter2", Some("alice"), Some("newpassword1"))
            .unwrap();
        assert!(s.verify("admin", "hunter2hunter2").unwrap().is_none());
        assert!(s.verify("alice", "newpassword1").unwrap().is_some());
    }

    #[test]
    fn update_credentials_partial_password_only_keeps_username() {
        let dir = tempdir().unwrap();
        let s = AdminStore::load_or_default(dir.path());
        s.bootstrap("admin", "hunter2hunter2").unwrap();
        s.update_credentials("hunter2hunter2", None, Some("newpassword1"))
            .unwrap();
        assert!(s.verify("admin", "newpassword1").unwrap().is_some());
    }

    #[test]
    fn validates_username_and_password() {
        let dir = tempdir().unwrap();
        let s = AdminStore::load_or_default(dir.path());
        assert!(s.bootstrap("", "hunter2hunter2").is_err());
        assert!(s.bootstrap("ad:min", "hunter2hunter2").is_err()); // ':' reserved
        assert!(s.bootstrap("admin", "short").is_err()); // <8 chars
        // 65-char username is too long.
        let long = "a".repeat(65);
        assert!(s.bootstrap(&long, "hunter2hunter2").is_err());
    }
}
