//! Admin router: builds CRUD routes, login/logout, and attaches authentication middleware.
use std::sync::Arc;

use axum::{
    Extension, Router,
    extract::Path,
    http::StatusCode,
    middleware,
    response::{IntoResponse, Redirect, Response},
    routing::{get, post},
};
use serde::Deserialize;

use crate::app::staging::AdminStaging;
use crate::auth::{
    guard::LoginGuard,
    session::{is_admin_authenticated, load_user_middleware, login, logout},
};
use crate::context::template::Request;
use crate::middleware::security::rate_limit_middleware;
use crate::urlpatterns;
use crate::utils::{
    aliases::AppResult,
    trad::{current_lang, t, tf},
};
use crate::{
    admin::{
        PrototypeAdminState,
        config::AdminConfig,
        middleware::admin_required,
        trad::{inject_admin_prefix, insert_admin_messages},
    },
    flash_now,
};

#[derive(Clone)]
pub struct AdminState {
    pub config: Arc<AdminConfig>,
    pub login_guard: Option<Arc<LoginGuard>>,
}

#[derive(Deserialize)]
struct AdminLoginData {
    username: String,
    password: String,
    #[serde(default)]
    csrf_token: String,
}

pub fn build_admin_router(admin_staging: AdminStaging, _db: crate::utils::aliases::ADb) -> Router {
    let prefix = admin_staging
        .config
        .prefix
        .trim_end_matches('/')
        .to_string();
    let config = admin_staging.config;
    let state = admin_staging.state;

    let login_guard = config.login_guard.clone();
    let rate_limiter = config.rate_limiter.clone();

    let admin_state = Arc::new(AdminState {
        config: Arc::new(config.clone()),
        login_guard,
    });

    // Public routes (login only)
    let login_get_route = urlpatterns! {
        &format!("{prefix}/login") => get(admin_login_get), name = "admin_login",
    };

    let login_post_route = Router::new().route(&format!("{prefix}/login"), post(admin_login_post));

    let login_post_route = if let Some(limiter) = rate_limiter {
        login_post_route.layer(middleware::from_fn_with_state(
            limiter,
            rate_limit_middleware,
        ))
    } else {
        login_post_route
    };

    let public_router = login_get_route.merge(login_post_route);

    // Protected routes (dashboard + logout + history)
    let protected_router = urlpatterns! {
        &format!("{prefix}/") => get(admin_dashboard), name = "admin_dashboard",
        &prefix => get(admin_dashboard_redirect), name = "admin_dashboard_redirect",
        &format!("{prefix}/logout") => post(admin_logout), name = "admin_logout",
        &format!("{prefix}/toggle-template") => get(admin_toggle_template), name = "admin_toggle_template",
        &format!("{prefix}/history") => get(admin_history), name = "admin_history",
        &format!("{prefix}/history/timeline") => get(admin_history_timeline), name = "admin_history_timeline",
        &format!("{prefix}/history/batch/{{batch_id}}") => get(admin_history_batch), name = "admin_history_batch",
        &format!("{prefix}/history/{{id}}") => get(admin_history_diff), name = "admin_history_diff",
    };

    // Generated CRUD routes (also protected)
    let generated_router = if let Some(router) = admin_staging.route_admin {
        router
    } else {
        Router::new()
    };

    // Extra routes registered via .extra_routes() — protected by admin middleware
    let generated_router = admin_staging
        .extra_routes
        .into_iter()
        .fold(generated_router, |router, (path, method_router)| {
            router.route(&format!("{}{}", prefix, path), method_router)
        });

    // Assembly: public + (protected + generated with middleware)
    let mut router = public_router
        .merge(
            protected_router
                .merge(generated_router)
                .layer(middleware::from_fn(admin_required)),
        )
        .layer(middleware::from_fn_with_state(_db, load_user_middleware))
        .layer(Extension(admin_state));

    if let Some(state) = state {
        // Replace proto_state config with the one from AdminStaging
        // so that templates configured via .templates() are taken into account.
        let order = config.resource_order.clone();
        let config = Arc::new(config);

        // Unwrap the Arc<PrototypeAdminState> to access fields in ownership.
        // try_unwrap succeeds as it is the sole owner at boot.
        let registry = match Arc::try_unwrap(state) {
            Ok(proto) => match Arc::try_unwrap(proto.registry) {
                Ok(mut reg) => {
                    if !order.is_empty() {
                        reg.reorder(&order);
                    }
                    Arc::new(reg)
                }
                Err(arc) => arc,
            },
            Err(arc) => arc.registry.clone(),
        };

        let merged = Arc::new(PrototypeAdminState { registry, config });
        router = router.layer(Extension(merged));
    }

    router
}

