//! `/api/v1/admin/groups/*` — CRUD over the group tree + per-group
//! membership (Phase 2, blueprint §5).
//!
//! Group capabilities resolve by walking the group's ancestor chain
//! (`authz::effective_group_role`): a `group_admin` on any ancestor is
//! implicitly admin of every descendant group. Structural mutations
//! (reparent/delete) are gated on `GroupAdmin`; reparent additionally
//! requires admin at BOTH the source and destination parent (§5.6).
//!
//! Slug is frozen at creation — PATCH edits name/description only.

use std::sync::Arc;

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

use picloud_orchestrator_core::routing::RouteTable;

use crate::admin_user_repo::{AdminUserRepository, AdminUserRow};
use crate::app_repo::AppRepository;
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
use crate::group_members_repo::{
    GroupMembersRepository, GroupMembersRepositoryError, GroupMembershipDetail, GroupMembershipRow,
};
use crate::group_repo::{GroupRepository, GroupRepositoryError};
use crate::route_repo::RouteRepository;

const SLUG_MAX: usize = 63;
const RESERVED_SLUGS: &[&str] = &[
    "new", "api", "admin", "admins", "healthz", "version", "login", "logout", "apps", "groups",
];

#[derive(Clone)]
pub struct GroupsState {
    pub groups: Arc<dyn GroupRepository>,
    pub group_members: Arc<dyn GroupMembersRepository>,
    pub apps: Arc<dyn AppRepository>,
    pub users: Arc<dyn AdminUserRepository>,
    pub authz: Arc<dyn AuthzRepo>,
    /// §11 tail full-live invalidation: reparenting a group moves a whole
    /// subtree, changing which ancestor-group route TEMPLATES its descendant
    /// apps inherit — so the route snapshot is rebuilt after a reparent.
    pub routes: Arc<dyn RouteRepository>,
    pub route_table: Arc<RouteTable>,
    /// §4.5 M5: reparenting moves a subtree, changing which ancestor-group
    /// stateful templates its apps inherit — their materialized cron/queue
    /// copies are reconciled after a reparent.
    pub pool: sqlx::PgPool,
}

pub fn groups_router(state: GroupsState) -> Router {
    Router::new()
        .route("/groups", get(list_groups).post(create_group))
        .route(
            "/groups/{id_or_slug}",
            get(get_group).patch(patch_group).delete(delete_group),
        )
        .route("/groups/{id_or_slug}/reparent", post(reparent_group))
        .route(
            "/groups/{id_or_slug}/members",
            get(list_members).post(grant_member),
        )
        .route(
            "/groups/{id_or_slug}/members/{user_id}",
            patch(patch_member).delete(remove_member),
        )
        .with_state(state)
}

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

#[derive(Debug, Serialize)]
pub struct GroupDetailDto {
    #[serde(flatten)]
    pub group: Group,
    /// Root → … → this group (nearest-last), for breadcrumb display.
    pub path: Vec<Group>,
    pub subgroups: Vec<Group>,
    pub apps: Vec<App>,
}

