use std::collections::{HashMap, HashSet};

use axum::{
    extract::{Multipart, Query},
    http::{HeaderMap, header},
};
use loco_rs::prelude::*;
use regex::Regex;
use reqwest::Url;
use sea_orm::{
    ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter,
    QueryOrder, QuerySelect, Set,
};
use serde::{Deserialize, Serialize};

use crate::{
    controllers::{
        admin::{
            admin_username, check_auth, clear_local_session, clear_local_session_cookie,
            local_login_enabled, proxy_auth_enabled, resolve_admin_identity, start_local_session,
            validate_admin_credentials,
        },
        site_settings::{self, SiteSettingsPayload},
    },
    models::_entities::{
        ai_chunks, comment_blacklist, comment_persona_analysis_logs, comments, friend_links, posts,
        reviews,
    },
    services::{
        admin_audit, ai, analytics, comment_guard, content, media_assets, storage, worker_jobs,
    },
    workers::downloader::{DownloadWorkerArgs, download_media_to_storage, normalize_target_format},
};

#[derive(Clone, Debug, Deserialize)]
pub struct AdminLoginPayload {
    pub username: String,
    pub password: String,
}

#[derive(Clone, Debug, Serialize)]
pub struct AdminSessionResponse {
    pub authenticated: bool,
    pub username: Option<String>,
    pub email: Option<String>,
    pub auth_source: Option<String>,
    pub auth_provider: Option<String>,
    pub groups: Vec<String>,
    pub proxy_auth_enabled: bool,
    pub local_login_enabled: bool,
    pub can_logout: bool,
}

fn build_session_response(
    identity: Option<crate::controllers::admin::AdminIdentity>,
) -> AdminSessionResponse {
    let can_logout = matches!(
        identity.as_ref().map(|item| item.source.as_str()),
        Some("local")
    );

    AdminSessionResponse {
        authenticated: identity.is_some(),
        username: identity.as_ref().map(|item| item.username.clone()),
        email: identity.as_ref().and_then(|item| item.email.clone()),
        auth_source: identity.as_ref().map(|item| item.source.clone()),
        auth_provider: identity.as_ref().and_then(|item| item.provider.clone()),
        groups: identity.map(|item| item.groups).unwrap_or_default(),
        proxy_auth_enabled: proxy_auth_enabled(),
        local_login_enabled: local_login_enabled(),
        can_logout,
    }
}

#[derive(Clone, Debug, Serialize)]
pub struct DashboardStats {
    pub total_posts: u64,
    pub total_comments: u64,
    pub pending_comments: u64,
    pub draft_posts: u64,
    pub scheduled_posts: u64,
    pub offline_posts: u64,
    pub expired_posts: u64,
    pub private_posts: u64,
    pub unlisted_posts: u64,
    pub total_categories: u64,
    pub total_tags: u64,
    pub total_reviews: u64,
    pub total_links: u64,
    pub pending_links: u64,
    pub ai_chunks: u64,
    pub ai_enabled: bool,
}

#[derive(Clone, Debug, Serialize)]
pub struct DashboardPostItem {
    pub id: i32,
    pub title: String,
    pub slug: String,
    pub category: String,
    pub post_type: String,
    pub pinned: bool,
    pub status: String,
    pub visibility: String,
    pub created_at: String,
}

#[derive(Clone, Debug, Serialize)]
pub struct DashboardCommentItem {
    pub id: i32,
    pub author: String,
    pub post_slug: String,
    pub scope: String,
    pub excerpt: String,
    pub approved: bool,
    pub created_at: String,
}

#[derive(Clone, Debug, Serialize)]
pub struct DashboardFriendLinkItem {
    pub id: i32,
    pub site_name: String,
    pub site_url: String,
    pub category: String,
    pub status: String,
    pub created_at: String,
}

#[derive(Clone, Debug, Serialize)]
pub struct DashboardReviewItem {
    pub id: i32,
    pub title: String,
    pub review_type: String,
    pub rating: i32,
    pub status: String,
    pub review_date: String,
}

#[derive(Clone, Debug, Serialize)]
pub struct DashboardSiteSummary {
    pub site_name: String,
    pub site_url: String,
    pub ai_enabled: bool,
    pub ai_chunks: u64,
    pub ai_last_indexed_at: Option<String>,
}

#[derive(Clone, Debug, Serialize)]
pub struct AdminDashboardResponse {
    pub stats: DashboardStats,
    pub site: DashboardSiteSummary,
    pub recent_posts: Vec<DashboardPostItem>,
    pub pending_comments: Vec<DashboardCommentItem>,
    pub pending_friend_links: Vec<DashboardFriendLinkItem>,
    pub recent_reviews: Vec<DashboardReviewItem>,
}

#[derive(Clone, Debug, Serialize)]
pub struct AdminSiteSettingsResponse {
    pub id: i32,
    pub site_name: Option<String>,
    pub site_short_name: Option<String>,
    pub site_url: Option<String>,
    pub site_title: Option<String>,
    pub site_description: Option<String>,
    pub hero_title: Option<String>,
    pub hero_subtitle: Option<String>,
    pub owner_name: Option<String>,
    pub owner_title: Option<String>,
    pub owner_bio: Option<String>,
    pub owner_avatar_url: Option<String>,
    pub social_github: Option<String>,
    pub social_twitter: Option<String>,
    pub social_email: Option<String>,
    pub location: Option<String>,
    pub tech_stack: Vec<String>,
    pub music_playlist: Vec<site_settings::MusicTrackPayload>,
    pub music_enabled: bool,
    pub maintenance_mode_enabled: bool,
    pub maintenance_access_code: Option<String>,
    pub ai_enabled: bool,
    pub paragraph_comments_enabled: bool,
    pub comment_verification_mode: String,
    pub comment_turnstile_enabled: bool,
    pub subscription_verification_mode: String,
    pub subscription_turnstile_enabled: bool,
    pub web_push_enabled: bool,
    pub turnstile_site_key: Option<String>,
    pub turnstile_secret_key: Option<String>,
    pub web_push_vapid_public_key: Option<String>,
    pub web_push_vapid_private_key: Option<String>,
    pub web_push_vapid_subject: Option<String>,
    pub ai_provider: Option<String>,
    pub ai_api_base: Option<String>,
    pub ai_api_key: Option<String>,
    pub ai_chat_model: Option<String>,
    pub ai_image_provider: Option<String>,
    pub ai_image_api_base: Option<String>,
    pub ai_image_api_key: Option<String>,
    pub ai_image_model: Option<String>,
    pub ai_providers: Vec<site_settings::AiProviderConfig>,
    pub ai_active_provider_id: Option<String>,
    pub ai_embedding_model: Option<String>,
    pub ai_system_prompt: Option<String>,
    pub ai_top_k: Option<i32>,
    pub ai_chunk_size: Option<i32>,
    pub ai_last_indexed_at: Option<String>,
    pub ai_chunks_count: u64,
    pub ai_local_embedding: String,
    pub media_storage_provider: Option<String>,
    pub media_r2_account_id: Option<String>,
    pub media_r2_bucket: Option<String>,
    pub media_r2_public_base_url: Option<String>,
    pub media_r2_access_key_id: Option<String>,
    pub media_r2_secret_access_key: Option<String>,
    pub seo_favicon_url: Option<String>,
    pub seo_default_og_image: Option<String>,
    pub seo_default_twitter_handle: Option<String>,
    pub seo_wechat_share_qr_enabled: bool,
    pub notification_webhook_url: Option<String>,
    pub notification_channel_type: String,
    pub notification_comment_enabled: bool,
    pub notification_friend_link_enabled: bool,
    pub subscription_popup_enabled: bool,
    pub subscription_popup_title: String,
    pub subscription_popup_description: String,
    pub subscription_popup_delay_seconds: i32,
    pub search_synonyms: Vec<String>,
}

#[derive(Clone, Debug, Serialize)]
pub struct AdminAiReindexResponse {
    pub queued: bool,
    pub job: worker_jobs::WorkerJobRecord,
}

#[derive(Clone, Debug, Deserialize)]
pub struct AdminAiProviderTestRequest {
    pub provider: site_settings::AiProviderConfig,
}

#[derive(Clone, Debug, Serialize)]
pub struct AdminAiProviderTestResponse {
    pub provider: String,
    pub endpoint: String,
    pub chat_model: String,
    pub reply_preview: String,
}

#[derive(Clone, Debug, Deserialize)]
pub struct AdminAiImageProviderTestRequest {
    pub provider: String,
    pub api_base: String,
    pub api_key: String,
    pub image_model: String,
}

#[derive(Clone, Debug, Serialize)]
pub struct AdminAiImageProviderTestResponse {
    pub provider: String,
    pub endpoint: String,
    pub image_model: String,
    pub result_preview: String,
}

#[derive(Clone, Debug, Serialize)]
pub struct AdminImageUploadResponse {
    pub url: String,
    pub key: String,
}

#[derive(Clone, Debug, Serialize)]
pub struct AdminR2ConnectivityResponse {
    pub bucket: String,
    pub public_base_url: String,
}

#[derive(Clone, Debug, Serialize)]
pub struct AdminMediaObjectResponse {
    pub key: String,
    pub url: String,
    pub size_bytes: i64,
    pub last_modified: Option<String>,
    pub title: Option<String>,
    pub alt_text: Option<String>,
    pub caption: Option<String>,
    pub tags: Vec<String>,
    pub notes: Option<String>,
}

#[derive(Clone, Debug, Serialize)]
pub struct AdminMediaListResponse {
    pub provider: String,
    pub bucket: String,
    pub public_base_url: String,
    pub items: Vec<AdminMediaObjectResponse>,
}

