use std::collections::{HashMap, HashSet};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::mpsc;
use std::time::Duration;

use anyhow::{Result, anyhow, bail};
use bds_core::db::Database;
use bds_core::engine::ai::{AiEndpointConfig, AiEndpointKind};
use bds_core::engine::task::{TaskId, TaskManager};
use bds_core::engine::{self, domain_events};
use bds_core::i18n::{UiLocale, normalize_language};
use bds_core::model::metadata::ProjectMetadata;
use bds_core::model::{
    DomainEntity, Media, NotificationAction, Post, PostStatus, Project, PublishingPreferences,
    ScriptKind, ScriptStatus, SshMode, Tag, TemplateKind, TemplateStatus,
};
use bds_editor::EditorBuffer;
use ratatui::Frame;
use ratatui::buffer::Buffer;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::layout::Size;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{
    Block, Borders, Clear, List, ListItem, ListState, Paragraph, StatefulWidget, Widget, Wrap,
};
use ratatui_image::picker::Picker;
use ratatui_image::protocol::Protocol;
use ratatui_image::{Image as TerminalImage, Resize};

use crate::host::ApplicationHost;

const SIDEBAR_WIDTH: u16 = 30;
const MAX_DIFF_BYTES: usize = 512 * 1024;
const COLORS: &[&str] = &["", "#7aa2f7", "#9ece6a", "#e0af68", "#bb9af7", "#f7768e"];

#[derive(Clone, Copy)]
struct CommandSpec {
    id: &'static str,
    key: &'static str,
}

