//! `/api/v1/admin/apps/{id_or_slug}/members/*` — CRUD over the
//! `app_members` table (blueprint §11.6).
//!
//! Every endpoint is gated on `Capability::AppAdmin(app_id)` after
//! resolving the app from `id_or_slug`. Editors and viewers receive
//! 403 from list and never see the dashboard's Members tab.
//!
//! POST is **non-idempotent on purpose**: a duplicate `(app_id,
//! user_id)` returns 409 rather than upsert-200, so the UI can show
//! "already a member — promote / demote them instead" cleanly. Role
//! changes go through PATCH.
//!
//! No last-app-admin guard: owners always implicitly satisfy
//! `Capability::AppAdmin(_)` (authz::role_grants), so removing the
//! final explicit `app_admin` membership cannot orphan an app.

use std::sync::Arc;

use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response};
use axum::routing::{get, patch};
use axum::{Extension, Router};
use chrono::{DateTime, Utc};
use picloud_shared::{AdminUserId, AppRole, InstanceRole, Principal};
use serde::{Deserialize, Serialize};
use serde_json::json;
use uuid::Uuid;

use crate::admin_user_repo::{AdminUserRepository, AdminUserRepositoryError, AdminUserRow};
use crate::app_members_repo::{
    AppMembersRepository, AppMembersRepositoryError, AppMembershipDetail, AppMembershipRow,
};
use crate::app_repo::AppRepository;
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
use crate::repo::ScriptRepositoryError;

#[derive(Clone)]
pub struct AppMembersState {
    pub apps: Arc<dyn AppRepository>,
    pub users: Arc<dyn AdminUserRepository>,
    pub members: Arc<dyn AppMembersRepository>,
    pub authz: Arc<dyn AuthzRepo>,
}

pub fn app_members_router(state: AppMembersState) -> Router {
    Router::new()
        .route(
            "/apps/{id_or_slug}/members",
            get(list_members).post(grant_member),
        )
        .route(
            "/apps/{id_or_slug}/members/{user_id}",
            patch(patch_member).delete(remove_member),
        )
        .with_state(state)
}

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

#[derive(Debug, Serialize)]
pub struct AppMemberDto {
    pub user_id: AdminUserId,
    pub username: String,
    pub email: Option<String>,
    pub instance_role: InstanceRole,
    pub is_active: bool,
    pub role: AppRole,
    pub created_at: DateTime<Utc>,
}

impl From<AppMembershipDetail> for AppMemberDto {
    fn from(d: AppMembershipDetail) -> Self {
        Self {
            user_id: d.user_id,
            username: d.username,
            email: d.email,
            instance_role: d.instance_role,
            is_active: d.is_active,
            role: d.role,
            created_at: d.created_at,
        }
    }
}

/// Compose a DTO from an `AdminUserRow` (fetched for validation) and
/// the `AppMembershipRow` returned by `upsert`. Saves a re-fetch on
/// POST/PATCH at the cost of trusting the two inputs reference the
/// same user_id — caller's responsibility.
fn compose_dto(user: AdminUserRow, membership: AppMembershipRow) -> AppMemberDto {
    AppMemberDto {
        user_id: user.id,
        username: user.username,
        email: user.email,
        instance_role: user.instance_role,
        is_active: user.is_active,
        role: membership.role,
        created_at: membership.created_at,
    }
}

#[derive(Debug, Deserialize)]
pub struct GrantMemberRequest {
    pub user_id: AdminUserId,
    pub role: AppRole,
}

#[derive(Debug, Deserialize)]
pub struct PatchMemberRequest {
    pub role: AppRole,
}

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

