use axum::extract::{FromRequestParts, Query, State};
use axum::http::request::Parts;
use axum::response::Redirect;
use axum::Json;
use axum_extra::extract::cookie::{Cookie, SameSite, SignedCookieJar};
use chrono::Utc;
use serde::Deserialize;
use uuid::Uuid;

use crate::error::{ApiError, ApiResult};
use crate::state::AppState;

pub const SESSION_COOKIE: &str = "photos_session";
const STATE_COOKIE: &str = "photos_oauth_state";
const SESSION_DAYS: i64 = 30;

#[derive(Debug, Clone, Deserialize)]
pub struct OidcDiscovery {
    pub authorization_endpoint: String,
    pub token_endpoint: String,
    pub userinfo_endpoint: String,
}

pub async fn discovery(state: &AppState) -> anyhow::Result<OidcDiscovery> {
    let discovered = state
        .oidc
        .get_or_try_init(|| async {
            let url = format!(
                "{}/.well-known/openid-configuration",
                state.config.oidc_issuer
            );
            let resp = state.http.get(&url).send().await?.error_for_status()?;
            Ok::<_, anyhow::Error>(resp.json::<OidcDiscovery>().await?)
        })
        .await?;
    Ok(discovered.clone())
}

#[derive(Debug, Clone)]
pub struct AuthUser {
    pub id: Uuid,
    pub email: String,
}

fn parse_session(value: &str) -> Option<(Uuid, i64, String)> {
    let mut parts = value.splitn(3, '|');
    let id = Uuid::parse_str(parts.next()?).ok()?;
    let exp: i64 = parts.next()?.parse().ok()?;
    let email = parts.next()?.to_string();
    Some((id, exp, email))
}

/// Sessions are only honored while the email is still in ALLOWED_EMAILS, so
/// removing an address from the allowlist revokes access immediately.
pub fn user_from_jar(state: &AppState, jar: &SignedCookieJar) -> Option<AuthUser> {
    let cookie = jar.get(SESSION_COOKIE)?;
    let (id, exp, email) = parse_session(cookie.value())?;
    if exp < Utc::now().timestamp() {
        return None;
    }
    if !state.config.allowed_emails.contains(&email) {
        return None;
    }
    Some(AuthUser { id, email })
}

/// A fresh session cookie when the current one has used up more than half its
/// lifetime — appended to responses by the admin middleware so an active
/// photographer's session slides instead of hard-expiring 30 days after login.
pub fn refreshed_session(state: &AppState, jar: &SignedCookieJar) -> Option<Cookie<'static>> {
    let cookie = jar.get(SESSION_COOKIE)?;
    let (id, exp, email) = parse_session(cookie.value())?;
    let remaining = exp - Utc::now().timestamp();
    if remaining > SESSION_DAYS * 86400 / 2 {
        return None;
    }
    Some(session_cookie(state, id, &email))
}

impl FromRequestParts<AppState> for AuthUser {
    type Rejection = ApiError;

    async fn from_request_parts(
        parts: &mut Parts,
        _state: &AppState,
    ) -> Result<Self, Self::Rejection> {
        // Only valid behind the require_photographer layer, which validates
        // the session and stashes the user. Routes outside the admin router
        // must not use this extractor — session validation lives in the
        // middleware (and user_from_jar for the dual-auth image routes).
        parts
            .extensions
            .get::<AuthUser>()
            .cloned()
            .ok_or_else(ApiError::unauthorized)
    }
}

pub fn random_token(len: usize) -> String {
    use rand::Rng;
    rand::thread_rng()
        .sample_iter(&rand::distributions::Alphanumeric)
        .take(len)
        .map(char::from)
        .collect()
}

pub(crate) fn base_cookie(name: &'static str, value: String, secure: bool) -> Cookie<'static> {
    Cookie::build((name, value))
        .path("/")
        .http_only(true)
        .same_site(SameSite::Lax)
        .secure(secure)
        .build()
}

fn session_cookie(state: &AppState, user_id: Uuid, email: &str) -> Cookie<'static> {
    let exp = Utc::now().timestamp() + SESSION_DAYS * 86400;
    let mut cookie = base_cookie(
        SESSION_COOKIE,
        format!("{user_id}|{exp}|{email}"),
        state.config.cookie_secure(),
    );
    cookie.set_max_age(time::Duration::days(SESSION_DAYS));
    cookie
}

async fn upsert_user(
    state: &AppState,
    oidc_subject: &str,
    email: &str,
    display_name: &str,
) -> Result<Uuid, sqlx::Error> {
    let (user_id,): (Uuid,) = sqlx::query_as(
        "insert into users (oidc_subject, email, display_name) values ($1, $2, $3)
         on conflict (oidc_subject) do update
             set email = excluded.email, display_name = excluded.display_name
         returning id",
    )
    .bind(oidc_subject)
    .bind(email)
    .bind(display_name)
    .fetch_one(&state.db)
    .await?;
    Ok(user_id)
}

/// Login/callback are top-level browser navigations — errors must land the
/// user back on the SPA login card, never on a raw JSON body.
fn error_redirect(error: &ApiError) -> Redirect {
    let message = if error.0.is_server_error() {
        "sign-in failed — please try again"
    } else {
        error.1.as_str()
    };
    Redirect::to(&format!("/?auth_error={}", urlencoding::encode(message)))
}