/// Session key for runtime override of the dashboard template.
///
/// A developer can store a Tera template name in this key to temporarily replace
/// the template configured via `.with_dashboard()`.
/// If absent or empty, `resolve()` applies normally.
///
/// ## Example (in a custom handler):
/// ```rust,ignore
/// session.insert(ADMIN_TEMPLATE_SESSION_KEY, "admin/dashboard").await?;
/// ```
pub const ADMIN_TEMPLATE_SESSION_KEY: &str = "admin_template_override";

async fn admin_dashboard_redirect(Extension(admin): Extension<Arc<AdminState>>) -> Response {
    Redirect::permanent(&format!("{}/", admin.config.prefix.trim_end_matches('/'))).into_response()
}

async fn admin_dashboard(
    Extension(admin): Extension<Arc<AdminState>>,
    Extension(current_user): Extension<crate::auth::session::CurrentUser>,
    proto: Option<Extension<Arc<PrototypeAdminState>>>,
    mut req: Request,
) -> AppResult<Response> {
    let db = req.engine.db.clone();

    let mut resource_counts: std::collections::HashMap<String, u64> =
        std::collections::HashMap::new();

    let resources: Vec<&crate::admin::AdminResource> = if let Some(Extension(ref state)) = proto {
        for (key, entry) in &state.registry.resources {
            if let Some(count_fn) = &entry.count_fn
                && let Ok(n) = (count_fn)(db.clone(), None).await
            {
                resource_counts.insert(key.clone(), n);
            }
        }
        state
            .registry
            .all()
            .filter(|e| {
                if current_user.is_superuser {
                    return true;
                }
                current_user.can_access_resource(e.meta.key)
            })
            .map(|e| &e.meta)
            .collect()
    } else {
        Vec::new()
    };

    // Groups with permission on each resource_key
    let resource_groups: std::collections::HashMap<String, Vec<String>> = {
        use crate::admin::permissions::{groupe, groupes_droits};
        use sea_orm::EntityTrait;
        let groupes: std::collections::HashMap<_, String> = groupe::Entity::find()
            .all(&*db)
            .await
            .unwrap_or_default()
            .into_iter()
            .map(|g| (g.id, g.nom))
            .collect();
        let rows = groupes_droits::Entity::find()
            .all(&*db)
            .await
            .unwrap_or_default();
        let mut map: std::collections::HashMap<String, Vec<String>> =
            std::collections::HashMap::new();
        for r in rows {
            let nom = groupes
                .get(&r.groupe_id)
                .cloned()
                .unwrap_or_else(|| r.groupe_id.to_string());
            let entry = map.entry(r.resource_key).or_default();
            if !entry.contains(&nom) {
                entry.push(nom);
            }
        }
        map
    };

    let session_override: Option<String> = req
        .session
        .get(ADMIN_TEMPLATE_SESSION_KEY)
        .await
        .unwrap_or(None);

    insert_admin_messages(&mut req.context, "dashboard");
    insert_admin_messages(&mut req.context, "base");
    inject_admin_prefix(&mut req.context, &admin.config.prefix);
    req = req
        .insert("current_user", &current_user)
        .insert("site_title", &admin.config.site_title)
        .insert("site_url", &admin.config.site_url)
        .insert("resources", &resources)
        .insert("resource_groups", &resource_groups)
        .insert("resource_counts", &resource_counts)
        .insert("current_page", "dashboard")
        .insert("lang", current_lang().code())
        .insert("current_resource", &Option::<String>::None)
        .insert("admin_has_session_override", session_override.is_some());

    let template = session_override
        .as_deref()
        .unwrap_or_else(|| admin.config.templates.dashboard.resolve());

    req.render(template)
}

