//! Admin (authenticated) surface: login/logout/session, user listing, and publishing.

use auth::{AuthUser, Authorized, ManageUsers, PublishObjects};
use axum::{
    Json, Router,
    extract::{Path, State},
    http::StatusCode,
    routing::{get, post},
};
use domain::{AuditActor, ObjectId, Visibility};
use serde::{Deserialize, Serialize};
use tower_sessions::Session;
use utoipa::ToSchema;

use crate::{AppState, reindex};

/// Credentials for password login.
#[derive(Deserialize, ToSchema)]
pub(crate) struct LoginRequest {
    pub email: String,
    pub password: String,
}

/// A user as exposed on the admin surface (no password material).
#[derive(Serialize, ToSchema)]
pub(crate) struct UserView {
    pub id: String,
    pub email: String,
    pub role: String,
}

/// Desired visibility for a publish/unpublish request.
#[derive(Deserialize, ToSchema)]
pub(crate) struct VisibilityRequest {
    pub visibility: Visibility,
}

/// Log in with email + password. On success establishes a session (Set-Cookie) and
/// returns 204; on failure 401 with no detail (no user enumeration).
#[utoipa::path(
    post,
    path = "/api/admin/login",
    request_body = LoginRequest,
    responses((status = 204, description = "Logged in"), (status = 401, description = "Invalid credentials"))
)]
pub(crate) async fn login(
    State(state): State<AppState>,
    session: Session,
    Json(req): Json<LoginRequest>,
) -> Result<StatusCode, StatusCode> {
    let normalized = req.email.trim().to_lowercase();

    let credentials = db::users::credentials_by_email(state.db.pool(), &normalized)
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let verified = match &credentials {
        Some((_, hash)) => auth::verify_password(&req.password, hash),
        None => {
            auth::verify_dummy(&req.password);
            false
        }
    };

    if !verified {
        return Err(StatusCode::UNAUTHORIZED);
    }

    let (user, _) = credentials.expect("verified implies Some");

    auth::establish_session(&session, user.id, &user.email, user.role)
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok(StatusCode::NO_CONTENT)
}

/// Log out: clear the session.
#[utoipa::path(post, path = "/api/admin/logout", responses((status = 204, description = "Logged out")))]
pub(crate) async fn logout(session: Session) -> Result<StatusCode, StatusCode> {
    session
        .flush()
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok(StatusCode::NO_CONTENT)
}

/// The current authenticated user.
#[utoipa::path(get, path = "/api/admin/me", responses((status = 200, body = UserView), (status = 401)))]
pub(crate) async fn me(user: AuthUser) -> Json<UserView> {
    Json(UserView {
        id: user.id.to_string(),
        email: user.email.as_str().to_owned(),
        role: user.role.as_str().to_owned(),
    })
}

/// List all users (Admin only).
#[utoipa::path(get, path = "/api/admin/users", responses((status = 200, body = [UserView]), (status = 401), (status = 403)))]
pub(crate) async fn list_users(
    _auth: Authorized<ManageUsers>,
    State(state): State<AppState>,
) -> Result<Json<Vec<UserView>>, StatusCode> {
    let users = db::users::list_users(state.db.pool())
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok(Json(
        users
            .into_iter()
            .map(|u| UserView {
                id: u.id.to_string(),
                email: u.email.as_str().to_owned(),
                role: u.role.as_str().to_owned(),
            })
            .collect(),
    ))
}

/// Change an object's visibility (publish/unpublish). Requires `PublishObjects`.
#[utoipa::path(
    post,
    path = "/api/admin/objects/{id}/visibility",
    params(("id" = String, Path, description = "Object id (UUID)")),
    request_body = VisibilityRequest,
    responses(
        (status = 204, description = "Visibility changed"),
        (status = 401), (status = 403),
        (status = 404, description = "No such object"),
        (status = 409, description = "Illegal visibility transition")
    )
)]
pub(crate) async fn set_visibility(
    _auth: Authorized<PublishObjects>,
    State(state): State<AppState>,
    Path(id): Path<String>,
    Json(req): Json<VisibilityRequest>,
) -> Result<StatusCode, StatusCode> {
    // 404 (not 400) for an unparseable id — same non-leaking convention as the public
    // surface: never reveal whether an id could exist.
    let object_id = id.parse::<ObjectId>().map_err(|_| StatusCode::NOT_FOUND)?;

    let mut tx = state
        .db
        .pool()
        .begin()
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    // TODO(#7): record the per-user actor (AuthUser carries the id) once auth-event
    // auditing lands; System for now.
    let result =
        db::catalog::set_visibility(&mut tx, AuditActor::System, object_id, req.visibility).await;

    match result {
        Ok(()) => {
            tx.commit()
                .await
                .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

            reindex(&state, object_id).await;

            Ok(StatusCode::NO_CONTENT)
        }
        Err(db::catalog::VisibilityError::ObjectNotFound) => Err(StatusCode::NOT_FOUND),
        Err(db::catalog::VisibilityError::Illegal(_)) => Err(StatusCode::CONFLICT),
        Err(db::catalog::VisibilityError::MissingRequiredFields(_)) => {
            Err(StatusCode::UNPROCESSABLE_ENTITY)
        }
        Err(db::catalog::VisibilityError::Db(_)) => Err(StatusCode::INTERNAL_SERVER_ERROR),
    }
}

/// Admin routes, parameterized over [`AppState`].
pub(crate) fn routes() -> Router<AppState> {
    Router::new()
        .route("/api/admin/login", post(login))
        .route("/api/admin/logout", post(logout))
        .route("/api/admin/me", get(me))
        .route("/api/admin/users", get(list_users))
        .route("/api/admin/objects/{id}/visibility", post(set_visibility))
}
