#![allow(clippy::missing_errors_doc)]
#![allow(clippy::unnecessary_struct_initialization)]
#![allow(clippy::unused_async)]

use std::collections::HashSet;

use axum::{extract::Multipart, http::HeaderMap};
use chrono::{TimeZone, Utc};
use loco_rs::prelude::*;
use sea_orm::QueryOrder;
use serde::{Deserialize, Deserializer, Serialize};

use crate::models::_entities::posts::{ActiveModel, Column, Entity, Model};
use crate::{
    controllers::admin::check_auth,
    services::{admin_audit, content, post_revisions, subscriptions},
};

fn deserialize_boolish_option<'de, D>(
    deserializer: D,
) -> std::result::Result<Option<bool>, D::Error>
where
    D: Deserializer<'de>,
{
    let raw = Option::<String>::deserialize(deserializer)?;

    raw.map(|value| match value.trim().to_ascii_lowercase().as_str() {
        "1" | "true" | "yes" | "on" => Ok(true),
        "0" | "false" | "no" | "off" => Ok(false),
        other => Err(serde::de::Error::custom(format!(
            "invalid boolean value `{other}`"
        ))),
    })
    .transpose()
}

fn normalize_slug_key(value: &str) -> String {
    value.trim().trim_matches('/').to_string()
}

fn request_preview_mode(preview: Option<bool>, headers: &HeaderMap) -> bool {
    preview.unwrap_or(false)
        || headers
            .get("x-termi-post-mode")
            .and_then(|value| value.to_str().ok())
            .map(|value| value.eq_ignore_ascii_case("preview"))
            .unwrap_or(false)
}

fn requested_status(status: Option<String>, published: Option<bool>) -> String {
    if let Some(status) = status.as_deref() {
        return content::normalize_post_status(Some(status));
    }

    if published == Some(false) {
        content::POST_STATUS_DRAFT.to_string()
    } else {
        content::POST_STATUS_PUBLISHED.to_string()
    }
}

fn normalize_visibility(value: Option<String>) -> String {
    content::normalize_post_visibility(value.as_deref())
}

fn post_has_tag(post: &Model, wanted_tag: &str) -> bool {
    let wanted = wanted_tag.trim().to_lowercase();

    post.tags
        .as_ref()
        .and_then(|value| value.as_array())
        .map(|tags| {
            tags.iter().filter_map(|tag| tag.as_str()).any(|tag| {
                let normalized = tag.trim().to_lowercase();
                normalized == wanted
            })
        })
        .unwrap_or(false)
}

fn effective_status(post: &Model) -> String {
    content::effective_post_state(
        post.status
            .as_deref()
            .unwrap_or(content::POST_STATUS_PUBLISHED),
        post.publish_at,
        post.unpublish_at,
        Utc::now().fixed_offset(),
    )
}

fn listed_publicly(post: &Model) -> bool {
    content::is_post_listed_publicly(post, Utc::now().fixed_offset())
}

fn publicly_accessible(post: &Model) -> bool {
    content::is_post_publicly_accessible(post, Utc::now().fixed_offset())
}

fn normalize_post_sort_by(value: Option<&str>) -> String {
    match value
        .map(str::trim)
        .unwrap_or_default()
        .to_ascii_lowercase()
        .as_str()
    {
        "updated_at" | "updated" => "updated_at".to_string(),
        "title" => "title".to_string(),
        _ => "created_at".to_string(),
    }
}

fn normalize_sort_order(value: Option<&str>) -> String {
    match value
        .map(str::trim)
        .unwrap_or_default()
        .to_ascii_lowercase()
        .as_str()
    {
        "asc" => "asc".to_string(),
        _ => "desc".to_string(),
    }
}

fn sort_posts(items: &mut [Model], sort_by: &str, sort_order: &str) {
    items.sort_by(|left, right| {
        let ordering = match sort_by {
            "updated_at" => left.updated_at.cmp(&right.updated_at),
            "title" => left
                .title
                .as_deref()
                .unwrap_or(&left.slug)
                .to_ascii_lowercase()
                .cmp(
                    &right
                        .title
                        .as_deref()
                        .unwrap_or(&right.slug)
                        .to_ascii_lowercase(),
                ),
            _ => left.created_at.cmp(&right.created_at),
        };

        let ordering = if sort_order == "asc" {
            ordering
        } else {
            ordering.reverse()
        };

        ordering.then_with(|| left.id.cmp(&right.id))
    });
}