#[derive(Clone, Debug, Serialize)]
pub struct AdminMediaDeleteResponse {
    pub deleted: bool,
    pub key: String,
}

#[derive(Clone, Debug, Serialize)]
pub struct AdminMediaUploadItem {
    pub key: String,
    pub url: String,
    pub size_bytes: i64,
}

#[derive(Clone, Debug, Serialize)]
pub struct AdminMediaUploadResponse {
    pub uploaded: Vec<AdminMediaUploadItem>,
}

#[derive(Clone, Debug, Deserialize)]
pub struct AdminMediaBatchDeleteRequest {
    #[serde(default)]
    pub keys: Vec<String>,
}

#[derive(Clone, Debug, Serialize)]
pub struct AdminMediaBatchDeleteResponse {
    pub deleted: Vec<String>,
    pub failed: Vec<String>,
}

#[derive(Clone, Debug, Serialize)]
pub struct AdminMediaReplaceResponse {
    pub key: String,
    pub url: String,
}

#[derive(Clone, Debug, Deserialize)]
pub struct AdminMediaMetadataPayload {
    pub key: String,
    #[serde(default)]
    pub title: Option<String>,
    #[serde(default)]
    pub alt_text: Option<String>,
    #[serde(default)]
    pub caption: Option<String>,
    #[serde(default)]
    pub tags: Option<Vec<String>>,
    #[serde(default)]
    pub notes: Option<String>,
}

#[derive(Clone, Debug, Serialize)]
pub struct AdminMediaMetadataResponse {
    pub saved: bool,
    pub key: String,
    pub title: Option<String>,
    pub alt_text: Option<String>,
    pub caption: Option<String>,
    pub tags: Vec<String>,
    pub notes: Option<String>,
}

#[derive(Clone, Debug, Deserialize)]
pub struct AdminMediaDownloadPayload {
    pub source_url: String,
    #[serde(default)]
    pub prefix: Option<String>,
    #[serde(default)]
    pub target_format: Option<String>,
    #[serde(default)]
    pub title: Option<String>,
    #[serde(default)]
    pub alt_text: Option<String>,
    #[serde(default)]
    pub caption: Option<String>,
    #[serde(default)]
    pub tags: Option<Vec<String>>,
    #[serde(default)]
    pub notes: Option<String>,
    #[serde(default)]
    pub sync: bool,
}

#[derive(Clone, Debug, Serialize)]
pub struct AdminMediaDownloadResponse {
    pub queued: bool,
    pub job_id: Option<i32>,
    pub status: Option<String>,
    pub key: Option<String>,
    pub url: Option<String>,
    pub size_bytes: Option<i64>,
    pub content_type: Option<String>,
}

#[derive(Clone, Debug, Deserialize)]
pub struct AdminMediaListQuery {
    pub prefix: Option<String>,
    pub limit: Option<i32>,
}

#[derive(Clone, Debug, Deserialize)]
pub struct AdminMediaDeleteQuery {
    pub key: String,
}

#[derive(Clone, Debug, Serialize)]
pub struct AdminCommentBlacklistItem {
    pub id: i32,
    pub matcher_type: String,
    pub matcher_value: String,
    pub reason: Option<String>,
    pub active: bool,
    pub expires_at: Option<String>,
    pub created_at: String,
    pub updated_at: String,
    pub effective: bool,
}

#[derive(Clone, Debug, Deserialize)]
pub struct AdminCommentBlacklistCreateRequest {
    pub matcher_type: String,
    pub matcher_value: String,
    pub reason: Option<String>,
    pub active: Option<bool>,
    pub expires_at: Option<String>,
}

#[derive(Clone, Debug, Deserialize)]
pub struct AdminCommentBlacklistUpdateRequest {
    pub reason: Option<String>,
    pub active: Option<bool>,
    pub expires_at: Option<String>,
    #[serde(default)]
    pub clear_expires_at: bool,
}

#[derive(Clone, Debug, Serialize)]
pub struct AdminCommentBlacklistDeleteResponse {
    pub deleted: bool,
    pub id: i32,
}

#[derive(Clone, Debug, Deserialize)]
pub struct AdminCommentAnalyzeRequest {
    pub matcher_type: String,
    pub matcher_value: String,
    pub from: Option<String>,
    pub to: Option<String>,
    pub limit: Option<u64>,
}

#[derive(Clone, Debug, Deserialize)]
pub struct AdminCommentAnalyzeLogsQuery {
    pub matcher_type: Option<String>,
    pub matcher_value: Option<String>,
    pub limit: Option<u64>,
}

#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct AdminCommentAnalyzeSample {
    pub id: i32,
    pub created_at: String,
    pub post_slug: String,
    pub author: String,
    pub email: String,
    pub approved: bool,
    pub content_preview: String,
}

#[derive(Clone, Debug, Serialize)]
pub struct AdminCommentAnalyzeResponse {
    pub matcher_type: String,
    pub matcher_value: String,
    pub total_comments: u64,
    pub pending_comments: u64,
    pub first_seen_at: Option<String>,
    pub latest_seen_at: Option<String>,
    pub distinct_posts: usize,
    pub analysis: String,
    pub samples: Vec<AdminCommentAnalyzeSample>,
}

#[derive(Clone, Debug, Serialize)]
pub struct AdminCommentAnalyzeLogItem {
    pub id: i32,
    pub matcher_type: String,
    pub matcher_value: String,
    pub from_at: Option<String>,
    pub to_at: Option<String>,
    pub total_comments: u64,
    pub pending_comments: u64,
    pub distinct_posts: usize,
    pub analysis: String,
    pub samples: Vec<AdminCommentAnalyzeSample>,
    pub created_at: String,
}

#[derive(Clone, Debug, Deserialize)]
pub struct AdminPostMetadataRequest {
    pub markdown: String,
}

#[derive(Clone, Debug, Deserialize)]
pub struct AdminPostPolishRequest {
    pub markdown: String,
}

#[derive(Clone, Debug, Deserialize)]
pub struct AdminPostLocalizeImagesRequest {
    pub markdown: String,
    #[serde(default)]
    pub prefix: Option<String>,
}

#[derive(Clone, Debug, Serialize)]
pub struct AdminPostLocalizedImageItem {
    pub source_url: String,
    pub localized_url: String,
    pub key: String,
}

#[derive(Clone, Debug, Serialize)]
pub struct AdminPostLocalizeImagesFailure {
    pub source_url: String,
    pub error: String,
}

#[derive(Clone, Debug, Serialize)]
pub struct AdminPostLocalizeImagesResponse {
    pub markdown: String,
    pub detected_count: usize,
    pub localized_count: usize,
    pub uploaded_count: usize,
    pub failed_count: usize,
    pub items: Vec<AdminPostLocalizedImageItem>,
    pub failures: Vec<AdminPostLocalizeImagesFailure>,
}

#[derive(Clone, Debug, Deserialize)]
pub struct AdminReviewPolishRequest {
    pub title: String,
    pub review_type: String,
    pub rating: i32,
    pub review_date: Option<String>,
    pub status: String,
    #[serde(default)]
    pub tags: Vec<String>,
    pub description: String,
}

#[derive(Clone, Debug, Deserialize)]
pub struct AdminPostCoverImageRequest {
    pub title: String,
    pub description: Option<String>,
    pub category: Option<String>,
    #[serde(default)]
    pub tags: Vec<String>,
    pub post_type: String,
    pub slug: Option<String>,
    pub markdown: String,
}

fn format_timestamp(
    value: Option<sea_orm::prelude::DateTimeWithTimeZone>,
    pattern: &str,
) -> Option<String> {
    value.map(|item| item.format(pattern).to_string())
}

fn required_text(value: Option<&str>, fallback: &str) -> String {
    value
        .map(str::trim)
        .filter(|item| !item.is_empty())
        .unwrap_or(fallback)
        .to_string()
}

fn trim_to_option(value: Option<String>) -> Option<String> {
    value.and_then(|item| {
        let trimmed = item.trim().to_string();
        if trimmed.is_empty() {
            None
        } else {
            Some(trimmed)
        }
    })
}

fn normalize_localize_image_prefix(value: Option<String>) -> String {
    trim_to_option(value)
        .map(|item| item.trim_matches('/').to_string())
        .filter(|item| !item.is_empty())
        .unwrap_or_else(|| "post-inline-images".to_string())
}

fn normalize_markdown_image_target(value: &str) -> Option<String> {
    let trimmed = value.trim();
    if trimmed.is_empty() {
        return None;
    }

    if trimmed.starts_with('<') && trimmed.ends_with('>') && trimmed.len() > 2 {
        Some(trimmed[1..trimmed.len() - 1].trim().to_string())
    } else {
        Some(trimmed.to_string())
    }
}

