// SPDX-FileCopyrightText: 2026 Ololduck <ololduck@vit.am> and contributors
//
// SPDX-License-Identifier: GPL-3.0-or-later

//! Moderation operations: user deletion with full cascade and grace-period
//! scheduling, plus defederation enforcement helpers.
//!
//! Handles the complex task of deleting a user and all their associated data,
//! including files on disk and bare git repositories. Deletion is scheduled
//! with a grace period to allow federation of Delete(Person) activities before
//! the user's actor is removed.
//!
//! Also provides helpers for checking instance-wide domain blocks and
//! admin-level remote actor blocks to enforce defederation.

use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;

use chrono::Utc;
use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set};
use tokio::sync::RwLock;
use tracing::{error, info, instrument, warn};
use uuid::Uuid;

use crate::activitypub::ApUrls;
use crate::attachments::file_store::FileStore;
use crate::entities::{
    admin_remote_blocks, ap_remote_actors, attachments, comments, instance_blocks,
    pending_deletions, repositories, tickets, user_blocks, user_silences, users,
};

/// Poll interval for the background deletion worker (in seconds).
const DELETION_WORKER_POLL_INTERVAL_SECS: u64 = 60;

/// Schedule a user for deletion after a grace period.
///
/// This function:
/// 1. Emits a Delete(Person) activity to all followers (if federation is
///    enabled)
/// 2. Inserts a pending_deletions row with execute_at = now + grace_period
///
/// The actual deletion happens later via the background deletion worker.
/// During the grace period, the user's actor and public key remain queryable
/// so remote servers can verify the Delete signature.
///
/// # Arguments
/// * `db` - Database connection
/// * `public_url` - Public URL for federation (empty if federation disabled)
/// * `user_id` - ID of the user to schedule for deletion
/// * `grace_period_seconds` - How long to wait before executing the deletion
///
/// # Returns
/// Ok(()) on success, or an error if scheduling fails.
#[instrument(skip(db), fields(user_id, grace_period_seconds))]
pub async fn schedule_user_deletion(
    db: &DatabaseConnection,
    public_url: &str,
    user_id: i64,
    grace_period_seconds: u64,
) -> Result<(), ScheduleDeletionError> {
    info!("Scheduling user deletion with grace period");

    // Check if user exists
    let user = users::Entity::find_by_id(user_id)
        .one(db)
        .await
        .map_err(ScheduleDeletionError::Database)?
        .ok_or(ScheduleDeletionError::UserNotFound)?;

    // Try to emit Delete(Person) activity if federation is enabled
    if !public_url.is_empty()
        && let Err(e) = emit_delete_activity(db, public_url, user_id, &user.name).await
    {
        warn!(
            user_id = user_id,
            error = %e,
            "Failed to emit Delete(Person) activity, proceeding with deletion scheduling"
        );
    }

    // Schedule the deletion
    let now = Utc::now();
    let execute_at = now + chrono::Duration::seconds(grace_period_seconds as i64);

    let pending = pending_deletions::ActiveModel {
        user_id: Set(user_id),
        scheduled_at: Set(now.into()),
        execute_at: Set(execute_at.into()),
        ..Default::default()
    };

    pending
        .insert(db)
        .await
        .map_err(ScheduleDeletionError::Database)?;

    info!(
        user_id = user_id,
        execute_at = %execute_at,
        "User deletion scheduled"
    );

    Ok(())
}

/// Emit a Delete(Person) activity for the user.
///
/// This creates a Delete activity and enqueues deliveries to all followers.
/// The activity is signed by the user's actor key.
async fn emit_delete_activity(
    db: &DatabaseConnection,
    public_url: &str,
    user_id: i64,
    user_name: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    use crate::activitypub::actor::get_or_create;
    use crate::activitypub::forge::activity::publish_activity;

    // Get or create the user's actor
    let actor = get_or_create(db, user_id).await?;
    let urls = ApUrls::new(public_url);
    let actor_url = urls.actor(user_name);

    // Build the Delete activity JSON
    let activity_id = format!("{}#delete-{}", actor_url, Uuid::new_v4());
    let delete_activity = serde_json::json!({
        "@context": "https://www.w3.org/ns/activitystreams",
        "id": activity_id,
        "type": "Delete",
        "actor": actor_url,
        "to": ["https://www.w3.org/ns/activitystreams#Public"],
        "object": actor_url
    });

    let activity_json = serde_json::to_string(&delete_activity)?;

    // Publish the activity (enqueues deliveries to followers)
    publish_activity(db, actor.id, &activity_json, &actor_url).await?;

    info!(user_id = user_id, actor_url = %actor_url, "Emitted Delete(Person) activity");

    Ok(())
}

