use auth::{
    crypto::{hash_password, verify_password},
    jwt::generate_tokens,
};
use axum::{
    extract::State,
    http::{header::SET_COOKIE, StatusCode},
    response::IntoResponse,
    routing::{get, post},
    Json, Router,
};
use db::models::user::{CreateUserPayload, UserRepository};
use serde::{Deserialize, Serialize};
use contracts::auth_middleware::AuthUser;
use uuid::Uuid;
use crate::AppState;

pub fn router() -> Router<AppState> {
    Router::new()
        .route("/captcha",         post(generate_captcha))
        .route("/check-email",     post(check_email))
        .route("/register",        post(register))
        .route("/login",           post(login))
        .route("/logout",          post(logout))
        .route("/refresh",         post(refresh))
        .route("/session",         get(session))
        .route("/switch-role",     post(switch_role))
        .route("/verify-email",    post(verify_email))
        .route("/verify-otp",      post(verify_email))
        .route("/resend-otp",      post(resend_otp))
        .route("/forgot-password", post(forgot_password))
        .route("/reset-password",  post(reset_password))
        .route("/change-password", post(change_password))
}

// ── DTOs ──────────────────────────────────────────────────────────────────────

#[derive(Deserialize)]
#[allow(dead_code)]
pub struct RegisterPayload {
    #[serde(default)]
    pub first_name: Option<String>,
    #[serde(default)]
    pub last_name:  Option<String>,
    #[serde(default)]
    pub name: Option<String>,
    pub email:     String,
    pub phone:     Option<String>,
    pub password:  String,
    pub intent:    Option<String>,
    #[serde(alias = "role_key", alias = "roleKey")]
    pub profession: Option<String>,
    pub captcha_id:     String,
    pub captcha_answer: String,
}

#[derive(Deserialize)]
pub struct LoginPayload {
    pub email:    String,
    pub password: String,
    pub captcha_id:     String,
    pub captcha_answer: String,
}

#[derive(Serialize)]
pub struct CaptchaResponse {
    pub captcha_id: String,
    pub challenge:  String,
}

#[derive(Deserialize)]
pub struct CheckEmailPayload {
    pub email: String,
}

#[derive(Deserialize)]
pub struct VerifyEmailPayload {
    pub otp: String,
    pub email: Option<String>,
}

#[derive(Deserialize)]
pub struct ResendOtpPayload {
    pub email: String,
}

#[derive(Deserialize)]
pub struct ForgotPasswordPayload {
    pub email: String,
}

#[derive(Deserialize)]
pub struct ResetPasswordPayload {
    pub code:         String,
    pub new_password: String,
}

#[derive(Deserialize)]
pub struct ChangePasswordPayload {
    pub current_password: String,
    pub new_password:     String,
}

#[derive(Deserialize)]
pub struct SwitchRolePayload {
    pub role_key: String,
}

#[derive(Serialize)]
pub struct RegisterResponse {
    pub user_id:        String,
    pub email:          String,
    pub phone:          Option<String>,
    pub name:      String,
    pub status:         String,
    pub email_verified: bool,
    pub created_at:     String,
    pub otp:            Option<String>,
}

#[derive(Serialize)]
pub struct SessionUser {
    pub id:             String,
    pub email:          String,
    pub name:      String,
    pub email_verified: bool,
    pub roles:          Vec<String>,
    pub active_role:    Option<String>,
}

#[derive(Serialize)]
pub struct ErrorResponse {
    pub error:       String,
    pub code:        String,
    #[serde(rename = "statusCode")]
    pub status_code: u16,
}

fn err(status: StatusCode, msg: &str, code: &str) -> (StatusCode, Json<ErrorResponse>) {
    (status, Json(ErrorResponse {
        error:       msg.to_string(),
        code:        code.to_string(),
        status_code: status.as_u16(),
    }))
}

fn normalize_role_key(raw: &str) -> String {
    raw.trim().to_uppercase().replace(['-', ' '], "_")
}

