use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use chrono::{DateTime, Utc};
use fastembed::{
    InitOptionsUserDefined, Pooling, TextEmbedding, TokenizerFiles, UserDefinedEmbeddingModel,
};
use loco_rs::prelude::*;
use reqwest::{Client, Url, header::CONTENT_TYPE, multipart};
use sea_orm::{
    ActiveModelTrait, ConnectionTrait, DbBackend, EntityTrait, FromQueryResult, IntoActiveModel,
    PaginatorTrait, QueryOrder, Set, Statement, TransactionTrait,
};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::thread;
use std::time::{Duration, Instant};
use uuid::Uuid;

use crate::{
    controllers::site_settings as site_settings_controller,
    models::_entities::{ai_chunks, site_settings},
    services::{content, storage, worker_jobs},
};

const DEFAULT_AI_PROVIDER: &str = "openai";
const DEFAULT_AI_API_BASE: &str = "https://91code.jiangnight.com/v1";
const DEFAULT_AI_API_KEY: &str =
    "sk-5a5e27db9fb8f8ee7e1d8e3c6a44638c2e50cdb0a0cf9d926fefb5418ff62571";
const DEFAULT_CHAT_MODEL: &str = "gpt-5.4";
const DEFAULT_REASONING_EFFORT: &str = "medium";
const DEFAULT_DISABLE_RESPONSE_STORAGE: bool = true;
const DEFAULT_IMAGE_MODEL: &str = "gpt-image-1";
const DEFAULT_CLOUDFLARE_CHAT_MODEL: &str = "@cf/meta/llama-3.1-8b-instruct";
const DEFAULT_CLOUDFLARE_IMAGE_MODEL: &str = "@cf/black-forest-labs/flux-2-klein-4b";
const DEFAULT_TOP_K: usize = 4;
const DEFAULT_CHUNK_SIZE: usize = 1200;
const DEFAULT_SYSTEM_PROMPT: &str = "你是这个博客的站内 AI 助手。请严格基于提供的博客上下文回答，优先给出准确结论，再补充细节；如果上下文不足，请明确说明。";
const EMBEDDING_BATCH_SIZE: usize = 32;
pub(crate) const REINDEX_EMBEDDING_BATCH_SIZE: usize = 4;
const EMBEDDING_DIMENSION: usize = 384;
const LOCAL_EMBEDDING_MODEL_LABEL: &str = "fastembed / local all-MiniLM-L6-v2";
const LOCAL_EMBEDDING_CACHE_DIR: &str = "storage/ai_embedding_models/all-minilm-l6-v2";
const LOCAL_EMBEDDING_IDLE_TIMEOUT_SECS: u64 = 300;
const LOCAL_EMBEDDING_REAPER_INTERVAL_SECS: u64 = 30;
const LOCAL_EMBEDDING_BASE_URL: &str =
    "https://huggingface.co/Qdrant/all-MiniLM-L6-v2-onnx/resolve/main";
const LOCAL_EMBEDDING_FILES: [&str; 5] = [
    "model.onnx",
    "tokenizer.json",
    "config.json",
    "special_tokens_map.json",
    "tokenizer_config.json",
];

static TEXT_EMBEDDING_MODEL: OnceLock<Mutex<Option<LocalEmbeddingRuntime>>> = OnceLock::new();
static TEXT_EMBEDDING_REAPER_STARTED: OnceLock<()> = OnceLock::new();

struct LocalEmbeddingRuntime {
    model: TextEmbedding,
    last_used_at: Instant,
}

#[derive(Clone, Debug)]
struct AiImageRuntimeSettings {
    provider: String,
    api_base: Option<String>,
    api_key: Option<String>,
    image_model: String,
}

#[derive(Clone, Debug)]
struct AiRuntimeSettings {
    raw: site_settings::Model,
    provider: String,
    api_base: Option<String>,
    api_key: Option<String>,
    chat_model: String,
    image: AiImageRuntimeSettings,
    system_prompt: String,
    top_k: usize,
    chunk_size: usize,
}

#[derive(Clone, Debug)]
struct ChunkDraft {
    source_slug: String,
    source_title: Option<String>,
    source_path: Option<String>,
    source_type: String,
    chunk_index: i32,
    content: String,
    content_preview: Option<String>,
    word_count: Option<i32>,
}

#[derive(Clone, Debug)]
struct ScoredChunk {
    score: f64,
    row: ai_chunks::Model,
}

#[derive(Clone, Debug, FromQueryResult)]
struct SimilarChunkRow {
    source_slug: String,
    source_title: Option<String>,
    source_type: String,
    chunk_index: i32,
    content: String,
    content_preview: Option<String>,
    word_count: Option<i32>,
    score: f64,
}

#[derive(Clone, Copy, Debug)]
enum EmbeddingKind {
    Passage,
    Query,
}

#[derive(Clone, Debug, Serialize)]
pub struct AiSource {
    pub slug: String,
    pub href: String,
    pub title: String,
    pub excerpt: String,
    pub score: f64,
    pub chunk_index: i32,
}

#[derive(Clone, Debug, Serialize)]
pub struct GeneratedPostMetadata {
    pub title: String,
    pub description: String,
    pub category: String,
    pub tags: Vec<String>,
    pub slug: String,
}

#[derive(Clone, Debug, Serialize)]
pub struct PolishedPostMarkdown {
    pub polished_markdown: String,
}

#[derive(Clone, Debug, Serialize)]
pub struct PolishedReviewDescription {
    pub polished_description: String,
}

#[derive(Clone, Debug, Serialize)]
pub struct GeneratedPostCoverImage {
    pub image_url: String,
    pub prompt: String,
}

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

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

#[derive(Clone, Debug, Default, Deserialize)]
struct GeneratedPostMetadataDraft {
    title: Option<String>,
    description: Option<String>,
    category: Option<String>,
    tags: Option<Vec<String>>,
    slug: Option<String>,
}

#[derive(Clone, Debug)]
pub struct AiAnswer {
    pub answer: String,
    pub sources: Vec<AiSource>,
    pub indexed_chunks: usize,
    pub last_indexed_at: Option<DateTime<Utc>>,
}

#[derive(Clone, Debug)]
pub(crate) struct AiProviderRequest {
    pub(crate) provider: String,
    pub(crate) api_base: String,
    pub(crate) api_key: String,
    pub(crate) chat_model: String,
    pub(crate) system_prompt: String,
    pub(crate) prompt: String,
}

#[derive(Clone, Debug)]
pub(crate) struct PreparedAiAnswer {
    pub(crate) question: String,
    pub(crate) provider_request: Option<AiProviderRequest>,
    pub(crate) immediate_answer: Option<String>,
    pub(crate) sources: Vec<AiSource>,
    pub(crate) indexed_chunks: usize,
    pub(crate) last_indexed_at: Option<DateTime<Utc>>,
}

#[derive(Clone, Debug)]
pub struct AiIndexSummary {
    pub indexed_chunks: usize,
    pub last_indexed_at: Option<DateTime<Utc>>,
}

#[derive(Clone, Debug, Serialize)]
struct AiReindexProgress {
    phase: String,
    message: String,
    total_chunks: usize,
    processed_chunks: usize,
    total_batches: usize,
    current_batch: usize,
    batch_size: usize,
    percent: usize,
}

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_newlines(input: &str) -> String {
    input.replace("\r\n", "\n")
}

fn preview_text(content: &str, limit: usize) -> Option<String> {
    let flattened = content
        .split_whitespace()
        .collect::<Vec<_>>()
        .join(" ")
        .trim()
        .to_string();

    if flattened.is_empty() {
        return None;
    }

    let preview = flattened.chars().take(limit).collect::<String>();
    Some(preview)
}

fn json_string_array(value: &Option<Value>) -> Vec<String> {
    value
        .as_ref()
        .and_then(|raw| serde_json::from_value::<Vec<String>>(raw.clone()).ok())
        .unwrap_or_default()
        .into_iter()
        .filter_map(|item| trim_to_option(Some(item)))
        .collect()
}

fn build_endpoint(api_base: &str, path: &str) -> String {
    format!(
        "{}/{}",
        api_base.trim_end_matches('/'),
        path.trim_start_matches('/')
    )
}

fn provider_uses_cloudflare(provider: &str) -> bool {
    provider.eq_ignore_ascii_case("cloudflare")
        || provider.eq_ignore_ascii_case("cloudflare-workers-ai")
        || provider.eq_ignore_ascii_case("workers-ai")
}

fn provider_uses_openai_api_prefix(provider: &str) -> bool {
    provider_uses_responses(provider) || provider.eq_ignore_ascii_case("openai-compatible")
}

fn normalize_cloudflare_api_base(api_base: &str) -> String {
    let trimmed = api_base.trim().trim_end_matches('/');
    if trimmed.is_empty() {
        return String::new();
    }

    if !trimmed.starts_with("http://") && !trimmed.starts_with("https://") {
        return format!(
            "https://api.cloudflare.com/client/v4/accounts/{}",
            trimmed.trim_matches('/'),
        );
    }

    let Ok(mut parsed) = Url::parse(trimmed) else {
        return trimmed.to_string();
    };

    let segments = parsed
        .path_segments()
        .map(|items| items.collect::<Vec<_>>())
        .unwrap_or_default();

    if let Some(account_index) = segments.iter().position(|segment| *segment == "accounts") {
        if let Some(account_id) = segments.get(account_index + 1) {
            parsed.set_path(&format!("/client/v4/accounts/{account_id}"));
        }
    }

    parsed.to_string().trim_end_matches('/').to_string()
}

fn normalize_provider_api_base(provider: &str, api_base: &str) -> String {
    let trimmed = api_base.trim();
    if trimmed.is_empty() {
        return String::new();
    }

    if provider_uses_cloudflare(provider) {
        return normalize_cloudflare_api_base(trimmed);
    }

    if !provider_uses_openai_api_prefix(provider) {
        return trimmed.trim_end_matches('/').to_string();
    }

    let Ok(mut parsed) = Url::parse(trimmed) else {
        return trimmed.trim_end_matches('/').to_string();
    };

    if parsed.path().trim_end_matches('/').is_empty() {
        parsed.set_path("/v1");
    }

    parsed.to_string().trim_end_matches('/').to_string()
}

fn local_embedding_dir() -> PathBuf {
    PathBuf::from(LOCAL_EMBEDDING_CACHE_DIR)
}

fn download_embedding_file(
    client: &reqwest::blocking::Client,
    directory: &Path,
    file_name: &str,
) -> Result<()> {
    let target_path = directory.join(file_name);
    if target_path.exists() {
        return Ok(());
    }

    let url = format!("{LOCAL_EMBEDDING_BASE_URL}/{file_name}");
    let bytes = client
        .get(url)
        .send()
        .and_then(reqwest::blocking::Response::error_for_status)
        .map_err(|error| Error::BadRequest(format!("下载本地 embedding 文件失败: {error}")))?
        .bytes()
        .map_err(|error| Error::BadRequest(format!("读取本地 embedding 文件失败: {error}")))?;

    fs::write(&target_path, &bytes)
        .map_err(|error| Error::BadRequest(format!("写入本地 embedding 文件失败: {error}")))?;

    Ok(())
}

