use std::collections::HashMap;

use crate::db::ApiAuthed;
use crate::secret_backend_ext::rename_vault_secrets_with_prefix;
use axum::{
    extract::{Extension, Path},
    Json,
};
use serde::{Deserialize, Serialize};
use windmill_api_auth::require_super_admin;
use windmill_api_users::users::delete_workspace_user_internal;
use windmill_audit::audit_oss::audit_log;
use windmill_audit::ActionKind;
use windmill_common::utils::require_admin;
use windmill_common::{
    error::{Error, JsonResult, Result},
    DB,
};
use windmill_git_sync::handle_deployment_metadata;

// ---- Types ----

#[derive(Serialize, Deserialize)]
pub(crate) struct OffboardPreview {
    /// Objects under u/{username}/ that will be reassigned
    owned: OffboardAffectedPaths,
    /// Objects NOT under the user's path but that execute on behalf of this user
    /// (will have their permissioned_as/on_behalf_of updated)
    executing_on_behalf: OffboardAffectedPaths,
    /// Scripts/flows/apps/resources whose content/value references u/{username}/ paths
    /// (these references may break after path changes — admin should review)
    referencing: OffboardAffectedPaths,
    /// Tokens owned by this user (will be deleted)
    tokens: Vec<OffboardTokenInfo>,
    /// HTTP triggers under the user's path (webhook URLs will change)
    http_triggers: i64,
    /// Email triggers under the user's path (email addresses will change)
    email_triggers: i64,
}

#[derive(Serialize, Deserialize, Default)]
struct OffboardAffectedPaths {
    #[serde(skip_serializing_if = "Vec::is_empty")]
    scripts: Vec<String>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    flows: Vec<String>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    apps: Vec<String>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    resources: Vec<String>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    variables: Vec<String>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    schedules: Vec<String>,
    #[serde(skip_serializing_if = "HashMap::is_empty")]
    triggers: HashMap<String, Vec<String>>,
}

#[derive(Serialize, Deserialize)]
struct OffboardTokenInfo {
    label: String,
    scopes: Vec<String>,
    expiration: Option<String>,
}

#[derive(Deserialize)]
pub(crate) struct OffboardRequest {
    reassign_to: String,
    /// Required when reassign_to is a folder (f/...). The username whose identity
    /// will be used as permissioned_as for schedules and triggers.
    new_on_behalf_of_user: Option<String>,
    #[serde(default = "default_true")]
    delete_user: bool,
}

fn default_true() -> bool {
    true
}

#[derive(Serialize)]
pub(crate) struct OffboardResponse {
    #[serde(skip_serializing_if = "Vec::is_empty")]
    conflicts: Vec<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    summary: Option<OffboardSummary>,
}

#[derive(Serialize)]
struct OffboardSummary {
    scripts_reassigned: i64,
    flows_reassigned: i64,
    apps_reassigned: i64,
    resources_reassigned: i64,
    variables_reassigned: i64,
    schedules_reassigned: i64,
    triggers_reassigned: i64,
    drafts_deleted: i64,
}

#[derive(Serialize)]
pub(crate) struct GlobalOffboardPreview {
    workspaces: Vec<WorkspaceOffboardPreview>,
}

#[derive(Serialize)]
struct WorkspaceOffboardPreview {
    workspace_id: String,
    username: String,
    preview: OffboardPreview,
}

#[derive(Deserialize)]
pub(crate) struct GlobalOffboardRequest {
    #[serde(default)]
    reassignments: HashMap<String, WorkspaceReassignment>,
    #[serde(default = "default_true")]
    delete_user: bool,
}

#[derive(Deserialize)]
struct WorkspaceReassignment {
    reassign_to: String,
    new_on_behalf_of_user: Option<String>,
}

// ---- Preview helpers ----