fn parse_optional_markdown_datetime(
    value: Option<&str>,
) -> Option<chrono::DateTime<chrono::FixedOffset>> {
    let value = value?.trim();
    if value.is_empty() {
        return None;
    }

    chrono::DateTime::parse_from_rfc3339(value)
        .ok()
        .or_else(|| {
            chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d")
                .ok()
                .and_then(|date| date.and_hms_opt(0, 0, 0))
                .and_then(|naive| {
                    chrono::FixedOffset::east_opt(0)?
                        .from_local_datetime(&naive)
                        .single()
                })
        })
}

fn markdown_post_listed_publicly(post: &content::MarkdownPost) -> bool {
    content::effective_post_state(
        &post.status,
        parse_optional_markdown_datetime(post.publish_at.as_deref()),
        parse_optional_markdown_datetime(post.unpublish_at.as_deref()),
        Utc::now().fixed_offset(),
    ) == content::POST_STATUS_PUBLISHED
        && post.visibility == content::POST_VISIBILITY_PUBLIC
}

fn should_include_post(
    post: &Model,
    query: &ListQuery,
    preview: bool,
    include_private: bool,
    include_redirects: bool,
) -> bool {
    if !preview && !listed_publicly(post) {
        return false;
    }

    if query.listed_only.unwrap_or(!preview) && !listed_publicly(post) {
        return false;
    }

    if !include_private
        && content::normalize_post_visibility(post.visibility.as_deref())
            == content::POST_VISIBILITY_PRIVATE
    {
        return false;
    }

    if !include_redirects
        && post
            .redirect_to
            .as_deref()
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .is_some()
    {
        return false;
    }

    if let Some(slug) = &query.slug {
        if post.slug != *slug {
            return false;
        }
    }

    if let Some(category) = &query.category {
        if post
            .category
            .as_deref()
            .map(|value| !value.eq_ignore_ascii_case(category))
            .unwrap_or(true)
        {
            return false;
        }
    }

    if let Some(post_type) = &query.post_type {
        if post
            .post_type
            .as_deref()
            .map(|value| !value.eq_ignore_ascii_case(post_type))
            .unwrap_or(true)
        {
            return false;
        }
    }

    if let Some(pinned) = query.pinned {
        if post.pinned.unwrap_or(false) != pinned {
            return false;
        }
    }

    if let Some(tag) = &query.tag {
        if !post_has_tag(post, tag) {
            return false;
        }
    }

    if let Some(status) = &query.status {
        if effective_status(post) != content::normalize_post_status(Some(status))
            && effective_status(post) != status.trim().to_ascii_lowercase()
        {
            return false;
        }
    }

    if let Some(visibility) = &query.visibility {
        if content::normalize_post_visibility(post.visibility.as_deref())
            != content::normalize_post_visibility(Some(visibility))
        {
            return false;
        }
    }

    if let Some(search) = &query.search {
        let wanted = search.trim().to_lowercase();
        let haystack = [
            post.title.as_deref().unwrap_or_default(),
            post.description.as_deref().unwrap_or_default(),
            post.content.as_deref().unwrap_or_default(),
            post.category.as_deref().unwrap_or_default(),
            &post.slug,
        ]
        .join("\n")
        .to_lowercase();

        if !haystack.contains(&wanted)
            && !post
                .tags
                .as_ref()
                .and_then(|value| value.as_array())
                .map(|tags| {
                    tags.iter()
                        .filter_map(|tag| tag.as_str())
                        .any(|tag| tag.to_lowercase().contains(&wanted))
                })
                .unwrap_or(false)
        {
            return false;
        }
    }

    true
}

async fn load_item(ctx: &AppContext, id: i32) -> Result<Model> {
    let item = Entity::find_by_id(id).one(&ctx.db).await?;
    item.ok_or(Error::NotFound)
}

async fn load_item_by_slug_once(ctx: &AppContext, slug: &str) -> Result<Option<Model>> {
    Entity::find()
        .filter(Column::Slug.eq(slug))
        .one(&ctx.db)
        .await
        .map_err(Into::into)
}