fn ensure_local_embedding_files() -> Result<PathBuf> {
    let directory = local_embedding_dir();
    fs::create_dir_all(&directory)
        .map_err(|error| Error::BadRequest(format!("创建本地 embedding 目录失败: {error}")))?;

    let client = reqwest::blocking::Client::builder()
        .build()
        .map_err(|error| {
            Error::BadRequest(format!("创建本地 embedding 下载客户端失败: {error}"))
        })?;

    for file_name in LOCAL_EMBEDDING_FILES {
        download_embedding_file(&client, &directory, file_name)?;
    }

    Ok(directory)
}

fn load_local_embedding_model() -> Result<TextEmbedding> {
    let directory = ensure_local_embedding_files()?;
    let tokenizer_files = TokenizerFiles {
        tokenizer_file: fs::read(directory.join("tokenizer.json"))
            .map_err(|error| Error::BadRequest(format!("读取 tokenizer.json 失败: {error}")))?,
        config_file: fs::read(directory.join("config.json"))
            .map_err(|error| Error::BadRequest(format!("读取 config.json 失败: {error}")))?,
        special_tokens_map_file: fs::read(directory.join("special_tokens_map.json")).map_err(
            |error| Error::BadRequest(format!("读取 special_tokens_map.json 失败: {error}")),
        )?,
        tokenizer_config_file: fs::read(directory.join("tokenizer_config.json")).map_err(
            |error| Error::BadRequest(format!("读取 tokenizer_config.json 失败: {error}")),
        )?,
    };

    let model = UserDefinedEmbeddingModel::new(
        fs::read(directory.join("model.onnx"))
            .map_err(|error| Error::BadRequest(format!("读取 model.onnx 失败: {error}")))?,
        tokenizer_files,
    )
    .with_pooling(Pooling::Mean);

    TextEmbedding::try_new_from_user_defined(model, InitOptionsUserDefined::default())
        .map_err(|error| Error::BadRequest(format!("本地 embedding 模型初始化失败: {error}")))
}

fn local_embedding_state() -> &'static Mutex<Option<LocalEmbeddingRuntime>> {
    TEXT_EMBEDDING_MODEL.get_or_init(|| Mutex::new(None))
}

fn ensure_local_embedding_reaper_started() {
    TEXT_EMBEDDING_REAPER_STARTED.get_or_init(|| {
        if let Err(error) = thread::Builder::new()
            .name("local-embedding-reaper".to_string())
            .spawn(|| {
                let idle_timeout = Duration::from_secs(LOCAL_EMBEDDING_IDLE_TIMEOUT_SECS);
                let check_interval = Duration::from_secs(LOCAL_EMBEDDING_REAPER_INTERVAL_SECS);

                loop {
                    thread::sleep(check_interval);

                    let Some(state) = TEXT_EMBEDDING_MODEL.get() else {
                        continue;
                    };

                    let mut guard = match state.lock() {
                        Ok(guard) => guard,
                        Err(_) => {
                            tracing::warn!("failed to lock local embedding model for idle cleanup");
                            continue;
                        }
                    };

                    let should_unload = guard
                        .as_ref()
                        .is_some_and(|runtime| runtime.last_used_at.elapsed() >= idle_timeout);

                    if should_unload {
                        *guard = None;
                        tracing::info!(
                            "unloaded local embedding model after {} seconds of inactivity",
                            LOCAL_EMBEDDING_IDLE_TIMEOUT_SECS
                        );
                    }
                }
            })
        {
            tracing::warn!("failed to start local embedding reaper thread: {error}");
        }
    });
}

fn with_local_embedding_engine<T>(
    operation: impl FnOnce(&mut TextEmbedding) -> Result<T>,
) -> Result<T> {
    ensure_local_embedding_reaper_started();

    let state = local_embedding_state();
    let mut guard = state
        .lock()
        .map_err(|_| Error::BadRequest("本地 embedding 模型当前不可用，请稍后重试".to_string()))?;

    if guard.is_none() {
        tracing::info!("loading local embedding model into memory");
        *guard = Some(LocalEmbeddingRuntime {
            model: load_local_embedding_model()?,
            last_used_at: Instant::now(),
        });
    }

    let runtime = guard
        .as_mut()
        .ok_or_else(|| Error::BadRequest("本地 embedding 模型未能成功缓存".to_string()))?;
    runtime.last_used_at = Instant::now();

    let result = operation(&mut runtime.model);
    runtime.last_used_at = Instant::now();
    result
}

fn vector_literal(embedding: &[f64]) -> Result<String> {
    if embedding.len() != EMBEDDING_DIMENSION {
        return Err(Error::BadRequest(format!(
            "embedding 维度异常，期望 {EMBEDDING_DIMENSION}，实际 {}",
            embedding.len()
        )));
    }

    Ok(format!(
        "[{}]",
        embedding
            .iter()
            .map(|value| value.to_string())
            .collect::<Vec<_>>()
            .join(",")
    ))
}

fn prepare_embedding_text(kind: EmbeddingKind, text: &str) -> String {
    match kind {
        EmbeddingKind::Passage | EmbeddingKind::Query => text.trim().to_string(),
    }
}

fn split_long_text(text: &str, chunk_size: usize) -> Vec<String> {
    let mut parts = Vec::new();
    let mut current = String::new();

    for line in text.lines() {
        let candidate = if current.is_empty() {
            line.to_string()
        } else {
            format!("{current}\n{line}")
        };

        if candidate.chars().count() > chunk_size && !current.is_empty() {
            parts.push(current.trim().to_string());
            current = line.to_string();
        } else {
            current = candidate;
        }
    }

    if !current.trim().is_empty() {
        parts.push(current.trim().to_string());
    }

    parts
}

fn build_source_chunks(
    source_slug: String,
    source_title: Option<String>,
    source_path: Option<String>,
    source_type: &str,
    source_text: String,
    chunk_size: usize,
) -> Vec<ChunkDraft> {
    let mut chunks = Vec::new();
    let paragraphs = source_text
        .split("\n\n")
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .collect::<Vec<_>>();

    let mut buffer = String::new();
    let mut chunk_index = 0_i32;

    for paragraph in paragraphs {
        if paragraph.chars().count() > chunk_size {
            if !buffer.trim().is_empty() {
                chunks.push(ChunkDraft {
                    source_slug: source_slug.clone(),
                    source_title: source_title.clone(),
                    source_path: source_path.clone(),
                    source_type: source_type.to_string(),
                    chunk_index,
                    content: buffer.trim().to_string(),
                    content_preview: preview_text(&buffer, 180),
                    word_count: Some(buffer.split_whitespace().count() as i32),
                });
                chunk_index += 1;
                buffer.clear();
            }

            for part in split_long_text(paragraph, chunk_size) {
                if part.trim().is_empty() {
                    continue;
                }

                chunks.push(ChunkDraft {
                    source_slug: source_slug.clone(),
                    source_title: source_title.clone(),
                    source_path: source_path.clone(),
                    source_type: source_type.to_string(),
                    chunk_index,
                    content_preview: preview_text(&part, 180),
                    word_count: Some(part.split_whitespace().count() as i32),
                    content: part,
                });
                chunk_index += 1;
            }
            continue;
        }

        let candidate = if buffer.is_empty() {
            paragraph.to_string()
        } else {
            format!("{buffer}\n\n{paragraph}")
        };

        if candidate.chars().count() > chunk_size && !buffer.trim().is_empty() {
            chunks.push(ChunkDraft {
                source_slug: source_slug.clone(),
                source_title: source_title.clone(),
                source_path: source_path.clone(),
                source_type: source_type.to_string(),
                chunk_index,
                content_preview: preview_text(&buffer, 180),
                word_count: Some(buffer.split_whitespace().count() as i32),
                content: buffer.trim().to_string(),
            });
            chunk_index += 1;
            buffer = paragraph.to_string();
        } else {
            buffer = candidate;
        }
    }

    if !buffer.trim().is_empty() {
        chunks.push(ChunkDraft {
            source_slug,
            source_title,
            source_path,
            source_type: source_type.to_string(),
            chunk_index,
            content_preview: preview_text(&buffer, 180),
            word_count: Some(buffer.split_whitespace().count() as i32),
            content: buffer.trim().to_string(),
        });
    }

    chunks
}

fn build_chunks(posts: &[content::MarkdownPost], chunk_size: usize) -> Vec<ChunkDraft> {
    let mut chunks = Vec::new();
    let now = chrono::Utc::now().fixed_offset();

    for post in posts.iter().filter(|post| {
        content::effective_post_state(
            &post.status,
            post.publish_at
                .clone()
                .and_then(|value| chrono::DateTime::parse_from_rfc3339(&value).ok()),
            post.unpublish_at
                .clone()
                .and_then(|value| chrono::DateTime::parse_from_rfc3339(&value).ok()),
            now,
        ) == content::POST_STATUS_PUBLISHED
            && content::normalize_post_visibility(Some(&post.visibility))
                != content::POST_VISIBILITY_PRIVATE
    }) {
        let mut sections = Vec::new();
        sections.push(format!("# {}", post.title));
        if let Some(description) = post
            .description
            .as_deref()
            .filter(|value| !value.trim().is_empty())
        {
            sections.push(description.trim().to_string());
        }
        sections.push(post.content.trim().to_string());

        let source_text = sections
            .into_iter()
            .filter(|item| !item.trim().is_empty())
            .collect::<Vec<_>>()
            .join("\n\n");

        chunks.extend(build_source_chunks(
            post.slug.clone(),
            Some(post.title.clone()),
            Some(post.file_path.clone()),
            "post",
            source_text,
            chunk_size,
        ));
    }

    chunks
}

fn build_profile_chunks(settings: &site_settings::Model, chunk_size: usize) -> Vec<ChunkDraft> {
    let owner_name =
        trim_to_option(settings.owner_name.clone()).unwrap_or_else(|| "InitCool".to_string());
    let owner_title = trim_to_option(settings.owner_title.clone());
    let owner_bio = trim_to_option(settings.owner_bio.clone());
    let owner_avatar = trim_to_option(settings.owner_avatar_url.clone());
    let github = trim_to_option(settings.social_github.clone());
    let email = trim_to_option(settings.social_email.clone());
    let site_url = trim_to_option(settings.site_url.clone());
    let location = trim_to_option(settings.location.clone());
    let tech_stack = json_string_array(&settings.tech_stack);

    let mut sections = vec![format!("# 关于作者 {owner_name}")];

    if let Some(title) = owner_title.as_deref() {
        sections.push(format!("身份: {title}"));
    }
    if let Some(bio) = owner_bio.as_deref() {
        sections.push(format!("简介: {bio}"));
    }
    if let Some(location) = location.as_deref() {
        sections.push(format!("位置: {location}"));
    }
    if !tech_stack.is_empty() {
        sections.push(format!("技术栈: {}", tech_stack.join(" / ")));
    }
    if let Some(github) = github.as_deref() {
        sections.push(format!("GitHub: {github}"));
    }
    if let Some(site_url) = site_url.as_deref() {
        sections.push(format!("网站: {site_url}"));
    }
    if let Some(email) = email.as_deref() {
        sections.push(format!("邮箱: {email}"));
    }
    if let Some(owner_avatar) = owner_avatar.as_deref() {
        sections.push(format!("头像: {owner_avatar}"));
    }

    let profile_text = sections
        .into_iter()
        .filter(|item| !item.trim().is_empty())
        .collect::<Vec<_>>()
        .join("\n\n");

    build_source_chunks(
        "about".to_string(),
        Some(format!("关于作者 {owner_name}")),
        Some("site_settings".to_string()),
        "profile",
        profile_text,
        chunk_size,
    )
}