async fn get_offboard_preview(
    db: impl sqlx::PgExecutor<'_> + Copy,
    w_id: &str,
    username: &str,
    email: &str,
) -> Result<OffboardPreview> {
    let user_prefix = format!("u/{}/%", username);
    let user_owner = format!("u/{}", username);

    // ---- Owned objects (under u/{username}/) ----
    let scripts = sqlx::query_scalar!(
        "SELECT path FROM script WHERE path LIKE $1 AND workspace_id = $2 AND NOT archived AND NOT deleted",
        &user_prefix, w_id
    ).fetch_all(db).await?;

    let flows = sqlx::query_scalar!(
        "SELECT path FROM flow WHERE path LIKE $1 AND workspace_id = $2 AND NOT archived",
        &user_prefix,
        w_id
    )
    .fetch_all(db)
    .await?;

    let apps = sqlx::query_scalar!(
        "SELECT path FROM app WHERE path LIKE $1 AND workspace_id = $2",
        &user_prefix,
        w_id
    )
    .fetch_all(db)
    .await?;

    let resources = sqlx::query_scalar!(
        "SELECT path FROM resource WHERE path LIKE $1 AND workspace_id = $2",
        &user_prefix,
        w_id
    )
    .fetch_all(db)
    .await?;

    let variables = sqlx::query_scalar!(
        "SELECT path FROM variable WHERE path LIKE $1 AND workspace_id = $2",
        &user_prefix,
        w_id
    )
    .fetch_all(db)
    .await?;

    let schedules = sqlx::query_scalar!(
        "SELECT path FROM schedule WHERE path LIKE $1 AND workspace_id = $2",
        &user_prefix,
        w_id
    )
    .fetch_all(db)
    .await?;

    let trigger_tables = [
        "http_trigger",
        "websocket_trigger",
        "kafka_trigger",
        "postgres_trigger",
        "mqtt_trigger",
        "nats_trigger",
        "sqs_trigger",
        "gcp_trigger",
        "azure_trigger",
        "email_trigger",
    ];
    let mut triggers = HashMap::new();
    for table in &trigger_tables {
        // SAFETY: `table` comes from a hardcoded allowlist `trigger_tables`, not user input.
        let paths: Vec<String> = sqlx::query_scalar(&format!(
            "SELECT path FROM {table} WHERE path LIKE $1 AND workspace_id = $2"
        ))
        .bind(&user_prefix)
        .bind(w_id)
        .fetch_all(db)
        .await?;
        if !paths.is_empty() {
            triggers.insert(table.to_string(), paths);
        }
    }

    // ---- Tokens (all workspace-scoped tokens that will be deleted) ----
    let token_rows = sqlx::query!(
        "SELECT label, scopes, expiration FROM token WHERE email = $1 AND workspace_id = $2",
        email,
        w_id
    )
    .fetch_all(db)
    .await?;

    let tokens: Vec<OffboardTokenInfo> = token_rows
        .into_iter()
        .map(|r| OffboardTokenInfo {
            label: r.label.unwrap_or_default(),
            scopes: r.scopes.unwrap_or_default(),
            expiration: r.expiration.map(|e| e.to_string()),
        })
        .collect();

    // ---- Operator references (not under user's path) ----
    let obo_scripts = sqlx::query_scalar!(
        "SELECT path FROM script WHERE on_behalf_of_email = $1 AND NOT path LIKE $2 AND workspace_id = $3 AND NOT archived AND NOT deleted",
        email, &user_prefix, w_id
    ).fetch_all(db).await?;

    let obo_flows = sqlx::query_scalar!(
        "SELECT path FROM flow WHERE on_behalf_of_email = $1 AND NOT path LIKE $2 AND workspace_id = $3 AND NOT archived",
        email, &user_prefix, w_id
    ).fetch_all(db).await?;

    let obo_apps = sqlx::query_scalar!(
        "SELECT path FROM app WHERE policy->>'on_behalf_of' = $1 AND NOT path LIKE $2 AND workspace_id = $3",
        &user_owner, &user_prefix, w_id
    ).fetch_all(db).await?;

    let obo_schedules = sqlx::query_scalar!(
        "SELECT path FROM schedule WHERE permissioned_as = $1 AND NOT path LIKE $2 AND workspace_id = $3",
        &user_owner, &user_prefix, w_id
    ).fetch_all(db).await?;

    let mut obo_triggers = HashMap::new();
    // SAFETY: `table` comes from a hardcoded allowlist `trigger_tables`, not user input.
    for table in &trigger_tables {
        let paths: Vec<String> = sqlx::query_scalar(&format!(
            "SELECT path FROM {table} WHERE permissioned_as = $1 AND NOT path LIKE $2 AND workspace_id = $3"
        ))
        .bind(&user_owner)
        .bind(&user_prefix)
        .bind(w_id)
        .fetch_all(db)
        .await?;
        if !paths.is_empty() {
            obo_triggers.insert(table.to_string(), paths);
        }
    }

    // ---- Objects whose content/value references u/{username}/ paths ----
    let ref_pattern = format!("%u/{}/%", username);

    let ref_scripts = sqlx::query_scalar!(
        "SELECT DISTINCT path FROM script WHERE content LIKE $1 AND NOT path LIKE $2 AND workspace_id = $3 AND NOT archived AND NOT deleted",
        &ref_pattern, &user_prefix, w_id
    ).fetch_all(db).await?;

    let ref_flows = sqlx::query_scalar!(
        "SELECT DISTINCT path FROM flow WHERE value::text LIKE $1 AND NOT path LIKE $2 AND workspace_id = $3 AND NOT archived",
        &ref_pattern, &user_prefix, w_id
    ).fetch_all(db).await?;

    let ref_apps = sqlx::query_scalar!(
        "SELECT DISTINCT path FROM app WHERE (policy::text LIKE $1 OR extra_perms::text LIKE $1) AND NOT path LIKE $2 AND workspace_id = $3",
        &ref_pattern, &user_prefix, w_id
    ).fetch_all(db).await?;

    let ref_resources = sqlx::query_scalar!(
        "SELECT DISTINCT path FROM resource WHERE value::text LIKE $1 AND NOT path LIKE $2 AND workspace_id = $3",
        &ref_pattern, &user_prefix, w_id
    ).fetch_all(db).await?;

    // ---- Specific trigger warnings ----
    let http_triggers = sqlx::query_scalar!(
        "SELECT COUNT(*) FROM http_trigger WHERE path LIKE $1 AND workspace_id = $2",
        &user_prefix,
        w_id
    )
    .fetch_one(db)
    .await?
    .unwrap_or(0);

    let email_triggers = sqlx::query_scalar!(
        "SELECT COUNT(*) FROM email_trigger WHERE path LIKE $1 AND workspace_id = $2",
        &user_prefix,
        w_id
    )
    .fetch_one(db)
    .await?
    .unwrap_or(0);

    Ok(OffboardPreview {
        owned: OffboardAffectedPaths {
            scripts,
            flows,
            apps,
            resources,
            variables,
            schedules,
            triggers,
        },
        executing_on_behalf: OffboardAffectedPaths {
            scripts: obo_scripts,
            flows: obo_flows,
            apps: obo_apps,
            schedules: obo_schedules,
            triggers: obo_triggers,
            ..Default::default()
        },
        referencing: OffboardAffectedPaths {
            scripts: ref_scripts,
            flows: ref_flows,
            apps: ref_apps,
            resources: ref_resources,
            ..Default::default()
        },
        tokens,
        http_triggers,
        email_triggers,
    })
}

