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

//! ActivityPub note publishing.
//!
//! Handles creating notes from markdown input, rendering them to HTML,
//! and creating the corresponding Create activities.

use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set};
use uuid::Uuid;

use crate::activitypub::types::{CONTEXT, Create, Note};
use crate::entities::prelude::{ApActors, ApNotes};
use crate::entities::{ap_activities, ap_actors, ap_notes};

/// Publishes a new note from markdown content.
///
/// Renders the markdown to HTML, stores the note, and creates a Create
/// activity.
pub async fn publish(
    db: &DatabaseConnection,
    actor_id: i64,
    actor_url: &str,
    public_url: &str,
    content_md: &str,
    user_name: &str,
    file_store: &crate::attachments::file_store::FileStore,
    attachments: Vec<(String, String, Vec<u8>)>,
) -> Result<ap_notes::Model, sea_orm::DbErr> {
    // Render markdown to HTML
    let content_html = crate::markdown::render(content_md);

    // Collect mentions and hashtags from the markdown source.
    let (mention_requests, hashtags) = crate::mentions::parse(content_md);
    let resolved_mentions = crate::mentions::resolve(db, public_url, &mention_requests).await;
    let tag = crate::mentions::tags(&resolved_mentions, &hashtags, public_url);
    let tag = if tag.is_empty() { None } else { Some(tag) };

    // Generate UUID for the note
    let note_uuid = Uuid::new_v4();
    let note_url = format!("{}/notes/{}", actor_url, note_uuid);

    // Insert the note
    let note = ap_notes::ActiveModel {
        uuid: Set(note_uuid),
        actor_id: Set(actor_id),
        content_md: Set(content_md.to_string()),
        content_html: Set(content_html.clone()),
        ..Default::default()
    };
    let note = note.insert(db).await?;

    // Save attachments and build ActivityPub attachment list
    let mut ap_attachments: Vec<crate::activitypub::types::Attachment> = Vec::new();
    for (filename, content_type, data) in attachments {
        if let Ok(model) = crate::attachments::store::create(
            db,
            file_store,
            crate::attachments::store::Owner::ApNote(note.id),
            filename.clone(),
            content_type.clone(),
            data,
        )
        .await
        {
            let url = format!(
                "{}/~{}/notes/{}/attachments/{}",
                public_url, user_name, note.uuid, model.uuid
            );
            ap_attachments.push(crate::activitypub::types::Attachment {
                kind: "Document".to_string(),
                media_type: Some(content_type),
                url,
                name: Some(filename),
            });
        }
    }
    let attachment_field = if ap_attachments.is_empty() {
        None
    } else {
        Some(ap_attachments)
    };

    // Build the Note object
    let mut cc = Vec::new();
    crate::mentions::add_mention_targets(&mut cc, &resolved_mentions);
    let note_obj = Note {
        context: CONTEXT.to_vec(),
        id: note_url.clone(),
        kind: "Note",
        attributed_to: actor_url.to_string(),
        content: content_html,
        media_type: None,
        url: note_url.clone(),
        in_reply_to: None,
        source: None,
        attachment: attachment_field.clone(),
        tag,
        published: chrono::Utc::now().to_rfc3339(),
        updated: None,
        to: vec!["https://www.w3.org/ns/activitystreams#Public".to_string()],
        cc,
    };

    // Build the Create activity
    let activity_uuid = Uuid::new_v4();
    let activity_url = format!("{}/activities/{}", actor_url, activity_uuid);

    let create_activity = Create {
        context: CONTEXT.to_vec(),
        id: activity_url,
        kind: "Create",
        actor: actor_url.to_string(),
        object: note_obj,
        published: chrono::Utc::now().to_rfc3339(),
        to: vec!["https://www.w3.org/ns/activitystreams#Public".to_string()],
        cc: vec![],
    };

    // Serialize the activity
    let activity_json = serde_json::to_string(&create_activity)
        .map_err(|e| sea_orm::DbErr::Custom(e.to_string()))?;

    // Insert the activity
    let activity = ap_activities::ActiveModel {
        uuid: Set(activity_uuid),
        actor_id: Set(actor_id),
        activity_json: Set(activity_json),
        object_url: Set(note_url),
        ..Default::default()
    };
    let activity = activity.insert(db).await?;

    // Fan out to followers
    deliver_to_followers(db, actor_id, activity.id).await?;

    Ok(note)
}

