//! Admin vocabulary + term management. Reads require `ViewInternal`; writes `EditCatalogue`.

use auth::{Authorized, EditCatalogue, ViewInternal};
use axum::{
    Json, Router,
    extract::{Path, State},
    http::StatusCode,
    response::{IntoResponse, Response},
    routing::get,
};
use domain::{AuditActor, LocalizedLabel, NewTerm, TermId, VocabularyId};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;

use crate::{AppState, admin_objects::LabelView};

#[derive(Serialize, ToSchema)]
pub(crate) struct VocabularyView {
    pub id: String,
    pub key: String,
}

#[derive(Deserialize, ToSchema)]
pub(crate) struct NewVocabularyRequest {
    pub key: String,
}

#[derive(Deserialize, ToSchema)]
pub(crate) struct LabelInput {
    pub lang: String,
    pub label: String,
}

#[derive(Deserialize, ToSchema)]
pub(crate) struct NewTermRequest {
    pub external_uri: Option<String>,
    pub labels: Vec<LabelInput>,
}

#[derive(Serialize, ToSchema)]
pub(crate) struct TermView {
    pub id: String,
    pub external_uri: Option<String>,
    pub labels: Vec<LabelView>,
}

#[derive(Serialize, ToSchema)]
pub(crate) struct CreatedId {
    pub id: String,
}

#[utoipa::path(
    get, path = "/api/admin/vocabularies",
    responses(
        (status = 200, body = [VocabularyView]),
        (status = 401),
        (status = 403)
    )
)]
pub(crate) async fn list_vocabularies(
    _auth: Authorized<ViewInternal>,
    State(state): State<AppState>,
) -> Result<Json<Vec<VocabularyView>>, StatusCode> {
    let vocabs = db::vocab::list_vocabularies(state.db.pool())
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok(Json(
        vocabs
            .into_iter()
            .map(|vocab| VocabularyView {
                id: vocab.id.to_string(),
                key: vocab.key,
            })
            .collect(),
    ))
}

#[utoipa::path(
    post, path = "/api/admin/vocabularies",
    request_body = NewVocabularyRequest,
    responses(
        (status = 201, body = VocabularyView),
        (status = 401),
        (status = 403)
    )
)]
pub(crate) async fn create_vocabulary(
    auth: Authorized<EditCatalogue>,
    State(state): State<AppState>,
    Json(req): Json<NewVocabularyRequest>,
) -> Result<(StatusCode, Json<VocabularyView>), StatusCode> {
    let mut tx = state
        .db
        .pool()
        .begin()
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let vocab =
        db::vocab::create_vocabulary(&mut tx, AuditActor::User(auth.user.id.to_uuid()), &req.key)
            .await
            .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    tx.commit()
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok((
        StatusCode::CREATED,
        Json(VocabularyView {
            id: vocab.id.to_string(),
            key: vocab.key,
        }),
    ))
}

#[utoipa::path(
    get, path = "/api/admin/vocabularies/{id}/terms",
    params(("id" = String, Path, description = "Vocabulary id (UUID)")),
    responses(
        (status = 200, body = [TermView]),
        (status = 401),
        (status = 403),
        (status = 404)
    )
)]
pub(crate) async fn list_terms(
    _auth: Authorized<ViewInternal>,
    State(state): State<AppState>,
    Path(id): Path<String>,
) -> Result<Json<Vec<TermView>>, StatusCode> {
    let vocab_id = id
        .parse::<VocabularyId>()
        .map_err(|_| StatusCode::NOT_FOUND)?;

    let terms = db::vocab::list_terms(state.db.pool(), vocab_id)
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok(Json(
        terms
            .into_iter()
            .map(|term| TermView {
                id: term.id.to_string(),
                external_uri: term.external_uri,
                labels: term
                    .labels
                    .into_iter()
                    .map(|label| LabelView {
                        lang: label.lang,
                        label: label.label,
                    })
                    .collect(),
            })
            .collect(),
    ))
}

