use std::path::PathBuf;

use anyhow::{Context, Result, anyhow};

/// Well-known dev JWT secret shipped in `.env.example`. If APP_ENV=production
/// we refuse to start with this value; otherwise we warn loudly.
const DEV_JWT_SECRET_SENTINEL: &str = "dev_secret_do_not_use_in_production_32byteslong_aaaa";

/// A secret is "placeholder-ish" if it's the shipped dev sentinel or still carries
/// the tell-tale scaffolding substrings from `.env.example`. Length alone is not
/// enough — the shipped `change_me_...` placeholder is >32 chars.
fn looks_placeholder(s: &str) -> bool {
    let lower = s.to_ascii_lowercase();
    s == DEV_JWT_SECRET_SENTINEL
        || lower.contains("change_me")
        || lower.contains("dev_secret")
        || lower.contains("placeholder")
}

/// A bcrypt hash is exactly 60 characters and opens with `$2<variant>$<cost>$`.
///
/// Checking the SHAPE, not just placeholder-ness, is what catches a hash silently mangled in
/// transit. The `$` segments are variable-expansion bait for both shell quoting and Docker
/// Compose's `env_file` parsing, and a mangled hash is not a placeholder — so without this it
/// passes every other guard here, the app boots green, `/health` returns `ok`, and every admin
/// login 401s.
///
/// That failure is unrecoverable mid-event, which is why it is worth a hard fail at boot: the
/// Admin row is created BY a successful admin login (`auth/handlers.rs`), and only an Admin or
/// Host can promote a Host. No admin login therefore means no host at all — the event cannot be
/// closed, the gallery cannot be released, and nothing can be moderated.
fn looks_bcrypt(s: &str) -> bool {
    let b = s.as_bytes();
    s.len() == 60
        && b[0] == b'$'
        && b[1] == b'2'
        && matches!(b[2], b'a' | b'b' | b'x' | b'y')
        && b[3] == b'$'
        && b[4].is_ascii_digit()
        && b[5].is_ascii_digit()
        && b[6] == b'$'
}

/// Enforce secret hygiene. In production every guard is hard-fail: a booting app
/// with a publicly-known signing key is worse than one that refuses to start.
/// Outside production the dev sentinel is tolerated (warned) so local dev is frictionless.
///
/// EVERY failure is collected and reported together. Returning on the first one made fixing two
/// secrets cost two boot cycles — the operator rotates JWT_SECRET, restarts, and only then learns
/// about ADMIN_PASSWORD_HASH. Restarting this stack is not free (Caddy waits on the unhealthy app),
/// and each avoidable cycle is another chance to reach for `down -v`.
fn validate_secrets(
    is_prod: bool,
    jwt_secret: &str,
    admin_password_hash: &str,
    database_url: &str,
) -> Result<()> {
    if is_prod {
        let mut problems: Vec<&str> = Vec::new();
        if looks_placeholder(jwt_secret) {
            problems.push(
                "JWT_SECRET is still the .env.example placeholder — rotate it \
                 (openssl rand -hex 64).",
            );
        } else if jwt_secret.len() < 32 {
            problems.push("JWT_SECRET must be at least 32 characters.");
        }
        if admin_password_hash.is_empty() || looks_placeholder(admin_password_hash) {
            problems.push(
                "ADMIN_PASSWORD_HASH is unset or still the .env.example placeholder — generate one \
                 (docker run --rm caddy:2-alpine caddy hash-password --plaintext '<password>').",
            );
        } else if !looks_bcrypt(admin_password_hash) {
            problems.push(
                "ADMIN_PASSWORD_HASH is not a well-formed bcrypt hash: expected exactly 60 \
                 characters starting `$2b$12$…`. First look at the value that actually reached \
                 the app — `docker compose exec app printenv ADMIN_PASSWORD_HASH` — and compare \
                 it to .env character for character. In .env, SINGLE-QUOTE the hash \
                 ('$2b$12$…'): Compose uses single-quoted env_file values literally, so the `$` \
                 segments survive. Double them to `$$` ONLY when setting the value under \
                 `environment:` in docker-compose.yml — doing that in .env corrupts a hash that \
                 would otherwise have worked. Regenerate with: \
                 docker run --rm caddy:2-alpine caddy hash-password --plaintext '<password>'",
            );
        }
        // The DATABASE_URL carries the Postgres password, so a placeholder here means the stack is
        // running on `CHANGE_ME_use_a_strong_password` — a credential published in the repo. The
        // app used to boot green on it, because this guard only ever covered the two secrets it
        // was written for and nothing else looked at POSTGRES_PASSWORD at all.
        //
        // Read POSTGRES_PASSWORD's docs before changing this: it is applied ONLY at initdb, so the
        // remedy is not "edit .env and restart" — see the 28P01 diagnostic in db.rs.
        if looks_placeholder(database_url) {
            problems.push(
                "DATABASE_URL still carries the .env.example placeholder password — set a strong \
                 one (openssl rand -hex 24) in BOTH DATABASE_URL and POSTGRES_PASSWORD.",
            );
        }
        if !problems.is_empty() {
            return Err(anyhow!(
                "Refusing to start in production — {} secret(s) still unset or placeholder:\n  - {}\n\
                 ALL secrets must be set BEFORE the first `docker compose up -d`: Postgres bakes \
                 POSTGRES_PASSWORD into its data directory on first boot and ignores later changes.",
                problems.len(),
                problems.join("\n  - ")
            ));
        }
    } else if jwt_secret == DEV_JWT_SECRET_SENTINEL {
        tracing::warn!(
            "JWT_SECRET is the dev sentinel — fine for local development, NEVER ship this."
        );
    } else if jwt_secret.len() < 32 {
        return Err(anyhow!("JWT_SECRET must be at least 32 characters."));
    }
    Ok(())
}

