pub mod dashboard;
pub mod issue;
pub mod public;
pub mod rate;
pub mod session;
pub mod users;

use std::fmt;
use std::sync::{LazyLock, Mutex};
use std::time::SystemTime;

use askama::Template;
use async_trait::async_trait;
use axum::extract::Request;
use axum::http::{HeaderValue, StatusCode, header};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use jiff::Timestamp;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};

use self::session::Viewer;
use axum_login::tower_sessions::Session;

#[async_trait]
pub trait JobRunner: Send + Sync {
    async fn start(&self, unit: &str) -> Result<(), String>;
    async fn status(&self, unit: &str) -> Result<UnitStatus, String>;
    async fn log(&self, unit: &str, lines: usize) -> Result<String, String>;
}

#[derive(Debug, Clone, Default)]
pub struct UnitStatus {
    pub active_state: String,
    pub sub_state: String,
    pub result: String,
    pub exit_status: Option<i32>,
    /// `ExecMainStartTimestamp`, as systemd prints it; empty when never run.
    pub started: Option<String>,
    /// `ExecMainExitTimestamp`, as systemd prints it.
    pub exited: Option<String>,
}

impl UnitStatus {
    /// The unit ran and stopped with a failure result (`exit-code`, `failed`,
    /// `signal`, `timeout`, …); a never-started unit reports `success`.
    pub fn exited_unsuccessfully(&self) -> bool {
        matches!(self.active_state.as_str(), "inactive" | "failed")
            && !self.result.is_empty()
            && self.result != "success"
    }
}

#[derive(Debug, Default)]
pub struct DisabledRunner;

#[async_trait]
impl JobRunner for DisabledRunner {
    async fn start(&self, _unit: &str) -> Result<(), String> {
        Err("jobs are disabled".into())
    }

    async fn status(&self, _unit: &str) -> Result<UnitStatus, String> {
        Err("jobs are disabled".into())
    }

    async fn log(&self, _unit: &str, _lines: usize) -> Result<String, String> {
        Err("jobs are disabled".into())
    }
}

/// In-memory runner for router tests: records every call and answers with
/// scripted results (`start` succeeds, `status` is inactive/success and `log`
/// is empty unless told otherwise).
#[derive(Debug, Default)]
pub struct MockRunner {
    calls: Mutex<Vec<String>>,
    start_error: Mutex<Option<String>>,
    statuses: Mutex<std::collections::HashMap<String, UnitStatus>>,
    log_text: Mutex<String>,
}

impl MockRunner {
    pub fn calls(&self) -> Vec<String> {
        self.calls.lock().expect("mock runner lock").clone()
    }

    /// Make every `start` fail with `message` (`None` restores success).
    pub fn fail_starts(&self, message: Option<&str>) {
        *self.start_error.lock().expect("mock runner lock") = message.map(str::to_string);
    }

    /// Script the `status` answer for one unit.
    pub fn set_status(&self, unit: &str, status: UnitStatus) {
        self.statuses
            .lock()
            .expect("mock runner lock")
            .insert(unit.to_string(), status);
    }

    /// Script the journal text every `log` call returns.
    pub fn set_log(&self, text: &str) {
        *self.log_text.lock().expect("mock runner lock") = text.to_string();
    }

    fn record(&self, call: String) {
        self.calls.lock().expect("mock runner lock").push(call);
    }
}

#[async_trait]
impl JobRunner for MockRunner {
    async fn start(&self, unit: &str) -> Result<(), String> {
        self.record(format!("start {unit}"));
        match self.start_error.lock().expect("mock runner lock").clone() {
            Some(message) => Err(message),
            None => Ok(()),
        }
    }

    async fn status(&self, unit: &str) -> Result<UnitStatus, String> {
        self.record(format!("status {unit}"));
        Ok(self
            .statuses
            .lock()
            .expect("mock runner lock")
            .get(unit)
            .cloned()
            .unwrap_or_else(|| UnitStatus {
                active_state: "inactive".into(),
                sub_state: "dead".into(),
                result: "success".into(),
                ..UnitStatus::default()
            }))
    }