#[utoipa::path(
    post, path = "/api/admin/vocabularies/{id}/terms",
    request_body = NewTermRequest,
    params(("id" = String, Path, description = "Vocabulary id (UUID)")),
    responses(
        (status = 201, body = CreatedId),
        (status = 401),
        (status = 403),
        (status = 404)
    )
)]
pub(crate) async fn add_term(
    auth: Authorized<EditCatalogue>,
    State(state): State<AppState>,
    Path(id): Path<String>,
    Json(req): Json<NewTermRequest>,
) -> Result<(StatusCode, Json<CreatedId>), StatusCode> {
    let vocabulary_id = id
        .parse::<VocabularyId>()
        .map_err(|_| StatusCode::NOT_FOUND)?;

    let new = NewTerm {
        vocabulary_id,
        external_uri: req.external_uri,
        labels: req
            .labels
            .into_iter()
            .map(|label| LocalizedLabel {
                lang: label.lang,
                label: label.label,
            })
            .collect(),
    };

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

    let term_id = db::vocab::add_term(&mut tx, AuditActor::User(auth.user.id.to_uuid()), &new)
        .await
        .map_err(|err| {
            // A well-formed id for a missing vocabulary hits the FK constraint (23503).
            if err.as_database_error().and_then(|e| e.code()).as_deref() == Some("23503") {
                StatusCode::NOT_FOUND
            } else {
                tracing::error!(?err, "adding term");
                StatusCode::INTERNAL_SERVER_ERROR
            }
        })?;

    tx.commit()
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok((
        StatusCode::CREATED,
        Json(CreatedId {
            id: term_id.to_string(),
        }),
    ))
}

/// 409 body: how many catalogue objects still reference the entity.
#[derive(Serialize, ToSchema)]
pub(crate) struct InUseView {
    pub count: i64,
}

#[derive(Deserialize, ToSchema)]
pub(crate) struct UpdateTermRequest {
    pub external_uri: Option<String>,
    pub labels: Vec<LabelInput>,
}

#[utoipa::path(
    patch, path = "/api/admin/vocabularies/{id}/terms/{term_id}",
    request_body = UpdateTermRequest,
    params(
        ("id" = String, Path, description = "Vocabulary id (UUID)"),
        ("term_id" = String, Path, description = "Term id (UUID)")
    ),
    responses(
        (status = 204),
        (status = 401),
        (status = 403),
        (status = 404)
    )
)]
pub(crate) async fn update_term(
    auth: Authorized<EditCatalogue>,
    State(state): State<AppState>,
    Path((id, term_id)): Path<(String, String)>,
    Json(req): Json<UpdateTermRequest>,
) -> Result<StatusCode, StatusCode> {
    let vocabulary_id = id
        .parse::<VocabularyId>()
        .map_err(|_| StatusCode::NOT_FOUND)?;

    let term_id = term_id
        .parse::<TermId>()
        .map_err(|_| StatusCode::NOT_FOUND)?;

    let labels: Vec<LocalizedLabel> = req
        .labels
        .into_iter()
        .map(|l| LocalizedLabel {
            lang: l.lang,
            label: l.label,
        })
        .collect();

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

    let existed = db::vocab::update_term(
        &mut tx,
        AuditActor::User(auth.user.id.to_uuid()),
        vocabulary_id,
        term_id,
        req.external_uri.as_deref(),
        &labels,
    )
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    if existed {
        tx.commit()
            .await
            .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

        Ok(StatusCode::NO_CONTENT)
    } else {
        let _ = tx.rollback().await;

        Err(StatusCode::NOT_FOUND)
    }
}

#[utoipa::path(
    delete, path = "/api/admin/vocabularies/{id}/terms/{term_id}",
    params(
        ("id" = String, Path, description = "Vocabulary id (UUID)"),
        ("term_id" = String, Path, description = "Term id (UUID)")
    ),
    responses(
        (status = 204),
        (status = 401),
        (status = 403),
        (status = 404),
        (status = 409, body = InUseView, description = "Referenced by catalogue objects")
    )
)]
pub(crate) async fn delete_term(
    auth: Authorized<EditCatalogue>,
    State(state): State<AppState>,
    Path((id, term_id)): Path<(String, String)>,
) -> Response {
    let (Ok(vocab_id), Ok(term_id)) = (id.parse::<VocabularyId>(), term_id.parse::<TermId>())
    else {
        return StatusCode::NOT_FOUND.into_response();
    };

    let Ok(mut tx) = state.db.pool().begin().await else {
        return StatusCode::INTERNAL_SERVER_ERROR.into_response();
    };

    let outcome = db::vocab::delete_term(
        &mut tx,
        AuditActor::User(auth.user.id.to_uuid()),
        vocab_id,
        term_id,
    )
    .await;

    match outcome {
        Ok(db::DeleteOutcome::Deleted) => match tx.commit().await {
            Ok(()) => StatusCode::NO_CONTENT.into_response(),
            Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
        },
        Ok(db::DeleteOutcome::InUse { count }) => {
            let _ = tx.rollback().await;

            (StatusCode::CONFLICT, Json(InUseView { count })).into_response()
        }
        Ok(db::DeleteOutcome::NotFound) => {
            let _ = tx.rollback().await;

            StatusCode::NOT_FOUND.into_response()
        }
        Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
    }
}