async fn admin_login_get(
    Extension(admin): Extension<Arc<AdminState>>,
    axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
    mut req: Request,
) -> AppResult<Response> {
    let from_logout = params.get("from").is_some_and(|v| v == "logout");
    if !from_logout && is_admin_authenticated(&req.session).await {
        return Ok(Redirect::to(&format!("{}/", admin.config.prefix)).into_response());
    }

    insert_admin_messages(&mut req.context, "login");
    inject_admin_prefix(&mut req.context, &admin.config.prefix);

    req = req
        .insert("site_title", &admin.config.site_title)
        .insert("site_url", &admin.config.site_url)
        .insert("lang", current_lang().code());
    req.render(admin.config.templates.login.resolve())
}

async fn admin_login_post(
    Extension(admin): Extension<Arc<AdminState>>,
    mut req: Request,
) -> Response {
    use crate::utils::middleware::csrf::unmask_csrf_token;
    use subtle::ConstantTimeEq;
    if is_admin_authenticated(&req.session).await {
        return Redirect::to(&format!("{}/", admin.config.prefix)).into_response();
    }
    let data = AdminLoginData {
        username: req.prisme.data.get("username").cloned().unwrap_or_default(),
        password: req.prisme.data.get("password").cloned().unwrap_or_default(),
        csrf_token: req
            .prisme
            .data
            .get(crate::utils::constante::session_key::session::CSRF_TOKEN_KEY)
            .cloned()
            .unwrap_or_default(),
    };
    let csrf_valid = unmask_csrf_token(&data.csrf_token)
        .map(|unmasked| {
            bool::from(
                unmasked
                    .as_bytes()
                    .ct_eq(req.csrf_token.as_str().as_bytes()),
            )
        })
        .unwrap_or(false);
    if !csrf_valid {
        insert_admin_messages(&mut req.context, "login");
        inject_admin_prefix(&mut req.context, &admin.config.prefix);
        req = req
            .insert("lang", current_lang().code())
            .insert("site_title", &admin.config.site_title)
            .insert("site_url", &admin.config.site_url)
            .insert("error", t("csrf.invalid_or_missing").to_string());
        return req
            .render(admin.config.templates.login.resolve())
            .unwrap_or_else(axum::response::IntoResponse::into_response);
    }

    // Login guard verification (brute-force)
    if let Some(guard) = &admin.login_guard {
        let key = LoginGuard::effective_key(&data.username, "unknown");
        if guard.is_locked(&key) {
            let secs = guard.remaining_lockout_secs(&key).unwrap_or(0);
            insert_admin_messages(&mut req.context, "login");
            inject_admin_prefix(&mut req.context, &admin.config.prefix);
            req = req
                .insert("lang", current_lang().code())
                .insert("site_title", &admin.config.site_title)
                .insert("site_url", &admin.config.site_url)
                .insert("error", tf("admin.login.error_locked", &[secs]));
            return req
                .render(admin.config.templates.login.resolve())
                .unwrap_or_else(axum::response::IntoResponse::into_response);
        }
    }

    let Some(auth) = &admin.config.auth else {
        return (
            StatusCode::NOT_IMPLEMENTED,
            t("admin.access.no_auth_handler").to_string(),
        )
            .into_response();
    };

    let result = auth
        .authenticate(&data.username, &data.password, &req.engine.db)
        .await;

    if let Some(user) = result {
        if let Some(guard) = &admin.login_guard {
            let key = LoginGuard::effective_key(&data.username, "unknown");
            guard.record_success(&key);
        }

        let db_store = req
            .engine
            .session_db_store
            .read()
            .ok()
            .and_then(|g| g.as_ref().cloned());
        let exclusive = req.engine.features.exclusive_login;
        if login(
            &req.session,
            &req.engine.db,
            user.user_id,
            &user.username,
            user.is_staff,
            user.is_superuser,
            db_store.as_deref(),
            exclusive,
        )
        .await
        .is_err()
        {
            insert_admin_messages(&mut req.context, "login");
            insert_admin_messages(&mut req.context, "base");
            inject_admin_prefix(&mut req.context, &admin.config.prefix);
            req = req
                .insert("lang", current_lang().code())
                .insert("site_title", &admin.config.site_title)
                .insert("site_url", &admin.config.site_url)
                .insert("error", t("admin.login.error_session").to_string());
            return req
                .render(admin.config.templates.login.resolve())
                .unwrap_or_else(axum::response::IntoResponse::into_response);
        }

        Redirect::to(&format!("{}/", admin.config.prefix)).into_response()
    } else {
        if let Some(guard) = &admin.login_guard {
            let key = LoginGuard::effective_key(&data.username, "unknown");
            guard.record_failure(&key);
        }

        insert_admin_messages(&mut req.context, "login");
        insert_admin_messages(&mut req.context, "base");
        inject_admin_prefix(&mut req.context, &admin.config.prefix);
        req = req
            .insert("lang", current_lang().code())
            .insert("site_title", &admin.config.site_title)
            .insert("site_url", &admin.config.site_url)
            .insert("error", t("admin.login.error_credentials").to_string());
        req.render(admin.config.templates.login.resolve())
            .unwrap_or_else(axum::response::IntoResponse::into_response)
    }
}

