use std::{env, sync::LazyLock};

use reqwest::Method;
use rocket::{
    Catcher, Route,
    form::Form,
    http::{Cookie, CookieJar, MediaType, SameSite, Status},
    request::{FromRequest, Outcome, Request},
    response::{Redirect, content::RawHtml as Html},
    serde::json::Json,
};
use serde::de::DeserializeOwned;
use serde_json::Value;

use crate::{
    CONFIG, VERSION,
    api::{
        ApiResult, EmptyResult, JsonResult, Notify,
        core::{log_event, two_factor},
        unregister_push_device,
    },
    auth::{ClientIp, Secure, decode_admin, encode_jwt, generate_admin_claims},
    config::ConfigBuilder,
    db::{
        ACTIVE_DB_TYPE, DbConn, DbConnType, backup_sqlite, get_sql_server_version,
        models::{
            Attachment, Cipher, Collection, Device, Event, EventType, Group, Invitation, Membership, MembershipId,
            MembershipType, OrgPolicy, Organization, OrganizationId, SsoUser, TwoFactor, User, UserId,
        },
    },
    error::{Error, MapResult},
    http_client::make_http_request,
    mail,
    sso::FAKE_SSO_IDENTIFIER,
    util::{
        FeatureFlagFilter, NumberOrString, container_base_image, format_naive_datetime_local, get_active_web_release,
        get_display_size, is_running_in_container, parse_experimental_client_feature_flags,
    },
};

pub fn routes() -> Vec<Route> {
    if !CONFIG.disable_admin_token() && !CONFIG.is_admin_token_set() {
        return routes![admin_disabled];
    }

    routes![
        get_users_json,
        get_user_json,
        get_user_by_mail_json,
        post_admin_login,
        admin_page,
        admin_page_login,
        invite_user,
        logout,
        delete_user,
        delete_sso_user,
        deauth_user,
        disable_user,
        enable_user,
        remove_2fa,
        update_membership_type,
        update_revision_users,
        post_config,
        delete_config,
        backup_db,
        test_smtp,
        users_overview,
        organizations_overview,
        delete_organization,
        diagnostics,
        get_diagnostics_config,
        resend_user_invite,
        get_diagnostics_http,
    ]
}

pub fn catchers() -> Vec<Catcher> {
    if !CONFIG.disable_admin_token() && !CONFIG.is_admin_token_set() {
        catchers![]
    } else {
        catchers![admin_login]
    }
}

static DB_TYPE: LazyLock<&str> = LazyLock::new(|| match ACTIVE_DB_TYPE.get() {
    #[cfg(mysql)]
    Some(DbConnType::Mysql) => "MySQL",
    #[cfg(postgresql)]
    Some(DbConnType::Postgresql) => "PostgreSQL",
    #[cfg(sqlite)]
    Some(DbConnType::Sqlite) => "SQLite",
    _ => "Unknown",
});

#[cfg(sqlite)]
static CAN_BACKUP: LazyLock<bool> = LazyLock::new(|| ACTIVE_DB_TYPE.get().is_some_and(|t| *t == DbConnType::Sqlite));
#[cfg(not(sqlite))]
static CAN_BACKUP: LazyLock<bool> = LazyLock::new(|| false);

#[get("/")]
fn admin_disabled() -> &'static str {
    "The admin panel is disabled, please configure the 'ADMIN_TOKEN' variable to enable it"
}

const COOKIE_NAME: &str = "VW_ADMIN";
const ADMIN_PATH: &str = "/admin";
const DT_FMT: &str = "%Y-%m-%d %H:%M:%S %Z";

const BASE_TEMPLATE: &str = "admin/base";

const ACTING_ADMIN_USER: &str = "vaultwarden-admin-00000-000000000000";
pub const FAKE_ADMIN_UUID: &str = "00000000-0000-0000-0000-000000000000";

fn admin_path() -> String {
    format!("{}{ADMIN_PATH}", CONFIG.domain_path())
}

#[derive(Debug)]
struct IpHeader(Option<String>);

#[rocket::async_trait]
impl<'r> FromRequest<'r> for IpHeader {
    type Error = ();

