//! `/api/v1/admin/apps/{id_or_slug}/users/*` + `/invitations/*` —
//! admin HTTP surface for v1.1.8 user management.
//!
//! All endpoints are capability-gated against the resolved
//! `app_id` (extracted from the path then validated via the apps
//! repo — never trusted at face value). Read vs Write vs Admin
//! mirrors the SDK's gating from the brief.
//!
//! No DTO ever returns a password hash, a session token, or a long-
//! lived reset token. The single exception is
//! `POST /users/{user_id}/reset-password` which returns the one-shot
//! reset token in its body so an admin can paste it into a manual
//! reset link (TTL 1h by default).

use std::sync::Arc;

use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response};
use axum::routing::{delete, get, post};
use axum::{Extension, Router};
use picloud_shared::{
    AppId, AppUser, AppUserId, CreateUserInput, EmailTemplateOpts, Invitation, InvitationId,
    InviteOpts, Principal, UpdateUserInput, UsersError, UsersListOpts, UsersService,
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use uuid::Uuid;

use crate::app_repo::AppRepository;
use crate::authz::{self, AuthzDenied, AuthzRepo, Capability};
use crate::repo::ScriptRepositoryError;

#[derive(Clone)]
pub struct AppUsersState {
    pub apps: Arc<dyn AppRepository>,
    pub authz: Arc<dyn AuthzRepo>,
    pub users: Arc<dyn UsersService>,
}

pub fn app_users_router(state: AppUsersState) -> Router {
    Router::new()
        .route(
            "/apps/{id_or_slug}/users",
            get(list_users).post(create_user),
        )
        .route(
            "/apps/{id_or_slug}/users/{user_id}",
            get(get_user).patch(patch_user).delete(delete_user),
        )
        .route(
            "/apps/{id_or_slug}/users/{user_id}/reset-password",
            post(reset_password),
        )
        .route(
            "/apps/{id_or_slug}/users/{user_id}/revoke-sessions",
            post(revoke_sessions),
        )
        .route(
            "/apps/{id_or_slug}/invitations",
            get(list_invitations_handler).post(create_invitation_handler),
        )
        .route(
            "/apps/{id_or_slug}/invitations/{invite_id}",
            delete(revoke_invitation_handler),
        )
        .with_state(state)
}

// ----------------------------------------------------------------------------
// DTOs
// ----------------------------------------------------------------------------

#[derive(Debug, Serialize)]
pub struct ListUsersResponse {
    pub users: Vec<AppUser>,
    /// F-P-012: opaque `<rfc3339>_<uuid>` cursor with id tiebreaker.
    pub next_cursor: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct ListUsersQuery {
    /// F-P-012: opaque `<rfc3339>_<uuid>` cursor with id tiebreaker.
    pub cursor: Option<String>,
    pub limit: Option<i64>,
}

#[derive(Debug, Deserialize)]
pub struct CreateUserRequest {
    pub email: String,
    pub password: String,
    pub display_name: Option<String>,
}

#[derive(Debug, Deserialize, Default)]
pub struct PatchUserRequest {
    /// `Some(Some(s))` to set, `Some(None)` to clear, absent to leave
    /// alone. Standard `serde` triple-state.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub display_name: Option<Option<String>>,
}

#[derive(Debug, Serialize)]
pub struct ResetPasswordResponse {
    /// The one-shot raw token. Returned exactly once — the admin
    /// pastes it into a manual reset link. Never logged server-side.
    pub token: String,
    pub expires_in_seconds: i64,
}

#[derive(Debug, Serialize)]
pub struct RevokeSessionsResponse {
    pub revoked: u64,
}

#[derive(Debug, Serialize)]
pub struct ListInvitationsResponse {
    pub invitations: Vec<Invitation>,
}

#[derive(Debug, Deserialize)]
pub struct CreateInvitationRequest {
    pub email: String,
    pub display_name: Option<String>,
    #[serde(default)]
    pub roles: Vec<String>,
    /// Optional email template — when present, the admin send goes
    /// out under the admin's own AppEmailSend gate. Omit to issue an
    /// invitation without sending an email (out-of-band delivery).
    pub template: Option<EmailTemplateRequest>,
}

#[derive(Debug, Deserialize)]
pub struct EmailTemplateRequest {
    pub link_base: String,
    pub from: String,
    pub subject: String,
    pub body_template: String,
}

impl From<EmailTemplateRequest> for EmailTemplateOpts {
    fn from(r: EmailTemplateRequest) -> Self {
        Self {
            link_base: r.link_base,
            from: r.from,
            subject: r.subject,
            body_template: r.body_template,
        }
    }
}

// ----------------------------------------------------------------------------
// Handlers
// ----------------------------------------------------------------------------

async fn list_users(
    State(s): State<AppUsersState>,
    Extension(principal): Extension<Principal>,
    Path(id_or_slug): Path<String>,
    Query(q): Query<ListUsersQuery>,
) -> Result<Json<ListUsersResponse>, AppUsersApiError> {
    let app = resolve_app(&*s.apps, &id_or_slug).await?;
    // The capability check happens inside the service via cx-style
    // require(); here we synthesize the admin-mediated call via the
    // service's list path. To use the existing service.list, we need
    // an SdkCallCx — but for admin routes we'd rather not synthesize
    // one. So we go through a small adapter: call into a list helper
    // that does its own require, mirroring how admin_create_invitation
    // works. For commit 10 the simplest path is a service-level
    // admin_list method; until that exists we synthesize a cx.
    //
    // Decision: do the synthesis here (one place, well-marked) rather
    // than fan a second list method out to the trait. The cx carries
    // the admin's principal so the service's authz fires exactly as
    // the brief specifies.
    let cx = synth_cx(app.id, &principal);
    let page = s
        .users
        .list(
            &cx,
            UsersListOpts {
                cursor: q.cursor,
                limit: q.limit,
            },
        )
        .await?;
    Ok(Json(ListUsersResponse {
        users: page.items,
        next_cursor: page.next_cursor,
    }))
}

async fn get_user(
    State(s): State<AppUsersState>,
    Extension(principal): Extension<Principal>,
    Path((id_or_slug, user_id)): Path<(String, Uuid)>,
) -> Result<Json<AppUser>, AppUsersApiError> {
    let app = resolve_app(&*s.apps, &id_or_slug).await?;
    let cx = synth_cx(app.id, &principal);
    let user = s
        .users
        .get(&cx, AppUserId::from(user_id))
        .await?
        .ok_or(AppUsersApiError::UserNotFound)?;
    Ok(Json(user))
}

async fn create_user(
    State(s): State<AppUsersState>,
    Extension(principal): Extension<Principal>,
    Path(id_or_slug): Path<String>,
    Json(input): Json<CreateUserRequest>,
) -> Result<(StatusCode, Json<AppUser>), AppUsersApiError> {
    let app = resolve_app(&*s.apps, &id_or_slug).await?;
    let cx = synth_cx(app.id, &principal);
    let user = s
        .users
        .create(
            &cx,
            CreateUserInput {
                email: input.email,
                password: input.password,
                display_name: input.display_name,
            },
        )
        .await?;
    Ok((StatusCode::CREATED, Json(user)))
}

async fn patch_user(
    State(s): State<AppUsersState>,
    Extension(principal): Extension<Principal>,
    Path((id_or_slug, user_id)): Path<(String, Uuid)>,
    Json(input): Json<PatchUserRequest>,
) -> Result<Json<AppUser>, AppUsersApiError> {
    let app = resolve_app(&*s.apps, &id_or_slug).await?;
    let cx = synth_cx(app.id, &principal);
    let user = s
        .users
        .update(
            &cx,
            AppUserId::from(user_id),
            UpdateUserInput {
                display_name: input.display_name,
            },
        )
        .await?;
    Ok(Json(user))
}

async fn delete_user(
    State(s): State<AppUsersState>,
    Extension(principal): Extension<Principal>,
    Path((id_or_slug, user_id)): Path<(String, Uuid)>,
) -> Result<StatusCode, AppUsersApiError> {
    let app = resolve_app(&*s.apps, &id_or_slug).await?;
    // F-S-005: gate on the stronger AppUsersAdmin per the original brief
    // — the dashboard delete is irreversible. The service-layer
    // `delete` only checks AppUsersWrite; without this explicit gate,
    // any editor could delete app users via the admin HTTP API.
    authz::require(
        s.authz.as_ref(),
        &principal,
        Capability::AppUsersAdmin(app.id),
    )
    .await?;
    let cx = synth_cx(app.id, &principal);
    let removed = s.users.delete(&cx, AppUserId::from(user_id)).await?;
    if removed {
        Ok(StatusCode::NO_CONTENT)
    } else {
        Err(AppUsersApiError::UserNotFound)
    }
}

async fn reset_password(
    State(s): State<AppUsersState>,
    Extension(principal): Extension<Principal>,
    Path((id_or_slug, user_id)): Path<(String, Uuid)>,
) -> Result<Json<ResetPasswordResponse>, AppUsersApiError> {
    let app = resolve_app(&*s.apps, &id_or_slug).await?;
    let token = s
        .users
        .admin_reset_password_token(&principal, app.id, AppUserId::from(user_id))
        .await?;
    Ok(Json(ResetPasswordResponse {
        token,
        // 1h default; if the operator overrode the env var the
        // returned value is the same the migrations enforce.
        expires_in_seconds: 3600,
    }))
}

async fn revoke_sessions(
    State(s): State<AppUsersState>,
    Extension(principal): Extension<Principal>,
    Path((id_or_slug, user_id)): Path<(String, Uuid)>,
) -> Result<Json<RevokeSessionsResponse>, AppUsersApiError> {
    let app = resolve_app(&*s.apps, &id_or_slug).await?;
    let revoked = s
        .users
        .admin_revoke_all_sessions(&principal, app.id, AppUserId::from(user_id))
        .await?;
    Ok(Json(RevokeSessionsResponse { revoked }))
}

async fn list_invitations_handler(
    State(s): State<AppUsersState>,
    Extension(principal): Extension<Principal>,
    Path(id_or_slug): Path<String>,
) -> Result<Json<ListInvitationsResponse>, AppUsersApiError> {
    let app = resolve_app(&*s.apps, &id_or_slug).await?;
    let invitations = s.users.list_invitations(&principal, app.id).await?;
    Ok(Json(ListInvitationsResponse { invitations }))
}

async fn create_invitation_handler(
    State(s): State<AppUsersState>,
    Extension(principal): Extension<Principal>,
    Path(id_or_slug): Path<String>,
    Json(input): Json<CreateInvitationRequest>,
) -> Result<(StatusCode, Json<Invitation>), AppUsersApiError> {
    let app = resolve_app(&*s.apps, &id_or_slug).await?;
    let opts = InviteOpts {
        template: input.template.map(Into::into),
        display_name: input.display_name,
        roles: input.roles,
    };
    let invitation = s
        .users
        .admin_create_invitation(&principal, app.id, &input.email, opts)
        .await?;
    Ok((StatusCode::CREATED, Json(invitation)))
}

async fn revoke_invitation_handler(
    State(s): State<AppUsersState>,
    Extension(principal): Extension<Principal>,
    Path((id_or_slug, invite_id)): Path<(String, Uuid)>,
) -> Result<StatusCode, AppUsersApiError> {
    let app = resolve_app(&*s.apps, &id_or_slug).await?;
    let removed = s
        .users
        .revoke_invitation(&principal, app.id, InvitationId::from(invite_id))
        .await?;
    if removed {
        Ok(StatusCode::NO_CONTENT)
    } else {
        Err(AppUsersApiError::InvitationNotFound)
    }
}

// ----------------------------------------------------------------------------
// Helpers
// ----------------------------------------------------------------------------

/// Synthesize an `SdkCallCx` for an admin HTTP request. The CRUD
/// users::* SDK methods take an `SdkCallCx`; here the dashboard
/// admin is the caller. We synthesize a cx carrying the admin's
/// principal so the service's existing capability checks fire
/// uniformly across SDK and admin code paths.
fn synth_cx(app_id: AppId, principal: &Principal) -> picloud_shared::SdkCallCx {
    picloud_shared::SdkCallCx {
        app_id,
        principal: Some(principal.clone()),
        script_id: picloud_shared::ScriptId::new(),
        execution_id: picloud_shared::ExecutionId::new(),
        request_id: picloud_shared::RequestId::new(),
        trigger_depth: 0,
        root_execution_id: picloud_shared::ExecutionId::new(),
        is_dead_letter_handler: false,
        event: None,
    }
}

async fn resolve_app(
    apps: &dyn AppRepository,
    ident: &str,
) -> Result<picloud_shared::App, AppUsersApiError> {
    crate::app_repo::resolve_app(apps, ident)
        .await?
        .map(|l| l.app)
        .ok_or_else(|| AppUsersApiError::AppNotFound(ident.to_string()))
}

// ----------------------------------------------------------------------------
// Errors
// ----------------------------------------------------------------------------

#[derive(Debug, thiserror::Error)]
pub enum AppUsersApiError {
    #[error("app not found: {0}")]
    AppNotFound(String),

    #[error("user not found")]
    UserNotFound,

    #[error("invitation not found")]
    InvitationNotFound,

    #[error("forbidden")]
    Forbidden,

    #[error("{0}")]
    Invalid(String),

    #[error("email already in use")]
    DuplicateEmail,

    #[error("email is not configured")]
    EmailNotConfigured,

    #[error("email send rate-limited")]
    EmailRateLimited,

    #[error("backend error: {0}")]
    Backend(String),

    #[error("repository error: {0}")]
    Apps(#[from] ScriptRepositoryError),
}

impl From<UsersError> for AppUsersApiError {
    fn from(e: UsersError) -> Self {
        match e {
            UsersError::Forbidden => Self::Forbidden,
            UsersError::NotFound => Self::UserNotFound,
            UsersError::DuplicateEmail => Self::DuplicateEmail,
            UsersError::Invalid(msg) => Self::Invalid(msg),
            UsersError::EmailNotConfigured => Self::EmailNotConfigured,
            UsersError::EmailRateLimited => Self::EmailRateLimited,
            UsersError::EmailTransport(msg) | UsersError::Backend(msg) => Self::Backend(msg),
        }
    }
}

impl From<AuthzDenied> for AppUsersApiError {
    fn from(d: AuthzDenied) -> Self {
        match d {
            AuthzDenied::Denied => Self::Forbidden,
            AuthzDenied::Repo(e) => Self::Backend(e.to_string()),
        }
    }
}

impl IntoResponse for AppUsersApiError {
    fn into_response(self) -> Response {
        let (status, body) = match &self {
            Self::AppNotFound(_)
            | Self::UserNotFound
            | Self::InvitationNotFound
            | Self::Apps(ScriptRepositoryError::NotFound(_)) => {
                (StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
            }
            Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
            Self::Invalid(_) => (
                StatusCode::UNPROCESSABLE_ENTITY,
                json!({ "error": self.to_string() }),
            ),
            Self::DuplicateEmail | Self::Apps(ScriptRepositoryError::Conflict(_)) => {
                (StatusCode::CONFLICT, json!({ "error": self.to_string() }))
            }
            Self::EmailNotConfigured => (
                StatusCode::SERVICE_UNAVAILABLE,
                json!({ "error": self.to_string() }),
            ),
            Self::EmailRateLimited => (
                StatusCode::TOO_MANY_REQUESTS,
                json!({ "error": self.to_string() }),
            ),
            Self::Backend(e) => {
                tracing::error!(error = %e, "app users admin backend error");
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    json!({ "error": "internal error" }),
                )
            }
            Self::Apps(ScriptRepositoryError::Db(e)) => {
                tracing::error!(error = %e, "app users admin apps repo error");
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    json!({ "error": "internal error" }),
                )
            }
        };
        (status, Json(body)).into_response()
    }
}