async fn admin_history(
    Extension(admin): Extension<Arc<AdminState>>,
    Extension(current_user): Extension<crate::auth::session::CurrentUser>,
    proto: Option<Extension<Arc<PrototypeAdminState>>>,
    axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
    mut req: Request,
) -> AppResult<Response> {
    use crate::admin::history;
    use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder};

    if !current_user.is_staff && !current_user.is_superuser {
        req.notices
            .error(t("admin.access.insufficient_rights").to_string())
            .await;
        return Ok(
            axum::response::Redirect::to(&format!("{}/", admin.config.prefix)).into_response(),
        );
    }

    let page = params
        .get("page")
        .and_then(|p| p.parse::<u64>().ok())
        .unwrap_or(1)
        .max(1);
    let per_page = admin.config.page_size;

    let filter_resource: Option<String> = params.get("resource").filter(|v| !v.is_empty()).cloned();
    let filter_action: Option<String> = params.get("action").filter(|v| !v.is_empty()).cloned();
    let filter_user: Option<String> = params.get("user").filter(|v| !v.is_empty()).cloned();

    let mut query = history::Entity::find().order_by_desc(history::Column::CreatedAt);
    if let Some(ref res) = filter_resource {
        query = query.filter(history::Column::ResourceKey.eq(res.clone()));
    }
    if let Some(ref action) = filter_action {
        query = query.filter(history::Column::Action.eq(action.clone()));
    }
    if let Some(ref user) = filter_user {
        query = query.filter(history::Column::Username.eq(user.clone()));
    }

    let paginator = query.paginate(req.engine.db.as_ref(), per_page);
    let total_pages = paginator.num_pages().await.unwrap_or(1);
    let raw_entries: Vec<history::Model> = paginator.fetch_page(page - 1).await.unwrap_or_default();

    #[derive(serde::Serialize)]
    struct HistoryRow {
        id: i64,
        resource_key: String,
        object_pk: String,
        action: String,
        username: String,
        created_at: chrono::NaiveDateTime,
        summary: Option<String>,
        batch_id: Option<String>,
        batch_count: usize,
    }

    let mut entries: Vec<HistoryRow> = Vec::new();
    let mut seen_batches: std::collections::HashSet<String> = std::collections::HashSet::new();
    for e in raw_entries {
        if let Some(ref bid) = e.batch_id {
            if seen_batches.contains(bid) {
                if let Some(row) = entries
                    .iter_mut()
                    .find(|r| r.batch_id.as_deref() == Some(bid))
                {
                    row.batch_count += 1;
                }
                continue;
            }
            seen_batches.insert(bid.clone());
        }
        entries.push(HistoryRow {
            id: e.id,
            resource_key: e.resource_key,
            object_pk: e.object_pk,
            action: e.action,
            username: e.username,
            created_at: e.created_at,
            summary: e.summary,
            batch_id: e.batch_id,
            batch_count: 1,
        });
    }

    let resources: Vec<&crate::admin::AdminResource> = if let Some(Extension(ref state)) = proto {
        state
            .registry
            .all()
            .filter(|e| current_user.is_superuser || current_user.can_access_resource(e.meta.key))
            .map(|e| &e.meta)
            .collect()
    } else {
        Vec::new()
    };

    insert_admin_messages(&mut req.context, "base");
    inject_admin_prefix(&mut req.context, &admin.config.prefix);
    req = req
        .insert("entries", &entries)
        .insert("page", page)
        .insert("total_pages", total_pages)
        .insert("filter_resource", &filter_resource)
        .insert("filter_action", &filter_action)
        .insert("filter_user", &filter_user)
        .insert("current_page", "history")
        .insert("current_resource", &Option::<String>::None)
        .insert("resources", &resources)
        .insert("current_user", &current_user)
        .insert("site_title", &admin.config.site_title)
        .insert("site_url", &admin.config.site_url)
        .insert("lang", current_lang().code());

    req.render("admin/history")
}