/// Updates an existing note with new markdown content.
///
/// Renders the markdown to HTML, updates the note, and creates an Update
/// activity for federation.
pub async fn update(
    db: &DatabaseConnection,
    note_id: i64,
    actor_id: i64,
    actor_url: &str,
    public_url: &str,
    content_md: &str,
    user_name: &str,
    file_store: &crate::attachments::file_store::FileStore,
    new_attachments: Vec<(String, String, Vec<u8>)>,
) -> Result<ap_notes::Model, sea_orm::DbErr> {
    use sea_orm::ActiveModelTrait;

    // Find the existing note
    let note = ApNotes::find_by_id(note_id)
        .one(db)
        .await?
        .ok_or_else(|| sea_orm::DbErr::RecordNotFound("note not found".to_string()))?;

    // Verify ownership
    if note.actor_id != actor_id {
        return Err(sea_orm::DbErr::Custom(
            "not authorized to update this note".to_string(),
        ));
    }

    // Render markdown to HTML
    let content_html = crate::markdown::render(content_md);

    // Collect mentions and hashtags from the markdown source.
    let (mention_requests, hashtags) = crate::mentions::parse(content_md);
    let resolved_mentions = crate::mentions::resolve(db, public_url, &mention_requests).await;
    let tag = crate::mentions::tags(&resolved_mentions, &hashtags, public_url);
    let tag = if tag.is_empty() { None } else { Some(tag) };

    // Update the note
    let now = chrono::Utc::now();
    let mut active_note: ap_notes::ActiveModel = note.clone().into();
    active_note.content_md = Set(content_md.to_string());
    active_note.content_html = Set(content_html.clone());
    active_note.updated_at = Set(Some(now.into()));
    let note = active_note.update(db).await?;

    // Handle attachments: delete existing and add new ones
    // First, delete existing attachments
    let existing_attachments =
        crate::attachments::store::list_for(db, &crate::attachments::store::Owner::ApNote(note.id))
            .await?;

    for att in existing_attachments {
        crate::attachments::store::delete(db, file_store, &att).await?;
    }

    // Add new attachments
    let mut ap_attachments: Vec<crate::activitypub::types::Attachment> = Vec::new();
    for (filename, content_type, data) in new_attachments {
        if let Ok(model) = crate::attachments::store::create(
            db,
            file_store,
            crate::attachments::store::Owner::ApNote(note.id),
            filename.clone(),
            content_type.clone(),
            data,
        )
        .await
        {
            let url = format!(
                "{}/~{}/notes/{}/attachments/{}",
                public_url, user_name, note.uuid, model.uuid
            );
            ap_attachments.push(crate::activitypub::types::Attachment {
                kind: "Document".to_string(),
                media_type: Some(content_type),
                url,
                name: Some(filename),
            });
        }
    }
    let attachment_field = if ap_attachments.is_empty() {
        None
    } else {
        Some(ap_attachments)
    };

    // Build the Note object for the Update activity
    let mut cc = Vec::new();
    crate::mentions::add_mention_targets(&mut cc, &resolved_mentions);
    let note_url = format!("{}/notes/{}", actor_url, note.uuid);
    let note_obj = Note {
        context: CONTEXT.to_vec(),
        id: note_url.clone(),
        kind: "Note",
        attributed_to: actor_url.to_string(),
        content: content_html,
        media_type: None,
        url: note_url.clone(),
        in_reply_to: None,
        source: None,
        attachment: attachment_field,
        tag,
        published: note.created_at.to_rfc3339(),
        updated: Some(now.to_rfc3339()),
        to: vec!["https://www.w3.org/ns/activitystreams#Public".to_string()],
        cc,
    };

    // Build the Update activity
    let activity_uuid = Uuid::new_v4();
    let activity_url = format!("{}/activities/{}", actor_url, activity_uuid);

    let update_activity = crate::activitypub::types::Update {
        context: CONTEXT.to_vec(),
        id: activity_url,
        kind: "Update",
        actor: actor_url.to_string(),
        object: note_obj,
        published: now.to_rfc3339(),
        to: vec!["https://www.w3.org/ns/activitystreams#Public".to_string()],
        cc: vec![],
    };

    // Serialize the activity
    let activity_json = serde_json::to_string(&update_activity)
        .map_err(|e| sea_orm::DbErr::Custom(e.to_string()))?;

    // Insert the activity
    let activity = ap_activities::ActiveModel {
        uuid: Set(activity_uuid),
        actor_id: Set(actor_id),
        activity_json: Set(activity_json),
        object_url: Set(note_url),
        ..Default::default()
    };
    let activity = activity.insert(db).await?;

    // Fan out to followers
    deliver_to_followers(db, actor_id, activity.id).await?;

    Ok(note)
}

