use axum::{
    extract::{Query, State},
    http::header::SET_COOKIE,
    response::{AppendHeaders, Redirect},
};
use openid::Options;
use rand::Rng;
use serde::Deserialize;
use sqlx::FromRow;
use tower_sessions::Session;

use crate::routes::profile::update_keycloak_user_attributes;
use crate::state::AppState;
use irc_now_common::auth::UserClaims;

#[derive(FromRow)]
struct UserRow {
    plan: String,
    stripe_customer_id: Option<String>,
    display_name: Option<String>,
    content_expires: bool,
}

fn random_string(len: usize) -> String {
    let mut rng = rand::thread_rng();
    (0..len)
        .map(|_| {
            let idx = rng.gen_range(0..36);
            if idx < 10 {
                (b'0' + idx) as char
            } else {
                (b'a' + idx - 10) as char
            }
        })
        .collect()
}

pub async fn login(State(state): State<AppState>, session: Session) -> Redirect {
    let csrf_state = random_string(32);
    let nonce = random_string(32);

    session
        .insert("oidc_state", &csrf_state)
        .await
        .expect("session insert failed");
    session
        .insert("oidc_nonce", &nonce)
        .await
        .expect("session insert failed");

    let options = Options {
        scope: Some("openid email profile".to_string()),
        state: Some(csrf_state),
        nonce: Some(nonce),
        ..Options::default()
    };

    let auth_url = state.oidc_client.auth_url(&options);

    Redirect::temporary(auth_url.as_str())
}

#[derive(Deserialize)]
pub struct CallbackParams {
    code: String,
    state: String,
}

pub async fn callback(
    State(state): State<AppState>,
    session: Session,
    Query(params): Query<CallbackParams>,
) -> Result<(AppendHeaders<[(axum::http::HeaderName, &'static str); 1]>, Redirect), Redirect> {
    let stored_state: Option<String> = session
        .get("oidc_state")
        .await
        .unwrap_or(None);
    let stored_nonce: Option<String> = session
        .get("oidc_nonce")
        .await
        .unwrap_or(None);

    session.remove::<String>("oidc_state").await.ok();
    session.remove::<String>("oidc_nonce").await.ok();

    let Some(stored_state) = stored_state else {
        tracing::warn!("no oidc_state in session");
        return Err(Redirect::temporary("/auth/login"));
    };

    if params.state != stored_state {
        tracing::warn!("CSRF state mismatch");
        return Err(Redirect::temporary("/auth/login"));
    }

    let nonce_ref = stored_nonce.as_deref();

    let token = state
        .oidc_client
        .authenticate(&params.code, nonce_ref, None)
        .await
        .map_err(|e| {
            tracing::error!("token exchange failed: {e}");
            Redirect::temporary("/auth/login")
        })?;

    let id_token = token.id_token.ok_or_else(|| {
        tracing::error!("no id_token in response");
        Redirect::temporary("/auth/login")
    })?;

    let payload = id_token.payload().map_err(|e| {
        tracing::error!("failed to decode id_token payload: {e}");
        Redirect::temporary("/auth/login")
    })?;

    let sub = &payload.userinfo.sub;
    let email = payload.userinfo.email.as_deref();

    let row = sqlx::query_as::<_, UserRow>(
        "INSERT INTO users (keycloak_sub, email, last_login_at)
         VALUES ($1, $2, now())
         ON CONFLICT (keycloak_sub) DO UPDATE SET email = EXCLUDED.email, last_login_at = now()
         RETURNING plan, stripe_customer_id, display_name, content_expires",
    )
    .bind(sub)
    .bind(email)
    .fetch_one(&state.db)
    .await
    .map_err(|e| {
        tracing::error!("user upsert failed: {e}");
        Redirect::temporary("/auth/login")
    })?;

    metrics::counter!("irc_now_logins_total").increment(1);

    let claims = UserClaims {
        sub: sub.clone(),
        email: email.map(String::from),
        plan: Some(row.plan.clone()),
        stripe_customer_id: row.stripe_customer_id,
        display_name: row.display_name,
        content_expires: Some(row.content_expires),
    };

    if let Err(e) = update_keycloak_user_attributes(
        &state,
        sub,
        &row.plan,
        row.content_expires,
    )
    .await
    {
        tracing::warn!("keycloak attribute sync on login failed: {e}");
    }

    session
        .insert("user", &claims)
        .await
        .map_err(|e| {
            tracing::error!("failed to store user claims in session: {e}");
            Redirect::temporary("/auth/login")
        })?;

    Ok((
        AppendHeaders([(SET_COOKIE, "irc_logged_in=1; Domain=irc.now; Path=/; SameSite=Lax; Secure; Max-Age=2592000")]),
        Redirect::temporary("/dashboard"),
    ))
}

pub async fn logout(
    State(state): State<AppState>,
    session: Session,
) -> (AppendHeaders<[(axum::http::HeaderName, &'static str); 1]>, Redirect) {
    session.flush().await.ok();

    let origin = url::Url::parse(&state.oidc.redirect_url)
        .map(|u| format!("{}://{}", u.scheme(), u.host_str().unwrap_or("irc.now")))
        .unwrap_or_else(|_| "https://irc.now".to_string());

    let logout_url = format!(
        "{}/protocol/openid-connect/logout?post_logout_redirect_uri={}&client_id={}",
        state.oidc.issuer_url,
        urlencoding::encode(&origin),
        urlencoding::encode(&state.oidc.client_id),
    );

    (
        AppendHeaders([(SET_COOKIE, "irc_logged_in=; Domain=irc.now; Path=/; SameSite=Lax; Secure; Max-Age=0")]),
        Redirect::temporary(&logout_url),
    )
}
