-- allium: 1
-- bDS Full-Text Search
-- Scope: core (Wave 1 — in-app full-text search with Snowball stemmers)
-- Distilled from: src/main/engine/PostEngine.ts (FTS methods),
--   MediaEngine.ts (FTS methods), stemmer.ts

use "./post.allium" as post
use "./media.allium" as media

surface SearchControlSurface {
    facing _: SearchOperator

    provides:
        SearchPostsRequested(query, filters)
        SearchMediaRequested(query)
}

surface SearchIndexRuntimeSurface {
    facing _: SearchRuntime

    provides:
        SearchIndexUpdated(post)
        SearchIndexUpdated(media)
}

value StemmerLanguage {
    -- Snowball stemmers for 24 languages
    -- ISO 639-1 to Snowball mapping
    -- Applied to both indexing and query processing
    code: String
}

surface StemmerLanguageSurface {
    context language: StemmerLanguage

    exposes:
        language.code
}

entity PostSearchIndex {
    -- Full-text index projection
    -- Indexed fields: title, excerpt, content, tags, categories
    -- Plus all translation titles, excerpts, and content
    post: post/Post
    stemmed_content: String
}

entity MediaSearchIndex {
    -- Full-text index projection
    -- Indexed fields: title, alt, caption, original_name, tags
    -- Plus all translation titles, alts, and captions
    media: media/Media
    stemmed_content: String
}

invariant CrossLanguageStemming {
    -- Search index uses Snowball stemmer matched to content language
    -- A post in German is stemmed with the German stemmer
    -- Translations are stemmed with their respective language stemmers
    -- Query-time stemming matches the index language
}

rule SearchPosts {
    when: SearchPostsRequested(query, filters)
    -- Full-text search with optional filters:
    --   status, tags, categories, language, missingTranslationLanguage,
    --   year, month, date range (from/to)
    -- Returns paginated results with total count
    let stemmed_query = stem(query, detect_language(query))
    let matched = search_fts(PostSearchIndex, stemmed_query, filters)
    ensures: SearchResults(
        posts: matched,
        total: matched.count,
        offset: filters.offset,
        limit: filters.limit
    )
}

rule SearchMedia {
    when: SearchMediaRequested(query)
    let stemmed_query = stem(query, detect_language(query))
    let matched = search_fts(MediaSearchIndex, stemmed_query)
    ensures: SearchResults(
        media: matched
    )
}

rule IndexPost {
    when: SearchIndexUpdated(post)
    -- Stems: title + excerpt + content + tags + categories
    -- Plus all translations' title + excerpt + content
    let all_text = concat_post_text(post)
        -- Concatenates: post.title, post.excerpt, post.content,
        -- join(post.tags, " "), join(post.categories, " "),
        -- and all translations' title, excerpt, content
    let index_entry = PostSearchIndex{post: post}
    ensures:
        if exists index_entry:
            index_entry.stemmed_content = stem(all_text)
        else:
            PostSearchIndex.created(post: post, stemmed_content: stem(all_text))
}

rule IndexMedia {
    when: SearchIndexUpdated(media)
    -- Stems: title + alt + caption + original_name + tags
    -- Plus all translations' title, alt, caption
    let all_text = concat_media_text(media)
        -- Concatenates: media.title, media.alt, media.caption,
        -- media.original_name, join(media.tags, " "),
        -- and all translations' title, alt, caption
    let index_entry = MediaSearchIndex{media: media}
    ensures:
        if exists index_entry:
            index_entry.stemmed_content = stem(all_text)
        else:
            MediaSearchIndex.created(media: media, stemmed_content: stem(all_text))
}