    async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
        if req.headers().get_one(&CONFIG.ip_header()).is_some() {
            Outcome::Success(IpHeader(Some(CONFIG.ip_header())))
        } else if req.headers().get_one("X-Client-IP").is_some() {
            Outcome::Success(IpHeader(Some(String::from("X-Client-IP"))))
        } else if req.headers().get_one("X-Real-IP").is_some() {
            Outcome::Success(IpHeader(Some(String::from("X-Real-IP"))))
        } else if req.headers().get_one("X-Forwarded-For").is_some() {
            Outcome::Success(IpHeader(Some(String::from("X-Forwarded-For"))))
        } else {
            Outcome::Success(IpHeader(None))
        }
    }
}

fn admin_url() -> String {
    format!("{}{}", CONFIG.domain_origin(), admin_path())
}

#[derive(Responder)]
enum AdminResponse {
    #[response(status = 200)]
    Ok(ApiResult<Html<String>>),
    #[response(status = 401)]
    Unauthorized(ApiResult<Html<String>>),
    #[response(status = 429)]
    TooManyRequests(ApiResult<Html<String>>),
}

#[catch(401)]
fn admin_login(request: &Request<'_>) -> ApiResult<Html<String>> {
    if request.format() == Some(&MediaType::JSON) {
        err_code!("Authorization failed.", Status::Unauthorized.code);
    }
    let redirect = request.segments::<std::path::PathBuf>(0..).unwrap_or_default().display().to_string();
    render_admin_login(None, Some(&redirect))
}

fn render_admin_login(msg: Option<&str>, redirect: Option<&str>) -> ApiResult<Html<String>> {
    // If there is an error, show it
    let msg = msg.map(|msg| format!("Error: {msg}"));
    let json = json!({
        "page_content": "admin/login",
        "error": msg,
        "redirect": redirect,
        "urlpath": CONFIG.domain_path()
    });

    // Return the page
    let text = CONFIG.render_template(BASE_TEMPLATE, &json)?;
    Ok(Html(text))
}

#[derive(FromForm)]
struct LoginForm {
    token: String,
    redirect: Option<String>,
}

#[post("/", format = "application/x-www-form-urlencoded", data = "<data>")]
fn post_admin_login(
    data: Form<LoginForm>,
    cookies: &CookieJar<'_>,
    ip: ClientIp,
    secure: Secure,
) -> Result<Redirect, AdminResponse> {
    let data = data.into_inner();
    let redirect = data.redirect;

    if crate::ratelimit::check_limit_admin(&ip.ip).is_err() {
        return Err(AdminResponse::TooManyRequests(render_admin_login(
            Some("Too many requests, try again later."),
            redirect.as_deref(),
        )));
    }

    // If the token is invalid, redirect to login page
    if validate_token(&data.token) {
        // If the token received is valid, generate JWT and save it as a cookie
        let claims = generate_admin_claims();
        let jwt = encode_jwt(&claims);

        let cookie = Cookie::build((COOKIE_NAME, jwt))
            .path(admin_path())
            .max_age(time::Duration::minutes(CONFIG.admin_session_lifetime()))
            .same_site(SameSite::Strict)
            .http_only(true)
            .secure(secure.https);

        cookies.add(cookie);
        if let Some(redirect) = redirect {
            Ok(Redirect::to(format!("{}{redirect}", admin_path())))
        } else {
            Err(AdminResponse::Ok(render_admin_page()))
        }
    } else {
        error!("Invalid admin token. IP: {}", ip.ip);
        Err(AdminResponse::Unauthorized(render_admin_login(
            Some("Invalid admin token, please try again."),
            redirect.as_deref(),
        )))
    }
}

fn validate_token(token: &str) -> bool {
    match CONFIG.admin_token().as_ref() {
        None => false,
        Some(t) if t.starts_with("$argon2") => {
            use argon2::password_hash::PasswordVerifier;
            match argon2::password_hash::PasswordHash::new(t) {
                Ok(h) => {
                    // NOTE: hash params from `ADMIN_TOKEN` are used instead of what is configured in the `Argon2` instance.
                    argon2::Argon2::default().verify_password(token.trim().as_ref(), &h).is_ok()
                }
                Err(e) => {
                    error!("The configured Argon2 PHC in `ADMIN_TOKEN` is invalid: {e}");
                    false
                }
            }
        }
        Some(t) => crate::crypto::ct_eq(t.trim(), token.trim()),
    }
}