fn resolve_signup_role_candidates(intent: Option<&str>, profession: Option<&str>) -> Vec<String> {
    let normalized_intent = intent.map(normalize_role_key).unwrap_or_default();
    let normalized_profession = profession.map(normalize_role_key).filter(|v| !v.is_empty());

    if normalized_intent.is_empty() {
        return vec![];
    }

    if normalized_intent.contains("COMPANY") {
        return vec!["COMPANY".to_string()];
    }
    if normalized_intent.contains("CUSTOMER") {
        return vec!["CUSTOMER".to_string()];
    }
    if normalized_intent.contains("JOB_SEEKER") || normalized_intent.contains("JOBSEEKER") {
        return vec!["JOB_SEEKER".to_string()];
    }
    if normalized_intent.contains("PROFESSIONAL") {
        if let Some(p) = normalized_profession {
            return vec![p];
        }
        return vec![];
    }

    vec![]
}

fn is_dummy_account_email(email: &str) -> bool {
    // Only allow explicitly allowlisted demo emails, not any @demo.com address
    static DEMO_EMAILS: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
    let allowed = DEMO_EMAILS.get_or_init(|| {
        std::env::var("DEMO_ACCOUNT_EMAILS")
            .unwrap_or_default()
            .split(',')
            .map(|s| s.trim().to_lowercase())
            .filter(|s| !s.is_empty())
            .collect()
    });
    allowed.contains(&email.to_lowercase())
}

fn role_display_name_from_code(code: &str) -> String {
    code
        .split('_')
        .filter(|part| !part.is_empty())
        .map(|part| {
            let lower = part.to_lowercase();
            let mut chars = lower.chars();
            match chars.next() {
                Some(first) => format!("{}{}", first.to_uppercase(), chars.collect::<String>()),
                None => String::new(),
            }
        })
        .collect::<Vec<String>>()
        .join(" ")
}

async fn ensure_role_exists(pool: &sqlx::PgPool, role_code: &str) -> Option<Uuid> {
    let normalized = normalize_role_key(role_code);
    if normalized.is_empty() {
        return None;
    }

    if let Ok(found) = sqlx::query_scalar::<_, Uuid>("SELECT id FROM roles WHERE key = $1")
        .bind(&normalized)
        .fetch_optional(pool)
        .await
    {
        if found.is_some() {
            return found;
        }
    }

    let display_name = role_display_name_from_code(&normalized);
    let role_id = sqlx::query_scalar::<_, Uuid>(
        r#"
        INSERT INTO roles (key, name, audience, is_active)
        VALUES ($1, $2, 'EXTERNAL', true)
        ON CONFLICT (key)
        DO UPDATE SET is_active = true
        RETURNING id
        "#,
    )
    .bind(&normalized)
    .bind(display_name)
    .fetch_one(pool)
    .await
    .ok()?;

    Some(role_id)
}

// ── Handlers ──────────────────────────────────────────────────────────────────

/// POST /api/auth/captcha
///
/// Generates a short-lived (5 min) math challenge, stores the expected answer
/// server-side keyed by a random `captcha_id`, and returns the id + human-readable
/// challenge text. The client must echo both `captcha_id` and `captcha_answer`
/// back on signup/login; the answer is verified and consumed (single-use) there.
async fn generate_captcha(
    State(state): State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
    let mut redis = state.redis.clone();
    let challenge = cache::captcha::generate();

    cache::captcha::set(&mut redis, &challenge.captcha_id, &challenge.answer)
        .await
        .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?;

    Ok((StatusCode::OK, Json(CaptchaResponse {
        captcha_id: challenge.captcha_id,
        challenge:  challenge.challenge,
    })))
}