fn markdown_image_reference_urls(markdown: &str) -> Vec<String> {
    let markdown_pattern =
        Regex::new(r#"!\[[^\]]*]\((?P<url><[^>\n]+>|[^)\s]+)(?:\s+(?:"[^"]*"|'[^']*'))?\)"#)
            .expect("valid markdown image regex");
    let html_double_quote_pattern = Regex::new(r#"(?i)<img\b[^>]*?\bsrc\s*=\s*"(?P<url>[^"]+)""#)
        .expect("valid html img double quote regex");
    let html_single_quote_pattern = Regex::new(r#"(?i)<img\b[^>]*?\bsrc\s*=\s*'(?P<url>[^']+)'"#)
        .expect("valid html img single quote regex");

    let mut urls = Vec::new();

    for captures in markdown_pattern.captures_iter(markdown) {
        if let Some(url) = captures
            .name("url")
            .and_then(|item| normalize_markdown_image_target(item.as_str()))
        {
            urls.push(url);
        }
    }

    for captures in html_double_quote_pattern.captures_iter(markdown) {
        if let Some(url) = captures
            .name("url")
            .and_then(|item| normalize_markdown_image_target(item.as_str()))
        {
            urls.push(url);
        }
    }

    for captures in html_single_quote_pattern.captures_iter(markdown) {
        if let Some(url) = captures
            .name("url")
            .and_then(|item| normalize_markdown_image_target(item.as_str()))
        {
            urls.push(url);
        }
    }

    urls
}

fn is_remote_markdown_image_candidate(
    url: &str,
    settings: Option<&storage::MediaStorageSettings>,
) -> bool {
    let Ok(parsed) = Url::parse(url) else {
        return false;
    };

    if !matches!(parsed.scheme(), "http" | "https") {
        return false;
    }

    if settings
        .and_then(|item| storage::object_key_from_public_url(item, url))
        .is_some()
    {
        return false;
    }

    true
}

fn replace_markdown_image_urls(
    markdown: &str,
    replacements: &HashMap<String, String>,
) -> (String, usize) {
    let markdown_pattern = Regex::new(
        r#"(?P<lead>!\[[^\]]*]\()(?P<url><[^>\n]+>|[^)\s]+)(?P<trail>(?:\s+(?:"[^"]*"|'[^']*'))?\))"#,
    )
    .expect("valid markdown image replacement regex");
    let html_double_quote_pattern =
        Regex::new(r#"(?i)(?P<lead><img\b[^>]*?\bsrc\s*=\s*")(?P<url>[^"]+)(?P<trail>"[^>]*>)"#)
            .expect("valid html img double quote replacement regex");
    let html_single_quote_pattern =
        Regex::new(r#"(?i)(?P<lead><img\b[^>]*?\bsrc\s*=\s*')(?P<url>[^']+)(?P<trail>'[^>]*>)"#)
            .expect("valid html img single quote replacement regex");

    let mut localized_count = 0usize;

    let after_markdown = markdown_pattern
        .replace_all(markdown, |captures: &regex::Captures<'_>| {
            let raw_url = captures
                .name("url")
                .map(|item| item.as_str())
                .unwrap_or_default();
            let Some(normalized_url) = normalize_markdown_image_target(raw_url) else {
                return captures
                    .get(0)
                    .map(|item| item.as_str())
                    .unwrap_or_default()
                    .to_string();
            };

            if let Some(localized_url) = replacements.get(&normalized_url) {
                localized_count += 1;
                format!(
                    "{}{}{}",
                    &captures["lead"], localized_url, &captures["trail"]
                )
            } else {
                captures
                    .get(0)
                    .map(|item| item.as_str())
                    .unwrap_or_default()
                    .to_string()
            }
        })
        .to_string();

    let after_html_double = html_double_quote_pattern
        .replace_all(&after_markdown, |captures: &regex::Captures<'_>| {
            let raw_url = captures
                .name("url")
                .map(|item| item.as_str())
                .unwrap_or_default();
            let Some(normalized_url) = normalize_markdown_image_target(raw_url) else {
                return captures
                    .get(0)
                    .map(|item| item.as_str())
                    .unwrap_or_default()
                    .to_string();
            };

            if let Some(localized_url) = replacements.get(&normalized_url) {
                localized_count += 1;
                format!(
                    "{}{}{}",
                    &captures["lead"], localized_url, &captures["trail"]
                )
            } else {
                captures
                    .get(0)
                    .map(|item| item.as_str())
                    .unwrap_or_default()
                    .to_string()
            }
        })
        .to_string();

    let after_html_single = html_single_quote_pattern
        .replace_all(&after_html_double, |captures: &regex::Captures<'_>| {
            let raw_url = captures
                .name("url")
                .map(|item| item.as_str())
                .unwrap_or_default();
            let Some(normalized_url) = normalize_markdown_image_target(raw_url) else {
                return captures
                    .get(0)
                    .map(|item| item.as_str())
                    .unwrap_or_default()
                    .to_string();
            };

            if let Some(localized_url) = replacements.get(&normalized_url) {
                localized_count += 1;
                format!(
                    "{}{}{}",
                    &captures["lead"], localized_url, &captures["trail"]
                )
            } else {
                captures
                    .get(0)
                    .map(|item| item.as_str())
                    .unwrap_or_default()
                    .to_string()
            }
        })
        .to_string();

    (after_html_single, localized_count)
}

fn parse_optional_timestamp(
    value: Option<&str>,
) -> Result<Option<chrono::DateTime<chrono::FixedOffset>>> {
    let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
        return Ok(None);
    };

    chrono::DateTime::parse_from_rfc3339(value)
        .map(Some)
        .map_err(|_| Error::BadRequest("expires_at 必须是 RFC3339 时间格式".to_string()))
}

fn parse_optional_datetime_utc(
    value: Option<&str>,
) -> Result<Option<chrono::DateTime<chrono::Utc>>> {
    let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
        return Ok(None);
    };

    if let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(value) {
        return Ok(Some(parsed.with_timezone(&chrono::Utc)));
    }

    if let Ok(date_only) = chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d") {
        let Some(naive) = date_only.and_hms_opt(0, 0, 0) else {
            return Ok(None);
        };
        return Ok(Some(
            chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(naive, chrono::Utc),
        ));
    }

    Err(Error::BadRequest(
        "from/to 必须是 RFC3339 或 YYYY-MM-DD 格式".to_string(),
    ))
}

fn truncate_chars(value: &str, max_chars: usize) -> String {
    let trimmed = value.trim();
    if trimmed.chars().count() <= max_chars {
        return trimmed.to_string();
    }

    let mut sliced = trimmed.chars().take(max_chars).collect::<String>();
    sliced.push_str("...");
    sliced
}

async fn save_comment_persona_analysis_log(
    ctx: &AppContext,
    matcher_type: &str,
    matcher_value: &str,
    from: Option<chrono::DateTime<chrono::Utc>>,
    to: Option<chrono::DateTime<chrono::Utc>>,
    total_comments: u64,
    pending_comments: u64,
    distinct_posts: usize,
    analysis_text: &str,
    samples: &[AdminCommentAnalyzeSample],
) -> Result<()> {
    let sample_json = serde_json::to_value(samples).ok();

    comment_persona_analysis_logs::ActiveModel {
        matcher_type: Set(matcher_type.to_string()),
        matcher_value: Set(matcher_value.to_string()),
        from_at: Set(from.map(|value| value.fixed_offset())),
        to_at: Set(to.map(|value| value.fixed_offset())),
        total_comments: Set(total_comments.min(i32::MAX as u64) as i32),
        pending_comments: Set(pending_comments.min(i32::MAX as u64) as i32),
        distinct_posts: Set(distinct_posts.min(i32::MAX as usize) as i32),
        analysis_text: Set(analysis_text.to_string()),
        sample_json: Set(sample_json),
        ..Default::default()
    }
    .insert(&ctx.db)
    .await?;

    Ok(())
}

fn format_comment_analyze_log_item(
    item: comment_persona_analysis_logs::Model,
) -> AdminCommentAnalyzeLogItem {
    let samples = item
        .sample_json
        .clone()
        .and_then(|value| serde_json::from_value::<Vec<AdminCommentAnalyzeSample>>(value).ok())
        .unwrap_or_default();

    AdminCommentAnalyzeLogItem {
        id: item.id,
        matcher_type: item.matcher_type,
        matcher_value: item.matcher_value,
        from_at: format_timestamp(item.from_at, "%Y-%m-%d %H:%M:%S UTC"),
        to_at: format_timestamp(item.to_at, "%Y-%m-%d %H:%M:%S UTC"),
        total_comments: item.total_comments.max(0) as u64,
        pending_comments: item.pending_comments.max(0) as u64,
        distinct_posts: item.distinct_posts.max(0) as usize,
        analysis: item.analysis_text,
        samples,
        created_at: item.created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
    }
}

fn format_comment_blacklist_item(item: comment_blacklist::Model) -> AdminCommentBlacklistItem {
    let now = chrono::Utc::now();
    let active = item.active.unwrap_or(true);
    let not_expired = item
        .expires_at
        .map(|value| chrono::DateTime::<chrono::Utc>::from(value) > now)
        .unwrap_or(true);

    AdminCommentBlacklistItem {
        id: item.id,
        matcher_type: item.matcher_type,
        matcher_value: item.matcher_value,
        reason: item.reason,
        active,
        expires_at: format_timestamp(item.expires_at, "%Y-%m-%d %H:%M:%S UTC"),
        created_at: item.created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
        updated_at: item.updated_at.format("%Y-%m-%d %H:%M:%S").to_string(),
        effective: active && not_expired,
    }
}

fn infer_media_extension(file_name: Option<&str>, content_type: Option<&str>) -> String {
    let from_name = file_name
        .and_then(|name| name.rsplit('.').next())
        .map(str::trim)
        .filter(|ext| !ext.is_empty())
        .map(str::to_ascii_lowercase);

    if let Some(ext) = from_name
        .as_deref()
        .filter(|ext| ext.chars().all(|ch| ch.is_ascii_alphanumeric()) && ext.len() <= 10)
    {
        return ext.to_string();
    }

    match content_type
        .unwrap_or_default()
        .trim()
        .to_ascii_lowercase()
        .as_str()
    {
        "image/png" => "png".to_string(),
        "image/jpeg" => "jpg".to_string(),
        "image/webp" => "webp".to_string(),
        "image/gif" => "gif".to_string(),
        "image/avif" => "avif".to_string(),
        "image/svg+xml" => "svg".to_string(),
        "application/pdf" => "pdf".to_string(),
        _ => "bin".to_string(),
    }
}