#[derive(Serialize)]
struct AdminTemplateData {
    page_content: String,
    page_data: Option<Value>,
    logged_in: bool,
    urlpath: String,
    sso_enabled: bool,
}

impl AdminTemplateData {
    fn new(page_content: &str, page_data: Value) -> Self {
        Self {
            page_content: String::from(page_content),
            page_data: Some(page_data),
            logged_in: true,
            urlpath: CONFIG.domain_path(),
            sso_enabled: CONFIG.sso_enabled(),
        }
    }

    fn render(self) -> Result<String, Error> {
        CONFIG.render_template(BASE_TEMPLATE, &self)
    }
}

fn render_admin_page() -> ApiResult<Html<String>> {
    let settings_json = json!({
        "config": CONFIG.prepare_json(),
        "can_backup": *CAN_BACKUP,
    });
    let text = AdminTemplateData::new("admin/settings", settings_json).render()?;
    Ok(Html(text))
}

#[get("/")]
fn admin_page(_token: AdminToken) -> ApiResult<Html<String>> {
    render_admin_page()
}

#[get("/", rank = 2)]
fn admin_page_login() -> ApiResult<Html<String>> {
    render_admin_login(None, None)
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct InviteData {
    email: String,
}

async fn get_user_or_404(user_id: &UserId, conn: &DbConn) -> ApiResult<User> {
    if let Some(user) = User::find_by_uuid(user_id, conn).await {
        Ok(user)
    } else {
        err_code!("User doesn't exist", Status::NotFound.code);
    }
}

#[post("/invite", format = "application/json", data = "<data>")]
async fn invite_user(data: Json<InviteData>, _token: AdminToken, conn: DbConn) -> JsonResult {
    async fn generate_invite(user: &User, conn: &DbConn) -> EmptyResult {
        if CONFIG.mail_enabled() {
            let org_id: OrganizationId = if CONFIG.sso_enabled() {
                FAKE_SSO_IDENTIFIER.into()
            } else {
                FAKE_ADMIN_UUID.into()
            };
            let member_id: MembershipId = FAKE_ADMIN_UUID.to_owned().into();
            mail::send_invite(user, org_id, member_id, &CONFIG.invitation_org_name(), None).await
        } else {
            let invitation = Invitation::new(&user.email);
            invitation.save(conn).await
        }
    }

    let data: InviteData = data.into_inner();
    if User::find_by_mail(&data.email, &conn).await.is_some() {
        err_code!("User already exists", Status::Conflict.code)
    }

    let mut user = User::new(&data.email, None);

    generate_invite(&user, &conn).await.map_err(|e| e.with_code(Status::InternalServerError.code))?;
    user.save(&conn).await.map_err(|e| e.with_code(Status::InternalServerError.code))?;

    Ok(Json(user.to_json(&conn).await))
}

#[post("/test/smtp", format = "application/json", data = "<data>")]
async fn test_smtp(data: Json<InviteData>, _token: AdminToken) -> EmptyResult {
    let data: InviteData = data.into_inner();

    if CONFIG.mail_enabled() {
        mail::send_test(&data.email).await
    } else {
        err!("Mail is not enabled")
    }
}

#[get("/logout")]
fn logout(cookies: &CookieJar<'_>) -> Redirect {
    cookies.remove(Cookie::build(COOKIE_NAME).path(admin_path()));
    Redirect::to(admin_path())
}

#[get("/users")]
async fn get_users_json(_token: AdminToken, conn: DbConn) -> Json<Value> {
    let users = User::get_all(&conn).await;
    let mut users_json = Vec::with_capacity(users.len());
    for (u, _) in users {
        let mut usr = u.to_json(&conn).await;
        usr["userEnabled"] = json!(u.enabled);
        usr["createdAt"] = json!(format_naive_datetime_local(&u.created_at, DT_FMT));
        usr["lastActive"] = match u.last_active(&conn).await {
            Some(dt) => json!(format_naive_datetime_local(&dt, DT_FMT)),
            None => json!(None::<String>),
        };
        users_json.push(usr);
    }

    Json(Value::Array(users_json))
}

#[get("/users/overview")]
async fn users_overview(_token: AdminToken, conn: DbConn) -> ApiResult<Html<String>> {
    let users = User::get_all(&conn).await;
    let mut users_json = Vec::with_capacity(users.len());
    for (u, sso_u) in users {
        let mut usr = u.to_json(&conn).await;
        usr["cipher_count"] = json!(Cipher::count_owned_by_user(&u.uuid, &conn).await);
        usr["attachment_count"] = json!(Attachment::count_by_user(&u.uuid, &conn).await);
        usr["attachment_size"] = json!(get_display_size(Attachment::size_by_user(&u.uuid, &conn).await));
        usr["user_enabled"] = json!(u.enabled);
        usr["created_at"] = json!(format_naive_datetime_local(&u.created_at, DT_FMT));
        usr["last_active"] = match u.last_active(&conn).await {
            Some(dt) => json!(format_naive_datetime_local(&dt, DT_FMT)),
            None => json!("Never"),
        };

        usr["sso_identifier"] = json!(sso_u.map_or(String::new(), |u| u.identifier.to_string()));

        users_json.push(usr);
    }

    let text = AdminTemplateData::new("admin/users", json!(users_json)).render()?;
    Ok(Html(text))
}

#[get("/users/by-mail/<mail>")]
async fn get_user_by_mail_json(mail: &str, _token: AdminToken, conn: DbConn) -> JsonResult {
    if let Some(u) = User::find_by_mail(mail, &conn).await {
        let mut usr = u.to_json(&conn).await;
        usr["userEnabled"] = json!(u.enabled);
        usr["createdAt"] = json!(format_naive_datetime_local(&u.created_at, DT_FMT));
        Ok(Json(usr))
    } else {
        err_code!("User doesn't exist", Status::NotFound.code);
    }
}

#[get("/users/<user_id>")]
async fn get_user_json(user_id: UserId, _token: AdminToken, conn: DbConn) -> JsonResult {
    let u = get_user_or_404(&user_id, &conn).await?;
    let mut usr = u.to_json(&conn).await;
    usr["userEnabled"] = json!(u.enabled);
    usr["createdAt"] = json!(format_naive_datetime_local(&u.created_at, DT_FMT));
    Ok(Json(usr))
}

#[post("/users/<user_id>/delete", format = "application/json")]
async fn delete_user(user_id: UserId, token: AdminToken, conn: DbConn) -> EmptyResult {
    let user = get_user_or_404(&user_id, &conn).await?;

    // Get the membership records before deleting the actual user
    let memberships = Membership::find_any_state_by_user(&user_id, &conn).await;
    let res = user.delete(&conn).await;

    for membership in memberships {
        log_event(
            EventType::OrganizationUserDeleted as i32,
            &membership.uuid,
            &membership.org_uuid,
            &ACTING_ADMIN_USER.into(),
            14, // Use UnknownBrowser type
            &token.ip.ip,
            &conn,
        )
        .await;
    }

    res
}

#[delete("/users/<user_id>/sso", format = "application/json")]
async fn delete_sso_user(user_id: UserId, token: AdminToken, conn: DbConn) -> EmptyResult {
    let memberships = Membership::find_any_state_by_user(&user_id, &conn).await;
    let res = SsoUser::delete(&user_id, &conn).await;

    for membership in memberships {
        log_event(
            EventType::OrganizationUserUnlinkedSso as i32,
            &membership.uuid,
            &membership.org_uuid,
            &ACTING_ADMIN_USER.into(),
            14, // Use UnknownBrowser type
            &token.ip.ip,
            &conn,
        )
        .await;
    }

    res
}

#[post("/users/<user_id>/deauth", format = "application/json")]
async fn deauth_user(user_id: UserId, _token: AdminToken, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
    let mut user = get_user_or_404(&user_id, &conn).await?;

    nt.send_logout(&user, None, &conn).await;

    if CONFIG.push_enabled() {
        for device in Device::find_push_devices_by_user(&user.uuid, &conn).await {
            match unregister_push_device(device.push_uuid.as_ref()).await {
                Ok(r) => r,
                Err(e) => error!("Unable to unregister devices from Bitwarden server: {e}"),
            }
        }
    }

    Device::delete_all_by_user(&user.uuid, &conn).await?;
    user.reset_security_stamp(&conn).await?;

    user.save(&conn).await
}

#[post("/users/<user_id>/disable", format = "application/json")]
async fn disable_user(user_id: UserId, _token: AdminToken, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
    let mut user = get_user_or_404(&user_id, &conn).await?;
    user.reset_security_stamp(&conn).await?;
    user.enabled = false;

    let save_result = user.save(&conn).await;

    nt.send_logout(&user, None, &conn).await;

    Device::delete_all_by_user(&user.uuid, &conn).await?;

    save_result
}

#[post("/users/<user_id>/enable", format = "application/json")]
async fn enable_user(user_id: UserId, _token: AdminToken, conn: DbConn) -> EmptyResult {
    let mut user = get_user_or_404(&user_id, &conn).await?;
    user.enabled = true;

    user.save(&conn).await
}

#[post("/users/<user_id>/remove-2fa", format = "application/json")]
async fn remove_2fa(user_id: UserId, token: AdminToken, conn: DbConn) -> EmptyResult {
    let mut user = get_user_or_404(&user_id, &conn).await?;
    TwoFactor::delete_all_by_user(&user.uuid, &conn).await?;
    two_factor::enforce_2fa_policy(&user, &ACTING_ADMIN_USER.into(), 14, &token.ip.ip, &conn).await?;
    user.totp_recover = None;
    user.save(&conn).await
}

#[post("/users/<user_id>/invite/resend", format = "application/json")]
async fn resend_user_invite(user_id: UserId, _token: AdminToken, conn: DbConn) -> EmptyResult {
    if let Some(user) = User::find_by_uuid(&user_id, &conn).await {
        //TODO: replace this with user.status check when it will be available (PR#3397)
        if !user.password_hash.is_empty() {
            err_code!("User already accepted invitation", Status::BadRequest.code);
        }

        if CONFIG.mail_enabled() {
            let org_id: OrganizationId = if CONFIG.sso_enabled() {
                FAKE_SSO_IDENTIFIER.into()
            } else {
                FAKE_ADMIN_UUID.into()
            };
            let member_id: MembershipId = FAKE_ADMIN_UUID.to_owned().into();
            mail::send_invite(&user, org_id, member_id, &CONFIG.invitation_org_name(), None).await
        } else {
            Ok(())
        }
    } else {
        err_code!("User doesn't exist", Status::NotFound.code);
    }
}

#[derive(Debug, Deserialize)]
struct MembershipTypeData {
    user_type: NumberOrString,
    user_uuid: UserId,
    org_uuid: OrganizationId,
}

#[post("/users/org_type", format = "application/json", data = "<data>")]
async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToken, conn: DbConn) -> EmptyResult {
    let data: MembershipTypeData = data.into_inner();

    let Some(mut member_to_edit) = Membership::find_by_user_and_org(&data.user_uuid, &data.org_uuid, &conn).await
    else {
        err!("The specified user isn't member of the organization")
    };

    let new_type = if let Some(new_type) = MembershipType::from_str(&data.user_type.into_string()) {
        new_type as i32
    } else {
        err!("Invalid type")
    };

    if member_to_edit.atype == MembershipType::Owner && new_type != MembershipType::Owner {
        // Removing owner permission, check that there is at least one other confirmed owner
        if Membership::count_confirmed_by_org_and_type(&data.org_uuid, MembershipType::Owner, &conn).await <= 1 {
            err!("Can't change the type of the last owner")
        }
    }

    member_to_edit.atype = new_type;
    // This check is also done at api::organizations::{accept_invite, _confirm_invite, _activate_member, edit_member}, update_membership_type
    OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?;

    log_event(
        EventType::OrganizationUserUpdated as i32,
        &member_to_edit.uuid,
        &data.org_uuid,
        &ACTING_ADMIN_USER.into(),
        14, // Use UnknownBrowser type
        &token.ip.ip,
        &conn,
    )
    .await;

    member_to_edit.save(&conn).await
}