pub async fn login(
    State(state): State<AppState>,
    jar: SignedCookieJar,
) -> (SignedCookieJar, Redirect) {
    match login_inner(&state, jar.clone()).await {
        Ok(ok) => ok,
        Err(e) => {
            tracing::warn!("login failed: {} {}", e.0, e.1);
            (jar, error_redirect(&e))
        }
    }
}

async fn login_inner(
    state: &AppState,
    jar: SignedCookieJar,
) -> ApiResult<(SignedCookieJar, Redirect)> {
    if let Some(email) = state.config.dev_autologin_email.clone() {
        tracing::warn!("DEV_AUTOLOGIN_EMAIL is set — signing in {email} without OIDC");
        let email = email.to_lowercase();
        if !state.config.allowed_emails.contains(&email) {
            return Err(ApiError::forbidden(
                "DEV_AUTOLOGIN_EMAIL must also be in ALLOWED_EMAILS",
            ));
        }
        let user_id = upsert_user(state, &format!("dev:{email}"), &email, &email).await?;
        let cookie = session_cookie(state, user_id, &email);
        return Ok((jar.add(cookie), Redirect::to("/")));
    }
    let discovered = discovery(state).await?;
    let oauth_state = random_token(24);
    let redirect_uri = format!("{}/api/auth/callback", state.config.public_url);
    let separator = if discovered.authorization_endpoint.contains('?') {
        '&'
    } else {
        '?'
    };
    let url = format!(
        "{}{}response_type=code&client_id={}&redirect_uri={}&scope=openid%20email%20profile&state={}",
        discovered.authorization_endpoint,
        separator,
        urlencoding::encode(&state.config.oidc_client_id),
        urlencoding::encode(&redirect_uri),
        oauth_state
    );
    let mut cookie = base_cookie(STATE_COOKIE, oauth_state, state.config.cookie_secure());
    cookie.set_max_age(time::Duration::minutes(10));
    Ok((jar.add(cookie), Redirect::to(&url)))
}

#[derive(Deserialize)]
pub struct CallbackQuery {
    code: Option<String>,
    state: Option<String>,
    error: Option<String>,
    error_description: Option<String>,
}

#[derive(Deserialize)]
struct TokenResponse {
    access_token: String,
}

#[derive(Deserialize)]
struct UserInfo {
    sub: String,
    email: Option<String>,
    name: Option<String>,
    preferred_username: Option<String>,
}

pub async fn callback(
    State(state): State<AppState>,
    jar: SignedCookieJar,
    Query(query): Query<CallbackQuery>,
) -> (SignedCookieJar, Redirect) {
    match callback_inner(&state, jar.clone(), query).await {
        Ok(ok) => ok,
        Err(e) => {
            tracing::warn!("oidc callback failed: {} {}", e.0, e.1);
            (jar, error_redirect(&e))
        }
    }
}

async fn callback_inner(
    state: &AppState,
    jar: SignedCookieJar,
    query: CallbackQuery,
) -> ApiResult<(SignedCookieJar, Redirect)> {
    if let Some(err) = query.error {
        let detail = query.error_description.unwrap_or_default();
        return Err(ApiError::bad_request(format!("oidc error: {err} {detail}")));
    }
    let code = query
        .code
        .ok_or_else(|| ApiError::bad_request("missing code"))?;
    let returned_state = query.state.unwrap_or_default();
    let cookie_state = jar.get(STATE_COOKIE).map(|c| c.value().to_string());
    if returned_state.is_empty() || cookie_state.as_deref() != Some(returned_state.as_str()) {
        return Err(ApiError::bad_request("oauth state mismatch"));
    }
    let jar = jar.remove(Cookie::build((STATE_COOKIE, "")).path("/").build());

    let discovered = discovery(state).await?;
    let redirect_uri = format!("{}/api/auth/callback", state.config.public_url);
    let token: TokenResponse = state
        .http
        .post(&discovered.token_endpoint)
        .form(&[
            ("grant_type", "authorization_code"),
            ("code", code.as_str()),
            ("redirect_uri", redirect_uri.as_str()),
            ("client_id", state.config.oidc_client_id.as_str()),
            ("client_secret", state.config.oidc_client_secret.as_str()),
        ])
        .send()
        .await?
        .error_for_status()?
        .json()
        .await?;

    let info: UserInfo = state
        .http
        .get(&discovered.userinfo_endpoint)
        .bearer_auth(&token.access_token)
        .send()
        .await?
        .error_for_status()?
        .json()
        .await?;

    let email = info.email.clone().unwrap_or_default().to_lowercase();
    if email.is_empty() || !state.config.allowed_emails.contains(&email) {
        return Err(ApiError::forbidden("this account is not allowed to sign in"));
    }
    let display_name = info
        .name
        .or(info.preferred_username)
        .unwrap_or_else(|| email.clone());

    let user_id = upsert_user(state, &info.sub, &email, &display_name).await?;
    let cookie = session_cookie(state, user_id, &email);
    Ok((jar.add(cookie), Redirect::to("/")))
}

pub async fn logout(jar: SignedCookieJar) -> (SignedCookieJar, Json<serde_json::Value>) {
    let jar = jar.remove(Cookie::build((SESSION_COOKIE, "")).path("/").build());
    (jar, Json(serde_json::json!({ "ok": true })))
}

pub async fn me(user: AuthUser) -> Json<serde_json::Value> {
    Json(serde_json::json!({ "email": user.email }))
}