/// Delete a user and all their associated data.
///
/// This performs a full cascade deletion:
/// 1. Delete attachment files from disk via FileStore
/// 2. Let SQLite's CASCADE DELETE handle DB rows (most FKs are CASCADE)
/// 3. Remove bare git repository directories for all user-owned repos
///
/// This function is called by the background deletion worker after the grace
/// period has elapsed.
///
/// # Arguments
/// * `db` - Database connection
/// * `file_store` - File store for deleting attachment files
/// * `repo_root` - Root directory for bare git repositories
/// * `user_id` - ID of the user to delete
///
/// # Returns
/// Ok(()) on success, or an error if a critical operation fails.
#[instrument(skip(db, file_store), fields(user_id))]
pub async fn delete_user(
    db: &DatabaseConnection,
    file_store: &FileStore,
    repo_root: &PathBuf,
    user_id: i64,
) -> Result<(), DeleteUserError> {
    info!("Starting user deletion cascade");

    // Step 1: Get user info before deletion (need name for repo cleanup)
    let user = users::Entity::find_by_id(user_id)
        .one(db)
        .await
        .map_err(DeleteUserError::Database)?
        .ok_or(DeleteUserError::UserNotFound)?;

    let user_name = user.name.clone();

    // Step 2: Delete attachment files from disk
    delete_user_attachment_files(db, file_store, user_id).await?;

    // Step 3: Get list of user's repositories before deletion
    let user_repos = repositories::Entity::find()
        .filter(repositories::Column::OwnerId.eq(user_id))
        .all(db)
        .await
        .map_err(DeleteUserError::Database)?;

    // Step 4: Delete the user - SQLite CASCADE DELETE will handle most rows
    users::Entity::delete_by_id(user_id)
        .exec(db)
        .await
        .map_err(DeleteUserError::Database)?;

    info!("User deleted from database, cleaning up filesystem");

    // Step 5: Remove bare git repository directories
    cleanup_user_repos(repo_root, &user_name, &user_repos).await;

    info!("User deletion cascade complete");
    Ok(())
}

/// Delete all attachment files owned by a user from disk.
async fn delete_user_attachment_files(
    db: &DatabaseConnection,
    file_store: &FileStore,
    user_id: i64,
) -> Result<(), DeleteUserError> {
    let repos = repositories::Entity::find()
        .filter(repositories::Column::OwnerId.eq(user_id))
        .all(db)
        .await
        .map_err(DeleteUserError::Database)?;

    let repo_ids: Vec<i64> = repos.iter().map(|r| r.id).collect();

    let user_comments = comments::Entity::find()
        .filter(comments::Column::AuthorId.eq(user_id))
        .all(db)
        .await
        .map_err(DeleteUserError::Database)?;
    let comment_ids: Vec<i64> = user_comments.iter().map(|c| c.id).collect();

    let comment_attachments = attachments::Entity::find()
        .filter(attachments::Column::CommentId.is_in(comment_ids.clone()))
        .all(db)
        .await
        .map_err(DeleteUserError::Database)?;

    let repo_tickets = tickets::Entity::find()
        .filter(tickets::Column::RepositoryId.is_in(repo_ids.clone()))
        .all(db)
        .await
        .map_err(DeleteUserError::Database)?;
    let ticket_ids: Vec<i64> = repo_tickets.iter().map(|t| t.id).collect();

    let ticket_attachments = attachments::Entity::find()
        .filter(attachments::Column::TicketId.is_in(ticket_ids.clone()))
        .all(db)
        .await
        .map_err(DeleteUserError::Database)?;

    let all_attachments: Vec<_> = comment_attachments
        .into_iter()
        .chain(ticket_attachments)
        .collect();

    for attachment in all_attachments {
        if let Err(e) = file_store
            .delete(attachment.uuid, &attachment.content_type)
            .await
        {
            warn!(
                attachment_uuid = %attachment.uuid,
                error = %e,
                "Failed to delete attachment file, continuing with deletion"
            );
        }
    }

    Ok(())
}

/// Remove bare git repository directories for a deleted user.
async fn cleanup_user_repos(repo_root: &Path, user_name: &str, repos: &[repositories::Model]) {
    let user_repo_dir = repo_root.join(user_name);

    if user_repo_dir.exists() {
        match tokio::fs::remove_dir_all(&user_repo_dir).await {
            Ok(_) => {
                info!(
                    user = %user_name,
                    repo_count = repos.len(),
                    "Removed user's bare git repository directory"
                );
            }
            Err(e) => {
                error!(
                    user = %user_name,
                    path = %user_repo_dir.display(),
                    error = %e,
                    "Failed to remove user's bare git repository directory"
                );
            }
        }
    }
}