#[derive(Clone, Debug)]
pub struct AppConfig {
    pub database_url: String,
    pub jwt_secret: String,
    pub session_expiry_days: i64,
    pub admin_password_hash: String,
    pub event_name: String,
    pub event_slug: String,
    pub media_path: PathBuf,
    /// Where export archives are written. MUST be outside `media_path` — that
    /// directory is served by a public `ServeDir`, so a predictable archive name
    /// under it would leak the whole gallery to anonymous visitors.
    pub export_path: PathBuf,
    pub app_port: u16,
    /// Number of concurrent media compression workers (read once at boot).
    pub compression_concurrency: usize,
    /// Master switch for the comment feature (env `COMMENTS_ENABLED`, default true).
    /// When false the backend rejects new comments and the frontend hides the whole
    /// comment UI. Existing comments stay in the DB (hidden), so flipping it back
    /// restores them. Boot-time immutable, like `compression_concurrency`.
    pub comments_enabled: bool,
    /// Default colour theme, used as the fallback when the DB config keys are unset.
    /// Runtime overrides live in the `config` table (admin UI); these env vars only
    /// seed the initial default. `preset` is an id the frontend knows (e.g.
    /// "champagne-gold", "rose", … or "custom"); the two seeds are `#rrggbb` brand +
    /// accent colours the whole palette is derived from.
    pub default_theme_preset: String,
    pub default_theme_primary: String,
    pub default_theme_accent: String,
}

/// The shipped default brand/accent seed (champagne gold — matches the hand-tuned
/// ramp in tailwind-theme.css). Kept here so an unset env still yields the current look.
const DEFAULT_THEME_SEED: &str = "#8a6a2b";

/// Upper bound on `SESSION_EXPIRY_DAYS`. ~10 years — absurdly generous for a one-evening event,
/// and low enough that `chrono::Duration::days` cannot overflow downstream.
const MAX_SESSION_EXPIRY_DAYS: i64 = 3650;

