-- allium: 1
-- bDS Miscellaneous Editor Views
-- Scope: UI content area — dashboard, menu editor, metadata diff, git diff,
--   documentation, validation, find duplicates, import analysis
-- Distilled from: Editor.tsx (Dashboard), MenuEditorView.tsx,
--   MetadataDiffPanel.tsx, ImportAnalysisView.tsx

-- Describes the layout and behaviour of smaller editor views that don't
-- warrant their own spec file.

use "./tabs.allium" as tabs
use "./i18n.allium" as i18n
use "./generation.allium" as generation

-- ─── Dashboard (no tab active) ───────────────────────────────

-- Shown as default/welcome view when no entity tab is active.

value Dashboard {
    title: String
    subtitle: String
    stats: DashboardStats
    timeline: DashboardTimeline
    tag_cloud: DashboardTagCloud
    category_cloud: DashboardCategoryCloud
    recent_posts: List<DashboardRecentPost>
}

value DashboardStats {
    total_posts: Integer
    published_count: Integer
    draft_count: Integer
    archived_count: Integer             -- shown only if > 0
    media_count: Integer
    image_count: Integer
    total_media_size: String            -- formatted B/KB/MB/GB
    tag_count: Integer
    category_count: Integer
}

value DashboardTimeline {
    months: List<DashboardTimelineMonth>
}

value DashboardTimelineMonth {
    label: String                       -- month abbreviation
    year: Integer
    count: Integer
}

value DashboardTagCloud {
    tags: List<DashboardTag>
    overflow_count: Integer?            -- "and N more" when > config.dashboard_max_tags
}

value DashboardTag {
    name: String
    count: Integer
    color: String?
}

value DashboardCategoryCloud {
    categories: List<DashboardCategory>
}

value DashboardCategory {
    name: String
    count: Integer
}

value DashboardRecentPost {
    post_id: String
    title: String                       -- "Untitled" as fallback
    status: String                      -- draft | published
    date: String                        -- locale-formatted
}

config {
    dashboard_max_tags: Integer = 40
    dashboard_tag_min_font: Integer = 11
    dashboard_tag_max_font: Integer = 22
    dashboard_recent_count: Integer = 5
    dashboard_timeline_months: Integer = 12
}

surface DashboardSurface {
    context dash: Dashboard

    exposes:
        dash.title
        dash.subtitle
        dash.stats.total_posts
        dash.stats.published_count
        dash.stats.draft_count
        dash.stats.archived_count when dash.stats.archived_count > 0
        dash.stats.media_count
        dash.stats.image_count
        dash.stats.total_media_size
        dash.stats.tag_count
        dash.stats.category_count
        for m in dash.timeline.months:
            m.label
            m.year
            m.count
        for t in dash.tag_cloud.tags:
            t.name
            t.count
            t.color
        dash.tag_cloud.overflow_count when dash.tag_cloud.overflow_count != null
        for c in dash.category_cloud.categories:
            c.name
            c.count
        for rp in dash.recent_posts:
            rp.title
            rp.status
            rp.date

    provides:
        DashboardRecentPostClicked(post_id, single)
        DashboardRecentPostClicked(post_id, double)

    @guarantee StatCards
        -- Three stat cards side by side.
        -- Posts card: total number, breakdown tags (published/drafts/archived if > 0).
        -- Media card: count, images count, total size (formatted bytes).
        -- Tags card: count, categories count.

    @guarantee TimelineChart
        -- Bar chart of posts over last config.dashboard_timeline_months months that have data.
        -- Each bar: count label on top, month abbreviation + year below.
        -- Bar height proportional to max count.

    @guarantee TagCloud
        -- Up to config.dashboard_max_tags tags, sorted alphabetically.
        -- Font size scaled config.dashboard_tag_min_font to config.dashboard_tag_max_font px
        --   based on post count.
        -- Tags with colours get coloured background with contrast text.
        -- Hover title shows post count.
        -- "and N more" text when overflow_count > 0.

    @guarantee CategoryCloud
        -- All categories as badge-like tags.
        -- Each shows category name + count.

    @guarantee RecentPosts
        -- Last config.dashboard_recent_count posts by updatedAt descending.
        -- Each row: title (or "Untitled"), status badge (draft/published), date.
        -- Single-click: preview tab. Double-click: pin tab.
}