fn parse_provider_sse_body(body: &str) -> Option<Value> {
    let normalized = normalize_newlines(body);
    let mut latest_payload = None;
    let mut latest_response = None;

    for event in normalized.split("\n\n") {
        let data = event
            .lines()
            .filter_map(|line| line.strip_prefix("data:"))
            .map(str::trim_start)
            .collect::<Vec<_>>()
            .join("\n");

        if data.is_empty() || data == "[DONE]" {
            continue;
        }

        let Ok(parsed) = serde_json::from_str::<Value>(&data) else {
            continue;
        };

        if let Some(response) = parsed.get("response") {
            latest_response = Some(response.clone());
        }

        latest_payload = Some(parsed);
    }

    latest_response.or(latest_payload)
}

fn parse_json_body(body: &str) -> Result<Value> {
    serde_json::from_str(body)
        .or_else(|_| {
            parse_provider_sse_body(body).ok_or_else(|| {
                serde_json::Error::io(std::io::Error::new(
                    std::io::ErrorKind::InvalidData,
                    "provider returned neither JSON nor SSE JSON payload",
                ))
            })
        })
        .map_err(|error| Error::BadRequest(format!("AI response parse failed: {error}")))
}

async fn request_json(client: &Client, url: &str, api_key: &str, payload: Value) -> Result<Value> {
    let response = client
        .post(url)
        .bearer_auth(api_key)
        .header("Accept", "application/json")
        .json(&payload)
        .send()
        .await
        .map_err(|error| Error::BadRequest(format!("AI request failed: {error}")))?;

    let status = response.status();
    let body = response
        .text()
        .await
        .map_err(|error| Error::BadRequest(format!("AI response read failed: {error}")))?;

    if !status.is_success() {
        return Err(Error::BadRequest(format!(
            "AI provider returned {status}: {body}"
        )));
    }

    parse_json_body(&body)
}

async fn request_multipart_json(
    client: &Client,
    url: &str,
    api_key: &str,
    form: multipart::Form,
) -> Result<Value> {
    let response = client
        .post(url)
        .bearer_auth(api_key)
        .header("Accept", "application/json")
        .multipart(form)
        .send()
        .await
        .map_err(|error| Error::BadRequest(format!("AI request failed: {error}")))?;

    let status = response.status();
    let body = response
        .text()
        .await
        .map_err(|error| Error::BadRequest(format!("AI response read failed: {error}")))?;

    if !status.is_success() {
        return Err(Error::BadRequest(format!(
            "AI provider returned {status}: {body}"
        )));
    }

    parse_json_body(&body)
}

fn provider_uses_responses(provider: &str) -> bool {
    provider.eq_ignore_ascii_case("newapi")
        || provider.eq_ignore_ascii_case("openai")
        || provider.eq_ignore_ascii_case("anthropic")
        || provider.eq_ignore_ascii_case("gemini")
}

fn default_image_model_for_provider_name(provider: &str) -> &'static str {
    if provider_uses_cloudflare(provider) {
        DEFAULT_CLOUDFLARE_IMAGE_MODEL
    } else {
        DEFAULT_IMAGE_MODEL
    }
}

pub fn default_image_model_for_provider(provider: &str) -> &'static str {
    default_image_model_for_provider_name(provider)
}

async fn embed_texts_locally(inputs: Vec<String>, kind: EmbeddingKind) -> Result<Vec<Vec<f64>>> {
    embed_texts_locally_with_batch_size(inputs, kind, EMBEDDING_BATCH_SIZE).await
}

async fn embed_texts_locally_with_batch_size(
    inputs: Vec<String>,
    kind: EmbeddingKind,
    batch_size: usize,
) -> Result<Vec<Vec<f64>>> {
    tokio::task::spawn_blocking(move || {
        let prepared = inputs
            .iter()
            .map(|item| prepare_embedding_text(kind, item))
            .collect::<Vec<_>>();

        with_local_embedding_engine(|model| {
            let embeddings = model
                .embed(prepared, Some(batch_size.max(1)))
                .map_err(|error| Error::BadRequest(format!("本地 embedding 生成失败: {error}")))?;

            Ok(embeddings
                .into_iter()
                .map(|embedding| embedding.into_iter().map(f64::from).collect::<Vec<_>>())
                .collect::<Vec<_>>())
        })
    })
    .await
    .map_err(|error| Error::BadRequest(format!("本地 embedding 任务执行失败: {error}")))?
}

fn extract_message_content(value: &Value) -> Option<String> {
    if let Some(content) = value
        .get("message")
        .and_then(|message| message.get("content"))
    {
        if let Some(text) = content.as_str() {
            return Some(text.trim().to_string());
        }

        if let Some(parts) = content.as_array() {
            let merged = parts
                .iter()
                .filter_map(|part| {
                    part.get("text")
                        .and_then(Value::as_str)
                        .or_else(|| part.get("output_text").and_then(Value::as_str))
                })
                .collect::<Vec<_>>()
                .join("\n");

            if !merged.trim().is_empty() {
                return Some(merged.trim().to_string());
            }
        }
    }

    if let Some(content) = value
        .get("choices")
        .and_then(Value::as_array)
        .and_then(|choices| choices.first())
        .and_then(|choice| choice.get("message"))
        .and_then(|message| message.get("content"))
    {
        if let Some(text) = content.as_str() {
            return Some(text.trim().to_string());
        }

        if let Some(parts) = content.as_array() {
            let merged = parts
                .iter()
                .filter_map(|part| part.get("text").and_then(Value::as_str))
                .collect::<Vec<_>>()
                .join("\n");

            if !merged.trim().is_empty() {
                return Some(merged.trim().to_string());
            }
        }
    }

    None
}

fn merge_text_segments(parts: Vec<String>) -> Option<String> {
    let merged = parts
        .into_iter()
        .filter_map(|part| {
            let trimmed = part.trim().to_string();
            if trimmed.is_empty() {
                None
            } else {
                Some(trimmed)
            }
        })
        .collect::<Vec<_>>()
        .join("\n");

    if merged.trim().is_empty() {
        None
    } else {
        Some(merged)
    }
}

fn extract_text_from_content_items(items: &[Value]) -> Option<String> {
    let mut segments = Vec::new();

    for item in items {
        if let Some(text) = item.get("text").and_then(Value::as_str) {
            segments.push(text.to_string());
            continue;
        }

        if let Some(text) = item
            .get("output_text")
            .and_then(|output_text| output_text.get("text"))
            .and_then(Value::as_str)
        {
            segments.push(text.to_string());
            continue;
        }

        if let Some(text) = item.get("output_text").and_then(Value::as_str) {
            segments.push(text.to_string());
        }
    }

    merge_text_segments(segments)
}

fn title_from_markdown(markdown: &str) -> Option<String> {
    markdown.lines().find_map(|line| {
        line.trim()
            .strip_prefix("# ")
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .map(ToString::to_string)
    })
}

fn excerpt_from_markdown(markdown: &str, limit: usize) -> Option<String> {
    let mut in_code_block = false;

    for line in markdown.lines() {
        let trimmed = line.trim();
        if trimmed.starts_with("```") {
            in_code_block = !in_code_block;
            continue;
        }

        if in_code_block || trimmed.is_empty() || trimmed.starts_with('#') {
            continue;
        }

        let excerpt = trimmed.chars().take(limit).collect::<String>();
        if !excerpt.is_empty() {
            return Some(excerpt);
        }
    }

    None
}

fn strip_markdown_frontmatter(markdown: &str) -> String {
    let normalized = markdown.replace("\r\n", "\n");
    if !normalized.starts_with("---\n") {
        return normalized;
    }

    let Some(end_index) = normalized[4..].find("\n---\n") else {
        return normalized;
    };

    normalized[(end_index + 9)..].to_string()
}

fn metadata_slugify(value: &str) -> String {
    let mut slug = String::new();
    let mut last_was_dash = false;

    for ch in value.trim().chars() {
        if ch.is_alphanumeric() {
            for lower in ch.to_lowercase() {
                slug.push(lower);
            }
            last_was_dash = false;
        } else if (ch.is_whitespace() || ch == '-' || ch == '_') && !last_was_dash {
            slug.push('-');
            last_was_dash = true;
        }
    }

    slug.trim_matches('-').to_string()
}

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

    if trimmed.starts_with('{') && trimmed.ends_with('}') {
        return Some(trimmed.to_string());
    }

    for marker in ["```json", "```JSON", "```"] {
        if let Some(start) = trimmed.find(marker) {
            let rest = &trimmed[(start + marker.len())..];
            if let Some(end) = rest.find("```") {
                let candidate = rest[..end].trim();
                if candidate.starts_with('{') && candidate.ends_with('}') {
                    return Some(candidate.to_string());
                }
            }
        }
    }

    let start = trimmed.find('{')?;
    let end = trimmed.rfind('}')?;
    (start < end).then(|| trimmed[start..=end].to_string())
}

fn normalize_generated_metadata(
    markdown: &str,
    draft: GeneratedPostMetadataDraft,
) -> GeneratedPostMetadata {
    let fallback_title = title_from_markdown(markdown).unwrap_or_else(|| "未命名文章".to_string());
    let title = trim_to_option(draft.title).unwrap_or_else(|| fallback_title.clone());
    let description = trim_to_option(draft.description)
        .or_else(|| excerpt_from_markdown(markdown, 140))
        .unwrap_or_else(|| format!("关于《{title}》的文章摘要。"));
    let category = trim_to_option(draft.category).unwrap_or_else(|| "未分类".to_string());

    let mut seen = std::collections::HashSet::new();
    let tags = draft
        .tags
        .unwrap_or_default()
        .into_iter()
        .filter_map(|tag| trim_to_option(Some(tag)))
        .filter(|tag| {
            let key = tag.to_lowercase();
            seen.insert(key)
        })
        .take(6)
        .collect::<Vec<_>>();

    let slug_source = trim_to_option(draft.slug)
        .filter(|value| !value.is_empty())
        .unwrap_or_else(|| title.clone());
    let slug = metadata_slugify(&slug_source);

    GeneratedPostMetadata {
        title,
        description,
        category,
        tags,
        slug: if slug.is_empty() {
            metadata_slugify(&fallback_title)
        } else {
            slug
        },
    }
}