async fn admin_history_diff(
    Path(entry_id): Path<i64>,
    Extension(admin): Extension<Arc<AdminState>>,
    Extension(current_user): Extension<crate::auth::session::CurrentUser>,
    proto: Option<Extension<Arc<PrototypeAdminState>>>,
    mut req: Request,
) -> AppResult<Response> {
    use crate::admin::history;
    use crate::context::template::AppError;
    use crate::errors::error::ErrorContext;
    use sea_orm::EntityTrait;

    if !current_user.is_staff && !current_user.is_superuser {
        req.notices
            .error(t("admin.access.insufficient_rights").to_string())
            .await;
        return Ok(
            axum::response::Redirect::to(&format!("{}/", admin.config.prefix)).into_response(),
        );
    }

    let entry = history::Entity::find_by_id(entry_id)
        .one(req.engine.db.as_ref())
        .await
        .map_err(|e| Box::new(AppError::new(ErrorContext::database(e))))?
        .ok_or_else(|| Box::new(AppError::new(ErrorContext::not_found("Entry not found"))))?;

    #[derive(serde::Serialize)]
    struct DiffField {
        key: String,
        old_val: String,
        new_val: String,
    }

    let diff_fields: Vec<DiffField> = entry
        .summary
        .as_deref()
        .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
        .and_then(|v| v.as_object().cloned())
        .map(|map| {
            let mut fields: Vec<DiffField> = map
                .into_iter()
                .filter_map(|(k, v)| {
                    let obj = v.as_object()?;
                    let str_val = |v: &serde_json::Value| match v {
                        serde_json::Value::String(s) => s.clone(),
                        other => other.to_string(),
                    };
                    Some(DiffField {
                        key: k,
                        old_val: obj.get("old").map(str_val).unwrap_or_default(),
                        new_val: obj.get("new").map(str_val).unwrap_or_default(),
                    })
                })
                .collect();
            fields.sort_by(|a, b| a.key.cmp(&b.key));
            fields
        })
        .unwrap_or_default();

    let resources: Vec<&crate::admin::AdminResource> = if let Some(Extension(ref state)) = proto {
        state
            .registry
            .all()
            .filter(|e| current_user.is_superuser || current_user.can_access_resource(e.meta.key))
            .map(|e| &e.meta)
            .collect()
    } else {
        Vec::new()
    };

    insert_admin_messages(&mut req.context, "base");
    inject_admin_prefix(&mut req.context, &admin.config.prefix);
    req = req
        .insert("entry", &entry)
        .insert("diff_fields", &diff_fields)
        .insert("current_page", "history")
        .insert("current_resource", &Option::<String>::None)
        .insert("resources", &resources)
        .insert("current_user", &current_user)
        .insert("site_title", &admin.config.site_title)
        .insert("site_url", &admin.config.site_url)
        .insert("lang", current_lang().code());

    req.render("admin/history_diff")
}