-- ─── Menu editor view ────────────────────────────────────────

-- Visual editor for the OPML navigation menu (meta/menu.opml).
-- See menu.allium for data model.

value MenuEditorView {
    items: List<MenuTreeItem>
}

value MenuTreeItem {
    item_id: String
    kind: String                        -- home | page | category_archive | submenu
    label: String
    children: List<MenuTreeItem>
    is_home: Boolean                    -- true for the home item (protected)
}

surface MenuEditorSurface {
    context menu: MenuEditorView

    exposes:
        for item in menu.items:
            item.kind
            item.label
            item.children
            item.is_home

    provides:
        MenuItemAdded(kind, data)
        MenuSaveRequested()
        MenuItemDeleted(item_id)
            when not item.is_home
        MenuItemMoved(item_id, direction)
            when not item.is_home

    @guarantee HeaderLayout
        -- Title + description text.

    @guarantee Toolbar
        -- 8 icon buttons with tooltips:
        --   Add Entry (+), Save (floppy), Add Category Archive,
        --   Move Up, Move Down, Indent, Unindent, Delete.

    @guarantee TreeView
        -- Drag-and-drop tree with items showing:
        --   Drag handle, kind icon (home/page/category-archive/submenu SVGs),
        --   title, selected row highlighting.
        -- Nested items indented to show hierarchy.

    @guarantee InlineEditing
        -- When creating page item: inline PageInput with search
        --   (filters posts in "page" category).
        -- When creating category archive: inline CategoryInput with search/create.
        -- Escape cancels, selection confirms.

    @guarantee HomeItemProtection
        -- Home item cannot be moved, reordered, or deleted.
        -- Maximum one home item allowed.

    @guarantee DragDrop
        -- Drag handle on each item.
        -- Auto-expand collapsed submenus on hover (config.menu_drag_expand_delay ms delay).
        -- Drop indicators show target position and nesting level.

    @guarantee MoveDirections
        -- Up/Down: reorder within same nesting level.
        -- Indent: nest under previous sibling (becomes child).
        -- Unindent: move to parent's level (becomes next sibling of parent).
}

config {
    menu_drag_expand_delay: Integer = 450
}

rule MenuAddItem {
    when: MenuItemAdded(kind, data)
    -- kind = page: opens lazy-loaded page picker (posts with "page" category)
    -- kind = category_archive: opens lazy-loaded category picker
    -- kind = submenu: creates empty container node for nesting children
    -- kind = home: always available, maximum one allowed
    ensures: MenuTreeUpdated()
}

rule MenuSave {
    when: MenuSaveRequested()
    -- Serializes tree to OPML 2.0, writes meta/menu.opml
    ensures: MenuFileWritten()
}

rule MenuMoveItem {
    when: MenuItemMoved(item_id, direction)
    -- direction = up | down | indent | unindent
    requires: not is_home_item(item_id)
    ensures: MenuTreeUpdated()
}

rule MenuDeleteItem {
    when: MenuItemDeleted(item_id)
    requires: not is_home_item(item_id)
    -- Removes item and all children, no confirmation dialog
    ensures: MenuTreeUpdated()
}

-- ─── Metadata diff view ──────────────────────────────────────

-- Shows DB vs filesystem differences for all entity types.
-- See metadata_diff.allium for diff field definitions.

value MetadataDiffView {
    is_scanning: Boolean
    active_entity_tab: String           -- posts | media | scripts | templates
    diff_stats: MetadataDiffStats
    field_summaries: List<MetadataDiffFieldSummary>
    items: List<MetadataDiffItem>
    orphan_files: List<MetadataDiffOrphanFile>
}

value MetadataDiffStats {
    total_posts: Integer
    published_posts: Integer
    draft_posts: Integer
    media_files: Integer
    scripts: Integer
    templates: Integer
}