/// Retrieves a note by its URL.
///
/// Returns the note as an ActivityPub Note object, or None if not found.
pub async fn get_note(
    db: &DatabaseConnection,
    note_url: &str,
    actor_url: &str,
) -> Result<Option<serde_json::Value>, sea_orm::DbErr> {
    // Extract the UUID from the URL
    // URL format: {base}/~{user}/notes/{uuid}
    let uuid_str = note_url.rsplit('/').next().unwrap_or("");
    let uuid = match Uuid::parse_str(uuid_str) {
        Ok(uuid) => uuid,
        Err(_) => return Ok(None),
    };

    // Find the note
    let note = ApNotes::find()
        .filter(ap_notes::Column::Uuid.eq(uuid))
        .one(db)
        .await?;

    let Some(note) = note else {
        return Ok(None);
    };

    // Find the actor to get the user name
    let actor = ApActors::find()
        .filter(ap_actors::Column::Id.eq(note.actor_id))
        .one(db)
        .await?;

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

    // Get the user to build the actor URL
    let user = match actor.user_id {
        Some(user_id) => {
            use sea_orm::QueryFilter;

            use crate::entities::prelude::Users;
            use crate::entities::users;

            Users::find()
                .filter(users::Column::Id.eq(user_id))
                .one(db)
                .await?
        }
        None => None,
    };

    let Some(_user) = user else {
        return Ok(None);
    };

    // Rebuild the tag list from the stored markdown source.
    let public_url = note_url
        .split_once("/~")
        .map(|(base, _)| base)
        .unwrap_or(actor_url);
    let (mention_requests, hashtags) = crate::mentions::parse(&note.content_md);
    let resolved_mentions = crate::mentions::resolve(db, public_url, &mention_requests).await;
    let tag = crate::mentions::tags(&resolved_mentions, &hashtags, public_url);
    let tag = if tag.is_empty() { None } else { Some(tag) };
    let mut cc = Vec::new();
    crate::mentions::add_mention_targets(&mut cc, &resolved_mentions);

    // Build the Note object
    let note_obj = Note {
        context: CONTEXT.to_vec(),
        id: note_url.to_string(),
        kind: "Note",
        attributed_to: actor_url.to_string(),
        content: note.content_html,
        media_type: None,
        url: note_url.to_string(),
        in_reply_to: None,
        source: None,
        attachment: None,
        tag,
        published: note.created_at.to_rfc3339(),
        updated: note.updated_at.map(|t| t.to_rfc3339()),
        to: vec!["https://www.w3.org/ns/activitystreams#Public".to_string()],
        cc,
    };

    Ok(Some(serde_json::to_value(note_obj).unwrap()))
}

/// Lists notes for an actor, newest first.
pub async fn list(
    db: &DatabaseConnection,
    actor_id: i64,
) -> Result<Vec<ap_notes::Model>, sea_orm::DbErr> {
    use sea_orm::QueryOrder;

    ApNotes::find()
        .filter(ap_notes::Column::ActorId.eq(actor_id))
        .order_by_desc(ap_notes::Column::CreatedAt)
        .all(db)
        .await
}

/// Reaction counts for a note.
#[derive(Clone)]
pub struct NoteReactions {
    pub likes: u64,
    pub announces: u64,
    pub emojis: Vec<(String, u64)>,
}

impl NoteReactions {
    pub fn is_empty(&self) -> bool {
        self.likes == 0 && self.announces == 0 && self.emojis.is_empty()
    }
}