/// POST /api/auth/check-email
async fn check_email(
    State(state): State<AppState>,
    Json(payload): Json<CheckEmailPayload>,
) -> impl IntoResponse {
    let email = payload.email.trim().to_lowercase();
    if email.is_empty() {
        return (
            StatusCode::BAD_REQUEST,
            Json(serde_json::json!({
                "exists": false,
                "error": "Email is required"
            })),
        );
    }

    let user = UserRepository::get_by_email(&state.pool, &email).await.ok();
    let exists = user.is_some();
    let roles = if let Some(ref found_user) = user {
        UserRepository::get_user_role_keys(&state.pool, found_user.id)
            .await
            .unwrap_or_default()
    } else {
        Vec::new()
    };
    let active_role = roles.first().cloned();
    (
        StatusCode::OK,
        Json(serde_json::json!({
            "exists": exists,
            "active_role": active_role,
            "roles": roles,
        })),
    )
}

/// POST /api/auth/register
async fn register(
    State(state): State<AppState>,
    Json(payload): Json<RegisterPayload>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
    let email     = payload.email.to_lowercase();
    let mut redis = state.redis.clone();

    // Captcha must be verified (and is consumed, single-use) before anything else.
    if !cache::captcha::verify(&mut redis, &payload.captcha_id, &payload.captcha_answer).await {
        return Err(err(StatusCode::BAD_REQUEST, "Invalid or expired captcha", "CAPTCHA_FAILED"));
    }

    // Rate limit: max 10 registrations per hour per email
    if !cache::rate_limit::check_register(&mut redis, &email).await.unwrap_or(true) {
        return Err(err(StatusCode::TOO_MANY_REQUESTS, "Too many registration attempts. Try again later.", "RATE_LIMITED"));
    }

    if payload.password.len() < 8 {
        return Err(err(StatusCode::UNPROCESSABLE_ENTITY, "Password must be at least 8 characters", "VALIDATION_ERROR"));
    }

    let password_hash = hash_password(&payload.password)
        .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "INTERNAL_ERROR"))?;

    let first_name = payload.first_name.unwrap_or_default().trim().to_string();
    let last_name = payload.last_name.unwrap_or_default().trim().to_string();

    let user = UserRepository::create(&state.pool, CreateUserPayload {
        first_name: Some(first_name),
        last_name: Some(last_name),
        email:      email.clone(),
        password_hash,
    })
    .await
    .map_err(|e| {
        let msg = e.to_string();
        if msg.contains("users_email_key") || (msg.contains("email") && msg.contains("unique")) {
            err(StatusCode::CONFLICT, "Email already registered", "EMAIL_EXISTS")
        } else if msg.contains("users_phone_key") || (msg.contains("phone") && msg.contains("unique")) {
            err(StatusCode::CONFLICT, "Phone already registered", "PHONE_EXISTS")
        } else {
            err(StatusCode::INTERNAL_SERVER_ERROR, &msg, "DB_ERROR")
        }
    })?;

    // Issue this account its own LiteLLM virtual key (best-effort — a LiteLLM
    // outage must not block registration; the internal /llm-key endpoint will
    // lazily generate one on first use if this fails).
    match crate::ai::litellm::LiteLlmClient::new() {
        Ok(client) => match client.generate_key(user.id).await {
            Ok(key) => {
                if let Err(e) = db::models::user::UserRepository::set_litellm_key(&state.pool, user.id, &key).await {
                    tracing::error!(user_id = %user.id, error = %e, "Failed to store LiteLLM key after registration");
                }
            }
            Err(e) => tracing::warn!(user_id = %user.id, error = %e, "Failed to generate LiteLLM key at registration"),
        },
        Err(e) => tracing::warn!(error = %e, "LiteLLM client unavailable at registration"),
    }

    // Check if this is a demo account (payment gateway integration)
    let is_demo_account = is_dummy_account_email(&email);

    // Assign signup role immediately (intent-driven). Email verification is still required for login.
    let role_candidates = resolve_signup_role_candidates(
        payload.intent.as_deref(),
        payload.profession.as_deref(),
    );
    
    // Log role assignment for debugging
    tracing::info!(
        user_id = %user.id,
        email = %email,
        intent = ?payload.intent,
        profession = ?payload.profession,
        candidates = ?role_candidates,
        "Assigning roles after registration"
    );
    
    for role_key in role_candidates {
        let role_id = ensure_role_exists(&state.pool, &role_key).await;
        if let Some(role_id) = role_id {
            // Roles assigned at signup are immediately ACTIVE for login + session.
            // Document/profile review (handled separately in onboarding / verifications)
            // still uses PENDING_APPROVAL via user_role_profiles — do NOT gate raw
            // role assignment on that flow. Previously this defaulted to "PENDING"
            // which made get_user_role_keys return [] and login issued a JWT with
            // no role, breaking the "role not assigned" UX bug.
            let status = "APPROVED";

            // Try to update existing assignment first
            let update_result = sqlx::query(
                r#"
                UPDATE user_role_assignments
                SET status = $3, approved_at = NOW()
                WHERE user_id = $1 AND role_id = $2
                "#,
            )
            .bind(user.id)
            .bind(role_id)
            .bind(status)
            .execute(&state.pool)
            .await;
            
            if let Err(e) = &update_result {
                tracing::warn!(error = %e, "Failed to update role assignment");
            }

            // Insert if not exists (UPSERT pattern)
            let insert_result = sqlx::query(
                r#"
                INSERT INTO user_role_assignments (user_id, role_id, status)
                SELECT $1, $2, $3
                WHERE NOT EXISTS (
                    SELECT 1 FROM user_role_assignments WHERE user_id = $1 AND role_id = $2
                )
                "#,
            )
            .bind(user.id)
            .bind(role_id)
            .bind(status)
            .execute(&state.pool)
            .await;
            
            if let Err(e) = &insert_result {
                tracing::error!(error = %e, "Failed to insert role assignment");
            } else {
                tracing::info!(role_key = %role_key, status = %status, "Role assigned successfully");
            }
        } else {
            tracing::warn!(role_key = %role_key, "Role not found in database");
        }
    }

    // For demo accounts: auto-verify email and skip OTP
    if is_demo_account {
        tracing::info!(email = %email, "Demo account auto-verified");
        let _ = sqlx::query(
            "UPDATE users SET email_verified = true, status = 'ACTIVE' WHERE id = $1"
        )
        .bind(user.id)
        .execute(&state.pool)
        .await;
        
        // Return success with demo flag
        let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
        return Ok((StatusCode::CREATED, Json(RegisterResponse {
            user_id:        user.id.to_string(),
            email:          user.email,
            phone:          None,
            name:           user_name,
            status:         "ACTIVE".to_string(),
            email_verified: true,
            created_at:     user.created_at.to_rfc3339(),
            otp:            Some("DEMO".to_string()), // Return dummy OTP for demo
        })));
    }

    // Store OTP in Redis (15-min TTL, keyed by code → user_id)
    let otp = format!("{:06}", rand::random::<u32>() % 1_000_000);
    tracing::info!(email = %email, "OTP generated for registration");
    cache::otp::set(&mut redis, &otp, &user.id.to_string())
        .await
        .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?;
    cache::otp::record_resend(&mut redis, &user.id.to_string()).await.ok();

    let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
    if let Err(e) = state.mail.send_verification_email(&user.email, &user_name, &otp).await {
        tracing::error!(
            error = %e,
            email = %user.email,
            endpoint = "/api/auth/register",
            "Failed to send verification email - OTP still stored in Redis"
        );
        // OTP is already in Redis — do not fail registration if email sending fails
    }

    Ok((StatusCode::CREATED, Json(RegisterResponse {
        user_id:        user.id.to_string(),
        email:          user.email,
        phone:          None,
        name:           user_name,
        status:         user.status,
        email_verified: user.email_verified,
        created_at:     user.created_at.to_rfc3339(),
        otp:            None,
    })))
}