fn normalize_media_key(value: Option<String>) -> Option<String> {
    value.and_then(|raw| {
        let trimmed = raw.trim().trim_start_matches('/').to_string();
        if trimmed.is_empty() {
            None
        } else {
            Some(trimmed)
        }
    })
}

fn build_media_object_response(
    item: storage::StoredObjectSummary,
    metadata: Option<&crate::models::_entities::media_assets::Model>,
) -> AdminMediaObjectResponse {
    AdminMediaObjectResponse {
        key: item.key,
        url: item.url,
        size_bytes: item.size_bytes,
        last_modified: item.last_modified,
        title: metadata.and_then(|entry| entry.title.clone()),
        alt_text: metadata.and_then(|entry| entry.alt_text.clone()),
        caption: metadata.and_then(|entry| entry.caption.clone()),
        tags: metadata.map(media_assets::tag_list).unwrap_or_default(),
        notes: metadata.and_then(|entry| entry.notes.clone()),
    }
}

fn tech_stack_values(value: &Option<serde_json::Value>) -> Vec<String> {
    value
        .as_ref()
        .and_then(serde_json::Value::as_array)
        .cloned()
        .unwrap_or_default()
        .into_iter()
        .filter_map(|item| item.as_str().map(ToString::to_string))
        .collect()
}

fn music_playlist_values(
    value: &Option<serde_json::Value>,
) -> Vec<site_settings::MusicTrackPayload> {
    value
        .as_ref()
        .and_then(serde_json::Value::as_array)
        .cloned()
        .unwrap_or_default()
        .into_iter()
        .filter_map(|item| serde_json::from_value::<site_settings::MusicTrackPayload>(item).ok())
        .filter(|item| !item.title.trim().is_empty() && !item.url.trim().is_empty())
        .collect()
}

fn build_settings_response(
    item: crate::models::_entities::site_settings::Model,
    ai_chunks_count: u64,
) -> AdminSiteSettingsResponse {
    let ai_providers = site_settings::ai_provider_configs(&item);
    let ai_active_provider_id = site_settings::active_ai_provider_id(&item);
    let comment_verification_mode = crate::services::turnstile::selected_mode(
        &item,
        crate::services::turnstile::TurnstileScope::Comment,
    );
    let subscription_verification_mode = crate::services::turnstile::selected_mode(
        &item,
        crate::services::turnstile::TurnstileScope::Subscription,
    );
    let turnstile_site_key = crate::services::turnstile::site_key(&item);
    let turnstile_secret_key = crate::services::turnstile::secret_key(&item);
    let web_push_vapid_public_key = crate::services::web_push::public_key(&item);
    let web_push_vapid_private_key = crate::services::web_push::private_key(&item);
    let web_push_vapid_subject = crate::services::web_push::vapid_subject(&item);

    AdminSiteSettingsResponse {
        id: item.id,
        site_name: item.site_name,
        site_short_name: item.site_short_name,
        site_url: item.site_url,
        site_title: item.site_title,
        site_description: item.site_description,
        hero_title: item.hero_title,
        hero_subtitle: item.hero_subtitle,
        owner_name: item.owner_name,
        owner_title: item.owner_title,
        owner_bio: item.owner_bio,
        owner_avatar_url: item.owner_avatar_url,
        social_github: item.social_github,
        social_twitter: item.social_twitter,
        social_email: item.social_email,
        location: item.location,
        tech_stack: tech_stack_values(&item.tech_stack),
        music_playlist: music_playlist_values(&item.music_playlist),
        music_enabled: item.music_enabled.unwrap_or(true),
        maintenance_mode_enabled: item.maintenance_mode_enabled.unwrap_or(false),
        maintenance_access_code: item.maintenance_access_code,
        ai_enabled: item.ai_enabled.unwrap_or(false),
        paragraph_comments_enabled: item.paragraph_comments_enabled.unwrap_or(true),
        comment_verification_mode: comment_verification_mode.as_str().to_string(),
        comment_turnstile_enabled: matches!(
            comment_verification_mode,
            crate::services::turnstile::VerificationMode::Turnstile
        ),
        subscription_verification_mode: subscription_verification_mode.as_str().to_string(),
        subscription_turnstile_enabled: matches!(
            subscription_verification_mode,
            crate::services::turnstile::VerificationMode::Turnstile
        ),
        web_push_enabled: item.web_push_enabled.unwrap_or(false),
        turnstile_site_key,
        turnstile_secret_key,
        web_push_vapid_public_key,
        web_push_vapid_private_key,
        web_push_vapid_subject,
        ai_provider: item.ai_provider,
        ai_api_base: item.ai_api_base,
        ai_api_key: item.ai_api_key,
        ai_chat_model: item.ai_chat_model,
        ai_image_provider: item.ai_image_provider,
        ai_image_api_base: item.ai_image_api_base,
        ai_image_api_key: item.ai_image_api_key,
        ai_image_model: item.ai_image_model,
        ai_providers,
        ai_active_provider_id,
        ai_embedding_model: item.ai_embedding_model,
        ai_system_prompt: item.ai_system_prompt,
        ai_top_k: item.ai_top_k,
        ai_chunk_size: item.ai_chunk_size,
        ai_last_indexed_at: format_timestamp(item.ai_last_indexed_at, "%Y-%m-%d %H:%M:%S UTC"),
        ai_chunks_count,
        ai_local_embedding: ai::local_embedding_label().to_string(),
        media_storage_provider: item.media_storage_provider,
        media_r2_account_id: item.media_r2_account_id,
        media_r2_bucket: item.media_r2_bucket,
        media_r2_public_base_url: item.media_r2_public_base_url,
        media_r2_access_key_id: item.media_r2_access_key_id,
        media_r2_secret_access_key: item.media_r2_secret_access_key,
        seo_favicon_url: item.seo_favicon_url,
        seo_default_og_image: item.seo_default_og_image,
        seo_default_twitter_handle: item.seo_default_twitter_handle,
        seo_wechat_share_qr_enabled: item.seo_wechat_share_qr_enabled.unwrap_or(false),
        notification_webhook_url: item.notification_webhook_url,
        notification_channel_type: item
            .notification_channel_type
            .unwrap_or_else(|| "webhook".to_string()),
        notification_comment_enabled: item.notification_comment_enabled.unwrap_or(false),
        notification_friend_link_enabled: item.notification_friend_link_enabled.unwrap_or(false),
        subscription_popup_enabled: item
            .subscription_popup_enabled
            .unwrap_or_else(site_settings::default_subscription_popup_enabled),
        subscription_popup_title: item
            .subscription_popup_title
            .unwrap_or_else(site_settings::default_subscription_popup_title),
        subscription_popup_description: item
            .subscription_popup_description
            .unwrap_or_else(site_settings::default_subscription_popup_description),
        subscription_popup_delay_seconds: item
            .subscription_popup_delay_seconds
            .unwrap_or_else(site_settings::default_subscription_popup_delay_seconds),
        search_synonyms: tech_stack_values(&item.search_synonyms),
    }
}

#[debug_handler]
pub async fn session_status(headers: HeaderMap) -> Result<Response> {
    format::json(build_session_response(resolve_admin_identity(&headers)))
}

#[debug_handler]
pub async fn session_login(
    State(ctx): State<AppContext>,
    Json(payload): Json<AdminLoginPayload>,
) -> Result<Response> {
    if !local_login_enabled() {
        return unauthorized("Local admin login is disabled");
    }

    if !validate_admin_credentials(payload.username.trim(), payload.password.trim()) {
        return unauthorized("Invalid credentials");
    }

    let (identity, _token, cookie) = start_local_session(&admin_username());
    admin_audit::log_event(
        &ctx,
        Some(&identity),
        "admin.login",
        "admin_session",
        None,
        Some(identity.username.clone()),
        None,
    )
    .await?;
    let mut response = format::json(build_session_response(Some(identity.clone())))?;
    response.headers_mut().append(
        header::SET_COOKIE,
        cookie
            .parse()
            .map_err(|error| Error::BadRequest(format!("invalid session cookie: {error}")))?,
    );

    Ok(response)
}

#[debug_handler]
pub async fn session_logout(headers: HeaderMap, State(ctx): State<AppContext>) -> Result<Response> {
    let before = resolve_admin_identity(&headers);
    if matches!(
        before.as_ref().map(|item| item.source.as_str()),
        Some("local")
    ) {
        clear_local_session(&headers);
    }

    if let Some(identity) = before.as_ref() {
        admin_audit::log_event(
            &ctx,
            Some(identity),
            "admin.logout",
            "admin_session",
            None,
            identity
                .email
                .clone()
                .or_else(|| Some(identity.username.clone())),
            None,
        )
        .await?;
    }

    let after = resolve_admin_identity(&headers).filter(|item| item.source != "local");
    let mut response = format::json(build_session_response(after))?;
    response.headers_mut().append(
        header::SET_COOKIE,
        clear_local_session_cookie()
            .parse()
            .map_err(|error| Error::BadRequest(format!("invalid logout cookie: {error}")))?,
    );

    Ok(response)
}