// ---- Workspace-level endpoints ----

pub(crate) async fn offboard_preview(
    authed: ApiAuthed,
    Extension(db): Extension<DB>,
    Path((w_id, username)): Path<(String, String)>,
) -> JsonResult<OffboardPreview> {
    require_admin(authed.is_admin, &authed.username)?;
    let email = sqlx::query_scalar!(
        "SELECT email FROM usr WHERE username = $1 AND workspace_id = $2",
        &username,
        &w_id
    )
    .fetch_optional(&db)
    .await?
    .ok_or_else(|| {
        Error::NotFound(format!(
            "user {} not found in workspace {}",
            &username, &w_id
        ))
    })?;
    let preview = get_offboard_preview(&db, &w_id, &username, &email).await?;
    Ok(Json(preview))
}

pub(crate) async fn offboard_workspace_user(
    authed: ApiAuthed,
    Extension(db): Extension<DB>,
    Path((w_id, username)): Path<(String, String)>,
    Json(req): Json<OffboardRequest>,
) -> JsonResult<OffboardResponse> {
    require_admin(authed.is_admin, &authed.username)?;

    let (target_kind, target_name) = parse_reassign_target(&req.reassign_to)?;
    validate_target(&db, &w_id, target_kind, target_name).await?;

    let new_permissioned_as = resolve_new_permissioned_as(
        target_kind,
        &req.reassign_to,
        req.new_on_behalf_of_user.as_deref(),
        &db,
        &w_id,
    )
    .await?;

    let conflicts = check_path_conflicts(&db, &w_id, &username, &req.reassign_to).await?;
    if !conflicts.is_empty() {
        return Ok(Json(OffboardResponse { conflicts, summary: None }));
    }

    let email = sqlx::query_scalar!(
        "SELECT email FROM usr WHERE username = $1 AND workspace_id = $2",
        &username,
        &w_id
    )
    .fetch_optional(&db)
    .await?
    .ok_or_else(|| {
        Error::NotFound(format!(
            "user {} not found in workspace {}",
            &username, &w_id
        ))
    })?;

    let mut tx = db.begin().await?;

    let summary = offboard_user_from_workspace(
        &mut tx,
        &db,
        &w_id,
        &username,
        &email,
        &req.reassign_to,
        &new_permissioned_as,
    )
    .await?;

    if req.delete_user {
        delete_workspace_user_internal(&w_id, &username, &email, &mut tx, Some(&authed)).await?;
    } else {
        audit_log(
            &mut *tx,
            &authed,
            "users.offboard_reassign",
            ActionKind::Update,
            &w_id,
            Some(&username),
            Some([("reassign_to", req.reassign_to.as_str())].into()),
        )
        .await?;
    }

    tx.commit().await?;

    if req.delete_user {
        handle_deployment_metadata(
            &authed.email,
            &authed.username,
            &db,
            &w_id,
            windmill_git_sync::DeployedObject::User { email: email.clone() },
            Some(format!(
                "Offboarded user '{}' from workspace (objects reassigned to {})",
                &email, &req.reassign_to
            )),
            true,
            None,
        )
        .await?;
    }

    Ok(Json(OffboardResponse {
        conflicts: vec![],
        summary: Some(summary),
    }))
}