async fn admin_history_timeline(
    Extension(admin): Extension<Arc<AdminState>>,
    Extension(current_user): Extension<crate::auth::session::CurrentUser>,
    proto: Option<Extension<Arc<PrototypeAdminState>>>,
    axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
    mut req: Request,
) -> AppResult<Response> {
    use crate::admin::history;
    use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder};

    if !current_user.is_staff && !current_user.is_superuser {
        req.notices
            .error(t("admin.access.insufficient_rights").to_string())
            .await;
        return Ok(
            axum::response::Redirect::to(&format!("{}/", admin.config.prefix)).into_response(),
        );
    }

    let page = params
        .get("page")
        .and_then(|p| p.parse::<u64>().ok())
        .unwrap_or(1)
        .max(1);
    let per_page = admin.config.page_size;

    let filter_user_id: Option<crate::utils::Pk> =
        params.get("user_id").and_then(|v| v.parse().ok());
    let filter_resource: Option<String> = params.get("resource").filter(|v| !v.is_empty()).cloned();
    let filter_object_id: Option<String> =
        params.get("object_id").filter(|v| !v.is_empty()).cloned();

    let mut query = history::Entity::find().order_by_desc(history::Column::CreatedAt);

    if let Some(uid) = filter_user_id {
        query = query.filter(history::Column::UserId.eq(uid));
    }
    if let Some(ref res) = filter_resource {
        query = query.filter(history::Column::ResourceKey.eq(res.clone()));
    }
    if let Some(ref oid) = filter_object_id {
        query = query.filter(history::Column::ObjectPk.eq(oid.clone()));
    }

    let paginator = query.paginate(req.engine.db.as_ref(), per_page);
    let total_pages = paginator.num_pages().await.unwrap_or(1);
    let total = paginator.num_items().await.unwrap_or(0);
    let entries: Vec<history::Model> = paginator.fetch_page(page - 1).await.unwrap_or_default();

    let filter_type = if filter_object_id.is_some() && filter_resource.is_some() {
        "object"
    } else if filter_user_id.is_some() {
        "user"
    } else if filter_resource.is_some() {
        "resource"
    } else {
        "all"
    };

    let resources: Vec<&crate::admin::AdminResource> = if let Some(Extension(ref state)) = proto {
        state
            .registry
            .all()
            .filter(|e| current_user.is_superuser || current_user.can_access_resource(e.meta.key))
            .map(|e| &e.meta)
            .collect()
    } else {
        Vec::new()
    };

    insert_admin_messages(&mut req.context, "base");
    inject_admin_prefix(&mut req.context, &admin.config.prefix);
    req = req
        .insert("entries", &entries)
        .insert("page", page)
        .insert("total_pages", total_pages)
        .insert("total", total)
        .insert("filter_type", filter_type)
        .insert("filter_user_id", filter_user_id)
        .insert("filter_resource", &filter_resource)
        .insert("filter_object_id", &filter_object_id)
        .insert("current_page", "history")
        .insert("current_resource", &Option::<String>::None)
        .insert("resources", &resources)
        .insert("current_user", &current_user)
        .insert("site_title", &admin.config.site_title)
        .insert("site_url", &admin.config.site_url)
        .insert("lang", current_lang().code());

    req.render("admin/history_timeline")
}