#[debug_handler]
pub async fn dashboard(headers: HeaderMap, State(ctx): State<AppContext>) -> Result<Response> {
    check_auth(&headers)?;

    let all_posts = posts::Entity::find().all(&ctx.db).await?;
    let total_posts = all_posts.len() as u64;
    let total_comments = comments::Entity::find().count(&ctx.db).await?;
    let pending_comments = comments::Entity::find()
        .filter(comments::Column::Approved.eq(false))
        .count(&ctx.db)
        .await?;
    let total_categories = crate::models::_entities::categories::Entity::find()
        .count(&ctx.db)
        .await?;
    let total_tags = crate::models::_entities::tags::Entity::find()
        .count(&ctx.db)
        .await?;
    let total_reviews = reviews::Entity::find().count(&ctx.db).await?;
    let total_links = friend_links::Entity::find().count(&ctx.db).await?;
    let pending_links = friend_links::Entity::find()
        .filter(friend_links::Column::Status.eq("pending"))
        .count(&ctx.db)
        .await?;
    let ai_chunks_count = ai_chunks::Entity::find().count(&ctx.db).await?;
    let site_settings = site_settings::load_current(&ctx).await?;
    let now = chrono::Utc::now().fixed_offset();

    let mut draft_posts = 0_u64;
    let mut scheduled_posts = 0_u64;
    let mut offline_posts = 0_u64;
    let mut expired_posts = 0_u64;
    let mut private_posts = 0_u64;
    let mut unlisted_posts = 0_u64;

    for post in &all_posts {
        let effective_state = content::effective_post_state(
            post.status
                .as_deref()
                .unwrap_or(content::POST_STATUS_PUBLISHED),
            post.publish_at,
            post.unpublish_at,
            now,
        );
        let visibility = content::normalize_post_visibility(post.visibility.as_deref());

        match effective_state.as_str() {
            content::POST_STATUS_DRAFT => draft_posts += 1,
            content::POST_STATUS_OFFLINE => offline_posts += 1,
            "scheduled" => scheduled_posts += 1,
            "expired" => expired_posts += 1,
            _ => {}
        }

        match visibility.as_str() {
            content::POST_VISIBILITY_PRIVATE => private_posts += 1,
            content::POST_VISIBILITY_UNLISTED => unlisted_posts += 1,
            _ => {}
        }
    }

    let mut recent_posts = all_posts.clone().into_iter().collect::<Vec<_>>();
    recent_posts.sort_by(|left, right| right.created_at.cmp(&left.created_at));
    let recent_posts = recent_posts
        .into_iter()
        .take(6)
        .map(|post| DashboardPostItem {
            id: post.id,
            title: required_text(post.title.as_deref(), "Untitled post"),
            slug: post.slug,
            category: required_text(post.category.as_deref(), "Uncategorized"),
            post_type: required_text(post.post_type.as_deref(), "article"),
            pinned: post.pinned.unwrap_or(false),
            status: content::effective_post_state(
                post.status
                    .as_deref()
                    .unwrap_or(content::POST_STATUS_PUBLISHED),
                post.publish_at,
                post.unpublish_at,
                now,
            ),
            visibility: content::normalize_post_visibility(post.visibility.as_deref()),
            created_at: post.created_at.format("%Y-%m-%d %H:%M").to_string(),
        })
        .collect::<Vec<_>>();

    let pending_comment_rows = comments::Entity::find()
        .filter(comments::Column::Approved.eq(false))
        .order_by_desc(comments::Column::CreatedAt)
        .limit(8)
        .all(&ctx.db)
        .await?
        .into_iter()
        .map(|comment| DashboardCommentItem {
            id: comment.id,
            author: required_text(comment.author.as_deref(), "Anonymous"),
            post_slug: required_text(comment.post_slug.as_deref(), "unknown-post"),
            scope: required_text(Some(comment.scope.as_str()), "global"),
            excerpt: required_text(comment.content.as_deref(), ""),
            approved: comment.approved.unwrap_or(false),
            created_at: comment.created_at.format("%Y-%m-%d %H:%M").to_string(),
        })
        .collect::<Vec<_>>();

    let pending_friend_links = friend_links::Entity::find()
        .filter(friend_links::Column::Status.eq("pending"))
        .order_by_desc(friend_links::Column::CreatedAt)
        .limit(6)
        .all(&ctx.db)
        .await?
        .into_iter()
        .map(|link| DashboardFriendLinkItem {
            id: link.id,
            site_name: required_text(link.site_name.as_deref(), "Unnamed site"),
            site_url: link.site_url,
            category: required_text(link.category.as_deref(), "Other"),
            status: required_text(link.status.as_deref(), "pending"),
            created_at: link.created_at.format("%Y-%m-%d %H:%M").to_string(),
        })
        .collect::<Vec<_>>();

    let recent_reviews = reviews::Entity::find()
        .order_by_desc(reviews::Column::CreatedAt)
        .limit(6)
        .all(&ctx.db)
        .await?
        .into_iter()
        .map(|review| DashboardReviewItem {
            id: review.id,
            title: required_text(review.title.as_deref(), "Untitled review"),
            review_type: required_text(review.review_type.as_deref(), "game"),
            rating: review.rating.unwrap_or(0),
            status: required_text(review.status.as_deref(), "completed"),
            review_date: required_text(review.review_date.as_deref(), ""),
        })
        .collect::<Vec<_>>();

    format::json(AdminDashboardResponse {
        stats: DashboardStats {
            total_posts,
            total_comments,
            pending_comments,
            draft_posts,
            scheduled_posts,
            offline_posts,
            expired_posts,
            private_posts,
            unlisted_posts,
            total_categories,
            total_tags,
            total_reviews,
            total_links,
            pending_links,
            ai_chunks: ai_chunks_count,
            ai_enabled: site_settings.ai_enabled.unwrap_or(false),
        },
        site: DashboardSiteSummary {
            site_name: required_text(site_settings.site_name.as_deref(), "Unnamed site"),
            site_url: required_text(site_settings.site_url.as_deref(), ""),
            ai_enabled: site_settings.ai_enabled.unwrap_or(false),
            ai_chunks: ai_chunks_count,
            ai_last_indexed_at: format_timestamp(
                site_settings.ai_last_indexed_at,
                "%Y-%m-%d %H:%M:%S UTC",
            ),
        },
        recent_posts,
        pending_comments: pending_comment_rows,
        pending_friend_links,
        recent_reviews,
    })
}

#[debug_handler]
pub async fn analytics_overview(
    headers: HeaderMap,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    check_auth(&headers)?;
    format::json(analytics::build_admin_analytics(&ctx).await?)
}

#[debug_handler]
pub async fn get_site_settings(
    headers: HeaderMap,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    check_auth(&headers)?;
    let current = site_settings::load_current(&ctx).await?;
    let ai_chunks_count = ai_chunks::Entity::find().count(&ctx.db).await?;
    format::json(build_settings_response(current, ai_chunks_count))
}

#[debug_handler]
pub async fn update_site_settings(
    headers: HeaderMap,
    State(ctx): State<AppContext>,
    Json(params): Json<SiteSettingsPayload>,
) -> Result<Response> {
    let actor = check_auth(&headers)?;

    let current = site_settings::load_current(&ctx).await?;
    let mut item = current;
    params.apply(&mut item);
    let item = item.into_active_model().reset_all();
    let updated = item.update(&ctx.db).await?;
    let ai_chunks_count = ai_chunks::Entity::find().count(&ctx.db).await?;
    admin_audit::log_event(
        &ctx,
        Some(&actor),
        "site_settings.update",
        "site_settings",
        Some(updated.id.to_string()),
        updated.site_name.clone(),
        None,
    )
    .await?;

    format::json(build_settings_response(updated, ai_chunks_count))
}

#[debug_handler]
pub async fn reindex_ai(headers: HeaderMap, State(ctx): State<AppContext>) -> Result<Response> {
    let actor = check_auth(&headers)?;
    let job = worker_jobs::queue_ai_reindex_job(
        &ctx,
        Some(actor.username.clone()),
        Some(actor.source.clone()),
        None,
        Some("manual".to_string()),
    )
    .await?;

    admin_audit::log_event(
        &ctx,
        Some(&actor),
        "worker.ai_reindex",
        "worker_job",
        Some(job.id.to_string()),
        Some(job.worker_name.clone()),
        None,
    )
    .await?;

    format::json(AdminAiReindexResponse { queued: true, job })
}

#[debug_handler]
pub async fn test_ai_provider(
    headers: HeaderMap,
    Json(payload): Json<AdminAiProviderTestRequest>,
) -> Result<Response> {
    check_auth(&headers)?;

    let result = ai::test_provider_connectivity(
        &payload.provider.provider,
        payload.provider.api_base.as_deref().unwrap_or_default(),
        payload.provider.api_key.as_deref().unwrap_or_default(),
        payload.provider.chat_model.as_deref().unwrap_or_default(),
    )
    .await?;

    format::json(AdminAiProviderTestResponse {
        provider: result.provider,
        endpoint: result.endpoint,
        chat_model: result.chat_model,
        reply_preview: result.reply_preview,
    })
}

#[debug_handler]
pub async fn test_ai_image_provider(
    headers: HeaderMap,
    Json(payload): Json<AdminAiImageProviderTestRequest>,
) -> Result<Response> {
    check_auth(&headers)?;

    let result = ai::test_image_provider_connectivity(
        &payload.provider,
        &payload.api_base,
        &payload.api_key,
        &payload.image_model,
    )
    .await?;

    format::json(AdminAiImageProviderTestResponse {
        provider: result.provider,
        endpoint: result.endpoint,
        image_model: result.image_model,
        result_preview: result.result_preview,
    })
}

#[debug_handler]
pub async fn test_r2_storage(
    headers: HeaderMap,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    check_auth(&headers)?;

    let settings = storage::require_r2_settings(&ctx).await?;
    let bucket = storage::test_r2_connectivity(&ctx).await?;

    format::json(AdminR2ConnectivityResponse {
        bucket,
        public_base_url: settings.public_base_url,
    })
}