async fn resolve_post_by_slug(ctx: &AppContext, slug: &str) -> Result<Model> {
    let mut current_slug = normalize_slug_key(slug);
    if current_slug.is_empty() {
        return Err(Error::NotFound);
    }

    let mut visited = HashSet::new();

    loop {
        if !visited.insert(current_slug.clone()) {
            return Err(Error::NotFound);
        }

        if let Some(post) = load_item_by_slug_once(ctx, &current_slug).await? {
            let next_slug = post
                .redirect_to
                .as_deref()
                .map(normalize_slug_key)
                .filter(|value| !value.is_empty() && *value != post.slug);

            if let Some(next_slug) = next_slug {
                current_slug = next_slug;
                continue;
            }

            return Ok(post);
        }

        let candidates = Entity::find().all(&ctx.db).await?;
        let Some(candidate) = candidates.into_iter().find(|item| {
            content::post_redirects_from_json(&item.redirect_from)
                .into_iter()
                .any(|redirect| redirect.eq_ignore_ascii_case(&current_slug))
        }) else {
            return Err(Error::NotFound);
        };

        let next_slug = candidate
            .redirect_to
            .as_deref()
            .map(normalize_slug_key)
            .filter(|value| !value.is_empty())
            .unwrap_or_else(|| candidate.slug.clone());

        if next_slug == candidate.slug {
            return Ok(candidate);
        }

        current_slug = next_slug;
    }
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Params {
    pub title: Option<String>,
    pub slug: String,
    pub description: Option<String>,
    pub content: Option<String>,
    pub category: Option<String>,
    pub tags: Option<serde_json::Value>,
    pub post_type: Option<String>,
    pub image: Option<String>,
    pub images: Option<serde_json::Value>,
    pub pinned: Option<bool>,
    pub status: Option<String>,
    pub visibility: Option<String>,
    pub publish_at: Option<String>,
    pub unpublish_at: Option<String>,
    pub canonical_url: Option<String>,
    pub noindex: Option<bool>,
    pub og_image: Option<String>,
    pub redirect_from: Option<serde_json::Value>,
    pub redirect_to: Option<String>,
}

impl Params {
    fn update(&self, item: &mut ActiveModel) {
        item.title = Set(self.title.clone());
        item.slug = Set(self.slug.clone());
        item.description = Set(self.description.clone());
        item.content = Set(self.content.clone());
        item.category = Set(self.category.clone());
        item.tags = Set(self.tags.clone());
        item.post_type = Set(self.post_type.clone());
        item.image = Set(self.image.clone());
        item.images = Set(self.images.clone());
        item.pinned = Set(self.pinned);
        item.status = Set(self
            .status
            .clone()
            .map(|value| requested_status(Some(value), None)));
        item.visibility = Set(self
            .visibility
            .clone()
            .map(|value| normalize_visibility(Some(value))));
        item.publish_at = Set(self
            .publish_at
            .clone()
            .and_then(|value| chrono::DateTime::parse_from_rfc3339(value.trim()).ok()));
        item.unpublish_at = Set(self
            .unpublish_at
            .clone()
            .and_then(|value| chrono::DateTime::parse_from_rfc3339(value.trim()).ok()));
        item.canonical_url = Set(self.canonical_url.clone());
        item.noindex = Set(self.noindex);
        item.og_image = Set(self.og_image.clone());
        item.redirect_from = Set(self.redirect_from.clone());
        item.redirect_to = Set(self.redirect_to.clone());
    }
}

#[derive(Clone, Debug, Default, Deserialize)]
pub struct ListQuery {
    pub slug: Option<String>,
    pub category: Option<String>,
    pub tag: Option<String>,
    pub search: Option<String>,
    #[serde(alias = "type")]
    pub post_type: Option<String>,
    pub pinned: Option<bool>,
    pub status: Option<String>,
    pub visibility: Option<String>,
    #[serde(default, deserialize_with = "deserialize_boolish_option")]
    pub listed_only: Option<bool>,
    #[serde(default, deserialize_with = "deserialize_boolish_option")]
    pub include_private: Option<bool>,
    #[serde(default, deserialize_with = "deserialize_boolish_option")]
    pub include_redirects: Option<bool>,
    #[serde(default, deserialize_with = "deserialize_boolish_option")]
    pub preview: Option<bool>,
}

#[derive(Clone, Debug, Default, Deserialize)]
pub struct PagedPostsQuery {
    #[serde(flatten)]
    pub filters: ListQuery,
    pub page: Option<u64>,
    #[serde(alias = "page_size")]
    pub page_size: Option<u64>,
    pub sort_by: Option<String>,
    pub sort_order: Option<String>,
}

#[derive(Clone, Debug, Serialize)]
pub struct PagedPostsResponse {
    pub items: Vec<Model>,
    pub page: u64,
    pub page_size: u64,
    pub total: usize,
    pub total_pages: u64,
    pub sort_by: String,
    pub sort_order: String,
}

#[derive(Clone, Debug, Default, Deserialize)]
pub struct LookupQuery {
    #[serde(default, deserialize_with = "deserialize_boolish_option")]
    pub preview: Option<bool>,
    #[serde(default, deserialize_with = "deserialize_boolish_option")]
    pub include_private: Option<bool>,
}

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

#[derive(Clone, Debug, Deserialize)]
pub struct MarkdownCreateParams {
    pub title: String,
    pub slug: Option<String>,
    pub description: Option<String>,
    pub content: Option<String>,
    pub category: Option<String>,
    pub tags: Option<Vec<String>>,
    pub post_type: Option<String>,
    pub image: Option<String>,
    pub images: Option<Vec<String>>,
    pub pinned: Option<bool>,
    pub status: Option<String>,
    pub visibility: Option<String>,
    pub publish_at: Option<String>,
    pub unpublish_at: Option<String>,
    pub canonical_url: Option<String>,
    pub noindex: Option<bool>,
    pub og_image: Option<String>,
    pub redirect_from: Option<Vec<String>>,
    pub redirect_to: Option<String>,
    pub published: Option<bool>,
}

#[derive(Clone, Debug, Serialize)]
pub struct MarkdownDocumentResponse {
    pub slug: String,
    pub path: String,
    pub markdown: String,
}

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

#[derive(Clone, Debug, Serialize)]
pub struct MarkdownImportResponse {
    pub count: usize,
    pub slugs: Vec<String>,
}

#[debug_handler]
pub async fn list(
    Query(query): Query<ListQuery>,
    State(ctx): State<AppContext>,
    headers: HeaderMap,
) -> Result<Response> {
    let preview = request_preview_mode(query.preview, &headers);
    let include_private = preview && query.include_private.unwrap_or(true);
    let include_redirects = query.include_redirects.unwrap_or(preview);

    let posts = Entity::find()
        .order_by_desc(Column::CreatedAt)
        .all(&ctx.db)
        .await?;

    let filtered = posts
        .into_iter()
        .filter(|post| {
            should_include_post(post, &query, preview, include_private, include_redirects)
        })
        .collect::<Vec<_>>();

    format::json(filtered)
}

#[debug_handler]
pub async fn list_page(
    Query(query): Query<PagedPostsQuery>,
    State(ctx): State<AppContext>,
    headers: HeaderMap,
) -> Result<Response> {
    let preview = request_preview_mode(query.filters.preview, &headers);
    let include_private = preview && query.filters.include_private.unwrap_or(true);
    let include_redirects = query.filters.include_redirects.unwrap_or(preview);
    let page_size = query.page_size.unwrap_or(20).clamp(1, 100);
    let sort_by = normalize_post_sort_by(query.sort_by.as_deref());
    let sort_order = normalize_sort_order(query.sort_order.as_deref());

    let mut filtered = Entity::find()
        .order_by_desc(Column::CreatedAt)
        .all(&ctx.db)
        .await?
        .into_iter()
        .filter(|post| {
            should_include_post(
                post,
                &query.filters,
                preview,
                include_private,
                include_redirects,
            )
        })
        .collect::<Vec<_>>();

    sort_posts(&mut filtered, &sort_by, &sort_order);

    let total = filtered.len();
    let total_pages = std::cmp::max(1, ((total as u64) + page_size - 1) / page_size);
    let page = query.page.unwrap_or(1).clamp(1, total_pages);
    let start = ((page - 1) * page_size) as usize;
    let end = std::cmp::min(start + page_size as usize, total);
    let items = if start >= total {
        Vec::new()
    } else {
        filtered[start..end].to_vec()
    };

    format::json(PagedPostsResponse {
        items,
        page,
        page_size,
        total,
        total_pages,
        sort_by,
        sort_order,
    })
}

#[debug_handler]
pub async fn add(
    headers: HeaderMap,
    State(ctx): State<AppContext>,
    Json(params): Json<Params>,
) -> Result<Response> {
    let actor = check_auth(&headers)?;
    let mut item = ActiveModel {
        ..Default::default()
    };
    params.update(&mut item);
    let item = item.insert(&ctx.db).await?;
    admin_audit::log_event(
        &ctx,
        Some(&actor),
        "post.create",
        "post",
        Some(item.id.to_string()),
        Some(item.slug.clone()),
        None,
    )
    .await?;
    format::json(item)
}

#[debug_handler]
pub async fn update(
    headers: HeaderMap,
    Path(id): Path<i32>,
    State(ctx): State<AppContext>,
    Json(params): Json<Params>,
) -> Result<Response> {
    let actor = check_auth(&headers)?;
    let previous = load_item(&ctx, id).await?;
    let was_public = content::is_post_listed_publicly(&previous, Utc::now().fixed_offset());
    let previous_slug = previous.slug.clone();

    let mut item = previous.into_active_model();
    params.update(&mut item);
    let item = item.update(&ctx.db).await?;
    let is_public = content::is_post_listed_publicly(&item, Utc::now().fixed_offset());

    admin_audit::log_event(
        &ctx,
        Some(&actor),
        "post.update",
        "post",
        Some(item.id.to_string()),
        Some(item.slug.clone()),
        Some(serde_json::json!({
            "previous_slug": previous_slug,
            "published": is_public,
        })),
    )
    .await?;

    if is_public && !was_public {
        let post = content::MarkdownPost {
            title: item.title.clone().unwrap_or_else(|| item.slug.clone()),
            slug: item.slug.clone(),
            description: item.description.clone(),
            content: item.content.clone().unwrap_or_default(),
            category: item.category.clone(),
            tags: item
                .tags
                .as_ref()
                .and_then(|value| value.as_array())
                .cloned()
                .unwrap_or_default()
                .into_iter()
                .filter_map(|tag| tag.as_str().map(ToString::to_string))
                .collect(),
            post_type: item
                .post_type
                .clone()
                .unwrap_or_else(|| "article".to_string()),
            image: item.image.clone(),
            images: item
                .images
                .as_ref()
                .and_then(|value| value.as_array())
                .cloned()
                .unwrap_or_default()
                .into_iter()
                .filter_map(|tag| tag.as_str().map(ToString::to_string))
                .collect(),
            pinned: item.pinned.unwrap_or(false),
            status: item
                .status
                .clone()
                .unwrap_or_else(|| content::POST_STATUS_PUBLISHED.to_string()),
            visibility: item
                .visibility
                .clone()
                .unwrap_or_else(|| content::POST_VISIBILITY_PUBLIC.to_string()),
            publish_at: item.publish_at.map(|value| value.to_rfc3339()),
            unpublish_at: item.unpublish_at.map(|value| value.to_rfc3339()),
            canonical_url: item.canonical_url.clone(),
            noindex: item.noindex.unwrap_or(false),
            og_image: item.og_image.clone(),
            redirect_from: content::post_redirects_from_json(&item.redirect_from),
            redirect_to: item.redirect_to.clone(),
            file_path: content::virtual_markdown_document_path(&item.slug),
        };
        let _ = subscriptions::notify_post_published(&ctx, &post).await;
    }

    format::json(item)
}

#[debug_handler]
pub async fn remove(
    headers: HeaderMap,
    Path(id): Path<i32>,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    let actor = check_auth(&headers)?;
    let item = load_item(&ctx, id).await?;
    let slug = item.slug.clone();
    item.delete(&ctx.db).await?;
    admin_audit::log_event(
        &ctx,
        Some(&actor),
        "post.delete",
        "post",
        Some(id.to_string()),
        Some(slug),
        None,
    )
    .await?;
    format::empty()
}

#[debug_handler]
pub async fn get_one(
    Path(id): Path<i32>,
    Query(query): Query<LookupQuery>,
    State(ctx): State<AppContext>,
    headers: HeaderMap,
) -> Result<Response> {
    let preview = request_preview_mode(query.preview, &headers);
    let post = load_item(&ctx, id).await?;

    if !preview && !publicly_accessible(&post) {
        return Err(Error::NotFound);
    }

    format::json(post)
}

#[debug_handler]
pub async fn get_by_slug(
    Path(slug): Path<String>,
    Query(query): Query<LookupQuery>,
    State(ctx): State<AppContext>,
    headers: HeaderMap,
) -> Result<Response> {
    let preview = request_preview_mode(query.preview, &headers);
    let include_private = preview && query.include_private.unwrap_or(true);
    let post = resolve_post_by_slug(&ctx, &slug).await?;

    if !preview && !publicly_accessible(&post) {
        return Err(Error::NotFound);
    }

    if !include_private
        && content::normalize_post_visibility(post.visibility.as_deref())
            == content::POST_VISIBILITY_PRIVATE
    {
        return Err(Error::NotFound);
    }

    format::json(post)
}

#[debug_handler]
pub async fn get_markdown_by_slug(
    headers: HeaderMap,
    Path(slug): Path<String>,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    check_auth(&headers)?;
    let (path, markdown) = content::read_markdown_document_from_store(&ctx, &slug).await?;
    format::json(MarkdownDocumentResponse {
        slug,
        path,
        markdown,
    })
}

#[debug_handler]
pub async fn update_markdown_by_slug(
    headers: HeaderMap,
    Path(slug): Path<String>,
    State(ctx): State<AppContext>,
    Json(params): Json<MarkdownUpdateParams>,
) -> Result<Response> {
    let actor = check_auth(&headers)?;
    let _ = post_revisions::capture_current_snapshot(
        &ctx,
        Some(&actor),
        &slug,
        "update",
        Some("保存文章前的自动快照"),
        None,
    )
    .await?;
    let updated = content::write_markdown_document(&ctx, &slug, &params.markdown).await?;
    let (path, markdown) = content::read_markdown_document_from_store(&ctx, &updated.slug).await?;
    let _ = post_revisions::capture_snapshot_from_markdown(
        &ctx,
        Some(&actor),
        &updated.slug,
        &markdown,
        "saved",
        Some("保存后的当前版本"),
        None,
    )
    .await?;
    admin_audit::log_event(
        &ctx,
        Some(&actor),
        "post.markdown.update",
        "post",
        None,
        Some(updated.slug.clone()),
        None,
    )
    .await?;

    format::json(MarkdownDocumentResponse {
        slug: updated.slug,
        path,
        markdown,
    })
}

#[debug_handler]
pub async fn create_markdown(
    headers: HeaderMap,
    State(ctx): State<AppContext>,
    Json(params): Json<MarkdownCreateParams>,
) -> Result<Response> {
    let actor = check_auth(&headers)?;
    let title = params.title.trim();
    if title.is_empty() {
        return Err(Error::BadRequest("title is required".to_string()));
    }

    let default_body = format!("# {title}\n");
    let created = content::create_markdown_post(
        &ctx,
        content::MarkdownPostDraft {
            title: title.to_string(),
            slug: params.slug,
            description: params.description,
            content: params.content.unwrap_or(default_body),
            category: params.category,
            tags: params.tags.unwrap_or_default(),
            post_type: params.post_type.unwrap_or_else(|| "article".to_string()),
            image: params.image,
            images: params.images.unwrap_or_default(),
            pinned: params.pinned.unwrap_or(false),
            status: requested_status(params.status, params.published),
            visibility: normalize_visibility(params.visibility),
            publish_at: params.publish_at,
            unpublish_at: params.unpublish_at,
            canonical_url: params.canonical_url,
            noindex: params.noindex.unwrap_or(false),
            og_image: params.og_image,
            redirect_from: params.redirect_from.unwrap_or_default(),
            redirect_to: params.redirect_to,
        },
    )
    .await?;
    let (path, markdown) = content::read_markdown_document_from_store(&ctx, &created.slug).await?;
    let _ = post_revisions::capture_snapshot_from_markdown(
        &ctx,
        Some(&actor),
        &created.slug,
        &markdown,
        "create",
        Some("新建文章"),
        None,
    )
    .await?;
    admin_audit::log_event(
        &ctx,
        Some(&actor),
        "post.markdown.create",
        "post",
        None,
        Some(created.slug.clone()),
        None,
    )
    .await?;
    if markdown_post_listed_publicly(&created) {
        let _ = subscriptions::notify_post_published(&ctx, &created).await;
    }

    format::json(MarkdownDocumentResponse {
        slug: created.slug,
        path,
        markdown,
    })
}

#[debug_handler]
pub async fn import_markdown(
    headers: HeaderMap,
    State(ctx): State<AppContext>,
    mut multipart: Multipart,
) -> Result<Response> {
    let actor = check_auth(&headers)?;
    let mut files = Vec::new();

    while let Some(field) = multipart
        .next_field()
        .await
        .map_err(|error| Error::BadRequest(error.to_string()))?
    {
        let file_name = field
            .file_name()
            .map(ToString::to_string)
            .unwrap_or_else(|| "imported.md".to_string());
        let bytes = field
            .bytes()
            .await
            .map_err(|error| Error::BadRequest(error.to_string()))?;
        let content = String::from_utf8(bytes.to_vec())
            .map_err(|_| Error::BadRequest("markdown file must be utf-8".to_string()))?;

        files.push(content::MarkdownImportFile { file_name, content });
    }

    let imported = content::import_markdown_documents(&ctx, files).await?;
    for item in &imported {
        if let Ok((_path, markdown)) =
            content::read_markdown_document_from_store(&ctx, &item.slug).await
        {
            let _ = post_revisions::capture_snapshot_from_markdown(
                &ctx,
                Some(&actor),
                &item.slug,
                &markdown,
                "import",
                Some("批量导入 Markdown"),
                None,
            )
            .await;
        }
        if markdown_post_listed_publicly(item) {
            let _ = subscriptions::notify_post_published(&ctx, item).await;
        }
    }
    admin_audit::log_event(
        &ctx,
        Some(&actor),
        "post.markdown.import",
        "post_import",
        None,
        Some(format!("{} files", imported.len())),
        Some(serde_json::json!({
            "slugs": imported.iter().map(|item| item.slug.clone()).collect::<Vec<_>>(),
        })),
    )
    .await?;

    format::json(MarkdownImportResponse {
        count: imported.len(),
        slugs: imported.into_iter().map(|item| item.slug).collect(),
    })
}

#[debug_handler]
pub async fn delete_markdown_by_slug(
    headers: HeaderMap,
    Path(slug): Path<String>,
    State(ctx): State<AppContext>,
) -> Result<Response> {
    let actor = check_auth(&headers)?;
    let _ = post_revisions::capture_current_snapshot(
        &ctx,
        Some(&actor),
        &slug,
        "delete",
        Some("删除前自动快照"),
        None,
    )
    .await?;
    content::delete_markdown_post(&ctx, &slug).await?;
    admin_audit::log_event(
        &ctx,
        Some(&actor),
        "post.markdown.delete",
        "post",
        None,
        Some(slug.clone()),
        None,
    )
    .await?;
    format::json(MarkdownDeleteResponse {
        slug,
        deleted: true,
    })
}

pub fn routes() -> Routes {
    Routes::new()
        .prefix("api/posts/")
        .add("page", get(list_page))
        .add("/", get(list))
        .add("/", post(add))
        .add("markdown", post(create_markdown))
        .add("markdown/import", post(import_markdown))
        .add("slug/{slug}/markdown", get(get_markdown_by_slug))
        .add("slug/{slug}/markdown", put(update_markdown_by_slug))
        .add("slug/{slug}/markdown", patch(update_markdown_by_slug))
        .add("slug/{slug}/markdown", delete(delete_markdown_by_slug))
        .add("slug/{slug}", get(get_by_slug))
        .add("{id}", get(get_one))
        .add("{id}", delete(remove))
        .add("{id}", put(update))
        .add("{id}", patch(update))
}