#[derive(Debug, Deserialize)]
pub struct CreateGroupRequest {
    pub slug: String,
    pub name: String,
    pub description: Option<String>,
    /// Parent group (slug or id). Omitted ⇒ a root-level group
    /// (owner/admin only).
    #[serde(default)]
    pub parent: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct PatchGroupRequest {
    pub name: Option<String>,
    pub description: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct ReparentRequest {
    /// New parent (slug or id). Omitted/null ⇒ move to root.
    #[serde(default)]
    pub parent: Option<String>,
}

#[derive(Debug, Serialize)]
pub struct GroupMemberDto {
    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<GroupMembershipDetail> for GroupMemberDto {
    fn from(d: GroupMembershipDetail) -> 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,
        }
    }
}

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

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

// ----------------------------------------------------------------------------
// Group CRUD handlers
// ----------------------------------------------------------------------------

/// List the whole tree. Phase-2 simplification: group names/structure are
/// low-sensitivity org metadata (groups own no resources/secrets yet), so
/// any authenticated admin sees the full tree; per-action authz still
/// gates every mutation and all app access. Tighten in Phase 3 when groups
/// carry inheritable config.
/// §7: a group plus its owning project's slug (`None` = unclaimed / UI-owned).
/// The group fields are flattened, so existing consumers (dashboard, `pic
/// groups tree`) keep working; `owner` is a new, optional field.
#[derive(Serialize)]
struct GroupListItem {
    #[serde(flatten)]
    group: Group,
    owner: Option<String>,
}

async fn list_groups(
    State(s): State<GroupsState>,
    Extension(_principal): Extension<Principal>,
) -> Result<Json<Vec<GroupListItem>>, GroupsApiError> {
    let items = s
        .groups
        .list_with_owner()
        .await?
        .into_iter()
        .map(|(group, owner)| GroupListItem { group, owner })
        .collect();
    Ok(Json(items))
}

async fn create_group(
    State(s): State<GroupsState>,
    Extension(principal): Extension<Principal>,
    Json(input): Json<CreateGroupRequest>,
) -> Result<(StatusCode, Json<Group>), GroupsApiError> {
    validate_slug(&input.slug)?;

    let parent_id = match input.parent.as_deref() {
        // Root-level group — an instance act.
        None => {
            require(
                s.authz.as_ref(),
                &principal,
                Capability::InstanceCreateGroup,
            )
            .await?;
            None
        }
        // Subgroup — requires group-admin at the parent.
        Some(ident) => {
            let parent = resolve_group(&*s.groups, ident).await?;
            require(
                s.authz.as_ref(),
                &principal,
                Capability::GroupAdmin(parent.id),
            )
            .await?;
            Some(parent.id)
        }
    };

    let created = s
        .groups
        .create(
            &input.slug,
            &input.name,
            input.description.as_deref(),
            parent_id,
        )
        .await?;
    Ok((StatusCode::CREATED, Json(created)))
}

async fn get_group(
    State(s): State<GroupsState>,
    Extension(principal): Extension<Principal>,
    Path(id_or_slug): Path<String>,
) -> Result<Json<GroupDetailDto>, GroupsApiError> {
    let group = resolve_group(&*s.groups, &id_or_slug).await?;
    require(
        s.authz.as_ref(),
        &principal,
        Capability::GroupRead(group.id),
    )
    .await?;

    // ancestors() is nearest-first incl. the node; reverse for a
    // root→…→node breadcrumb.
    let mut path = s.groups.ancestors(group.id).await?;
    path.reverse();
    let subgroups = s.groups.list_children(group.id).await?;
    let apps = s.apps.list_for_group(group.id).await?;
    Ok(Json(GroupDetailDto {
        group,
        path,
        subgroups,
        apps,
    }))
}

async fn patch_group(
    State(s): State<GroupsState>,
    Extension(principal): Extension<Principal>,
    Path(id_or_slug): Path<String>,
    Json(input): Json<PatchGroupRequest>,
) -> Result<Json<Group>, GroupsApiError> {
    let group = resolve_group(&*s.groups, &id_or_slug).await?;
    require(
        s.authz.as_ref(),
        &principal,
        Capability::GroupWrite(group.id),
    )
    .await?;
    let updated = s
        .groups
        .rename(
            group.id,
            input.name.as_deref(),
            input.description.as_ref().map(|d| Some(d.as_str())),
        )
        .await?;
    Ok(Json(updated))
}

async fn reparent_group(
    State(s): State<GroupsState>,
    Extension(principal): Extension<Principal>,
    Path(id_or_slug): Path<String>,
    Json(input): Json<ReparentRequest>,
) -> Result<Json<Group>, GroupsApiError> {
    let group = resolve_group(&*s.groups, &id_or_slug).await?;
    // Admin of the node being moved.
    require(
        s.authz.as_ref(),
        &principal,
        Capability::GroupAdmin(group.id),
    )
    .await?;
    // Admin at the SOURCE parent (you're removing it from that domain).
    if let Some(src) = group.parent_id {
        require(s.authz.as_ref(), &principal, Capability::GroupAdmin(src)).await?;
    }
    // Resolve + require admin at the DESTINATION parent.
    let new_parent = match input.parent.as_deref() {
        None => None,
        Some(ident) => {
            let dest = resolve_group(&*s.groups, ident).await?;
            require(
                s.authz.as_ref(),
                &principal,
                Capability::GroupAdmin(dest.id),
            )
            .await?;
            Some(dest.id)
        }
    };
    let moved = s.groups.reparent(group.id, new_parent).await?;
    // §11 tail: the moved subtree now inherits a different set of ancestor-group
    // route TEMPLATES — rebuild the snapshot (best-effort; self-heals on the
    // next route write or restart if it fails).
    if let Err(e) = crate::route_admin::rebuild_route_table(s.routes.as_ref(), &s.route_table).await
    {
        tracing::warn!(error = %e, "groups: route table refresh after reparent failed; it will self-heal");
    }
    // §4.5 M5: the moved subtree inherits a different set of ancestor-group
    // stateful templates — reconcile its materialized copies.
    match crate::materialize::rematerialize_stateful_templates(&s.pool).await {
        Ok(warnings) => {
            for w in warnings {
                tracing::warn!(warning = %w, "groups: stateful-template materialization warning after reparent");
            }
        }
        Err(e) => {
            tracing::warn!(error = %e, "groups: stateful-template materialization after reparent failed; it will self-heal");
        }
    }
    Ok(Json(moved))
}

async fn delete_group(
    State(s): State<GroupsState>,
    Extension(principal): Extension<Principal>,
    Path(id_or_slug): Path<String>,
) -> Result<StatusCode, GroupsApiError> {
    let group = resolve_group(&*s.groups, &id_or_slug).await?;
    require(
        s.authz.as_ref(),
        &principal,
        Capability::GroupAdmin(group.id),
    )
    .await?;
    s.groups.delete(group.id).await?;
    Ok(StatusCode::NO_CONTENT)
}

// ----------------------------------------------------------------------------
// Member handlers — gated on GroupAdmin(group)
// ----------------------------------------------------------------------------

async fn list_members(
    State(s): State<GroupsState>,
    Extension(principal): Extension<Principal>,
    Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<GroupMemberDto>>, GroupsApiError> {
    let group = resolve_group(&*s.groups, &id_or_slug).await?;
    require(
        s.authz.as_ref(),
        &principal,
        Capability::GroupAdmin(group.id),
    )
    .await?;
    let rows = s.group_members.list_for_group_enriched(group.id).await?;
    Ok(Json(rows.into_iter().map(Into::into).collect()))
}

async fn grant_member(
    State(s): State<GroupsState>,
    Extension(principal): Extension<Principal>,
    Path(id_or_slug): Path<String>,
    Json(input): Json<GrantMemberRequest>,
) -> Result<(StatusCode, Json<GroupMemberDto>), GroupsApiError> {
    let group = resolve_group(&*s.groups, &id_or_slug).await?;
    require(
        s.authz.as_ref(),
        &principal,
        Capability::GroupAdmin(group.id),
    )
    .await?;

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

    let row = s
        .group_members
        .try_insert(group.id, user.id, input.role)
        .await?
        .ok_or_else(|| GroupsApiError::AlreadyMember {
            username: user.username.clone(),
        })?;
    Ok((StatusCode::CREATED, Json(compose_dto(user, row))))
}

async fn patch_member(
    State(s): State<GroupsState>,
    Extension(principal): Extension<Principal>,
    Path((id_or_slug, user_id)): Path<(String, Uuid)>,
    Json(input): Json<PatchMemberRequest>,
) -> Result<Json<GroupMemberDto>, GroupsApiError> {
    let group = resolve_group(&*s.groups, &id_or_slug).await?;
    require(
        s.authz.as_ref(),
        &principal,
        Capability::GroupAdmin(group.id),
    )
    .await?;

    let user_id = AdminUserId::from(user_id);
    let user = s
        .users
        .get(user_id)
        .await?
        .ok_or(GroupsApiError::UserNotFound(user_id))?;
    let row = s
        .group_members
        .update_role(group.id, user_id, input.role)
        .await?
        .ok_or(GroupsApiError::MembershipNotFound)?;
    Ok(Json(compose_dto(user, row)))
}

async fn remove_member(
    State(s): State<GroupsState>,
    Extension(principal): Extension<Principal>,
    Path((id_or_slug, user_id)): Path<(String, Uuid)>,
) -> Result<StatusCode, GroupsApiError> {
    let group = resolve_group(&*s.groups, &id_or_slug).await?;
    require(
        s.authz.as_ref(),
        &principal,
        Capability::GroupAdmin(group.id),
    )
    .await?;
    s.group_members
        .remove(group.id, AdminUserId::from(user_id))
        .await?;
    Ok(StatusCode::NO_CONTENT)
}

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

/// Resolve a group identifier (slug or UUID) to a `Group`.
async fn resolve_group(groups: &dyn GroupRepository, ident: &str) -> Result<Group, GroupsApiError> {
    let found = if let Ok(uuid) = ident.parse::<Uuid>() {
        groups.get_by_id(uuid.into()).await?
    } else {
        groups.get_by_slug(ident).await?
    };
    found.ok_or_else(|| GroupsApiError::GroupNotFound(ident.to_string()))
}

/// The shared slug FORMAT rule (`^[a-z0-9][a-z0-9-]{0,62}$`), returning the
/// human reason on failure so each caller can wrap it in its own error type.
/// Shared by group slugs and §7 project slugs; the reserved-word check is
/// layered on separately by callers that route slugs (groups), not by projects
/// (never routed). See [`crate::apply_service`]'s `validate_project_slug`.
pub(crate) fn validate_slug_format(slug: &str) -> Result<(), String> {
    if slug.is_empty() || slug.len() > SLUG_MAX {
        return Err("must be 1–63 characters".to_string());
    }
    let first = slug.chars().next().expect("non-empty checked above");
    if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
        return Err("must start with a lowercase letter or digit".to_string());
    }
    if !slug
        .chars()
        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
    {
        return Err("may contain only lowercase letters, digits, and hyphens".to_string());
    }
    Ok(())
}

/// Same rule as app slugs: `^[a-z0-9][a-z0-9-]{0,62}$`, no reserved words.
fn validate_slug(slug: &str) -> Result<(), GroupsApiError> {
    let invalid = |reason: &str| GroupsApiError::InvalidSlug(format!("{slug:?}: {reason}"));
    validate_slug_format(slug).map_err(|reason| invalid(&reason))?;
    if RESERVED_SLUGS.contains(&slug) {
        return Err(invalid("is a reserved word"));
    }
    Ok(())
}

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

fn compose_dto(user: AdminUserRow, membership: GroupMembershipRow) -> GroupMemberDto {
    GroupMemberDto {
        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,
    }
}

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

#[derive(Debug, thiserror::Error)]
pub enum GroupsApiError {
    #[error("group not found: {0}")]
    GroupNotFound(String),
    #[error("user not found: {0}")]
    UserNotFound(AdminUserId),
    #[error("{username} is already a member of this group")]
    AlreadyMember { username: String },
    #[error("membership not found")]
    MembershipNotFound,
    #[error("{username} is deactivated")]
    TargetInactive { username: String },
    #[error("{username} has instance role {instance_role:?}; only members get explicit grants")]
    TargetNotMember {
        username: String,
        instance_role: InstanceRole,
    },
    #[error("invalid slug: {0}")]
    InvalidSlug(String),
    #[error("conflict: {0}")]
    Conflict(String),
    #[error("forbidden")]
    Forbidden,
    #[error("authorization repo error: {0}")]
    AuthzRepo(String),
    #[error("group repository error: {0}")]
    Repo(#[from] GroupRepositoryError),
    #[error("member repository error: {0}")]
    MembersRepo(#[from] GroupMembersRepositoryError),
    #[error("user repository error: {0}")]
    UsersRepo(#[from] crate::admin_user_repo::AdminUserRepositoryError),
    #[error("app repository error: {0}")]
    AppsRepo(#[from] crate::repo::ScriptRepositoryError),
}

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

impl From<AuthzError> for GroupsApiError {
    fn from(e: AuthzError) -> Self {
        Self::AuthzRepo(e.to_string())
    }
}

impl IntoResponse for GroupsApiError {
    fn into_response(self) -> Response {
        let (status, body) = match &self {
            Self::GroupNotFound(_)
            | Self::UserNotFound(_)
            | Self::MembershipNotFound
            | Self::Repo(GroupRepositoryError::NotFound(_)) => {
                (StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
            }
            Self::InvalidSlug(_) | Self::TargetInactive { .. } | Self::TargetNotMember { .. } => (
                StatusCode::UNPROCESSABLE_ENTITY,
                json!({ "error": self.to_string() }),
            ),
            Self::AlreadyMember { .. } | Self::Conflict(_) => {
                (StatusCode::CONFLICT, json!({ "error": self.to_string() }))
            }
            Self::Repo(GroupRepositoryError::Conflict(msg)) => {
                (StatusCode::CONFLICT, json!({ "error": msg }))
            }
            Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
            Self::AuthzRepo(e) => {
                tracing::error!(error = %e, "groups authz repo error");
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    json!({ "error": "internal error" }),
                )
            }
            Self::Repo(GroupRepositoryError::Db(e)) => {
                tracing::error!(error = %e, "groups api db error");
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    json!({ "error": "internal error" }),
                )
            }
            Self::MembersRepo(e) => {
                tracing::error!(error = %e, "group members repo error");
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    json!({ "error": "internal error" }),
                )
            }
            Self::UsersRepo(e) => {
                tracing::error!(error = %e, "groups api user repo error");
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    json!({ "error": "internal error" }),
                )
            }
            Self::AppsRepo(e) => {
                tracing::error!(error = %e, "groups api app repo error");
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    json!({ "error": "internal error" }),
                )
            }
        };
        (status, Json(body)).into_response()
    }
}
