use std::sync::Arc;
use axum::Json;
use axum::extract::State;
use axum::response::{IntoResponse, Response};

use crate::state::AppState;
use super::types::*;

pub const TAGLINES: &[&str] = &[
    "Five grids, zero excuses.",
    "Your morning routine just got more demanding.",
    "Somewhere between coffee and regret, there's a crossword.",
    "Today's grids are freshly generated and completely indifferent to your plans.",
    "Another day, another chance to stare at 1-across for way too long.",
    "The clues aren't getting easier. You're getting better. Probably.",
    "Five puzzles, five difficulty levels, one inevitable rabbit hole.",
    "Generated overnight while you were sleeping. You're welcome.",
    "Hot off the generator, ready to ruin your productivity.",
    "Five ways to avoid doing what you're supposed to be doing.",
    "The grids don't care about your schedule.",
    "New day, new grids, same questionable life choices.",
    "Freshly baked. Zero calories. Maximum frustration.",
    "Today's puzzles were generated with love and a complete lack of mercy.",
    "Your daily reminder that 3-letter words are harder than they look.",
    "The generator worked all night so you could suffer all day.",
    "Five puzzles walk into a bar. You never leave.",
    "Solve responsibly. Or don't. We're not your parents.",
    "Every answer is a real word. We checked. Mostly.",
    "The daily dose of squares you didn't know you needed.",
    "Fresh grids, fresh frustration, fresh start.",
    "Today's assortment of carefully arranged suffering.",
    "Five new reasons to feel either very smart or very humble.",
    "The crosswords are calling. They don't accept voicemail.",
    "A new batch of grids that think they're smarter than you.",
    "Ready when you are. The grids have been waiting since midnight.",
    "Your daily appointment with the dictionary.",
    "Five puzzles, zero judgment. Well, maybe a little.",
    "The only decision harder than 7-down is which puzzle to start with.",
    "Today's puzzles come with a free sense of accomplishment.",
    "Rise, shine, and wonder what a 4-letter word for \"regret\" is.",
    "The generator promises these are solvable. The generator might be lying.",
    "Five grids that believe in you even when 12-across doesn't.",
    "New puzzles, same you. That's either reassuring or terrifying.",
    "Brought to you by the letter E and the number 7.",
    "Start easy, build confidence, lose it all at the Codex.",
    "Guaranteed to make your commute feel shorter. Or longer. Depends.",
    "The internet's most productive way to procrastinate.",
    "Five puzzles, one goal: fill every single square.",
    "Today's grids have been pre-tested by absolutely nobody.",
    "Fresh from the oven. Handle with care. Or don't.",
    "Somewhere out there, 1-across is waiting for you.",
    "The daily grids are here and they brought friends.",
    "Five chances to prove you know what \"esne\" means.",
    "Your brain asked for this. Your brain might regret it.",
    "Another day of pretending you don't need the hint button.",
    "The grids are ready. The question is: are you?",
    "Five puzzles to make the waiting room feel like a study hall.",
    "Generated fresh, served immediately, regretted eventually.",
    "Today's crosswords: now with 100% more crosswords.",
];