value MetadataDiffFieldSummary {
    field_name: String
    diff_count: Integer
}

value MetadataDiffItem {
    entity_name: String
    entity_type: String
    file_missing: Boolean               -- badge shown when file not found
    field_diffs: List<MetadataDiffField>
}

value MetadataDiffField {
    field_name: String
    db_value: String?
    file_value: String?
}

value MetadataDiffOrphanFile {
    file_path: String
    entity_type: String
}

surface MetadataDiffSurface {
    context diff: MetadataDiffView

    exposes:
        diff.is_scanning
        diff.active_entity_tab
        diff.diff_stats
        for fs in diff.field_summaries:
            fs.field_name
            fs.diff_count
        for item in diff.items:
            item.entity_name
            item.file_missing
            for fd in item.field_diffs:
                fd.field_name
                fd.db_value
                fd.file_value
        for orphan in diff.orphan_files:
            orphan.file_path

    provides:
        MetadataDiffScanRequested()
        MetadataDiffSyncFieldToFile(entity_name, field_name)
        MetadataDiffSyncFieldToDb(entity_name, field_name)
        MetadataDiffSyncAllFieldToFile(field_name)
        MetadataDiffSyncAllFieldToDb(field_name)
        MetadataDiffImportOrphan(file_path)

    @guarantee HeaderLayout
        -- Title + description text.
        -- Stats row: 6 stat items (total posts, published, drafts, media, scripts, templates).

    @guarantee ScanAction
        -- Scan/Rescan button at top.
        -- Progress bar + message during scan.

    @guarantee EntityTabs
        -- Tabs: Posts, Media, Scripts, Templates — each with badge count of diffs.

    @guarantee FieldSummaryPills
        -- Clickable filter pills per field, each with count.
        -- Two bulk sync buttons per pill: DB→File and File→DB (syncs all items for that field).

    @guarantee DiffItemCards
        -- Per-item card: header (entity label + file-missing badge),
        --   field rows (field name, DB value, file value),
        --   two sync buttons per field (DB→File, File→DB).
        -- Button disappears after successful sync (field now matches).

    @guarantee OrphanFilesSection
        -- Files on disk with no matching DB record shown at bottom of each entity tab.
        -- "Import" button creates DB record from file metadata.

    @guarantee NoConfirmation
        -- Individual field syncs require no confirmation dialog.
}

-- ─── Git diff view ────────────────────────────────────────────

-- Renders diff for a file (working tree vs HEAD) or a commit.
-- File diff: id = "git-diff:{filePath}"
-- Commit diff: id = "git-diff:commit:{commitHash}"

value GitDiffView {
    diff_id: String
    diff_type: String                   -- file | commit
    display_mode: String                -- inline | side_by_side (from editor settings)
}

surface GitDiffSurface {
    context diff: GitDiffView

    exposes:
        diff.diff_id
        diff.diff_type
        diff.display_mode

    @guarantee DiffDisplayModes
        -- Supports inline and side-by-side diff display modes.
        -- Mode comes from editor settings (Diff View Style).

    @guarantee ReadOnly
        -- No actions beyond viewing — changes are managed via git sidebar.
}

-- ─── Documentation views ─────────────────────────────────────

-- documentation:     renders DOCUMENTATION.md as styled HTML
-- api_documentation: renders API.md as styled HTML

surface DocumentationSurface {
    @guarantee MarkdownRendering
        -- Renders markdown file as styled HTML.
        -- No edit actions. Read-only view.
}

-- ─── Site validation view ───────────────────────────────────

value SiteValidationReport {
    project_id: String
    expected_url_count: Integer
    existing_html_count: Integer
    missing_url_paths: List<String>     -- in sitemap, no HTML on disk
    extra_url_paths: List<String>       -- HTML on disk, not in sitemap
    updated_post_url_paths: List<String> -- source .md newer than HTML
    affected_sections: Set<generation/GenerationSection>
}