#[post("/users/update_revision", format = "application/json")]
async fn update_revision_users(_token: AdminToken, conn: DbConn) -> EmptyResult {
    User::update_all_revisions(&conn).await
}

#[get("/organizations/overview")]
async fn organizations_overview(_token: AdminToken, conn: DbConn) -> ApiResult<Html<String>> {
    let organizations = Organization::get_all(&conn).await;
    let mut organizations_json = Vec::with_capacity(organizations.len());
    for o in organizations {
        let mut org = o.to_json();
        org["user_count"] = json!(Membership::count_by_org(&o.uuid, &conn).await);
        org["cipher_count"] = json!(Cipher::count_by_org(&o.uuid, &conn).await);
        org["collection_count"] = json!(Collection::count_by_org(&o.uuid, &conn).await);
        org["group_count"] = json!(Group::count_by_org(&o.uuid, &conn).await);
        org["event_count"] = json!(Event::count_by_org(&o.uuid, &conn).await);
        org["attachment_count"] = json!(Attachment::count_by_org(&o.uuid, &conn).await);
        org["attachment_size"] = json!(get_display_size(Attachment::size_by_org(&o.uuid, &conn).await));
        organizations_json.push(org);
    }

    let text = AdminTemplateData::new("admin/organizations", json!(organizations_json)).render()?;
    Ok(Html(text))
}