fn build_post_metadata_prompt(markdown: &str) -> String {
    let content = strip_markdown_frontmatter(markdown)
        .chars()
        .take(6000)
        .collect::<String>();

    format!(
        "请根据下面的 Markdown 文章内容，生成适合博客后台直接回填的元数据。\n\
要求：\n\
1. 使用中文理解文章，但 slug 必须是适合 URL 的短横线形式。\n\
2. title 要自然，不要保留“未命名文章”之类的占位词。\n\
3. description 控制在 40 到 120 个中文字符之间，像站点摘要，不要分点。\n\
4. category 只输出 1 个分类名称。\n\
5. tags 输出 3 到 6 个标签，尽量具体，不要和 category 完全重复。\n\
6. 只返回 JSON，不要解释，不要代码块。\n\
JSON 结构：\n\
{{\n\
  \"title\": \"\",\n\
  \"description\": \"\",\n\
  \"category\": \"\",\n\
  \"tags\": [\"\", \"\"],\n\
  \"slug\": \"\"\n\
}}\n\n\
文章内容：\n{content}"
    )
}

fn infer_category_and_tags(markdown: &str) -> (String, Vec<String>) {
    let normalized = strip_markdown_frontmatter(markdown).to_lowercase();
    let candidates = [
        ("canokey", "Linux", "CanoKey"),
        ("ffmpeg", "ffmpeg", "ffmpeg"),
        ("grpc", "Go", "gRPC"),
        ("protobuf", "Go", "Protobuf"),
        ("protoc", "Go", "Go"),
        ("go ", "Go", "Go"),
        ("golang", "Go", "Go"),
        ("rust", "Rust", "Rust"),
        ("serde", "Rust", "Serde"),
        ("sqlx", "Rust", "Sqlx"),
        ("dll", "Rust", "Dll"),
        ("mysql", "Database", "Mysql"),
        ("redis", "Database", "Redis"),
        ("sql", "Database", "Sql"),
        ("linux", "Linux", "Linux"),
        ("shell", "Linux", "Shell"),
        ("tmux", "Linux", "Tmux"),
        ("dhcp", "Linux", "DHCP"),
        ("glibc", "Linux", "GLIBC"),
        ("hugo", "Go", "Hugo"),
        ("xml", "Go", "Xml"),
        ("arm", "Go", "Arm"),
    ];

    let mut category = None;
    let mut tags = Vec::new();
    let mut seen = std::collections::HashSet::new();

    for (needle, matched_category, tag) in candidates {
        if !normalized.contains(needle) {
            continue;
        }

        if category.is_none() {
            category = Some(matched_category.to_string());
        }

        if seen.insert(tag.to_lowercase()) {
            tags.push(tag.to_string());
        }
    }

    if tags.is_empty() {
        tags.push("技术笔记".to_string());
    }

    (category.unwrap_or_else(|| "开发".to_string()), tags)
}

fn fallback_generated_metadata(markdown: &str) -> GeneratedPostMetadata {
    let fallback_title = title_from_markdown(markdown).unwrap_or_else(|| "未命名文章".to_string());
    let (category, tags) = infer_category_and_tags(markdown);

    normalize_generated_metadata(
        markdown,
        GeneratedPostMetadataDraft {
            title: Some(fallback_title.clone()),
            description: excerpt_from_markdown(markdown, 96)
                .or_else(|| Some(format!("关于《{fallback_title}》的技术实践记录。"))),
            category: Some(category),
            tags: Some(tags),
            slug: Some(metadata_slugify(&fallback_title)),
        },
    )
}

fn build_polish_markdown_prompt(markdown: &str) -> String {
    let content = normalize_newlines(markdown)
        .chars()
        .take(12000)
        .collect::<String>();

    format!(
        "请润色下面这篇博客 Markdown 文档，输出一份可直接保存的新版本。\n\
要求：\n\
1. 直接返回完整 Markdown 文档，不要解释，不要代码块包裹。\n\
2. 如果文档包含 frontmatter，请一起优化 title、description、category、tags、slug；保留图片、发布状态等合理字段。\n\
3. 正文要更顺、更准确、更适合发布，但不要改掉核心事实。\n\
4. 保持 Markdown 结构清晰，标题层级合理，列表和代码块不要损坏。\n\
5. 使用中文润色，技术名词保持正确。\n\n\
原始 Markdown：\n{content}"
    )
}

fn build_polish_review_prompt(
    title: &str,
    review_type: &str,
    rating: i32,
    review_date: Option<&str>,
    status: &str,
    tags: &[String],
    description: &str,
) -> String {
    let review_date_text = review_date
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or("未填写");
    let tag_text = if tags.is_empty() {
        "无".to_string()
    } else {
        tags.join(" / ")
    };

    format!(
        "请润色一段中文作品评测简介/点评文案。\n\
要求：\n\
1. 保留原文观点、倾向和结论，不要杜撰剧情、设定或体验细节。\n\
2. 语言更凝练、更自然，适合放在评测页中展示。\n\
3. 可以优化句式与节奏，但不要改写成标题、列表或营销文案。\n\
4. 默认输出一到三段正文，总长度尽量控制在 80 到 220 字之间；如果原文本身更长，可适度保留信息密度。\n\
5. 只返回润色后的简介正文，不要附加解释。\n\n\
作品标题：{}\n\
评测类型：{}\n\
评分：{}/5\n\
评测日期：{}\n\
状态：{}\n\
标签：{}\n\n\
当前简介：\n{}",
        title.trim(),
        review_type.trim(),
        rating,
        review_date_text,
        status.trim(),
        tag_text,
        description.trim(),
    )
}

fn ensure_sentence_ending(text: &str) -> String {
    let trimmed = text.trim();
    if trimmed.is_empty() {
        return String::new();
    }

    if matches!(
        trimmed.chars().last(),
        Some('。' | '！' | '？' | '.' | '!' | '?')
    ) {
        trimmed.to_string()
    } else {
        format!("{trimmed}。")
    }
}

fn build_post_cover_prompt(
    title: &str,
    description: Option<&str>,
    category: Option<&str>,
    tags: &[String],
    post_type: &str,
    markdown: &str,
) -> String {
    let excerpt = strip_markdown_frontmatter(markdown)
        .chars()
        .take(1600)
        .collect::<String>();
    let description_text = description
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or("暂无摘要");
    let category_text = category
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or("未分类");
    let tag_text = if tags.is_empty() {
        "无".to_string()
    } else {
        tags.join(" / ")
    };
    let post_type_text = if post_type.trim().is_empty() {
        "article"
    } else {
        post_type.trim()
    };

    format!(
        "为一篇中文技术博客生成横版封面图。\n\
要求：\n\
1. 画面比例 16:9，适合作为文章头图。\n\
2. 风格偏终端审美、现代、克制、有设计感，不要廉价素材感。\n\
3. 不要在图片里放可读文字、logo、水印、UI 截图。\n\
4. 画面要能体现文章主题，但保持抽象和高级感。\n\
5. 适合中文技术博客首页与文章页展示。\n\n\
文章标题：{title}\n\
文章摘要：{description_text}\n\
分类：{category_text}\n\
标签：{tag_text}\n\
文章类型：{post_type_text}\n\n\
正文摘录：\n{excerpt}"
    )
}

fn build_image_generation_url(provider: &str, api_base: &str, image_model: &str) -> String {
    let normalized = normalize_provider_api_base(provider, api_base);

    if provider_uses_cloudflare(provider) {
        return build_endpoint(&normalized, &format!("/ai/run/{}", image_model.trim()));
    }

    build_endpoint(&normalized, "/images/generations")
}

fn extract_image_generation_result(value: &Value) -> Option<String> {
    if let Some(result) = value.get("result") {
        if let Some(image) = result.get("image").and_then(Value::as_str) {
            let trimmed = image.trim();
            if !trimmed.is_empty() {
                return Some(trimmed.to_string());
            }
        }

        if let Some(url) = result.get("url").and_then(Value::as_str) {
            let trimmed = url.trim();
            if !trimmed.is_empty() {
                return Some(trimmed.to_string());
            }
        }
    }

    if let Some(image) = value.get("image").and_then(Value::as_str) {
        let trimmed = image.trim();
        if !trimmed.is_empty() {
            return Some(trimmed.to_string());
        }
    }

    let data = value.get("data").and_then(Value::as_array)?;

    for item in data {
        if let Some(url) = item.get("url").and_then(Value::as_str) {
            let trimmed = url.trim();
            if !trimmed.is_empty() {
                return Some(trimmed.to_string());
            }
        }

        if let Some(base64_data) = item.get("b64_json").and_then(Value::as_str) {
            let trimmed = base64_data.trim();
            if !trimmed.is_empty() {
                return Some(trimmed.to_string());
            }
        }
    }

    None
}

fn generated_cover_directory() -> PathBuf {
    let current_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
    let candidates = [
        current_dir
            .join("frontend")
            .join("public")
            .join("generated-covers"),
        current_dir
            .join("..")
            .join("frontend")
            .join("public")
            .join("generated-covers"),
    ];

    candidates
        .into_iter()
        .find(|path| path.parent().map(|parent| parent.exists()).unwrap_or(false))
        .unwrap_or_else(|| {
            PathBuf::from("..")
                .join("frontend")
                .join("public")
                .join("generated-covers")
        })
}

fn image_details_from_mime(content_type: &str) -> Option<(&'static str, &'static str)> {
    match content_type
        .trim()
        .split(';')
        .next()
        .unwrap_or_default()
        .trim()
        .to_ascii_lowercase()
        .as_str()
    {
        "image/png" => Some(("png", "image/png")),
        "image/jpeg" | "image/jpg" => Some(("jpg", "image/jpeg")),
        "image/webp" => Some(("webp", "image/webp")),
        "image/gif" => Some(("gif", "image/gif")),
        "image/svg+xml" => Some(("svg", "image/svg+xml")),
        _ => None,
    }
}

fn image_details_from_extension(extension: &str) -> Option<(&'static str, &'static str)> {
    match extension
        .trim()
        .trim_start_matches('.')
        .to_ascii_lowercase()
        .as_str()
    {
        "png" => Some(("png", "image/png")),
        "jpg" | "jpeg" => Some(("jpg", "image/jpeg")),
        "webp" => Some(("webp", "image/webp")),
        "gif" => Some(("gif", "image/gif")),
        "svg" => Some(("svg", "image/svg+xml")),
        _ => None,
    }
}

fn image_details_from_url(url: &str) -> Option<(&'static str, &'static str)> {
    let parsed = Url::parse(url).ok()?;
    let path = parsed.path();
    let extension = path.rsplit_once('.')?.1;
    image_details_from_extension(extension)
}

fn decode_base64_image_payload(base64_data: &str) -> Result<(Vec<u8>, &'static str, &'static str)> {
    let trimmed = base64_data.trim();
    let (payload, extension, content_type) = if let Some(rest) = trimmed.strip_prefix("data:") {
        let (metadata, encoded) = rest
            .split_once(',')
            .ok_or_else(|| Error::BadRequest("AI 封面图 data URL 格式不正确".to_string()))?;
        let mime = metadata
            .split(';')
            .next()
            .filter(|value| !value.trim().is_empty())
            .unwrap_or("image/png");
        let (extension, content_type) =
            image_details_from_mime(mime).unwrap_or(("png", "image/png"));
        (encoded, extension, content_type)
    } else {
        (trimmed, "png", "image/png")
    };

    let image_bytes = BASE64_STANDARD
        .decode(payload)
        .map_err(|error| Error::BadRequest(format!("解析 AI 封面图失败: {error}")))?;

    Ok((image_bytes, extension, content_type))
}

