Skip to content
use actix_identity::Identity;
use actix_web::{HttpMessage, HttpRequest, HttpResponse, Responder, get, post, web};
use maud::{Markup, html};
use serde::Deserialize;

use crate::auth::{
    create_user, current_user, validate_email, validate_password, validate_username,
    verify_password,
};
use crate::config::Config;
use crate::db::Database;
use crate::view::{is_htmx, page_or_partial, render_error, render_success};

#[derive(Deserialize)]
pub struct LoginForm {
    pub username: String,
    pub password: String,
}

#[derive(Deserialize)]
pub struct SignupForm {
    pub username: String,
    pub email: String,
    pub password: String,
    pub invite: String,
}

#[derive(Deserialize)]
pub struct InviteQuery {
    pub invite: String,
}

#[derive(Deserialize)]
pub struct CreateInviteForm {
    pub invite_secret: String,
}

fn field(id: &str, label: &str, input_type: &str, minlength: Option<&str>) -> Markup {
    html! {
        div class="form-control w-full" {
            label class="label" for=(id) {
                span class="label-text" { (label) }
            }
            input
                class="input input-bordered w-full"
                type=(input_type)
                name=(id)
                id=(id)
                required
                minlength=[minlength];
        }
    }
}

fn render_login_page(invites_enabled: bool) -> Markup {
    html! {
        div class="max-w-sm mx-auto mt-8" {
            section class="card bg-base-200" aria-labelledby="login-title" {
                div class="card-body" {
                    h1 id="login-title" class="card-title" { "Log in" }
                    p class="text-sm opacity-70" { "Welcome back to your private task list." }
                    form
                        hx-post="/auth/login"
                        hx-target="#login-result"
                        "hx-status:4xx"="swap:innerHTML target:#login-result"
                        "hx-status:5xx"="swap:innerHTML target:#login-result"
                        hx-swap="innerHTML"
                        method="POST"
                        action="/auth/login"
                        class="flex flex-col gap-4 mt-2"
                    {
                        (field("username", "Username", "text", None))
                        (field("password", "Password", "password", None))
                        button type="submit" class="btn btn-primary w-full" { "Log in" }
                    }
                    div id="login-result" aria-live="polite" {}
                    @if invites_enabled {
                        a class="btn btn-outline btn-primary w-full mt-4" href="/auth/invites" { "Create an invitation" }
                    }
                }
            }
        }
    }
}

fn render_signup_page(invite: &str) -> Markup {
    html! {
        div class="max-w-sm mx-auto mt-8" {
            section class="card bg-base-200" aria-labelledby="signup-title" {
                div class="card-body" {
                    h1 id="signup-title" class="card-title" { "Create account" }
                    p class="text-sm opacity-70" { "A quiet place for the tasks you want to remember." }
                    form
                        hx-post="/auth/signup"
                        hx-target="#signup-result"
                        "hx-status:4xx"="swap:innerHTML target:#signup-result"
                        "hx-status:5xx"="swap:innerHTML target:#signup-result"
                        hx-swap="innerHTML"
                        method="POST"
                        action="/auth/signup"
                        class="flex flex-col gap-4 mt-2"
                    {
                        (field("username", "Username", "text", Some("3")))
                        (field("email", "Email", "email", None))
                        (field("password", "Password", "password", Some("8")))
                        input type="hidden" name="invite" value=(invite);
                        button type="submit" class="btn btn-primary w-full" { "Sign up" }
                    }
                    div id="signup-result" aria-live="polite" {}
                    p class="text-sm opacity-70 mt-2" {
                        "Already have an account? "
                        a class="link link-primary" href="/auth/login" { "Log in" }
                    }
                }
            }
        }
    }
}

fn render_invite_page() -> Markup {
    html! {
        div class="max-w-sm mx-auto mt-8" {
            section class="card bg-base-200" aria-labelledby="invite-title" {
                div class="card-body" {
                    h1 id="invite-title" class="card-title" { "Create an invitation" }
                    p class="text-sm opacity-70" { "Generate a one-time link for someone to create an account." }
                    form
                        hx-post="/auth/invites"
                        hx-target="#invite-result"
                        "hx-status:4xx"="swap:innerHTML target:#invite-result"
                        "hx-status:5xx"="swap:innerHTML target:#invite-result"
                        hx-swap="innerHTML"
                        method="POST"
                        action="/auth/invites"
                        class="flex flex-col gap-4 mt-2"
                    {
                        (field("invite_secret", "Invite creation secret", "password", None))
                        button type="submit" class="btn btn-primary w-full" { "Create invite link" }
                    }
                    div id="invite-result" aria-live="polite" {}
                    p class="text-sm opacity-70 mt-2" {
                        a class="link link-primary" href="/auth/login" { "Back to log in" }
                    }
                }
            }
        }
    }
}