surface SiteValidationSurface {
    context report: SiteValidationReport

    exposes:
        report.project_id
        report.expected_url_count
        report.existing_html_count
        report.missing_url_paths
        report.extra_url_paths
        report.updated_post_url_paths
        report.affected_sections

    provides:
        SiteValidationScanRequested()
        SiteValidationApplyRequested(report)
            when report.missing_url_paths.count > 0
              or report.extra_url_paths.count > 0
              or report.updated_post_url_paths.count > 0

    @guarantee SummaryLine
        -- "Expected URLs: N — Existing HTML URLs: N — Missing: N — Extra: N — Updated: N"

    @guarantee UrlSections
        -- Three sections: Missing URLs, Extra URLs, Updated URLs.
        -- Each section: heading + list of URL paths, or "None found".

    @guarantee ApplyAction
        -- Apply button disabled when nothing to fix.
        -- On apply: renders missing, deletes extra, re-renders updated.
        -- Toast: "Validation applied: N rendered, N deleted".
}

rule SiteValidationScan {
    when: SiteValidationScanRequested()
    -- Parses <loc> entries from sitemap.xml into expected URL set
    -- Scans HTML output dir for index.html files (zero-byte = missing)
    -- Compares source .md mtime against generated HTML mtime
    ensures: SiteValidationReport
}

rule SiteValidationApply {
    when: SiteValidationApplyRequested(report)
    -- Classifies affected paths into generation sections (core, single, category, tag, date)
    -- Renders only affected sections in parallel
    -- Deletes extra HTML files, removes empty directories
    -- Regenerates calendar if anything changed
    -- Rebuilds search index if anything rendered or deleted
    ensures: ApplyValidationRequested(report.project_id, report.affected_sections)
}

-- ─── Translation validation view ───────────────────────────

value TranslationValidationReport {
    checked_database_row_count: Integer
    checked_filesystem_file_count: Integer
    invalid_database_rows: List<TranslationValidationIssue>
    invalid_filesystem_files: List<TranslationValidationIssue>
}

value TranslationValidationIssue {
    issue: String
    -- Issue kinds:
    --   missing_source_post: translationFor points to nonexistent post
    --   same_language_as_canonical: translation language matches source post language
    --   do_not_translate_has_translations: source post is doNotTranslate but has translations
    --   content_in_database: published translation still has content in DB (should be on disk)
    translation_id: String?
    translation_for: String
    canonical_language: String?
    translation_language: String
    title: String?
    file_path: String?
}

surface TranslationValidationSurface {
    context report: TranslationValidationReport

    exposes:
        report.checked_database_row_count
        report.checked_filesystem_file_count
        for issue in report.invalid_database_rows:
            issue.issue
            issue.translation_id
            issue.translation_for
            issue.canonical_language
            issue.translation_language
            issue.title
            issue.file_path
        for issue in report.invalid_filesystem_files:
            issue.issue
            issue.file_path

    provides:
        TranslationValidationScanRequested()
        TranslationValidationFixRequested(report)
            when report.invalid_database_rows.count > 0
              or report.invalid_filesystem_files.count > 0

    @guarantee SummaryLine
        -- "Checked DB rows: N — Checked files: N — Invalid DB rows: N — Invalid files: N"

    @guarantee IssueSections
        -- Two sections: Database Issues, Filesystem Issues.
        -- Each issue rendered as card with coloured left border.
        -- Card shows: issue label, source post ID, translation ID, title, languages, file path.

    @guarantee IssueTypes
        -- same_language_as_canonical: translation language matches source.
        -- do_not_translate_has_translations: source is doNotTranslate.
        -- content_in_database: published translation has content in DB.
        -- missing_source_post: translationFor references nonexistent post.

    @guarantee FixAction
        -- Revalidate button + Fix button (disabled when no issues).
        -- Fix: content_in_database -> flush to .md file, set content null.
        --   Other issues -> delete DB row or .md file.
        -- After fix: automatically re-validates.
        -- Toast: "Deleted N DB rows and N files, flushed N translations to disk".
}

rule TranslationValidationScan {
    when: TranslationValidationScanRequested()
    -- Database pass: checks all translation rows for integrity issues
    -- Filesystem pass: scans posts/ for translation .md files, checks frontmatter
    ensures: TranslationValidationReport
}