pub const SIGNOFFS: &[&str] = &[
    "The Grimoire is feeling particularly hostile today.",
    "Good luck with the Codex. You'll need it.",
    "The Glyph is right there if you need a warm-up. No shame.",
    "Start with the Rune. Build confidence. Then try the rest.",
    "We believe in you. The Grimoire does not.",
    "Don't skip the Scroll. It's the sweet spot today.",
    "The Codex sends its regards.",
    "May your crossings be fair and your clues be merciful.",
    "Remember: there's no shame in starting with the Glyph.",
    "The Grimoire is optional. Your pride is not.",
    "Pro tip: the Scroll is always a good place to start.",
    "Today's Codex was generated with a slight grudge.",
    "Solve one, solve all, solve none. All valid options.",
    "The Glyph takes two minutes. The Grimoire takes your dignity.",
    "Don't let the Rune fool you. It has opinions.",
    "If the Grimoire defeats you, the Glyph is always there. Waiting.",
    "The puzzles don't get easier. You get more stubborn.",
    "Start wherever you want. End wherever the grid lets you.",
    "The Scroll is calling. It says you owe it a solve.",
    "Today's Grimoire was specially crafted for maximum humility.",
    "One does not simply solve the Codex on the first try.",
    "The Rune looks easy. The Rune is a liar.",
    "Your streak depends on it. No pressure.",
    "Good luck. The grids show no mercy.",
    "Today's puzzles rate themselves 5/5. Humble, clearly.",
    "Finish the Glyph in under a minute and you earn bragging rights.",
    "The Codex respects your time. The Grimoire does not.",
    "Approach the Grimoire with caution and a dictionary.",
    "All five are solvable. That's the nicest thing we'll say today.",
    "The Scroll is the Goldilocks puzzle. Just right. Usually.",
    "Let the Rune lull you into false confidence. Classic move.",
    "If you solve the Grimoire, tell no one. Keep the mystery alive.",
    "Fair warning: the Codex has been feeling ambitious lately.",
    "Five puzzles, five moods. Pick the one that matches yours.",
    "Today's Rune is suspiciously reasonable. Proceed with caution.",
    "The Glyph: perfect for coffee breaks and ego maintenance.",
    "Solve order recommendation: Glyph, Rune, Scroll, deep breath, Codex, existential crisis, Grimoire.",
    "Fun fact: the Grimoire was generated with malice aforethought.",
    "The Scroll doesn't judge. The Grimoire absolutely does.",
    "Today's puzzles promise nothing except a good time. Probably.",
    "Your streak is watching. Don't let it down.",
    "The Codex dares you. The Grimoire double-dares you.",
    "If at first you don't succeed, the Glyph is still there.",
    "Today's Rune: small but mighty. Like a crossword espresso.",
    "Tackle them in order or pick randomly. We don't make the rules. Actually we do.",
    "The Scroll: nine rows, nine columns, ninety minutes of your life.",
    "Don't overthink 1-across. Or do. It's your solve.",
    "The Grimoire awaits. It has all day. Unlike you.",
    "Remember: every empty cell is just a letter you haven't found yet.",
    "Good puzzling. See you tomorrow. Same time, same grids. Well, different grids.",
];

pub const COMMUNITY_MIDDLES: &[&str] = &[
    "Some of them might even be solvable.",
    "All free to play, no account needed. The clues owe you nothing.",
    "Fresh grids from people who think filling squares is an art form. They might be right.",
    "Hand-crafted, locally sourced, artisanal word grids. Organic? Debatable.",
    "Every one of them free, every one of them convinced their clues are fair.",
    "Give them a solve and leave a rating. Constructors thrive on validation.",
    "Free to play. Free to judge. Free to wonder what 4-down was supposed to mean.",
    "Each one someone's labor of love disguised as a grid full of squares.",
    "Homemade puzzles. Side effects may include opinions about clue quality.",
    "Built by cruciverbalists for cruciverbalists. That's a real word, look it up.",
    "The clues are creative. The grids are ambitious. The solvers are warned.",
    "Community-built, community-tested, community-approved. Mostly.",
    "Every puzzle is someone's masterpiece. Treat it accordingly. Or don't.",
    "The constructors did their best. Now it's your turn.",
    "Fresh from the puzzle editor. Handle with appropriate skepticism.",
    "Puzzles made by real people with real dictionaries and real ambition.",
    "No two constructors think alike. Today's grids prove it.",
    "Quality may vary. Enthusiasm is guaranteed.",
    "Every grid tells a story. Some stories are more confusing than others.",
    "Straight from the editor to your screen. No middleman, no filter.",
    "Community puzzles: where creativity meets the dictionary.",
    "Rate them fairly. The constructors can see the ratings.",
    "Some are easy, some are devious, all are free.",
    "The community has been busy. Your afternoon just got more interesting.",
    "Today's batch includes at least one puzzle that will surprise you.",
    "Made with love, frustration, and an unreasonable number of three-letter words.",
    "The constructors stayed up late for these. The least you can do is solve one.",
    "From the community, for the community. With love and mild sadness.",
    "Every puzzle started as a blank grid and a dream. Here are the results.",
    "New puzzles from people who voluntarily spend their free time making crosswords.",
    "Solve one, rate one, maybe submit your own. The cycle continues.",
    "Fresh community puzzles. No two grids are alike. Most clues are fair.",
    "Today's community output: ambitious, creative, and occasionally baffling.",
    "Puzzles by people who think constructing is fun. And honestly, they're right.",
    "The community delivered. Time to see what they came up with.",
    "Warning: may contain clever themes, questionable fills, and at least one answer you'll argue about.",
    "New grids, new constructors, new reasons to question your vocabulary.",
    "The puzzle editor was used today. The results are in.",
    "Every puzzle was someone's idea of a good time. Give it a try.",
    "Community crosswords: where the grids are made up and the ratings matter.",
    "Built by humans, for humans. The generator had the day off.",
    "The community speaks in crossword form. Here's what they said today.",
    "Puzzles that exist because someone thought \"I could make a better one.\" And they tried.",
    "New grids from the community. Your feedback shapes the future ones.",
    "Today's puzzles come with no warranty, express or implied.",
    "Fresh off the press. The community press, that is.",
    "Constructors put their hearts into these grids. Show some love.",
    "Another day, another batch of community puzzles to discover.",
    "The constructors have spoken. The puzzles are ready. Your move.",
    "New community puzzles just dropped. We take no responsibility for 14-across.",
];