fn render_invite_link(link: &str) -> Markup {
    html! {
        div class="alert alert-success" role="status" {
            span { "Invite created. Share this one-time link: " }
            a class="link" href=(link) { (link) }
        }
    }
}

#[get("/auth/login")]
pub async fn login_page(
    req: HttpRequest,
    identity: Option<Identity>,
    db: web::Data<Database>,
    config: web::Data<Config>,
) -> Markup {
    let username = current_user(identity, &db).await.map(|u| u.username);
    page_or_partial(
        &req,
        render_login_page(config.invites_enabled()),
        username.as_deref(),
        "Log in",
    )
}

#[get("/auth/signup")]
pub async fn signup_page(
    req: HttpRequest,
    identity: Option<Identity>,
    db: web::Data<Database>,
    query: Result<web::Query<InviteQuery>, actix_web::Error>,
) -> HttpResponse {
    let Ok(query) = query else {
        return HttpResponse::NotFound().finish();
    };
    match db.invite_exists(&query.invite).await {
        Ok(true) => {}
        Ok(false) => return HttpResponse::NotFound().finish(),
        Err(e) => {
            log::error!("Failed to validate invite: {e}");
            return HttpResponse::InternalServerError().finish();
        }
    }
    let username = current_user(identity, &db).await.map(|u| u.username);
    HttpResponse::Ok()
        .content_type("text/html; charset=utf-8")
        .body(
            page_or_partial(
                &req,
                render_signup_page(&query.invite),
                username.as_deref(),
                "Sign up",
            )
            .into_string(),
        )
}

#[get("/auth/invites")]
pub async fn invite_page(
    req: HttpRequest,
    identity: Option<Identity>,
    db: web::Data<Database>,
    config: web::Data<Config>,
) -> HttpResponse {
    if !config.invites_enabled() {
        return HttpResponse::NotFound().finish();
    }
    let username = current_user(identity, &db).await.map(|u| u.username);
    HttpResponse::Ok()
        .content_type("text/html; charset=utf-8")
        .body(
            page_or_partial(
                &req,
                render_invite_page(),
                username.as_deref(),
                "Create invite",
            )
            .into_string(),
        )
}

/// Redirects for HTMX requests (via `HX-Redirect`) and plain browser form posts.
fn redirect(req: &HttpRequest, location: &str) -> HttpResponse {
    if is_htmx(req) {
        HttpResponse::Ok()
            .insert_header(("HX-Redirect", location.to_string()))
            .finish()
    } else {
        HttpResponse::SeeOther()
            .insert_header(("Location", location.to_string()))
            .finish()
    }
}

fn form_error(status: actix_web::http::StatusCode, message: &str) -> HttpResponse {
    HttpResponse::build(status)
        .content_type("text/html; charset=utf-8")
        .body(render_error(message).into_string())
}

#[post("/auth/login")]
pub async fn login_submit(
    req: HttpRequest,
    db: web::Data<Database>,
    form: web::Form<LoginForm>,
) -> impl Responder {
    let user = match db.get_user_by_username(form.username.trim()).await {
        Ok(Some(user)) => user,
        Ok(None) => {
            return form_error(
                actix_web::http::StatusCode::UNAUTHORIZED,
                "Invalid username or password",
            );
        }
        Err(e) => {
            log::error!("Failed to look up user: {e}");
            return form_error(
                actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
                "Something went wrong",
            );
        }
    };

    match verify_password(&form.password, &user.password_hash) {
        Ok(true) => {}
        Ok(false) => {
            return form_error(
                actix_web::http::StatusCode::UNAUTHORIZED,
                "Invalid username or password",
            );
        }
        Err(e) => {
            log::error!("Password verification error: {e}");
            return form_error(
                actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
                "Something went wrong",
            );
        }
    }

    if let Err(e) = Identity::login(&req.extensions(), user.id.clone()) {
        log::error!("Failed to create identity session: {e}");
        return form_error(
            actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
            "Failed to create session",
        );
    }

    log::info!("User logged in: {}", user.username);
    redirect(&req, "/")
}