/// POST /api/auth/login
async fn login(
    State(state): State<AppState>,
    Json(payload): Json<LoginPayload>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
    let email = payload.email.to_lowercase();
    let is_demo_account = is_dummy_account_email(&email);
    let mut redis = state.redis.clone();

    // Captcha must be verified (and is consumed, single-use) before anything else.
    if !cache::captcha::verify(&mut redis, &payload.captcha_id, &payload.captcha_answer).await {
        return Err(err(StatusCode::BAD_REQUEST, "Invalid or expired captcha", "CAPTCHA_FAILED"));
    }

    // Demo logins must not depend on Redis because the live demo path needs to work
    // even when cache persistence is degraded.
    if !is_demo_account && !cache::rate_limit::check_login(&mut redis, &email).await.unwrap_or(true) {
        return Err(err(StatusCode::TOO_MANY_REQUESTS, "Too many login attempts. Try again in 15 minutes.", "RATE_LIMITED"));
    }

    let user = UserRepository::get_by_email(&state.pool, &email)
        .await
        .map_err(|_| err(StatusCode::UNAUTHORIZED, "Invalid credentials", "INVALID_CREDENTIALS"))?;

    if user.status == "SUSPENDED" {
        return Err(err(StatusCode::FORBIDDEN, "Account suspended", "ACCOUNT_SUSPENDED"));
    }
    
    // Allow demo accounts to login without email verification
    if !user.email_verified && !is_demo_account {
        return Err(err(StatusCode::UNAUTHORIZED, "Email not verified. Check your inbox.", "EMAIL_NOT_VERIFIED"));
    }

    let is_valid = verify_password(&payload.password, &user.password_hash)
        .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "INTERNAL_ERROR"))?;
    if !is_valid {
        return Err(err(StatusCode::UNAUTHORIZED, "Invalid credentials", "INVALID_CREDENTIALS"));
    }

    let user_roles = UserRepository::get_user_role_keys(&state.pool, user.id)
        .await
        .unwrap_or_default();

    let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
    let tokens = generate_tokens(
        user.id.to_string(),
        user.email.clone(),
        user_roles.clone(),
        user_roles.first().cloned(),
        &jwt_secret,
    )
    .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "TOKEN_ERROR"))?;

    // Refresh token → Redis (30-day TTL)
    if let Err(e) = cache::token::store_refresh(&mut redis, &tokens.refresh_token, &user.id.to_string()).await {
        tracing::warn!(
            error = %e,
            user_id = %user.id,
            email = %user.email,
            "Failed to store refresh token in Redis; continuing with access token only"
        );
    }

    let cookie = format!(
        "nxtgauge_refresh_token={}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=2592000",
        tokens.refresh_token
    );
    let active_role = user_roles.first().cloned();

    let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
    Ok((StatusCode::OK, [(SET_COOKIE, cookie)], Json(serde_json::json!({
        "access_token": tokens.access_token,
        "token_type":   "Bearer",
        "expires_in":   900,
        "user": {
            "id":             user.id.to_string(),
            "email":          user.email,
            "name":           user_name,
            "email_verified": user.email_verified,
            "active_role":    active_role,
            "roles":          user_roles,
        }
    }))))
}