/// Parse and RANGE-CHECK `SESSION_EXPIRY_DAYS`. Refusing to boot is the whole point.
///
/// This was `.parse().context(...)` with no bounds, and both ends of the range were live faults
/// that a green health check hid completely (H7):
///
///   * A huge value made `chrono::Duration::days` PANIC on every `/join`, `/recover` and
///     `/admin/login`. There is no `CatchPanicLayer`, so the client got a connection reset with no
///     HTTP response at all — the app was up, healthy, and unable to authenticate anybody.
///   * Zero or negative created every session already-expired: `/join` returns 201 with a token,
///     and then every authenticated request 401s. A guest joins successfully and the app
///     immediately behaves as though they never did.
///
/// Both booted green because `/health` only probes the database. A bad value must stop the
/// container instead, where the operator sees it.
fn parse_session_expiry_days(raw: Option<&str>) -> Result<i64> {
    let Some(raw) = raw else { return Ok(30) };
    let trimmed = raw.trim();
    if trimmed.is_empty() {
        return Ok(30);
    }
    let days: i64 = trimmed
        .parse()
        .with_context(|| format!("SESSION_EXPIRY_DAYS must be a whole number (got {trimmed:?})"))?;
    if days < 1 {
        return Err(anyhow!(
            "SESSION_EXPIRY_DAYS must be at least 1 (got {days}). Zero or negative makes every \
             session expire the moment it is created: /join succeeds and every request after it \
             returns 401."
        ));
    }
    if days > MAX_SESSION_EXPIRY_DAYS {
        return Err(anyhow!(
            "SESSION_EXPIRY_DAYS must be at most {MAX_SESSION_EXPIRY_DAYS} (got {days}). Larger \
             values overflow the token-expiry arithmetic and panic on every auth request."
        ));
    }
    Ok(days)
}