    async fn log(&self, unit: &str, lines: usize) -> Result<String, String> {
        self.record(format!("log {unit} {lines}"));
        Ok(self.log_text.lock().expect("mock runner lock").clone())
    }
}

pub struct WebState {
    pub jobs: std::sync::Arc<dyn JobRunner>,
    pub started_at: Timestamp,
    pub config_mtime: Mutex<Option<SystemTime>>,
}

impl fmt::Debug for WebState {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("WebState")
            .field("started_at", &self.started_at)
            .field("config_mtime", &self.config_mtime)
            .finish_non_exhaustive()
    }
}

impl WebState {
    /// Config reload on mtime (dashboard plan §4.2): when `config_path`'s
    /// modification time differs from the cached one, re-run `Config::load`
    /// and swap the live config. Returns `Ok(true)` when a reload happened,
    /// `Ok(false)` when nothing changed or no file is configured, and the
    /// load error when the file on disk no longer loads — the previous
    /// config stays live and the cached mtime is left alone so the next call
    /// tries again. Called by the settings page and by job starts.
    pub fn reload_if_changed(
        state: &crate::server::AppState,
    ) -> Result<bool, crate::config::ConfigError> {
        let Some(path) = state.config_path.as_deref() else {
            return Ok(false);
        };
        let mtime = std::fs::metadata(path)
            .and_then(|metadata| metadata.modified())
            .ok();
        let mut cached = state
            .web
            .config_mtime
            .lock()
            .unwrap_or_else(|poisoned| poisoned.into_inner());
        if *cached == mtime {
            return Ok(false);
        }
        let config = crate::config::Config::load(Some(path))?;
        match state.config.write() {
            Ok(mut live) => *live = std::sync::Arc::new(config),
            Err(poisoned) => *poisoned.into_inner() = std::sync::Arc::new(config),
        }
        *cached = mtime;
        tracing::info!(path = %path.display(), "reloaded configuration from disk");
        Ok(true)
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Flash {
    pub kind: String,
    pub text: String,
}

#[derive(Debug, Clone)]
pub struct Page {
    pub title: String,
    pub viewer: Option<Viewer>,
    pub flash: Option<Flash>,
    pub active_nav: String,
    pub version: &'static str,
    /// Cache-busting token for `/static/*.css|js` URLs: a content hash, so
    /// any stylesheet or script change reaches browsers that cached the
    /// previous build (they are served with a one-day `max-age`).
    pub asset_version: &'static str,
}

/// First 12 hex digits of the SHA-256 over the embedded CSS and JS assets.
pub static ASSET_VERSION: LazyLock<String> = LazyLock::new(|| {
    let mut hasher = Sha256::new();
    hasher.update(include_str!("static/app.css"));
    hasher.update(include_str!("static/app.js"));
    hasher.update(include_str!("static/theme.js"));
    hex::encode(hasher.finalize())[..12].to_string()
});

impl Page {
    pub fn new(title: impl Into<String>, viewer: Option<Viewer>, active_nav: &str) -> Self {
        Self {
            title: title.into(),
            viewer,
            flash: None,
            active_nav: active_nav.to_string(),
            version: crate::VERSION,
            asset_version: ASSET_VERSION.as_str(),
        }
    }

    pub fn is_admin(&self) -> bool {
        self.viewer
            .as_ref()
            .is_some_and(|viewer| viewer.role == users::Role::Admin)
    }
}

pub async fn take_flash(session: &Session) -> Result<Option<Flash>, WebError> {
    session
        .remove("flash")
        .await
        .map_err(|error| WebError::Internal(error.into()))
}

pub struct Html<T: Template>(pub T);

impl<T: Template> IntoResponse for Html<T> {
    fn into_response(self) -> Response {
        match self.0.render() {
            Ok(body) => (
                StatusCode::OK,
                [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
                body,
            )
                .into_response(),
            Err(error) => {
                tracing::error!(%error, "rendering web template failed");
                error_page_response(
                    StatusCode::INTERNAL_SERVER_ERROR,
                    "Server error",
                    "The request could not be completed.",
                )
            }
        }
    }
}

#[derive(Debug, thiserror::Error)]
pub enum WebError {
    #[error("not found")]
    NotFound,
    #[error("forbidden")]
    Forbidden,
    #[error("authentication required")]
    Unauthenticated { next: String },
    #[error("bad request: {0}")]
    BadRequest(String),
    #[error("request origin did not match this site")]
    Csrf,
    #[error(transparent)]
    Db(#[from] crate::db::DbError),
    #[error(transparent)]
    Internal(#[from] anyhow::Error),
}

#[derive(Template)]
#[template(path = "error.html")]
struct ErrorTemplate {
    page: Page,
    heading: String,
    message: String,
}

fn error_page_response(status: StatusCode, heading: &str, message: &str) -> Response {
    let rendered = ErrorTemplate {
        page: Page::new(heading, None, ""),
        heading: heading.to_string(),
        message: message.to_string(),
    }
    .render()
    .unwrap_or_else(|_| message.to_string());
    (
        status,
        [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
        rendered,
    )
        .into_response()
}

impl IntoResponse for WebError {
    fn into_response(self) -> Response {
        if let Self::Unauthenticated { next } = self {
            return axum::response::Redirect::temporary(&format!(
                "/login?next={}",
                encode_component(&next)
            ))
            .into_response();
        }
        let (status, heading, message) = match self {
            Self::NotFound => (
                StatusCode::NOT_FOUND,
                "Not found",
                "That page does not exist.",
            ),
            Self::Forbidden | Self::Csrf => (
                StatusCode::FORBIDDEN,
                "Forbidden",
                "You do not have permission to do that.",
            ),
            Self::BadRequest(ref message) => {
                (StatusCode::BAD_REQUEST, "Bad request", message.as_str())
            }
            Self::Db(ref error) => {
                tracing::error!(%error, "web database request failed");
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    "Server error",
                    "The request could not be completed.",
                )
            }
            Self::Internal(ref error) => {
                tracing::error!(%error, "web request failed");
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    "Server error",
                    "The request could not be completed.",
                )
            }
            Self::Unauthenticated { .. } => unreachable!(),
        };
        error_page_response(status, heading, message)
    }
}

pub fn encode_component(value: &str) -> String {
    url::form_urlencoded::byte_serialize(value.as_bytes()).collect()
}

#[derive(Debug, Clone, Copy)]
pub struct Pagination {
    pub page: u32,
    pub per_page: u32,
    pub total: i64,
}

impl Pagination {
    pub fn offset(self) -> i64 {
        i64::from(self.page.saturating_sub(1)) * i64::from(self.per_page)
    }

    pub fn pages(self) -> u32 {
        ((self.total.max(0) as u64).div_ceil(u64::from(self.per_page))) as u32
    }
}

pub fn format_time(timestamp: Timestamp, config: &crate::config::Config) -> String {
    config
        .tz()
        .map(|tz| {
            timestamp
                .to_zoned(tz)
                .strftime("%Y-%m-%d %H:%M %Z")
                .to_string()
        })
        .unwrap_or_else(|_| timestamp.to_string())
}

pub async fn security_headers(request: Request, next: Next) -> Response {
    let path = request.uri().path().to_string();
    let mut response = next.run(request).await;
    let headers = response.headers_mut();
    headers.insert(
        header::HeaderName::from_static("content-security-policy"),
        HeaderValue::from_static(
            "default-src 'self'; img-src * data:; style-src 'self'; script-src 'self'; frame-ancestors 'none'; form-action 'self'",
        ),
    );
    headers.insert(
        header::X_CONTENT_TYPE_OPTIONS,
        HeaderValue::from_static("nosniff"),
    );
    headers.insert(
        header::REFERRER_POLICY,
        HeaderValue::from_static("strict-origin-when-cross-origin"),
    );
    if path.starts_with("/dashboard") {
        headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
    }
    if headers
        .get(header::CONTENT_TYPE)
        .and_then(|value| value.to_str().ok())
        .is_some_and(|value| value.starts_with("text/html"))
    {
        headers.append(header::VARY, HeaderValue::from_static("Cookie"));
    }
    response
}

pub fn router(config: &crate::config::Config) -> axum::Router<crate::server::AppState> {
    use axum::middleware::from_fn;
    use axum::routing::{get, post};
    use axum_login::{login_required, permission_required};
    use tower_governor::GovernorLayer;
    use tower_governor::governor::GovernorConfigBuilder;
    use tower_governor::key_extractor::SmartIpKeyExtractor;

    let seconds_per_token = (u64::from(config.server.login_window_minutes) * 60
        / u64::from(config.server.login_attempts))
    .max(1);
    let governor = std::sync::Arc::new(
        GovernorConfigBuilder::default()
            .per_second(seconds_per_token)
            .burst_size(config.server.login_attempts)
            .key_extractor(SmartIpKeyExtractor)
            .finish()
            .expect("validated non-zero login governor configuration"),
    );
    if let Ok(handle) = tokio::runtime::Handle::try_current() {
        let cleanup = governor.clone();
        handle.spawn(async move {
            let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
            loop {
                interval.tick().await;
                cleanup.limiter().retain_recent();
            }
        });
    }

    let login = axum::Router::new()
        .route("/login", get(session::login_page))
        .route(
            "/login",
            post(session::login).route_layer(GovernorLayer::new(governor)),
        );
    let account = axum::Router::new()
        .route("/account", get(session::account))
        .route("/account/password", post(session::change_password))
        .route("/account/logout-all", post(session::logout_everywhere))
        .route("/logout", post(session::logout))
        .route_layer(login_required!(
            session::Backend,
            login_url = "/login",
            redirect_field = "next"
        ));
    let full_issues = axum::Router::new()
        .route("/issues/{date}/articles/{article_id}", get(issue::article))
        .route("/issues/{date}/world", get(issue::world))
        .route("/issues/{date}/behind", get(issue::behind))
        .route_layer(login_required!(
            session::Backend,
            login_url = "/login",
            redirect_field = "next"
        ))
        .route_layer(from_fn(map_forbidden));
    let dashboard = axum::Router::new()
        .merge(dashboard::router())
        .route("/rate", post(rate::post))
        .route_layer(permission_required!(
            session::Backend,
            login_url = "/login",
            redirect_field = "next",
            users::Role::Admin
        ))
        .route_layer(from_fn(map_forbidden));

    axum::Router::new()
        .route("/", get(public::latest))
        .route("/issues", get(public::archive))
        .route("/issues/{date}", get(public::show_issue))
        .route("/feed.xml", get(public::feed))
        .route("/robots.txt", get(public::robots))
        .route("/static/{file}", get(static_asset))
        .merge(login)
        .merge(account)
        .merge(full_issues)
        .merge(dashboard)
        .fallback(|| async { WebError::NotFound })
}

async fn map_forbidden(request: Request, next: Next) -> Response {
    let mut response = next.run(request).await;
    if response.status() == StatusCode::FORBIDDEN {
        WebError::Forbidden.into_response()
    } else {
        if response.status() == StatusCode::TEMPORARY_REDIRECT {
            *response.status_mut() = StatusCode::FOUND;
        }
        response
    }
}

async fn static_asset(
    axum::extract::Path(file): axum::extract::Path<String>,
    headers: axum::http::HeaderMap,
) -> Response {
    let asset: (&str, &'static [u8]) = match file.as_str() {
        "app.css" => (
            "text/css; charset=utf-8",
            include_str!("static/app.css").as_bytes(),
        ),
        "app.js" => (
            "application/javascript; charset=utf-8",
            include_str!("static/app.js").as_bytes(),
        ),
        "theme.js" => (
            "application/javascript; charset=utf-8",
            include_str!("static/theme.js").as_bytes(),
        ),
        "favicon.svg" => (
            "image/svg+xml",
            include_str!("static/favicon.svg").as_bytes(),
        ),
        "Newsreader.woff2" => (
            "font/woff2",
            include_bytes!("static/fonts/Newsreader.woff2"),
        ),
        "Newsreader-italic.woff2" => (
            "font/woff2",
            include_bytes!("static/fonts/Newsreader-italic.woff2"),
        ),
        _ => return WebError::NotFound.into_response(),
    };
    let etag = format!("\"{}\"", hex::encode(Sha256::digest(asset.1)));
    if headers
        .get(header::IF_NONE_MATCH)
        .and_then(|value| value.to_str().ok())
        == Some(etag.as_str())
    {
        return (
            StatusCode::NOT_MODIFIED,
            [
                (header::ETAG, etag),
                (header::CACHE_CONTROL, "public, max-age=86400".into()),
            ],
        )
            .into_response();
    }
    (
        StatusCode::OK,
        [
            (header::CONTENT_TYPE, asset.0.to_string()),
            (header::CACHE_CONTROL, "public, max-age=86400".into()),
            (header::ETAG, etag),
        ],
        asset.1,
    )
        .into_response()
}

#[cfg(test)]
mod tests {
    use axum::body::{Body, to_bytes};
    use axum::http::{Method, Request, header};
    use tower::ServiceExt;

    use super::*;
    use crate::config::Config;
    use crate::db::Db;
    use crate::server::{AppState, router};

    async fn test_state(config: Config) -> (tempfile::TempDir, AppState) {
        let dir = tempfile::tempdir().unwrap();
        let db = Db::open_and_migrate(&dir.path().join("db.sqlite"))
            .await
            .unwrap();
        (dir, AppState::new(db, config, None))
    }

    fn post(uri: &str, body: &str, ip: &str) -> Request<Body> {
        Request::builder()
            .method(Method::POST)
            .uri(uri)
            .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
            .header("sec-fetch-site", "same-origin")
            .header("x-forwarded-for", ip)
            .body(Body::from(body.to_string()))
            .unwrap()
    }

    async fn login_cookie(app: &axum::Router, username: &str, password: &str) -> String {
        let response = app
            .clone()
            .oneshot(post(
                "/login",
                &format!("username={username}&password={password}&next=%2F"),
                "192.0.2.1",
            ))
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::SEE_OTHER);
        response
            .headers()
            .get(header::SET_COOKIE)
            .unwrap()
            .to_str()
            .unwrap()
            .split(';')
            .next()
            .unwrap()
            .to_string()
    }

    async fn response_text(response: Response) -> String {
        String::from_utf8(
            to_bytes(response.into_body(), 1024 * 1024)
                .await
                .unwrap()
                .to_vec(),
        )
        .unwrap()
    }

    #[tokio::test]
    async fn login_cookie_account_logout_and_anonymous_pages() {
        let mut config = Config::default();
        config.server.public_url = "https://daily.example".into();
        let (_dir, state) = test_state(config).await;
        users::add(&state.db, "admin", "correct horse battery", true)
            .await
            .unwrap();
        let app = router(state.clone());

        let anonymous = app
            .clone()
            .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
            .await
            .unwrap();
        assert!(anonymous.headers().get(header::SET_COOKIE).is_none());
        assert_eq!(
            anonymous.headers().get(header::CACHE_CONTROL).unwrap(),
            "public, max-age=300"
        );

        let response = app
            .clone()
            .oneshot(post(
                "/login",
                "username=admin&password=correct+horse+battery&next=%2Faccount",
                "192.0.2.3",
            ))
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::SEE_OTHER);
        assert_eq!(
            response.headers().get(header::LOCATION).unwrap(),
            "/account"
        );
        let set_cookie = response
            .headers()
            .get(header::SET_COOKIE)
            .unwrap()
            .to_str()
            .unwrap();
        assert!(set_cookie.contains("daily_session="));
        assert!(set_cookie.contains("HttpOnly"));
        assert!(set_cookie.contains("SameSite=Lax"));
        assert!(set_cookie.contains("Secure"));
        let cookie = set_cookie.split(';').next().unwrap();

        let account = app
            .clone()
            .oneshot(
                Request::builder()
                    .uri("/account")
                    .header(header::COOKIE, cookie)
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(account.status(), StatusCode::OK);
        assert!(response_text(account).await.contains("admin"));

        let logout = app
            .clone()
            .oneshot(
                Request::builder()
                    .method(Method::POST)
                    .uri("/logout")
                    .header(header::COOKIE, cookie)
                    .header("sec-fetch-site", "same-origin")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(logout.status(), StatusCode::SEE_OTHER);
    }

    #[tokio::test]
    async fn guards_distinguish_anonymous_users_and_admins() {
        let (_dir, state) = test_state(Config::default()).await;
        users::add(&state.db, "reader", "correct horse battery", false)
            .await
            .unwrap();
        users::add(&state.db, "admin", "correct horse battery", true)
            .await
            .unwrap();
        let app = router(state);
        let anonymous = app
            .clone()
            .oneshot(
                Request::builder()
                    .uri("/dashboard")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(anonymous.status(), StatusCode::FOUND);
        assert_eq!(
            anonymous.headers().get(header::LOCATION).unwrap(),
            "/login?next=%2Fdashboard"
        );
        let anonymous_rate = app
            .clone()
            .oneshot(post("/rate", "", "192.0.2.20"))
            .await
            .unwrap();
        assert_eq!(anonymous_rate.status(), StatusCode::FOUND);
        assert_eq!(
            anonymous_rate.headers().get(header::LOCATION).unwrap(),
            "/login?next=%2Frate"
        );

        let reader = login_cookie(&app, "reader", "correct horse battery").await;
        let forbidden = app
            .clone()
            .oneshot(
                Request::builder()
                    .uri("/dashboard")
                    .header(header::COOKIE, &reader)
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(forbidden.status(), StatusCode::FORBIDDEN);
        assert!(response_text(forbidden).await.contains("Forbidden"));
        let forbidden_rate = app
            .clone()
            .oneshot(
                Request::builder()
                    .method(Method::POST)
                    .uri("/rate")
                    .header(header::COOKIE, &reader)
                    .header("sec-fetch-site", "same-origin")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(forbidden_rate.status(), StatusCode::FORBIDDEN);

        let admin = login_cookie(&app, "admin", "correct horse battery").await;
        let allowed = app
            .clone()
            .oneshot(
                Request::builder()
                    .uri("/dashboard")
                    .header(header::COOKIE, &admin)
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(allowed.status(), StatusCode::OK);
        assert_eq!(
            allowed.headers().get(header::CACHE_CONTROL).unwrap(),
            "no-store"
        );
        let allowed_rate = app
            .oneshot(
                Request::builder()
                    .method(Method::POST)
                    .uri("/rate")
                    .header(header::COOKIE, admin)
                    .header("sec-fetch-site", "same-origin")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(allowed_rate.status(), StatusCode::BAD_REQUEST);
    }

    #[tokio::test]
    async fn origin_check_rejects_cross_site_and_foreign_origins() {
        let (_dir, state) = test_state(Config::default()).await;
        let app = router(state);
        let cross = app
            .clone()
            .oneshot(
                Request::builder()
                    .method(Method::POST)
                    .uri("/login")
                    .header("sec-fetch-site", "cross-site")
                    .header("x-forwarded-for", "192.0.2.10")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(cross.status(), StatusCode::FORBIDDEN);
        let foreign = app
            .clone()
            .oneshot(
                Request::builder()
                    .method(Method::POST)
                    .uri("/login")
                    .header(header::ORIGIN, "https://evil.example")
                    .header(header::HOST, "daily.hallada.net")
                    .header("x-forwarded-proto", "https")
                    .header("x-forwarded-for", "192.0.2.11")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(foreign.status(), StatusCode::FORBIDDEN);
        let same = app
            .oneshot(
                Request::builder()
                    .method(Method::POST)
                    .uri("/login")
                    .header(header::ORIGIN, "https://daily.hallada.net")
                    .header(header::HOST, "daily.hallada.net")
                    .header("x-forwarded-proto", "https")
                    .header("x-forwarded-for", "192.0.2.12")
                    .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
                    .body(Body::from("username=x&password=invalid-invalid"))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(same.status(), StatusCode::UNAUTHORIZED);
    }

    #[tokio::test]
    async fn login_throttle_is_per_ip() {
        let mut config = Config::default();
        config.server.login_attempts = 3;
        let (_dir, state) = test_state(config).await;
        let app = router(state);
        for _ in 0..3 {
            let response = app
                .clone()
                .oneshot(post(
                    "/login",
                    "username=nobody&password=invalid-invalid",
                    "192.0.2.20",
                ))
                .await
                .unwrap();
            assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
        }
        let limited = app
            .clone()
            .oneshot(post(
                "/login",
                "username=nobody&password=invalid-invalid",
                "192.0.2.20",
            ))
            .await
            .unwrap();
        assert_eq!(limited.status(), StatusCode::TOO_MANY_REQUESTS);
        let other = app
            .oneshot(post(
                "/login",
                "username=nobody&password=invalid-invalid",
                "192.0.2.21",
            ))
            .await
            .unwrap();
        assert_eq!(other.status(), StatusCode::UNAUTHORIZED);
    }

    #[tokio::test]
    async fn disabled_users_and_password_changes_invalidate_other_sessions() {
        let (_dir, state) = test_state(Config::default()).await;
        users::add(&state.db, "reader", "correct horse battery", false)
            .await
            .unwrap();
        let app = router(state.clone());
        let first = login_cookie(&app, "reader", "correct horse battery").await;
        let second = login_cookie(&app, "reader", "correct horse battery").await;
        let changed = app
            .clone()
            .oneshot(
                Request::builder()
                    .method(Method::POST)
                    .uri("/account/password")
                    .header(header::COOKIE, &first)
                    .header("sec-fetch-site", "same-origin")
                    .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
                    .body(Body::from("current_password=correct+horse+battery&new_password=a+replacement+password&confirm_password=a+replacement+password"))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(changed.status(), StatusCode::SEE_OTHER);
        let old_session = app
            .clone()
            .oneshot(
                Request::builder()
                    .uri("/account")
                    .header(header::COOKIE, second)
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert!(old_session.status().is_redirection());

        let fresh = login_cookie(&app, "reader", "a replacement password").await;
        users::set_disabled(&state.db, "reader", true)
            .await
            .unwrap();
        let disabled = app
            .oneshot(
                Request::builder()
                    .uri("/account")
                    .header(header::COOKIE, fresh)
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert!(disabled.status().is_redirection());
    }

    #[tokio::test]
    async fn files_accept_a_session_or_basic_auth() {
        let mut config = Config::default();
        config.server.basic_auth_user = Some("opds".into());
        config.server.basic_auth_pass = Some("hunter2".into());
        let (dir, state) = test_state(config).await;
        let epub_dir = dir.path().join("epub");
        std::fs::create_dir_all(&epub_dir).unwrap();
        std::fs::write(epub_dir.join("issue.epub"), b"epub").unwrap();
        {
            let mut live = state.config.write().unwrap();
            let mut changed = (**live).clone();
            changed.publish.epub_dir = epub_dir;
            *live = std::sync::Arc::new(changed);
        }
        users::add(&state.db, "reader", "correct horse battery", false)
            .await
            .unwrap();
        let app = router(state);
        let anonymous = app
            .clone()
            .oneshot(
                Request::builder()
                    .uri("/files/epub/issue.epub")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(anonymous.status(), StatusCode::UNAUTHORIZED);
        assert!(anonymous.headers().contains_key(header::WWW_AUTHENTICATE));
        let basic = app
            .clone()
            .oneshot(
                Request::builder()
                    .uri("/files/epub/issue.epub")
                    .header(header::AUTHORIZATION, "Basic b3BkczpodW50ZXIy")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(basic.status(), StatusCode::OK);
        let cookie = login_cookie(&app, "reader", "correct horse battery").await;
        let session = app
            .oneshot(
                Request::builder()
                    .uri("/files/epub/issue.epub")
                    .header(header::COOKIE, cookie)
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(session.status(), StatusCode::OK);
    }

    #[test]
    fn asset_urls_carry_a_content_hash_not_the_crate_version() {
        let page = Page::new("t", None, "latest");
        assert_eq!(page.asset_version.len(), 12);
        assert!(page.asset_version.chars().all(|c| c.is_ascii_hexdigit()));
        assert_ne!(page.asset_version, crate::VERSION);
        let html = ErrorTemplate {
            page,
            heading: "h".into(),
            message: "m".into(),
        }
        .render()
        .unwrap();
        let expected = format!("/static/app.css?v={}", ASSET_VERSION.as_str());
        assert!(html.contains(&expected), "{html}");
        assert!(!html.contains(&format!("/static/app.css?v={}", crate::VERSION)));
    }

    #[tokio::test]
    async fn static_assets_use_content_hash_etags() {
        let (_dir, state) = test_state(Config::default()).await;
        let app = router(state);
        let first = app
            .clone()
            .oneshot(
                Request::builder()
                    .uri("/static/app.css")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(first.status(), StatusCode::OK);
        assert_eq!(
            first.headers().get(header::CACHE_CONTROL).unwrap(),
            "public, max-age=86400"
        );
        let etag = first.headers().get(header::ETAG).unwrap().clone();
        let cached = app
            .oneshot(
                Request::builder()
                    .uri("/static/app.css")
                    .header(header::IF_NONE_MATCH, etag)
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(cached.status(), StatusCode::NOT_MODIFIED);
    }

    #[tokio::test]
    async fn not_found_and_server_error_pages_use_the_site_layout() {
        let (_dir, state) = test_state(Config::default()).await;
        let app = router(state);
        let missing = app
            .oneshot(
                Request::builder()
                    .uri("/no-such-page")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(missing.status(), StatusCode::NOT_FOUND);
        let missing = response_text(missing).await;
        assert!(missing.contains("<!doctype html>"), "{missing}");
        assert!(missing.contains("The Daily EPUB"), "{missing}");
        assert!(missing.contains("That page does not exist"), "{missing}");

        let failed = WebError::Internal(anyhow::anyhow!("fixture failure")).into_response();
        assert_eq!(failed.status(), StatusCode::INTERNAL_SERVER_ERROR);
        let failed = response_text(failed).await;
        assert!(failed.contains("<!doctype html>"), "{failed}");
        assert!(failed.contains("The Daily EPUB"), "{failed}");
        assert!(
            failed.contains("The request could not be completed"),
            "{failed}"
        );
        assert!(!failed.contains("fixture failure"), "{failed}");
    }
}