pub fn pick_line<'a>(date: &str, builtins: &'a [&str], custom: &'a [String], salt: &str) -> &'a str {
    let total = builtins.len() + custom.len();
    if total == 0 { return ""; }
    let hash = {
        use sha2::{Sha256, Digest};
        let mut h = Sha256::new();
        h.update(date.as_bytes());
        h.update(salt.as_bytes());
        let result = h.finalize();
        u64::from_le_bytes(result[..8].try_into().unwrap())
    };
    let idx = (hash % total as u64) as usize;
    if idx < builtins.len() {
        builtins[idx]
    } else {
        &custom[idx - builtins.len()]
    }
}

fn format_date(date: &str) -> String {
    if let Ok(d) = chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d") {
        d.format("%A, %B %-d, %Y").to_string()
    } else {
        date.to_string()
    }
}

pub fn load_custom_lines(db: &rusqlite::Connection, key: &str) -> Vec<String> {
    crate::db::get_server_config(db, key)
        .and_then(|json| serde_json::from_str::<Vec<String>>(&json).ok())
        .unwrap_or_default()
}

fn collect_peer_inboxes(db: &rusqlite::Connection) -> Vec<String> {
    let mut inboxes: Vec<String> = Vec::new();
    if let Ok(mut stmt) = db.prepare("SELECT inbox_uri FROM ap_peers") {
        if let Ok(rows) = stmt.query_map([], |row| row.get(0)) {
            for r in rows.flatten() { inboxes.push(r); }
        }
    }
    inboxes
}

fn collect_all_inboxes(db: &rusqlite::Connection) -> Vec<String> {
    let mut inboxes: Vec<String> = Vec::new();
    if let Ok(mut stmt) = db.prepare("SELECT inbox_uri FROM ap_followers WHERE accepted = 1") {
        if let Ok(rows) = stmt.query_map([], |row| row.get(0)) {
            for r in rows.flatten() { inboxes.push(r); }
        }
    }
    if let Ok(mut stmt) = db.prepare("SELECT inbox_uri FROM ap_peers") {
        if let Ok(rows) = stmt.query_map([], |row| row.get(0)) {
            for r in rows.flatten() { if !inboxes.contains(&r) { inboxes.push(r); } }
        }
    }
    inboxes
}

fn enqueue_delivery(db: &rusqlite::Connection, inboxes: &[String], payload: &str) {
    let retry = chrono::Utc::now().to_rfc3339();
    for inbox in inboxes {
        db.execute(
            "INSERT INTO ap_delivery_queue (inbox_uri, payload, next_retry, created_at) VALUES (?1, ?2, ?3, ?3)",
            rusqlite::params![inbox, payload, retry],
        ).ok();
    }
}