const COMMANDS: &[CommandSpec] = &[
    CommandSpec {
        id: "metadata-diff",
        key: "tui.commandMetadataDiff",
    },
    CommandSpec {
        id: "validate-site",
        key: "tui.commandValidateSite",
    },
    CommandSpec {
        id: "force-render",
        key: "tui.commandForceRender",
    },
    CommandSpec {
        id: "rebuild-database",
        key: "tui.commandRebuildDatabase",
    },
    CommandSpec {
        id: "reindex-search",
        key: "tui.commandReindexSearch",
    },
    CommandSpec {
        id: "validate-translations-gui",
        key: "tui.commandValidateTranslations",
    },
    CommandSpec {
        id: "find-duplicates-gui",
        key: "tui.commandFindDuplicates",
    },
    CommandSpec {
        id: "upload-site",
        key: "tui.commandUploadSite",
    },
    CommandSpec {
        id: "browser-preview-url",
        key: "tui.commandBrowserPreviewUrl",
    },
];

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TuiView {
    Posts,
    Media,
    Templates,
    Scripts,
    Tags,
    Settings,
    Git,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Focus {
    Sidebar,
    Editor,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EditorMode {
    Source,
    Preview,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TuiKey {
    Char(char),
    Enter,
    Esc,
    Backspace,
    Delete,
    Up,
    Down,
    Left,
    Right,
    Home,
    End,
    PageUp,
    PageDown,
    Tab,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct TuiModifiers {
    pub ctrl: bool,
    pub shift: bool,
    pub alt: bool,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TuiInput {
    pub key: TuiKey,
    pub modifiers: TuiModifiers,
}

impl TuiInput {
    pub const fn plain(key: TuiKey) -> Self {
        Self {
            key,
            modifiers: TuiModifiers {
                ctrl: false,
                shift: false,
                alt: false,
            },
        }
    }

    pub const fn ctrl(key: TuiKey) -> Self {
        Self {
            key,
            modifiers: TuiModifiers {
                ctrl: true,
                shift: false,
                alt: false,
            },
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
enum SidebarItem {
    Header(String),
    Post(String, String),
    Media(String, String),
    Template(String, String),
    Script(String, String),
    TagSection(TagSection),
    SettingSection(SettingSection),
    GitFile(String, String),
    GitCommit(String, String),
    Empty(String),
}

impl SidebarItem {
    fn selectable(&self) -> bool {
        !matches!(self, Self::Header(_) | Self::Empty(_))
    }

    fn label(&self, locale: UiLocale) -> String {
        match self {
            Self::Header(value) | Self::Empty(value) => value.clone(),
            Self::Post(_, value)
            | Self::Media(_, value)
            | Self::Template(_, value)
            | Self::Script(_, value)
            | Self::GitFile(_, value)
            | Self::GitCommit(_, value) => value.clone(),
            Self::TagSection(value) => bds_core::i18n::translate(locale, value.key()),
            Self::SettingSection(value) => bds_core::i18n::translate(locale, value.key()),
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TagSection {
    Cloud,
    Manage,
    Merge,
}

impl TagSection {
    const ALL: [Self; 3] = [Self::Cloud, Self::Manage, Self::Merge];
    const fn key(self) -> &'static str {
        match self {
            Self::Cloud => "tags.nav.cloud",
            Self::Manage => "tags.nav.manage",
            Self::Merge => "tags.nav.merge",
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SettingSection {
    Project,
    Editor,
    Content,
    Ai,
    Technology,
    Publishing,
    Data,
    Mcp,
}

impl SettingSection {
    const ALL: [Self; 8] = [
        Self::Project,
        Self::Editor,
        Self::Content,
        Self::Ai,
        Self::Technology,
        Self::Publishing,
        Self::Data,
        Self::Mcp,
    ];
    const fn key(self) -> &'static str {
        match self {
            Self::Project => "settings.nav.project",
            Self::Editor => "settings.nav.editor",
            Self::Content => "settings.nav.content",
            Self::Ai => "settings.nav.ai",
            Self::Technology => "settings.nav.technology",
            Self::Publishing => "settings.nav.publishing",
            Self::Data => "settings.nav.data",
            Self::Mcp => "settings.nav.mcp",
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
enum FieldKind {
    Text,
    Bool,
    Enum(&'static [&'static str]),
    ReadOnly,
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct SettingField {
    label: String,
    key: &'static str,
    kind: FieldKind,
    value: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
enum EditorEntity {
    Post(String),
    Template(String),
    Script(String),
}

struct Editor {
    entity: EditorEntity,
    title: String,
    syntax: &'static str,
    post_language: Option<String>,
    buffer: EditorBuffer,
    mode: EditorMode,
}

#[derive(Debug, Clone, PartialEq, Eq)]
enum PromptKind {
    Search,
    Command,
    ProjectPath,
    Commit,
    Setting(usize),
    TagCreate,
    TagRename(String),
    ConfirmDeleteTag(String),
    EditorTitle,
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct Prompt {
    kind: PromptKind,
    value: String,
    candidates: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
enum Panel {
    Welcome,
    MediaPreview {
        title: String,
        path: PathBuf,
    },
    Settings(SettingSection),
    Tags(TagSection),
    Git,
    Report {
        title: String,
        body: String,
        action: ReportAction,
    },
    Help,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ReportAction {
    MetadataDiff,
    SiteValidation,
}

#[derive(Debug, Clone, PartialEq, Eq)]
enum Overlay {
    Projects { selected: usize },
    ConfirmDiscard,
}

struct BackgroundResult {
    status: String,
    panel: Option<Panel>,
    reload: bool,
}

pub struct TuiApp {
    host: ApplicationHost,
    remote: bool,
    locale: UiLocale,
    project: Option<Project>,
    data_dir: Option<PathBuf>,
    pub view: TuiView,
    pub focus: Focus,
    pub selected_index: usize,
    items: Vec<SidebarItem>,
    editor: Option<Editor>,
    panel: Panel,
    overlay: Option<Overlay>,
    prompt: Option<Prompt>,
    filters: HashMap<TuiView, String>,
    status: String,
    output: Vec<String>,
    settings_fields: Vec<SettingField>,
    panel_index: usize,
    marked_tags: HashSet<String>,
    scroll: u16,
    quit: bool,
    events: domain_events::EventSubscription,
    started_task_ids: HashSet<u64>,
    completed_task_ids: HashSet<u64>,
    airplane: bool,
    background_tx: mpsc::Sender<BackgroundResult>,
    background_rx: mpsc::Receiver<BackgroundResult>,
    image_source: Option<image::DynamicImage>,
    image_protocol: Option<Protocol>,
    terminal_size: (u16, u16),
}

impl TuiApp {
    pub fn new(host: ApplicationHost, remote: bool) -> Result<Self> {
        let db = host.database()?;
        let locale = engine::settings::ui_language(db.conn())?
            .map(|value| normalize_language(&value))
            .unwrap_or_else(bds_core::i18n::detect_os_locale);
        let project = engine::project::get_active_project(db.conn())?;
        let data_dir = project
            .as_ref()
            .map(|project| host.project_data_dir(project));
        if let (Some(project), Some(data_dir)) = (&project, &data_dir) {
            engine::meta::startup_sync(data_dir)?;
            engine::meta::initialize_metadata_snapshots(db.conn(), data_dir, &project.id)?;
        }
        let started_task_ids = host
            .tasks()
            .snapshots()
            .into_iter()
            .map(|task| task.id)
            .collect();
        let (background_tx, background_rx) = mpsc::channel();
        let mut app = Self {
            host,
            remote,
            locale,
            project,
            data_dir,
            view: TuiView::Posts,
            focus: Focus::Sidebar,
            selected_index: 0,
            items: Vec::new(),
            editor: None,
            panel: Panel::Welcome,
            overlay: None,
            prompt: None,
            filters: HashMap::new(),
            status: String::new(),
            output: Vec::new(),
            settings_fields: Vec::new(),
            panel_index: 0,
            marked_tags: HashSet::new(),
            scroll: 0,
            quit: false,
            events: domain_events::subscribe(),
            started_task_ids,
            completed_task_ids: HashSet::new(),
            airplane: std::env::var_os("BDS_AIRPLANE").is_some(),
            background_tx,
            background_rx,
            image_source: None,
            image_protocol: None,
            terminal_size: (80, 24),
        };
        app.reload()?;
        Ok(app)
    }

    pub fn should_quit(&self) -> bool {
        self.quit
    }
    pub fn locale(&self) -> UiLocale {
        self.locale
    }
    pub fn editor_text(&self) -> Option<String> {
        self.editor.as_ref().map(|editor| editor.buffer.text())
    }
    pub fn has_unsaved_changes(&self) -> bool {
        self.editor
            .as_ref()
            .is_some_and(|editor| editor.buffer.is_dirty())
    }

    pub fn resize(&mut self, width: u16, height: u16) {
        let size = (width.max(20), height.max(6));
        if size != self.terminal_size {
            self.terminal_size = size;
            self.rebuild_image_protocol();
        }
    }

    fn rebuild_image_protocol(&mut self) {
        let Some(source) = self.image_source.clone() else {
            self.image_protocol = None;
            return;
        };
        let size = Size::new(
            self.terminal_size
                .0
                .saturating_sub(SIDEBAR_WIDTH + 4)
                .max(4),
            self.terminal_size.1.saturating_sub(6).max(2),
        );
        self.image_protocol = Picker::halfblocks()
            .new_protocol(source, size, Resize::Fit(None))
            .ok();
    }

    fn database(&self) -> Result<Database> {
        self.host.database()
    }
    fn project_id(&self) -> Result<&str> {
        self.project
            .as_ref()
            .map(|value| value.id.as_str())
            .ok_or_else(|| anyhow!(self.tr("tui.noActiveProject")))
    }
    fn data_dir(&self) -> Result<&Path> {
        self.data_dir
            .as_deref()
            .ok_or_else(|| anyhow!(self.tr("tui.noActiveProjectDataFolder")))
    }

    pub fn poll(&mut self) -> Result<()> {
        self.poll_tasks();
        while let Ok(result) = self.background_rx.try_recv() {
            self.status = result.status;
            if let Some(panel) = result.panel {
                self.panel = panel;
                self.focus = Focus::Editor;
            }
            if result.reload {
                self.reload()?;
            }
        }
        for event in self.events.drain() {
            if let bds_core::model::DomainEvent::SettingsChanged { key, .. } = &event
                && key == engine::settings::UI_LANGUAGE_KEY
            {
                let db = self.database()?;
                self.locale = engine::settings::ui_language(db.conn())?
                    .map(|v| normalize_language(&v))
                    .unwrap_or(UiLocale::En);
                self.reload()?;
                if let Panel::Settings(section) = self.panel {
                    self.settings_fields = self.load_setting_fields(section)?;
                    self.panel_index = self
                        .panel_index
                        .min(self.settings_fields.len().saturating_sub(1));
                }
                if self
                    .prompt
                    .as_ref()
                    .is_some_and(|prompt| prompt.kind == PromptKind::Command)
                {
                    let query = self
                        .prompt
                        .as_ref()
                        .map(|prompt| prompt.value.clone())
                        .unwrap_or_default();
                    let candidates = self
                        .command_names()
                        .into_iter()
                        .filter(|name| contains_ci(name, &query))
                        .collect();
                    if let Some(prompt) = &mut self.prompt {
                        prompt.candidates = candidates;
                    }
                }
                self.refresh_open_report()?;
            }
            if let bds_core::model::DomainEvent::EntityChanged {
                project_id,
                entity,
                entity_id,
                action,
            } = &event
                && self
                    .project
                    .as_ref()
                    .is_some_and(|project| &project.id == project_id)
            {
                let editing_deleted = *action == NotificationAction::Deleted
                    && match (&self.editor, entity) {
                        (
                            Some(Editor {
                                entity: EditorEntity::Post(id),
                                ..
                            }),
                            DomainEntity::Post,
                        )
                        | (
                            Some(Editor {
                                entity: EditorEntity::Template(id),
                                ..
                            }),
                            DomainEntity::Template,
                        )
                        | (
                            Some(Editor {
                                entity: EditorEntity::Script(id),
                                ..
                            }),
                            DomainEntity::Script,
                        ) => id == entity_id,
                        _ => false,
                    };
                if editing_deleted {
                    self.editor = None;
                    self.focus = Focus::Sidebar;
                    self.status = self.tr("tui.openItemDeleted");
                }
                self.reload()?;
                self.refresh_open_report()?;
            }
        }
        Ok(())
    }

    fn refresh_open_report(&mut self) -> Result<()> {
        let Panel::Report { action, .. } = self.panel else {
            return Ok(());
        };
        let db = self.database()?;
        let data_dir = self.data_dir()?;
        let project_id = self.project_id()?;
        self.panel = match action {
            ReportAction::MetadataDiff => Panel::Report {
                title: self.tr("menu.item.metadataDiff"),
                body: metadata_report_body(db.conn(), data_dir, project_id, self.locale)?,
                action,
            },
            ReportAction::SiteValidation => Panel::Report {
                title: self.tr("menu.item.validateSite"),
                body: site_validation_body(db.conn(), data_dir, project_id, self.locale)?,
                action,
            },
        };
        Ok(())
    }

    fn queue_task(
        &mut self,
        label: &str,
        work: impl FnOnce(Arc<TaskManager>, TaskId) -> Result<BackgroundResult> + Send + 'static,
    ) {
        let tasks = self.host.tasks();
        let task_id = tasks.submit(label);
        let sender = self.background_tx.clone();
        self.started_task_ids.insert(task_id);
        self.status = self.tr_with("tui.taskRunning", &[("label", label)]);
        std::thread::spawn(move || {
            let Some(worker) = tasks.admit_blocking(task_id) else {
                return;
            };
            let work_tasks = Arc::clone(&tasks);
            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
                work(work_tasks, task_id)
            }))
            .map_err(|_| anyhow!("background task panicked"))
            .and_then(|result| result);
            drop(worker);
            match result {
                Ok(result) => {
                    tasks.complete(task_id);
                    let _ = sender.send(result);
                }
                Err(error) => {
                    let cancelled = tasks.is_cancelled(task_id);
                    let message = error.to_string();
                    tasks.fail(task_id, message.clone());
                    if !cancelled {
                        let _ = sender.send(BackgroundResult {
                            status: message,
                            panel: None,
                            reload: false,
                        });
                    }
                }
            }
        });
    }

    fn poll_tasks(&mut self) {
        for task in self.host.tasks().snapshots() {
            if self.started_task_ids.insert(task.id) {
                self.status = task.label.clone();
            }
            match task.status {
                engine::task::TaskStatus::Pending | engine::task::TaskStatus::Running => {
                    self.status = if task.cancellation_requested {
                        self.tr_with("tui.taskCancelling", &[("label", &task.label)])
                    } else {
                        match task.progress {
                            Some(progress) => self.tr_with(
                                "tui.taskProgress",
                                &[
                                    ("label", &task.label),
                                    ("percent", &format!("{:.0}", progress * 100.0)),
                                ],
                            ),
                            None => task.label,
                        }
                    };
                }
                engine::task::TaskStatus::Completed if self.completed_task_ids.insert(task.id) => {
                    self.status = self.tr_with("tui.taskComplete", &[("label", &task.label)]);
                }
                engine::task::TaskStatus::Failed(error)
                    if self.completed_task_ids.insert(task.id) =>
                {
                    self.status = self.tr_with(
                        "tui.taskFailed",
                        &[("label", &task.label), ("error", &error)],
                    );
                }
                engine::task::TaskStatus::Cancelled if self.completed_task_ids.insert(task.id) => {
                    self.status = self.tr_with("tui.taskCancelled", &[("label", &task.label)]);
                }
                _ => {}
            }
        }
    }

    fn reload(&mut self) -> Result<()> {
        self.items = self.sidebar_items()?;
        self.clamp_selection();
        Ok(())
    }

    fn sidebar_items(&self) -> Result<Vec<SidebarItem>> {
        let Some(project) = &self.project else {
            return Ok(vec![SidebarItem::Empty(self.tr("tui.noProject"))]);
        };
        let db = self.database()?;
        let query = self
            .filters
            .get(&self.view)
            .map(String::as_str)
            .unwrap_or("");
        let mut items = Vec::new();
        match self.view {
            TuiView::Posts => {
                let posts =
                    bds_core::db::queries::post::list_posts_by_project(db.conn(), &project.id)?;
                for (heading_key, status) in [
                    ("sidebar.drafts", PostStatus::Draft),
                    ("sidebar.published", PostStatus::Published),
                    ("sidebar.archived", PostStatus::Archived),
                ] {
                    items.push(SidebarItem::Header(self.tr(heading_key)));
                    items.extend(
                        posts
                            .iter()
                            .filter(|post| {
                                post.status == status && matches_filter_post(post, query)
                            })
                            .map(|post| SidebarItem::Post(post.id.clone(), post.title.clone())),
                    );
                }
            }
            TuiView::Media => {
                items.push(SidebarItem::Header(self.tr("tui.viewMedia")));
                items.extend(
                    bds_core::db::queries::media::list_media_by_project(db.conn(), &project.id)?
                        .into_iter()
                        .filter(|media| matches_filter_media(media, query))
                        .map(|media| {
                            SidebarItem::Media(
                                media.id,
                                media.title.unwrap_or_else(|| media.filename.clone()),
                            )
                        }),
                );
            }
            TuiView::Templates => {
                items.push(SidebarItem::Header(self.tr("tui.viewTemplates")));
                items.extend(
                    bds_core::db::queries::template::list_templates_by_project(
                        db.conn(),
                        &project.id,
                    )?
                    .into_iter()
                    .filter(|item| contains_ci(&format!("{} {}", item.title, item.slug), query))
                    .map(|item| SidebarItem::Template(item.id, item.title)),
                );
            }
            TuiView::Scripts => {
                items.push(SidebarItem::Header(self.tr("tui.viewScripts")));
                items.extend(
                    bds_core::db::queries::script::list_scripts_by_project(db.conn(), &project.id)?
                        .into_iter()
                        .filter(|item| contains_ci(&format!("{} {}", item.title, item.slug), query))
                        .map(|item| SidebarItem::Script(item.id, item.title)),
                );
            }
            TuiView::Tags => {
                items.push(SidebarItem::Header(self.tr("tui.viewTags")));
                items.extend(TagSection::ALL.into_iter().map(SidebarItem::TagSection));
            }
            TuiView::Settings => {
                items.push(SidebarItem::Header(self.tr("tui.viewSettings")));
                items.extend(
                    SettingSection::ALL
                        .into_iter()
                        .map(SidebarItem::SettingSection),
                );
            }
            TuiView::Git => return self.git_items(),
        }
        if !items.iter().any(SidebarItem::selectable) {
            items.push(SidebarItem::Empty(self.tr("tui.noMatchingItems")));
        }
        Ok(items)
    }

    fn clamp_selection(&mut self) {
        if self.items.is_empty() {
            self.selected_index = 0;
            return;
        }
        self.selected_index = self.selected_index.min(self.items.len() - 1);
        if !self.items[self.selected_index].selectable() {
            self.selected_index = self
                .items
                .iter()
                .position(SidebarItem::selectable)
                .unwrap_or(0);
        }
    }
}

fn contains_ci(haystack: &str, needle: &str) -> bool {
    haystack.to_lowercase().contains(&needle.to_lowercase())
}

fn matches_filter_post(post: &Post, query: &str) -> bool {
    query.split_whitespace().all(|token| {
        if let Some(tag) = token.strip_prefix("tag:") {
            post.tags
                .iter()
                .any(|value| value.eq_ignore_ascii_case(tag))
        } else if let Some(category) = token.strip_prefix("category:") {
            post.categories
                .iter()
                .any(|value| value.eq_ignore_ascii_case(category))
        } else if token.len() == 10
            && token.as_bytes().get(4) == Some(&b'-')
            && token.as_bytes().get(7) == Some(&b'-')
        {
            chrono::DateTime::from_timestamp_millis(post.created_at)
                .is_some_and(|date| date.format("%Y-%m-%d").to_string() == token)
        } else {
            contains_ci(
                &format!(
                    "{} {} {}",
                    post.title,
                    post.slug,
                    post.excerpt.as_deref().unwrap_or("")
                ),
                token,
            )
        }
    })
}

fn matches_filter_media(media: &Media, query: &str) -> bool {
    query.split_whitespace().all(|token| {
        contains_ci(
            &format!(
                "{} {} {}",
                media.title.as_deref().unwrap_or(""),
                media.filename,
                media.alt.as_deref().unwrap_or("")
            ),
            token,
        )
    })
}

impl TuiApp {
    pub fn handle_input(&mut self, input: TuiInput) -> Result<()> {
        if let Err(error) = self.handle_input_inner(input) {
            self.status = error.to_string();
        }
        Ok(())
    }

    fn handle_input_inner(&mut self, input: TuiInput) -> Result<()> {
        if self.prompt.is_some() {
            return self.handle_prompt(input);
        }
        if self.overlay.is_some() {
            return self.handle_overlay(input);
        }
        if input.modifiers.ctrl {
            return match input.key {
                TuiKey::Char('s') => self.save(false),
                TuiKey::Char('p') => self.save(true),
                TuiKey::Char('e') => {
                    self.toggle_preview();
                    Ok(())
                }
                TuiKey::Char('g') => self.ai_quick_action(),
                TuiKey::Char('u') => self.unpublish(),
                TuiKey::Char('t') => self.edit_title(),
                TuiKey::Char('l') => self.cycle_editor_language(),
                TuiKey::Char('q') => {
                    self.request_quit();
                    Ok(())
                }
                _ => self.handle_editor_input(input),
            };
        }
        if self.focus == Focus::Editor && self.editor.is_none() {
            return self.handle_panel_input(input);
        }
        if self.focus == Focus::Editor {
            return self.handle_editor_input(input);
        }
        match input.key {
            TuiKey::Char('q') => self.request_quit(),
            TuiKey::Char('j') | TuiKey::Down => self.move_selection(1),
            TuiKey::Char('k') | TuiKey::Up => self.move_selection(-1),
            TuiKey::Enter => self.open_selected()?,
            TuiKey::Char('n') => self.new_item()?,
            TuiKey::Char('/') => {
                self.prompt = Some(Prompt {
                    kind: PromptKind::Search,
                    value: self.filters.get(&self.view).cloned().unwrap_or_default(),
                    candidates: Vec::new(),
                })
            }
            TuiKey::Char(':') => {
                self.prompt = Some(Prompt {
                    kind: PromptKind::Command,
                    value: String::new(),
                    candidates: self.command_names(),
                })
            }
            TuiKey::Char('p') => self.overlay = Some(Overlay::Projects { selected: 0 }),
            TuiKey::Char('o') => {
                self.prompt = Some(Prompt {
                    kind: PromptKind::ProjectPath,
                    value: String::new(),
                    candidates: Vec::new(),
                })
            }
            TuiKey::Char('1') => self.set_view(TuiView::Posts)?,
            TuiKey::Char('2') => self.set_view(TuiView::Media)?,
            TuiKey::Char('3') => self.set_view(TuiView::Templates)?,
            TuiKey::Char('4') => self.set_view(TuiView::Scripts)?,
            TuiKey::Char('5') => self.set_view(TuiView::Tags)?,
            TuiKey::Char('6') => self.set_view(TuiView::Settings)?,
            TuiKey::Char('7') => self.set_view(TuiView::Git)?,
            TuiKey::PageUp => self.scroll = self.scroll.saturating_sub(10),
            TuiKey::PageDown => self.scroll = self.scroll.saturating_add(10),
            TuiKey::Char('c') if self.view == TuiView::Git => {
                self.prompt = Some(Prompt {
                    kind: PromptKind::Commit,
                    value: String::new(),
                    candidates: Vec::new(),
                })
            }
            TuiKey::Char('u') if self.view == TuiView::Git => self.git_pull()?,
            TuiKey::Char('s') if self.view == TuiView::Git => self.git_push()?,
            TuiKey::Char(' ') if self.view == TuiView::Tags => self.toggle_tag_mark(),
            TuiKey::Char('m') if self.view == TuiView::Tags => self.merge_marked_tags()?,
            TuiKey::Char('d') if self.view == TuiView::Tags => self.begin_tag_delete()?,
            TuiKey::Char('c') if self.view == TuiView::Tags => self.cycle_tag_color()?,
            TuiKey::Char('t') if self.view == TuiView::Tags => self.cycle_tag_template()?,
            TuiKey::Char('s') if self.view == TuiView::Tags => self.sync_tags()?,
            TuiKey::Esc => {
                self.panel = Panel::Welcome;
                self.scroll = 0;
            }
            _ => {}
        }
        Ok(())
    }

    fn request_quit(&mut self) {
        if self.has_unsaved_changes() {
            self.overlay = Some(Overlay::ConfirmDiscard);
        } else {
            self.quit = true;
        }
    }

    fn set_view(&mut self, view: TuiView) -> Result<()> {
        self.view = view;
        self.focus = Focus::Sidebar;
        self.selected_index = 0;
        self.editor = None;
        self.image_source = None;
        self.image_protocol = None;
        self.panel = match view {
            TuiView::Tags => Panel::Tags(TagSection::Cloud),
            TuiView::Settings => Panel::Settings(SettingSection::Project),
            TuiView::Git => Panel::Git,
            _ => Panel::Welcome,
        };
        self.reload()
    }

    fn move_selection(&mut self, delta: isize) {
        if self.items.is_empty() {
            return;
        }
        let mut index = self.selected_index;
        for _ in 0..self.items.len() {
            index = if delta > 0 {
                (index + 1) % self.items.len()
            } else {
                index.checked_sub(1).unwrap_or(self.items.len() - 1)
            };
            if self.items[index].selectable() {
                self.selected_index = index;
                break;
            }
        }
    }

    fn open_selected(&mut self) -> Result<()> {
        let Some(item) = self.items.get(self.selected_index).cloned() else {
            return Ok(());
        };
        match item {
            SidebarItem::Post(id, _) => self.open_post(&id),
            SidebarItem::Template(id, _) => self.open_template(&id),
            SidebarItem::Script(id, _) => self.open_script(&id),
            SidebarItem::Media(id, _) => self.open_media(&id),
            SidebarItem::TagSection(section) => {
                self.panel = Panel::Tags(section);
                self.panel_index = 0;
                self.focus = Focus::Editor;
                Ok(())
            }
            SidebarItem::SettingSection(section) => self.open_settings(section),
            SidebarItem::GitFile(path, _) => self.open_git_file(&path),
            SidebarItem::GitCommit(hash, _) => self.open_git_commit(&hash),
            _ => Ok(()),
        }
    }

    fn new_item(&mut self) -> Result<()> {
        let db = self.database()?;
        let project_id = self.project_id()?.to_owned();
        let untitled = self.tr("tui.untitled");
        let entity = match self.view {
            TuiView::Posts => EditorEntity::Post(
                engine::post::create_post(
                    db.conn(),
                    self.data_dir()?,
                    &project_id,
                    &untitled,
                    Some(""),
                    Vec::new(),
                    Vec::new(),
                    None,
                    None,
                    None,
                )?
                .id,
            ),
            TuiView::Templates => EditorEntity::Template(
                engine::template::create_template(
                    db.conn(),
                    &project_id,
                    &untitled,
                    TemplateKind::Post,
                    "",
                )?
                .id,
            ),
            TuiView::Scripts => EditorEntity::Script(
                engine::script::create_script(
                    db.conn(),
                    &project_id,
                    &untitled,
                    ScriptKind::Utility,
                    "function main()\nend\n",
                    Some("main"),
                )?
                .id,
            ),
            TuiView::Tags => {
                self.prompt = Some(Prompt {
                    kind: PromptKind::TagCreate,
                    value: String::new(),
                    candidates: Vec::new(),
                });
                return Ok(());
            }
            _ => {
                self.status = self.tr("tui.newItemUnavailable");
                return Ok(());
            }
        };
        match entity {
            EditorEntity::Post(id) => self.open_post(&id),
            EditorEntity::Template(id) => self.open_template(&id),
            EditorEntity::Script(id) => self.open_script(&id),
        }
    }

    fn open_post(&mut self, id: &str) -> Result<()> {
        let db = self.database()?;
        let post = bds_core::db::queries::post::get_post_by_id(db.conn(), id)?;
        let content = if let Some(content) = post.content.clone() {
            content
        } else {
            read_published_body(self.data_dir()?, &post.file_path, PublishedKind::Post)?
        };
        self.editor = Some(editor(
            EditorEntity::Post(post.id),
            post.title,
            "markdown",
            post.language,
            &content,
        ));
        self.focus = Focus::Editor;
        Ok(())
    }

    fn open_template(&mut self, id: &str) -> Result<()> {
        let db = self.database()?;
        let item = bds_core::db::queries::template::get_template_by_id(db.conn(), id)?;
        let content = if let Some(content) = item.content.clone() {
            content
        } else {
            read_published_body(self.data_dir()?, &item.file_path, PublishedKind::Template)?
        };
        self.editor = Some(editor(
            EditorEntity::Template(item.id),
            item.title,
            "html",
            None,
            &content,
        ));
        self.focus = Focus::Editor;
        Ok(())
    }

    fn open_script(&mut self, id: &str) -> Result<()> {
        let db = self.database()?;
        let item = bds_core::db::queries::script::get_script_by_id(db.conn(), id)?;
        let content = if let Some(content) = item.content.clone() {
            content
        } else {
            read_published_body(self.data_dir()?, &item.file_path, PublishedKind::Script)?
        };
        self.editor = Some(editor(
            EditorEntity::Script(item.id),
            item.title,
            "lua",
            None,
            &content,
        ));
        self.focus = Focus::Editor;
        Ok(())
    }

    fn open_media(&mut self, id: &str) -> Result<()> {
        let db = self.database()?;
        let item = bds_core::db::queries::media::get_media_by_id(db.conn(), id)?;
        let path = self.data_dir()?.join(item.file_path);
        if item.mime_type.starts_with("image/") {
            self.image_source = image::ImageReader::open(&path)
                .ok()
                .and_then(|reader| reader.decode().ok());
            self.rebuild_image_protocol();
        } else {
            self.image_source = None;
            self.image_protocol = None;
        }
        self.panel = Panel::MediaPreview {
            title: item.title.unwrap_or(item.original_name),
            path,
        };
        Ok(())
    }

    fn handle_editor_input(&mut self, input: TuiInput) -> Result<()> {
        let Some(editor) = self.editor.as_mut() else {
            if input.key == TuiKey::Esc {
                self.focus = Focus::Sidebar;
            }
            return Ok(());
        };
        if input.key == TuiKey::Esc {
            self.focus = Focus::Sidebar;
            return Ok(());
        }
        if editor.mode == EditorMode::Preview {
            return Ok(());
        }
        let shift = input.modifiers.shift;
        match input.key {
            TuiKey::Char(value) if !input.modifiers.ctrl && !input.modifiers.alt => {
                editor.buffer.insert(&value.to_string())
            }
            TuiKey::Enter => editor.buffer.insert("\n"),
            TuiKey::Tab => editor.buffer.insert("    "),
            TuiKey::Backspace => editor.buffer.backspace(),
            TuiKey::Delete => editor.buffer.delete_forward(),
            TuiKey::Up if shift => editor.buffer.select_up(),
            TuiKey::Up => editor.buffer.move_up(),
            TuiKey::Down if shift => editor.buffer.select_down(),
            TuiKey::Down => editor.buffer.move_down(),
            TuiKey::Left if shift => editor.buffer.select_left(),
            TuiKey::Left => editor.buffer.move_left(),
            TuiKey::Right if shift => editor.buffer.select_right(),
            TuiKey::Right => editor.buffer.move_right(),
            TuiKey::Home if shift => editor.buffer.select_home(),
            TuiKey::Home => editor.buffer.move_home(),
            TuiKey::End if shift => editor.buffer.select_end(),
            TuiKey::End => editor.buffer.move_end(),
            TuiKey::PageUp => editor.buffer.move_page_up(20),
            TuiKey::PageDown => editor.buffer.move_page_down(20),
            TuiKey::Char('z') if input.modifiers.ctrl => editor.buffer.undo(),
            TuiKey::Char('y') if input.modifiers.ctrl => editor.buffer.redo(),
            _ => {}
        }
        let visible_width = self.terminal_size.0.saturating_sub(SIDEBAR_WIDTH).max(1) as usize;
        let visible_height = self.terminal_size.1.saturating_sub(3).max(1) as usize;
        let (cursor_visual_line, total_visual_lines) =
            visual_line_metrics(&editor.buffer, visible_width);
        editor.buffer.ensure_visual_line_visible(
            cursor_visual_line,
            visible_height,
            total_visual_lines.saturating_sub(visible_height),
        );
        Ok(())
    }

    fn toggle_preview(&mut self) {
        if let Some(editor) = &mut self.editor {
            editor.mode = if editor.mode == EditorMode::Source {
                EditorMode::Preview
            } else {
                EditorMode::Source
            };
            self.status = if editor.mode == EditorMode::Preview {
                bds_core::i18n::translate(self.locale, "editor.modePreview")
            } else {
                bds_core::i18n::translate(self.locale, "tui.editorSource")
            };
        }
    }

    fn save(&mut self, publish: bool) -> Result<()> {
        let Some(editor) = &self.editor else {
            return self.save_settings();
        };
        let entity = editor.entity.clone();
        let title = editor.title.clone();
        let post_language = editor.post_language.clone();
        let content = editor.buffer.text();
        let db = self.database()?;
        match entity {
            EditorEntity::Post(id) => {
                engine::post::update_post(
                    db.conn(),
                    self.data_dir()?,
                    &id,
                    Some(&title),
                    None,
                    None,
                    Some(&content),
                    None,
                    None,
                    None,
                    Some(post_language.as_deref()),
                    None,
                    None,
                )?;
                if publish {
                    engine::post::publish_post(db.conn(), self.data_dir()?, &id)?;
                }
            }
            EditorEntity::Template(id) => {
                engine::template::validate_template(&content).map_err(|error| anyhow!(error))?;
                let project_id = self.project_id()?.to_owned();
                engine::template::update_template(
                    db.conn(),
                    self.data_dir()?,
                    &id,
                    &project_id,
                    Some(&title),
                    None,
                    None,
                    None,
                    Some(&content),
                )?;
                if publish {
                    let item = bds_core::db::queries::template::get_template_by_id(db.conn(), &id)?;
                    if item.status == TemplateStatus::Published {
                        engine::template::unpublish_template(db.conn(), self.data_dir()?, &id)?;
                    }
                    engine::template::publish_template(db.conn(), self.data_dir()?, &id)?;
                }
            }
            EditorEntity::Script(id) => {
                engine::script::validate_script_syntax(&content).map_err(|error| anyhow!(error))?;
                let project_id = self.project_id()?.to_owned();
                engine::script::update_script(
                    db.conn(),
                    self.data_dir()?,
                    &id,
                    &project_id,
                    Some(&title),
                    None,
                    None,
                    None,
                    None,
                    Some(&content),
                )?;
                if publish {
                    let item = bds_core::db::queries::script::get_script_by_id(db.conn(), &id)?;
                    if item.status == ScriptStatus::Published {
                        engine::script::unpublish_script(db.conn(), self.data_dir()?, &id)?;
                    }
                    engine::script::publish_script(db.conn(), self.data_dir()?, &id)?;
                }
            }
        }
        if let Some(editor) = &mut self.editor {
            editor.buffer.set_dirty(false);
        }
        self.status = if publish {
            self.tr("tui.savedAndPublished")
        } else {
            self.tr("tui.saved")
        };
        self.reload()
    }

    fn ai_quick_action(&mut self) -> Result<()> {
        let Some(Editor {
            entity: EditorEntity::Post(_),
            title,
            buffer,
            ..
        }) = &self.editor
        else {
            return Ok(());
        };
        let db = self.database()?;
        if let Err(error) = engine::ai::active_endpoint(db.conn(), self.airplane) {
            self.status = if self.airplane {
                self.tr("tui.airplaneAiEndpointRequired")
            } else {
                error.to_string()
            };
            return Ok(());
        }
        let request = engine::ai::OneShotRequest {
            operation: engine::ai::OneShotOperation::AnalyzePost,
            content: serde_json::json!({ "title": title, "content": buffer.text() }),
        };
        let (response, _) = engine::ai::run_one_shot(db.conn(), self.airplane, &request)?;
        let engine::ai::OneShotResponse::PostAnalysis(analysis) = response else {
            bail!(self.tr("tui.aiWrongResponseType"));
        };
        self.output.push(self.tr_with(
            "tui.aiSuggestions",
            &[("title", &analysis.title), ("excerpt", &analysis.excerpt)],
        ));
        self.status = self.tr("tui.aiSuggestionsAdded");
        Ok(())
    }

    fn unpublish(&mut self) -> Result<()> {
        let Some(editor) = &self.editor else {
            return Ok(());
        };
        let db = self.database()?;
        match &editor.entity {
            EditorEntity::Post(id) => engine::post::archive_post(db.conn(), self.data_dir()?, id)?,
            EditorEntity::Template(id) => {
                engine::template::unpublish_template(db.conn(), self.data_dir()?, id)?;
            }
            EditorEntity::Script(id) => {
                engine::script::unpublish_script(db.conn(), self.data_dir()?, id)?;
            }
        }
        self.status = self.tr("tui.unpublished");
        self.reload()
    }

    fn edit_title(&mut self) -> Result<()> {
        if let Some(editor) = &self.editor {
            self.prompt = Some(Prompt {
                kind: PromptKind::EditorTitle,
                value: editor.title.clone(),
                candidates: Vec::new(),
            });
        }
        Ok(())
    }

    fn cycle_editor_language(&mut self) -> Result<()> {
        let Some(editor) = &mut self.editor else {
            return Ok(());
        };
        if !matches!(editor.entity, EditorEntity::Post(_)) {
            return Ok(());
        }
        let mut languages = vec!["en".to_string()];
        if let Some(data_dir) = &self.data_dir
            && let Ok(metadata) = engine::meta::read_project_json(data_dir)
        {
            if let Some(main) = metadata.main_language {
                languages.insert(0, main);
            }
            languages.extend(metadata.blog_languages);
        }
        languages.sort();
        languages.dedup();
        let current = editor
            .post_language
            .as_deref()
            .unwrap_or(languages.first().map(String::as_str).unwrap_or("en"));
        let index = languages
            .iter()
            .position(|value| value == current)
            .unwrap_or(0);
        editor.post_language = Some(languages[(index + 1) % languages.len()].clone());
        editor.buffer.set_dirty(true);
        self.status = bds_core::i18n::translate_with(
            self.locale,
            "tui.editorLanguage",
            &[("language", editor.post_language.as_deref().unwrap_or("en"))],
        );
        Ok(())
    }

    fn handle_panel_input(&mut self, input: TuiInput) -> Result<()> {
        match input.key {
            TuiKey::Esc => {
                self.focus = Focus::Sidebar;
                self.prompt = None;
                self.panel = Panel::Welcome;
                self.settings_fields.clear();
                self.scroll = 0;
            }
            TuiKey::Up | TuiKey::Char('k') => {
                if matches!(self.panel, Panel::Settings(_)) {
                    self.move_setting_selection(-1);
                } else {
                    self.panel_index = self.panel_index.saturating_sub(1);
                }
            }
            TuiKey::Down | TuiKey::Char('j') => {
                if matches!(self.panel, Panel::Settings(_)) {
                    self.move_setting_selection(1);
                } else {
                    self.panel_index = self.panel_index.saturating_add(1);
                }
            }
            TuiKey::PageUp => self.scroll = self.scroll.saturating_sub(10),
            TuiKey::PageDown => self.scroll = self.scroll.saturating_add(10),
            TuiKey::Enter => match self.panel {
                Panel::Settings(_) => self.edit_setting_field()?,
                Panel::Tags(TagSection::Manage) => self.begin_tag_rename()?,
                Panel::Report { action, .. } => self.apply_report(action)?,
                _ => {}
            },
            TuiKey::Char('n') if matches!(self.panel, Panel::Tags(TagSection::Manage)) => {
                self.prompt = Some(Prompt {
                    kind: PromptKind::TagCreate,
                    value: String::new(),
                    candidates: Vec::new(),
                })
            }
            TuiKey::Char(' ') if matches!(self.panel, Panel::Tags(TagSection::Merge)) => {
                self.toggle_tag_mark()
            }
            TuiKey::Char('m') if matches!(self.panel, Panel::Tags(TagSection::Merge)) => {
                self.merge_marked_tags()?
            }
            TuiKey::Char('d') if matches!(self.panel, Panel::Tags(TagSection::Manage)) => {
                self.begin_tag_delete()?
            }
            TuiKey::Char('c') if matches!(self.panel, Panel::Tags(TagSection::Manage)) => {
                self.cycle_tag_color()?
            }
            TuiKey::Char('t') if matches!(self.panel, Panel::Tags(TagSection::Manage)) => {
                self.cycle_tag_template()?
            }
            TuiKey::Char('s') if matches!(self.panel, Panel::Tags(_)) => self.sync_tags()?,
            _ => {}
        }
        let max = match self.panel {
            Panel::Settings(_) => self.settings_fields.len(),
            Panel::Tags(_) => self.tags().map(|tags| tags.len()).unwrap_or(0),
            _ => usize::MAX,
        };
        if max > 0 {
            self.panel_index = self.panel_index.min(max - 1);
        } else {
            self.panel_index = 0;
        }
        Ok(())
    }

    fn move_setting_selection(&mut self, delta: isize) {
        if self.settings_fields.is_empty() {
            return;
        }
        let mut index = self.panel_index;
        for _ in 0..self.settings_fields.len() {
            index = if delta > 0 {
                (index + 1) % self.settings_fields.len()
            } else {
                index
                    .checked_sub(1)
                    .unwrap_or(self.settings_fields.len() - 1)
            };
            if self.settings_fields[index].kind != FieldKind::ReadOnly {
                self.panel_index = index;
                return;
            }
        }
    }

    fn handle_prompt(&mut self, input: TuiInput) -> Result<()> {
        let Some(mut prompt) = self.prompt.take() else {
            return Ok(());
        };
        let refilter = matches!(input.key, TuiKey::Char(_) | TuiKey::Backspace);
        match input.key {
            TuiKey::Esc => {
                if prompt.kind == PromptKind::Search {
                    self.filters.remove(&self.view);
                    self.reload()?;
                } else if prompt.kind == PromptKind::ProjectPath {
                    self.overlay = Some(Overlay::Projects { selected: 0 });
                }
                self.status.clear();
                return Ok(());
            }
            TuiKey::Backspace => {
                prompt.value.pop();
            }
            TuiKey::Char(value) if !input.modifiers.ctrl && !input.modifiers.alt => {
                prompt.value.push(value);
                if prompt.kind == PromptKind::ProjectPath {
                    prompt.candidates.clear();
                }
            }
            TuiKey::Tab if prompt.kind == PromptKind::ProjectPath => complete_path(&mut prompt),
            TuiKey::Up if prompt.kind == PromptKind::Command && !prompt.candidates.is_empty() => {
                prompt.candidates.rotate_right(1)
            }
            TuiKey::Down if prompt.kind == PromptKind::Command && !prompt.candidates.is_empty() => {
                prompt.candidates.rotate_left(1)
            }
            TuiKey::Enter => return self.submit_prompt(prompt),
            _ => {}
        }
        if prompt.kind == PromptKind::Search {
            self.filters.insert(self.view, prompt.value.clone());
            self.reload()?;
        } else if prompt.kind == PromptKind::Command && refilter {
            prompt.candidates = self
                .command_names()
                .iter()
                .filter(|name| contains_ci(name, &prompt.value))
                .cloned()
                .collect();
        }
        self.prompt = Some(prompt);
        Ok(())
    }

    fn submit_prompt(&mut self, prompt: Prompt) -> Result<()> {
        match prompt.kind {
            PromptKind::Search => {
                self.filters.insert(self.view, prompt.value);
                self.reload()?;
            }
            PromptKind::Command => {
                let typed = prompt.value.trim_start_matches(':').trim();
                let selected = if typed == "?" || self.command_id(typed).is_some() {
                    typed.to_owned()
                } else {
                    prompt
                        .candidates
                        .first()
                        .cloned()
                        .unwrap_or_else(|| typed.to_owned())
                };
                self.run_command(&selected)?;
            }
            PromptKind::ProjectPath => self.open_project_path(&prompt.value)?,
            PromptKind::Commit => {
                if !self.require_git()? {
                    return Ok(());
                } else if prompt.value.trim().is_empty() {
                    self.status = self.tr("tui.commitMessageRequired");
                    self.prompt = Some(prompt);
                } else {
                    let data_dir = self.data_dir()?.to_owned();
                    let message = prompt.value;
                    let locale = self.locale;
                    let label = self.tr("tui.taskGitCommit");
                    self.queue_task(&label, move |tasks, task_id| {
                        if tasks.is_cancelled(task_id) {
                            return Err(anyhow!("operation cancelled"));
                        }
                        let output = engine::git::GitEngine::new(&data_dir)
                            .commit_all(&message)?
                            .output;
                        Ok(BackgroundResult {
                            status: if output.trim().is_empty() {
                                bds_core::i18n::translate(locale, "tui.committed")
                            } else {
                                output
                            },
                            panel: Some(Panel::Git),
                            reload: true,
                        })
                    });
                }
            }
            PromptKind::Setting(index) => {
                if let Some(field) = self.settings_fields.get_mut(index) {
                    field.value = prompt.value;
                }
            }
            PromptKind::TagCreate => {
                if prompt.value.trim().is_empty() {
                    self.status = self.tr("tui.tagNameRequired");
                    self.prompt = Some(prompt);
                } else {
                    let db = self.database()?;
                    engine::tag::create_tag(
                        db.conn(),
                        self.data_dir()?,
                        self.project_id()?,
                        prompt.value.trim(),
                        None,
                    )?;
                    self.status = self.tr("tui.tagCreated");
                }
            }
            PromptKind::TagRename(id) => {
                if prompt.value.trim().is_empty() {
                    self.status = self.tr("tui.tagNameRequired");
                } else {
                    let db = self.database()?;
                    engine::tag::rename_tag(
                        db.conn(),
                        self.data_dir()?,
                        self.project_id()?,
                        &id,
                        prompt.value.trim(),
                    )?;
                    self.status = self.tr("tui.tagRenamed");
                }
            }
            PromptKind::ConfirmDeleteTag(id) => {
                if prompt.value.eq_ignore_ascii_case("y")
                    || prompt
                        .value
                        .eq_ignore_ascii_case(&self.tr("tui.confirmYesInput"))
                {
                    let db = self.database()?;
                    engine::tag::delete_tag(db.conn(), self.data_dir()?, self.project_id()?, &id)?;
                    self.status = self.tr("tui.tagDeleted");
                } else {
                    self.status = self.tr("tui.deleteCancelled");
                }
            }
            PromptKind::EditorTitle => {
                if prompt.value.trim().is_empty() {
                    self.status = self.tr("tui.titleRequired");
                    self.prompt = Some(prompt);
                } else if let Some(editor) = &mut self.editor {
                    editor.title = prompt.value.trim().to_owned();
                    editor.buffer.set_dirty(true);
                }
            }
        }
        Ok(())
    }

    fn handle_overlay(&mut self, input: TuiInput) -> Result<()> {
        let Some(mut overlay) = self.overlay.take() else {
            return Ok(());
        };
        match &mut overlay {
            Overlay::ConfirmDiscard => match input.key {
                TuiKey::Char(value)
                    if value == 'y'
                        || self
                            .tr("tui.confirmYesInput")
                            .starts_with(value.to_ascii_lowercase()) =>
                {
                    self.quit = true
                }
                TuiKey::Char(value)
                    if value == 'n'
                        || self
                            .tr("tui.confirmNoInput")
                            .starts_with(value.to_ascii_lowercase()) => {}
                TuiKey::Esc => {}
                _ => self.overlay = Some(overlay),
            },
            Overlay::Projects { selected } => {
                let projects = engine::project::list_projects(self.database()?.conn())?;
                match input.key {
                    TuiKey::Esc => {}
                    TuiKey::Up | TuiKey::Char('k') => *selected = selected.saturating_sub(1),
                    TuiKey::Down | TuiKey::Char('j') => {
                        *selected = (*selected + 1).min(projects.len().saturating_sub(1))
                    }
                    TuiKey::Enter => {
                        if let Some(project) = projects.get(*selected) {
                            self.activate_project(project.clone())?;
                            return Ok(());
                        }
                    }
                    TuiKey::Char('o') => {
                        self.prompt = Some(Prompt {
                            kind: PromptKind::ProjectPath,
                            value: String::new(),
                            candidates: Vec::new(),
                        });
                        return Ok(());
                    }
                    _ => {}
                }
                if !matches!(input.key, TuiKey::Esc) {
                    self.overlay = Some(overlay);
                }
            }
        }
        Ok(())
    }

    fn activate_project(&mut self, project: Project) -> Result<()> {
        let db = self.database()?;
        engine::project::set_active_project(db.conn(), &project.id)?;
        self.data_dir = Some(self.host.project_data_dir(&project));
        self.project = Some(project);
        self.editor = None;
        self.panel = Panel::Welcome;
        self.status = self.tr("tui.projectSwitched");
        self.reload()
    }

    fn open_project_path(&mut self, value: &str) -> Result<()> {
        let path = expand_home(value);
        if !path.is_dir() {
            self.status =
                self.tr_with("tui.notDirectory", &[("path", &path.display().to_string())]);
            self.prompt = Some(Prompt {
                kind: PromptKind::ProjectPath,
                value: value.into(),
                candidates: Vec::new(),
            });
            return Ok(());
        }
        let canonical = path.canonicalize()?;
        let db = self.database()?;
        if let Some(project) =
            engine::project::list_projects(db.conn())?
                .into_iter()
                .find(|project| {
                    project
                        .data_path
                        .as_deref()
                        .map(Path::new)
                        .is_some_and(|existing| existing == canonical)
                })
        {
            return self.activate_project(project);
        }
        let name = canonical
            .file_name()
            .and_then(|name| name.to_str())
            .filter(|name| !name.is_empty())
            .map(ToOwned::to_owned)
            .unwrap_or_else(|| self.tr("tui.defaultProjectName"));
        let project = engine::project::create_project(
            db.conn(),
            &name,
            Some(
                canonical
                    .to_str()
                    .ok_or_else(|| anyhow!(self.tr("tui.projectPathInvalidUtf8")))?,
            ),
        )?;
        engine::project::set_active_project(db.conn(), &project.id)?;
        let report = engine::rebuild::rebuild_from_filesystem(db.conn(), &canonical, &project.id)?;
        self.output.push(self.tr_with(
            "tui.rebuildSummary",
            &[
                (
                    "posts",
                    &(report.posts_created + report.posts_updated).to_string(),
                ),
                (
                    "media",
                    &(report.media_created + report.media_updated).to_string(),
                ),
                (
                    "templates",
                    &(report.templates_created + report.templates_updated).to_string(),
                ),
                (
                    "scripts",
                    &(report.scripts_created + report.scripts_updated).to_string(),
                ),
            ],
        ));
        self.activate_project(project)
    }
}

fn expand_home(value: &str) -> PathBuf {
    if value == "~" {
        return dirs::home_dir().unwrap_or_else(|| PathBuf::from(value));
    }
    if let Some(rest) = value.strip_prefix("~/") {
        return dirs::home_dir()
            .unwrap_or_else(|| PathBuf::from("."))
            .join(rest);
    }
    PathBuf::from(value)
}

fn complete_path(prompt: &mut Prompt) {
    let expanded = expand_home(&prompt.value);
    let (directory, prefix) = if expanded.is_dir() {
        (expanded.as_path(), "")
    } else {
        (
            expanded.parent().unwrap_or(Path::new(".")),
            expanded.file_name().and_then(|v| v.to_str()).unwrap_or(""),
        )
    };
    let show_dot = prefix.starts_with('.');
    let Ok(entries) = fs::read_dir(directory) else {
        return;
    };
    let mut candidates = entries
        .flatten()
        .filter(|entry| entry.path().is_dir())
        .filter_map(|entry| entry.file_name().into_string().ok())
        .filter(|name| (show_dot || !name.starts_with('.')) && name.starts_with(prefix))
        .collect::<Vec<_>>();
    candidates.sort();
    if candidates.is_empty() {
        return;
    }
    let completion = longest_common_prefix(&candidates);
    let parent = Path::new(&prompt.value).parent().unwrap_or(Path::new(""));
    prompt.value = parent.join(completion).to_string_lossy().into_owned();
    if candidates.len() == 1 {
        prompt.value.push(std::path::MAIN_SEPARATOR);
    }
    prompt.candidates = candidates;
}

fn longest_common_prefix(values: &[String]) -> String {
    let Some(first) = values.first() else {
        return String::new();
    };
    first
        .chars()
        .take_while(|_| true)
        .enumerate()
        .take_while(|(index, value)| {
            values
                .iter()
                .all(|candidate| candidate.chars().nth(*index) == Some(*value))
        })
        .map(|(_, value)| value)
        .collect()
}

impl TuiApp {
    fn open_settings(&mut self, section: SettingSection) -> Result<()> {
        self.settings_fields = self.load_setting_fields(section)?;
        self.panel = Panel::Settings(section);
        self.panel_index = self
            .settings_fields
            .iter()
            .position(|field| field.kind != FieldKind::ReadOnly)
            .unwrap_or(0);
        self.focus = Focus::Editor;
        Ok(())
    }

    fn load_setting_fields(&self, section: SettingSection) -> Result<Vec<SettingField>> {
        let db = self.database()?;
        let project = self.project.as_ref();
        let metadata = self
            .data_dir()
            .ok()
            .and_then(|path| engine::meta::read_project_json(path).ok());
        let publishing = self
            .data_dir()
            .ok()
            .and_then(|path| engine::meta::read_publishing_json(path).ok())
            .unwrap_or_default();
        let ai = engine::ai::load_ai_settings(db.conn(), self.airplane)?;
        let specs: Vec<(&'static str, FieldKind, String)> = match section {
            SettingSection::Project => vec![
                (
                    "project.name",
                    FieldKind::Text,
                    project.map(|p| p.name.clone()).unwrap_or_default(),
                ),
                (
                    "project.description",
                    FieldKind::Text,
                    project
                        .and_then(|p| p.description.clone())
                        .unwrap_or_default(),
                ),
                (
                    "project.data_path",
                    FieldKind::ReadOnly,
                    self.data_dir
                        .as_ref()
                        .map(|p| p.display().to_string())
                        .unwrap_or_default(),
                ),
                (
                    engine::settings::UI_LANGUAGE_KEY,
                    FieldKind::Enum(&["en", "de", "fr", "it", "es"]),
                    self.locale.code().into(),
                ),
                (
                    "meta.default_author",
                    FieldKind::Text,
                    metadata
                        .as_ref()
                        .and_then(|value| value.default_author.clone())
                        .unwrap_or_default(),
                ),
                (
                    "meta.max_posts_per_page",
                    FieldKind::Text,
                    metadata
                        .as_ref()
                        .map(|value| value.max_posts_per_page.to_string())
                        .unwrap_or_else(|| "50".into()),
                ),
                (
                    "meta.main_language",
                    FieldKind::Text,
                    metadata
                        .as_ref()
                        .and_then(|value| value.main_language.clone())
                        .unwrap_or_default(),
                ),
                (
                    "meta.blog_languages",
                    FieldKind::Text,
                    metadata
                        .as_ref()
                        .map(|value| value.blog_languages.join(", "))
                        .unwrap_or_default(),
                ),
                (
                    "meta.public_url",
                    FieldKind::Text,
                    metadata
                        .as_ref()
                        .and_then(|value| value.public_url.clone())
                        .unwrap_or_default(),
                ),
                (
                    "meta.image_import_concurrency",
                    FieldKind::Text,
                    metadata
                        .as_ref()
                        .map(|value| value.image_import_concurrency.to_string())
                        .unwrap_or_else(|| "4".into()),
                ),
                (
                    "meta.blogmark_category",
                    FieldKind::Text,
                    metadata
                        .as_ref()
                        .and_then(|value| value.blogmark_category.clone())
                        .unwrap_or_default(),
                ),
            ],
            SettingSection::Editor => field_specs(&[
                (
                    "editor.default_mode",
                    FieldKind::Enum(&["markdown", "preview"]),
                    "markdown",
                ),
                (
                    "editor.diff_view_style",
                    FieldKind::Enum(&["inline", "side-by-side"]),
                    "inline",
                ),
                ("editor.wrap_long_lines", FieldKind::Bool, "true"),
                ("editor.hide_unchanged_regions", FieldKind::Bool, "false"),
            ]),
            SettingSection::Content => vec![
                (
                    "meta.categories",
                    FieldKind::ReadOnly,
                    self.data_dir()
                        .ok()
                        .and_then(|path| engine::meta::read_categories_json(path).ok())
                        .map(|values| values.join(", "))
                        .unwrap_or_default(),
                ),
                (
                    "meta.category_editing",
                    FieldKind::ReadOnly,
                    self.tr("tui.categoryEditingGuiOnly"),
                ),
            ],
            SettingSection::Ai => vec![
                (
                    "ai.endpoint.online.url",
                    FieldKind::Text,
                    ai.online.endpoint.url,
                ),
                (
                    "ai.endpoint.online.model",
                    FieldKind::Text,
                    ai.online.endpoint.model,
                ),
                (
                    "ai.endpoint.online.api_key",
                    FieldKind::ReadOnly,
                    if ai.online.endpoint.api_key_configured {
                        self.tr("tui.apiKeyConfigured")
                    } else {
                        self.tr("tui.apiKeyConfigureDesktop")
                    },
                ),
                (
                    "ai.endpoint.online.title_model",
                    FieldKind::Text,
                    ai.online.title_model.unwrap_or_default(),
                ),
                (
                    "ai.endpoint.online.image_model",
                    FieldKind::Text,
                    ai.online.image_model.unwrap_or_default(),
                ),
                (
                    "ai.endpoint.online.chat_supports_tools",
                    FieldKind::Bool,
                    ai.online.chat_supports_tools.unwrap_or(false).to_string(),
                ),
                (
                    "ai.endpoint.online.image_supports_vision",
                    FieldKind::Bool,
                    ai.online.image_supports_vision.unwrap_or(false).to_string(),
                ),
                (
                    "ai.endpoint.airplane.url",
                    FieldKind::Text,
                    ai.airplane.endpoint.url,
                ),
                (
                    "ai.endpoint.airplane.model",
                    FieldKind::Text,
                    ai.airplane.endpoint.model,
                ),
                (
                    "ai.endpoint.airplane.api_key",
                    FieldKind::ReadOnly,
                    if ai.airplane.endpoint.api_key_configured {
                        self.tr("tui.apiKeyConfigured")
                    } else {
                        self.tr("tui.apiKeyConfigureDesktop")
                    },
                ),
                (
                    "ai.endpoint.airplane.title_model",
                    FieldKind::Text,
                    ai.airplane.title_model.unwrap_or_default(),
                ),
                (
                    "ai.endpoint.airplane.image_model",
                    FieldKind::Text,
                    ai.airplane.image_model.unwrap_or_default(),
                ),
                (
                    "ai.endpoint.airplane.chat_supports_tools",
                    FieldKind::Bool,
                    ai.airplane.chat_supports_tools.unwrap_or(false).to_string(),
                ),
                (
                    "ai.endpoint.airplane.image_supports_vision",
                    FieldKind::Bool,
                    ai.airplane
                        .image_supports_vision
                        .unwrap_or(false)
                        .to_string(),
                ),
                ("ai.system_prompt", FieldKind::Text, ai.system_prompt),
            ],
            SettingSection::Technology => vec![
                ("technology.runtime", FieldKind::ReadOnly, "Lua".into()),
                (
                    "meta.semantic_similarity_enabled",
                    FieldKind::Bool,
                    metadata
                        .as_ref()
                        .map(|value| value.semantic_similarity_enabled.to_string())
                        .unwrap_or_else(|| "false".into()),
                ),
            ],
            SettingSection::Publishing => vec![
                (
                    "publishing.ssh_host",
                    FieldKind::Text,
                    publishing.ssh_host.unwrap_or_default(),
                ),
                (
                    "publishing.ssh_username",
                    FieldKind::Text,
                    publishing.ssh_user.unwrap_or_default(),
                ),
                (
                    "publishing.ssh_remote_path",
                    FieldKind::Text,
                    publishing.ssh_remote_path.unwrap_or_default(),
                ),
                (
                    "publishing.ssh_mode",
                    FieldKind::Enum(&["scp", "rsync"]),
                    match publishing.ssh_mode {
                        SshMode::Scp => "scp".into(),
                        SshMode::Rsync => "rsync".into(),
                    },
                ),
            ],
            SettingSection::Data => vec![
                (
                    "data.database",
                    FieldKind::ReadOnly,
                    self.host.database_path().display().to_string(),
                ),
                ("data.automatic_rebuild", FieldKind::Bool, "true".into()),
            ],
            SettingSection::Mcp => {
                let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
                vec![
                    ("mcp.http.enabled", FieldKind::Bool, "false".into()),
                    (
                        "mcp.agent.claude_code",
                        FieldKind::Bool,
                        engine::mcp::is_agent_configured(engine::mcp::McpAgent::ClaudeCode, &home)
                            .to_string(),
                    ),
                    (
                        "mcp.agent.github_copilot",
                        FieldKind::Bool,
                        engine::mcp::is_agent_configured(
                            engine::mcp::McpAgent::GithubCopilot,
                            &home,
                        )
                        .to_string(),
                    ),
                    (
                        "mcp.proposals",
                        FieldKind::ReadOnly,
                        engine::mcp::list_pending_proposals(db.conn(), self.project_id()?)?
                            .len()
                            .to_string(),
                    ),
                ]
            }
        };
        specs
            .into_iter()
            .map(|(key, kind, fallback)| {
                let value = if kind == FieldKind::ReadOnly
                    || key.starts_with("project.")
                    || key.starts_with("meta.")
                    || key.starts_with("publishing.")
                    || key.starts_with("ai.endpoint.")
                    || key.starts_with("mcp.agent.")
                    || key == "ai.system_prompt"
                {
                    fallback
                } else {
                    engine::settings::get_effective(db.conn(), key)?.unwrap_or(fallback)
                };
                Ok(SettingField {
                    label: setting_field_label(self.locale, key),
                    key,
                    kind,
                    value,
                })
            })
            .collect()
    }

    fn edit_setting_field(&mut self) -> Result<()> {
        let Some(field) = self.settings_fields.get_mut(self.panel_index) else {
            return Ok(());
        };
        match &field.kind {
            FieldKind::ReadOnly => {
                self.status = bds_core::i18n::translate(self.locale, "tui.valueReadOnly")
            }
            FieldKind::Bool => {
                field.value = (!field.value.eq_ignore_ascii_case("true")).to_string()
            }
            FieldKind::Enum(values) => {
                let index = values
                    .iter()
                    .position(|value| *value == field.value)
                    .unwrap_or(0);
                field.value = values[(index + 1) % values.len()].to_string();
            }
            FieldKind::Text => {
                self.prompt = Some(Prompt {
                    kind: PromptKind::Setting(self.panel_index),
                    value: field.value.clone(),
                    candidates: Vec::new(),
                })
            }
        }
        Ok(())
    }

    fn save_settings(&mut self) -> Result<()> {
        let Panel::Settings(section) = self.panel else {
            return Ok(());
        };
        let db = self.database()?;
        match section {
            SettingSection::Project => {
                let name = self.setting_value("project.name").trim();
                if name.is_empty() {
                    bail!(self.tr("tui.projectNameRequired"));
                }
                let mut project = self
                    .project
                    .clone()
                    .ok_or_else(|| anyhow!(self.tr("tui.noActiveProject")))?;
                project.name = name.into();
                project.description = optional_string(self.setting_value("project.description"));
                project.updated_at = bds_core::util::now_unix_ms();
                let mut metadata = self.project_metadata()?;
                metadata.name = project.name.clone();
                metadata.description = project.description.clone();
                metadata.default_author =
                    optional_string(self.setting_value("meta.default_author"));
                metadata.main_language = optional_string(self.setting_value("meta.main_language"));
                metadata.public_url = optional_string(self.setting_value("meta.public_url"));
                metadata.blogmark_category =
                    optional_string(self.setting_value("meta.blogmark_category"));
                metadata.blog_languages = self
                    .setting_value("meta.blog_languages")
                    .split(',')
                    .map(str::trim)
                    .filter(|value| !value.is_empty())
                    .map(ToOwned::to_owned)
                    .collect();
                metadata.blog_languages.sort();
                metadata.blog_languages.dedup();
                metadata.max_posts_per_page = self
                    .setting_value("meta.max_posts_per_page")
                    .trim()
                    .parse()
                    .map_err(|_| anyhow!(self.tr("tui.postsPerPageInvalid")))?;
                metadata.image_import_concurrency = self
                    .setting_value("meta.image_import_concurrency")
                    .trim()
                    .parse()
                    .map_err(|_| anyhow!(self.tr("tui.imageImportConcurrencyInvalid")))?;
                metadata.validate().map_err(|error| anyhow!(error))?;
                engine::meta::update_project_metadata(
                    db.conn(),
                    self.data_dir()?,
                    &project,
                    &metadata,
                )?;
                self.project = Some(project);
                if let Some(value) = self.setting_field(engine::settings::UI_LANGUAGE_KEY) {
                    engine::settings::set(db.conn(), value.key, &value.value)?;
                    self.locale = normalize_language(&value.value);
                }
            }
            SettingSection::Content => {}
            SettingSection::Ai => {
                self.save_ai_settings(&db)?;
            }
            SettingSection::Technology => {
                let mut metadata = self.project_metadata()?;
                metadata.semantic_similarity_enabled = self
                    .setting_value("meta.semantic_similarity_enabled")
                    .eq_ignore_ascii_case("true");
                let project = self
                    .project
                    .as_ref()
                    .ok_or_else(|| anyhow!(self.tr("tui.noActiveProject")))?;
                engine::meta::update_project_metadata(
                    db.conn(),
                    self.data_dir()?,
                    project,
                    &metadata,
                )?;
            }
            SettingSection::Publishing => {
                let preferences = PublishingPreferences {
                    ssh_host: optional_string(self.setting_value("publishing.ssh_host")),
                    ssh_user: optional_string(self.setting_value("publishing.ssh_username")),
                    ssh_remote_path: optional_string(
                        self.setting_value("publishing.ssh_remote_path"),
                    ),
                    ssh_mode: if self.setting_value("publishing.ssh_mode") == "rsync" {
                        SshMode::Rsync
                    } else {
                        SshMode::Scp
                    },
                };
                engine::meta::set_publishing_preferences(
                    db.conn(),
                    self.data_dir()?,
                    self.project_id()?,
                    &preferences,
                )?;
            }
            SettingSection::Mcp => {
                engine::settings::set(
                    db.conn(),
                    "mcp.http.enabled",
                    self.setting_value("mcp.http.enabled"),
                )?;
                let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
                for (agent, key) in [
                    (engine::mcp::McpAgent::ClaudeCode, "mcp.agent.claude_code"),
                    (
                        engine::mcp::McpAgent::GithubCopilot,
                        "mcp.agent.github_copilot",
                    ),
                ] {
                    let desired = self.setting_value(key).eq_ignore_ascii_case("true");
                    let configured = engine::mcp::is_agent_configured(agent, &home);
                    if desired && !configured {
                        let executable = engine::mcp::packaged_mcp_executable()?;
                        engine::mcp::install_agent_config(agent, &home, &executable)?;
                    } else if !desired && configured {
                        engine::mcp::remove_agent_config(agent, &home)?;
                    }
                }
            }
            _ => {
                for field in &self.settings_fields {
                    if field.kind != FieldKind::ReadOnly {
                        engine::settings::set(db.conn(), field.key, &field.value)?;
                    }
                }
            }
        }
        if let Some(value) = self
            .settings_fields
            .iter()
            .find(|field| field.key == "ai.airplane_mode")
        {
            self.airplane = value.value == "true";
        }
        if let Some(value) = self
            .settings_fields
            .iter()
            .find(|field| field.key == engine::settings::UI_LANGUAGE_KEY)
        {
            self.locale = normalize_language(&value.value);
        }
        let selected_key = self
            .settings_fields
            .get(self.panel_index)
            .map(|field| field.key);
        self.settings_fields = self.load_setting_fields(section)?;
        self.panel_index = selected_key
            .and_then(|key| {
                self.settings_fields
                    .iter()
                    .position(|field| field.key == key)
            })
            .filter(|index| self.settings_fields[*index].kind != FieldKind::ReadOnly)
            .or_else(|| {
                self.settings_fields
                    .iter()
                    .position(|field| field.kind != FieldKind::ReadOnly)
            })
            .unwrap_or(0);
        self.status = self.tr("tui.settingsSaved");
        Ok(())
    }

    fn setting_field(&self, key: &str) -> Option<&SettingField> {
        self.settings_fields.iter().find(|field| field.key == key)
    }

    fn setting_value(&self, key: &str) -> &str {
        self.setting_field(key)
            .map(|field| field.value.as_str())
            .unwrap_or("")
    }

    fn project_metadata(&self) -> Result<ProjectMetadata> {
        engine::meta::read_project_json(self.data_dir()?).or_else(|_| {
            let project = self
                .project
                .as_ref()
                .ok_or_else(|| anyhow!(self.tr("tui.noActiveProject")))?;
            Ok(ProjectMetadata {
                name: project.name.clone(),
                description: project.description.clone(),
                public_url: None,
                main_language: None,
                default_author: None,
                max_posts_per_page: 50,
                image_import_concurrency: 4,
                blogmark_category: None,
                pico_theme: None,
                semantic_similarity_enabled: false,
                blog_languages: Vec::new(),
            })
        })
    }

    fn save_ai_settings(&mut self, db: &Database) -> Result<()> {
        for (kind, url_key, model_key) in [
            (
                AiEndpointKind::Online,
                "ai.endpoint.online.url",
                "ai.endpoint.online.model",
            ),
            (
                AiEndpointKind::Airplane,
                "ai.endpoint.airplane.url",
                "ai.endpoint.airplane.model",
            ),
        ] {
            let url = self.setting_value(url_key).trim();
            let model = self.setting_value(model_key).trim();
            if !url.is_empty() || !model.is_empty() {
                engine::ai::save_endpoint(
                    db.conn(),
                    &AiEndpointConfig {
                        kind,
                        url: url.into(),
                        model: model.into(),
                        api_key: engine::ai::load_endpoint_api_key(kind)?,
                    },
                )?;
            }
            let prefix = format!("ai.endpoint.{}", kind.as_str());
            engine::ai::save_model_preferences(
                db.conn(),
                kind,
                optional_str(self.setting_value(&format!("{prefix}.title_model"))),
                optional_str(self.setting_value(&format!("{prefix}.image_model"))),
                Some(
                    self.setting_value(&format!("{prefix}.chat_supports_tools"))
                        .eq_ignore_ascii_case("true"),
                ),
                Some(
                    self.setting_value(&format!("{prefix}.image_supports_vision"))
                        .eq_ignore_ascii_case("true"),
                ),
            )?;
        }
        engine::ai::save_system_prompt(db.conn(), self.setting_value("ai.system_prompt"))?;
        Ok(())
    }

    fn tags(&self) -> Result<Vec<Tag>> {
        let db = self.database()?;
        let mut tags =
            bds_core::db::queries::tag::list_tags_by_project(db.conn(), self.project_id()?)?;
        tags.sort_by_key(|tag| tag.name.to_lowercase());
        Ok(tags)
    }

    fn begin_tag_rename(&mut self) -> Result<()> {
        if let Some(tag) = self.tags()?.get(self.panel_index) {
            self.prompt = Some(Prompt {
                kind: PromptKind::TagRename(tag.id.clone()),
                value: tag.name.clone(),
                candidates: Vec::new(),
            });
        }
        Ok(())
    }

    fn begin_tag_delete(&mut self) -> Result<()> {
        if let Some(tag) = self.tags()?.get(self.panel_index) {
            let db = self.database()?;
            let count =
                bds_core::db::queries::post::list_posts_by_project(db.conn(), self.project_id()?)?
                    .into_iter()
                    .filter(|post| {
                        post.tags
                            .iter()
                            .any(|name| name.eq_ignore_ascii_case(&tag.name))
                    })
                    .count();
            self.status = self.tr_with(
                "tui.deleteTagConfirm",
                &[
                    ("name", &tag.name),
                    ("count", &count.to_string()),
                    ("yes", &self.tr("tui.confirmYesInput")),
                ],
            );
            self.prompt = Some(Prompt {
                kind: PromptKind::ConfirmDeleteTag(tag.id.clone()),
                value: String::new(),
                candidates: Vec::new(),
            });
        }
        Ok(())
    }

    fn toggle_tag_mark(&mut self) {
        if let Ok(tags) = self.tags()
            && let Some(tag) = tags.get(self.panel_index)
            && !self.marked_tags.remove(&tag.id)
        {
            self.marked_tags.insert(tag.id.clone());
        }
    }

    fn merge_marked_tags(&mut self) -> Result<()> {
        let tags = self.tags()?;
        let Some(target) = tags.get(self.panel_index) else {
            return Ok(());
        };
        if !self.marked_tags.contains(&target.id) || self.marked_tags.len() < 2 {
            self.status = self.tr("tui.markTagsRequired");
            return Ok(());
        }
        let sources = self
            .marked_tags
            .iter()
            .filter(|id| *id != &target.id)
            .map(String::as_str)
            .collect::<Vec<_>>();
        let db = self.database()?;
        engine::tag::merge_tags(
            db.conn(),
            self.data_dir()?,
            self.project_id()?,
            &sources,
            &target.id,
        )?;
        self.marked_tags.clear();
        self.status = self.tr("tui.tagsMerged");
        Ok(())
    }

    fn cycle_tag_color(&mut self) -> Result<()> {
        let Some(tag) = self.tags()?.get(self.panel_index).cloned() else {
            return Ok(());
        };
        let index = COLORS
            .iter()
            .position(|color| Some(*color) == tag.color.as_deref())
            .unwrap_or(0);
        let db = self.database()?;
        engine::tag::update_tag(
            db.conn(),
            self.data_dir()?,
            &tag.id,
            None,
            Some(COLORS[(index + 1) % COLORS.len()]),
            None,
        )?;
        self.status = self.tr("tui.tagColourUpdated");
        Ok(())
    }

    fn cycle_tag_template(&mut self) -> Result<()> {
        let Some(tag) = self.tags()?.get(self.panel_index).cloned() else {
            return Ok(());
        };
        let db = self.database()?;
        let mut templates = bds_core::db::queries::template::list_templates_by_project(
            db.conn(),
            self.project_id()?,
        )?
        .into_iter()
        .filter(|template| template.kind == TemplateKind::Post)
        .map(|template| template.slug)
        .collect::<Vec<_>>();
        templates.insert(0, String::new());
        let index = templates
            .iter()
            .position(|slug| Some(slug.as_str()) == tag.post_template_slug.as_deref())
            .unwrap_or(0);
        engine::tag::update_tag(
            db.conn(),
            self.data_dir()?,
            &tag.id,
            None,
            None,
            Some(&templates[(index + 1) % templates.len()]),
        )?;
        self.status = self.tr("tui.tagTemplateUpdated");
        Ok(())
    }

    fn sync_tags(&mut self) -> Result<()> {
        let db = self.database()?;
        let tags = engine::tag::sync_tags_from_posts(db.conn(), self.project_id()?)?;
        engine::tag::rewrite_tags_json(db.conn(), self.data_dir()?, self.project_id()?)?;
        self.status = self.tr_with(
            "tui.tagsSynchronized",
            &[("count", &tags.len().to_string())],
        );
        Ok(())
    }
}

fn field_specs(
    values: &[(&'static str, FieldKind, &'static str)],
) -> Vec<(&'static str, FieldKind, String)> {
    values
        .iter()
        .map(|(key, kind, value)| (*key, kind.clone(), (*value).into()))
        .collect()
}

fn setting_field_label(locale: UiLocale, key: &str) -> String {
    let translation_key = match key {
        "project.name" => "settings.projectName",
        "project.description" => "settings.projectDescription",
        "project.data_path" => "settings.dataPath",
        engine::settings::UI_LANGUAGE_KEY => "tui.settingUiLanguage",
        "editor.default_mode" => "settings.defaultMode",
        "editor.diff_view_style" => "settings.diffViewStyle",
        "editor.wrap_long_lines" => "settings.wrapLongLines",
        "editor.hide_unchanged_regions" => "settings.hideUnchangedRegions",
        "meta.default_author" => "settings.defaultAuthor",
        "meta.max_posts_per_page" => "settings.maxPostsPerPage",
        "meta.main_language" => "settings.mainLanguage",
        "meta.blog_languages" => "settings.blogLanguages",
        "meta.public_url" => "settings.publicUrl",
        "meta.image_import_concurrency" => "settings.imageImportConcurrency",
        "meta.blogmark_category" => "settings.blogmarkCategory",
        "meta.categories" => "editor.categories",
        "meta.category_editing" => "tui.categoryEditing",
        "ai.endpoint.online.url" => "settings.onlineEndpointUrl",
        "ai.endpoint.online.model" => "settings.onlineChatModel",
        "ai.endpoint.online.api_key" => "settings.onlineApiKey",
        "ai.endpoint.online.title_model" => "settings.onlineTitleModel",
        "ai.endpoint.online.image_model" => "settings.onlineImageModel",
        "ai.endpoint.online.chat_supports_tools" => "settings.onlineModelSupportsTools",
        "ai.endpoint.online.image_supports_vision" => "settings.onlineModelSupportsVision",
        "ai.endpoint.airplane.url" => "settings.airplaneEndpointUrl",
        "ai.endpoint.airplane.model" => "settings.airplaneChatModel",
        "ai.endpoint.airplane.api_key" => "settings.airplaneApiKey",
        "ai.endpoint.airplane.title_model" => "settings.airplaneTitleModel",
        "ai.endpoint.airplane.image_model" => "settings.airplaneImageModel",
        "ai.endpoint.airplane.chat_supports_tools" => "settings.airplaneModelSupportsTools",
        "ai.endpoint.airplane.image_supports_vision" => "settings.airplaneModelSupportsVision",
        "ai.system_prompt" => "settings.systemPrompt",
        "technology.runtime" => "tui.settingRuntime",
        "meta.semantic_similarity_enabled" => "settings.semanticSimilarityEnabled",
        "publishing.ssh_host" => "settings.sshHost",
        "publishing.ssh_username" => "settings.sshUsername",
        "publishing.ssh_remote_path" => "settings.sshRemotePath",
        "publishing.ssh_mode" => "tui.settingTransferMode",
        "data.database" => "tui.settingDatabase",
        "data.automatic_rebuild" => "tui.settingAutomaticRebuild",
        "mcp.http.enabled" => "settings.mcpEnable",
        "mcp.agent.claude_code" => "tui.settingClaudeCode",
        "mcp.agent.github_copilot" => "tui.settingGithubCopilot",
        "mcp.proposals" => "settings.mcpProposals",
        _ => return key.into(),
    };
    bds_core::i18n::translate(locale, translation_key)
}

fn optional_string(value: &str) -> Option<String> {
    optional_str(value).map(ToOwned::to_owned)
}

fn optional_str(value: &str) -> Option<&str> {
    let value = value.trim();
    (!value.is_empty()).then_some(value)
}

fn visual_line_metrics(buffer: &EditorBuffer, width: usize) -> (usize, usize) {
    let width = width.max(1);
    let (cursor_line, cursor_col) = buffer.cursor();
    let mut visual_cursor = 0;
    let mut visual_total = 0;
    for index in 0..buffer.line_count() {
        let line_len = buffer
            .line(index)
            .map(|line| line.chars().filter(|character| *character != '\n').count())
            .unwrap_or(0);
        if index < cursor_line {
            visual_cursor += line_len.max(1).div_ceil(width);
        } else if index == cursor_line {
            visual_cursor += cursor_col / width;
        }
        visual_total += line_len.max(1).div_ceil(width);
    }
    (visual_cursor, visual_total.max(1))
}

impl TuiApp {
    fn git_items(&self) -> Result<Vec<SidebarItem>> {
        let Some(data_dir) = &self.data_dir else {
            return Ok(vec![SidebarItem::Empty(self.tr("tui.noActiveProject"))]);
        };
        let git = engine::git::GitEngine::new(data_dir);
        let repository = git.repository()?;
        if !repository.is_initialized {
            return Ok(vec![SidebarItem::Empty(self.tr("git.notRepository"))]);
        }
        let remote = git.remote_state()?;
        let branch = remote
            .local_branch
            .clone()
            .unwrap_or_else(|| self.tr("tui.detachedHead"));
        let mut items = vec![SidebarItem::Header(format!(
            "{branch} ↑{} ↓{}",
            remote.ahead, remote.behind
        ))];
        items.extend(git.status()?.into_iter().map(|file| {
            SidebarItem::GitFile(
                file.path.clone(),
                format!("{} {}", file.kind.code(), file.path),
            )
        }));
        items.push(SidebarItem::Header(self.tr("git.history")));
        items.extend(git.history(&branch)?.into_iter().map(|commit| {
            let marker = match commit.sync_status {
                engine::git::SyncStatus::LocalOnly => "↑",
                engine::git::SyncStatus::RemoteOnly => "↓",
                engine::git::SyncStatus::Both => " ",
            };
            let short = commit.hash.chars().take(8).collect::<String>();
            SidebarItem::GitCommit(
                commit.hash,
                format!("{marker} {short} {}", commit.subject.unwrap_or_default()),
            )
        }));
        if !items.iter().any(SidebarItem::selectable) {
            items.push(SidebarItem::Empty(self.tr("tui.workingTreeClean")));
        }
        Ok(items)
    }

    fn open_git_file(&mut self, path: &str) -> Result<()> {
        let patch = engine::git::GitEngine::new(self.data_dir()?)
            .file_diff(path)?
            .patch;
        self.output = vec![truncate_bytes(&patch, MAX_DIFF_BYTES, self.locale)];
        self.panel = Panel::Git;
        self.scroll = 0;
        Ok(())
    }

    fn open_git_commit(&mut self, hash: &str) -> Result<()> {
        let diff = engine::git::GitEngine::new(self.data_dir()?).commit_diff(hash)?;
        self.output = vec![truncate_bytes(&diff, MAX_DIFF_BYTES, self.locale)];
        self.panel = Panel::Git;
        self.scroll = 0;
        Ok(())
    }

    fn git_pull(&mut self) -> Result<()> {
        if !self.require_git()? {
            return Ok(());
        }
        if self.airplane {
            self.status = self.tr("tui.airplaneGitPullBlocked");
            return Ok(());
        }
        let data_dir = self.data_dir()?.to_owned();
        let locale = self.locale;
        let label = self.tr("tui.taskGitPull");
        self.queue_task(&label, move |tasks, task_id| {
            let mut output = Vec::new();
            let result = engine::git::GitEngine::new(&data_dir).pull(
                || tasks.is_cancelled(task_id),
                |line| output.push(line.text),
            )?;
            output.push(result.output);
            Ok(BackgroundResult {
                status: bds_core::i18n::translate_with(
                    locale,
                    "tui.gitPullComplete",
                    &[("output", &output.join("\n"))],
                ),
                panel: Some(Panel::Git),
                reload: true,
            })
        });
        Ok(())
    }

    fn git_push(&mut self) -> Result<()> {
        if !self.require_git()? {
            return Ok(());
        }
        if self.airplane {
            self.status = self.tr("tui.airplaneGitPushBlocked");
            return Ok(());
        }
        let data_dir = self.data_dir()?.to_owned();
        let locale = self.locale;
        let label = self.tr("tui.taskGitPush");
        self.queue_task(&label, move |tasks, task_id| {
            let mut output = Vec::new();
            let result = engine::git::GitEngine::new(&data_dir).push(
                || tasks.is_cancelled(task_id),
                |line| output.push(line.text),
            )?;
            output.push(result.output);
            Ok(BackgroundResult {
                status: bds_core::i18n::translate_with(
                    locale,
                    "tui.gitPushComplete",
                    &[("output", &output.join("\n"))],
                ),
                panel: Some(Panel::Git),
                reload: true,
            })
        });
        Ok(())
    }

    fn require_git(&mut self) -> Result<bool> {
        let ready = engine::git::GitEngine::new(self.data_dir()?)
            .repository()?
            .is_initialized;
        if !ready {
            self.status = self.tr("git.notRepository");
        }
        Ok(ready)
    }

    fn run_command(&mut self, command: &str) -> Result<()> {
        let command = if command.is_empty() {
            COMMANDS.first().map(|command| command.id).unwrap_or("")
        } else if command == "?" {
            command
        } else {
            self.command_id(command).unwrap_or(command)
        };
        if command == "?" {
            self.panel = Panel::Help;
            self.focus = Focus::Editor;
            return Ok(());
        }
        let project_id = self.project_id()?.to_owned();
        let data_dir = self.data_dir()?.to_owned();
        let database_path = self.host.database_path().to_owned();
        match command {
            "metadata-diff" => {
                let locale = self.locale;
                let label = self.tr("tui.commandMetadataDiff");
                self.queue_task(&label, move |tasks, task_id| {
                    let db = Database::open(&database_path)?;
                    let report = engine::metadata_diff::compute_metadata_diff_with_progress(
                        db.conn(),
                        &data_dir,
                        &project_id,
                        |current, total| {
                            tasks.report_progress(
                                task_id,
                                Some(current as f32 / total.max(1) as f32),
                                Some(bds_core::i18n::translate(locale, "tui.commandMetadataDiff")),
                            );
                            !tasks.is_cancelled(task_id)
                        },
                    )?;
                    Ok(BackgroundResult {
                        status: bds_core::i18n::translate(locale, "tui.metadataDiffComplete"),
                        panel: Some(Panel::Report {
                            title: bds_core::i18n::translate(locale, "menu.item.metadataDiff"),
                            body: metadata_report_body_from_report(&report, locale),
                            action: ReportAction::MetadataDiff,
                        }),
                        reload: false,
                    })
                });
            }
            "validate-site" => {
                let locale = self.locale;
                let label = self.tr("tui.commandValidateSite");
                self.queue_task(&label, move |tasks, task_id| {
                    let db = Database::open(&database_path)?;
                    let report = engine::validate_site::validate_site_with_progress(
                        db.conn(),
                        &data_dir,
                        &project_id,
                        |current, total| {
                            tasks.report_progress(
                                task_id,
                                Some(current as f32 / total.max(1) as f32),
                                Some(bds_core::i18n::translate(locale, "tui.commandValidateSite")),
                            );
                            !tasks.is_cancelled(task_id)
                        },
                    )?;
                    Ok(BackgroundResult {
                        status: bds_core::i18n::translate(locale, "tui.siteValidationComplete"),
                        panel: Some(Panel::Report {
                            title: bds_core::i18n::translate(locale, "menu.item.validateSite"),
                            body: site_validation_body_from_report(&report, locale),
                            action: ReportAction::SiteValidation,
                        }),
                        reload: false,
                    })
                });
            }
            "force-render" => {
                let locale = self.locale;
                let label = self.tr("tui.commandForceRender");
                self.queue_task(&label, move |tasks, task_id| {
                    let db = Database::open(&database_path)?;
                    let metadata = engine::meta::read_project_json(&data_dir)?;
                    let posts =
                        bds_core::db::queries::post::list_posts_by_project(db.conn(), &project_id)?;
                    let total_posts = posts.len();
                    let mut sources = Vec::new();
                    for (index, post) in posts.into_iter().enumerate() {
                        tasks.report_progress(
                            task_id,
                            Some((index + 1) as f32 / total_posts.max(1) as f32 * 0.1),
                            Some(post.title.clone()),
                        );
                        if tasks.is_cancelled(task_id) {
                            return Err(anyhow!("operation cancelled"));
                        }
                        if let Some(source) =
                            engine::generation::load_published_post_source(&data_dir, post)?
                        {
                            sources.push(source);
                        }
                    }
                    let prepared = engine::generation::prepare_site_generation(
                        db.conn(),
                        &data_dir,
                        &project_id,
                        &metadata,
                        &sources,
                    )?;
                    let output_dir = data_dir.join("html");
                    let mut report = engine::generation::GenerationReport::default();
                    for (index, section) in engine::generation::GenerationSection::ALL
                        .into_iter()
                        .enumerate()
                    {
                        let progress_tasks = Arc::clone(&tasks);
                        let cancel_tasks = Arc::clone(&tasks);
                        report.append(
                            engine::generation::render_prepared_site_section_with_progress(
                                db.conn(),
                                &output_dir,
                                &project_id,
                                &prepared,
                                section,
                                true,
                                &|_| {},
                                move |current, total, path| {
                                    progress_tasks.report_progress(
                                        task_id,
                                        Some(
                                            0.1 + (index as f32
                                                + current as f32 / total.max(1) as f32)
                                                / 6.0
                                                * 0.9,
                                        ),
                                        Some(path.to_string()),
                                    );
                                },
                                move || cancel_tasks.is_cancelled(task_id),
                            )?,
                        );
                    }
                    let progress_tasks = Arc::clone(&tasks);
                    let cancel_tasks = Arc::clone(&tasks);
                    report.append(
                        engine::generation::build_site_search_index_forced_with_progress(
                            db.conn(),
                            &output_dir,
                            &project_id,
                            &metadata,
                            move |current, total, path| {
                                progress_tasks.report_progress(
                                    task_id,
                                    Some(0.85 + current as f32 / total.max(1) as f32 * 0.15),
                                    Some(path.to_string()),
                                );
                            },
                            move || cancel_tasks.is_cancelled(task_id),
                        )?,
                    );
                    Ok(BackgroundResult {
                        status: bds_core::i18n::translate_with(
                            locale,
                            "tui.filesRendered",
                            &[("count", &report.written_paths.len().to_string())],
                        ),
                        panel: None,
                        reload: false,
                    })
                });
            }
            "rebuild-database" => {
                let locale = self.locale;
                let label = self.tr("tui.commandRebuildDatabase");
                self.queue_task(&label, move |tasks, task_id| {
                    let db = Database::open(&database_path)?;
                    let progress_tasks = Arc::clone(&tasks);
                    let report = engine::rebuild::rebuild_from_filesystem_with_progress(
                        db.conn(),
                        &data_dir,
                        &project_id,
                        Some(Arc::new(move |progress, event| {
                            progress_tasks.report_progress(
                                task_id,
                                Some(progress),
                                Some(event.localized(locale)),
                            );
                            !progress_tasks.is_cancelled(task_id)
                        })),
                    )?;
                    let count = report.posts_created
                        + report.posts_updated
                        + report.media_created
                        + report.media_updated
                        + report.templates_created
                        + report.templates_updated
                        + report.scripts_created
                        + report.scripts_updated;
                    Ok(BackgroundResult {
                        status: bds_core::i18n::translate_with(
                            locale,
                            "tui.itemsRebuilt",
                            &[("count", &count.to_string())],
                        ),
                        panel: None,
                        reload: true,
                    })
                });
            }
            "reindex-search" => {
                let locale = self.locale;
                let label = self.tr("tui.commandReindexSearch");
                self.queue_task(&label, move |tasks, task_id| {
                    let db = Database::open(&database_path)?;
                    let progress_tasks = Arc::clone(&tasks);
                    let report = engine::search::reindex_project(
                        db.conn(),
                        &project_id,
                        Some(Box::new(move |current, total, name| {
                            progress_tasks.report_progress(
                                task_id,
                                Some(current as f32 / total.max(1) as f32),
                                Some(name.to_string()),
                            );
                            !progress_tasks.is_cancelled(task_id)
                        })),
                    )?;
                    Ok(BackgroundResult {
                        status: bds_core::i18n::translate_with(
                            locale,
                            "tui.postsReindexed",
                            &[("count", &report.posts_indexed.to_string())],
                        ),
                        panel: None,
                        reload: false,
                    })
                });
            }
            "validate-translations-gui" | "find-duplicates-gui" => {
                self.status = self.tr("tui.commandDesktopOnly")
            }
            "upload-site" if self.airplane => self.status = self.tr("tui.airplaneUploadBlocked"),
            "upload-site" => self.status = self.tr("tui.uploadRequiresPublishingSettings"),
            "browser-preview-url" => {
                self.status = format!("file://{}", data_dir.join("html/index.html").display())
            }
            _ => self.status = self.tr_with("tui.unknownCommand", &[("command", command)]),
        }
        Ok(())
    }

    fn apply_report(&mut self, action: ReportAction) -> Result<()> {
        let db = self.database()?;
        let project_id = self.project_id()?.to_owned();
        let data_dir = self.data_dir()?.to_owned();
        match action {
            ReportAction::MetadataDiff => {
                let report = engine::metadata_diff::compute_metadata_diff(
                    db.conn(),
                    &data_dir,
                    &project_id,
                )?;
                for item in &report.diffs {
                    engine::metadata_diff::repair_metadata_diff_item(
                        db.conn(),
                        &data_dir,
                        &project_id,
                        engine::metadata_diff::RepairDirection::FileToDatabase,
                        item,
                    )?;
                }
                for orphan in &report.orphans {
                    if orphan.reason == "file_without_db_entry" {
                        engine::metadata_diff::import_orphan_file(
                            db.conn(),
                            &data_dir,
                            &project_id,
                            orphan,
                        )?;
                    }
                }
                self.status = self.tr_with(
                    "tui.metadataChangesApplied",
                    &[(
                        "count",
                        &(report.diffs.len() + report.orphans.len()).to_string(),
                    )],
                );
                self.reload()?;
            }
            ReportAction::SiteValidation => {
                let validation =
                    engine::validate_site::validate_site(db.conn(), &data_dir, &project_id)?;
                let metadata = engine::meta::read_project_json(&data_dir)?;
                let posts = published_sources(db.conn(), &data_dir, &project_id)?;
                let sections =
                    engine::generation::sections_from_validation_report(&validation, &metadata);
                let report = engine::generation::apply_validation_sections(
                    db.conn(),
                    &data_dir.join("html"),
                    &project_id,
                    &metadata,
                    &posts,
                    &validation,
                    &sections,
                )?;
                self.status = self.tr_with(
                    "tui.validationApplied",
                    &[
                        ("written", &report.written_paths.len().to_string()),
                        ("removed", &report.deleted_paths.len().to_string()),
                    ],
                );
            }
        }
        self.panel = Panel::Welcome;
        self.focus = Focus::Sidebar;
        Ok(())
    }
}

fn truncate_bytes(value: &str, limit: usize, locale: UiLocale) -> String {
    if value.len() <= limit {
        return value.into();
    }
    let mut end = limit;
    while !value.is_char_boundary(end) {
        end -= 1;
    }
    format!(
        "{}\n\n{}",
        &value[..end],
        bds_core::i18n::translate(locale, "tui.diffTruncated")
    )
}

fn published_sources(
    conn: &bds_core::db::DbConnection,
    data_dir: &Path,
    project_id: &str,
) -> Result<Vec<engine::generation::PublishedPostSource>> {
    let mut sources = Vec::new();
    for post in bds_core::db::queries::post::list_posts_by_project(conn, project_id)? {
        if let Some(source) = engine::generation::load_published_post_source(data_dir, post)? {
            sources.push(source);
        }
    }
    Ok(sources)
}

fn metadata_report_body(
    conn: &bds_core::db::DbConnection,
    data_dir: &Path,
    project_id: &str,
    locale: UiLocale,
) -> Result<String> {
    let report = engine::metadata_diff::compute_metadata_diff(conn, data_dir, project_id)?;
    Ok(metadata_report_body_from_report(&report, locale))
}

fn metadata_report_body_from_report(
    report: &engine::metadata_diff::DiffReport,
    locale: UiLocale,
) -> String {
    let mut lines = report
        .diffs
        .iter()
        .map(|item| {
            bds_core::i18n::translate_with(
                locale,
                "tui.metadataDiffFields",
                &[
                    ("entity", &item.entity_type),
                    ("path", &item.file_path),
                    ("count", &item.fields.len().to_string()),
                ],
            )
        })
        .collect::<Vec<_>>();
    lines.extend(report.orphans.iter().map(|item| {
        bds_core::i18n::translate_with(
            locale,
            "tui.metadataOrphan",
            &[("path", &item.file_path), ("reason", &item.reason)],
        )
    }));
    lines.extend(report.errors.iter().map(|error| {
        bds_core::i18n::translate_with(locale, "tui.metadataError", &[("error", error)])
    }));
    if lines.is_empty() {
        lines.push(bds_core::i18n::translate(
            locale,
            "tui.noMetadataDifferences",
        ));
    }
    lines.join("\n")
}

fn site_validation_body(
    conn: &bds_core::db::DbConnection,
    data_dir: &Path,
    project_id: &str,
    locale: UiLocale,
) -> Result<String> {
    let report = engine::validate_site::validate_site(conn, data_dir, project_id)?;
    Ok(site_validation_body_from_report(&report, locale))
}

fn site_validation_body_from_report(
    report: &engine::validate_site::SiteValidationReport,
    locale: UiLocale,
) -> String {
    bds_core::i18n::translate_with(
        locale,
        "tui.siteValidationReport",
        &[
            ("missing", &report.missing_pages.join("\n")),
            ("extra", &report.extra_pages.join("\n")),
            ("stale", &report.stale_pages.join("\n")),
        ],
    )
}

impl TuiApp {
    pub fn render(&mut self, frame: &mut Frame<'_>) {
        let area = frame.area();
        self.resize(area.width, area.height);
        self.render_buffer(area, frame.buffer_mut());
    }

    pub fn render_buffer(&self, area: Rect, buffer: &mut Buffer) {
        let background = Style::default()
            .bg(Color::Rgb(24, 26, 32))
            .fg(Color::Rgb(210, 214, 224));
        buffer.set_style(area, background);
        let vertical = Layout::default()
            .direction(Direction::Vertical)
            .constraints([
                Constraint::Min(4),
                Constraint::Length(1),
                Constraint::Length(1),
            ])
            .split(area);
        let body = Layout::default()
            .direction(Direction::Horizontal)
            .constraints([
                Constraint::Length(SIDEBAR_WIDTH.min(area.width.saturating_sub(8))),
                Constraint::Min(8),
            ])
            .split(vertical[0]);
        self.render_sidebar(body[0], buffer);
        self.render_main(body[1], buffer);
        let output = self
            .output
            .last()
            .map(|value| value.lines().last().unwrap_or(""))
            .unwrap_or("");
        Paragraph::new(output)
            .style(
                Style::default()
                    .bg(Color::Rgb(35, 38, 46))
                    .fg(Color::Rgb(153, 162, 184)),
            )
            .render(vertical[1], buffer);
        self.render_status(vertical[2], buffer);
        if let Some(prompt) = &self.prompt {
            self.render_prompt_overlay(area, prompt, buffer);
        }
        if let Some(overlay) = &self.overlay {
            self.render_overlay(area, overlay, buffer);
        }
    }

    fn render_sidebar(&self, area: Rect, buffer: &mut Buffer) {
        let filter = self
            .filters
            .get(&self.view)
            .filter(|value| !value.is_empty())
            .map(|value| format!(" /{value}"))
            .unwrap_or_default();
        let title = format!(" {}{} ", self.view_title(), filter);
        let inner = Block::default()
            .title(title)
            .borders(Borders::RIGHT)
            .border_style(Style::default().fg(Color::Rgb(65, 70, 83)))
            .inner(area);
        Block::default()
            .title(format!(" {}{} ", self.view_title(), filter))
            .borders(Borders::RIGHT)
            .border_style(Style::default().fg(Color::Rgb(65, 70, 83)))
            .render(area, buffer);
        let rows = self
            .items
            .iter()
            .map(|item| {
                let style = if matches!(item, SidebarItem::Header(_)) {
                    Style::default()
                        .fg(Color::Rgb(126, 137, 160))
                        .add_modifier(Modifier::BOLD)
                } else {
                    Style::default().fg(Color::Rgb(196, 201, 214))
                };
                let prefix = if matches!(item, SidebarItem::Header(_)) {
                    ""
                } else {
                    "  "
                };
                ListItem::new(Line::styled(
                    format!("{prefix}{}", item.label(self.locale)),
                    style,
                ))
            })
            .collect::<Vec<_>>();
        let highlight = if self.focus == Focus::Sidebar {
            Style::default()
                .bg(Color::Rgb(54, 61, 78))
                .fg(Color::White)
                .add_modifier(Modifier::BOLD)
        } else {
            Style::default()
        };
        let mut state = ListState::default().with_selected(Some(self.selected_index));
        StatefulWidget::render(
            List::new(rows).highlight_style(highlight),
            inner,
            buffer,
            &mut state,
        );
    }

    fn render_main(&self, area: Rect, buffer: &mut Buffer) {
        if let Some(editor) = &self.editor {
            self.render_editor(area, editor, buffer);
            return;
        }
        if let Panel::MediaPreview { title, path } = &self.panel
            && let Some(protocol) = &self.image_protocol
        {
            let block = Block::default()
                .title(format!(" {title} — {} ", path.display()))
                .padding(ratatui::widgets::Padding::new(1, 1, 1, 1));
            let inner = block.inner(area);
            block.render(area, buffer);
            TerminalImage::new(protocol).render(inner, buffer);
            return;
        }
        let (title, text) = match &self.panel {
            Panel::Welcome => (
                self.project
                    .as_ref()
                    .map(|project| project.name.clone())
                    .unwrap_or_else(|| {
                        self.tr(if self.remote {
                            "remoteTerminal.serverTitle"
                        } else {
                            "remoteTerminal.localTitle"
                        })
                    }),
                self.tr("tui.help"),
            ),
            Panel::MediaPreview { title, path } => (
                title.clone(),
                self.tr_with(
                    "tui.imagePreview",
                    &[
                        ("path", &path.display().to_string()),
                        (
                            "dimensions",
                            &image_dimensions(path, self.locale)
                                .unwrap_or_else(|| self.tr("modal.postGallery.unavailable")),
                        ),
                    ],
                ),
            ),
            Panel::Settings(section) => (self.tr(section.key()), self.settings_text()),
            Panel::Tags(section) => (self.tr(section.key()), self.tags_text(*section)),
            Panel::Git => (
                self.tr("git.diff"),
                self.output
                    .last()
                    .cloned()
                    .unwrap_or_else(|| self.git_diff_text()),
            ),
            Panel::Report { title, body, .. } => (
                title.clone(),
                format!("{body}\n\n{}", self.tr("tui.applyCancel")),
            ),
            Panel::Help => (
                self.tr("tui.commandsTitle"),
                self.command_names().join("\n"),
            ),
        };
        Paragraph::new(text)
            .block(
                Block::default()
                    .title(format!(" {title} "))
                    .borders(Borders::NONE)
                    .padding(ratatui::widgets::Padding::new(2, 2, 1, 1)),
            )
            .wrap(Wrap { trim: false })
            .scroll((self.scroll, 0))
            .render(area, buffer);
    }

    fn render_editor(&self, area: Rect, editor: &Editor, buffer: &mut Buffer) {
        let dirty = if editor.buffer.is_dirty() { " ●" } else { "" };
        let language = editor.post_language.as_deref().unwrap_or(editor.syntax);
        let mode = if editor.mode == EditorMode::Preview {
            self.tr("editor.modePreview")
        } else {
            language.to_owned()
        };
        let title = format!(" {} — {}{} ", editor.title, mode, dirty);
        let inner = Block::default()
            .title(title.clone())
            .borders(Borders::BOTTOM)
            .border_style(Style::default().fg(Color::Rgb(65, 70, 83)))
            .inner(area);
        Block::default()
            .title(title)
            .borders(Borders::BOTTOM)
            .border_style(Style::default().fg(Color::Rgb(65, 70, 83)))
            .render(area, buffer);
        if editor.mode == EditorMode::Preview {
            let source = editor.buffer.text();
            Paragraph::new(tui_markdown::from_str(&source))
                .wrap(Wrap { trim: false })
                .scroll((editor.buffer.scroll_offset() as u16, 0))
                .render(inner, buffer);
            return;
        }
        let source = editor.buffer.text();
        let highlighter = bds_editor::highlighter();
        let extension = match editor.syntax {
            "markdown" => "md",
            other => other,
        };
        let syntax = highlighter.syntax_for_extension(extension);
        let cursor_line = editor.buffer.cursor().0;
        let lines = highlighter
            .highlight_lines(&source, syntax)
            .into_iter()
            .enumerate()
            .map(|(index, spans)| {
                let row_style = if index == cursor_line {
                    Style::default().bg(Color::Rgb(39, 43, 53))
                } else {
                    Style::default()
                };
                Line::from(
                    spans
                        .into_iter()
                        .map(|(style, value)| {
                            Span::styled(
                                value.trim_end_matches('\n').to_owned(),
                                Style::default()
                                    .fg(Color::Rgb(
                                        style.foreground.r,
                                        style.foreground.g,
                                        style.foreground.b,
                                    ))
                                    .bg(row_style.bg.unwrap_or(Color::Reset)),
                            )
                        })
                        .collect::<Vec<_>>(),
                )
                .style(row_style)
            })
            .collect::<Vec<_>>();
        Paragraph::new(Text::from(lines))
            .wrap(Wrap { trim: false })
            .scroll((editor.buffer.scroll_offset() as u16, 0))
            .render(inner, buffer);
    }

    fn render_status(&self, area: Rect, buffer: &mut Buffer) {
        let transport = bds_core::i18n::translate_with(
            self.locale,
            "tui.status",
            &[
                (
                    "transport",
                    &self.tr(if self.remote {
                        "tui.remote"
                    } else {
                        "tui.local"
                    }),
                ),
                ("locale", self.locale.code()),
            ],
        );
        let text = if let Some(prompt) = &self.prompt {
            let marker = match prompt.kind {
                PromptKind::Search => "/".into(),
                PromptKind::Command => ":".into(),
                PromptKind::ProjectPath => self.tr("tui.promptOpen"),
                PromptKind::Commit => self.tr("tui.promptCommit"),
                PromptKind::ConfirmDeleteTag(_) => self.tr("tui.promptConfirm"),
                _ => self.tr("tui.promptValue"),
            };
            if matches!(prompt.kind, PromptKind::Search | PromptKind::Command) {
                format!("{marker}{}", prompt.value)
            } else {
                format!("{marker} {}", prompt.value)
            }
        } else if !self.status.is_empty() {
            format!("{transport} · {}", self.status)
        } else {
            transport
        };
        Paragraph::new(text)
            .style(
                Style::default()
                    .bg(Color::Rgb(47, 52, 64))
                    .fg(Color::Rgb(230, 233, 240)),
            )
            .render(area, buffer);
    }

    fn render_overlay(&self, area: Rect, overlay: &Overlay, buffer: &mut Buffer) {
        let popup = centered(
            area,
            64.min(area.width.saturating_sub(2)),
            16.min(area.height.saturating_sub(2)),
        );
        Clear.render(popup, buffer);
        let (title, lines) = match overlay {
            Overlay::ConfirmDiscard => (
                self.tr("tui.unsavedTitle"),
                vec![Line::from(self.tr("tui.unsavedPrompt"))],
            ),
            Overlay::Projects { selected } => {
                let projects = self
                    .database()
                    .and_then(|db| engine::project::list_projects(db.conn()).map_err(Into::into))
                    .unwrap_or_default();
                (
                    self.tr("remoteTerminal.availableProjects"),
                    projects
                        .into_iter()
                        .enumerate()
                        .map(|(index, project)| {
                            Line::styled(
                                format!(
                                    "{} {}",
                                    if project.is_active { "●" } else { " " },
                                    project.name
                                ),
                                if index == *selected {
                                    Style::default()
                                        .bg(Color::Rgb(54, 61, 78))
                                        .add_modifier(Modifier::BOLD)
                                } else {
                                    Style::default()
                                },
                            )
                        })
                        .collect(),
                )
            }
        };
        Paragraph::new(lines)
            .block(
                Block::default()
                    .title(title)
                    .borders(Borders::ALL)
                    .style(Style::default().bg(Color::Rgb(31, 34, 42))),
            )
            .render(popup, buffer);
    }

    fn render_prompt_overlay(&self, area: Rect, prompt: &Prompt, buffer: &mut Buffer) {
        if !matches!(prompt.kind, PromptKind::Command | PromptKind::ProjectPath) {
            return;
        }
        let candidates = if prompt.kind == PromptKind::Command && prompt.value == "?" {
            self.command_names()
        } else {
            prompt.candidates.clone()
        };
        if candidates.is_empty() && prompt.kind == PromptKind::ProjectPath {
            return;
        }
        let height = (candidates.len() as u16 + 2).clamp(3, area.height.saturating_sub(3).max(3));
        let popup = Rect::new(
            area.x + 2,
            area.y + area.height.saturating_sub(height + 2),
            area.width.saturating_sub(4),
            height,
        );
        Clear.render(popup, buffer);
        let rows = candidates
            .into_iter()
            .enumerate()
            .map(|(index, value)| {
                Line::styled(
                    value,
                    if index == 0 {
                        Style::default()
                            .bg(Color::Rgb(54, 61, 78))
                            .add_modifier(Modifier::BOLD)
                    } else {
                        Style::default()
                    },
                )
            })
            .collect::<Vec<_>>();
        Paragraph::new(rows)
            .block(
                Block::default()
                    .title(if prompt.kind == PromptKind::Command {
                        self.tr("tui.commandsTitle")
                    } else {
                        self.tr("tui.foldersTitle")
                    })
                    .borders(Borders::ALL)
                    .style(Style::default().bg(Color::Rgb(31, 34, 42))),
            )
            .render(popup, buffer);
    }

    fn view_title(&self) -> String {
        self.tr(match self.view {
            TuiView::Posts => "tui.viewPosts",
            TuiView::Media => "tui.viewMedia",
            TuiView::Templates => "tui.viewTemplates",
            TuiView::Scripts => "tui.viewScripts",
            TuiView::Tags => "tui.viewTags",
            TuiView::Settings => "tui.viewSettings",
            TuiView::Git => "tui.viewGit",
        })
    }

    fn settings_text(&self) -> String {
        let fields = self
            .settings_fields
            .iter()
            .enumerate()
            .map(|(index, field)| {
                format!(
                    "{} {:<22} {}{}",
                    if index == self.panel_index {
                        "›"
                    } else {
                        " "
                    },
                    field.label,
                    field.value,
                    if field.kind == FieldKind::ReadOnly {
                        self.tr("tui.readOnlyMarker")
                    } else {
                        String::new()
                    }
                )
            })
            .collect::<Vec<_>>()
            .join("\n");
        format!("{fields}\n\n{}", self.tr("tui.settingsHint"))
    }

    fn tags_text(&self, section: TagSection) -> String {
        let Ok(mut tags) = self.tags() else {
            return self.tr("tui.tagsLoadFailed");
        };
        let usage = self.tag_usage();
        if section == TagSection::Cloud {
            tags.sort_by_key(|tag| {
                std::cmp::Reverse(*usage.get(&tag.name.to_lowercase()).unwrap_or(&0))
            });
        }
        let mut rows = tags
            .iter()
            .enumerate()
            .map(|(index, tag)| {
                format!(
                    "{}{} {:<24} {:>4}  {}  {}",
                    if index == self.panel_index {
                        "›"
                    } else {
                        " "
                    },
                    if self.marked_tags.contains(&tag.id) {
                        "●"
                    } else {
                        " "
                    },
                    tag.name,
                    usage.get(&tag.name.to_lowercase()).unwrap_or(&0),
                    tag.color.as_deref().unwrap_or("—"),
                    tag.post_template_slug.as_deref().unwrap_or("—")
                )
            })
            .collect::<Vec<_>>();
        if rows.is_empty() {
            rows.push(self.tr("tui.noTags"));
        }
        rows.push(format!(
            "\n{}",
            self.tr(match section {
                TagSection::Cloud => "tui.tagCloudHint",
                TagSection::Manage => "tui.tagManageHint",
                TagSection::Merge => "tui.tagMergeHint",
            })
        ));
        rows.join("\n")
    }

    fn tag_usage(&self) -> HashMap<String, usize> {
        let Ok(db) = self.database() else {
            return HashMap::new();
        };
        let Ok(project_id) = self.project_id() else {
            return HashMap::new();
        };
        let Ok(posts) = bds_core::db::queries::post::list_posts_by_project(db.conn(), project_id)
        else {
            return HashMap::new();
        };
        let mut usage = HashMap::new();
        for tag in posts.into_iter().flat_map(|post| post.tags) {
            *usage.entry(tag.to_lowercase()).or_default() += 1;
        }
        usage
    }

    fn git_diff_text(&self) -> String {
        let Ok(data_dir) = self.data_dir() else {
            return self.tr("tui.noActiveProject");
        };
        match engine::git::GitEngine::new(data_dir).diff() {
            Ok(diff) => truncate_bytes(
                &self.tr_with(
                    "tui.gitDiffBody",
                    &[("staged", &diff.staged), ("unstaged", &diff.unstaged)],
                ),
                MAX_DIFF_BYTES,
                self.locale,
            ),
            Err(error) => error.to_string(),
        }
    }

    fn tr(&self, key: &str) -> String {
        bds_core::i18n::translate(self.locale, key)
    }

    fn tr_with(&self, key: &str, params: &[(&str, &str)]) -> String {
        bds_core::i18n::translate_with(self.locale, key, params)
    }

    fn command_names(&self) -> Vec<String> {
        COMMANDS
            .iter()
            .map(|command| self.tr(command.key))
            .collect()
    }

    fn command_id(&self, name: &str) -> Option<&'static str> {
        COMMANDS
            .iter()
            .find(|command| command.id == name || self.tr(command.key) == name)
            .map(|command| command.id)
    }
}

fn centered(area: Rect, width: u16, height: u16) -> Rect {
    Rect::new(
        area.x + area.width.saturating_sub(width) / 2,
        area.y + area.height.saturating_sub(height) / 2,
        width,
        height,
    )
}

fn image_dimensions(path: &Path, locale: UiLocale) -> Option<String> {
    let data = fs::read(path).ok()?;
    if data.starts_with(b"\x89PNG") && data.len() >= 24 {
        return Some(bds_core::i18n::translate_with(
            locale,
            "tui.imageDimensions",
            &[
                ("format", "PNG"),
                (
                    "width",
                    &u32::from_be_bytes(data[16..20].try_into().ok()?).to_string(),
                ),
                (
                    "height",
                    &u32::from_be_bytes(data[20..24].try_into().ok()?).to_string(),
                ),
            ],
        ));
    }
    if data.starts_with(b"GIF8") && data.len() >= 10 {
        return Some(bds_core::i18n::translate_with(
            locale,
            "tui.imageDimensions",
            &[
                ("format", "GIF"),
                (
                    "width",
                    &u16::from_le_bytes(data[6..8].try_into().ok()?).to_string(),
                ),
                (
                    "height",
                    &u16::from_le_bytes(data[8..10].try_into().ok()?).to_string(),
                ),
            ],
        ));
    }
    Some(bds_core::i18n::translate_with(
        locale,
        "tui.fileBytes",
        &[("count", &data.len().to_string())],
    ))
}

pub fn run_local(host: ApplicationHost) -> Result<()> {
    ratatui::run(|terminal| -> io::Result<()> {
        let mut app = TuiApp::new(host, false).map_err(io::Error::other)?;
        loop {
            app.poll().map_err(io::Error::other)?;
            terminal.draw(|frame| app.render(frame))?;
            if event::poll(Duration::from_millis(100))? {
                match event::read()? {
                    Event::Key(key) if key.kind == KeyEventKind::Press => {
                        if let Some(input) = crossterm_input(key) {
                            app.handle_input(input).map_err(io::Error::other)?;
                        }
                    }
                    Event::Resize(_, _) => {}
                    _ => {}
                }
            }
            if app.should_quit() {
                break Ok(());
            }
        }
    })
    .map_err(Into::into)
}

fn crossterm_input(key: KeyEvent) -> Option<TuiInput> {
    let modifiers = key.modifiers;
    let key = match key.code {
        KeyCode::Char(value) => TuiKey::Char(value),
        KeyCode::Enter => TuiKey::Enter,
        KeyCode::Esc => TuiKey::Esc,
        KeyCode::Backspace => TuiKey::Backspace,
        KeyCode::Delete => TuiKey::Delete,
        KeyCode::Up => TuiKey::Up,
        KeyCode::Down => TuiKey::Down,
        KeyCode::Left => TuiKey::Left,
        KeyCode::Right => TuiKey::Right,
        KeyCode::Home => TuiKey::Home,
        KeyCode::End => TuiKey::End,
        KeyCode::PageUp => TuiKey::PageUp,
        KeyCode::PageDown => TuiKey::PageDown,
        KeyCode::Tab | KeyCode::BackTab => TuiKey::Tab,
        _ => return None,
    };
    Some(TuiInput {
        key,
        modifiers: TuiModifiers {
            ctrl: modifiers.contains(KeyModifiers::CONTROL),
            shift: modifiers.contains(KeyModifiers::SHIFT),
            alt: modifiers.contains(KeyModifiers::ALT),
        },
    })
}

#[derive(Default)]
pub(crate) struct InputDecoder {
    pending: Vec<u8>,
}

impl InputDecoder {
    pub(crate) fn push(&mut self, bytes: &[u8]) -> Vec<TuiInput> {
        self.pending.extend_from_slice(bytes);
        let mut inputs = Vec::new();
        while let Some(first) = self.pending.first().copied() {
            if first == 0x1b {
                if self.pending.len() == 1 {
                    break;
                }
                let sequences: &[(&[u8], TuiKey)] = &[
                    (b"\x1b[A", TuiKey::Up),
                    (b"\x1b[B", TuiKey::Down),
                    (b"\x1b[C", TuiKey::Right),
                    (b"\x1b[D", TuiKey::Left),
                    (b"\x1b[H", TuiKey::Home),
                    (b"\x1b[F", TuiKey::End),
                    (b"\x1b[5~", TuiKey::PageUp),
                    (b"\x1b[6~", TuiKey::PageDown),
                    (b"\x1b[3~", TuiKey::Delete),
                    (b"\x1b[Z", TuiKey::Tab),
                ];
                if let Some((sequence, key)) = sequences
                    .iter()
                    .find(|(sequence, _)| self.pending.starts_with(sequence))
                {
                    self.pending.drain(..sequence.len());
                    inputs.push(TuiInput::plain(*key));
                    continue;
                }
                if sequences
                    .iter()
                    .any(|(sequence, _)| sequence.starts_with(&self.pending))
                {
                    break;
                }
                self.pending.remove(0);
                inputs.push(TuiInput::plain(TuiKey::Esc));
                continue;
            }
            let (input, consumed) = match first {
                b'\r' | b'\n' => (Some(TuiInput::plain(TuiKey::Enter)), 1),
                b'\t' => (Some(TuiInput::plain(TuiKey::Tab)), 1),
                0x7f | 0x08 => (Some(TuiInput::plain(TuiKey::Backspace)), 1),
                1..=26 => (
                    Some(TuiInput::ctrl(TuiKey::Char((b'a' + first - 1) as char))),
                    1,
                ),
                _ => match std::str::from_utf8(&self.pending) {
                    Ok(value) => {
                        let value = value.chars().next().expect("non-empty input");
                        (Some(TuiInput::plain(TuiKey::Char(value))), value.len_utf8())
                    }
                    Err(error) if error.error_len().is_none() => break,
                    Err(_) => (None, 1),
                },
            };
            self.pending.drain(..consumed);
            if let Some(input) = input {
                inputs.push(input);
            }
        }
        inputs
    }

    pub(crate) fn flush(&mut self) -> Vec<TuiInput> {
        if self.pending.as_slice() == [0x1b] {
            self.pending.clear();
            vec![TuiInput::plain(TuiKey::Esc)]
        } else {
            Vec::new()
        }
    }
}

pub(crate) fn render_ansi(app: &mut TuiApp, width: u16, height: u16) -> Vec<u8> {
    let width = width.max(20);
    let height = height.max(6);
    let area = Rect::new(0, 0, width, height);
    app.resize(width, height);
    let mut buffer = Buffer::empty(area);
    app.render_buffer(area, &mut buffer);
    let mut output = String::from("\x1b[?25l\x1b[2J\x1b[H");
    let mut last_style: Option<Style> = None;
    for y in 0..height {
        if y > 0 {
            output.push_str("\r\n");
        }
        for x in 0..width {
            let cell = &buffer[(x, y)];
            if last_style != Some(cell.style()) {
                output.push_str(&ansi_style(cell.style()));
                last_style = Some(cell.style());
            }
            output.push_str(cell.symbol());
        }
    }
    output.push_str("\x1b[0m");
    output.into_bytes()
}

fn ansi_style(style: Style) -> String {
    let mut codes = vec!["0".to_string()];
    if style.add_modifier.contains(Modifier::BOLD) {
        codes.push("1".into());
    }
    if style.add_modifier.contains(Modifier::ITALIC) {
        codes.push("3".into());
    }
    if style.add_modifier.contains(Modifier::UNDERLINED) {
        codes.push("4".into());
    }
    if let Some(color) = style.fg {
        codes.push(ansi_color(color, false));
    }
    if let Some(color) = style.bg {
        codes.push(ansi_color(color, true));
    }
    format!("\x1b[{}m", codes.join(";"))
}

fn ansi_color(color: Color, background: bool) -> String {
    let prefix = if background { 48 } else { 38 };
    match color {
        Color::Rgb(r, g, b) => format!("{prefix};2;{r};{g};{b}"),
        Color::Black => if background { "40" } else { "30" }.into(),
        Color::Red => if background { "41" } else { "31" }.into(),
        Color::Green => if background { "42" } else { "32" }.into(),
        Color::Yellow => if background { "43" } else { "33" }.into(),
        Color::Blue => if background { "44" } else { "34" }.into(),
        Color::Magenta => if background { "45" } else { "35" }.into(),
        Color::Cyan => if background { "46" } else { "36" }.into(),
        Color::Gray | Color::White => if background { "47" } else { "37" }.into(),
        _ => if background { "49" } else { "39" }.into(),
    }
}

fn editor(
    entity: EditorEntity,
    title: String,
    syntax: &'static str,
    post_language: Option<String>,
    content: &str,
) -> Editor {
    let mut buffer = EditorBuffer::new(content);
    buffer.set_soft_wrap(true);
    Editor {
        entity,
        title,
        syntax,
        post_language,
        buffer,
        mode: EditorMode::Source,
    }
}

enum PublishedKind {
    Post,
    Template,
    Script,
}

fn read_published_body(root: &Path, relative: &str, kind: PublishedKind) -> Result<String> {
    if relative.is_empty() {
        return Ok(String::new());
    }
    let source = fs::read_to_string(root.join(relative))?;
    let body = match kind {
        PublishedKind::Post => {
            bds_core::util::frontmatter::read_post_file(&source)
                .map_err(|error| anyhow!(error))?
                .1
        }
        PublishedKind::Template => {
            bds_core::util::frontmatter::read_template_file(&source)
                .map_err(|error| anyhow!(error))?
                .1
        }
        PublishedKind::Script => {
            bds_core::util::frontmatter::read_script_file(&source)
                .map_err(|error| anyhow!(error))?
                .1
        }
    };
    Ok(body)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::{Read, Write};
    use std::net::TcpListener;
    use std::thread;
    use std::time::Instant;

    struct Fixture {
        _root: tempfile::TempDir,
        host: ApplicationHost,
        project: Project,
        data_dir: PathBuf,
    }

    impl Fixture {
        fn new() -> Self {
            let root = tempfile::tempdir().unwrap();
            let database_path = root.path().join("bds.db");
            let data_root = root.path().join("app");
            let data_dir = root.path().join("blog");
            let host = ApplicationHost::start(database_path.clone(), data_root).unwrap();
            let db = Database::open(&database_path).unwrap();
            let project = engine::project::create_project(
                db.conn(),
                "Terminal Blog",
                Some(data_dir.to_str().unwrap()),
            )
            .unwrap();
            engine::project::set_active_project(db.conn(), &project.id).unwrap();
            engine::settings::set(
                db.conn(),
                engine::settings::UI_LANGUAGE_KEY,
                UiLocale::En.code(),
            )
            .unwrap();
            Self {
                _root: root,
                host,
                project,
                data_dir,
            }
        }

        fn app(&self, remote: bool) -> TuiApp {
            TuiApp::new(self.host.clone(), remote).unwrap()
        }
        fn db(&self) -> Database {
            self.host.database().unwrap()
        }
    }

    fn type_text(app: &mut TuiApp, value: &str) {
        for character in value.chars() {
            app.handle_input(TuiInput::plain(TuiKey::Char(character)))
                .unwrap();
        }
    }

    #[test]
    fn startup_initializes_legacy_project_metadata_snapshots() {
        let fixture = Fixture::new();
        let db = fixture.db();
        bds_core::db::queries::setting::delete_settings_by_prefix(
            db.conn(),
            &format!("project:{}:", fixture.project.id),
        )
        .unwrap();

        let _app = fixture.app(false);

        assert!(
            engine::meta::read_categories_snapshot(db.conn(), &fixture.project.id)
                .unwrap()
                .is_some()
        );
    }

    fn wait_for(app: &mut TuiApp, predicate: impl Fn(&TuiApp) -> bool) {
        let started = Instant::now();
        while !predicate(app) && started.elapsed() < Duration::from_secs(2) {
            app.poll().unwrap();
            thread::sleep(Duration::from_millis(10));
        }
        assert!(predicate(app));
    }

    fn rendered_text(app: &TuiApp, width: u16, height: u16) -> String {
        let area = Rect::new(0, 0, width, height);
        let mut buffer = Buffer::empty(area);
        app.render_buffer(area, &mut buffer);
        (0..height)
            .map(|y| {
                (0..width)
                    .map(|x| buffer[(x, y)].symbol())
                    .collect::<String>()
            })
            .collect::<Vec<_>>()
            .join("\n")
    }

    fn git(directory: &Path, arguments: &[&str]) {
        let status = std::process::Command::new("git")
            .args(arguments)
            .current_dir(directory)
            .status()
            .unwrap();
        assert!(status.success(), "git {arguments:?} failed");
    }

    #[test]
    fn sidebar_navigation_skips_headers_wraps_and_views_are_numbered() {
        let fixture = Fixture::new();
        let db = fixture.db();
        engine::post::create_post(
            db.conn(),
            &fixture.data_dir,
            &fixture.project.id,
            "One",
            Some("body"),
            Vec::new(),
            Vec::new(),
            None,
            None,
            None,
        )
        .unwrap();
        let mut app = fixture.app(false);
        assert!(app.items[app.selected_index].selectable());
        app.handle_input(TuiInput::plain(TuiKey::Down)).unwrap();
        assert!(app.items[app.selected_index].selectable());
        app.handle_input(TuiInput::plain(TuiKey::Char('4')))
            .unwrap();
        assert_eq!(app.view, TuiView::Scripts);
        app.handle_input(TuiInput::plain(TuiKey::Char('6')))
            .unwrap();
        assert_eq!(app.view, TuiView::Settings);
    }

    #[test]
    fn sidebar_scroll_follows_j_and_k_selection() {
        let fixture = Fixture::new();
        let mut app = fixture.app(false);
        app.items = (0..20)
            .map(|index| SidebarItem::Post(index.to_string(), format!("Post {index:02}")))
            .collect();
        app.selected_index = 0;

        for _ in 0..12 {
            app.handle_input(TuiInput::plain(TuiKey::Char('j')))
                .unwrap();
        }
        let scrolled_down = rendered_text(&app, 50, 10);
        assert!(scrolled_down.contains("Post 12"));
        assert!(!scrolled_down.contains("Post 00"));

        for _ in 0..12 {
            app.handle_input(TuiInput::plain(TuiKey::Char('k')))
                .unwrap();
        }
        let scrolled_up = rendered_text(&app, 50, 10);
        assert!(scrolled_up.contains("Post 00"));
    }

    #[test]
    fn posts_templates_and_scripts_edit_validate_save_and_publish_through_shared_engines() {
        let fixture = Fixture::new();
        let mut app = fixture.app(false);
        app.handle_input(TuiInput::plain(TuiKey::Char('n')))
            .unwrap();
        type_text(&mut app, "Hello **terminal**");
        app.handle_input(TuiInput::ctrl(TuiKey::Char('t'))).unwrap();
        for _ in 0..app.tr("tui.untitled").chars().count() {
            app.handle_input(TuiInput::plain(TuiKey::Backspace))
                .unwrap();
        }
        type_text(&mut app, "Terminal Post");
        app.handle_input(TuiInput::plain(TuiKey::Enter)).unwrap();
        app.handle_input(TuiInput::ctrl(TuiKey::Char('l'))).unwrap();
        app.handle_input(TuiInput::ctrl(TuiKey::Char('s'))).unwrap();
        assert!(!app.has_unsaved_changes());
        app.handle_input(TuiInput::ctrl(TuiKey::Char('p'))).unwrap();
        let db = fixture.db();
        let post =
            bds_core::db::queries::post::list_posts_by_project(db.conn(), &fixture.project.id)
                .unwrap()
                .pop()
                .unwrap();
        assert_eq!(post.status, PostStatus::Published);
        assert!(post.content.is_none());
        assert!(fixture.data_dir.join(post.file_path).is_file());
        assert_eq!(post.title, "Terminal Post");
        app.handle_input(TuiInput::ctrl(TuiKey::Char('u'))).unwrap();
        let post = bds_core::db::queries::post::get_post_by_id(db.conn(), &post.id).unwrap();
        assert_eq!(post.status, PostStatus::Archived);

        app.handle_input(TuiInput::plain(TuiKey::Esc)).unwrap();
        app.handle_input(TuiInput::plain(TuiKey::Char('3')))
            .unwrap();
        app.handle_input(TuiInput::plain(TuiKey::Char('n')))
            .unwrap();
        type_text(&mut app, "<h1>{{ post.title }}</h1>");
        app.handle_input(TuiInput::ctrl(TuiKey::Char('p'))).unwrap();
        let template = bds_core::db::queries::template::list_templates_by_project(
            db.conn(),
            &fixture.project.id,
        )
        .unwrap()
        .pop()
        .unwrap();
        assert_eq!(template.status, TemplateStatus::Published);
        app.handle_input(TuiInput::ctrl(TuiKey::Char('u'))).unwrap();
        assert_eq!(
            bds_core::db::queries::template::get_template_by_id(db.conn(), &template.id)
                .unwrap()
                .status,
            TemplateStatus::Draft
        );

        app.handle_input(TuiInput::plain(TuiKey::Esc)).unwrap();
        app.handle_input(TuiInput::plain(TuiKey::Char('4')))
            .unwrap();
        app.handle_input(TuiInput::plain(TuiKey::Char('n')))
            .unwrap();
        app.handle_input(TuiInput::ctrl(TuiKey::Char('p'))).unwrap();
        let script =
            bds_core::db::queries::script::list_scripts_by_project(db.conn(), &fixture.project.id)
                .unwrap()
                .pop()
                .unwrap();
        assert_eq!(script.status, ScriptStatus::Published);
        app.handle_input(TuiInput::ctrl(TuiKey::Char('u'))).unwrap();
        assert_eq!(
            bds_core::db::queries::script::get_script_by_id(db.conn(), &script.id)
                .unwrap()
                .status,
            ScriptStatus::Draft
        );
    }

    #[test]
    fn preview_is_read_only_and_source_editor_soft_wraps_without_a_gutter() {
        let fixture = Fixture::new();
        let mut app = fixture.app(false);
        app.handle_input(TuiInput::plain(TuiKey::Char('n')))
            .unwrap();
        type_text(&mut app, "# Heading\nA very long paragraph");
        let before = app.editor_text().unwrap();
        assert!(app.editor.as_ref().unwrap().buffer.soft_wrap());
        app.handle_input(TuiInput::ctrl(TuiKey::Char('e'))).unwrap();
        type_text(&mut app, "ignored");
        assert_eq!(app.editor_text().unwrap(), before);
        let rendered = rendered_text(&app, 70, 18);
        assert!(rendered.contains("Heading"));
        assert!(!rendered.contains(" 1 │"));

        app.handle_input(TuiInput::ctrl(TuiKey::Char('e'))).unwrap();
        type_text(&mut app, &"x".repeat(1_000));
        assert!(app.editor.as_ref().unwrap().buffer.scroll_offset() > 0);
    }

    #[test]
    fn live_filters_are_per_view_and_combine_text_tag_category_and_date_tokens() {
        let fixture = Fixture::new();
        let db = fixture.db();
        engine::post::create_post(
            db.conn(),
            &fixture.data_dir,
            &fixture.project.id,
            "Rust Notes",
            Some("body"),
            vec!["rust".into()],
            vec!["dev".into()],
            None,
            None,
            None,
        )
        .unwrap();
        engine::post::create_post(
            db.conn(),
            &fixture.data_dir,
            &fixture.project.id,
            "Other",
            Some("body"),
            vec!["misc".into()],
            Vec::new(),
            None,
            None,
            None,
        )
        .unwrap();
        let mut app = fixture.app(false);
        app.handle_input(TuiInput::plain(TuiKey::Char('/')))
            .unwrap();
        type_text(&mut app, "Rust tag:rust category:dev");
        assert_eq!(
            app.items
                .iter()
                .filter(|item| matches!(item, SidebarItem::Post(_, _)))
                .count(),
            1
        );
        app.handle_input(TuiInput::plain(TuiKey::Enter)).unwrap();
        app.handle_input(TuiInput::plain(TuiKey::Char('2')))
            .unwrap();
        assert!(!app.filters.contains_key(&TuiView::Media));
        assert_eq!(
            app.filters.get(&TuiView::Posts).map(String::as_str),
            Some("Rust tag:rust category:dev")
        );
    }

    #[test]
    fn external_events_preserve_dirty_buffers_and_external_delete_closes_the_editor() {
        let fixture = Fixture::new();
        let db = fixture.db();
        let post = engine::post::create_post(
            db.conn(),
            &fixture.data_dir,
            &fixture.project.id,
            "Open",
            Some("base"),
            Vec::new(),
            Vec::new(),
            None,
            None,
            None,
        )
        .unwrap();
        let mut app = fixture.app(true);
        app.open_post(&post.id).unwrap();
        type_text(&mut app, " dirty");
        let dirty = app.editor_text().unwrap();
        engine::post::create_post(
            db.conn(),
            &fixture.data_dir,
            &fixture.project.id,
            "Elsewhere",
            Some("body"),
            Vec::new(),
            Vec::new(),
            None,
            None,
            None,
        )
        .unwrap();
        app.poll().unwrap();
        assert_eq!(app.editor_text().unwrap(), dirty);
        engine::post::delete_post(db.conn(), &fixture.data_dir, &post.id).unwrap();
        app.poll().unwrap();
        assert!(app.editor.is_none());
        assert_eq!(app.focus, Focus::Sidebar);

        let mut metadata = engine::meta::read_project_json(&fixture.data_dir).unwrap();
        metadata.name = "Changed on disk".into();
        engine::meta::write_project_json(&fixture.data_dir, &metadata).unwrap();
        app.panel = Panel::Report {
            title: "Metadata Diff".into(),
            body: "stale report".into(),
            action: ReportAction::MetadataDiff,
        };
        domain_events::entity_changed(
            &fixture.project.id,
            DomainEntity::Project,
            &fixture.project.id,
            NotificationAction::Updated,
        );
        app.poll().unwrap();
        assert!(matches!(app.panel, Panel::Report { .. }));
        assert!(!matches!(&app.panel, Panel::Report { body, .. } if body == "stale report"));
    }

    #[test]
    fn settings_are_typed_saved_and_server_locale_relocalizes_every_session() {
        let fixture = Fixture::new();
        let mut app = fixture.app(false);
        assert_eq!(
            SettingSection::ALL,
            [
                SettingSection::Project,
                SettingSection::Editor,
                SettingSection::Content,
                SettingSection::Ai,
                SettingSection::Technology,
                SettingSection::Publishing,
                SettingSection::Data,
                SettingSection::Mcp,
            ]
        );
        let removed_prefix = ["style", "."].concat();
        for section in SettingSection::ALL {
            let fields = app.load_setting_fields(section).unwrap();
            assert!(
                fields
                    .iter()
                    .all(|field| !field.key.starts_with(&removed_prefix))
            );
            assert!(fields.iter().all(|field| field.label != field.key));
        }
        app.set_view(TuiView::Settings).unwrap();
        app.selected_index = 2;
        app.open_selected().unwrap();
        app.panel_index = 2;
        app.handle_input(TuiInput::plain(TuiKey::Enter)).unwrap();
        app.handle_input(TuiInput::ctrl(TuiKey::Char('s'))).unwrap();
        assert_eq!(
            engine::settings::get(fixture.db().conn(), "editor.wrap_long_lines")
                .unwrap()
                .as_deref(),
            Some("false")
        );
        app.open_settings(SettingSection::Project).unwrap();
        assert_eq!(
            app.settings_fields
                .iter()
                .find(|field| field.key == "project.name")
                .unwrap()
                .label,
            bds_core::i18n::translate(UiLocale::En, "settings.projectName")
        );
        engine::settings::set(fixture.db().conn(), engine::settings::UI_LANGUAGE_KEY, "de")
            .unwrap();
        app.poll().unwrap();
        assert_eq!(app.locale(), UiLocale::De);
        assert!(rendered_text(&app, 70, 18).contains("Einstellungen"));
        assert_eq!(
            app.settings_fields
                .iter()
                .find(|field| field.key == "project.name")
                .unwrap()
                .label,
            bds_core::i18n::translate(UiLocale::De, "settings.projectName")
        );
        app.save_settings().unwrap();
        assert_eq!(
            app.status,
            bds_core::i18n::translate(UiLocale::De, "tui.settingsSaved")
        );
        assert_eq!(fixture.app(true).locale(), UiLocale::De);
    }

    #[test]
    fn project_content_ai_and_publishing_settings_use_shared_backends() {
        let fixture = Fixture::new();
        let mut app = fixture.app(false);

        app.open_settings(SettingSection::Project).unwrap();
        app.settings_fields
            .iter_mut()
            .find(|field| field.key == "project.name")
            .unwrap()
            .value = "Renamed Terminal Blog".into();
        app.save_settings().unwrap();
        assert_eq!(
            bds_core::db::queries::project::get_project_by_id(
                fixture.db().conn(),
                &fixture.project.id
            )
            .unwrap()
            .name,
            "Renamed Terminal Blog"
        );
        assert_eq!(
            engine::meta::read_project_json(&fixture.data_dir)
                .unwrap()
                .name,
            "Renamed Terminal Blog"
        );

        app.open_settings(SettingSection::Project).unwrap();
        app.settings_fields
            .iter_mut()
            .find(|field| field.key == "meta.max_posts_per_page")
            .unwrap()
            .value = "75".into();
        app.settings_fields
            .iter_mut()
            .find(|field| field.key == "meta.blog_languages")
            .unwrap()
            .value = "de, en, de".into();
        app.save_settings().unwrap();
        let metadata = engine::meta::read_project_json(&fixture.data_dir).unwrap();
        assert_eq!(metadata.max_posts_per_page, 75);
        assert_eq!(metadata.blog_languages, ["de", "en"]);

        let mut remote = fixture.app(true);
        remote.open_settings(SettingSection::Publishing).unwrap();
        remote
            .settings_fields
            .iter_mut()
            .find(|field| field.key == "publishing.ssh_host")
            .unwrap()
            .value = "example.net".into();
        remote
            .settings_fields
            .iter_mut()
            .find(|field| field.key == "publishing.ssh_mode")
            .unwrap()
            .value = "rsync".into();
        remote.save_settings().unwrap();
        let publishing = engine::meta::read_publishing_json(&fixture.data_dir).unwrap();
        assert_eq!(publishing.ssh_host.as_deref(), Some("example.net"));
        assert_eq!(publishing.ssh_mode, SshMode::Rsync);

        remote.open_settings(SettingSection::Ai).unwrap();
        remote
            .settings_fields
            .iter_mut()
            .find(|field| field.key == "ai.endpoint.airplane.url")
            .unwrap()
            .value = "http://127.0.0.1:11434/v1".into();
        remote
            .settings_fields
            .iter_mut()
            .find(|field| field.key == "ai.endpoint.airplane.model")
            .unwrap()
            .value = "local-model".into();
        remote.save_settings().unwrap();
        let ai = engine::ai::load_ai_settings(fixture.db().conn(), false).unwrap();
        assert_eq!(ai.airplane.endpoint.model, "local-model");
    }

    #[test]
    fn tags_manage_colour_template_delete_sync_and_merge_use_tag_engine() {
        let fixture = Fixture::new();
        let db = fixture.db();
        let one = engine::tag::create_tag(
            db.conn(),
            &fixture.data_dir,
            &fixture.project.id,
            "one",
            None,
        )
        .unwrap();
        let two = engine::tag::create_tag(
            db.conn(),
            &fixture.data_dir,
            &fixture.project.id,
            "two",
            None,
        )
        .unwrap();
        let mut app = fixture.app(false);
        app.panel = Panel::Tags(TagSection::Merge);
        app.focus = Focus::Editor;
        app.panel_index = 0;
        app.toggle_tag_mark();
        app.panel_index = 1;
        app.toggle_tag_mark();
        app.merge_marked_tags().unwrap();
        assert_eq!(app.tags().unwrap().len(), 1);
        app.panel_index = 0;
        app.cycle_tag_color().unwrap();
        assert!(app.tags().unwrap()[0].color.is_some());
        app.sync_tags().unwrap();
        assert!(fixture.data_dir.join("meta/tags.json").is_file());
        assert!(
            app.tags()
                .unwrap()
                .iter()
                .any(|tag| tag.id == one.id || tag.id == two.id)
        );
    }

    #[test]
    fn command_reports_run_as_tasks_and_only_open_after_completion() {
        let fixture = Fixture::new();
        let mut app = fixture.app(false);
        app.run_command("metadata-diff").unwrap();
        assert!(fixture.host.tasks().running_count() + fixture.host.tasks().pending_count() >= 1);
        wait_for(&mut app, |app| {
            matches!(
                app.panel,
                Panel::Report {
                    action: ReportAction::MetadataDiff,
                    ..
                }
            )
        });
        assert_eq!(app.status, app.tr("tui.metadataDiffComplete"));
        let report = engine::metadata_diff::compute_metadata_diff(
            fixture.db().conn(),
            &fixture.data_dir,
            &fixture.project.id,
        )
        .unwrap();
        let applied = (report.diffs.len() + report.orphans.len()).to_string();
        app.handle_input(TuiInput::plain(TuiKey::Enter)).unwrap();
        assert_eq!(
            app.status,
            app.tr_with("tui.metadataChangesApplied", &[("count", &applied)])
        );
        assert!(matches!(app.panel, Panel::Welcome));
    }

    #[test]
    fn command_palette_lists_filters_selects_and_marks_gui_only_commands() {
        let fixture = Fixture::new();
        let mut app = fixture.app(false);
        app.handle_input(TuiInput::plain(TuiKey::Char(':')))
            .unwrap();
        let listed = rendered_text(&app, 90, 24);
        assert!(listed.contains(&app.tr("tui.commandMetadataDiff")));
        assert!(listed.contains("[GUI]"));
        let first = app.prompt.as_ref().unwrap().candidates[0].clone();
        app.handle_input(TuiInput::plain(TuiKey::Down)).unwrap();
        assert_ne!(app.prompt.as_ref().unwrap().candidates[0], first);
        app.handle_input(TuiInput::plain(TuiKey::Esc)).unwrap();
        app.handle_input(TuiInput::plain(TuiKey::Char(':')))
            .unwrap();
        let query = app
            .tr("tui.commandMetadataDiff")
            .chars()
            .take(4)
            .collect::<String>();
        type_text(&mut app, &query);
        app.handle_input(TuiInput::plain(TuiKey::Enter)).unwrap();
        wait_for(&mut app, |app| {
            matches!(
                app.panel,
                Panel::Report {
                    action: ReportAction::MetadataDiff,
                    ..
                }
            )
        });
    }

    #[test]
    fn projects_overlay_path_validation_and_completion_are_directory_only() {
        let fixture = Fixture::new();
        let folder = fixture._root.path().join("second-blog");
        fs::create_dir_all(&folder).unwrap();
        fs::write(fixture._root.path().join("second-file"), "x").unwrap();
        let mut prompt = Prompt {
            kind: PromptKind::ProjectPath,
            value: fixture._root.path().join("sec").display().to_string(),
            candidates: Vec::new(),
        };
        complete_path(&mut prompt);
        assert!(prompt.value.ends_with("second-blog/"));
        assert!(!prompt.candidates.iter().any(|value| value == "second-file"));
        let mut app = fixture.app(false);
        app.handle_input(TuiInput::plain(TuiKey::Char('p')))
            .unwrap();
        app.handle_input(TuiInput::plain(TuiKey::Char('o')))
            .unwrap();
        assert!(matches!(
            app.prompt,
            Some(Prompt {
                kind: PromptKind::ProjectPath,
                ..
            })
        ));
        app.handle_input(TuiInput::plain(TuiKey::Esc)).unwrap();
        assert!(matches!(app.overlay, Some(Overlay::Projects { .. })));
        app.open_project_path(folder.to_str().unwrap()).unwrap();
        assert_eq!(app.project.as_ref().unwrap().name, "second-blog");
    }

    #[test]
    fn local_and_remote_apps_share_persistence_but_identify_their_transport() {
        let fixture = Fixture::new();
        let mut local = fixture.app(false);
        let mut remote = fixture.app(true);
        local
            .handle_input(TuiInput::plain(TuiKey::Char('n')))
            .unwrap();
        type_text(&mut local, "shared");
        local
            .handle_input(TuiInput::ctrl(TuiKey::Char('s')))
            .unwrap();
        remote.poll().unwrap();
        assert!(
            remote
                .items
                .iter()
                .any(|item| matches!(item, SidebarItem::Post(_, title) if title == &remote.tr("tui.untitled")))
        );
        let local_render = rendered_text(&local, 60, 15);
        let remote_render = rendered_text(&remote, 60, 15);
        assert!(
            local_render.contains(&local.tr("tui.local")),
            "{local_render}"
        );
        assert!(
            remote_render.contains(&remote.tr("tui.remote")),
            "{remote_render}"
        );
    }

    #[test]
    fn pty_decoder_handles_split_escape_utf8_control_and_resize_renderer() {
        let mut decoder = InputDecoder::default();
        assert!(decoder.push(b"\x1b").is_empty());
        assert_eq!(decoder.push(b"[A"), vec![TuiInput::plain(TuiKey::Up)]);
        assert_eq!(
            decoder.push("é".as_bytes()),
            vec![TuiInput::plain(TuiKey::Char('é'))]
        );
        assert_eq!(decoder.push(&[19]), vec![TuiInput::ctrl(TuiKey::Char('s'))]);
        assert!(decoder.push(b"\x1b").is_empty());
        assert_eq!(decoder.flush(), vec![TuiInput::plain(TuiKey::Esc)]);
        let fixture = Fixture::new();
        let mut app = fixture.app(true);
        let small = render_ansi(&mut app, 40, 10);
        let large = render_ansi(&mut app, 100, 30);
        assert!(small.starts_with(b"\x1b[?25l\x1b[2J\x1b[H"));
        assert!(large.len() > small.len());
        assert!(large.ends_with(b"\x1b[0m"));
    }

    #[test]
    fn unsaved_quit_requires_confirmation_and_clean_quit_does_not() {
        let fixture = Fixture::new();
        let mut app = fixture.app(false);
        app.handle_input(TuiInput::plain(TuiKey::Char('n')))
            .unwrap();
        type_text(&mut app, "dirty");
        app.handle_input(TuiInput::ctrl(TuiKey::Char('q'))).unwrap();
        assert!(matches!(app.overlay, Some(Overlay::ConfirmDiscard)));
        app.handle_input(TuiInput::plain(TuiKey::Char('n')))
            .unwrap();
        assert!(!app.should_quit());
        app.handle_input(TuiInput::ctrl(TuiKey::Char('s'))).unwrap();
        app.handle_input(TuiInput::ctrl(TuiKey::Char('q'))).unwrap();
        assert!(app.should_quit());
    }

    #[test]
    fn validation_and_ai_gating_are_reported_without_ending_the_session() {
        let fixture = Fixture::new();
        let mut app = fixture.app(false);
        app.open_settings(SettingSection::Project).unwrap();
        app.settings_fields
            .iter_mut()
            .find(|field| field.key == "meta.max_posts_per_page")
            .unwrap()
            .value = "0".into();
        app.handle_input(TuiInput::ctrl(TuiKey::Char('s'))).unwrap();
        assert!(app.status.contains("1..500"));
        assert!(!app.should_quit());

        app.set_view(TuiView::Posts).unwrap();
        app.handle_input(TuiInput::plain(TuiKey::Char('n')))
            .unwrap();
        app.handle_input(TuiInput::ctrl(TuiKey::Char('g'))).unwrap();
        assert!(!app.status.is_empty());
        assert!(app.output.is_empty());
        assert!(!app.should_quit());
    }

    #[test]
    fn ai_quick_action_uses_the_active_airplane_endpoint() {
        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
        let endpoint = format!("http://{}", listener.local_addr().unwrap());
        let server = thread::spawn(move || {
            let (mut stream, _) = listener.accept().unwrap();
            let mut request = [0_u8; 8192];
            let size = stream.read(&mut request).unwrap();
            assert!(
                String::from_utf8_lossy(&request[..size])
                    .starts_with("POST /v1/chat/completions HTTP/1.1")
            );
            let body = r#"{"choices":[{"message":{"content":"{\"title\":\"Sharper title\",\"excerpt\":\"Brief\",\"slug\":\"sharper-title\"}"}}]}"#;
            write!(
                stream,
                "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
                body.len(),
                body
            )
            .unwrap();
        });
        let fixture = Fixture::new();
        engine::ai::save_endpoint(
            fixture.db().conn(),
            &AiEndpointConfig {
                kind: AiEndpointKind::Airplane,
                url: endpoint,
                model: "local-test".into(),
                api_key: None,
            },
        )
        .unwrap();
        let mut app = fixture.app(true);
        app.airplane = true;
        app.handle_input(TuiInput::plain(TuiKey::Char('n')))
            .unwrap();
        type_text(&mut app, "draft body");
        app.handle_input(TuiInput::ctrl(TuiKey::Char('g'))).unwrap();
        server.join().unwrap();
        assert!(app.output.last().unwrap().contains("Sharper title"));
        assert_eq!(app.status, app.tr("tui.aiSuggestionsAdded"));
    }

    #[test]
    fn image_media_opens_as_a_colour_halfblock_preview_that_survives_resize() {
        let fixture = Fixture::new();
        let source = fixture._root.path().join("preview.png");
        image::RgbImage::from_pixel(12, 8, image::Rgb([220, 40, 80]))
            .save(&source)
            .unwrap();
        let db = fixture.db();
        let media = engine::media::import_media(
            db.conn(),
            &fixture.data_dir,
            &fixture.project.id,
            &source,
            "preview.png",
            Some("Preview"),
            None,
            None,
            None,
            None,
            Vec::new(),
        )
        .unwrap();
        let mut app = fixture.app(true);
        app.open_media(&media.id).unwrap();
        assert!(app.image_protocol.is_some());
        let before = rendered_text(&app, 60, 18);
        app.resize(100, 30);
        let after = rendered_text(&app, 100, 30);
        assert!(before.contains('▀'));
        assert!(after.contains('▀'));
        assert!(after.len() > before.len());
    }

    #[test]
    fn git_actions_in_a_non_repository_report_instead_of_starting_work() {
        let fixture = Fixture::new();
        let mut app = fixture.app(false);
        app.set_view(TuiView::Git).unwrap();
        app.git_pull().unwrap();
        assert_eq!(app.status, app.tr("git.notRepository"));
        app.git_push().unwrap();
        assert_eq!(fixture.host.tasks().running_count(), 0);
    }

    #[test]
    fn local_git_commit_is_visible_in_the_remote_terminal_session() {
        let fixture = Fixture::new();
        git(&fixture.data_dir, &["init", "-b", "master"]);
        git(&fixture.data_dir, &["config", "user.name", "TUI Test"]);
        git(
            &fixture.data_dir,
            &["config", "user.email", "tui@example.invalid"],
        );
        fs::write(fixture.data_dir.join("terminal-change.txt"), "shared").unwrap();

        let mut local = fixture.app(false);
        local.set_view(TuiView::Git).unwrap();
        assert!(local.items.iter().any(
            |item| matches!(item, SidebarItem::GitFile(path, _) if path == "terminal-change.txt")
        ));
        local
            .handle_input(TuiInput::plain(TuiKey::Char('c')))
            .unwrap();
        type_text(&mut local, "Commit from terminal");
        local.handle_input(TuiInput::plain(TuiKey::Enter)).unwrap();
        wait_for(&mut local, |app| {
            app.items.iter().any(
                |item| matches!(item, SidebarItem::GitCommit(_, subject) if subject.contains("Commit from terminal")),
            )
        });

        let mut remote = fixture.app(true);
        remote.set_view(TuiView::Git).unwrap();
        assert!(remote.items.iter().any(
            |item| matches!(item, SidebarItem::GitCommit(_, subject) if subject.contains("Commit from terminal"))
        ));
    }
}