#[post("/organizations/<org_id>/delete", format = "application/json")]
async fn delete_organization(org_id: OrganizationId, _token: AdminToken, conn: DbConn) -> EmptyResult {
    let org = Organization::find_by_uuid(&org_id, &conn).await.map_res("Organization doesn't exist")?;
    org.delete(&conn).await
}

#[derive(Deserialize)]
struct GitRelease {
    tag_name: String,
}

#[derive(Deserialize)]
struct GitCommit {
    sha: String,
}

async fn get_json_api<T: DeserializeOwned>(url: &str) -> Result<T, Error> {
    Ok(make_http_request(Method::GET, url)?.send().await?.error_for_status()?.json::<T>().await?)
}

async fn get_text_api(url: &str) -> Result<String, Error> {
    Ok(make_http_request(Method::GET, url)?.send().await?.error_for_status()?.text().await?)
}

async fn has_http_access() -> bool {
    let Ok(req) = make_http_request(Method::HEAD, "https://github.com/dani-garcia/vaultwarden") else {
        return false;
    };
    match req.send().await {
        Ok(r) => r.status().is_success(),
        _ => false,
    }
}

use cached::macros::cached;
/// Cache this function to prevent API call rate limit. Github only allows 60 requests per hour, and we use 3 here already
/// It will cache this function for 600 seconds (10 minutes) which should prevent the exhaustion of the rate limit
/// Any cache will be lost if Vaultwarden is restarted
#[cached(ttl = 600, sync_writes = "default")]
async fn get_release_info(has_http_access: bool) -> (String, String, String) {
    // If the HTTP Check failed, do not even attempt to check for new versions since we were not able to connect with github.com anyway.
    if has_http_access {
        (
            match get_json_api::<GitRelease>("https://api.github.com/repos/dani-garcia/vaultwarden/releases/latest")
                .await
            {
                Ok(r) => r.tag_name,
                _ => "-".to_owned(),
            },
            match get_json_api::<GitCommit>("https://api.github.com/repos/dani-garcia/vaultwarden/commits/main").await {
                Ok(mut c) => {
                    c.sha.truncate(8);
                    c.sha
                }
                _ => "-".to_owned(),
            },
            // Do not fetch the web-vault version when running within a container
            // The web-vault version is embedded within the container it self, and should not be updated manually
            match get_json_api::<GitRelease>("https://api.github.com/repos/dani-garcia/bw_web_builds/releases/latest")
                .await
            {
                Ok(r) => r.tag_name.trim_start_matches('v').to_owned(),
                _ => "-".to_owned(),
            },
        )
    } else {
        ("-".to_owned(), "-".to_owned(), "-".to_owned())
    }
}