pub async fn outbox(
    State(state): State<Arc<AppState>>,
) -> Result<Json<OrderedCollection>, Response> {
    let ap = state.ap.as_ref().ok_or_else(|| {
        let status = axum::http::StatusCode::NOT_FOUND;
        (status, "not found").into_response()
    })?;
    let limit: i64 = std::env::var("AP_OUTBOX_LIMIT")
        .ok().and_then(|v| v.parse().ok())
        .unwrap_or(20);
    if limit == 0 {
        return Ok(Json(OrderedCollection {
            context: CONTEXT.into(),
            id: format!("{}/outbox", ap.actor_uri),
            collection_type: "OrderedCollection".into(),
            total_items: 0,
            ordered_items: vec![],
        }));
    }

    let dbi = state.db.lock().await;
    let mut stmt = dbi.db.prepare(
        "SELECT activity FROM ap_outbox ORDER BY published DESC LIMIT ?1"
    ).unwrap();
    let items: Vec<serde_json::Value> = stmt.query_map([limit], |row| {
        let json: String = row.get(0)?;
        Ok(serde_json::from_str(&json).unwrap_or_default())
    }).unwrap().filter_map(|r| r.ok()).collect();

    Ok(Json(OrderedCollection {
        context: CONTEXT.into(),
        id: format!("{}/outbox", ap.actor_uri),
        collection_type: "OrderedCollection".into(),
        total_items: items.len(),
        ordered_items: items,
    }))
}

pub async fn publish_daily_puzzle(state: &Arc<AppState>, date: &str) {
    let ap = match state.ap.as_ref() {
        Some(ap) => ap,
        None => return,
    };

    let formatted_date = format_date(date);

    let (tagline, signoff, custom_taglines, custom_signoffs) = {
        let dbi = state.db.lock().await;
        let ct = load_custom_lines(&dbi.db, "ap_daily_taglines");
        let cs = load_custom_lines(&dbi.db, "ap_daily_signoffs");
        let t = pick_line(date, TAGLINES, &ct, "tagline").to_string();
        let s = pick_line(date, SIGNOFFS, &cs, "signoff").to_string();
        (t, s, ct, cs)
    };
    let _ = (custom_taglines, custom_signoffs); // used by pick_line above

    let domain = &ap.domain;

    let post_url = format!("https://{}/post/{}", domain, date);
    let html_text = format!(
        "Fresh puzzles for {}. {}<br/><br/>\
         - Glyph (5x5, very easy) - <a href=\"https://{}/daily/glyph\">https://{}/daily/glyph</a><br/>\
         - Rune (5x5, easy) - <a href=\"https://{}/daily/rune\">https://{}/daily/rune</a><br/>\
         - Scroll (9x9, medium) - <a href=\"https://{}/daily/scroll\">https://{}/daily/scroll</a><br/>\
         - Codex (15x15, hard) - <a href=\"https://{}/daily/codex\">https://{}/daily/codex</a><br/>\
         - Grimoire (21x21, expert) - <a href=\"https://{}/daily/grimoire\">https://{}/daily/grimoire</a><br/><br/>\
         {}<br/><br/>\
         <a href=\"https://{}/tags/crossword\" class=\"mention hashtag\" rel=\"tag\">#<span>crossword</span></a> \
         <a href=\"https://{}/tags/puzzle\" class=\"mention hashtag\" rel=\"tag\">#<span>puzzle</span></a> \
         <a href=\"https://{}/tags/cruciverbDaily\" class=\"mention hashtag\" rel=\"tag\">#<span>cruciverbDaily</span></a>",
        formatted_date, tagline,
        domain, domain, domain, domain, domain, domain, domain, domain, domain, domain,
        signoff,
        domain, domain, domain,
    );
    let note_id = format!("https://{}/ap/daily/{}/{}", domain, date, uuid::Uuid::new_v4());
    let now = chrono::Utc::now().to_rfc3339();

    let attachments = vec![serde_json::json!({
        "type": "Document",
        "mediaType": "image/jpeg",
        "url": format!("https://{}/daily-image", domain),
        "name": "Five crossword puzzle grids side by side representing today's daily puzzles from very easy to expert."
    })];

    let note = Note {
        note_type: "Note".into(),
        id: note_id.clone(),
        attributed_to: ap.actor_uri.clone(),
        content: format!("<p>{}</p>", html_text),
        published: now.clone(),
        url: Some(post_url.clone()),
        to: vec![PUBLIC.into()],
        cc: vec![format!("{}/followers", ap.actor_uri)],
        tag: Some(vec![
            serde_json::json!({ "type": "Hashtag", "name": "#crossword", "href": format!("https://{}/tags/crossword", domain) }),
            serde_json::json!({ "type": "Hashtag", "name": "#puzzle", "href": format!("https://{}/tags/puzzle", domain) }),
            serde_json::json!({ "type": "Hashtag", "name": "#cruciverbDaily", "href": format!("https://{}/tags/cruciverbDaily", domain) }),
        ]),
        attachment: Some(attachments),
    };

    let activity_id = format!("{}/activity", note_id);
    let activity = Activity {
        context: Some(serde_json::json!([CONTEXT, SECURITY_CONTEXT])),
        id: activity_id.clone(),
        activity_type: "Create".into(),
        actor: ap.actor_uri.clone(),
        published: Some(now.clone()),
        to: Some(vec![PUBLIC.into()]),
        cc: Some(vec![format!("{}/followers", ap.actor_uri)]),
        object: Some(serde_json::to_value(&note).unwrap()),
    };

    let activity_json = serde_json::to_string(&activity).unwrap();

    let dbi = state.db.lock().await;
    dbi.db.execute(
        "INSERT OR REPLACE INTO ap_outbox (id, activity, published) VALUES (?1, ?2, ?3)",
        rusqlite::params![activity_id, activity_json, now],
    ).ok();

    let mut inboxes: Vec<String> = Vec::new();
    if let Ok(mut stmt) = dbi.db.prepare("SELECT inbox_uri FROM ap_followers WHERE accepted = 1") {
        if let Ok(rows) = stmt.query_map([], |row| row.get(0)) {
            for r in rows.flatten() { inboxes.push(r); }
        }
    }
    if let Ok(mut stmt) = dbi.db.prepare("SELECT inbox_uri FROM ap_peers") {
        if let Ok(rows) = stmt.query_map([], |row| row.get(0)) {
            for r in rows.flatten() { if !inboxes.contains(&r) { inboxes.push(r); } }
        }
    }

    enqueue_delivery(&dbi.db, &inboxes, &activity_json);
    tracing::info!("published daily puzzle post for {} to {} recipients", date, inboxes.len());
}