#[debug_handler]
pub async fn list_media_objects(
    headers: HeaderMap,
    State(ctx): State<AppContext>,
    Query(query): Query<AdminMediaListQuery>,
) -> Result<Response> {
    check_auth(&headers)?;

    let settings = storage::require_r2_settings(&ctx).await?;
    let objects =
        storage::list_objects(&ctx, query.prefix.as_deref(), query.limit.unwrap_or(200)).await?;
    let keys = objects
        .iter()
        .map(|item| item.key.clone())
        .collect::<Vec<_>>();
    let metadata_map = media_assets::list_by_keys(&ctx, &keys).await?;
    let items = objects
        .into_iter()
        .map(|item| {
            let metadata = metadata_map.get(&item.key);
            build_media_object_response(item, metadata)
        })
        .collect::<Vec<_>>();

    format::json(AdminMediaListResponse {
        provider: settings.provider_name,
        bucket: settings.bucket,
        public_base_url: settings.public_base_url,
        items,
    })
}

#[debug_handler]
pub async fn delete_media_object(
    headers: HeaderMap,
    State(ctx): State<AppContext>,
    Query(query): Query<AdminMediaDeleteQuery>,
) -> Result<Response> {
    check_auth(&headers)?;

    let key = query.key.trim();
    if key.is_empty() {
        return Err(Error::BadRequest("缺少对象 key".to_string()));
    }

    storage::delete_object(&ctx, key).await?;
    if let Err(error) = media_assets::delete_by_key(&ctx, key).await {
        tracing::warn!(
            ?error,
            key,
            "failed to delete media metadata after object deletion"
        );
    }

    format::json(AdminMediaDeleteResponse {
        deleted: true,
        key: key.to_string(),
    })
}

#[debug_handler]
pub async fn upload_media_objects(
    headers: HeaderMap,
    State(ctx): State<AppContext>,
    mut multipart: Multipart,
) -> Result<Response> {
    check_auth(&headers)?;

    let mut prefix = "uploads".to_string();
    let mut uploaded = Vec::<AdminMediaUploadItem>::new();

    while let Some(field) = multipart
        .next_field()
        .await
        .map_err(|error| Error::BadRequest(error.to_string()))?
    {
        let name = field.name().unwrap_or_default().to_string();
        if name == "prefix" {
            if let Ok(value) = field.text().await {
                if let Some(next_prefix) = trim_to_option(Some(value)) {
                    prefix = next_prefix.trim_matches('/').to_string();
                }
            }
            continue;
        }

        let file_name = field.file_name().map(ToString::to_string);
        let content_type = field.content_type().map(ToString::to_string);
        let bytes = field
            .bytes()
            .await
            .map_err(|error| Error::BadRequest(error.to_string()))?;

        if bytes.is_empty() {
            continue;
        }

        let extension = infer_media_extension(file_name.as_deref(), content_type.as_deref());
        let key =
            storage::build_object_key(&prefix, file_name.as_deref().unwrap_or("asset"), &extension);
        let stored = storage::upload_bytes_to_r2(
            &ctx,
            &key,
            bytes.to_vec(),
            content_type.as_deref(),
            Some("public, max-age=31536000, immutable"),
        )
        .await?;

        uploaded.push(AdminMediaUploadItem {
            key: stored.key,
            url: stored.url,
            size_bytes: bytes.len() as i64,
        });
    }

    if uploaded.is_empty() {
        return Err(Error::BadRequest("请至少选择一个文件上传".to_string()));
    }

    format::json(AdminMediaUploadResponse { uploaded })
}

#[debug_handler]
pub async fn batch_delete_media_objects(
    headers: HeaderMap,
    State(ctx): State<AppContext>,
    Json(payload): Json<AdminMediaBatchDeleteRequest>,
) -> Result<Response> {
    check_auth(&headers)?;

    let keys = payload
        .keys
        .into_iter()
        .filter_map(|key| normalize_media_key(Some(key)))
        .collect::<Vec<_>>();

    if keys.is_empty() {
        return Err(Error::BadRequest("请至少传入一个对象 key".to_string()));
    }

    let mut deleted = Vec::new();
    let mut failed = Vec::new();

    for key in keys {
        match storage::delete_object(&ctx, &key).await {
            Ok(()) => {
                if let Err(error) = media_assets::delete_by_key(&ctx, &key).await {
                    tracing::warn!(
                        ?error,
                        key,
                        "failed to delete media metadata after batch removal"
                    );
                }
                deleted.push(key)
            }
            Err(_) => failed.push(key),
        }
    }

    format::json(AdminMediaBatchDeleteResponse { deleted, failed })
}

#[debug_handler]
pub async fn update_media_object_metadata(
    headers: HeaderMap,
    State(ctx): State<AppContext>,
    Json(payload): Json<AdminMediaMetadataPayload>,
) -> Result<Response> {
    check_auth(&headers)?;

    let key = payload.key.trim();
    if key.is_empty() {
        return Err(Error::BadRequest("缺少对象 key".to_string()));
    }

    let metadata = media_assets::upsert_by_key(
        &ctx,
        key,
        media_assets::MediaAssetMetadataInput {
            title: payload.title,
            alt_text: payload.alt_text,
            caption: payload.caption,
            tags: payload.tags,
            notes: payload.notes,
        },
    )
    .await?;

    format::json(AdminMediaMetadataResponse {
        saved: true,
        key: metadata.object_key.clone(),
        title: metadata.title.clone(),
        alt_text: metadata.alt_text.clone(),
        caption: metadata.caption.clone(),
        tags: media_assets::tag_list(&metadata),
        notes: metadata.notes.clone(),
    })
}

#[debug_handler]
pub async fn replace_media_object(
    headers: HeaderMap,
    State(ctx): State<AppContext>,
    mut multipart: Multipart,
) -> Result<Response> {
    check_auth(&headers)?;

    let mut key: Option<String> = None;
    let mut bytes: Option<Vec<u8>> = None;
    let mut content_type: Option<String> = None;

    while let Some(field) = multipart
        .next_field()
        .await
        .map_err(|error| Error::BadRequest(error.to_string()))?
    {
        let name = field.name().unwrap_or_default().to_string();
        if name == "key" {
            let text = field
                .text()
                .await
                .map_err(|error| Error::BadRequest(error.to_string()))?;
            key = normalize_media_key(Some(text));
            continue;
        }

        if bytes.is_none() {
            content_type = field.content_type().map(ToString::to_string);
            bytes = Some(
                field
                    .bytes()
                    .await
                    .map_err(|error| Error::BadRequest(error.to_string()))?
                    .to_vec(),
            );
        }
    }

    let key = key.ok_or_else(|| Error::BadRequest("缺少待替换对象 key".to_string()))?;
    let bytes = bytes.ok_or_else(|| Error::BadRequest("请先选择替换文件".to_string()))?;

    if bytes.is_empty() {
        return Err(Error::BadRequest("替换文件内容为空".to_string()));
    }

    let stored = storage::upload_bytes_to_r2(
        &ctx,
        &key,
        bytes,
        content_type.as_deref(),
        Some("public, max-age=31536000, immutable"),
    )
    .await?;

    format::json(AdminMediaReplaceResponse {
        key: stored.key,
        url: stored.url,
    })
}

#[debug_handler]
pub async fn download_media_object(
    headers: HeaderMap,
    State(ctx): State<AppContext>,
    Json(payload): Json<AdminMediaDownloadPayload>,
) -> Result<Response> {
    let actor = check_auth(&headers)?;
    let target_format = normalize_target_format(payload.target_format.clone())?;
    let worker_args = DownloadWorkerArgs {
        source_url: payload.source_url.clone(),
        prefix: payload.prefix.clone(),
        target_format,
        title: payload.title.clone(),
        alt_text: payload.alt_text.clone(),
        caption: payload.caption.clone(),
        tags: payload.tags.unwrap_or_default(),
        notes: payload.notes.clone(),
        job_id: None,
    };

    if payload.sync {
        let downloaded = download_media_to_storage(&ctx, &worker_args).await?;

        admin_audit::log_event(
            &ctx,
            Some(&actor),
            "media.download",
            "media",
            Some(downloaded.key.clone()),
            Some(payload.source_url.clone()),
            Some(serde_json::json!({
                "queued": false,
                "source_url": payload.source_url,
                "target_format": worker_args.target_format,
                "key": downloaded.key,
                "url": downloaded.url,
            })),
        )
        .await?;

        return format::json(AdminMediaDownloadResponse {
            queued: false,
            job_id: None,
            status: Some("completed".to_string()),
            key: Some(downloaded.key),
            url: Some(downloaded.url),
            size_bytes: Some(downloaded.size_bytes),
            content_type: downloaded.content_type,
        });
    }

    let job = worker_jobs::queue_download_job(
        &ctx,
        &worker_args,
        Some(actor.username.clone()),
        Some(actor.source.clone()),
        None,
        Some("manual".to_string()),
    )
    .await?;

    admin_audit::log_event(
        &ctx,
        Some(&actor),
        "media.download",
        "media",
        Some(job.id.to_string()),
        Some(payload.source_url.clone()),
        Some(serde_json::json!({
            "job_id": job.id,
            "queued": true,
            "source_url": payload.source_url,
            "target_format": worker_args.target_format,
        })),
    )
    .await?;

    format::json(AdminMediaDownloadResponse {
        queued: true,
        job_id: Some(job.id),
        status: Some(job.status),
        key: None,
        url: None,
        size_bytes: None,
        content_type: None,
    })
}