async fn persist_generated_cover_image_bytes(
    ctx: &AppContext,
    slug_hint: &str,
    image_bytes: Vec<u8>,
    extension: &str,
    content_type: &str,
) -> Result<String> {
    if storage::optional_r2_settings(ctx).await?.is_some() {
        let key = storage::build_object_key("post-covers", slug_hint, extension);
        let stored = storage::upload_bytes_to_r2(
            ctx,
            &key,
            image_bytes,
            Some(content_type),
            Some("public, max-age=31536000, immutable"),
        )
        .await?;

        return Ok(stored.url);
    }

    let directory = generated_cover_directory();
    fs::create_dir_all(&directory)
        .map_err(|error| Error::BadRequest(format!("创建封面图目录失败: {error}")))?;

    let safe_slug = metadata_slugify(slug_hint);
    let file_name = format!(
        "{}-{}.{}",
        if safe_slug.is_empty() {
            "cover".to_string()
        } else {
            safe_slug
        },
        Uuid::new_v4().simple(),
        extension
            .trim()
            .trim_start_matches('.')
            .to_ascii_lowercase()
    );
    let file_path = directory.join(&file_name);

    fs::write(&file_path, image_bytes)
        .map_err(|error| Error::BadRequest(format!("写入 AI 封面图失败: {error}")))?;

    Ok(format!("/generated-covers/{file_name}"))
}

async fn persist_generated_cover_image(
    ctx: &AppContext,
    slug_hint: &str,
    image_result: &str,
) -> Result<String> {
    if image_result.starts_with("http://") || image_result.starts_with("https://") {
        let client = Client::new();
        let response = client
            .get(image_result)
            .send()
            .await
            .map_err(|error| Error::BadRequest(format!("下载 AI 封面图失败: {error}")))?
            .error_for_status()
            .map_err(|error| Error::BadRequest(format!("下载 AI 封面图失败: {error}")))?;
        let content_type_header = response
            .headers()
            .get(CONTENT_TYPE)
            .and_then(|value| value.to_str().ok())
            .map(ToString::to_string);
        let image_bytes = response
            .bytes()
            .await
            .map_err(|error| Error::BadRequest(format!("读取 AI 封面图失败: {error}")))?;
        let (extension, content_type) = content_type_header
            .as_deref()
            .and_then(image_details_from_mime)
            .or_else(|| image_details_from_url(image_result))
            .unwrap_or(("png", "image/png"));

        return persist_generated_cover_image_bytes(
            ctx,
            slug_hint,
            image_bytes.to_vec(),
            extension,
            content_type,
        )
        .await;
    }

    let (image_bytes, extension, content_type) = decode_base64_image_payload(image_result)?;
    persist_generated_cover_image_bytes(ctx, slug_hint, image_bytes, extension, content_type).await
}

fn fallback_polished_markdown(markdown: &str) -> String {
    let metadata = fallback_generated_metadata(markdown);
    let body = strip_markdown_frontmatter(markdown)
        .lines()
        .map(str::trim_end)
        .collect::<Vec<_>>()
        .join("\n")
        .replace("\n\n\n", "\n\n");

    format!(
        "---\n\
title: {}\n\
slug: {}\n\
description: {}\n\
category: {}\n\
post_type: \"article\"\n\
pinned: false\n\
published: true\n\
tags:\n{}\n\
---\n\n{}\n",
        serde_json::to_string(&metadata.title).unwrap_or_else(|_| "\"未命名文章\"".to_string()),
        metadata.slug,
        serde_json::to_string(&metadata.description)
            .unwrap_or_else(|_| "\"关于文章内容的摘要。\"".to_string()),
        serde_json::to_string(&metadata.category).unwrap_or_else(|_| "\"开发\"".to_string()),
        metadata
            .tags
            .iter()
            .map(|tag| format!("  - {}", serde_json::to_string(tag).unwrap_or_default()))
            .collect::<Vec<_>>()
            .join("\n"),
        body.trim()
    )
}

fn fallback_polished_review_description(description: &str) -> String {
    let normalized = normalize_newlines(description)
        .lines()
        .map(str::trim)
        .filter(|line| !line.is_empty())
        .collect::<Vec<_>>()
        .join("\n\n");

    ensure_sentence_ending(&normalized)
}

pub async fn generate_post_metadata(
    ctx: &AppContext,
    markdown: &str,
) -> Result<GeneratedPostMetadata> {
    let trimmed_markdown = markdown.trim();
    if trimmed_markdown.is_empty() {
        return Err(Error::BadRequest("文章内容不能为空".to_string()));
    }

    let settings = load_runtime_settings(ctx, false).await?;
    let remote_result: Result<GeneratedPostMetadata> = match (
        settings.api_base.clone(),
        settings.api_key.clone(),
    ) {
        (Some(api_base), Some(api_key)) => {
            let request = AiProviderRequest {
                provider: settings.provider.clone(),
                api_base,
                api_key,
                chat_model: settings.chat_model.clone(),
                system_prompt: "你是博客后台的内容编辑助手。你只负责提取和整理文章元数据，输出必须是合法 JSON。不要输出额外解释。".to_string(),
                prompt: build_post_metadata_prompt(trimmed_markdown),
            };

            let client = Client::new();
            let response = request_json(
                &client,
                &build_provider_url(&request),
                &request.api_key,
                build_provider_payload(&request, false),
            )
            .await;

            match response {
                Ok(response) => {
                    let text = extract_provider_text(&response).ok_or_else(|| {
                        Error::BadRequest("AI 元数据响应里没有可读取内容。".to_string())
                    })?;
                    let json_block = take_json_block(&text).ok_or_else(|| {
                        Error::BadRequest("AI 返回的元数据不是合法 JSON。".to_string())
                    })?;
                    let draft = serde_json::from_str::<GeneratedPostMetadataDraft>(&json_block)
                        .map_err(|error| {
                            Error::BadRequest(format!("AI 元数据解析失败: {error}"))
                        })?;

                    Ok(normalize_generated_metadata(trimmed_markdown, draft))
                }
                Err(error) => Err(error),
            }
        }
        _ => Err(Error::BadRequest(
            "AI 服务未配置完整，已自动切换为本地智能推断。".to_string(),
        )),
    };

    match remote_result {
        Ok(metadata) => Ok(metadata),
        Err(error) => {
            tracing::warn!("AI metadata generation fallback: {error}");
            Ok(fallback_generated_metadata(trimmed_markdown))
        }
    }
}

pub async fn polish_post_markdown(
    ctx: &AppContext,
    markdown: &str,
) -> Result<PolishedPostMarkdown> {
    let trimmed_markdown = markdown.trim();
    if trimmed_markdown.is_empty() {
        return Err(Error::BadRequest("文章内容不能为空".to_string()));
    }

    let settings = load_runtime_settings(ctx, false).await?;
    let remote_result: Result<PolishedPostMarkdown> = match (
        settings.api_base.clone(),
        settings.api_key.clone(),
    ) {
        (Some(api_base), Some(api_key)) => {
            let request = AiProviderRequest {
                    provider: settings.provider.clone(),
                    api_base,
                    api_key,
                    chat_model: settings.chat_model.clone(),
                    system_prompt: "你是博客后台的资深编辑。你的任务是把用户给出的 Markdown 文档润色成更适合发布的版本，并且只返回完整 Markdown。".to_string(),
                    prompt: build_polish_markdown_prompt(trimmed_markdown),
                };

            let client = Client::new();
            let response = request_json(
                &client,
                &build_provider_url(&request),
                &request.api_key,
                build_provider_payload(&request, false),
            )
            .await;

            match response {
                Ok(response) => {
                    let polished_markdown = extract_provider_text(&response).ok_or_else(|| {
                        Error::BadRequest("AI 润色响应里没有可读取内容。".to_string())
                    })?;

                    Ok(PolishedPostMarkdown {
                        polished_markdown: normalize_newlines(&polished_markdown),
                    })
                }
                Err(error) => Err(error),
            }
        }
        _ => Err(Error::BadRequest(
            "AI 服务未配置完整，已自动切换为本地智能推断。".to_string(),
        )),
    };

    match remote_result {
        Ok(result) => Ok(result),
        Err(error) => {
            tracing::warn!("AI post polish fallback: {error}");
            Ok(PolishedPostMarkdown {
                polished_markdown: fallback_polished_markdown(trimmed_markdown),
            })
        }
    }
}

pub async fn polish_review_description(
    ctx: &AppContext,
    title: &str,
    review_type: &str,
    rating: i32,
    review_date: Option<&str>,
    status: &str,
    tags: &[String],
    description: &str,
) -> Result<PolishedReviewDescription> {
    let trimmed_description = description.trim();
    if trimmed_description.is_empty() {
        return Err(Error::BadRequest(
            "请先填写点评内容，再进行润色。".to_string(),
        ));
    }

    let settings = load_runtime_settings(ctx, false).await?;
    let remote_result: Result<PolishedReviewDescription> = match (
        settings.api_base.clone(),
        settings.api_key.clone(),
    ) {
        (Some(api_base), Some(api_key)) => {
            let request = AiProviderRequest {
                provider: settings.provider.clone(),
                api_base,
                api_key,
                chat_model: settings.chat_model.clone(),
                system_prompt: "你是中文内容平台里的资深评测编辑。你只负责润色用户已有的作品点评文案，不要改写核心观点，不要虚构事实，不要输出额外解释。".to_string(),
                prompt: build_polish_review_prompt(
                    title,
                    review_type,
                    rating,
                    review_date,
                    status,
                    tags,
                    trimmed_description,
                ),
            };

            let client = Client::new();
            let response = request_json(
                &client,
                &build_provider_url(&request),
                &request.api_key,
                build_provider_payload(&request, false),
            )
            .await;

            match response {
                Ok(response) => {
                    let polished_description =
                        extract_provider_text(&response).ok_or_else(|| {
                            Error::BadRequest("AI 润色响应里没有可读取内容。".to_string())
                        })?;

                    Ok(PolishedReviewDescription {
                        polished_description: normalize_newlines(polished_description.trim()),
                    })
                }
                Err(error) => Err(error),
            }
        }
        _ => Err(Error::BadRequest(
            "AI 服务未配置完整，已自动切换为本地智能润色。".to_string(),
        )),
    };

    match remote_result {
        Ok(result) => Ok(result),
        Err(error) => {
            tracing::warn!("AI review polish fallback: {error}");
            Ok(PolishedReviewDescription {
                polished_description: fallback_polished_review_description(trimmed_description),
            })
        }
    }
}