pub async fn publish_community_digest(state: &Arc<AppState>, date: &str) {
    let ap = match state.ap.as_ref() {
        Some(ap) => ap,
        None => return,
    };

    let (count, middle) = {
        let dbi = state.db.lock().await;
        let c: i64 = dbi.db.query_row(
            "SELECT COUNT(*) FROM user_puzzles WHERE status = 'published' AND date(created_at) = ?1",
            [date], |row| row.get(0),
        ).unwrap_or(0);
        if c == 0 { return; }
        let cm = load_custom_lines(&dbi.db, "ap_community_middles");
        let m = pick_line(date, COMMUNITY_MIDDLES, &cm, "community").to_string();
        (c, m)
    };

    let s = if count == 1 { "" } else { "s" };
    let domain = &ap.domain;

    let text = format!(
        "{} new community puzzle{} landed today. {}\n\n\
         Browse today's submissions: https://{}/community/today\n\n\
         #crossword #community #cruciverbCommunity",
        count, s, middle, domain,
    );

    let html_text = text.replace('\n', "<br/>");
    let note_id = format!("https://{}/ap/community-digest/{}", domain, date);
    let now = chrono::Utc::now().to_rfc3339();

    let note = Note {
        note_type: "Note".into(),
        id: note_id.clone(),
        attributed_to: ap.actor_uri.clone(),
        content: format!("<p>{}</p>", html_text),
        published: now.clone(),
        url: Some(format!("https://{}/community/today", domain)),
        to: vec![PUBLIC.into()],
        cc: vec![format!("{}/followers", ap.actor_uri)],
        tag: Some(vec![
            serde_json::json!({ "type": "Hashtag", "name": "#crossword" }),
            serde_json::json!({ "type": "Hashtag", "name": "#community" }),
            serde_json::json!({ "type": "Hashtag", "name": "#cruciverbCommunity" }),
        ]),
        attachment: None,
    };

    let activity_id = format!("{}/activity", note_id);
    let activity = Activity {
        context: Some(serde_json::json!([CONTEXT, SECURITY_CONTEXT])),
        id: activity_id.clone(),
        activity_type: "Create".into(),
        actor: ap.actor_uri.clone(),
        published: Some(now.clone()),
        to: Some(vec![PUBLIC.into()]),
        cc: Some(vec![format!("{}/followers", ap.actor_uri)]),
        object: Some(serde_json::to_value(&note).unwrap()),
    };

    let activity_json = serde_json::to_string(&activity).unwrap();

    let dbi = state.db.lock().await;
    dbi.db.execute(
        "INSERT OR REPLACE INTO ap_outbox (id, activity, published) VALUES (?1, ?2, ?3)",
        rusqlite::params![activity_id, activity_json, now],
    ).ok();

    let inboxes = collect_all_inboxes(&dbi.db);
    enqueue_delivery(&dbi.db, &inboxes, &activity_json);
    tracing::info!("published community digest for {} ({} puzzles, {} recipients)", date, count, inboxes.len());
}