/// POST /api/auth/logout
async fn logout(
    State(state): State<AppState>,
    req: axum::http::Request<axum::body::Body>,
) -> impl IntoResponse {
    let cookie_header = req
        .headers()
        .get(axum::http::header::COOKIE)
        .and_then(|v| v.to_str().ok())
        .unwrap_or("");

    if let Some(token) = cookie_header
        .split(';')
        .map(str::trim)
        .find_map(|p| p.strip_prefix("nxtgauge_refresh_token="))
    {
        let mut redis = state.redis.clone();
        let _ = cache::token::revoke_refresh(&mut redis, token).await;
    }

    let clear = "nxtgauge_refresh_token=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0";
    (StatusCode::OK, [(SET_COOKIE, clear)], Json(serde_json::json!({ "message": "Logged out" })))
}

/// POST /api/auth/refresh
async fn refresh(
    State(state): State<AppState>,
    req: axum::http::Request<axum::body::Body>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
    let cookie_header = req
        .headers()
        .get(axum::http::header::COOKIE)
        .and_then(|v| v.to_str().ok())
        .unwrap_or("");

    let token = cookie_header
        .split(';')
        .map(str::trim)
        .find_map(|p| p.strip_prefix("nxtgauge_refresh_token="))
        .ok_or_else(|| err(StatusCode::UNAUTHORIZED, "Refresh token missing", "REFRESH_TOKEN_INVALID"))?;

    let mut redis = state.redis.clone();

    let user_id_str = cache::token::get_refresh(&mut redis, token)
        .await
        .map_err(|_| err(StatusCode::UNAUTHORIZED, "Refresh token invalid", "REFRESH_TOKEN_INVALID"))?
        .ok_or_else(|| err(StatusCode::UNAUTHORIZED, "Refresh token expired", "REFRESH_TOKEN_INVALID"))?;

    let user_id = user_id_str
        .parse::<uuid::Uuid>()
        .map_err(|_| err(StatusCode::UNAUTHORIZED, "Refresh token corrupt", "REFRESH_TOKEN_INVALID"))?;

    let user = UserRepository::get_by_id(&state.pool, user_id)
        .await
        .map_err(|_| err(StatusCode::UNAUTHORIZED, "User not found", "INVALID_CREDENTIALS"))?;

    // Rotate: revoke old, issue new
    let _ = cache::token::revoke_refresh(&mut redis, token).await;

    let user_roles = UserRepository::get_user_role_keys(&state.pool, user.id)
        .await
        .unwrap_or_default();

    let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
    let tokens = generate_tokens(
        user.id.to_string(),
        user.email.clone(),
        user_roles.clone(),
        user_roles.first().cloned(),
        &jwt_secret,
    )
    .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "TOKEN_ERROR"))?;

    if let Err(e) = cache::token::store_refresh(&mut redis, &tokens.refresh_token, &user.id.to_string()).await {
        tracing::warn!(
            error = %e,
            user_id = %user.id,
            email = %user.email,
            "Failed to store refresh token in Redis; continuing with access token only"
        );
    }

    let new_cookie = format!(
        "nxtgauge_refresh_token={}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=2592000",
        tokens.refresh_token
    );

    Ok((StatusCode::OK, [(SET_COOKIE, new_cookie)], Json(serde_json::json!({
        "access_token": tokens.access_token,
        "expires_in":   900
    }))))
}