pub async fn generate_post_cover_image(
    ctx: &AppContext,
    title: &str,
    description: Option<&str>,
    category: Option<&str>,
    tags: &[String],
    post_type: &str,
    slug: Option<&str>,
    markdown: &str,
) -> Result<GeneratedPostCoverImage> {
    let trimmed_title = title.trim();
    let trimmed_markdown = markdown.trim();

    if trimmed_title.is_empty() && trimmed_markdown.is_empty() {
        return Err(Error::BadRequest(
            "请至少填写标题或正文，再生成封面图。".to_string(),
        ));
    }

    let settings = load_runtime_settings(ctx, false).await?;
    let image_settings = settings.image.clone();
    let api_base = image_settings.api_base.clone().ok_or_else(|| {
        Error::BadRequest("图片 AI API Base 未配置，无法生成封面图。".to_string())
    })?;
    let api_key = image_settings
        .api_key
        .clone()
        .ok_or_else(|| Error::BadRequest("图片 AI API Key 未配置，无法生成封面图。".to_string()))?;
    let prompt = build_post_cover_prompt(
        if trimmed_title.is_empty() {
            "未命名文章"
        } else {
            trimmed_title
        },
        description,
        category,
        tags,
        post_type,
        trimmed_markdown,
    );
    let image_model = image_settings.image_model.clone();
    let response = request_image_generation(
        &image_settings.provider,
        &api_base,
        &api_key,
        &image_model,
        &prompt,
    )
    .await?;
    let image_result = extract_image_generation_result(&response)
        .ok_or_else(|| Error::BadRequest("AI 封面图响应里没有可读取图片。".to_string()))?;
    let slug_hint = slug
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .unwrap_or(trimmed_title);
    let image_url = persist_generated_cover_image(ctx, slug_hint, &image_result).await?;

    Ok(GeneratedPostCoverImage { image_url, prompt })
}

fn extract_response_output(value: &Value) -> Option<String> {
    if let Some(text) = value.get("output_text").and_then(Value::as_str) {
        let trimmed = text.trim();
        if !trimmed.is_empty() {
            return Some(trimmed.to_string());
        }
    }

    if let Some(text) = value.get("text").and_then(Value::as_str) {
        let trimmed = text.trim();
        if !trimmed.is_empty() {
            return Some(trimmed.to_string());
        }
    }

    if let Some(result) = value.get("result") {
        if let Some(text) = extract_response_output(result) {
            return Some(text);
        }
    }

    if let Some(response) = value.get("response") {
        if let Some(text) = extract_response_output(response) {
            return Some(text);
        }
    }

    if let Some(item) = value.get("item") {
        if let Some(text) = extract_response_output(item) {
            return Some(text);
        }
    }

    if let Some(part) = value.get("part") {
        if let Some(text) = extract_response_output(part) {
            return Some(text);
        }
    }

    if let Some(content_items) = value.get("content").and_then(Value::as_array) {
        if let Some(text) = extract_text_from_content_items(content_items) {
            return Some(text);
        }
    }

    let output_items = value.get("output").and_then(Value::as_array)?;
    let mut segments = Vec::new();

    for item in output_items {
        if let Some(content_items) = item.get("content").and_then(Value::as_array) {
            if let Some(text) = extract_text_from_content_items(content_items) {
                segments.push(text);
            }
        }
    }

    merge_text_segments(segments)
}

fn build_chat_prompt(question: &str, matches: &[ScoredChunk]) -> String {
    let context_blocks = matches
        .iter()
        .enumerate()
        .map(|(index, item)| {
            format!(
                "[资料 {}]\n标题: {}\nSlug: {}\n相似度: {:.4}\n内容:\n{}",
                index + 1,
                item.row
                    .source_title
                    .as_deref()
                    .filter(|value| !value.trim().is_empty())
                    .unwrap_or("未命名内容"),
                item.row.source_slug,
                item.score,
                item.row.content
            )
        })
        .collect::<Vec<_>>()
        .join("\n\n");

    format!(
        "请仅根据下面提供的资料回答用户问题。\n\
如果资料不足以支撑结论，请直接说明“我在当前博客资料里没有找到足够信息”。\n\
回答要求：\n\
1. 使用中文。\n\
2. 使用 Markdown 输出，必要时用短列表或小标题，不要输出 HTML。\n\
3. 先给直接结论，再补充关键点，整体尽量精炼。\n\
4. 不要编造未在资料中出现的事实。\n\
5. 如果回答引用了具体资料，可自然地提及文章标题。\n\n\
用户问题：{question}\n\n\
可用资料：\n{context_blocks}"
    )
}

fn is_profile_question(question: &str) -> bool {
    let normalized = question.trim().to_lowercase();
    [
        "站长",
        "博主",
        "作者",
        "个人介绍",
        "个人信息",
        "技术栈",
        "github",
        "邮箱",
        "联系方式",
        "init.cool",
        "owner",
        "author",
        "maintainer",
        "profile",
        "tech stack",
        "who runs",
        "who built",
    ]
    .iter()
    .any(|keyword| normalized.contains(keyword))
}

async fn prioritize_profile_matches(
    ctx: &AppContext,
    question: &str,
    matches: Vec<ScoredChunk>,
    limit: usize,
) -> Result<Vec<ScoredChunk>> {
    if !is_profile_question(question) {
        return Ok(matches);
    }

    let statement = Statement::from_sql_and_values(
        DbBackend::Postgres,
        r#"
        SELECT
            source_slug,
            source_title,
            source_type,
            chunk_index,
            content,
            content_preview,
            word_count,
            1.0::float8 AS score
        FROM ai_chunks
        WHERE source_type = $1
        ORDER BY chunk_index ASC
        LIMIT $2
        "#,
        ["profile".into(), (limit as i64).into()],
    );

    let profile_rows = SimilarChunkRow::find_by_statement(statement)
        .all(&ctx.db)
        .await?
        .into_iter()
        .map(|row| ScoredChunk {
            score: row.score,
            row: ai_chunks::Model {
                created_at: Utc::now().into(),
                updated_at: Utc::now().into(),
                id: 0,
                source_slug: row.source_slug,
                source_title: row.source_title,
                source_path: None,
                source_type: row.source_type,
                chunk_index: row.chunk_index,
                content: row.content,
                content_preview: row.content_preview,
                embedding: None,
                word_count: row.word_count,
            },
        })
        .collect::<Vec<_>>();

    if profile_rows.is_empty() {
        return Ok(matches);
    }

    let mut merged = profile_rows;

    for item in matches {
        let duplicated = merged.iter().any(|existing| {
            existing.row.source_slug == item.row.source_slug
                && existing.row.chunk_index == item.row.chunk_index
        });

        if !duplicated {
            merged.push(item);
        }
    }

    merged.truncate(limit);
    Ok(merged)
}

fn build_sources(matches: &[ScoredChunk]) -> Vec<AiSource> {
    matches
        .iter()
        .map(|item| AiSource {
            slug: item.row.source_slug.clone(),
            href: if item.row.source_type == "profile" {
                "/about".to_string()
            } else {
                format!("/articles/{}", item.row.source_slug)
            },
            title: item
                .row
                .source_title
                .as_deref()
                .filter(|value| !value.trim().is_empty())
                .unwrap_or("未命名内容")
                .to_string(),
            excerpt: item
                .row
                .content_preview
                .clone()
                .unwrap_or_else(|| preview_text(&item.row.content, 180).unwrap_or_default()),
            score: (item.score * 10000.0).round() / 10000.0,
            chunk_index: item.row.chunk_index,
        })
        .collect::<Vec<_>>()
}

pub(crate) fn build_provider_payload(request: &AiProviderRequest, stream: bool) -> Value {
    if provider_uses_cloudflare(&request.provider) {
        json!({
            "prompt": format!(
                "系统指令：{}

用户请求：{}",
                request.system_prompt, request.prompt,
            )
        })
    } else if provider_uses_responses(&request.provider) {
        json!({
            "model": request.chat_model,
            "input": [
                {
                    "role": "system",
                    "content": [
                        {
                            "type": "input_text",
                            "text": request.system_prompt
                        }
                    ]
                },
                {
                    "role": "user",
                    "content": [
                        {
                            "type": "input_text",
                            "text": request.prompt
                        }
                    ]
                }
            ],
            "reasoning": {
                "effort": DEFAULT_REASONING_EFFORT
            },
            "store": !DEFAULT_DISABLE_RESPONSE_STORAGE,
            "stream": stream
        })
    } else {
        json!({
            "model": request.chat_model,
            "temperature": 0.2,
            "stream": stream,
            "messages": [
                {
                    "role": "system",
                    "content": request.system_prompt,
                },
                {
                    "role": "user",
                    "content": request.prompt,
                }
            ]
        })
    }
}

pub(crate) fn build_provider_url(request: &AiProviderRequest) -> String {
    let api_base = normalize_provider_api_base(&request.provider, &request.api_base);
    let path = if provider_uses_cloudflare(&request.provider) {
        format!("/ai/run/{}", request.chat_model.trim())
    } else if provider_uses_responses(&request.provider) {
        "/responses".to_string()
    } else {
        "/chat/completions".to_string()
    };

    build_endpoint(&api_base, &path)
}

#[cfg(test)]
mod tests {
    use super::{
        AiProviderRequest, build_provider_url, extract_provider_text, is_profile_question,
        normalize_provider_api_base, parse_provider_sse_body,
    };

    fn build_request(provider: &str, api_base: &str) -> AiProviderRequest {
        AiProviderRequest {
            provider: provider.to_string(),
            api_base: api_base.to_string(),
            api_key: "test-key".to_string(),
            chat_model: "gpt-5.4".to_string(),
            system_prompt: "system".to_string(),
            prompt: "hello".to_string(),
        }
    }

    #[test]
    fn normalize_provider_api_base_adds_v1_for_root_openai_style_urls() {
        assert_eq!(
            normalize_provider_api_base("newapi", "https://91code.jiangnight.com"),
            "https://91code.jiangnight.com/v1"
        );
    }

    #[test]
    fn normalize_provider_api_base_keeps_existing_version_path() {
        assert_eq!(
            normalize_provider_api_base("newapi", "https://91code.jiangnight.com/v1/"),
            "https://91code.jiangnight.com/v1"
        );
    }

    #[test]
    fn normalize_provider_api_base_preserves_custom_subpaths() {
        assert_eq!(
            normalize_provider_api_base("openai-compatible", "https://proxy.example.com/openai"),
            "https://proxy.example.com/openai"
        );
    }

    #[test]
    fn build_provider_url_uses_normalized_base_for_responses_api() {
        let request = build_request("newapi", "https://91code.jiangnight.com");

        assert_eq!(
            build_provider_url(&request),
            "https://91code.jiangnight.com/v1/responses"
        );
    }

    #[test]
    fn normalize_provider_api_base_supports_cloudflare_account_id() {
        assert_eq!(
            normalize_provider_api_base("cloudflare", "test-account-id"),
            "https://api.cloudflare.com/client/v4/accounts/test-account-id"
        );
    }