#[debug_handler]
pub async fn list_comment_blacklist(
    headers: HeaderMap,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    check_auth(&headers)?;

    let items = comment_blacklist::Entity::find()
        .order_by_desc(comment_blacklist::Column::CreatedAt)
        .all(&ctx.db)
        .await?
        .into_iter()
        .map(format_comment_blacklist_item)
        .collect::<Vec<_>>();

    format::json(items)
}

#[debug_handler]
pub async fn create_comment_blacklist(
    headers: HeaderMap,
    State(ctx): State<AppContext>,
    Json(payload): Json<AdminCommentBlacklistCreateRequest>,
) -> Result<Response> {
    check_auth(&headers)?;

    let matcher_type =
        comment_guard::normalize_matcher_type(&payload.matcher_type).ok_or_else(|| {
            Error::BadRequest("matcher_type 仅支持 ip / email / user_agent".to_string())
        })?;
    let matcher_value =
        comment_guard::normalize_matcher_value(matcher_type, &payload.matcher_value)
            .ok_or_else(|| Error::BadRequest("matcher_value 不能为空".to_string()))?;
    let expires_at = parse_optional_timestamp(payload.expires_at.as_deref())?;

    let item = comment_blacklist::ActiveModel {
        matcher_type: Set(matcher_type.to_string()),
        matcher_value: Set(matcher_value),
        reason: Set(trim_to_option(payload.reason)),
        active: Set(Some(payload.active.unwrap_or(true))),
        expires_at: Set(expires_at),
        ..Default::default()
    }
    .insert(&ctx.db)
    .await?;

    format::json(format_comment_blacklist_item(item))
}

#[debug_handler]
pub async fn update_comment_blacklist(
    headers: HeaderMap,
    Path(id): Path<i32>,
    State(ctx): State<AppContext>,
    Json(payload): Json<AdminCommentBlacklistUpdateRequest>,
) -> Result<Response> {
    check_auth(&headers)?;

    let item = comment_blacklist::Entity::find_by_id(id)
        .one(&ctx.db)
        .await?
        .ok_or(Error::NotFound)?;
    let mut item = item.into_active_model();

    if let Some(reason) = payload.reason {
        item.reason = Set(trim_to_option(Some(reason)));
    }

    if let Some(active) = payload.active {
        item.active = Set(Some(active));
    }

    if payload.clear_expires_at {
        item.expires_at = Set(None);
    } else if payload.expires_at.is_some() {
        item.expires_at = Set(parse_optional_timestamp(payload.expires_at.as_deref())?);
    }

    let updated = item.update(&ctx.db).await?;
    format::json(format_comment_blacklist_item(updated))
}

#[debug_handler]
pub async fn delete_comment_blacklist(
    headers: HeaderMap,
    Path(id): Path<i32>,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    check_auth(&headers)?;

    if let Some(item) = comment_blacklist::Entity::find_by_id(id)
        .one(&ctx.db)
        .await?
    {
        item.delete(&ctx.db).await?;
    }

    format::json(AdminCommentBlacklistDeleteResponse { deleted: true, id })
}

#[debug_handler]
pub async fn list_comment_persona_analysis_logs(
    headers: HeaderMap,
    State(ctx): State<AppContext>,
    Query(query): Query<AdminCommentAnalyzeLogsQuery>,
) -> Result<Response> {
    check_auth(&headers)?;

    let matcher_type = query
        .matcher_type
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(|value| {
            comment_guard::normalize_matcher_type(value).ok_or_else(|| {
                Error::BadRequest("matcher_type 仅支持 ip / email / user_agent".to_string())
            })
        })
        .transpose()?;

    let matcher_value = query
        .matcher_value
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(|value| {
            if let Some(matcher_type) = matcher_type {
                comment_guard::normalize_matcher_value(matcher_type, value)
            } else {
                Some(value.to_string())
            }
        })
        .flatten();

    if query.matcher_value.is_some() && matcher_value.is_none() {
        return Err(Error::BadRequest("matcher_value 不能为空".to_string()));
    }

    let mut query_builder = comment_persona_analysis_logs::Entity::find();

    if let Some(matcher_type) = matcher_type {
        query_builder = query_builder
            .filter(comment_persona_analysis_logs::Column::MatcherType.eq(matcher_type));
    }

    if let Some(matcher_value) = matcher_value {
        query_builder = query_builder
            .filter(comment_persona_analysis_logs::Column::MatcherValue.eq(matcher_value));
    }

    let limit = query.limit.unwrap_or(20).clamp(1, 100);

    let items = query_builder
        .order_by_desc(comment_persona_analysis_logs::Column::CreatedAt)
        .limit(limit)
        .all(&ctx.db)
        .await?
        .into_iter()
        .map(format_comment_analyze_log_item)
        .collect::<Vec<_>>();

    format::json(items)
}

#[debug_handler]
pub async fn analyze_comment_persona(
    headers: HeaderMap,
    State(ctx): State<AppContext>,
    Json(payload): Json<AdminCommentAnalyzeRequest>,
) -> Result<Response> {
    check_auth(&headers)?;

    let matcher_type =
        comment_guard::normalize_matcher_type(&payload.matcher_type).ok_or_else(|| {
            Error::BadRequest("matcher_type 仅支持 ip / email / user_agent".to_string())
        })?;
    let matcher_value =
        comment_guard::normalize_matcher_value(matcher_type, &payload.matcher_value)
            .ok_or_else(|| Error::BadRequest("matcher_value 不能为空".to_string()))?;
    let from = parse_optional_datetime_utc(payload.from.as_deref())?;
    let to = parse_optional_datetime_utc(payload.to.as_deref())?;
    let limit = payload.limit.unwrap_or(20).clamp(5, 80);

    let build_query = || {
        let mut query = comments::Entity::find();

        query = match matcher_type {
            comment_guard::MATCHER_TYPE_IP => {
                query.filter(comments::Column::IpAddress.eq(&matcher_value))
            }
            comment_guard::MATCHER_TYPE_EMAIL => {
                query.filter(comments::Column::Email.eq(&matcher_value))
            }
            comment_guard::MATCHER_TYPE_USER_AGENT => {
                query.filter(comments::Column::UserAgent.eq(&matcher_value))
            }
            _ => query,
        };

        if let Some(from) = from {
            query = query.filter(comments::Column::CreatedAt.gte(from));
        }
        if let Some(to) = to {
            query = query.filter(comments::Column::CreatedAt.lte(to));
        }

        query
    };

    let total_comments = build_query().count(&ctx.db).await?;
    if total_comments == 0 {
        let analysis = "当前条件下没有匹配评论，无法生成画像。".to_string();
        save_comment_persona_analysis_log(
            &ctx,
            matcher_type,
            &matcher_value,
            from,
            to,
            0,
            0,
            0,
            &analysis,
            &[],
        )
        .await?;

        return format::json(AdminCommentAnalyzeResponse {
            matcher_type: matcher_type.to_string(),
            matcher_value,
            total_comments: 0,
            pending_comments: 0,
            first_seen_at: None,
            latest_seen_at: None,
            distinct_posts: 0,
            analysis,
            samples: Vec::new(),
        });
    }

    let pending_comments = build_query()
        .filter(comments::Column::Approved.eq(false))
        .count(&ctx.db)
        .await?;

    let first_item = build_query()
        .order_by_asc(comments::Column::CreatedAt)
        .one(&ctx.db)
        .await?;
    let latest_item = build_query()
        .order_by_desc(comments::Column::CreatedAt)
        .one(&ctx.db)
        .await?;

    let distinct_posts = build_query()
        .select_only()
        .column(comments::Column::PostSlug)
        .distinct()
        .into_tuple::<Option<String>>()
        .all(&ctx.db)
        .await?
        .into_iter()
        .filter_map(|item| item.map(|value| value.trim().to_string()))
        .filter(|value| !value.is_empty())
        .collect::<std::collections::HashSet<_>>()
        .len();

    let sample_rows = build_query()
        .order_by_desc(comments::Column::CreatedAt)
        .limit(limit)
        .all(&ctx.db)
        .await?;

    let samples = sample_rows
        .iter()
        .map(|item| AdminCommentAnalyzeSample {
            id: item.id,
            created_at: item.created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
            post_slug: required_text(item.post_slug.as_deref(), "unknown-post"),
            author: required_text(item.author.as_deref(), "匿名"),
            email: required_text(item.email.as_deref(), ""),
            approved: item.approved.unwrap_or(false),
            content_preview: truncate_chars(item.content.as_deref().unwrap_or_default(), 220),
        })
        .collect::<Vec<_>>();

    let sample_text = samples
        .iter()
        .map(|item| {
            format!(
                "- [{}] {} | post={} | author={} | status={} | content={}",
                item.id,
                item.created_at,
                item.post_slug,
                item.author,
                if item.approved { "approved" } else { "pending" },
                item.content_preview
            )
        })
        .collect::<Vec<_>>()
        .join("\n");

    let analysis = ai::admin_chat_completion(
        &ctx,
        "你是博客评论风控分析助手。请输出中文，先结论后细节，不要编造。",
        &format!(
            "请基于以下评论画像数据，输出：\n\
1) 风险等级（低/中/高）和理由；\n\
2) 行为特征总结；\n\
3) 建议动作（通过/观察/限速/临时封禁/永久封禁）及理由；\n\
4) 误伤风险提示。\n\n\
画像维度: type={matcher_type}, value={matcher_value}\n\
评论总数: {total_comments}\n\
待审核数: {pending_comments}\n\
涉及文章数: {distinct_posts}\n\
时间范围: from={} to={}\n\
样本:\n{}",
            payload.from.as_deref().unwrap_or("-"),
            payload.to.as_deref().unwrap_or("-"),
            sample_text
        ),
    )
    .await?;

    save_comment_persona_analysis_log(
        &ctx,
        matcher_type,
        &matcher_value,
        from,
        to,
        total_comments,
        pending_comments,
        distinct_posts,
        &analysis,
        &samples,
    )
    .await?;

    format::json(AdminCommentAnalyzeResponse {
        matcher_type: matcher_type.to_string(),
        matcher_value,
        total_comments,
        pending_comments,
        first_seen_at: first_item
            .map(|item| item.created_at.format("%Y-%m-%d %H:%M:%S").to_string()),
        latest_seen_at: latest_item
            .map(|item| item.created_at.format("%Y-%m-%d %H:%M:%S").to_string()),
        distinct_posts,
        analysis,
        samples,
    })
}