/// Process pending deletions that are ready to execute.
///
/// This function is called by the background deletion worker. It queries
/// pending_deletions where execute_at <= now, executes the deletion for each,
/// and removes the pending_deletions row.
pub async fn process_pending_deletions(
    db: &DatabaseConnection,
    file_store: &FileStore,
    repo_root: &PathBuf,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    let now = Utc::now();

    let pending = pending_deletions::Entity::find()
        .filter(pending_deletions::Column::ExecuteAt.lte(now))
        .all(db)
        .await?;

    for deletion in pending {
        info!(
            pending_deletion_id = deletion.id,
            user_id = deletion.user_id,
            "Processing pending deletion"
        );

        match delete_user(db, file_store, repo_root, deletion.user_id).await {
            Ok(_) => {
                info!(
                    pending_deletion_id = deletion.id,
                    user_id = deletion.user_id,
                    "Deletion completed successfully"
                );
            }
            Err(e) => {
                error!(
                    pending_deletion_id = deletion.id,
                    user_id = deletion.user_id,
                    error = %e,
                    "Deletion failed"
                );
            }
        }

        pending_deletions::Entity::delete_by_id(deletion.id)
            .exec(db)
            .await?;
    }

    Ok(())
}

/// Spawn the background deletion worker.
///
/// This worker polls pending_deletions every DELETION_WORKER_POLL_INTERVAL_SECS
/// and executes deletions that are ready (execute_at <= now).
pub fn spawn_deletion_worker(state: Arc<RwLock<crate::AppState>>) -> tokio::task::JoinHandle<()> {
    tokio::spawn(async move {
        loop {
            tokio::time::sleep(Duration::from_secs(DELETION_WORKER_POLL_INTERVAL_SECS)).await;

            let state_guard = state.read().await;
            let db = state_guard.db.clone();
            let file_store = state_guard.file_store.clone();
            let repo_root = state_guard.repo_root.clone();
            drop(state_guard);

            if let Err(e) = process_pending_deletions(&db, &file_store, &repo_root).await {
                error!(error = %e, "deletion worker error");
            }
        }
    })
}

/// Normalize a user-supplied domain or URL to a lowercase host.
///
/// Accepts forms like `Example.ORG`, `https://example.org/users/alice`,
/// `example.org:8443/path`, or `example.org?x=1`. The scheme, port, path,
/// query, and fragment are dropped: instance blocks are host-based, matching
/// what [`is_domain_blocked`] extracts from actor URLs.
///
/// Returns `None` for empty or unparseable input.
pub fn normalize_domain(input: &str) -> Option<String> {
    let trimmed = input.trim();
    if trimmed.is_empty() {
        return None;
    }

    let candidate = if trimmed.contains("://") {
        trimmed.to_string()
    } else {
        format!("http://{trimmed}")
    };

    url::Url::parse(&candidate)
        .ok()
        .and_then(|parsed| parsed.host_str().map(str::to_lowercase))
}

/// Check if a domain is blocked instance-wide.
///
/// The input can be either a full URL or just a domain name. It is normalized
/// to a lowercase host before comparing against the instance_blocks table.
pub async fn is_domain_blocked(
    db: &DatabaseConnection,
    url_or_domain: &str,
) -> Result<bool, sea_orm::DbErr> {
    let Some(domain) = normalize_domain(url_or_domain) else {
        return Ok(false);
    };

    let blocked = instance_blocks::Entity::find()
        .filter(instance_blocks::Column::Domain.eq(domain))
        .one(db)
        .await?;

    Ok(blocked.is_some())
}

/// Check if a specific remote actor is blocked by an admin.
///
/// Looks up the actor in ap_remote_actors by their actor_url, then checks
/// if there's a corresponding entry in admin_remote_blocks. Returns false
/// if the actor is not cached or not blocked.
pub async fn is_remote_actor_blocked(
    db: &DatabaseConnection,
    actor_url: &str,
) -> Result<bool, sea_orm::DbErr> {
    let actor = ap_remote_actors::Entity::find()
        .filter(ap_remote_actors::Column::ActorUrl.eq(actor_url))
        .one(db)
        .await?;

    let Some(actor) = actor else {
        return Ok(false);
    };

    let blocked = admin_remote_blocks::Entity::find()
        .filter(admin_remote_blocks::Column::RemoteActorId.eq(actor.id))
        .one(db)
        .await?;

    Ok(blocked.is_some())
}