/// GET /api/auth/session
async fn session(
    auth: AuthUser,
    State(state): State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
    let user = UserRepository::get_by_id(&state.pool, auth.user_id)
        .await
        .map_err(|_| err(StatusCode::UNAUTHORIZED, "User not found", "USER_NOT_FOUND"))?;

    let user_roles = UserRepository::get_user_role_keys(&state.pool, user.id)
        .await
        .unwrap_or_default();

    let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
    Ok(Json(SessionUser {
        id:             user.id.to_string(),
        email:          user.email,
        name:      user_name,
        email_verified: user.email_verified,
        active_role:    user_roles.first().cloned(),
        roles:          user_roles,
    }))
}

/// POST /api/auth/verify-email  { "otp": "123456" }
async fn verify_email(
    State(state): State<AppState>,
    Json(payload): Json<VerifyEmailPayload>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
    let mut redis = state.redis.clone();

    // Rate limit OTP verification attempts by email (max 10 per 15 min)
    if let Some(ref email) = payload.email {
        if !cache::rate_limit::check(&mut redis, "otp_verify", email, 10, 900).await.unwrap_or(true) {
            return Err(err(StatusCode::TOO_MANY_REQUESTS, "Too many verification attempts. Try again later.", "RATE_LIMITED"));
        }
    }

    // Atomically consume OTP from Redis (GETDEL — single use, auto-expiry)
    let user_id_str = cache::otp::consume(&mut redis, &payload.otp)
        .await
        .map_err(|_| err(StatusCode::INTERNAL_SERVER_ERROR, "Cache error", "CACHE_ERROR"))?
        .ok_or_else(|| err(StatusCode::UNAUTHORIZED, "Invalid or expired verification code", "INVALID_CODE"))?;

    let user_id = user_id_str
        .parse::<uuid::Uuid>()
        .map_err(|_| err(StatusCode::UNAUTHORIZED, "Invalid verification code", "INVALID_CODE"))?;

    UserRepository::set_email_verified(&state.pool, user_id)
        .await
        .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?;

    // Get user details for welcome email
    if let Ok(user) = UserRepository::get_by_id(&state.pool, user_id).await {
        let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
        if let Err(e) = state.mail.send_welcome_email(&user.email, &user_name).await {
            tracing::error!(
                error = %e,
                email = %user.email,
                endpoint = "/api/auth/verify-email",
                "Failed to send welcome email"
            );
        }
    }

    Ok((StatusCode::OK, Json(serde_json::json!({ "message": "Email verified successfully" }))))
}