async fn get_ntp_time(has_http_access: bool) -> String {
    if has_http_access && let Ok(cf_trace) = get_text_api("https://cloudflare.com/cdn-cgi/trace").await {
        for line in cf_trace.lines() {
            if let Some((key, value)) = line.split_once('=')
                && key == "ts"
            {
                let ts = value.split_once('.').map_or(value, |(s, _)| s);
                if let Ok(dt) = chrono::DateTime::parse_from_str(ts, "%s") {
                    return dt.format("%Y-%m-%d %H:%M:%S UTC").to_string();
                }
                break;
            }
        }
    }
    String::from("Unable to fetch NTP time.")
}

fn web_vault_compare(active: &str, latest: &str) -> i8 {
    use semver::Version;
    use std::cmp::Ordering;

    let active_semver = Version::parse(active).unwrap_or_else(|e| {
        warn!("Unable to parse active web-vault version '{active}': {e}");
        Version::parse("2025.1.1").unwrap()
    });
    let latest_semver = Version::parse(latest).unwrap_or_else(|e| {
        warn!("Unable to parse latest web-vault version '{latest}': {e}");
        Version::parse("2025.1.1").unwrap()
    });

    match active_semver.cmp(&latest_semver) {
        Ordering::Less => -1,
        Ordering::Equal => 0,
        Ordering::Greater => 1,
    }
}