/// Loads reaction counts for a note.
/// If viewer_id is provided, reactions from blocked or silenced actors are
/// excluded.
pub async fn get_reactions(
    db: &DatabaseConnection,
    note_id: i64,
    viewer_id: Option<i64>,
) -> Result<NoteReactions, sea_orm::DbErr> {
    use crate::entities::ap_reactions;
    use crate::entities::prelude::ApReactions;

    let mut query = ApReactions::find().filter(ap_reactions::Column::NoteId.eq(note_id));

    // If viewer is provided, exclude reactions from blocked or silenced actors
    if let Some(vid) = viewer_id {
        // Get blocked remote actor IDs
        let blocked_actor_ids: Vec<i64> = crate::entities::prelude::UserBlocks::find()
            .filter(crate::entities::user_blocks::Column::BlockerId.eq(vid))
            .filter(crate::entities::user_blocks::Column::BlockedRemoteActorId.is_not_null())
            .all(db)
            .await?
            .into_iter()
            .filter_map(|b| b.blocked_remote_actor_id)
            .collect();

        // Get silenced remote actor IDs
        let silenced_actor_ids: Vec<i64> = crate::entities::prelude::UserSilences::find()
            .filter(crate::entities::user_silences::Column::BlockerId.eq(vid))
            .filter(crate::entities::user_silences::Column::BlockedRemoteActorId.is_not_null())
            .all(db)
            .await?
            .into_iter()
            .filter_map(|s| s.blocked_remote_actor_id)
            .collect();

        // Combine blocked and silenced IDs
        let mut excluded_ids = blocked_actor_ids;
        excluded_ids.extend(silenced_actor_ids);

        // Exclude reactions from these actors
        if !excluded_ids.is_empty() {
            use sea_orm::QueryFilter;
            query = query.filter(ap_reactions::Column::RemoteActorId.is_not_in(excluded_ids));
        }
    }

    let reactions = query.all(db).await?;

    // Load instance-blocked domains and remote actors to filter reactions
    let blocked_domains: std::collections::HashSet<String> =
        crate::entities::instance_blocks::Entity::find()
            .all(db)
            .await?
            .into_iter()
            .map(|b| b.domain)
            .collect();

    // Filter out reactions from actors on blocked domains
    let mut filtered_reactions = Vec::new();
    if !blocked_domains.is_empty() {
        let actor_ids: Vec<i64> = reactions.iter().filter_map(|r| r.remote_actor_id).collect();
        let actors = crate::entities::ap_remote_actors::Entity::find()
            .filter(crate::entities::ap_remote_actors::Column::Id.is_in(actor_ids))
            .all(db)
            .await?;
        let actor_domain_map: std::collections::HashMap<i64, String> = actors
            .into_iter()
            .map(|a| {
                let domain = crate::moderation::normalize_domain(&a.actor_url).unwrap_or_default();
                (a.id, domain)
            })
            .collect();
        for r in reactions {
            if let Some(ra) = r.remote_actor_id {
                if let Some(domain) = actor_domain_map.get(&ra) {
                    if blocked_domains.contains(domain) {
                        continue;
                    }
                }
            }
            filtered_reactions.push(r);
        }
    } else {
        filtered_reactions = reactions;
    }

    let reactions = filtered_reactions;

    let mut likes = 0u64;
    let mut announces = 0u64;
    let mut emoji_counts = std::collections::HashMap::new();

    for reaction in reactions {
        match reaction.kind.as_str() {
            "like" => likes += 1,
            "announce" => announces += 1,
            "emoji_react" => {
                if let Some(emoji) = reaction.emoji {
                    *emoji_counts.entry(emoji).or_insert(0u64) += 1;
                }
            }
            _ => {}
        }
    }

    let mut emojis: Vec<(String, u64)> = emoji_counts.into_iter().collect();
    emojis.sort_by_key(|a| std::cmp::Reverse(a.1)); // Sort by count descending

    Ok(NoteReactions {
        likes,
        announces,
        emojis,
    })
}

/// Deliver an activity to all followers of an actor.
///
/// Groups followers by sharedInbox when available to avoid duplicate
/// deliveries.
async fn deliver_to_followers(
    db: &DatabaseConnection,
    actor_id: i64,
    activity_id: i64,
) -> Result<(), sea_orm::DbErr> {
    use std::collections::HashSet;

    use sea_orm::QueryFilter;

    use crate::entities::prelude::{ApFollowers, ApRemoteActors};
    use crate::entities::{ap_deliveries, ap_followers};

    // Find all followers of this actor
    let followers = ApFollowers::find()
        .filter(ap_followers::Column::LocalActorId.eq(actor_id))
        .all(db)
        .await?;

    // Collect unique inbox URLs, preferring sharedInbox when available
    let mut inbox_urls = HashSet::new();
    for follower in followers {
        let remote_actor = ApRemoteActors::find_by_id(follower.remote_actor_id)
            .one(db)
            .await?;

        if let Some(remote_actor) = remote_actor {
            // Use sharedInbox if available, otherwise use individual inbox
            let inbox_url = remote_actor
                .shared_inbox_url
                .unwrap_or(remote_actor.inbox_url);
            inbox_urls.insert(inbox_url);
        }
    }

    // Create a delivery row for each unique inbox
    for inbox_url in inbox_urls {
        let delivery = ap_deliveries::ActiveModel {
            activity_id: Set(activity_id),
            signer_actor_id: Set(actor_id),
            inbox_url: Set(inbox_url),
            status: Set("pending".to_string()),
            attempts: Set(0),
            next_attempt_at: Set(chrono::Utc::now().into()),
            last_error: Set(None),
            ..Default::default()
        };
        delivery.insert(db).await?;
    }

    Ok(())
}