/// Combined check: is federation refused for this actor?
///
/// Returns true if either:
/// - The actor's domain is blocked instance-wide, OR
/// - The specific actor is blocked by an admin
///
/// This is the primary entry point for defederation enforcement checks.
pub async fn is_federation_refused(
    db: &DatabaseConnection,
    actor_url: &str,
) -> Result<bool, sea_orm::DbErr> {
    if is_domain_blocked(db, actor_url).await? {
        return Ok(true);
    }

    is_remote_actor_blocked(db, actor_url).await
}

/// Remote actor IDs whose content must be hidden from a viewer.
///
/// Includes admin-blocked actors and actors on instance-blocked domains for
/// every viewer, plus the viewer's own blocked and silenced actors when a
/// viewer ID is supplied. Used by feeds and reaction counts so defederation
/// hides content regardless of who is looking.
pub async fn excluded_remote_actor_ids(
    db: &DatabaseConnection,
    viewer_id: Option<i64>,
) -> Result<std::collections::HashSet<i64>, sea_orm::DbErr> {
    let mut excluded = std::collections::HashSet::new();

    for block in admin_remote_blocks::Entity::find().all(db).await? {
        excluded.insert(block.remote_actor_id);
    }

    let blocked_domains: Vec<String> = instance_blocks::Entity::find()
        .all(db)
        .await?
        .into_iter()
        .filter_map(|block| normalize_domain(&block.domain))
        .collect();

    if !blocked_domains.is_empty() {
        for actor in ap_remote_actors::Entity::find().all(db).await? {
            let blocked = normalize_domain(&actor.actor_url)
                .is_some_and(|domain| blocked_domains.contains(&domain));
            if blocked {
                excluded.insert(actor.id);
            }
        }
    }

    if let Some(viewer_id) = viewer_id {
        for block in user_blocks::Entity::find()
            .filter(user_blocks::Column::BlockerId.eq(viewer_id))
            .all(db)
            .await?
        {
            if let Some(remote_actor_id) = block.blocked_remote_actor_id {
                excluded.insert(remote_actor_id);
            }
        }

        for silence in user_silences::Entity::find()
            .filter(user_silences::Column::BlockerId.eq(viewer_id))
            .all(db)
            .await?
        {
            if let Some(remote_actor_id) = silence.blocked_remote_actor_id {
                excluded.insert(remote_actor_id);
            }
        }
    }

    Ok(excluded)
}

/// Errors that can occur when scheduling user deletion.
#[derive(Debug, thiserror::Error)]
pub enum ScheduleDeletionError {
    #[error("User not found")]
    UserNotFound,

    #[error("Database error: {0}")]
    Database(#[from] sea_orm::DbErr),
}

/// Errors that can occur during user deletion.
#[derive(Debug, thiserror::Error)]
pub enum DeleteUserError {
    #[error("User not found")]
    UserNotFound,

    #[error("Database error: {0}")]
    Database(#[from] sea_orm::DbErr),
}

#[cfg(test)]
mod tests {
    use super::normalize_domain;

    #[test]
    fn scheme_is_stripped() {
        assert_eq!(
            normalize_domain("https://example.org/users/alice"),
            Some("example.org".to_string())
        );
        assert_eq!(
            normalize_domain("http://example.org"),
            Some("example.org".to_string())
        );
    }

    #[test]
    fn port_is_stripped() {
        assert_eq!(
            normalize_domain("example.org:8443"),
            Some("example.org".to_string())
        );
        assert_eq!(
            normalize_domain("http://127.0.0.1:8081/~bob"),
            Some("127.0.0.1".to_string())
        );
    }

    #[test]
    fn case_is_lowered() {
        assert_eq!(
            normalize_domain("Example.ORG"),
            Some("example.org".to_string())
        );
        assert_eq!(
            normalize_domain("HTTPS://EXAMPLE.ORG/"),
            Some("example.org".to_string())
        );
    }

    #[test]
    fn path_and_query_are_ignored() {
        assert_eq!(
            normalize_domain("example.org/path?x=1#fragment"),
            Some("example.org".to_string())
        );
    }

    #[test]
    fn whitespace_is_trimmed() {
        assert_eq!(
            normalize_domain("  example.org  "),
            Some("example.org".to_string())
        );
    }

    #[test]
    fn empty_or_unparseable_input_is_rejected() {
        assert_eq!(normalize_domain(""), None);
        assert_eq!(normalize_domain("   "), None);
        assert_eq!(normalize_domain("http://"), None);
        assert_eq!(normalize_domain("not a domain"), None);
    }
}