    #[test]
    fn build_provider_url_uses_cloudflare_run_endpoint() {
        let mut request = build_request("cloudflare", "test-account-id");
        request.chat_model = "@cf/meta/llama-3.1-8b-instruct".to_string();

        assert_eq!(
            build_provider_url(&request),
            "https://api.cloudflare.com/client/v4/accounts/test-account-id/ai/run/@cf/meta/llama-3.1-8b-instruct"
        );
    }

    #[test]
    fn profile_question_detects_owner_keywords() {
        assert!(is_profile_question("站长的技术栈和个人介绍是什么？"));
        assert!(is_profile_question("Who runs init.cool?"));
    }

    #[test]
    fn parse_provider_sse_body_extracts_final_response_payload() {
        let body = concat!(
            "event: response.created\n",
            "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\",\"output\":[]}}\n\n",
            "event: response.completed\n",
            "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"output\":[{\"type\":\"message\",\"content\":[{\"type\":\"output_text\",\"text\":\"ok\"}]}]}}\n\n"
        );

        let parsed = parse_provider_sse_body(body).expect("expected SSE body to parse");

        assert_eq!(parsed["id"], "resp_1");
        assert_eq!(parsed["output"][0]["content"][0]["text"], "ok");
    }

    #[test]
    fn extract_provider_text_reads_completed_response_payload() {
        let payload = serde_json::json!({
            "response": {
                "output": [
                    {
                        "type": "message",
                        "content": [
                            {
                                "type": "output_text",
                                "text": "ok"
                            }
                        ]
                    }
                ]
            }
        });

        assert_eq!(extract_provider_text(&payload).as_deref(), Some("ok"));
    }

    #[test]
    fn extract_provider_text_reads_output_item_done_payload() {
        let payload = serde_json::json!({
            "item": {
                "type": "message",
                "content": [
                    {
                        "type": "output_text",
                        "text": "hello"
                    }
                ]
            }
        });

        assert_eq!(extract_provider_text(&payload).as_deref(), Some("hello"));
    }
}

pub(crate) fn extract_provider_text(value: &Value) -> Option<String> {
    extract_response_output(value).or_else(|| extract_message_content(value))
}

async fn request_chat_answer(request: &AiProviderRequest) -> Result<String> {
    let client = Client::new();
    let response = request_json(
        &client,
        &build_provider_url(request),
        &request.api_key,
        build_provider_payload(request, false),
    )
    .await?;

    extract_provider_text(&response).ok_or_else(|| {
        Error::BadRequest("AI chat response did not contain readable content".to_string())
    })
}

async fn request_image_generation(
    provider: &str,
    api_base: &str,
    api_key: &str,
    image_model: &str,
    prompt: &str,
) -> Result<Value> {
    let client = Client::new();

    if provider_uses_cloudflare(provider) {
        if image_model.eq_ignore_ascii_case(DEFAULT_CLOUDFLARE_IMAGE_MODEL) {
            let form = multipart::Form::new()
                .text("prompt", prompt.to_string())
                .text("width", "1024")
                .text("height", "576")
                .text("steps", "16");

            return request_multipart_json(
                &client,
                &build_image_generation_url(provider, api_base, image_model),
                api_key,
                form,
            )
            .await;
        }

        return request_json(
            &client,
            &build_image_generation_url(provider, api_base, image_model),
            api_key,
            json!({
                "prompt": prompt,
                "steps": 4
            }),
        )
        .await;
    }

    request_json(
        &client,
        &build_image_generation_url(provider, api_base, image_model),
        api_key,
        json!({
            "model": image_model,
            "prompt": prompt,
            "size": "1536x1024",
            "quality": "high",
            "output_format": "png"
        }),
    )
    .await
}

pub async fn test_provider_connectivity(
    provider: &str,
    api_base: &str,
    api_key: &str,
    chat_model: &str,
) -> Result<AiProviderConnectivityResult> {
    let provider = trim_to_option(Some(provider.to_string()))
        .unwrap_or_else(|| DEFAULT_AI_PROVIDER.to_string());
    let api_base = trim_to_option(Some(api_base.to_string()))
        .ok_or_else(|| Error::BadRequest("请先填写 API 地址".to_string()))?;
    let api_key = trim_to_option(Some(api_key.to_string()))
        .ok_or_else(|| Error::BadRequest("请先填写 API 密钥".to_string()))?;
    let chat_model = trim_to_option(Some(chat_model.to_string()))
        .ok_or_else(|| Error::BadRequest("请先填写对话模型".to_string()))?;

    let request = AiProviderRequest {
        provider: provider.clone(),
        api_base,
        api_key,
        chat_model: chat_model.clone(),
        system_prompt: "你是一个连通性检测助手。".to_string(),
        prompt: "请只回复 pong".to_string(),
    };
    let reply = request_chat_answer(&request).await?;
    let reply_preview = reply.trim().chars().take(160).collect::<String>();

    Ok(AiProviderConnectivityResult {
        provider,
        endpoint: build_provider_url(&request),
        chat_model,
        reply_preview,
    })
}

pub async fn test_image_provider_connectivity(
    provider: &str,
    api_base: &str,
    api_key: &str,
    image_model: &str,
) -> Result<AiImageProviderConnectivityResult> {
    let provider = trim_to_option(Some(provider.to_string()))
        .unwrap_or_else(|| DEFAULT_AI_PROVIDER.to_string());
    let api_base = trim_to_option(Some(api_base.to_string()))
        .ok_or_else(|| Error::BadRequest("请先填写图片 API 地址".to_string()))?;
    let api_key = trim_to_option(Some(api_key.to_string()))
        .ok_or_else(|| Error::BadRequest("请先填写图片 API 密钥".to_string()))?;
    let image_model = trim_to_option(Some(image_model.to_string()))
        .ok_or_else(|| Error::BadRequest("请先填写图片模型".to_string()))?;

    let prompt = "Minimal abstract technology cover art, blue gradient, no text, no watermark";
    let response = if provider_uses_cloudflare(&provider)
        && image_model.eq_ignore_ascii_case(DEFAULT_CLOUDFLARE_IMAGE_MODEL)
    {
        let client = Client::new();
        let form = multipart::Form::new()
            .text("prompt", prompt.to_string())
            .text("width", "512")
            .text("height", "288")
            .text("steps", "4");

        request_multipart_json(
            &client,
            &build_image_generation_url(&provider, &api_base, &image_model),
            &api_key,
            form,
        )
        .await?
    } else {
        request_image_generation(&provider, &api_base, &api_key, &image_model, prompt).await?
    };
    let image_result = extract_image_generation_result(&response)
        .ok_or_else(|| Error::BadRequest("图片接口响应里没有可读取的图片结果".to_string()))?;
    let result_preview =
        if image_result.starts_with("http://") || image_result.starts_with("https://") {
            image_result
        } else {
            format!("base64 image ok ({} chars)", image_result.len())
        };

    Ok(AiImageProviderConnectivityResult {
        provider: provider.clone(),
        endpoint: build_image_generation_url(&provider, &api_base, &image_model),
        image_model,
        result_preview,
    })
}

pub(crate) async fn prepare_answer(ctx: &AppContext, question: &str) -> Result<PreparedAiAnswer> {
    let trimmed_question = question.trim();
    if trimmed_question.is_empty() {
        return Err(Error::BadRequest("问题不能为空".to_string()));
    }

    let settings = load_runtime_settings(ctx, true).await?;
    let (matches, indexed_chunks, last_indexed_at) =
        retrieve_matches(ctx, &settings, trimmed_question).await?;

    if matches.is_empty() {
        return Ok(PreparedAiAnswer {
            question: trimmed_question.to_string(),
            provider_request: None,
            immediate_answer: Some(
                "我在当前博客资料里没有找到足够信息。你可以换个更具体的问题，或者先去后台重建一下 AI 索引。"
                    .to_string(),
            ),
            sources: Vec::new(),
            indexed_chunks,
            last_indexed_at,
        });
    }

    let sources = build_sources(&matches);
    let provider_request = match (settings.api_base.clone(), settings.api_key.clone()) {
        (Some(api_base), Some(api_key)) => Some(AiProviderRequest {
            provider: settings.provider.clone(),
            api_base,
            api_key,
            chat_model: settings.chat_model.clone(),
            system_prompt: settings.system_prompt.clone(),
            prompt: build_chat_prompt(trimmed_question, &matches),
        }),
        _ => None,
    };

    let immediate_answer = provider_request
        .is_none()
        .then(|| retrieval_only_answer(&matches));

    Ok(PreparedAiAnswer {
        question: trimmed_question.to_string(),
        provider_request,
        immediate_answer,
        sources,
        indexed_chunks,
        last_indexed_at,
    })
}

fn retrieval_only_answer(matches: &[ScoredChunk]) -> String {
    let summary = matches
        .iter()
        .take(3)
        .map(|item| {
            let title = item
                .row
                .source_title
                .as_deref()
                .filter(|value| !value.trim().is_empty())
                .unwrap_or("未命名内容");
            let excerpt = item
                .row
                .content_preview
                .clone()
                .unwrap_or_else(|| preview_text(&item.row.content, 120).unwrap_or_default());

            format!("《{title}》: {excerpt}")
        })
        .collect::<Vec<_>>()
        .join("\n");

    format!(
        "本地知识检索已经完成，但后台还没有配置聊天模型 API，所以我先返回最相关的资料摘要：\n{summary}\n\n\
如果你希望得到完整的自然语言回答，请在后台补上聊天模型的 API Base / API Key。"
    )
}

fn build_reindex_progress(
    phase: &str,
    message: String,
    total_chunks: usize,
    processed_chunks: usize,
    batch_size: usize,
) -> AiReindexProgress {
    let normalized_batch_size = batch_size.max(1);
    let total_batches = total_chunks.div_ceil(normalized_batch_size);
    let current_batch = if processed_chunks == 0 {
        0
    } else {
        processed_chunks
            .div_ceil(normalized_batch_size)
            .min(total_batches)
    };
    let percent = if total_chunks == 0 {
        100
    } else {
        ((processed_chunks * 100) / total_chunks).min(100)
    };

    AiReindexProgress {
        phase: phase.to_string(),
        message,
        total_chunks,
        processed_chunks,
        total_batches,
        current_batch,
        batch_size: normalized_batch_size,
        percent,
    }
}

async fn update_reindex_job_progress(
    ctx: &AppContext,
    job_id: Option<i32>,
    progress: &AiReindexProgress,
) -> Result<()> {
    let Some(job_id) = job_id else {
        return Ok(());
    };

    worker_jobs::update_job_result(
        ctx,
        job_id,
        serde_json::json!({
            "phase": progress.phase,
            "message": progress.message,
            "progress": progress,
        }),
    )
    .await
}

async fn stop_reindex_if_cancel_requested(ctx: &AppContext, job_id: Option<i32>) -> Result<()> {
    let Some(job_id) = job_id else {
        return Ok(());
    };

    if worker_jobs::cancel_job_if_requested(ctx, job_id, "job cancelled during reindex").await? {
        return Err(Error::BadRequest("job cancelled".to_string()));
    }

    Ok(())
}

