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

use event_log::DomainEventRecord;
use regex::RegexBuilder;
use serde::{Deserialize, Serialize};
use serde_json::Value;

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IndexedEntityKind {
    PageTitle,
    PageSummary,
    BlockContent,
    BlockPath,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexedDocument {
    pub workspace_id: String,
    pub entity_kind: IndexedEntityKind,
    pub entity_id: String,
    pub parent_id: Option<String>,
    pub title: Option<String>,
    pub content: String,
    pub updated_at: String,
    pub source_event_id: Option<String>,
    pub revision: Option<u64>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexCursor {
    pub workspace_id: String,
    pub last_processed_event_id: String,
    pub last_processed_at: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchHit {
    pub workspace_id: String,
    pub entity_kind: IndexedEntityKind,
    pub entity_id: String,
    pub snippet: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchResultSet {
    pub query: String,
    pub workspace_id: Option<String>,
    pub page_id: Option<String>,
    pub hits: Vec<SearchHit>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectedDocumentBatch {
    pub workspace_id: String,
    pub source_event_id: String,
    pub documents: Vec<IndexedDocument>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectionResult {
    pub cursor: IndexCursor,
    pub batches: Vec<ProjectedDocumentBatch>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchDocumentRecord {
    pub id: String,
    pub workspace_id: String,
    pub title: Option<String>,
    pub raw_text: Option<String>,
    pub created_at: Option<String>,
    pub updated_at: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchMindmapRecord {
    pub document_id: String,
    pub data: Value,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchTableRecord {
    pub id: String,
    pub document_id: String,
    pub title: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchTableRowRecord {
    pub table_id: String,
    pub document_id: String,
    pub row_hash: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchAssetRecord {
    pub id: String,
    pub document_id: String,
    pub asset_type: Option<String>,
    pub file_name: Option<String>,
    pub mime_type: Option<String>,
    pub ocr_text: Option<String>,
    pub ocr_status: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchDocumentsDataset {
    pub documents: Vec<SearchDocumentRecord>,
    pub mindmaps: Vec<SearchMindmapRecord>,
    pub tables: Vec<SearchTableRecord>,
    pub table_rows: Vec<SearchTableRowRecord>,
    pub assets: Vec<SearchAssetRecord>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchDocumentsRequest {
    pub query: String,
    pub workspace_id: String,
    pub page_id: Option<String>,
    pub limit: usize,
    pub title_only: bool,
    pub exact: bool,
    pub include_ocr: bool,
    pub time_range: String,
    pub time_field: String,
    pub custom_range_from: Option<String>,
    pub custom_range_to: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SearchMatchField {
    Title,
    Content,
    Recent,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchEvidenceRecord {
    pub kind: String,
    pub node_id: Option<String>,
    pub snippet: String,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RankedSearchDocument {
    pub id: String,
    pub title: String,
    pub snippet: String,
    pub updated_at: Option<String>,
    pub created_at: Option<String>,
    pub match_field: SearchMatchField,
    pub has_ocr: bool,
    pub public_path: String,
    pub score: f64,
    pub node_id: Option<String>,
    pub subtree_root_id: Option<String>,
    pub evidence: Vec<SearchEvidenceRecord>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchDocumentsEvaluation {
    pub enqueue_asset_ids: Vec<String>,
    pub results: Vec<RankedSearchDocument>,
}

pub trait DomainEventProjector {
    fn project(&self, event: &DomainEventRecord) -> Vec<IndexedDocument>;
}

pub fn supported_index_objects() -> Vec<IndexedEntityKind> {
    vec![
        IndexedEntityKind::PageTitle,
        IndexedEntityKind::PageSummary,
        IndexedEntityKind::BlockContent,
        IndexedEntityKind::BlockPath,
    ]
}

pub fn can_rebuild_from_events(cursor: &IndexCursor) -> bool {
    !cursor.workspace_id.trim().is_empty() && !cursor.last_processed_event_id.trim().is_empty()
}

pub fn advance_workspace_cursor(
    cursor: &IndexCursor,
    event_id: impl Into<String>,
    processed_at: impl Into<String>,
) -> IndexCursor {
    IndexCursor {
        workspace_id: cursor.workspace_id.clone(),
        last_processed_event_id: event_id.into(),
        last_processed_at: processed_at.into(),
    }
}

pub fn project_domain_event<P: DomainEventProjector>(
    projector: &P,
    event: &DomainEventRecord,
    cursor: &IndexCursor,
) -> ProjectionResult {
    let documents = projector.project(event);
    ProjectionResult {
        cursor: advance_workspace_cursor(
            cursor,
            event.event_id.clone(),
            event.created_at.as_str().to_string(),
        ),
        batches: if documents.is_empty() {
            Vec::new()
        } else {
            vec![ProjectedDocumentBatch {
                workspace_id: event.workspace_id.clone(),
                source_event_id: event.event_id.clone(),
                documents,
            }]
        },
    }
}

pub fn rebuild_from_events<P: DomainEventProjector>(
    projector: &P,
    events: &[DomainEventRecord],
    cursor: &IndexCursor,
) -> ProjectionResult {
    let mut next_cursor = cursor.clone();
    let mut batches = Vec::new();
    for event in events {
        if event.workspace_id != cursor.workspace_id {
            continue;
        }
        let projected = projector.project(event);
        next_cursor = advance_workspace_cursor(
            &next_cursor,
            event.event_id.clone(),
            event.created_at.as_str().to_string(),
        );
        if !projected.is_empty() {
            batches.push(ProjectedDocumentBatch {
                workspace_id: event.workspace_id.clone(),
                source_event_id: event.event_id.clone(),
                documents: projected,
            });
        }
    }
    ProjectionResult {
        cursor: next_cursor,
        batches,
    }
}

pub fn search_pages(
    query: &str,
    workspace_id: Option<&str>,
    documents: &[IndexedDocument],
    limit: usize,
) -> SearchResultSet {
    let query = query.trim().to_lowercase();
    let hits = documents
        .iter()
        .filter(|document| {
            matches!(
                document.entity_kind,
                IndexedEntityKind::PageTitle | IndexedEntityKind::PageSummary
            )
        })
        .filter(|document| {
            workspace_id.map_or(true, |workspace| document.workspace_id == workspace)
        })
        .filter(|document| {
            document
                .title
                .as_deref()
                .unwrap_or("")
                .to_lowercase()
                .contains(&query)
                || document.content.to_lowercase().contains(&query)
        })
        .take(limit)
        .map(|document| SearchHit {
            workspace_id: document.workspace_id.clone(),
            entity_kind: document.entity_kind.clone(),
            entity_id: document.entity_id.clone(),
            snippet: Some(document.content.chars().take(120).collect()),
        })
        .collect();

    SearchResultSet {
        query: query.into(),
        workspace_id: workspace_id.map(|value| value.into()),
        page_id: None,
        hits,
    }
}

pub fn search_blocks(
    query: &str,
    page_id: Option<&str>,
    documents: &[IndexedDocument],
    limit: usize,
) -> SearchResultSet {
    let query = query.trim().to_lowercase();
    let hits = documents
        .iter()
        .filter(|document| {
            matches!(
                document.entity_kind,
                IndexedEntityKind::BlockContent | IndexedEntityKind::BlockPath
            )
        })
        .filter(|document| {
            page_id.map_or(true, |page| {
                document.parent_id.as_deref() == Some(page) || document.entity_id == page
            })
        })
        .filter(|document| document.content.to_lowercase().contains(&query))
        .take(limit)
        .map(|document| SearchHit {
            workspace_id: document.workspace_id.clone(),
            entity_kind: document.entity_kind.clone(),
            entity_id: document.entity_id.clone(),
            snippet: Some(document.content.chars().take(120).collect()),
        })
        .collect();

    SearchResultSet {
        query: query.into(),
        workspace_id: None,
        page_id: page_id.map(|value| value.into()),
        hits,
    }
}

pub fn evaluate_search_documents(
    request: &SearchDocumentsRequest,
    dataset: &SearchDocumentsDataset,
) -> SearchDocumentsEvaluation {
    let normalized_query = request.query.trim();
    if normalized_query.is_empty() {
        return SearchDocumentsEvaluation {
            enqueue_asset_ids: Vec::new(),
            results: Vec::new(),
        };
    }

    let normalized_lower = normalized_query.to_lowercase();
    let boundary_iso = match request.time_range.as_str() {
        "7d" => iso_days_ago(7),
        "30d" => iso_days_ago(30),
        _ => None,
    };

    let eligible_docs: Vec<&SearchDocumentRecord> = dataset
        .documents
        .iter()
        .filter(|document| document.workspace_id == request.workspace_id)
        .filter(|document| {
            if let Some(page_id) = request.page_id.as_ref() {
                document.id == *page_id
            } else {
                true
            }
        })
        .filter(|document| match request.time_field.as_str() {
            "created" => within_range(
                document.created_at.as_deref(),
                boundary_iso.as_deref(),
                request.custom_range_from.as_deref(),
                request.custom_range_to.as_deref(),
            ),
            _ => within_range(
                document
                    .updated_at
                    .as_deref()
                    .or(document.created_at.as_deref()),
                boundary_iso.as_deref(),
                request.custom_range_from.as_deref(),
                request.custom_range_to.as_deref(),
            ),
        })
        .collect();

    let eligible_doc_ids: HashSet<&str> = eligible_docs
        .iter()
        .map(|document| document.id.as_str())
        .collect();
    let doc_map: HashMap<&str, &SearchDocumentRecord> = eligible_docs
        .iter()
        .map(|document| (document.id.as_str(), *document))
        .collect();

    let mut matches = HashMap::<String, SearchMatchInfo>::new();
    for document in &eligible_docs {
        let title = normalize_title(document.title.as_deref());
        let title_lower = title.to_lowercase();
        let hit_title = if request.exact {
            title_lower == normalized_lower
        } else {
            title_lower.contains(&normalized_lower)
        };

        if hit_title {
            upsert_match(
                &mut matches,
                &document.id,
                SearchMatchInfo {
                    score: 3.0,
                    match_field: SearchMatchField::Title,
                    snippet: build_snippet(&title, normalized_query),
                    has_ocr: false,
                },
            );
        }

        if request.title_only {
            continue;
        }

        let raw_text = document.raw_text.as_deref().unwrap_or("").trim();
        if !raw_text.is_empty() && raw_text.to_lowercase().contains(&normalized_lower) {
            upsert_match(
                &mut matches,
                &document.id,
                SearchMatchInfo {
                    score: 2.0,
                    match_field: SearchMatchField::Content,
                    snippet: build_snippet(raw_text, normalized_query),
                    has_ocr: false,
                },
            );
        }
    }

    let mut table_title_by_id = HashMap::<String, String>::new();
    if !request.title_only {
        for mindmap in &dataset.mindmaps {
            if !eligible_doc_ids.contains(mindmap.document_id.as_str()) {
                continue;
            }
            let text = extract_text_from_mindmap_data(&mindmap.data, 60_000);
            if text.is_empty() || !text.to_lowercase().contains(&normalized_lower) {
                continue;
            }
            upsert_match(
                &mut matches,
                &mindmap.document_id,
                SearchMatchInfo {
                    score: 1.6,
                    match_field: SearchMatchField::Content,
                    snippet: build_snippet(&format!("思维导图：{text}"), normalized_query),
                    has_ocr: false,
                },
            );
        }

        for table in &dataset.tables {
            if !eligible_doc_ids.contains(table.document_id.as_str()) {
                continue;
            }
            let title = normalize_title(table.title.as_deref());
            table_title_by_id.insert(table.id.clone(), title.clone());
            if title.to_lowercase().contains(&normalized_lower) {
                upsert_match(
                    &mut matches,
                    &table.document_id,
                    SearchMatchInfo {
                        score: 1.5,
                        match_field: SearchMatchField::Content,
                        snippet: build_snippet(&format!("表格：{title}"), normalized_query),
                        has_ocr: false,
                    },
                );
            }
        }

        for row in &dataset.table_rows {
            if !eligible_doc_ids.contains(row.document_id.as_str()) {
                continue;
            }
            let row_hash = row.row_hash.as_deref().unwrap_or("").trim();
            if row_hash.is_empty() || !row_hash.to_lowercase().contains(&normalized_lower) {
                continue;
            }
            let table_title = table_title_by_id
                .get(&row.table_id)
                .cloned()
                .unwrap_or_else(|| "未命名表格".into());
            upsert_match(
                &mut matches,
                &row.document_id,
                SearchMatchInfo {
                    score: 1.4,
                    match_field: SearchMatchField::Content,
                    snippet: build_snippet(
                        &format!("表格：{table_title}\n{row_hash}"),
                        normalized_query,
                    ),
                    has_ocr: false,
                },
            );
        }
    }

    let mut enqueue_asset_ids = Vec::new();
    for asset in &dataset.assets {
        if !eligible_doc_ids.contains(asset.document_id.as_str()) {
            continue;
        }

        let file_name = asset.file_name.as_deref().unwrap_or("").trim();
        if !request.title_only
            && !file_name.is_empty()
            && file_name.to_lowercase().contains(&normalized_lower)
        {
            upsert_match(
                &mut matches,
                &asset.document_id,
                SearchMatchInfo {
                    score: 1.2,
                    match_field: SearchMatchField::Content,
                    snippet: build_snippet(&format!("附件：{file_name}"), normalized_query),
                    has_ocr: false,
                },
            );
        }

        if !request.include_ocr {
            continue;
        }

        let ocr_text = asset.ocr_text.as_deref().unwrap_or("").trim();
        if ocr_text.is_empty() {
            let ocr_status = asset.ocr_status.as_deref().unwrap_or("").trim();
            let busy = ocr_status == "queued" || ocr_status == "running";
            if !busy
                && should_extract_attachment_text(
                    asset.asset_type.as_deref(),
                    asset.mime_type.as_deref(),
                    asset.file_name.as_deref(),
                )
            {
                enqueue_asset_ids.push(asset.id.clone());
            }
            continue;
        }

        if ocr_text.to_lowercase().contains(&normalized_lower) {
            upsert_match(
                &mut matches,
                &asset.document_id,
                SearchMatchInfo {
                    score: 1.7,
                    match_field: SearchMatchField::Content,
                    snippet: build_snippet(
                        &format!(
                            "附件：{}\n{ocr_text}",
                            if file_name.is_empty() {
                                asset.id.as_str()
                            } else {
                                file_name
                            }
                        ),
                        normalized_query,
                    ),
                    has_ocr: true,
                },
            );
        }
    }

    let mut results = matches
        .into_iter()
        .filter_map(|(document_id, matched)| {
            let document = doc_map.get(document_id.as_str())?;
            let snippet = matched.snippet.clone();
            let evidence_kind = if matched.has_ocr {
                "ocr".to_string()
            } else {
                match matched.match_field {
                    SearchMatchField::Title => "title".to_string(),
                    SearchMatchField::Content => "content".to_string(),
                    SearchMatchField::Recent => "recent".to_string(),
                }
            };
            Some(RankedSearchDocument {
                id: document.id.clone(),
                title: normalize_title(document.title.as_deref()),
                snippet: snippet.clone(),
                updated_at: document.updated_at.clone(),
                created_at: document.created_at.clone(),
                match_field: matched.match_field,
                has_ocr: matched.has_ocr,
                public_path: format!("/documents/{}", document.id),
                score: matched.score,
                node_id: Some(document.id.clone()),
                subtree_root_id: Some(document.id.clone()),
                evidence: vec![SearchEvidenceRecord {
                    kind: evidence_kind,
                    node_id: Some(document.id.clone()),
                    snippet,
                }],
            })
        })
        .collect::<Vec<_>>();

    results.sort_by(|left, right| {
        right
            .score
            .partial_cmp(&left.score)
            .unwrap_or(std::cmp::Ordering::Equal)
            .then_with(|| {
                let left_time = left
                    .updated_at
                    .as_deref()
                    .or(left.created_at.as_deref())
                    .unwrap_or("");
                let right_time = right
                    .updated_at
                    .as_deref()
                    .or(right.created_at.as_deref())
                    .unwrap_or("");
                right_time.cmp(left_time)
            })
            .then_with(|| left.title.cmp(&right.title))
    });
    results.truncate(request.limit);

    enqueue_asset_ids.sort();
    enqueue_asset_ids.dedup();
    if enqueue_asset_ids.len() > 3 {
        enqueue_asset_ids.truncate(3);
    }

    SearchDocumentsEvaluation {
        enqueue_asset_ids,
        results,
    }
}

pub struct MinimalWorkspaceProjector;

impl DomainEventProjector for MinimalWorkspaceProjector {
    fn project(&self, event: &DomainEventRecord) -> Vec<IndexedDocument> {
        let content = event.payload_json.trim();
        let (entity_kind, title, parent_id) = match event.aggregate_type.as_str() {
            "page" => (
                IndexedEntityKind::PageTitle,
                Some(event.event_type.clone()),
                None,
            ),
            "block" => (
                IndexedEntityKind::BlockContent,
                None,
                Some(event.aggregate_id.clone()),
            ),
            _ => return Vec::new(),
        };

        vec![IndexedDocument {
            workspace_id: event.workspace_id.clone(),
            entity_kind,
            entity_id: event.aggregate_id.clone(),
            parent_id,
            title,
            content: content.to_string(),
            updated_at: event.created_at.as_str().to_string(),
            source_event_id: Some(event.event_id.clone()),
            revision: Some(event.event_version as u64),
        }]
    }
}

#[derive(Debug, Clone)]
struct SearchMatchInfo {
    score: f64,
    match_field: SearchMatchField,
    snippet: String,
    has_ocr: bool,
}

fn normalize_title(title: Option<&str>) -> String {
    let normalized = title.unwrap_or("").trim();
    if normalized.is_empty() {
        "无标题".into()
    } else {
        normalized.into()
    }
}

fn within_range(
    timestamp: Option<&str>,
    boundary_iso: Option<&str>,
    custom_range_from: Option<&str>,
    custom_range_to: Option<&str>,
) -> bool {
    let Some(timestamp) = timestamp else {
        return boundary_iso.is_none() && custom_range_from.is_none() && custom_range_to.is_none();
    };

    if let Some(boundary_iso) = boundary_iso {
        if timestamp < boundary_iso {
            return false;
        }
    }
    if let Some(custom_range_from) = custom_range_from {
        if timestamp < custom_range_from {
            return false;
        }
    }
    if let Some(custom_range_to) = custom_range_to {
        if timestamp > custom_range_to {
            return false;
        }
    }
    true
}

fn iso_days_ago(days: i64) -> Option<String> {
    use std::time::{Duration, SystemTime};

    let seconds = days.checked_mul(24 * 60 * 60)?;
    let duration = Duration::from_secs(seconds as u64);
    let cutoff = SystemTime::now().checked_sub(duration)?;
    let datetime = chrono_like::system_time_to_iso(cutoff)?;
    Some(datetime)
}

fn upsert_match(
    matches: &mut HashMap<String, SearchMatchInfo>,
    document_id: &str,
    patch: SearchMatchInfo,
) {
    let Some(previous) = matches.get(document_id).cloned() else {
        matches.insert(document_id.into(), patch);
        return;
    };

    let mut next = SearchMatchInfo {
        score: previous.score.max(patch.score),
        match_field: patch.match_field.clone(),
        snippet: patch.snippet.clone(),
        has_ocr: previous.has_ocr || patch.has_ocr,
    };

    if matches!(previous.match_field, SearchMatchField::Title)
        && !matches!(patch.match_field, SearchMatchField::Title)
    {
        next.match_field = SearchMatchField::Title;
        next.snippet = previous.snippet;
    } else if patch.score <= previous.score {
        next.match_field = previous.match_field;
        next.snippet = previous.snippet;
    }

    matches.insert(document_id.into(), next);
}

fn extract_text_from_mindmap_data(value: &Value, max_chars: usize) -> String {
    fn walk(node: &Value, output: &mut Vec<String>, max_chars: usize) {
        if output.join("\n").len() >= max_chars {
            return;
        }
        match node {
            Value::Object(map) => {
                if let Some(Value::Object(data)) = map.get("data") {
                    if let Some(Value::String(text)) = data.get("text") {
                        let normalized = normalize_text(text);
                        if !normalized.is_empty() {
                            output.push(normalized);
                        }
                    }
                }
                if let Some(Value::Array(children)) = map.get("children") {
                    for child in children {
                        walk(child, output, max_chars);
                        if output.join("\n").len() >= max_chars {
                            break;
                        }
                    }
                }
                if let Some(root) = map.get("root") {
                    walk(root, output, max_chars);
                }
            }
            Value::Array(items) => {
                for item in items {
                    walk(item, output, max_chars);
                    if output.join("\n").len() >= max_chars {
                        break;
                    }
                }
            }
            _ => {}
        }
    }

    let mut output = Vec::new();
    walk(value, &mut output, max_chars);
    let joined = output.join("\n").trim().to_string();
    if joined.len() > max_chars {
        format!("{}…", &joined[..max_chars])
    } else {
        joined
    }
}

fn normalize_text(value: &str) -> String {
    value
        .split_whitespace()
        .collect::<Vec<_>>()
        .join(" ")
        .trim()
        .to_string()
}

fn escape_html(value: &str) -> String {
    value
        .replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
}

fn build_snippet(text: &str, keyword: &str) -> String {
    let source = text.trim();
    if source.is_empty() {
        return "暂无正文内容".into();
    }
    let escaped = escape_html(source);
    if keyword.trim().is_empty() {
        return truncate_snippet(&escaped);
    }

    let regex = match RegexBuilder::new(&regex::escape(keyword))
        .case_insensitive(true)
        .build()
    {
        Ok(regex) => regex,
        Err(_) => return truncate_snippet(&escaped),
    };

    let Some(found) = regex.find(&escaped) else {
        return truncate_snippet(&escaped);
    };

    let start = found.start().saturating_sub(20);
    let end = (found.end() + 80).min(escaped.len());
    let segment = escaped[start..end].to_string();
    regex
        .replace_all(&segment, |captures: &regex::Captures<'_>| {
            format!("<mark>{}</mark>", &captures[0])
        })
        .to_string()
}

fn truncate_snippet(value: &str) -> String {
    let mut chars = value.chars();
    let truncated = chars.by_ref().take(120).collect::<String>();
    if chars.next().is_some() {
        format!("{truncated}…")
    } else {
        truncated
    }
}

fn should_extract_attachment_text(
    asset_type: Option<&str>,
    mime_type: Option<&str>,
    file_name: Option<&str>,
) -> bool {
    if asset_type.unwrap_or("").trim() != "file" {
        return false;
    }

    let mime_type = mime_type.unwrap_or("").trim().to_lowercase();
    if matches!(
        mime_type.as_str(),
        "application/pdf"
            | "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
            | "application/vnd.openxmlformats-officedocument.presentationml.presentation"
            | "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
    ) {
        return true;
    }

    let file_name = file_name.unwrap_or("").trim().to_lowercase();
    [".pdf", ".docx", ".pptx", ".xlsx"]
        .iter()
        .any(|suffix| file_name.ends_with(suffix))
}

mod chrono_like {
    use std::time::{SystemTime, UNIX_EPOCH};

    pub fn system_time_to_iso(time: SystemTime) -> Option<String> {
        let duration = time.duration_since(UNIX_EPOCH).ok()?;
        let seconds = duration.as_secs() as i64;
        let days = seconds.div_euclid(86_400);
        let secs_of_day = seconds.rem_euclid(86_400);

        let (year, month, day) = civil_from_days(days)?;
        let hour = secs_of_day / 3_600;
        let minute = (secs_of_day % 3_600) / 60;
        let second = secs_of_day % 60;
        Some(format!(
            "{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z"
        ))
    }

    fn civil_from_days(days: i64) -> Option<(i64, i64, i64)> {
        let z = days.checked_add(719_468)?;
        let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
        let doe = z - era * 146_097;
        let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
        let y = yoe + era * 400;
        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
        let mp = (5 * doy + 2) / 153;
        let d = doy - (153 * mp + 2) / 5 + 1;
        let m = mp + if mp < 10 { 3 } else { -9 };
        let year = y + if m <= 2 { 1 } else { 0 };
        Some((year, m, d))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use core_domain::Timestamp;
    use event_log::EventStatus;
    use serde_json::json;

    fn cursor() -> IndexCursor {
        IndexCursor {
            workspace_id: "ws_1".into(),
            last_processed_event_id: "evt_0".into(),
            last_processed_at: "2026-04-11T00:00:00Z".into(),
        }
    }

    fn event(
        workspace_id: &str,
        aggregate_type: &str,
        aggregate_id: &str,
        event_id: &str,
        event_type: &str,
    ) -> DomainEventRecord {
        DomainEventRecord {
            event_id: event_id.into(),
            workspace_id: workspace_id.into(),
            aggregate_type: aggregate_type.into(),
            aggregate_id: aggregate_id.into(),
            event_type: event_type.into(),
            event_version: 1,
            payload_json: "{\"text\":\"hello\"}".into(),
            command_log_id: "cmd_1".into(),
            actor_type: "human".into(),
            created_at: Timestamp::new("2026-04-11T00:00:01Z"),
            status: EventStatus::Committed,
            trace_id: "trace_1".into(),
            request_id: "req_1".into(),
            command_id: "cmd_1".into(),
        }
    }

    #[test]
    fn supports_page_and_block_projection_objects() {
        assert_eq!(
            supported_index_objects(),
            vec![
                IndexedEntityKind::PageTitle,
                IndexedEntityKind::PageSummary,
                IndexedEntityKind::BlockContent,
                IndexedEntityKind::BlockPath
            ]
        );
    }

    #[test]
    fn cursor_advances_incrementally() {
        let next = advance_workspace_cursor(&cursor(), "evt_2", "2026-04-11T00:00:02Z");
        assert_eq!(next.last_processed_event_id, "evt_2");
        assert_eq!(next.workspace_id, "ws_1");
    }

    #[test]
    fn projector_builds_real_documents_for_page_and_block() {
        let projector = MinimalWorkspaceProjector;
        let page_docs =
            projector.project(&event("ws_1", "page", "page_1", "evt_1", "page.created"));
        let block_docs =
            projector.project(&event("ws_1", "block", "block_1", "evt_2", "block.created"));
        assert_eq!(page_docs.len(), 1);
        assert_eq!(block_docs.len(), 1);
        assert_eq!(page_docs[0].entity_kind, IndexedEntityKind::PageTitle);
        assert_eq!(block_docs[0].entity_kind, IndexedEntityKind::BlockContent);
    }

    #[test]
    fn rebuild_skips_other_workspaces_and_keeps_cursor() {
        let projector = MinimalWorkspaceProjector;
        let events = vec![
            event("ws_2", "page", "page_x", "evt_x", "page.created"),
            event("ws_1", "block", "block_1", "evt_1", "block.created"),
        ];
        let result = rebuild_from_events(&projector, &events, &cursor());
        assert_eq!(result.batches.len(), 1);
        assert_eq!(result.cursor.last_processed_event_id, "evt_1");
    }

    #[test]
    fn search_helpers_return_hits() {
        let docs = vec![
            IndexedDocument {
                workspace_id: "ws_1".into(),
                entity_kind: IndexedEntityKind::PageTitle,
                entity_id: "page_1".into(),
                parent_id: None,
                title: Some("Rust Notes".into()),
                content: "Rust notes for phase 2".into(),
                updated_at: "2026-04-11T00:00:01Z".into(),
                source_event_id: Some("evt_1".into()),
                revision: Some(1),
            },
            IndexedDocument {
                workspace_id: "ws_1".into(),
                entity_kind: IndexedEntityKind::BlockContent,
                entity_id: "block_1".into(),
                parent_id: Some("page_1".into()),
                title: None,
                content: "A block about rust search indexing".into(),
                updated_at: "2026-04-11T00:00:02Z".into(),
                source_event_id: Some("evt_2".into()),
                revision: Some(1),
            },
        ];

        let pages = search_pages("rust", Some("ws_1"), &docs, 10);
        assert_eq!(pages.hits.len(), 1);
        assert_eq!(pages.hits[0].entity_id, "page_1");

        let blocks = search_blocks("search", Some("page_1"), &docs, 10);
        assert_eq!(blocks.hits.len(), 1);
        assert_eq!(blocks.hits[0].entity_id, "block_1");
    }

    #[test]
    fn evaluate_search_documents_uses_rust_scoring_and_snippet() {
        let result = evaluate_search_documents(
            &SearchDocumentsRequest {
                query: "rust".into(),
                workspace_id: "ws_1".into(),
                page_id: None,
                limit: 10,
                title_only: false,
                exact: false,
                include_ocr: true,
                time_range: "any".into(),
                time_field: "updated".into(),
                custom_range_from: None,
                custom_range_to: None,
            },
            &SearchDocumentsDataset {
                documents: vec![
                    SearchDocumentRecord {
                        id: "page_1".into(),
                        workspace_id: "ws_1".into(),
                        title: Some("Rust Notes".into()),
                        raw_text: Some("正文包含 rust 搜索".into()),
                        created_at: Some("2026-04-11T00:00:01Z".into()),
                        updated_at: Some("2026-04-11T00:00:02Z".into()),
                    },
                    SearchDocumentRecord {
                        id: "page_2".into(),
                        workspace_id: "ws_1".into(),
                        title: Some("附件页".into()),
                        raw_text: Some("".into()),
                        created_at: Some("2026-04-11T00:00:01Z".into()),
                        updated_at: Some("2026-04-11T00:00:03Z".into()),
                    },
                ],
                mindmaps: vec![SearchMindmapRecord {
                    document_id: "page_1".into(),
                    data: json!({
                        "root": {
                            "data": { "text": "Rust 脑图节点" },
                            "children": [],
                        }
                    }),
                }],
                tables: vec![],
                table_rows: vec![],
                assets: vec![SearchAssetRecord {
                    id: "asset_1".into(),
                    document_id: "page_2".into(),
                    asset_type: Some("file".into()),
                    file_name: Some("demo.pdf".into()),
                    mime_type: Some("application/pdf".into()),
                    ocr_text: None,
                    ocr_status: Some("idle".into()),
                }],
            },
        );

        assert_eq!(result.results.len(), 1);
        assert_eq!(result.results[0].id, "page_1");
        assert!(
            result.results[0].snippet.contains("<mark>Rust</mark>")
                || result.results[0].snippet.contains("<mark>rust</mark>")
        );
        assert_eq!(result.results[0].node_id.as_deref(), Some("page_1"));
        assert_eq!(result.results[0].subtree_root_id.as_deref(), Some("page_1"));
        assert_eq!(result.results[0].evidence.len(), 1);
        assert_eq!(result.enqueue_asset_ids, vec!["asset_1".to_string()]);
    }
}