#[post("/auth/signup")]
pub async fn signup_submit(
    req: HttpRequest,
    db: web::Data<Database>,
    form: web::Form<SignupForm>,
) -> impl Responder {
    match db.invite_exists(&form.invite).await {
        Ok(true) => {}
        Ok(false) => return HttpResponse::NotFound().finish(),
        Err(e) => {
            log::error!("Failed to validate invite: {e}");
            return HttpResponse::InternalServerError().finish();
        }
    }

    if let Err(message) = validate_username(&form.username) {
        return form_error(actix_web::http::StatusCode::BAD_REQUEST, &message);
    }
    if let Err(message) = validate_email(&form.email) {
        return form_error(actix_web::http::StatusCode::BAD_REQUEST, &message);
    }
    if let Err(message) = validate_password(&form.password) {
        return form_error(actix_web::http::StatusCode::BAD_REQUEST, &message);
    }

    let user = match create_user(&form.username, &form.email, &form.password) {
        Ok(user) => user,
        Err(e) => {
            log::error!("Failed to create user: {e}");
            return form_error(
                actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
                "Failed to create user",
            );
        }
    };

    match db.create_user_with_invite(&user, &form.invite).await {
        Ok(true) => {}
        Ok(false) => return HttpResponse::NotFound().finish(),
        Err(e) if e.contains("UNIQUE constraint failed: users.username") => {
            return form_error(
                actix_web::http::StatusCode::CONFLICT,
                "Username already exists",
            );
        }
        Err(e) => {
            log::error!("Failed to create user: {e}");
            return form_error(
                actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
                "Failed to create user",
            );
        }
    }

    log::info!("User signed up: {}", user.username);

    if is_htmx(&req) {
        HttpResponse::Ok()
            .content_type("text/html; charset=utf-8")
            .body(render_success("Account created. You can now log in.").into_string())
    } else {
        redirect(&req, "/auth/login")
    }
}

#[post("/auth/invites")]
pub async fn create_invite(
    req: HttpRequest,
    db: web::Data<Database>,
    config: web::Data<Config>,
    form: web::Form<CreateInviteForm>,
) -> impl Responder {
    if !config.invite_secret_matches(&form.invite_secret) {
        return HttpResponse::NotFound().finish();
    }

    let token = uuid::Uuid::new_v4().to_string();
    if let Err(e) = db.create_invite(&token).await {
        log::error!("Failed to create invite: {e}");
        return form_error(
            actix_web::http::StatusCode::INTERNAL_SERVER_ERROR,
            "Failed to create invite",
        );
    }

    let connection = req.connection_info();
    let link = format!(
        "{}://{}/auth/signup?invite={token}",
        connection.scheme(),
        connection.host()
    );
    log::info!("Invite created");
    if is_htmx(&req) {
        HttpResponse::Ok()
            .content_type("text/html; charset=utf-8")
            .body(render_invite_link(&link).into_string())
    } else {
        HttpResponse::Ok()
            .content_type("text/html; charset=utf-8")
            .body(
                page_or_partial(&req, render_invite_link(&link), None, "Invite created")
                    .into_string(),
            )
    }
}

#[post("/auth/logout")]
pub async fn logout(req: HttpRequest, identity: Option<Identity>) -> impl Responder {
    if let Some(identity) = identity {
        identity.logout();
    }
    redirect(&req, "/")
}

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

    #[test]
    fn test_login_page_contract() {
        let html = render_login_page(true).into_string();
        for attribute in [
            "hx-post=\"/auth/login\"",
            "hx-target=\"#login-result\"",
            "hx-status:4xx=\"swap:innerHTML target:#login-result\"",
            "hx-swap=\"innerHTML\"",
        ] {
            assert!(html.contains(attribute), "missing {attribute}: {html}");
        }
        assert!(html.contains("id=\"login-result\" aria-live=\"polite\""));
        assert!(html.contains("href=\"/auth/invites\""));
        assert!(
            !render_login_page(false)
                .into_string()
                .contains("href=\"/auth/invites\"")
        );
    }

    #[test]
    fn test_signup_page_contract() {
        let html = render_signup_page("test-token").into_string();
        for attribute in [
            "hx-post=\"/auth/signup\"",
            "hx-target=\"#signup-result\"",
            "hx-swap=\"innerHTML\"",
        ] {
            assert!(html.contains(attribute), "missing {attribute}: {html}");
        }
        assert!(html.contains("id=\"signup-result\" aria-live=\"polite\""));
        assert!(html.contains("name=\"email\""));
        assert!(html.contains("name=\"invite\" value=\"test-token\""));
    }
}