rule TranslationValidationFix {
    when: TranslationValidationFixRequested(report)
    -- content_in_database: flushes content to .md file, sets content = null in DB
    -- missing_source_post | same_language_as_canonical | do_not_translate:
    --   DB issues: deletes translation row
    --   Filesystem issues: deletes the .md file
    -- After fix: automatically re-validates
    ensures: TranslationValidationScan
}

-- ─── Find duplicates view ──────────────────────────────────

value DuplicateSearchResult {
    pairs: List<DuplicatePair>
    has_more: Boolean                   -- pagination with config.duplicate_page_size per page
}

value DuplicatePair {
    post_id_a: String
    title_a: String
    post_id_b: String
    title_b: String
    similarity: Decimal                 -- 0.0 to 1.0
    exact_match: Boolean                -- true if titles + content identical
}

config {
    duplicate_similarity_threshold: Decimal = 0.92
    duplicate_page_size: Integer = 500
    duplicate_neighbor_count: Integer = 21
}

surface DuplicatesSurface {
    context result: DuplicateSearchResult

    exposes:
        for pair in result.pairs:
            pair.title_a
            pair.title_b
            pair.similarity
            pair.exact_match
        result.has_more

    provides:
        DuplicateSearchRequested()
        DuplicatePairDismissed(post_id_a, post_id_b)
        DuplicatePairsBatchDismissed(pair_ids)
        DuplicatePostClicked(post_id)
        DuplicateShowMoreRequested()
            when result.has_more

    @guarantee SemanticSimilarityGate
        -- Requires semanticSimilarityEnabled in project metadata.
        -- If disabled: shows "Semantic similarity is not enabled" message.
        -- No search functionality available when disabled.

    @guarantee ActionsBar
        -- Refresh button, Check All, Uncheck All,
        --   Dismiss Checked (with count), disabled when none checked.

    @guarantee PairRows
        -- Each row: checkbox, post A title (clickable -> opens tab),
        --   arrow, post B title (clickable -> opens tab),
        --   similarity badge (percentage or "Exact Match"),
        --   Dismiss button.
        -- Exact matches styled distinctly from similarity matches.

    @guarantee Pagination
        -- "Show More" button when has_more is true.
        -- config.duplicate_page_size pairs per page.

    @guarantee BatchDismiss
        -- Batch insert in chunks of 100.
        -- Dismissed pairs excluded from future searches.
}

rule DuplicateSearch {
    when: DuplicateSearchRequested()
    requires: semantic_similarity_enabled
    -- Loads USearch vector index for project
    -- For each indexed post: search config.duplicate_neighbor_count nearest neighbors
    -- similarity = max(0, 1 - distance)
    -- Filter: similarity >= config.duplicate_similarity_threshold, exclude dismissed
    -- For 100% embedding similarity: load post bodies, compare title+content
    --   If identical: exact_match = true
    -- Sort: exact matches first, then descending similarity
    ensures: DuplicateSearchResult
}

rule DuplicateDismiss {
    when: DuplicatePairDismissed(post_id_a, post_id_b)
    -- Inserts into dismissed_duplicate_pairs with canonical ID ordering
    -- Excluded from future searches
    ensures: PairDismissed(post_id_a, post_id_b)
}

rule DuplicateBatchDismiss {
    when: DuplicatePairsBatchDismissed(pair_ids)
    -- Batch insert in chunks of 100
    ensures: for pair in pair_ids: PairDismissed(pair)
}

-- ─── Import analysis view ───────────────────────────────────

-- Editor for WXR (WordPress eXtended RSS) import definitions.
-- Keyed by import definition ID. Opened as always-pinned tab.

value ImportAnalysisView {
    definition_id: String
    definition_name: String             -- editable name input
    uploads_folder_path: String?        -- path display + Browse button
    wxr_file_path: String?              -- path display + Select & Analyze button
    is_loading: Boolean
    report: ImportAnalysisReport?
}