/// POST /api/auth/resend-otp
async fn resend_otp(
    State(state): State<AppState>,
    Json(payload): Json<ResendOtpPayload>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
    let silent_ok = (StatusCode::OK, Json(serde_json::json!({
        "message": "If the email is registered, a new code has been sent"
    })));

    let user = match UserRepository::get_by_email(&state.pool, &payload.email.to_lowercase()).await {
        Ok(u)  => u,
        Err(_) => return Ok(silent_ok),
    };
    if user.email_verified {
        return Ok(silent_ok);
    }

    let mut redis = state.redis.clone();

    // Rate limit: max 3 resends per hour per user
    if !cache::otp::resend_allowed(&mut redis, &user.id.to_string()).await.unwrap_or(true) {
        return Err(err(StatusCode::TOO_MANY_REQUESTS, "Too many OTP requests. Try again in an hour.", "RATE_LIMITED"));
    }

    let otp = format!("{:06}", rand::random::<u32>() % 1_000_000);
    tracing::info!(email = %user.email, "OTP generated for resend");
    cache::otp::set(&mut redis, &otp, &user.id.to_string())
        .await
        .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?;
    cache::otp::record_resend(&mut redis, &user.id.to_string()).await.ok();

    let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
    if let Err(e) = state.mail.send_verification_email(&user.email, &user_name, &otp).await {
        tracing::error!(
            error = %e,
            email = %user.email,
            endpoint = "/api/auth/resend-otp",
            "Failed to resend verification email"
        );
        return Err(err(
            StatusCode::INTERNAL_SERVER_ERROR,
            "Failed to resend verification email",
            "SMTP_ERROR",
        ));
    }

    Ok(silent_ok)
}

/// POST /api/auth/forgot-password
async fn forgot_password(
    State(state): State<AppState>,
    Json(payload): Json<ForgotPasswordPayload>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
    let silent_ok = (StatusCode::OK, Json(serde_json::json!({ "message": "Reset code sent if email exists" })));

    let mut redis = state.redis.clone();
    if !cache::rate_limit::check(&mut redis, "forgot_password", &payload.email.to_lowercase(), 3, 900).await.unwrap_or(true) {
        return Ok(silent_ok); // Silently cap to avoid email enumeration
    }

    let user = match UserRepository::get_by_email(&state.pool, &payload.email.to_lowercase()).await {
        Ok(u)  => u,
        Err(_) => return Ok(silent_ok),
    };

    let code = format!("{:06}", rand::random::<u32>() % 1_000_000);
    tracing::info!(email = %user.email, "OTP generated for password reset");

    cache::token::store_reset(&mut redis, &code, &user.id.to_string())
        .await
        .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?;

    let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
    let _ = state.mail.send_password_reset_email(&user.email, &user_name, &code).await;

    Ok(silent_ok)
}