impl AppConfig {
    pub fn from_env() -> Result<Self> {
        let app_env = std::env::var("APP_ENV").unwrap_or_else(|_| "development".to_string());
        let is_prod = app_env.eq_ignore_ascii_case("production");

        let jwt_secret = std::env::var("JWT_SECRET").context("JWT_SECRET must be set")?;
        let admin_password_hash = std::env::var("ADMIN_PASSWORD_HASH").unwrap_or_default();
        let database_url = std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?;

        validate_secrets(is_prod, &jwt_secret, &admin_password_hash, &database_url)?;

        Ok(Self {
            database_url,
            jwt_secret,
            session_expiry_days: parse_session_expiry_days(
                std::env::var("SESSION_EXPIRY_DAYS").ok().as_deref(),
            )?,
            admin_password_hash,
            event_name: std::env::var("EVENT_NAME").unwrap_or_else(|_| "EventSnap".to_string()),
            event_slug: std::env::var("EVENT_SLUG").context("EVENT_SLUG must be set")?,
            media_path: PathBuf::from(
                std::env::var("MEDIA_PATH").unwrap_or_else(|_| "/media".to_string()),
            ),
            export_path: PathBuf::from(
                std::env::var("EXPORT_PATH").unwrap_or_else(|_| "/exports".to_string()),
            ),
            app_port: std::env::var("APP_PORT")
                .unwrap_or_else(|_| "3000".to_string())
                .parse()
                .context("APP_PORT must be a number")?,
            compression_concurrency: std::env::var("COMPRESSION_WORKER_CONCURRENCY")
                .ok()
                .and_then(|v| v.parse().ok())
                .filter(|&n| n >= 1)
                .unwrap_or(2),
            comments_enabled: std::env::var("COMMENTS_ENABLED")
                .map(|v| {
                    !matches!(
                        v.trim().to_ascii_lowercase().as_str(),
                        "false" | "0" | "no" | "off"
                    )
                })
                .unwrap_or(true),
            default_theme_preset: std::env::var("THEME_PRESET")
                .unwrap_or_else(|_| "champagne-gold".to_string()),
            default_theme_primary: std::env::var("THEME_PRIMARY")
                .unwrap_or_else(|_| DEFAULT_THEME_SEED.to_string()),
            default_theme_accent: std::env::var("THEME_ACCENT")
                .unwrap_or_else(|_| DEFAULT_THEME_SEED.to_string()),
        })
    }
}

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

    const REAL_SECRET: &str = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2";
    // A structurally valid bcrypt hash: exactly 60 chars, `$2y$12$` + 53 of salt/digest.
    // The shape matters — `looks_bcrypt` enforces it, so a fixture of the wrong length
    // would assert the opposite of what these tests claim.
    const REAL_HASH: &str = "$2y$12$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXY01";
    const REAL_DB_URL: &str = "postgres://eventsnap:7f3a9c1e5b2d8a4f@db:5432/eventsnap";

    #[test]
    fn prod_rejects_shipped_placeholder_secret() {
        // The exact string shipped in `.env` — >32 chars, so it must be caught by
        // the substring guard, not the length check.
        let err = validate_secrets(
            true,
            "change_me_to_a_random_64_byte_hex_string",
            REAL_HASH,
            REAL_DB_URL,
        );
        assert!(
            err.is_err(),
            "placeholder JWT_SECRET must be rejected in prod"
        );
    }

    #[test]
    fn prod_rejects_dev_sentinel_and_short_secret() {
        assert!(validate_secrets(true, DEV_JWT_SECRET_SENTINEL, REAL_HASH, REAL_DB_URL).is_err());
        assert!(validate_secrets(true, "tooshort", REAL_HASH, REAL_DB_URL).is_err());
    }

    /// The failure this guards is silent and unrecoverable mid-event: a hash whose `$`
    /// segments were eaten by shell or Compose interpolation is NOT a placeholder, so every
    /// other guard passes, the app boots green and `/health` reports ok — and then every
    /// admin login 401s, which (because the Admin row is created by a successful login, and
    /// only an Admin/Host can promote a Host) means no host exists for the whole event.
    #[test]
    fn prod_rejects_mangled_admin_hash() {
        // What `$2y$12$…` degrades to once `$2y`/`$12` are read as unset variables.
        assert!(validate_secrets(true, REAL_SECRET, "abcdefghijklmnop", REAL_DB_URL).is_err());
        // Right prefix, truncated body — still not a usable hash.
        assert!(validate_secrets(true, REAL_SECRET, "$2y$12$tooshort", REAL_DB_URL).is_err());
        // Correct length but no bcrypt prefix at all.
        assert!(validate_secrets(true, REAL_SECRET, &"x".repeat(60), REAL_DB_URL).is_err());
        // All three shipped bcrypt variants stay acceptable.
        let body = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXY01";
        for variant in ["2a", "2b", "2y"] {
            let hash = format!("${variant}$12${body}");
            assert_eq!(hash.len(), 60);
            assert!(
                validate_secrets(true, REAL_SECRET, &hash, REAL_DB_URL).is_ok(),
                "bcrypt variant ${variant}$ must be accepted"
            );
        }
    }

    #[test]
    fn prod_rejects_missing_or_placeholder_admin_hash() {
        assert!(validate_secrets(true, REAL_SECRET, "", REAL_DB_URL).is_err());
        assert!(
            validate_secrets(
                true,
                REAL_SECRET,
                "$2y$12$placeholder_replace_me",
                REAL_DB_URL
            )
            .is_err()
        );
    }

    /// The stack used to come up GREEN on the database password published in the repo: this guard
    /// covered the two secrets it was written for, and nothing anywhere looked at the Postgres
    /// credential. README step 2 doesn't name POSTGRES_PASSWORD either, so following the
    /// documented procedure verbatim shipped it.
    #[test]
    fn prod_rejects_the_shipped_placeholder_database_password() {
        let shipped = "postgres://eventsnap:CHANGE_ME_use_a_strong_password@db:5432/eventsnap";
        let err = validate_secrets(true, REAL_SECRET, REAL_HASH, shipped).unwrap_err();
        assert!(
            err.to_string().contains("DATABASE_URL"),
            "the refusal must name DATABASE_URL, not just fail: {err}"
        );
        // And it must point at the initdb trap, or the operator edits .env, restarts, and lands
        // in a permanent auth-failure loop instead.
        assert!(
            err.to_string().contains("POSTGRES_PASSWORD"),
            "the refusal must name POSTGRES_PASSWORD as the other half: {err}"
        );
    }

    /// Every problem in ONE message. Reporting them one per boot made fixing two secrets cost two
    /// restart cycles, on a stack where Caddy waits on the unhealthy app the whole time.
    #[test]
    fn prod_reports_every_placeholder_at_once() {
        let err = validate_secrets(
            true,
            "change_me_to_a_random_64_byte_hex_string",
            "$2y$12$placeholder_replace_me",
            "postgres://eventsnap:CHANGE_ME_use_a_strong_password@db:5432/eventsnap",
        )
        .unwrap_err()
        .to_string();
        for expected in ["JWT_SECRET", "ADMIN_PASSWORD_HASH", "DATABASE_URL"] {
            assert!(err.contains(expected), "{expected} missing from: {err}");
        }
        assert!(
            err.contains("3 secret(s)"),
            "the count must match what is listed: {err}"
        );
    }

    #[test]
    fn prod_accepts_real_secrets() {
        assert!(validate_secrets(true, REAL_SECRET, REAL_HASH, REAL_DB_URL).is_ok());
    }

    /// A real password that happens to contain no placeholder substring must pass — including one
    /// with URL-ish punctuation, so the guard can't be mistaken for a URL validator.
    #[test]
    fn prod_accepts_a_real_database_url_with_awkward_punctuation() {
        assert!(
            validate_secrets(
                true,
                REAL_SECRET,
                REAL_HASH,
                "postgres://eventsnap:aB3%24xY9-_.qW@db:5432/eventsnap"
            )
            .is_ok()
        );
    }

    /// The e2e stack runs without APP_ENV=production, so none of this applies there — but assert
    /// it, because a guard that tripped in e2e would be found the hard way.
    #[test]
    fn non_prod_ignores_a_placeholder_database_url() {
        assert!(
            validate_secrets(
                false,
                REAL_SECRET,
                "",
                "postgres://eventsnap:CHANGE_ME_use_a_strong_password@db:5432/eventsnap"
            )
            .is_ok()
        );
    }

    #[test]
    fn non_prod_tolerates_dev_sentinel() {
        assert!(validate_secrets(false, DEV_JWT_SECRET_SENTINEL, "", REAL_DB_URL).is_ok());
    }

    #[test]
    fn non_prod_still_rejects_short_non_sentinel_secret() {
        assert!(validate_secrets(false, "tooshort", "", REAL_DB_URL).is_err());
    }

    #[test]
    fn placeholder_detection_is_case_insensitive() {
        // looks_placeholder lowercases before matching — an upper/mixed-case
        // placeholder must still be rejected in prod.
        assert!(
            validate_secrets(
                true,
                "CHANGE_ME_TO_A_RANDOM_64_BYTE_HEX_STRING",
                REAL_HASH,
                REAL_DB_URL
            )
            .is_err()
        );
        assert!(
            validate_secrets(
                true,
                REAL_SECRET,
                "$2Y$12$PLACEHOLDER_replace_me",
                REAL_DB_URL
            )
            .is_err()
        );
    }

    #[test]
    fn prod_len_boundary_at_32() {
        // Exactly 32 non-placeholder chars is the minimum accepted; 31 is rejected.
        const LEN_32: &str = "abcdefghijklmnopqrstuvwxyz012345";
        const LEN_31: &str = "abcdefghijklmnopqrstuvwxyz01234";
        assert_eq!(LEN_32.len(), 32);
        assert_eq!(LEN_31.len(), 31);
        assert!(validate_secrets(true, LEN_32, REAL_HASH, REAL_DB_URL).is_ok());
        assert!(validate_secrets(true, LEN_31, REAL_HASH, REAL_DB_URL).is_err());
    }
}