async fn list_members(
    State(s): State<AppMembersState>,
    Extension(principal): Extension<Principal>,
    Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<AppMemberDto>>, AppMembersApiError> {
    let app = resolve_app(&*s.apps, &id_or_slug).await?;
    require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
    let rows = s.members.list_for_app_enriched(app.id).await?;
    Ok(Json(rows.into_iter().map(Into::into).collect()))
}

async fn grant_member(
    State(s): State<AppMembersState>,
    Extension(principal): Extension<Principal>,
    Path(id_or_slug): Path<String>,
    Json(input): Json<GrantMemberRequest>,
) -> Result<(StatusCode, Json<AppMemberDto>), AppMembersApiError> {
    let app = resolve_app(&*s.apps, &id_or_slug).await?;
    require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;

    let user = s
        .users
        .get(input.user_id)
        .await?
        .ok_or(AppMembersApiError::UserNotFound(input.user_id))?;
    validate_grant_target(&user)?;

    // Atomic insert — if a row already exists, returns None and we 409.
    // Avoids the find-then-upsert race where two concurrent POSTs would
    // both pass the existence check and the second `upsert` would
    // silently rewrite the role.
    let row = s
        .members
        .try_insert(app.id, user.id, input.role)
        .await?
        .ok_or_else(|| AppMembersApiError::AlreadyMember {
            username: user.username.clone(),
        })?;
    Ok((StatusCode::CREATED, Json(compose_dto(user, row))))
}

async fn patch_member(
    State(s): State<AppMembersState>,
    Extension(principal): Extension<Principal>,
    Path((id_or_slug, user_id)): Path<(String, Uuid)>,
    Json(input): Json<PatchMemberRequest>,
) -> Result<Json<AppMemberDto>, AppMembersApiError> {
    let app = resolve_app(&*s.apps, &id_or_slug).await?;
    require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;

    let user_id = AdminUserId::from(user_id);
    let user = s
        .users
        .get(user_id)
        .await?
        .ok_or(AppMembersApiError::UserNotFound(user_id))?;

    // Atomic update — returns None if no row exists, so 404 is decided
    // by the same statement that does the write. Eliminates the
    // find-then-upsert race where a concurrent DELETE between the two
    // calls would let PATCH silently re-create the row.
    let row = s
        .members
        .update_role(app.id, user_id, input.role)
        .await?
        .ok_or(AppMembersApiError::MembershipNotFound)?;
    Ok(Json(compose_dto(user, row)))
}

async fn remove_member(
    State(s): State<AppMembersState>,
    Extension(principal): Extension<Principal>,
    Path((id_or_slug, user_id)): Path<(String, Uuid)>,
) -> Result<StatusCode, AppMembersApiError> {
    let app = resolve_app(&*s.apps, &id_or_slug).await?;
    require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
    s.members.remove(app.id, AdminUserId::from(user_id)).await?;
    Ok(StatusCode::NO_CONTENT)
}

// ----------------------------------------------------------------------------
// Validation + helpers
// ----------------------------------------------------------------------------

fn validate_grant_target(user: &AdminUserRow) -> Result<(), AppMembersApiError> {
    if !user.is_active {
        return Err(AppMembersApiError::TargetInactive {
            username: user.username.clone(),
        });
    }
    if user.instance_role != InstanceRole::Member {
        return Err(AppMembersApiError::TargetNotMember {
            username: user.username.clone(),
            instance_role: user.instance_role,
        });
    }
    Ok(())
}

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

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

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

    #[error("user not found: {0}")]
    UserNotFound(AdminUserId),

    #[error("no membership exists for this user on this app")]
    MembershipNotFound,

    #[error("{username} is already a member of this app — use PATCH to change their role")]
    AlreadyMember { username: String },

    #[error("{username} is deactivated and cannot be added as a member")]
    TargetInactive { username: String },

    #[error(
        "{username} has instance_role {instance_role:?} and already has implicit access \
         on every app — no explicit membership needed"
    )]
    TargetNotMember {
        username: String,
        instance_role: InstanceRole,
    },

    #[error("forbidden")]
    Forbidden,

    #[error("authorization repo error: {0}")]
    AuthzRepo(String),

    #[error("repository error: {0}")]
    Members(#[from] AppMembersRepositoryError),

    #[error("user repository error: {0}")]
    Users(#[from] AdminUserRepositoryError),

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

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

impl IntoResponse for AppMembersApiError {
    fn into_response(self) -> Response {
        let (status, body) = match &self {
            Self::AppNotFound(_)
            | Self::UserNotFound(_)
            | Self::MembershipNotFound
            | Self::Apps(ScriptRepositoryError::NotFound(_)) => {
                (StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
            }
            Self::AlreadyMember { .. } | Self::Apps(ScriptRepositoryError::Conflict(_)) => {
                (StatusCode::CONFLICT, json!({ "error": self.to_string() }))
            }
            Self::TargetInactive { .. } | Self::TargetNotMember { .. } => (
                StatusCode::UNPROCESSABLE_ENTITY,
                json!({ "error": self.to_string() }),
            ),
            Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
            Self::AuthzRepo(e) => {
                tracing::error!(error = %e, "app members authz repo error");
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    json!({ "error": "internal error" }),
                )
            }
            Self::Members(e) => {
                tracing::error!(error = %e, "app members repo error");
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    json!({ "error": "internal error" }),
                )
            }
            Self::Users(e) => {
                tracing::error!(error = %e, "admin users repo error");
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    json!({ "error": "internal error" }),
                )
            }
            Self::Apps(ScriptRepositoryError::Db(e)) => {
                tracing::error!(error = %e, "apps repo error in app_members");
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    json!({ "error": "internal error" }),
                )
            }
        };
        (status, Json(body)).into_response()
    }
}