async fn admin_history_batch(
    Path(batch_id): Path<String>,
    Extension(admin): Extension<Arc<AdminState>>,
    Extension(current_user): Extension<crate::auth::session::CurrentUser>,
    proto: Option<Extension<Arc<PrototypeAdminState>>>,
    mut req: Request,
) -> AppResult<Response> {
    use crate::admin::history;
    use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};

    if !current_user.is_staff && !current_user.is_superuser {
        req.notices
            .error(t("admin.access.insufficient_rights").to_string())
            .await;
        return Ok(
            axum::response::Redirect::to(&format!("{}/", admin.config.prefix)).into_response(),
        );
    }

    let entries: Vec<history::Model> = history::Entity::find()
        .filter(history::Column::BatchId.eq(batch_id.clone()))
        .order_by_asc(history::Column::Id)
        .all(req.engine.db.as_ref())
        .await
        .unwrap_or_default();

    #[derive(serde::Serialize)]
    struct DiffField {
        key: String,
        old_val: String,
        new_val: String,
    }

    #[derive(serde::Serialize)]
    struct BatchEntry {
        id: i64,
        object_pk: String,
        display_name: Option<String>,
        action: String,
        summary: Option<String>,
        diff_fields: Vec<DiffField>,
    }

    fn parse_diff(summary: Option<&str>) -> Vec<DiffField> {
        summary
            .and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
            .and_then(|v| v.as_object().cloned())
            .map(|map| {
                let mut fields: Vec<DiffField> = map
                    .into_iter()
                    .filter_map(|(k, v)| {
                        let obj = v.as_object()?;
                        let str_val = |v: &serde_json::Value| match v {
                            serde_json::Value::String(s) => s.clone(),
                            other => other.to_string(),
                        };
                        Some(DiffField {
                            key: k,
                            old_val: obj.get("old").map(str_val).unwrap_or_default(),
                            new_val: obj.get("new").map(str_val).unwrap_or_default(),
                        })
                    })
                    .collect();
                fields.sort_by(|a, b| a.key.cmp(&b.key));
                fields
            })
            .unwrap_or_default()
    }

    fn extract_display_name(val: &serde_json::Value) -> Option<String> {
        let obj = val.as_object()?;
        for key in &["username", "name", "title", "email", "label", "slug"] {
            if let Some(serde_json::Value::String(s)) = obj.get(*key)
                && !s.is_empty()
            {
                return Some(s.clone());
            }
        }
        None
    }

    let resource_key = entries
        .first()
        .map(|e| e.resource_key.clone())
        .unwrap_or_default();
    let username = entries
        .first()
        .map(|e| e.username.clone())
        .unwrap_or_default();
    let created_at = entries.first().map(|e| e.created_at);
    let action = entries
        .first()
        .map(|e| e.action.clone())
        .unwrap_or_default();

    let get_fn = proto
        .as_ref()
        .and_then(|Extension(state)| state.registry.get(&resource_key))
        .and_then(|entry| entry.get_fn.clone());

    let mut batch_entries: Vec<BatchEntry> = Vec::new();
    for e in entries {
        let display_name = if let Some(ref f) = get_fn {
            f(req.engine.db.clone(), e.object_pk.clone())
                .await
                .ok()
                .flatten()
                .as_ref()
                .and_then(extract_display_name)
        } else {
            None
        };
        let diff = parse_diff(e.summary.as_deref());
        batch_entries.push(BatchEntry {
            id: e.id,
            object_pk: e.object_pk,
            display_name,
            action: e.action,
            summary: e.summary,
            diff_fields: diff,
        });
    }

    let resources: Vec<&crate::admin::AdminResource> = if let Some(Extension(ref state)) = proto {
        state
            .registry
            .all()
            .filter(|e| current_user.is_superuser || current_user.can_access_resource(e.meta.key))
            .map(|e| &e.meta)
            .collect()
    } else {
        Vec::new()
    };

    insert_admin_messages(&mut req.context, "base");
    inject_admin_prefix(&mut req.context, &admin.config.prefix);
    req = req
        .insert("batch_id", &batch_id)
        .insert("batch_entries", &batch_entries)
        .insert("resource_key", &resource_key)
        .insert("username", &username)
        .insert("created_at", created_at)
        .insert("action", &action)
        .insert("current_page", "history")
        .insert("current_resource", &Option::<String>::None)
        .insert("resources", &resources)
        .insert("current_user", &current_user)
        .insert("site_title", &admin.config.site_title)
        .insert("site_url", &admin.config.site_url)
        .insert("lang", current_lang().code());

    req.render("admin/history_batch")
}

async fn admin_toggle_template(
    Extension(admin): Extension<Arc<AdminState>>,
    req: Request,
) -> Response {
    let current: Option<String> = req
        .session
        .get::<String>(ADMIN_TEMPLATE_SESSION_KEY)
        .await
        .unwrap_or(None);
    if current.is_some() {
        let _ = req
            .session
            .remove::<String>(ADMIN_TEMPLATE_SESSION_KEY)
            .await;
    } else {
        let _ = req
            .session
            .insert(ADMIN_TEMPLATE_SESSION_KEY, "admin/dashboard")
            .await;
    }
    Redirect::to(&format!("{}/", admin.config.prefix)).into_response()
}

async fn admin_logout(Extension(admin): Extension<Arc<AdminState>>, req: Request) -> Response {
    let session = &req.session;
    let db_store = req
        .engine
        .session_db_store
        .read()
        .ok()
        .and_then(|g| g.as_ref().cloned());
    let _ = logout(session, db_store.as_deref()).await;
    let login_url = format!("{}/login?from=logout", admin.config.prefix);
    flash_now!(info => t("admin.logout_success").as_ref());
    Redirect::to(&login_url).into_response()
}