// ---- Instance-level endpoints ----

pub(crate) async fn global_offboard_preview(
    authed: ApiAuthed,
    Extension(db): Extension<DB>,
    Path(email): Path<String>,
) -> JsonResult<GlobalOffboardPreview> {
    require_super_admin(&db, &authed.email).await?;

    let workspaces = sqlx::query!(
        "SELECT workspace_id, username FROM usr WHERE email = $1",
        &email
    )
    .fetch_all(&db)
    .await?;

    let mut previews = Vec::new();
    for w in workspaces {
        let preview = get_offboard_preview(&db, &w.workspace_id, &w.username, &email).await?;
        previews.push(WorkspaceOffboardPreview {
            workspace_id: w.workspace_id,
            username: w.username,
            preview,
        });
    }

    Ok(Json(GlobalOffboardPreview { workspaces: previews }))
}

pub(crate) async fn offboard_global_user(
    authed: ApiAuthed,
    Extension(db): Extension<DB>,
    Path(email): Path<String>,
    Json(req): Json<GlobalOffboardRequest>,
) -> Result<Json<OffboardResponse>> {
    require_super_admin(&db, &authed.email).await?;

    let workspaces = sqlx::query!(
        "SELECT workspace_id, username FROM usr WHERE email = $1",
        &email
    )
    .fetch_all(&db)
    .await?;

    // Validate all targets and resolve permissioned_as upfront
    let mut resolved_permissioned_as: HashMap<String, String> = HashMap::new();
    for (w_id, reassignment) in &req.reassignments {
        let (target_kind, target_name) = parse_reassign_target(&reassignment.reassign_to)?;
        validate_target(&db, w_id, target_kind, target_name).await?;
        let perm_as = resolve_new_permissioned_as(
            target_kind,
            &reassignment.reassign_to,
            reassignment.new_on_behalf_of_user.as_deref(),
            &db,
            w_id,
        )
        .await?;
        resolved_permissioned_as.insert(w_id.clone(), perm_as);
    }

    // Check for conflicts in all workspaces
    let mut all_conflicts = Vec::new();
    for ws in &workspaces {
        if let Some(reassignment) = req.reassignments.get(&ws.workspace_id) {
            let conflicts = check_path_conflicts(
                &db,
                &ws.workspace_id,
                &ws.username,
                &reassignment.reassign_to,
            )
            .await?;
            for c in conflicts {
                all_conflicts.push(format!("[{}] {}", &ws.workspace_id, c));
            }
        }
    }
    if !all_conflicts.is_empty() {
        return Ok(Json(OffboardResponse {
            conflicts: all_conflicts,
            summary: None,
        }));
    }

    let mut tx = db.begin().await?;

    let mut total_summary = OffboardSummary {
        scripts_reassigned: 0,
        flows_reassigned: 0,
        apps_reassigned: 0,
        resources_reassigned: 0,
        variables_reassigned: 0,
        schedules_reassigned: 0,
        triggers_reassigned: 0,
        drafts_deleted: 0,
    };

    for ws in &workspaces {
        if let Some(reassignment) = req.reassignments.get(&ws.workspace_id) {
            let perm_as = resolved_permissioned_as
                .get(&ws.workspace_id)
                .ok_or_else(|| {
                    Error::InternalErr("missing resolved permissioned_as".to_string())
                })?;
            let ws_summary = offboard_user_from_workspace(
                &mut tx,
                &db,
                &ws.workspace_id,
                &ws.username,
                &email,
                &reassignment.reassign_to,
                perm_as,
            )
            .await?;
            total_summary.scripts_reassigned += ws_summary.scripts_reassigned;
            total_summary.flows_reassigned += ws_summary.flows_reassigned;
            total_summary.apps_reassigned += ws_summary.apps_reassigned;
            total_summary.resources_reassigned += ws_summary.resources_reassigned;
            total_summary.variables_reassigned += ws_summary.variables_reassigned;
            total_summary.schedules_reassigned += ws_summary.schedules_reassigned;
            total_summary.triggers_reassigned += ws_summary.triggers_reassigned;
            total_summary.drafts_deleted += ws_summary.drafts_deleted;
        }

        if req.delete_user {
            delete_workspace_user_internal(&ws.workspace_id, &ws.username, &email, &mut tx, None)
                .await?;
        }
    }

    if req.delete_user {
        sqlx::query!("DELETE FROM token WHERE email = $1", &email)
            .execute(&mut *tx)
            .await?;
        sqlx::query!("DELETE FROM password WHERE email = $1", &email)
            .execute(&mut *tx)
            .await?;
        sqlx::query!("DELETE FROM workspace_invite WHERE email = $1", &email)
            .execute(&mut *tx)
            .await?;
        sqlx::query!("DELETE FROM email_to_igroup WHERE email = $1", &email)
            .execute(&mut *tx)
            .await?;
    }

    audit_log(
        &mut *tx,
        &authed,
        if req.delete_user {
            "users.offboard_delete"
        } else {
            "users.offboard_reassign"
        },
        if req.delete_user {
            ActionKind::Delete
        } else {
            ActionKind::Update
        },
        "global",
        Some(&email),
        None,
    )
    .await?;

    tx.commit().await?;

    Ok(Json(OffboardResponse {
        conflicts: vec![],
        summary: Some(total_summary),
    }))
}

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