#[debug_handler]
pub async fn generate_post_metadata(
    headers: HeaderMap,
    State(ctx): State<AppContext>,
    Json(payload): Json<AdminPostMetadataRequest>,
) -> Result<Response> {
    check_auth(&headers)?;
    format::json(ai::generate_post_metadata(&ctx, &payload.markdown).await?)
}

#[debug_handler]
pub async fn polish_post_markdown(
    headers: HeaderMap,
    State(ctx): State<AppContext>,
    Json(payload): Json<AdminPostPolishRequest>,
) -> Result<Response> {
    check_auth(&headers)?;
    format::json(ai::polish_post_markdown(&ctx, &payload.markdown).await?)
}

#[debug_handler]
pub async fn localize_post_markdown_images(
    headers: HeaderMap,
    State(ctx): State<AppContext>,
    Json(payload): Json<AdminPostLocalizeImagesRequest>,
) -> Result<Response> {
    check_auth(&headers)?;

    let normalized_markdown = payload.markdown.replace("\r\n", "\n");
    let prefix = normalize_localize_image_prefix(payload.prefix);
    let settings = storage::optional_r2_settings(&ctx).await?;
    let detected_urls = markdown_image_reference_urls(&normalized_markdown);
    let candidate_urls = detected_urls
        .into_iter()
        .filter(|url| is_remote_markdown_image_candidate(url, settings.as_ref()))
        .collect::<Vec<_>>();

    if candidate_urls.is_empty() {
        return format::json(AdminPostLocalizeImagesResponse {
            markdown: normalized_markdown,
            detected_count: 0,
            localized_count: 0,
            uploaded_count: 0,
            failed_count: 0,
            items: Vec::new(),
            failures: Vec::new(),
        });
    }

    let mut seen = HashSet::new();
    let unique_urls = candidate_urls
        .iter()
        .filter(|url| seen.insert((*url).clone()))
        .cloned()
        .collect::<Vec<_>>();

    let mut replacements = HashMap::<String, String>::new();
    let mut items = Vec::<AdminPostLocalizedImageItem>::new();
    let mut failures = Vec::<AdminPostLocalizeImagesFailure>::new();

    for source_url in unique_urls {
        let args = DownloadWorkerArgs {
            source_url: source_url.clone(),
            prefix: Some(prefix.clone()),
            target_format: None,
            title: None,
            alt_text: None,
            caption: None,
            tags: vec!["markdown-image".to_string()],
            notes: Some("localized from markdown body".to_string()),
            job_id: None,
        };

        match download_media_to_storage(&ctx, &args).await {
            Ok(downloaded) => {
                replacements.insert(source_url.clone(), downloaded.url.clone());
                items.push(AdminPostLocalizedImageItem {
                    source_url,
                    localized_url: downloaded.url,
                    key: downloaded.key,
                });
            }
            Err(error) => failures.push(AdminPostLocalizeImagesFailure {
                source_url,
                error: error.to_string(),
            }),
        }
    }

    let (markdown, localized_count) =
        replace_markdown_image_urls(&normalized_markdown, &replacements);

    format::json(AdminPostLocalizeImagesResponse {
        markdown,
        detected_count: candidate_urls.len(),
        localized_count,
        uploaded_count: items.len(),
        failed_count: failures.len(),
        items,
        failures,
    })
}

#[debug_handler]
pub async fn polish_review_description(
    headers: HeaderMap,
    State(ctx): State<AppContext>,
    Json(payload): Json<AdminReviewPolishRequest>,
) -> Result<Response> {
    check_auth(&headers)?;
    format::json(
        ai::polish_review_description(
            &ctx,
            &payload.title,
            &payload.review_type,
            payload.rating,
            payload.review_date.as_deref(),
            &payload.status,
            &payload.tags,
            &payload.description,
        )
        .await?,
    )
}

#[debug_handler]
pub async fn generate_post_cover_image(
    headers: HeaderMap,
    State(ctx): State<AppContext>,
    Json(payload): Json<AdminPostCoverImageRequest>,
) -> Result<Response> {
    check_auth(&headers)?;
    format::json(
        ai::generate_post_cover_image(
            &ctx,
            &payload.title,
            payload.description.as_deref(),
            payload.category.as_deref(),
            &payload.tags,
            &payload.post_type,
            payload.slug.as_deref(),
            &payload.markdown,
        )
        .await?,
    )
}

fn review_cover_extension(
    file_name: Option<&str>,
    content_type: Option<&str>,
) -> Option<&'static str> {
    let from_file_name = file_name
        .and_then(|name| name.rsplit('.').next())
        .map(|ext| ext.trim().to_ascii_lowercase());

    match from_file_name.as_deref() {
        Some("png") => return Some("png"),
        Some("jpg") | Some("jpeg") => return Some("jpg"),
        Some("webp") => return Some("webp"),
        Some("gif") => return Some("gif"),
        Some("avif") => return Some("avif"),
        Some("svg") => return Some("svg"),
        _ => {}
    }

    match content_type
        .unwrap_or_default()
        .trim()
        .to_ascii_lowercase()
        .as_str()
    {
        "image/png" => Some("png"),
        "image/jpeg" => Some("jpg"),
        "image/webp" => Some("webp"),
        "image/gif" => Some("gif"),
        "image/avif" => Some("avif"),
        "image/svg+xml" => Some("svg"),
        _ => None,
    }
}

#[debug_handler]
pub async fn upload_review_cover_image(
    headers: HeaderMap,
    State(ctx): State<AppContext>,
    mut multipart: Multipart,
) -> Result<Response> {
    check_auth(&headers)?;

    let field = multipart
        .next_field()
        .await
        .map_err(|error| Error::BadRequest(error.to_string()))?
        .ok_or_else(|| Error::BadRequest("请先选择图片文件".to_string()))?;
    let file_name = field.file_name().map(ToString::to_string);
    let content_type = field.content_type().map(ToString::to_string);
    let extension = review_cover_extension(file_name.as_deref(), content_type.as_deref())
        .ok_or_else(|| Error::BadRequest("仅支持常见图片格式上传".to_string()))?;
    let bytes = field
        .bytes()
        .await
        .map_err(|error| Error::BadRequest(error.to_string()))?;

    if bytes.is_empty() {
        return Err(Error::BadRequest("上传的图片内容为空".to_string()));
    }

    let key = crate::services::storage::build_object_key(
        "review-covers",
        file_name.as_deref().unwrap_or("review-cover"),
        extension,
    );
    let stored = crate::services::storage::upload_bytes_to_r2(
        &ctx,
        &key,
        bytes.to_vec(),
        content_type.as_deref(),
        Some("public, max-age=31536000, immutable"),
    )
    .await?;

    format::json(AdminImageUploadResponse {
        url: stored.url,
        key: stored.key,
    })
}

pub fn routes() -> Routes {
    Routes::new()
        .prefix("/api/admin")
        .add("/session", get(session_status))
        .add("/session", delete(session_logout))
        .add("/session/login", post(session_login))
        .add("/dashboard", get(dashboard))
        .add("/analytics", get(analytics_overview))
        .add("/site-settings", get(get_site_settings))
        .add("/site-settings", patch(update_site_settings))
        .add("/site-settings", put(update_site_settings))
        .add("/ai/reindex", post(reindex_ai))
        .add("/ai/test-provider", post(test_ai_provider))
        .add("/ai/test-image-provider", post(test_ai_image_provider))
        .add(
            "/posts/localize-images",
            post(localize_post_markdown_images),
        )
        .add("/storage/r2/test", post(test_r2_storage))
        .add(
            "/storage/media",
            get(list_media_objects)
                .post(upload_media_objects)
                .delete(delete_media_object),
        )
        .add(
            "/storage/media/batch-delete",
            post(batch_delete_media_objects),
        )
        .add(
            "/storage/media/metadata",
            patch(update_media_object_metadata),
        )
        .add("/storage/media/download", post(download_media_object))
        .add("/storage/media/replace", post(replace_media_object))
        .add(
            "/comments/blacklist",
            get(list_comment_blacklist).post(create_comment_blacklist),
        )
        .add(
            "/comments/blacklist/{id}",
            patch(update_comment_blacklist).delete(delete_comment_blacklist),
        )
        .add(
            "/comments/analyze/logs",
            get(list_comment_persona_analysis_logs),
        )
        .add("/comments/analyze", post(analyze_comment_persona))
        .add("/ai/post-metadata", post(generate_post_metadata))
        .add("/ai/polish-post", post(polish_post_markdown))
        .add("/ai/polish-review", post(polish_review_description))
        .add("/ai/post-cover", post(generate_post_cover_image))
        .add("/storage/review-cover", post(upload_review_cover_image))
}