#[get("/diagnostics")]
async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> ApiResult<Html<String>> {
    use chrono::prelude::*;
    use std::net::ToSocketAddrs;

    // Execute some environment checks
    let running_within_container = is_running_in_container();
    let has_http_access = has_http_access().await;
    let uses_proxy = env::var_os("HTTP_PROXY").is_some()
        || env::var_os("http_proxy").is_some()
        || env::var_os("HTTPS_PROXY").is_some()
        || env::var_os("https_proxy").is_some();

    // Check if we are able to resolve DNS entries
    let dns_resolved = match ("github.com", 0).to_socket_addrs().map(|mut i| i.next()) {
        Ok(Some(a)) => a.ip().to_string(),
        _ => "Unable to resolve domain name.".to_owned(),
    };

    let (latest_vw_release, latest_vw_commit, latest_web_release) = get_release_info(has_http_access).await;
    let active_web_release = get_active_web_release();
    let web_vault_compare = web_vault_compare(&active_web_release, &latest_web_release);

    let ip_header_name = &ip_header.0.unwrap_or_default();

    let invalid_feature_flags: Vec<String> = parse_experimental_client_feature_flags(
        &CONFIG.experimental_client_feature_flags(),
        &FeatureFlagFilter::InvalidOnly,
    )
    .into_keys()
    .collect();

    let diagnostics_json = json!({
        "dns_resolved": dns_resolved,
        "current_release": VERSION,
        "latest_release": latest_vw_release,
        "latest_commit": latest_vw_commit,
        "web_vault_enabled": &CONFIG.web_vault_enabled(),
        "active_web_release": active_web_release,
        "latest_web_release": latest_web_release,
        "web_vault_compare": web_vault_compare,
        "running_within_container": running_within_container,
        "container_base_image": if running_within_container { container_base_image() } else { "Not applicable" },
        "has_http_access": has_http_access,
        "ip_header_exists": !ip_header_name.is_empty(),
        "ip_header_match": ip_header_name.eq(&CONFIG.ip_header()),
        "ip_header_name": ip_header_name,
        "ip_header_config": &CONFIG.ip_header(),
        "uses_proxy": uses_proxy,
        "enable_websocket": &CONFIG.enable_websocket(),
        "db_type": *DB_TYPE,
        "db_version": get_sql_server_version(&conn).await,
        "admin_url": format!("{}/diagnostics", admin_url()),
        "overrides": &CONFIG.get_overrides().join(", "),
        "invalid_feature_flags": invalid_feature_flags,
        "host_arch": env::consts::ARCH,
        "host_os":  env::consts::OS,
        "tz_env": env::var("TZ").unwrap_or_default(),
        "server_time_local": Local::now().format("%Y-%m-%d %H:%M:%S %Z").to_string(),
        "server_time": Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(), // Run the server date/time check as late as possible to minimize the time difference
        "ntp_time": get_ntp_time(has_http_access).await, // Run the ntp check as late as possible to minimize the time difference
    });

    let text = AdminTemplateData::new("admin/diagnostics", diagnostics_json).render()?;
    Ok(Html(text))
}