fn parse_reassign_target(target: &str) -> Result<(&str, &str)> {
    if let Some(name) = target.strip_prefix("u/") {
        if name.is_empty() {
            return Err(Error::BadRequest(
                "empty username in reassign_to".to_string(),
            ));
        }
        Ok(("user", name))
    } else if let Some(name) = target.strip_prefix("f/") {
        if name.is_empty() {
            return Err(Error::BadRequest("empty folder in reassign_to".to_string()));
        }
        Ok(("folder", name))
    } else {
        Err(Error::BadRequest(
            "reassign_to must start with 'u/' (user) or 'f/' (folder)".to_string(),
        ))
    }
}

async fn validate_target(db: &DB, w_id: &str, kind: &str, name: &str) -> Result<()> {
    match kind {
        "user" => {
            let exists = sqlx::query_scalar!(
                "SELECT EXISTS(SELECT 1 FROM usr WHERE username = $1 AND workspace_id = $2)",
                name,
                w_id
            )
            .fetch_one(db)
            .await?
            .unwrap_or(false);
            if !exists {
                return Err(Error::BadRequest(format!(
                    "target user '{}' not found in workspace '{}'",
                    name, w_id
                )));
            }
        }
        "folder" => {
            let exists = sqlx::query_scalar!(
                "SELECT EXISTS(SELECT 1 FROM folder WHERE name = $1 AND workspace_id = $2)",
                name,
                w_id
            )
            .fetch_one(db)
            .await?
            .unwrap_or(false);
            if !exists {
                return Err(Error::BadRequest(format!(
                    "target folder '{}' not found in workspace '{}'",
                    name, w_id
                )));
            }
        }
        _ => unreachable!(),
    }
    Ok(())
}

/// Resolve the permissioned_as value for schedules/triggers.
/// If new_on_behalf_of_user is provided, use that user.
/// Otherwise, default to the target user (for user targets) or error (for folder targets).
async fn resolve_new_permissioned_as(
    target_kind: &str,
    reassign_to: &str,
    new_on_behalf_of_user: Option<&str>,
    db: &DB,
    w_id: &str,
) -> Result<String> {
    let username = if let Some(user) = new_on_behalf_of_user {
        user.to_string()
    } else {
        match target_kind {
            "user" => reassign_to
                .strip_prefix("u/")
                .unwrap_or(reassign_to)
                .to_string(),
            "folder" => {
                return Err(Error::BadRequest(
                    "new_on_behalf_of_user is required when reassigning to a folder".to_string(),
                ));
            }
            _ => unreachable!(),
        }
    };
    let exists = sqlx::query_scalar!(
        "SELECT EXISTS(SELECT 1 FROM usr WHERE username = $1 AND workspace_id = $2)",
        &username,
        w_id
    )
    .fetch_one(db)
    .await?
    .unwrap_or(false);
    if !exists {
        return Err(Error::NotFound(format!(
            "new_on_behalf_of_user '{}' not found in workspace '{}'",
            username, w_id
        )));
    }
    Ok(format!("u/{}", username))
}