value ImportAnalysisReport {
    site_info: ImportSiteInfo
    post_stats: ImportEntityStats
    page_stats: ImportEntityStats
    media_stats: ImportMediaStats
    category_stats: ImportTaxonomyStats
    tag_stats: ImportTaxonomyStats
    date_distribution: List<ImportYearDistribution>
    conflicts: List<ImportConflict>
    macros: List<ImportMacro>
}

value ImportSiteInfo {
    title: String
    url: String
    language: String
    source_file: String
}

value ImportEntityStats {
    new_count: Integer
    update_count: Integer
    conflict_count: Integer
    duplicate_count: Integer
}

value ImportMediaStats {
    new_count: Integer
    update_count: Integer
    conflict_count: Integer
    duplicate_count: Integer
    missing_count: Integer
}

value ImportTaxonomyStats {
    existing_count: Integer
    mapped_count: Integer
    new_count: Integer
}

value ImportYearDistribution {
    year: Integer
    post_count: Integer
    media_count: Integer
}

value ImportConflict {
    item_type: String                   -- post | page | media
    item_name: String
    resolution: String                  -- ignore | overwrite | import
}

value ImportMacro {
    name: String
    usage_count: Integer
    parameters: List<String>
    validation_status: String           -- valid | invalid | unknown
}

surface ImportAnalysisSurface {
    context analysis: ImportAnalysisView

    exposes:
        analysis.definition_name
        analysis.uploads_folder_path
        analysis.wxr_file_path
        analysis.is_loading
        analysis.report when analysis.report != null

    provides:
        ImportAnalyzeRequested(analysis.definition_id, file_path)
            when analysis.wxr_file_path != null
        ImportExecuteRequested(analysis.definition_id)
            when analysis.report != null
        ImportConflictResolutionChanged(item_name, resolution)
        ImportTaxonomyMappingChanged(source_term, target_term)
        ImportAITaxonomyAnalysisRequested(analysis.definition_id)

    @guarantee FileSelectors
        -- Uploads folder: path display + Browse button (native folder dialog).
        -- WXR file: path display + Select & Analyze button (native file dialog).

    @guarantee LoadingState
        -- Spinner + progress step + detail text during analysis.

    @guarantee SiteInfoCard
        -- Shows: site title, URL, language, source file path.

    @guarantee StatCards
        -- Posts (new/update/conflict/duplicate), Pages (same),
        --   Media (new/update/conflict/duplicate/missing),
        --   Categories (existing/mapped/new), Tags (existing/mapped/new).

    @guarantee DateDistribution
        -- Year-by-year bar charts for posts + media.

    @guarantee ConflictsSection
        -- Collapsible. Per-item dropdown: Ignore/Overwrite/Import.
        -- Default: Import for new items, Ignore for existing matches.

    @guarantee TaxonomySection
        -- Collapsible. Category + tag pills.
        -- Click pill to map to existing term. Inline edit with suggestion dropdown.
        -- AI analyze button (with model selector dropdown).
        -- Gate: airplane mode check for AI taxonomy analysis.

    @guarantee MacrosSection
        -- Collapsible. Discovered macros with usage counts, parameters,
        --   validation status (valid/invalid/unknown badges).

    @guarantee ExecuteAction
        -- Execute button shows importable counts (tags, posts, media, pages).
        -- Disabled if nothing to import.
        -- Progress bar during execution (current/total, phase, detail, ETA).
        -- No confirmation dialog — executes immediately.

    @guarantee CreatedEntitiesRefresh
        -- Created entities appear in sidebar immediately after execution.
}

rule ImportSelectAndAnalyze {
    when: ImportAnalyzeRequested(definition_id, file_path)
    -- Parses WXR XML file
    -- Extracts: posts, pages, media, tags, categories, authors
    -- Shows summary counts per entity type
    -- Identifies conflicts: duplicate slugs, existing categories/tags
}

rule ImportExecute {
    when: ImportExecuteRequested(definition_id)
    -- No confirmation dialog — executes immediately
    -- Processes items per conflict resolution settings
    -- Creates posts, media, tags, categories as needed
    -- Summary with counts shown in import view on completion
    -- Created entities appear in sidebar immediately (store updated)
}