#[get("/diagnostics/config", format = "application/json")]
fn get_diagnostics_config(_token: AdminToken) -> Json<Value> {
    let support_json = CONFIG.get_support_json();
    Json(support_json)
}

#[get("/diagnostics/http?<code>")]
fn get_diagnostics_http(code: u16, _token: AdminToken) -> EmptyResult {
    err_code!(format!("Testing error {code} response"), code);
}

#[post("/config", format = "application/json", data = "<data>")]
async fn post_config(data: Json<ConfigBuilder>, _token: AdminToken) -> EmptyResult {
    let data: ConfigBuilder = data.into_inner();
    if let Err(e) = CONFIG.update_config(data, true).await {
        err!(format!("Unable to save config: {e:?}"))
    }
    Ok(())
}

#[post("/config/delete", format = "application/json")]
async fn delete_config(_token: AdminToken) -> EmptyResult {
    if let Err(e) = CONFIG.delete_user_config().await {
        err!(format!("Unable to delete config: {e:?}"))
    }
    Ok(())
}

#[post("/config/backup_db", format = "application/json")]
fn backup_db(_token: AdminToken) -> ApiResult<String> {
    if *CAN_BACKUP {
        match backup_sqlite() {
            Ok(f) => Ok(format!("Backup to '{f}' was successful")),
            Err(e) => err!(format!("Backup was unsuccessful {e}")),
        }
    } else {
        err!("Can't back up current DB (Only SQLite supports this feature)");
    }
}

pub struct AdminToken {
    ip: ClientIp,
}

#[rocket::async_trait]
impl<'r> FromRequest<'r> for AdminToken {
    type Error = &'static str;

    async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
        let Outcome::Success(ip) = ClientIp::from_request(request).await else {
            err_handler!("Error getting Client IP")
        };

        if !CONFIG.disable_admin_token() {
            let cookies = request.cookies();

            let access_token = if let Some(cookie) = cookies.get(COOKIE_NAME) {
                cookie.value()
            } else {
                let requested_page =
                    request.segments::<std::path::PathBuf>(0..).unwrap_or_default().display().to_string();
                // When the requested page is empty, it is `/admin`, in that case, Forward, so it will render the login page
                // Else, return a 401 failure, which will be caught
                if requested_page.is_empty() {
                    return Outcome::Forward(Status::Unauthorized);
                }
                return Outcome::Error((Status::Unauthorized, "Unauthorized"));
            };

            if decode_admin(access_token).is_err() {
                // Remove admin cookie
                cookies.remove(Cookie::build(COOKIE_NAME).path(admin_path()));
                error!("Invalid or expired admin JWT. IP: {}.", ip.ip);
                return Outcome::Error((Status::Unauthorized, "Session expired"));
            }
        }

        Outcome::Success(Self {
            ip,
        })
    }
}

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

    #[test]
    fn validate_web_vault_compare() {
        // web_vault_compare(active, latest)
        // Test normal versions
        assert!(web_vault_compare("2025.12.0", "2025.12.1") == -1);
        assert!(web_vault_compare("2025.12.1", "2025.12.1") == 0);
        assert!(web_vault_compare("2025.12.2", "2025.12.1") == 1);

        // Test patched/+build.n versions
        // Newer latest version
        assert!(web_vault_compare("2025.12.0+build.1", "2025.12.1") == -1);
        assert!(web_vault_compare("2025.12.1", "2025.12.1+build.1") == -1);
        assert!(web_vault_compare("2025.12.0+build.1", "2025.12.1+build.1") == -1);
        assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.2") == -1);
        // Equal versions
        assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.1") == 0);
        assert!(web_vault_compare("2025.12.2+build.2", "2025.12.2+build.2") == 0);
        // Newer active version
        assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1") == 1);
        assert!(web_vault_compare("2025.12.2", "2025.12.1+build.1") == 1);
        assert!(web_vault_compare("2025.12.2+build.1", "2025.12.1+build.1") == 1);
        assert!(web_vault_compare("2025.12.1+build.3", "2025.12.1+build.2") == 1);
    }
}