pub async fn publish_delete(state: &Arc<AppState>, object_uri: &str) {
    let ap = match state.ap.as_ref() { Some(ap) => ap, None => return };
    let now = chrono::Utc::now().to_rfc3339();
    let activity = serde_json::json!({
        "@context": [CONTEXT, SECURITY_CONTEXT],
        "id": format!("{}/delete/{}", ap.actor_uri, uuid::Uuid::new_v4()),
        "type": "Delete",
        "actor": ap.actor_uri,
        "object": object_uri,
        "published": now,
        "to": [PUBLIC],
        "cc": [format!("{}/followers", ap.actor_uri)],
    });
    let payload = serde_json::to_string(&activity).unwrap();
    let dbi = state.db.lock().await;
    dbi.db.execute("DELETE FROM ap_outbox WHERE id LIKE ?1 || '%'", [object_uri]).ok();
    // drop interaction orphans that reference the deleted object
    dbi.db.execute("DELETE FROM ap_replies WHERE outbox_id LIKE ?1 || '%'", [object_uri]).ok();
    dbi.db.execute("DELETE FROM ap_boosts WHERE outbox_id LIKE ?1 || '%'", [object_uri]).ok();
    let inboxes = collect_all_inboxes(&dbi.db);
    enqueue_delivery(&dbi.db, &inboxes, &payload);
}

pub async fn publish_leaderboard(
    state: &Arc<AppState>,
    date: &str,
    preset: &str,
    entries: Vec<(String, i64)>,
) {
    let ap = match state.ap.as_ref() { Some(ap) => ap, None => return };
    if entries.is_empty() { return; }

    let top10: Vec<serde_json::Value> = entries.iter().take(10).map(|(name, time)| {
        serde_json::json!({ "name": name, "time": time })
    }).collect();

    let payload = serde_json::json!({
        "origin_domain": ap.domain,
        "puzzle_date": date,
        "preset": preset,
        "entries": top10,
    });
    let payload_str = serde_json::to_string(&payload).unwrap();

    let peers: Vec<String> = {
        let dbi = state.db.lock().await;
        let mut stmt = dbi.db.prepare("SELECT domain FROM ap_peers").unwrap();
        stmt.query_map([], |row| row.get(0)).unwrap()
            .filter_map(|r| r.ok()).collect()
    };

    if peers.is_empty() { return; }

    let client = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(10))
        .build()
        .unwrap();

    let key_id = format!("{}#main-key", ap.actor_uri);

    for domain in &peers {
        let url = format!("https://{}/api/federation/leaderboard", domain);
        let path = "/api/federation/leaderboard";

        let sig_headers = super::signatures::sign_request(
            &ap.keys.private_key, &key_id, "POST", path, domain,
            Some(payload_str.as_bytes()),
        );

        let mut req = client.post(&url)
            .header("Content-Type", "application/json");
        for (name, value) in &sig_headers {
            req = req.header(name.as_str(), value.as_str());
        }

        match req.body(payload_str.clone()).send().await {
            Ok(resp) if resp.status().is_success() => {
                tracing::debug!("sent leaderboard to {}", domain);
            }
            Ok(resp) => {
                tracing::warn!("leaderboard to {} failed: {}", domain, resp.status());
            }
            Err(e) => {
                tracing::warn!("leaderboard to {} error: {}", domain, e);
            }
        }
    }

    tracing::info!("published leaderboard {} {} to {} peers", preset, date, peers.len());
}