/// POST /api/auth/reset-password
async fn reset_password(
    State(state): State<AppState>,
    Json(payload): Json<ResetPasswordPayload>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
    let mut redis = state.redis.clone();

    // Consume reset code from Redis (single-use GETDEL)
    let user_id_str = cache::token::consume_reset(&mut redis, &payload.code)
        .await
        .map_err(|_| err(StatusCode::INTERNAL_SERVER_ERROR, "Cache error", "CACHE_ERROR"))?
        .ok_or_else(|| err(StatusCode::UNAUTHORIZED, "Invalid or expired reset code", "INVALID_CODE"))?;

    let user_id = user_id_str
        .parse::<uuid::Uuid>()
        .map_err(|_| err(StatusCode::UNAUTHORIZED, "Invalid reset code", "INVALID_CODE"))?;

    if payload.new_password.len() < 8 {
        return Err(err(StatusCode::UNPROCESSABLE_ENTITY, "Password must be at least 8 characters", "VALIDATION_ERROR"));
    }

    let password_hash = hash_password(&payload.new_password)
        .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "INTERNAL_ERROR"))?;

    UserRepository::update_password(&state.pool, user_id, &password_hash)
        .await
        .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?;

        if let Ok(user) = UserRepository::get_by_id(&state.pool, user_id).await {
        let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
        let _ = state.mail.send_password_changed_email(&user.email, &user_name).await;
    }

    Ok((StatusCode::OK, Json(serde_json::json!({ "message": "Password reset successfully" }))))
}

/// POST /api/auth/change-password
async fn change_password(
    auth: AuthUser,
    State(state): State<AppState>,
    Json(payload): Json<ChangePasswordPayload>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
    let user = UserRepository::get_by_id(&state.pool, auth.user_id)
        .await
        .map_err(|_| err(StatusCode::UNAUTHORIZED, "User not found", "USER_NOT_FOUND"))?;

    if !verify_password(&payload.current_password, &user.password_hash)
        .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "AUTH_ERROR"))?
    {
        return Err(err(StatusCode::UNAUTHORIZED, "Incorrect current password", "INVALID_PASSWORD"));
    }

    if payload.new_password.len() < 8 {
        return Err(err(StatusCode::UNPROCESSABLE_ENTITY, "Password must be at least 8 characters", "VALIDATION_ERROR"));
    }

    let password_hash = hash_password(&payload.new_password)
        .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "INTERNAL_ERROR"))?;

    UserRepository::update_password(&state.pool, user.id, &password_hash)
        .await
        .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?;

    let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
    let _ = state.mail.send_password_changed_email(&user.email, &user_name).await;

    Ok((StatusCode::OK, Json(serde_json::json!({ "message": "Password changed successfully" }))))
}

/// POST /api/auth/switch-role
async fn switch_role(
    auth: AuthUser,
    State(state): State<AppState>,
    Json(payload): Json<SwitchRolePayload>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
    let user_roles = UserRepository::get_user_role_keys(&state.pool, auth.user_id)
        .await
        .unwrap_or_default();

    let requested = payload.role_key.to_uppercase();
    if !user_roles.contains(&requested) {
        return Err(err(StatusCode::FORBIDDEN, "You do not have this role", "ROLE_NOT_FOUND"));
    }

    let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
    let tokens = generate_tokens(
        auth.user_id.to_string(),
        auth.email.clone(),
        user_roles,
        Some(requested),
        &jwt_secret,
    )
    .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "TOKEN_ERROR"))?;

    Ok((StatusCode::OK, Json(serde_json::json!({
        "access_token": tokens.access_token,
        "expires_in":   900
    }))))
}

// ── V1 API Router (for backward compatibility) ─────────────────────────

pub fn v1_router() -> Router<AppState> {
    Router::new()
        .route("/sign-up", post(v1_sign_up))
        .route("/verify-otp", post(v1_verify_otp))
        .route("/resend-otp", post(resend_otp))
}

#[derive(Deserialize)]
struct V1VerifyOtpPayload {
    #[serde(alias = "code")]
    otp: String,
}

/// POST /api/v1/users/sign-up
async fn v1_sign_up(
    State(state): State<AppState>,
    Json(payload): Json<RegisterPayload>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
    register(State(state), Json(payload)).await
}

/// POST /api/v1/users/verify-otp
async fn v1_verify_otp(
    State(state): State<AppState>,
    Json(payload): Json<V1VerifyOtpPayload>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
    verify_email(State(state), Json(VerifyEmailPayload { otp: payload.otp, email: None })).await
}
