//! `POST /api/devices/cli` — used by `rustdesk --assign --token <T> ...`
//! to enroll a freshly installed device into a tenant slot.
//!
//! Per CONSOLE_API.md §11: bearer-authenticated; the response body is plain
//! text (empty = success, non-empty = informational message). The client
//! prints "Done!" when the body is empty.

use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use crate::api::state::AppState;
use crate::database::AbPeerInsert;
use axum::extract::Extension;
use axum::http::header;
use axum::response::IntoResponse;
use axum::Json;
use serde::Deserialize;
use serde_json::Value;
use std::sync::Arc;

#[derive(Debug, Deserialize)]
pub struct AssignBody {
    pub id: String,
    pub uuid: String,
    #[serde(default)]
    pub user_name: Option<String>,
    #[serde(default)]
    pub strategy_name: Option<String>,
    #[serde(default)]
    pub address_book_name: Option<String>,
    #[serde(default)]
    pub address_book_tag: Option<String>,
    #[serde(default)]
    pub address_book_alias: Option<String>,
    #[serde(default)]
    pub address_book_password: Option<String>,
    #[serde(default)]
    pub address_book_note: Option<String>,
    #[serde(default)]
    pub device_group_name: Option<String>,
    #[serde(default)]
    pub note: Option<String>,
    #[serde(default)]
    pub device_username: Option<String>,
    #[serde(default)]
    pub device_name: Option<String>,
}

pub async fn assign(
    Extension(state): Extension<Arc<AppState>>,
    caller: AuthedUser,
    Json(body): Json<AssignBody>,
) -> Result<impl IntoResponse, ApiError> {
    if body.id.is_empty() || body.uuid.is_empty() {
        return Err(ApiError::BadRequest("id and uuid required".into()));
    }
    let mut warnings: Vec<String> = vec![];

    // Resolve owner. If --user_name was supplied, that's the owner; otherwise
    // the caller becomes the owner (matches `rustdesk --assign` flows where
    // the operator's account is the destination).
    let owner = if let Some(name) = body.user_name.as_deref().filter(|s| !s.is_empty()) {
        if !caller.is_admin {
            return Err(ApiError::Forbidden(
                "admin required to assign to another user".into(),
            ));
        }
        match state
            .db
            .user_find_by_username(name)
            .await
            .map_err(|e| ApiError::Internal(e.to_string()))?
        {
            Some(u) => u,
            None => {
                return Err(ApiError::BadRequest(format!(
                    "no such user: {}",
                    name
                )));
            }
        }
    } else {
        state
            .db
            .user_find_by_id(caller.user_id)
            .await
            .map_err(|e| ApiError::Internal(e.to_string()))?
            .ok_or(ApiError::Unauthorized)?
    };

    // Bind the device to the owner (mirrors what /api/login's device_claim
    // does, but here it's an admin operation rather than user-initiated).
    state.db.device_claim(owner.id, &body.id, &body.uuid).await;

    // Address-book entry. We always target the *owner's* personal AB.
    if let Some(ab_name) = body.address_book_name.as_deref().filter(|s| !s.is_empty()) {
        let _ = ab_name; // M2's get_or_create_personal ignores the name; OSS has one personal AB per user.
        let ab_guid = state
            .db
            .ab_get_or_create_personal(owner.id)
            .await
            .map_err(|e| ApiError::Internal(e.to_string()))?;
        let tags: Option<Vec<String>> = body
            .address_book_tag
            .as_deref()
            .filter(|s| !s.is_empty())
            .map(|t| t.split(',').map(|s| s.trim().to_string()).collect());
        if let Err(e) = state
            .db
            .ab_peer_insert(
                &ab_guid,
                AbPeerInsert {
                    id: &body.id,
                    alias: body.address_book_alias.as_deref(),
                    note: body.address_book_note.as_deref(),
                    password: body.address_book_password.as_deref(),
                    hash: None,
                    username: body.device_username.as_deref(),
                    hostname: body.device_name.as_deref(),
                    platform: None,
                },
                tags.as_deref(),
            )
            .await
        {
            // Likely a UNIQUE conflict if the peer is already in the AB;
            // surface as a warning rather than failing the whole call.
            warnings.push(format!("address-book entry not added: {}", e));
        }
    }

    // Strategy assignment by name. We attach to the device directly (peer-scoped),
    // which is the most-specific tier in our resolver.
    if let Some(name) = body.strategy_name.as_deref().filter(|s| !s.is_empty()) {
        match resolve_strategy_id(&state, name).await? {
            Some(strategy_id) => {
                if let Err(e) = state
                    .db
                    .strategy_assign_peer(strategy_id, &body.id)
                    .await
                {
                    warnings.push(format!("strategy assignment failed: {}", e));
                }
            }
            None => {
                warnings.push(format!("strategy {:?} does not exist", name));
            }
        }
    }

    // Device-group membership: ensure the group exists, ensure the owner is a
    // member. We treat the group name as the natural key per the M2 schema.
    if let Some(group_name) = body.device_group_name.as_deref().filter(|s| !s.is_empty()) {
        if let Err(e) = state
            .db
            .device_group_ensure_member(group_name, owner.id)
            .await
        {
            warnings.push(format!("device-group assignment failed: {}", e));
        }
    }

    // Fields we accept but don't currently persist as discrete columns. These
    // travel with the next sysinfo upload anyway (note, device_username,
    // device_name end up in `device_sysinfo.payload` JSON).
    if body.note.as_deref().map(|s| !s.is_empty()).unwrap_or(false) {
        warnings.push(
            "--note is currently surfaced via sysinfo only, not persisted as a discrete field"
                .into(),
        );
    }

    let body_text = if warnings.is_empty() {
        String::new()
    } else {
        warnings.join("\n")
    };
    Ok((
        [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
        body_text,
    ))
}

async fn resolve_strategy_id(
    state: &AppState,
    name: &str,
) -> Result<Option<i64>, ApiError> {
    state
        .db
        .strategy_find_by_name(name)
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))
}

/// Wrap the `Value` JSON the request _could_ have under `Json<Value>` if a
/// future variation needs it. Currently unused; kept for symmetry with other
/// modules that work with raw JSON in/out.
#[allow(dead_code)]
fn ignore_value(_v: Value) {}