async fn load_runtime_settings(
    ctx: &AppContext,
    require_enabled: bool,
) -> Result<AiRuntimeSettings> {
    let raw = site_settings::Entity::find()
        .order_by_asc(site_settings::Column::Id)
        .one(&ctx.db)
        .await?
        .ok_or(Error::NotFound)?;

    if require_enabled && !raw.ai_enabled.unwrap_or(false) {
        return Err(Error::NotFound);
    }

    let active_provider =
        site_settings_controller::active_ai_provider_id(&raw).and_then(|active_id| {
            site_settings_controller::ai_provider_configs(&raw)
                .into_iter()
                .find(|item| item.id == active_id)
        });
    let provider = active_provider
        .as_ref()
        .map(|item| provider_name(Some(item.provider.as_str())))
        .unwrap_or_else(|| provider_name(raw.ai_provider.as_deref()));
    let api_base = active_provider
        .as_ref()
        .and_then(|item| trim_to_option(item.api_base.clone()))
        .or_else(|| trim_to_option(raw.ai_api_base.clone()));
    let api_key = active_provider
        .as_ref()
        .and_then(|item| trim_to_option(item.api_key.clone()))
        .or_else(|| trim_to_option(raw.ai_api_key.clone()));
    let chat_model = active_provider
        .as_ref()
        .and_then(|item| trim_to_option(item.chat_model.clone()))
        .unwrap_or_else(|| {
            if provider_uses_cloudflare(&provider) {
                DEFAULT_CLOUDFLARE_CHAT_MODEL.to_string()
            } else {
                DEFAULT_CHAT_MODEL.to_string()
            }
        });
    let legacy_image_model = active_provider
        .as_ref()
        .and_then(|item| trim_to_option(item.image_model.clone()))
        .unwrap_or_else(|| default_image_model_for_provider_name(&provider).to_string());
    let image_provider = trim_to_option(raw.ai_image_provider.clone());
    let image_api_base = trim_to_option(raw.ai_image_api_base.clone());
    let image_api_key = trim_to_option(raw.ai_image_api_key.clone());
    let image_model = trim_to_option(raw.ai_image_model.clone());
    let has_dedicated_image_settings = image_provider.is_some()
        || image_api_base.is_some()
        || image_api_key.is_some()
        || image_model.is_some();
    let image = if has_dedicated_image_settings {
        let provider = image_provider.unwrap_or_else(|| provider.clone());
        let image_model = image_model
            .unwrap_or_else(|| default_image_model_for_provider_name(&provider).to_string());

        AiImageRuntimeSettings {
            provider,
            api_base: image_api_base,
            api_key: image_api_key,
            image_model,
        }
    } else {
        AiImageRuntimeSettings {
            provider: provider.clone(),
            api_base: api_base.clone(),
            api_key: api_key.clone(),
            image_model: legacy_image_model,
        }
    };

    Ok(AiRuntimeSettings {
        provider,
        api_base,
        api_key,
        chat_model,
        image,
        system_prompt: trim_to_option(raw.ai_system_prompt.clone())
            .unwrap_or_else(|| DEFAULT_SYSTEM_PROMPT.to_string()),
        top_k: raw
            .ai_top_k
            .map(|value| value.clamp(1, 12) as usize)
            .unwrap_or(DEFAULT_TOP_K),
        chunk_size: raw
            .ai_chunk_size
            .map(|value| value.clamp(400, 4000) as usize)
            .unwrap_or(DEFAULT_CHUNK_SIZE),
        raw,
    })
}

async fn update_indexed_at<C: ConnectionTrait>(
    db: &C,
    settings: &site_settings::Model,
) -> Result<DateTime<Utc>> {
    let now = Utc::now();
    let mut model = settings.clone().into_active_model();
    model.ai_last_indexed_at = Set(Some(now.into()));
    let _ = model.update(db).await?;
    Ok(now)
}

async fn retrieve_matches(
    ctx: &AppContext,
    settings: &AiRuntimeSettings,
    question: &str,
) -> Result<(Vec<ScoredChunk>, usize, Option<DateTime<Utc>>)> {
    let indexed_chunks = ai_chunks::Entity::find().count(&ctx.db).await? as usize;
    let last_indexed_at = settings.raw.ai_last_indexed_at.map(Into::into);

    if indexed_chunks == 0 {
        return Ok((Vec::new(), 0, last_indexed_at));
    }

    let question_embedding =
        embed_texts_locally(vec![question.trim().to_string()], EmbeddingKind::Query)
            .await?
            .into_iter()
            .next()
            .unwrap_or_default();
    let query_vector = vector_literal(&question_embedding)?;

    let statement = Statement::from_sql_and_values(
        DbBackend::Postgres,
        r#"
        SELECT
            source_slug,
            source_title,
            source_type,
            chunk_index,
            content,
            content_preview,
            word_count,
            (1 - (embedding <=> $1::vector))::float8 AS score
        FROM ai_chunks
        WHERE embedding IS NOT NULL
        ORDER BY embedding <=> $1::vector
        LIMIT $2
        "#,
        [query_vector.into(), (settings.top_k as i64).into()],
    );

    let matches = SimilarChunkRow::find_by_statement(statement)
        .all(&ctx.db)
        .await?
        .into_iter()
        .map(|row| ScoredChunk {
            score: row.score,
            row: ai_chunks::Model {
                created_at: Utc::now().into(),
                updated_at: Utc::now().into(),
                id: 0,
                source_slug: row.source_slug,
                source_title: row.source_title,
                source_path: None,
                source_type: row.source_type,
                chunk_index: row.chunk_index,
                content: row.content,
                content_preview: row.content_preview,
                embedding: None,
                word_count: row.word_count,
            },
        })
        .collect::<Vec<_>>();

    let matches = prioritize_profile_matches(ctx, question, matches, settings.top_k).await?;

    Ok((matches, indexed_chunks, last_indexed_at))
}

pub async fn rebuild_index(ctx: &AppContext, job_id: Option<i32>) -> Result<AiIndexSummary> {
    let settings = load_runtime_settings(ctx, false).await?;
    let posts = content::load_markdown_posts_from_store(ctx).await?;
    let mut chunk_drafts = build_chunks(&posts, settings.chunk_size);
    chunk_drafts.extend(build_profile_chunks(&settings.raw, settings.chunk_size));
    let total_chunks = chunk_drafts.len();
    let batch_size = REINDEX_EMBEDDING_BATCH_SIZE.max(1);
    let preparing_progress = build_reindex_progress(
        "preparing",
        if total_chunks == 0 {
            "没有可写入的内容，正在清理旧索引。".to_string()
        } else {
            format!("已收集 {total_chunks} 个分块，准备重建向量索引。")
        },
        total_chunks,
        0,
        batch_size,
    );
    update_reindex_job_progress(ctx, job_id, &preparing_progress).await?;
    stop_reindex_if_cancel_requested(ctx, job_id).await?;
    let txn = ctx.db.begin().await?;

    txn.execute(Statement::from_string(
        DbBackend::Postgres,
        "TRUNCATE TABLE ai_chunks RESTART IDENTITY".to_string(),
    ))
    .await?;

    let mut processed_chunks = 0usize;

    for chunk_batch in chunk_drafts.chunks(batch_size) {
        stop_reindex_if_cancel_requested(ctx, job_id).await?;
        let embeddings = embed_texts_locally_with_batch_size(
            chunk_batch
                .iter()
                .map(|chunk| chunk.content.clone())
                .collect::<Vec<_>>(),
            EmbeddingKind::Passage,
            batch_size,
        )
        .await?;

        for (draft, embedding) in chunk_batch.iter().zip(embeddings.into_iter()) {
            let embedding_literal = vector_literal(&embedding)?;
            let statement = Statement::from_sql_and_values(
                DbBackend::Postgres,
                r#"
                INSERT INTO ai_chunks (
                    source_slug,
                    source_title,
                    source_path,
                    source_type,
                    chunk_index,
                    content,
                    content_preview,
                    embedding,
                    word_count
                ) VALUES (
                    $1, $2, $3, $4, $5, $6, $7, $8::vector, $9
                )
                "#,
                vec![
                    draft.source_slug.clone().into(),
                    draft.source_title.clone().into(),
                    draft.source_path.clone().into(),
                    draft.source_type.clone().into(),
                    draft.chunk_index.into(),
                    draft.content.clone().into(),
                    draft.content_preview.clone().into(),
                    embedding_literal.into(),
                    draft.word_count.into(),
                ],
            );
            txn.execute(statement).await?;
        }

        processed_chunks += chunk_batch.len();
        let embedding_progress = build_reindex_progress(
            "embedding",
            format!(
                "正在写入第 {}/{} 批向量。",
                processed_chunks.div_ceil(batch_size),
                total_chunks.div_ceil(batch_size)
            ),
            total_chunks,
            processed_chunks,
            batch_size,
        );
        update_reindex_job_progress(ctx, job_id, &embedding_progress).await?;
    }

    stop_reindex_if_cancel_requested(ctx, job_id).await?;
    let last_indexed_at = update_indexed_at(&txn, &settings.raw).await?;
    txn.commit().await?;

    Ok(AiIndexSummary {
        indexed_chunks: chunk_drafts.len(),
        last_indexed_at: Some(last_indexed_at),
    })
}

pub async fn answer_question(ctx: &AppContext, question: &str) -> Result<AiAnswer> {
    let prepared = prepare_answer(ctx, question).await?;
    let answer = if let Some(immediate_answer) = prepared.immediate_answer.clone() {
        immediate_answer
    } else {
        let request = prepared
            .provider_request
            .as_ref()
            .ok_or_else(|| Error::BadRequest("AI provider request was not prepared".to_string()))?;
        request_chat_answer(request).await?
    };

    Ok(AiAnswer {
        answer,
        sources: prepared.sources,
        indexed_chunks: prepared.indexed_chunks,
        last_indexed_at: prepared.last_indexed_at,
    })
}

pub async fn admin_chat_completion(
    ctx: &AppContext,
    system_prompt: &str,
    prompt: &str,
) -> Result<String> {
    let settings = load_runtime_settings(ctx, false).await?;
    let api_base = settings
        .api_base
        .ok_or_else(|| Error::BadRequest("请先在后台配置 AI API Base".to_string()))?;
    let api_key = settings
        .api_key
        .ok_or_else(|| Error::BadRequest("请先在后台配置 AI API Key".to_string()))?;

    request_chat_answer(&AiProviderRequest {
        provider: settings.provider,
        api_base,
        api_key,
        chat_model: settings.chat_model,
        system_prompt: system_prompt.trim().to_string(),
        prompt: prompt.trim().to_string(),
    })
    .await
}

pub fn provider_name(value: Option<&str>) -> String {
    trim_to_option(value.map(ToString::to_string))
        .unwrap_or_else(|| DEFAULT_AI_PROVIDER.to_string())
}

pub fn default_api_base() -> &'static str {
    DEFAULT_AI_API_BASE
}

pub fn default_api_key() -> &'static str {
    DEFAULT_AI_API_KEY
}

pub fn default_chat_model() -> &'static str {
    DEFAULT_CHAT_MODEL
}

pub fn local_embedding_label() -> &'static str {
    LOCAL_EMBEDDING_MODEL_LABEL
}