async fn check_path_conflicts(
    db: &DB,
    w_id: &str,
    username: &str,
    reassign_to: &str,
) -> Result<Vec<String>> {
    let new_prefix = format!("{}/", reassign_to);
    let mut conflicts = Vec::new();

    let tables = [
        "script",
        "flow",
        "app",
        "resource",
        "variable",
        "schedule",
        "http_trigger",
        "websocket_trigger",
        "kafka_trigger",
        "postgres_trigger",
        "mqtt_trigger",
        "nats_trigger",
        "sqs_trigger",
        "gcp_trigger",
        "azure_trigger",
        "email_trigger",
    ];

    for table_name in &tables {
        let extra_filter = match *table_name {
            "script" => " AND NOT t1.archived AND NOT t1.deleted",
            "flow" => " AND NOT t1.archived",
            _ => "",
        };
        // SAFETY: `table_name` comes from a hardcoded allowlist `tables`, not user input.
        let rows: Vec<String> = sqlx::query_scalar(&format!(
            "SELECT REGEXP_REPLACE(t1.path, '^u/' || $1 || '/', $3) \
             FROM {table} t1 \
             WHERE t1.path LIKE ('u/' || $1 || '/%') AND t1.workspace_id = $2{extra_filter} \
             AND EXISTS ( \
                 SELECT 1 FROM {table} t2 \
                 WHERE t2.path = REGEXP_REPLACE(t1.path, '^u/' || $1 || '/', $3) \
                 AND t2.workspace_id = $2 \
             )",
            table = table_name
        ))
        .bind(username)
        .bind(w_id)
        .bind(&new_prefix)
        .fetch_all(db)
        .await?;

        for path in rows {
            conflicts.push(format!("{}: {}", table_name, path));
        }
    }

    Ok(conflicts)
}

// ---- Core reassignment logic ----