#[derive(Deserialize, ToSchema)]
pub(crate) struct RenameVocabularyRequest {
    pub key: String,
}

#[utoipa::path(
    patch, path = "/api/admin/vocabularies/{id}",
    request_body = RenameVocabularyRequest,
    params(("id" = String, Path, description = "Vocabulary id (UUID)")),
    responses(
        (status = 204),
        (status = 401),
        (status = 403),
        (status = 404),
        (status = 409, description = "Key already in use")
    )
)]
pub(crate) async fn rename_vocabulary(
    auth: Authorized<EditCatalogue>,
    State(state): State<AppState>,
    Path(id): Path<String>,
    Json(req): Json<RenameVocabularyRequest>,
) -> Result<StatusCode, StatusCode> {
    let id = id
        .parse::<VocabularyId>()
        .map_err(|_| StatusCode::NOT_FOUND)?;

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

    let existed = db::vocab::rename_vocabulary(
        &mut tx,
        AuditActor::User(auth.user.id.to_uuid()),
        id,
        &req.key,
    )
    .await
    .map_err(|err| {
        if err.as_database_error().and_then(|e| e.code()).as_deref() == Some("23505") {
            StatusCode::CONFLICT
        } else {
            StatusCode::INTERNAL_SERVER_ERROR
        }
    })?;

    if existed {
        tx.commit()
            .await
            .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
        Ok(StatusCode::NO_CONTENT)
    } else {
        let _ = tx.rollback().await;

        Err(StatusCode::NOT_FOUND)
    }
}

#[utoipa::path(
    delete, path = "/api/admin/vocabularies/{id}",
    params(("id" = String, Path, description = "Vocabulary id (UUID)")),
    responses(
        (status = 204),
        (status = 401),
        (status = 403),
        (status = 404),
        (status = 409, body = InUseView, description = "Has terms or is bound by a field")
    )
)]
pub(crate) async fn delete_vocabulary(
    auth: Authorized<EditCatalogue>,
    State(state): State<AppState>,
    Path(id): Path<String>,
) -> Response {
    let Ok(id) = id.parse::<VocabularyId>() else {
        return StatusCode::NOT_FOUND.into_response();
    };

    let Ok(mut tx) = state.db.pool().begin().await else {
        return StatusCode::INTERNAL_SERVER_ERROR.into_response();
    };

    match db::vocab::delete_vocabulary(&mut tx, AuditActor::User(auth.user.id.to_uuid()), id).await
    {
        Ok(db::DeleteOutcome::Deleted) => match tx.commit().await {
            Ok(()) => StatusCode::NO_CONTENT.into_response(),
            Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
        },
        Ok(db::DeleteOutcome::InUse { count }) => {
            let _ = tx.rollback().await;

            (StatusCode::CONFLICT, Json(InUseView { count })).into_response()
        }
        Ok(db::DeleteOutcome::NotFound) => {
            let _ = tx.rollback().await;

            StatusCode::NOT_FOUND.into_response()
        }
        Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
    }
}

pub(crate) fn routes() -> Router<AppState> {
    Router::new()
        .route(
            "/api/admin/vocabularies",
            get(list_vocabularies).post(create_vocabulary),
        )
        .route(
            "/api/admin/vocabularies/{id}",
            axum::routing::patch(rename_vocabulary).delete(delete_vocabulary),
        )
        .route(
            "/api/admin/vocabularies/{id}/terms",
            get(list_terms).post(add_term),
        )
        .route(
            "/api/admin/vocabularies/{id}/terms/{term_id}",
            axum::routing::patch(update_term).delete(delete_term),
        )
}