pub async fn publish_blocklist_update(state: &Arc<AppState>, domain: &str, action: &str) {
    let ap = match state.ap.as_ref() { Some(ap) => ap, None => return };
    let now = chrono::Utc::now().to_rfc3339();
    let id = format!("https://{}/ap/blocklist/{}/{}", ap.domain, action, uuid::Uuid::new_v4());

    let activity = serde_json::json!({
        "@context": [CONTEXT, SECURITY_CONTEXT],
        "id": id,
        "type": "Announce",
        "actor": ap.actor_uri,
        "published": now,
        "to": [PUBLIC],
        "tag": [{ "type": "Hashtag", "name": "#cruciverb-blocklist" }],
        "attachment": [
            { "type": "PropertyValue", "name": "cruciverb:blocklist-domain", "value": domain },
            { "type": "PropertyValue", "name": "cruciverb:blocklist-action", "value": action },
        ],
    });

    let payload = serde_json::to_string(&activity).unwrap();
    let dbi = state.db.lock().await;
    let inboxes = collect_peer_inboxes(&dbi.db);
    enqueue_delivery(&dbi.db, &inboxes, &payload);
    tracing::info!("published blocklist {} for {}", action, domain);
}

pub async fn publish_move(state: &Arc<AppState>, target_domain: &str) {
    let ap = match state.ap.as_ref() { Some(ap) => ap, None => return };
    let now = chrono::Utc::now().to_rfc3339();
    let target_actor = format!("https://{}/@cruciverb", target_domain);

    let activity = serde_json::json!({
        "@context": [CONTEXT, SECURITY_CONTEXT],
        "id": format!("{}/move/{}", ap.actor_uri, uuid::Uuid::new_v4()),
        "type": "Move",
        "actor": ap.actor_uri,
        "target": target_actor,
        "object": ap.actor_uri,
        "published": now,
        "to": [PUBLIC],
    });

    let payload = serde_json::to_string(&activity).unwrap();
    let dbi = state.db.lock().await;
    let inboxes = collect_all_inboxes(&dbi.db);
    enqueue_delivery(&dbi.db, &inboxes, &payload);
    tracing::info!("published Move to {} ({} recipients)", target_domain, inboxes.len());
}

pub async fn publish_actor_update(state: &Arc<AppState>) {
    let ap = match state.ap.as_ref() {
        Some(ap) => ap,
        None => return,
    };

    let now = chrono::Utc::now().to_rfc3339();

    let default_bio = "Daily crossword puzzles in five sizes, four game modes, cryptic clues, multiplayer rooms, party games, a built-in puzzle editor for constructors, and a clue trainer for the stubborn.";

    let (display_name, bio, avatar_path, banner_path) = {
        let dbi = state.db.lock().await;
        let dn = crate::db::get_server_config(&dbi.db, "ap_actor_name")
            .unwrap_or_else(|| "crossword".into());
        let b = crate::db::get_server_config(&dbi.db, "ap_bio")
            .unwrap_or_else(|| default_bio.into());
        let av = crate::db::get_server_config(&dbi.db, "ap_avatar_path");
        let bn = crate::db::get_server_config(&dbi.db, "ap_banner_path");
        (dn, b, av, bn)
    };

    let mut actor_doc = serde_json::json!({
        "@context": [CONTEXT, SECURITY_CONTEXT],
        "id": ap.actor_uri,
        "type": "Application",
        "preferredUsername": ap.actor_name,
        "name": display_name,
        "summary": bio,
        "inbox": format!("{}/inbox", ap.actor_uri),
        "outbox": format!("{}/outbox", ap.actor_uri),
        "followers": format!("{}/followers", ap.actor_uri),
        "publicKey": {
            "id": format!("{}#main-key", ap.actor_uri),
            "owner": ap.actor_uri,
            "publicKeyPem": ap.keys.public_key_pem,
        },
    });

    if avatar_path.is_some() {
        actor_doc["icon"] = serde_json::json!({ "type": "Image", "url": format!("https://{}/avatar", ap.domain) });
    }
    if banner_path.is_some() {
        actor_doc["image"] = serde_json::json!({ "type": "Image", "url": format!("https://{}/banner", ap.domain) });
    }

    let activity_id = format!("{}/update/{}", ap.actor_uri, uuid::Uuid::new_v4());
    let activity = serde_json::json!({
        "@context": [CONTEXT, SECURITY_CONTEXT],
        "id": activity_id,
        "type": "Update",
        "actor": ap.actor_uri,
        "published": now,
        "to": [PUBLIC],
        "cc": [format!("{}/followers", ap.actor_uri)],
        "object": actor_doc,
    });

    let payload = serde_json::to_string(&activity).unwrap();
    let dbi = state.db.lock().await;
    let inboxes = collect_all_inboxes(&dbi.db);
    enqueue_delivery(&dbi.db, &inboxes, &payload);
    tracing::info!("published actor Update to {} recipients", inboxes.len());
}