async fn offboard_user_from_workspace<'c>(
    tx: &mut sqlx::Transaction<'c, sqlx::Postgres>,
    db: &DB,
    w_id: &str,
    username: &str,
    email: &str,
    reassign_to: &str,
    new_permissioned_as: &str,
) -> Result<OffboardSummary> {
    let new_prefix = reassign_to.to_string();

    // Resolve the new operator's email for on_behalf_of_email on scripts/flows/apps.
    // resolve_new_permissioned_as already validated the user exists, and usr.email is NOT NULL.
    let new_on_behalf_of_user_username = new_permissioned_as
        .strip_prefix("u/")
        .unwrap_or(new_permissioned_as);
    let new_on_behalf_of_user_email = sqlx::query_scalar!(
        "SELECT email FROM usr WHERE username = $1 AND workspace_id = $2",
        new_on_behalf_of_user_username,
        w_id
    )
    .fetch_optional(&mut **tx)
    .await?
    .ok_or_else(|| {
        Error::NotFound(format!(
            "new on_behalf_of user '{}' not found in workspace '{}'",
            new_on_behalf_of_user_username, w_id
        ))
    })?;

    // ---- scripts ----
    let scripts_reassigned = sqlx::query_scalar!(
        r#"WITH updated AS (
            UPDATE script SET path = REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1')
            WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3
            RETURNING 1
        ) SELECT COUNT(*) FROM updated"#,
        &new_prefix,
        username,
        w_id
    )
    .fetch_one(&mut **tx)
    .await?
    .unwrap_or(0);

    sqlx::query!(
        "UPDATE script SET on_behalf_of_email = $1 WHERE on_behalf_of_email = $2 AND workspace_id = $3",
        new_on_behalf_of_user_email,
        email,
        w_id
    )
    .execute(&mut **tx)
    .await?;

    // ---- flows ----
    let flows_reassigned = sqlx::query_scalar!(
        r#"WITH inserted AS (
            INSERT INTO flow
                (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs)
            SELECT workspace_id, REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs
                FROM flow
                WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3
            RETURNING 1
        ) SELECT COUNT(*) FROM inserted"#,
        &new_prefix, username, w_id
    ).fetch_one(&mut **tx).await?.unwrap_or(0);

    sqlx::query!(
        r#"UPDATE flow_version SET path = REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#,
        &new_prefix, username, w_id
    ).execute(&mut **tx).await?;

    sqlx::query!(
        r#"UPDATE flow_node SET path = REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#,
        &new_prefix, username, w_id
    ).execute(&mut **tx).await?;

    sqlx::query!(
        "DELETE FROM flow WHERE path LIKE ('u/' || $1 || '/%') AND workspace_id = $2",
        username,
        w_id
    )
    .execute(&mut **tx)
    .await?;

    sqlx::query!(
        "UPDATE flow SET on_behalf_of_email = $1 WHERE on_behalf_of_email = $2 AND workspace_id = $3",
        new_on_behalf_of_user_email,
        email,
        w_id
    )
    .execute(&mut **tx)
    .await?;

    // ---- apps ----
    let apps_reassigned = sqlx::query_scalar!(
        r#"WITH updated AS (
            UPDATE app SET path = REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1')
            WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3
            RETURNING 1
        ) SELECT COUNT(*) FROM updated"#,
        &new_prefix,
        username,
        w_id
    )
    .fetch_one(&mut **tx)
    .await?
    .unwrap_or(0);

    sqlx::query!(
        "UPDATE app SET policy = jsonb_set(
            jsonb_set(policy, ARRAY['on_behalf_of'], to_jsonb($1::text)),
            ARRAY['on_behalf_of_email'], to_jsonb($4::text)
        ) WHERE policy->>'on_behalf_of' = ('u/' || $2) AND workspace_id = $3",
        &new_permissioned_as,
        username,
        w_id,
        new_on_behalf_of_user_email
    )
    .execute(&mut **tx)
    .await?;

    // ---- raw_app (mirrors app paths) ----
    sqlx::query!(
        r#"UPDATE raw_app SET path = REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1')
        WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#,
        &new_prefix,
        username,
        w_id
    )
    .execute(&mut **tx)
    .await?;

    // ---- resources ----
    let resources_reassigned = sqlx::query_scalar!(
        r#"WITH updated AS (
            UPDATE resource SET path = REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1')
            WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3
            RETURNING 1
        ) SELECT COUNT(*) FROM updated"#,
        &new_prefix,
        username,
        w_id
    )
    .fetch_one(&mut **tx)
    .await?
    .unwrap_or(0);

    // ---- variables (with Vault secret handling) ----
    let old_var_prefix = format!("u/{}/", username);
    let new_var_prefix = format!("{}/", reassign_to);

    let vault_secrets: Vec<(String, String)> = sqlx::query!(
        r#"SELECT path, value FROM variable
           WHERE path LIKE ('u/' || $1 || '/%')
           AND workspace_id = $2
           AND is_secret = true
           AND (value LIKE '$vault:%' OR value LIKE '$azure_kv:%')"#,
        username,
        w_id
    )
    .fetch_all(&mut **tx)
    .await?
    .into_iter()
    .map(|r| (r.path, r.value))
    .collect();

    let vault_updates =
        rename_vault_secrets_with_prefix(db, w_id, &old_var_prefix, &new_var_prefix, vault_secrets)
            .await?;

    for (old_path, new_value) in vault_updates {
        sqlx::query!(
            "UPDATE variable SET value = $1 WHERE path = $2 AND workspace_id = $3",
            new_value,
            old_path,
            w_id
        )
        .execute(&mut **tx)
        .await?;
    }

    let variables_reassigned = sqlx::query_scalar!(
        r#"WITH updated AS (
            UPDATE variable SET path = REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1')
            WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3
            RETURNING 1
        ) SELECT COUNT(*) FROM updated"#,
        &new_prefix,
        username,
        w_id
    )
    .fetch_one(&mut **tx)
    .await?
    .unwrap_or(0);

    // ---- schedules ----
    let schedules_reassigned = sqlx::query_scalar!(
        r#"WITH updated AS (
            UPDATE schedule SET
                path = REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1'),
                script_path = REGEXP_REPLACE(script_path, 'u/' || $2 || '/(.*)', $1 || '/\1'),
                permissioned_as = $4
            WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3
            RETURNING 1
        ) SELECT COUNT(*) FROM updated"#,
        &new_prefix,
        username,
        w_id,
        &new_permissioned_as
    )
    .fetch_one(&mut **tx)
    .await?
    .unwrap_or(0);

    sqlx::query!(
        r#"UPDATE schedule SET script_path = REGEXP_REPLACE(script_path, 'u/' || $2 || '/(.*)', $1 || '/\1')
        WHERE script_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#,
        &new_prefix, username, w_id
    ).execute(&mut **tx).await?;

    sqlx::query!(
        "UPDATE schedule SET permissioned_as = $1 WHERE permissioned_as = ('u/' || $2) AND workspace_id = $3",
        &new_permissioned_as, username, w_id
    ).execute(&mut **tx).await?;

    // ---- triggers (all 9 types with path/permissioned_as) ----
    let trigger_tables = [
        "http_trigger",
        "websocket_trigger",
        "kafka_trigger",
        "postgres_trigger",
        "mqtt_trigger",
        "nats_trigger",
        "sqs_trigger",
        "gcp_trigger",
        "azure_trigger",
        "email_trigger",
    ];

    let mut triggers_reassigned: i64 = 0;
    // SAFETY: `table` comes from a hardcoded allowlist `trigger_tables`, not user input.
    for table in &trigger_tables {
        let count: i64 = sqlx::query_scalar(&format!(
            "WITH updated AS ( \
                UPDATE {table} SET \
                    path = REGEXP_REPLACE(path, 'u/' || $1 || '/(.*)', $2 || '/\\1'), \
                    script_path = REGEXP_REPLACE(script_path, 'u/' || $1 || '/(.*)', $2 || '/\\1'), \
                    permissioned_as = $4 \
                WHERE path LIKE ('u/' || $1 || '/%') AND workspace_id = $3 \
                RETURNING 1 \
            ) SELECT COUNT(*) FROM updated"
        ))
        .bind(username)
        .bind(&new_prefix)
        .bind(w_id)
        .bind(&new_permissioned_as)
        .fetch_one(&mut **tx)
        .await?;
        triggers_reassigned += count;

        sqlx::query(&format!(
            "UPDATE {table} SET script_path = REGEXP_REPLACE(script_path, 'u/' || $1 || '/(.*)', $2 || '/\\1') \
             WHERE script_path LIKE ('u/' || $1 || '/%') AND workspace_id = $3"
        ))
        .bind(username)
        .bind(&new_prefix)
        .bind(w_id)
        .execute(&mut **tx)
        .await?;

        sqlx::query(&format!(
            "UPDATE {table} SET permissioned_as = $1 WHERE permissioned_as = ('u/' || $2) AND workspace_id = $3"
        ))
        .bind(&new_permissioned_as)
        .bind(username)
        .bind(w_id)
        .execute(&mut **tx)
        .await?;
    }

    // NOTE: extra_perms cleanup, folder owners, favorites, inputs, captures,
    // and token deletion are handled by delete_workspace_user_internal when delete_user=true.
    // They are NOT done here for reassign-only (delete_user=false) since the user stays.

    // ---- Related path tables ----
    sqlx::query!(
        r#"UPDATE workspace_integrations SET resource_path = REGEXP_REPLACE(resource_path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE resource_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#,
        &new_prefix, username, w_id
    ).execute(&mut **tx).await?;

    sqlx::query!(
        r#"UPDATE workspace_runnable_dependencies SET flow_path = REGEXP_REPLACE(flow_path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE flow_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#,
        &new_prefix, username, w_id
    ).execute(&mut **tx).await?;

    sqlx::query!(
        r#"UPDATE workspace_runnable_dependencies SET app_path = REGEXP_REPLACE(app_path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE app_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#,
        &new_prefix, username, w_id
    ).execute(&mut **tx).await?;

    sqlx::query!(
        r#"UPDATE workspace_runnable_dependencies SET runnable_path = REGEXP_REPLACE(runnable_path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE runnable_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#,
        &new_prefix, username, w_id
    ).execute(&mut **tx).await?;

    sqlx::query!(
        r#"UPDATE asset SET usage_path = REGEXP_REPLACE(usage_path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE usage_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#,
        &new_prefix, username, w_id
    ).execute(&mut **tx).await?;

    // ---- dependency_map ----
    sqlx::query!(
        r#"UPDATE dependency_map SET importer_path = REGEXP_REPLACE(importer_path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE importer_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#,
        &new_prefix, username, w_id
    ).execute(&mut **tx).await?;

    sqlx::query!(
        r#"UPDATE dependency_map SET imported_path = REGEXP_REPLACE(imported_path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE imported_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#,
        &new_prefix, username, w_id
    ).execute(&mut **tx).await?;

    // ---- Drafts (stale after path reassignment) ----
    let drafts_deleted = sqlx::query_scalar!(
        r#"WITH deleted AS (
            DELETE FROM draft WHERE path LIKE ('u/' || $1 || '/%') AND workspace_id = $2
            RETURNING 1
        ) SELECT COUNT(*) FROM deleted"#,
        username,
        w_id
    )
    .fetch_one(&mut **tx)
    .await?
    .unwrap_or(0);

    Ok(OffboardSummary {
        scripts_reassigned,
        flows_reassigned,
        apps_reassigned,
        resources_reassigned,
        variables_reassigned,
        schedules_reassigned,
        triggers_reassigned,
        drafts_deleted,
    })
}
