use std::fmt::Write as _;
use std::net::IpAddr;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};

use anyhow::{Context, Result, anyhow, bail};
use bds_core::db::{Database, DbConnection};
use bds_core::engine::{self, cli_sync, domain_events};
use bds_core::i18n::{UiLocale, detect_os_locale, normalize_language, translate, translate_with};
use bds_core::model::{DomainEntity, NotificationAction, Project, ScriptKind};
use bds_core::scripting::{CoreHost, ExecutionControl, ExecutionKind, execute_many_with_host};
use clap::{Args, Parser, Subcommand, ValueEnum};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};

#[derive(Debug, Parser)]
#[command(
    name = "bds-cli",
    version,
    about = "RuDS workspace automation using the desktop application's shared engines"
)]
pub struct Cli {
    /// Print a stable JSON result envelope.
    #[arg(long, global = true)]
    pub json: bool,

    /// Gate network activity and route automatic AI work to the local endpoint.
    #[arg(long, global = true)]
    pub airplane: bool,

    #[command(subcommand)]
    pub command: Command,
}

#[derive(Debug, Subcommand)]
pub enum Command {
    /// Rebuild the cache database from the active project's files.
    Rebuild {
        #[arg(long)]
        incremental: bool,
    },
    /// Run one derived-data repair task.
    Repair { part: RepairPart },
    /// Render the generated site.
    Render {
        #[arg(long, conflicts_with = "force")]
        incremental: bool,
        #[arg(long, conflicts_with = "incremental")]
        force: bool,
    },
    /// Upload generated HTML, thumbnails, and media using publishing settings.
    Upload,
    /// Push the active project repository to origin.
    Push,
    /// Fast-forward pull the active project and reconcile its cache database.
    Pull,
    /// Create a post from flags or JSON stdin.
    Post(PostArgs),
    /// Import and optionally AI-enrich one image.
    Media {
        file: PathBuf,
        #[arg(long)]
        language: Option<String>,
    },
    /// Create a post and import/link all supplied images.
    Gallery(GalleryArgs),
    /// Read or update global application settings.
    Config {
        #[command(subcommand)]
        command: ConfigCommand,
    },
    /// List, add, or switch projects in the shared registry.
    Project {
        #[command(subcommand)]
        command: ProjectCommand,
    },
    /// Start the authenticated headless SSH server.
    Server(ServerArgs),
    /// Start interactive TUI mode (normally intercepted by the packaged launcher).
    Tui,
    /// Run an enabled utility Lua script from the active project.
    Lua {
        script: String,
        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
        args: Vec<String>,
    },
    /// Install this packaged CLI in ~/.local/bin.
    Install,
}

#[derive(Debug, Args, Default)]
pub struct ServerArgs {
    /// SSH listen address. Defaults to loopback; external access must be explicit.
    #[arg(long)]
    pub bind: Option<IpAddr>,
    /// SSH listen port.
    #[arg(long)]
    pub port: Option<u16>,
    /// Application database path.
    #[arg(long)]
    pub database: Option<PathBuf>,
    /// Private application data directory containing SSH key material.
    #[arg(long)]
    pub data_dir: Option<PathBuf>,
}

#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum RepairPart {
    PostLinks,
    MediaLinks,
    Thumbnails,
    Embeddings,
    Search,
}

#[derive(Debug, Args, Default)]
pub struct PostArgs {
    /// Read the post object as JSON from standard input.
    #[arg(long)]
    pub stdin: bool,
    /// Skip automatic translation after creation.
    #[arg(long)]
    pub no_translate: bool,
    /// Post title (required unless --stdin is used).
    #[arg(long)]
    pub title: Option<String>,
    /// Markdown post body.
    #[arg(long)]
    pub content: Option<String>,
    /// Short post excerpt.
    #[arg(long)]
    pub excerpt: Option<String>,
    /// Author name.
    #[arg(long)]
    pub author: Option<String>,
    /// BCP 47 source language; detected when omitted.
    #[arg(long)]
    pub language: Option<String>,
    /// Post template slug.
    #[arg(long)]
    pub template: Option<String>,
    /// Comma-separated tags.
    #[arg(long, value_delimiter = ',')]
    pub tags: Vec<String>,
    /// Comma-separated categories.
    #[arg(long, value_delimiter = ',')]
    pub categories: Vec<String>,
}

#[derive(Debug, Args, Default)]
pub struct GalleryArgs {
    #[command(flatten)]
    pub post: PostArgs,
    /// Image files to import and link to the new post.
    #[arg(value_name = "IMAGE")]
    pub images: Vec<PathBuf>,
}

#[derive(Debug, Subcommand)]
pub enum ConfigCommand {
    Get { key: String },
    Set { key: String, value: String },
    List,
}

#[derive(Debug, Subcommand)]
pub enum ProjectCommand {
    List,
    Add {
        path: PathBuf,
        #[arg(long)]
        name: Option<String>,
    },
    Switch {
        project: String,
    },
}

#[derive(Debug, Clone)]
pub struct RunContext {
    pub database_path: PathBuf,
    pub stdin: String,
    pub home_dir: PathBuf,
    pub executable_path: PathBuf,
}

impl RunContext {
    pub fn system() -> Self {
        Self {
            database_path: bds_core::util::application_database_path(),
            stdin: String::new(),
            home_dir: dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")),
            executable_path: std::env::current_exe().unwrap_or_else(|_| PathBuf::from("bds-cli")),
        }
    }
}

#[derive(Debug, Clone, PartialEq)]
pub struct CliProgress {
    pub value: f32,
    pub message: String,
}

pub type ProgressSink = Arc<dyn Fn(CliProgress) + Send + Sync>;

#[derive(Clone)]
struct ProgressReporter {
    history: Arc<Mutex<Vec<String>>>,
    sink: Option<ProgressSink>,
    locale: UiLocale,
}

impl ProgressReporter {
    fn new(sink: Option<ProgressSink>) -> Self {
        Self {
            history: Arc::new(Mutex::new(Vec::new())),
            sink,
            locale: detect_os_locale(),
        }
    }

    fn with_locale(mut self, locale: UiLocale) -> Self {
        self.locale = locale;
        self
    }

    fn report(&self, value: f32, message: String) {
        let value = if value.is_finite() {
            value.clamp(0.0, 1.0)
        } else {
            0.0
        };
        let line = format!("[{:>3}%] {message}", (value * 100.0).round() as i32);
        let mut history = self
            .history
            .lock()
            .unwrap_or_else(|poisoned| poisoned.into_inner());
        if history.last() == Some(&line) {
            return;
        }
        history.push(line);
        drop(history);
        if let Some(sink) = &self.sink {
            sink(CliProgress { value, message });
        }
    }

    fn history(&self) -> Vec<String> {
        self.history
            .lock()
            .unwrap_or_else(|poisoned| poisoned.into_inner())
            .clone()
    }
}

pub struct HumanProgress {
    terminal: bool,
    active: bool,
}

impl HumanProgress {
    pub fn new(terminal: bool) -> Self {
        Self {
            terminal,
            active: false,
        }
    }

    pub fn write(
        &mut self,
        progress: &CliProgress,
        mut writer: impl std::io::Write,
    ) -> std::io::Result<()> {
        let percent = (progress.value * 100.0).round() as usize;
        if self.terminal {
            const WIDTH: usize = 24;
            let filled = percent * WIDTH / 100;
            write!(
                writer,
                "\r\x1b[2K{}: {:>3}%|{}{}|",
                progress.message,
                percent,
                "█".repeat(filled),
                " ".repeat(WIDTH - filled),
            )?;
            writer.flush()?;
        } else {
            writeln!(writer, "[{:>3}%] {}", percent, progress.message)?;
        }
        self.active = true;
        Ok(())
    }

    pub fn finish(&mut self, mut writer: impl std::io::Write) -> std::io::Result<()> {
        if self.terminal && self.active {
            writeln!(writer)?;
            writer.flush()?;
        }
        self.active = false;
        Ok(())
    }
}

#[derive(Debug, Serialize)]
pub struct CommandOutput {
    pub command: &'static str,
    pub message: String,
    pub data: Value,
    pub progress: Vec<String>,
    pub notices: Vec<String>,
    #[serde(skip)]
    json: bool,
}

impl std::fmt::Display for CommandOutput {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        if self.json {
            let envelope = json!({
                "ok": true,
                "command": self.command,
                "message": self.message,
                "data": self.data,
                "progress": self.progress,
                "notices": self.notices,
            });
            return write!(
                formatter,
                "{}",
                serde_json::to_string(&envelope).map_err(|_| std::fmt::Error)?
            );
        }
        for line in &self.progress {
            writeln!(formatter, "{line}")?;
        }
        for notice in &self.notices {
            writeln!(formatter, "Notice: {notice}")?;
        }
        formatter.write_str(&self.message)
    }
}

#[derive(Debug, Clone, Deserialize)]
struct PostInput {
    title: String,
    #[serde(default)]
    content: String,
    excerpt: Option<String>,
    author: Option<String>,
    language: Option<String>,
    template: Option<String>,
    #[serde(default, deserialize_with = "string_list")]
    tags: Vec<String>,
    #[serde(default, deserialize_with = "string_list")]
    categories: Vec<String>,
    #[serde(default)]
    images: Vec<PathBuf>,
}

fn string_list<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    let value = Value::deserialize(deserializer)?;
    Ok(match value {
        Value::Array(values) => values
            .into_iter()
            .filter_map(|value| value.as_str().map(str::to_string))
            .collect(),
        Value::String(value) => split_list(&value),
        _ => Vec::new(),
    })
}

pub fn run(cli: Cli, context: RunContext) -> Result<CommandOutput> {
    run_with_progress(cli, context, None)
}

pub fn run_with_progress(
    cli: Cli,
    context: RunContext,
    progress_sink: Option<ProgressSink>,
) -> Result<CommandOutput> {
    let command_name = command_name(&cli.command);
    let progress = ProgressReporter::new(progress_sink);
    let mut output = execute(cli.command, &context, cli.airplane, &progress)?;
    output.command = command_name;
    output.json = cli.json;
    output.progress = progress.history();
    Ok(output)
}

fn execute(
    command: Command,
    context: &RunContext,
    airplane: bool,
    progress: &ProgressReporter,
) -> Result<CommandOutput> {
    if matches!(command, Command::Install) {
        return install_launcher(context);
    }
    if matches!(command, Command::Tui | Command::Server(_)) {
        bail!("server and TUI modes are started directly by the native bds-cli entry point");
    }

    let db = open_database(&context.database_path)?;
    let locale = engine::settings::ui_language(db.conn())
        .ok()
        .flatten()
        .map_or_else(detect_os_locale, |language| normalize_language(&language));
    let progress = progress.clone().with_locale(locale);
    match command {
        Command::Rebuild { incremental } => rebuild(&db, incremental, &progress),
        Command::Repair { part } => repair(&db, part),
        Command::Render { incremental, force } => render(&db, incremental, force, &progress),
        Command::Upload => upload(&db, airplane),
        Command::Push => git_push(&db, airplane),
        Command::Pull => git_pull(&db, airplane, &progress),
        Command::Post(args) => create_post(&db, args, &context.stdin, airplane),
        Command::Media { file, language } => import_media(&db, &file, language, airplane),
        Command::Gallery(args) => create_gallery(&db, args, &context.stdin, airplane),
        Command::Config { command } => config(&db, command),
        Command::Project { command } => project(&db, command),
        Command::Lua { script, args } => run_lua(&db, &script, &args, airplane),
        Command::Server(_) | Command::Tui | Command::Install => unreachable!(),
    }
}

fn open_database(path: &Path) -> Result<Database> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).with_context(|| {
            format!(
                "could not create application data directory {}",
                parent.display()
            )
        })?;
    }
    let db = Database::open(path).with_context(|| format!("could not open {}", path.display()))?;
    db.migrate()
        .map_err(|error| anyhow!("could not migrate the shared database: {error}"))?;
    engine::search::prepare_search_index(db.conn())
        .context("could not prepare the shared search index")?;
    Ok(db)
}

fn active_project(db: &Database) -> Result<(Project, PathBuf)> {
    let project = engine::project::get_active_project(db.conn())?
        .ok_or_else(|| anyhow!("no active project selected; use: project switch <project>"))?;
    let data_dir = project
        .data_path
        .as_deref()
        .map(PathBuf::from)
        .ok_or_else(|| anyhow!("active project {} has no portable data path", project.id))?;
    if !data_dir.is_dir() {
        bail!(
            "active project folder does not exist: {}",
            data_dir.display()
        );
    }
    Ok((project, data_dir))
}

fn rebuild(db: &Database, incremental: bool, progress: &ProgressReporter) -> Result<CommandOutput> {
    let (project, data_dir) = active_project(db)?;
    if incremental {
        let message = translate(progress.locale, "metadataDiff.running");
        progress.report(0.0, message.clone());
        let report = cli_sync::run_cli_mutation(db.conn(), || {
            let report = engine::rebuild::rebuild_incremental_with_progress(
                db.conn(),
                &data_dir,
                &project.id,
                |value| {
                    progress.report(value, message.clone());
                    true
                },
            )?;
            if report.differences_applied > 0 || report.orphans_imported > 0 {
                emit_bulk(&project.id);
            }
            Ok(report)
        })?;
        return Ok(output(
            "Applied incremental filesystem changes",
            json!({
                "differences_applied": report.differences_applied,
                "orphans_imported": report.orphans_imported,
                "orphans_failed": report.orphans_failed,
            }),
        ));
    }

    let report = cli_sync::run_cli_mutation(db.conn(), || {
        let progress = progress.clone();
        let report = engine::rebuild::rebuild_from_filesystem_with_progress(
            db.conn(),
            &data_dir,
            &project.id,
            Some(Arc::new(move |value, event| {
                progress.report(value, event.localized(progress.locale));
                true
            })),
        )?;
        emit_bulk(&project.id);
        Ok(report)
    })?;
    Ok(output(
        "Rebuild complete",
        json!({
            "posts_created": report.posts_created,
            "posts_updated": report.posts_updated,
            "translations_created": report.translations_created,
            "translations_updated": report.translations_updated,
            "media_created": report.media_created,
            "media_updated": report.media_updated,
            "templates_created": report.templates_created,
            "templates_updated": report.templates_updated,
            "scripts_created": report.scripts_created,
            "scripts_updated": report.scripts_updated,
            "thumbnails_generated": report.thumbnails_generated,
            "thumbnail_media_failed": report.thumbnail_media_failed,
        }),
    ))
}

fn repair(db: &Database, part: RepairPart) -> Result<CommandOutput> {
    let (project, data_dir) = active_project(db)?;
    match part {
        RepairPart::PostLinks => {
            let links = cli_sync::run_cli_mutation(db.conn(), || {
                let links = engine::post::rebuild_all_links(db.conn(), &data_dir, &project.id)?;
                domain_events::entity_changed(
                    &project.id,
                    DomainEntity::Post,
                    "*",
                    NotificationAction::Updated,
                );
                Ok(links)
            })?;
            Ok(output("Post links rebuilt", json!({"links": links})))
        }
        RepairPart::MediaLinks => {
            let report = cli_sync::run_cli_mutation(db.conn(), || {
                let report = engine::media::rebuild_media_links(db.conn(), &data_dir, &project.id)?;
                domain_events::entity_changed(
                    &project.id,
                    DomainEntity::Media,
                    "*",
                    NotificationAction::Updated,
                );
                Ok(report)
            })?;
            Ok(output(
                "Media links rebuilt",
                json!({"links": report.links}),
            ))
        }
        RepairPart::Thumbnails => {
            let report = cli_sync::run_cli_mutation(db.conn(), || {
                let report = engine::media::regenerate_missing_thumbnails(
                    db.conn(),
                    &data_dir,
                    &project.id,
                )?;
                domain_events::entity_changed(
                    &project.id,
                    DomainEntity::Media,
                    "*",
                    NotificationAction::Updated,
                );
                Ok(report)
            })?;
            Ok(output(
                "Missing thumbnails regenerated",
                json!({
                    "media_processed": report.media_processed,
                    "media_repaired": report.media_repaired,
                    "media_failed": report.media_failed,
                    "thumbnails_generated": report.thumbnails_generated,
                }),
            ))
        }
        RepairPart::Search => {
            let report = cli_sync::run_cli_mutation(db.conn(), || {
                let report = engine::search::reindex_project(db.conn(), &project.id, None)?;
                domain_events::entity_changed(
                    &project.id,
                    DomainEntity::Post,
                    "*",
                    NotificationAction::Updated,
                );
                domain_events::entity_changed(
                    &project.id,
                    DomainEntity::Media,
                    "*",
                    NotificationAction::Updated,
                );
                Ok(report)
            })?;
            Ok(output(
                "Search text reindexed",
                json!({"posts_indexed": report.posts_indexed, "media_indexed": report.media_indexed}),
            ))
        }
        RepairPart::Embeddings => {
            let metadata = engine::meta::read_project_json(&data_dir)?;
            if metadata.semantic_similarity_enabled {
                let service = engine::embedding::EmbeddingService::production(db.conn(), &data_dir);
                let indexed = service.reindex_all(&project.id)?;
                service.flush_project(&project.id)?;
                return Ok(output(
                    "Embedding index rebuilt",
                    json!({"rebuilt": indexed.len(), "disabled": false}),
                ));
            }
            Ok(output(
                "Embedding repair skipped because semantic similarity is disabled",
                json!({"rebuilt": 0, "disabled": true}),
            ))
        }
    }
}

fn render(
    db: &Database,
    incremental: bool,
    force: bool,
    progress: &ProgressReporter,
) -> Result<CommandOutput> {
    let (project, data_dir) = active_project(db)?;
    let metadata = engine::meta::read_project_json(&data_dir)?;
    let posts = published_sources(db.conn(), &data_dir, &project.id)?;
    let output_dir = data_dir.join("html");
    std::fs::create_dir_all(&output_dir)?;
    if incremental {
        let validating = translate(progress.locale, "siteValidation.running");
        progress.report(0.0, validating.clone());
        let validation = engine::validate_site::validate_site_with_progress(
            db.conn(),
            &data_dir,
            &project.id,
            |current, total| {
                progress.report(
                    0.25 * current as f32 / total.max(1) as f32,
                    validating.clone(),
                );
                true
            },
        )?;
        let sections = engine::generation::sections_from_validation_report(&validation, &metadata);
        let report = engine::generation::apply_validation_sections_with_progress(
            db.conn(),
            &output_dir,
            &project.id,
            &metadata,
            &posts,
            &validation,
            &sections,
            |current, total, url| report_render_progress(progress, current, total, url, 0.25, 1.0),
        )?;
        progress.report(1.0, translate(progress.locale, "engine.generationComplete"));
        return Ok(output(
            "Validation differences applied",
            json!({"written": report.written_paths.len(), "skipped": report.skipped_paths.len(), "deleted": report.deleted_paths.len()}),
        ));
    }
    let language = metadata.main_language.as_deref().unwrap_or("en");
    progress.report(
        0.0,
        translate(progress.locale, "engine.generateSiteStarted"),
    );
    let report = if force {
        engine::generation::generate_starter_site_forced_with_progress(
            db.conn(),
            &output_dir,
            &project.id,
            &metadata,
            &posts,
            language,
            |current, total, url| report_render_progress(progress, current, total, url, 0.0, 1.0),
        )?
    } else {
        engine::generation::generate_starter_site_with_progress(
            db.conn(),
            &output_dir,
            &project.id,
            &metadata,
            &posts,
            language,
            |current, total, url| report_render_progress(progress, current, total, url, 0.0, 1.0),
        )?
    };
    progress.report(1.0, translate(progress.locale, "engine.generationComplete"));
    Ok(output(
        "Site rendered",
        json!({"written": report.written_paths.len(), "skipped": report.skipped_paths.len(), "deleted": report.deleted_paths.len(), "force": force}),
    ))
}

fn report_render_progress(
    progress: &ProgressReporter,
    current: usize,
    total: usize,
    url: &str,
    start: f32,
    end: f32,
) {
    progress.report(
        start + (end - start) * current as f32 / total.max(1) as f32,
        translate_with(
            progress.locale,
            "engine.renderingPage",
            &[
                ("url", url),
                ("current", &current.to_string()),
                ("total", &total.to_string()),
            ],
        ),
    );
}

fn published_sources(
    conn: &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 upload(db: &Database, airplane: bool) -> Result<CommandOutput> {
    if airplane {
        bail!("upload is unavailable in airplane mode");
    }
    let (_project, data_dir) = active_project(db)?;
    let preferences = engine::meta::read_publishing_json(&data_dir)?;
    let private_cache_dir = db
        .conn()
        .database_path()?
        .parent()
        .map(PathBuf::from)
        .ok_or_else(|| anyhow!("private application directory unavailable"))?;
    let job = engine::publishing::upload_site(
        &data_dir,
        &private_cache_dir,
        &preferences,
        |_current, _total, _kind| {},
    )?;
    Ok(output(
        "Upload complete",
        json!({"targets": job.completed_targets.len()}),
    ))
}

fn git_push(db: &Database, airplane: bool) -> Result<CommandOutput> {
    if airplane {
        bail!("git push is unavailable in airplane mode");
    }
    let (_project, data_dir) = active_project(db)?;
    let result = engine::git::GitEngine::new(data_dir).push(|| false, |_| {})?;
    Ok(output("Pushed", json!({"output": result.output})))
}

fn git_pull(db: &Database, airplane: bool, progress: &ProgressReporter) -> Result<CommandOutput> {
    if airplane {
        bail!("git pull is unavailable in airplane mode");
    }
    let (_project, data_dir) = active_project(db)?;
    let result = engine::git::GitEngine::new(data_dir).pull(|| false, |_| {})?;
    let mut rebuilt = rebuild(db, true, progress)?;
    rebuilt.message = "Pulled and reconciled the cache database".into();
    rebuilt.data["git_output"] = Value::String(result.output);
    Ok(rebuilt)
}

fn create_post(
    db: &Database,
    args: PostArgs,
    stdin: &str,
    airplane: bool,
) -> Result<CommandOutput> {
    let (project, data_dir) = active_project(db)?;
    let no_translate = args.no_translate;
    let mut input = post_input(args, stdin)?;
    let mut notices = Vec::new();
    ensure_language(db.conn(), &mut input, airplane, &mut notices);
    let metadata = engine::meta::read_project_json(&data_dir)?;
    let (post, translation) = cli_sync::run_cli_mutation(db.conn(), || {
        let mut post = engine::post::create_post(
            db.conn(),
            &data_dir,
            &project.id,
            &input.title,
            Some(&input.content),
            input.tags.clone(),
            input.categories.clone(),
            input.author.as_deref(),
            input.language.as_deref(),
            input.template.as_deref(),
        )?;
        if input.excerpt.is_some() {
            post = engine::post::update_post(
                db.conn(),
                &data_dir,
                &post.id,
                None,
                None,
                Some(input.excerpt.as_deref()),
                None,
                None,
                None,
                None,
                None,
                None,
                None,
            )?;
        }
        let translation = if no_translate {
            None
        } else {
            Some(engine::auto_translation::translate_missing_for_post(
                db.conn(),
                &data_dir,
                &post.id,
                metadata.main_language.as_deref().unwrap_or("en"),
                &metadata.blog_languages,
                airplane,
                || false,
            ))
        };
        Ok((post, translation))
    })?;
    translation_notice(translation, &mut notices);
    Ok(CommandOutput {
        command: "post",
        message: format!(
            "Created post {} ({}, {})",
            post.id,
            post.slug,
            post.language.as_deref().unwrap_or("unknown language")
        ),
        data: json!({"id": post.id, "slug": post.slug, "language": post.language}),
        progress: Vec::new(),
        notices,
        json: false,
    })
}

fn import_media(
    db: &Database,
    file: &Path,
    language: Option<String>,
    airplane: bool,
) -> Result<CommandOutput> {
    require_file(file)?;
    let (project, data_dir) = active_project(db)?;
    let metadata = engine::meta::read_project_json(&data_dir)?;
    let language = language
        .or(metadata.main_language.clone())
        .unwrap_or_else(|| "en".into());
    let targets = engine::gallery_import::translation_targets(
        metadata.main_language.as_deref(),
        &metadata.blog_languages,
        &language,
    );
    let ai_available = engine::gallery_import::active_ai_endpoint_configured(db.conn(), airplane);
    let imported = cli_sync::run_cli_mutation(db.conn(), || {
        let original_name = file
            .file_name()
            .and_then(|name| name.to_str())
            .unwrap_or("image");
        let imported = engine::media::import_media(
            db.conn(),
            &data_dir,
            &project.id,
            file,
            original_name,
            None,
            None,
            None,
            None,
            Some(&language),
            Vec::new(),
        )?;
        if ai_available {
            let _ = engine::gallery_import::enrich_imported_image(
                db.conn(),
                &data_dir,
                &imported,
                airplane,
                &targets,
            );
        }
        Ok(imported)
    })?;
    let notices = (!ai_available)
        .then(|| {
            "AI enrichment was not run because the permitted endpoint is not configured".into()
        })
        .into_iter()
        .collect();
    Ok(CommandOutput {
        command: "media",
        message: format!(
            "Imported media {} ({})",
            imported.id, imported.original_name
        ),
        data: json!({"id": imported.id, "file_path": imported.file_path, "language": imported.language}),
        progress: Vec::new(),
        notices,
        json: false,
    })
}

fn create_gallery(
    db: &Database,
    args: GalleryArgs,
    stdin: &str,
    airplane: bool,
) -> Result<CommandOutput> {
    let no_translate = args.post.no_translate;
    let stdin_mode = args.post.stdin;
    let supplied_images = args.images;
    let mut input = post_input(args.post, stdin)?;
    if !stdin_mode {
        input.images = supplied_images;
    }
    if input.images.is_empty() {
        bail!("pass at least one image (or use --stdin with a non-empty images array)");
    }
    for image in &input.images {
        require_file(image)?;
    }
    let (project, data_dir) = active_project(db)?;
    let metadata = engine::meta::read_project_json(&data_dir)?;
    let mut notices = Vec::new();
    ensure_language(db.conn(), &mut input, airplane, &mut notices);
    let post = cli_sync::run_cli_mutation(db.conn(), || {
        let mut post = engine::post::create_post(
            db.conn(),
            &data_dir,
            &project.id,
            &input.title,
            Some(&input.content),
            input.tags.clone(),
            input.categories.clone(),
            input.author.as_deref(),
            input.language.as_deref(),
            input.template.as_deref(),
        )?;
        if input.excerpt.is_some() {
            post = engine::post::update_post(
                db.conn(),
                &data_dir,
                &post.id,
                None,
                None,
                Some(input.excerpt.as_deref()),
                None,
                None,
                None,
                None,
                None,
                None,
                None,
            )?;
        }
        let report = engine::gallery_import::import_gallery_images(
            &db.conn().database_path().map_err(engine::EngineError::Db)?,
            &data_dir,
            &project.id,
            &post.id,
            input.images.clone(),
            input.language.as_deref().unwrap_or("en"),
            airplane,
        );
        for outcome in &report.outcomes {
            if let Ok(imported) = &outcome.result {
                domain_events::entity_changed(
                    &project.id,
                    DomainEntity::Media,
                    &imported.media_id,
                    NotificationAction::Created,
                );
            }
        }
        domain_events::entity_changed(
            &project.id,
            DomainEntity::Post,
            &post.id,
            NotificationAction::Updated,
        );
        if !no_translate {
            let translation = engine::auto_translation::translate_missing_for_post(
                db.conn(),
                &data_dir,
                &post.id,
                metadata.main_language.as_deref().unwrap_or("en"),
                &metadata.blog_languages,
                airplane,
                || false,
            );
            translation_notice(Some(translation), &mut notices);
        }
        let failed = report
            .outcomes
            .iter()
            .filter(|item| item.result.is_err())
            .count();
        if failed > 0 {
            notices.push(format!(
                "{failed} image import(s) failed; successful imports remain linked"
            ));
        }
        Ok(post)
    })?;
    Ok(CommandOutput {
        command: "gallery",
        message: format!(
            "Created gallery post {} ({}) with {} image(s)",
            post.id,
            post.slug,
            input.images.len()
        ),
        data: json!({"id": post.id, "slug": post.slug, "images": input.images.len()}),
        progress: Vec::new(),
        notices,
        json: false,
    })
}

fn config(db: &Database, command: ConfigCommand) -> Result<CommandOutput> {
    match command {
        ConfigCommand::Get { key } => {
            let value = engine::settings::get_effective(db.conn(), &key)?
                .ok_or_else(|| anyhow!("{key} is not set"))?;
            let value = printable_config_value(&key, &value);
            Ok(output(&value, json!({"key": key, "value": value})))
        }
        ConfigCommand::Set { key, value } => {
            cli_sync::run_cli_mutation(db.conn(), || {
                if key == engine::settings::ONLINE_API_KEY {
                    engine::ai::save_online_api_key(db.conn(), &value)
                } else if key == engine::settings::AIRPLANE_API_KEY {
                    engine::ai::save_endpoint_api_key(
                        db.conn(),
                        engine::ai::AiEndpointKind::Airplane,
                        &value,
                    )
                } else {
                    engine::settings::set(db.conn(), &key, &value)
                }
            })?;
            let printable = printable_config_value(&key, &value);
            Ok(output(
                &format!("{key} = {printable}"),
                json!({"key": key, "value": printable}),
            ))
        }
        ConfigCommand::List => {
            let settings = engine::settings::list_effective(db.conn())?;
            let mut message = String::new();
            let mut values = serde_json::Map::new();
            for (key, value) in settings {
                let value = printable_config_value(&key, &value);
                let _ = writeln!(message, "{key}={value}");
                values.insert(key, Value::String(value));
            }
            Ok(output(message.trim_end(), Value::Object(values)))
        }
    }
}

fn printable_config_value(key: &str, value: &str) -> String {
    let key = key.to_ascii_lowercase();
    if [
        "api_key",
        "access_key",
        "private_key",
        "password",
        "secret",
        "token",
        "credential",
    ]
    .iter()
    .any(|name| key.ends_with(name) || key.split(['.', '-']).any(|part| part == *name))
    {
        if value.trim().is_empty() {
            "<not set>".to_string()
        } else {
            "<set>".to_string()
        }
    } else {
        value.to_string()
    }
}

fn project(db: &Database, command: ProjectCommand) -> Result<CommandOutput> {
    match command {
        ProjectCommand::List => {
            let projects = engine::project::list_projects(db.conn())?;
            let message = projects
                .iter()
                .map(|project| {
                    format!(
                        "{} {}  {}  {}",
                        if project.is_active { "*" } else { " " },
                        project.id,
                        project.slug,
                        project.name
                    )
                })
                .collect::<Vec<_>>()
                .join("\n");
            Ok(output(&message, serde_json::to_value(projects)?))
        }
        ProjectCommand::Add { path, name } => {
            if !path.is_dir() {
                bail!("{} is not a directory", path.display());
            }
            let path = path.canonicalize()?;
            let project = cli_sync::run_cli_mutation(db.conn(), || {
                if path.join("meta/project.json").is_file() {
                    engine::project::open_project(db.conn(), &path)
                } else {
                    let fallback = path
                        .file_name()
                        .and_then(|value| value.to_str())
                        .unwrap_or("Blog");
                    engine::project::create_project(
                        db.conn(),
                        name.as_deref().unwrap_or(fallback),
                        path.to_str(),
                    )
                }
            })?;
            Ok(output(
                &format!(
                    "Added project {} ({}); switch with: project switch {}",
                    project.id, project.slug, project.slug
                ),
                serde_json::to_value(project)?,
            ))
        }
        ProjectCommand::Switch { project: reference } => {
            let selected = engine::project::list_projects(db.conn())?
                .into_iter()
                .find(|project| {
                    reference == project.id
                        || reference == project.slug
                        || reference == project.name
                })
                .ok_or_else(|| anyhow!("no project matches {reference:?}"))?;
            cli_sync::run_cli_mutation(db.conn(), || {
                engine::project::set_active_project(db.conn(), &selected.id)
            })?;
            Ok(output(
                &format!("Active project: {}", selected.name),
                serde_json::to_value(selected)?,
            ))
        }
    }
}

fn run_lua(db: &Database, slug: &str, args: &[String], airplane: bool) -> Result<CommandOutput> {
    let (project, data_dir) = active_project(db)?;
    let script = bds_core::db::queries::script::get_script_by_slug(db.conn(), &project.id, slug)
        .with_context(|| format!("no script with slug {slug:?} in the active project"))?;
    if script.kind != ScriptKind::Utility {
        bail!(
            "script {slug:?} is a {} script; only utility scripts can be run",
            script.kind.as_str()
        );
    }
    if !script.enabled {
        bail!("script {slug:?} is disabled");
    }
    let source = if let Some(content) = script.content {
        content
    } else {
        let raw = std::fs::read_to_string(data_dir.join(&script.file_path))?;
        bds_core::util::frontmatter::read_script_file(&raw)
            .map_err(|error| anyhow!(error))?
            .1
    };
    let values = args.iter().cloned().map(Value::String).collect::<Vec<_>>();
    let host = Arc::new(
        CoreHost::new(db.conn().database_path()?, project.id.clone(), data_dir)
            .with_offline_mode(airplane),
    );
    let execution = cli_sync::run_cli_mutation(db.conn(), || {
        execute_many_with_host(
            &source,
            &script.entrypoint,
            &values,
            ExecutionKind::Utility,
            &ExecutionControl::default(),
            host,
        )
        .map_err(|error| engine::EngineError::Validation(format!("script failed: {error}")))
    })?;
    let mut message = execution.output.join("\n");
    if !message.is_empty() {
        message.push('\n');
    }
    write!(message, "Script finished: {}", execution.value)?;
    Ok(output(
        &message,
        json!({"value": execution.value, "output": execution.output, "progress": execution.progress.iter().map(|item| json!({"current": item.current, "total": item.total, "message": item.message})).collect::<Vec<_>>() }),
    ))
}

fn install_launcher(context: &RunContext) -> Result<CommandOutput> {
    let target =
        engine::cli_launcher::install_launcher(&context.executable_path, &context.home_dir)?;
    Ok(output(
        &format!("Installed launcher at {}", target.display()),
        json!({"path": target}),
    ))
}

fn post_input(args: PostArgs, stdin: &str) -> Result<PostInput> {
    if args.stdin {
        if stdin.trim().is_empty() {
            bail!("no JSON data on stdin");
        }
        let input: PostInput = serde_json::from_str(stdin).context("invalid JSON on stdin")?;
        if input.title.trim().is_empty() {
            bail!("JSON post data needs a non-empty title");
        }
        return Ok(input);
    }
    let title = args
        .title
        .filter(|value| !value.trim().is_empty())
        .ok_or_else(|| anyhow!("--title is required (or pass --stdin with JSON post data)"))?;
    Ok(PostInput {
        title,
        content: args.content.unwrap_or_default(),
        excerpt: args.excerpt,
        author: args.author,
        language: args.language,
        template: args.template,
        tags: cleaned(args.tags),
        categories: cleaned(args.categories),
        images: Vec::new(),
    })
}

fn ensure_language(
    conn: &DbConnection,
    input: &mut PostInput,
    airplane: bool,
    notices: &mut Vec<String>,
) {
    if input
        .language
        .as_deref()
        .is_some_and(|value| !value.trim().is_empty())
    {
        return;
    }
    let request = engine::ai::OneShotRequest {
        operation: engine::ai::OneShotOperation::DetectLanguage,
        content: json!({"title": input.title, "content": input.content}),
    };
    if let Ok((engine::ai::OneShotResponse::LanguageDetection(result), _)) =
        engine::ai::run_one_shot(conn, airplane, &request)
    {
        input.language = Some(result.language_code);
    } else {
        input.language = Some(
            engine::search::detect_language(&format!("{}\n{}", input.title, input.content)).into(),
        );
        notices.push("AI language detection was unavailable; used the offline heuristic".into());
    }
}

fn translation_notice(
    result: Option<engine::EngineResult<engine::auto_translation::FillMissingTranslationsReport>>,
    notices: &mut Vec<String>,
) {
    match result {
        None => {}
        Some(Ok(report)) if report.nothing_to_do => notices.push(
            "automatic translation was not needed (single language or already translated)".into(),
        ),
        Some(Ok(report)) if report.failed_count > 0 => notices.push(format!(
            "automatic translation completed with {} failure(s): {}",
            report.failed_count,
            report.errors.join("; ")
        )),
        Some(Ok(report)) => notices.push(format!(
            "automatic translation created {} post and {} media translation(s)",
            report.translated_posts, report.translated_media
        )),
        Some(Err(error)) => notices.push(format!(
            "automatic translation was not run (offline, unconfigured AI, or endpoint error): {error}"
        )),
    }
}

fn emit_bulk(project_id: &str) {
    for entity in [
        DomainEntity::Post,
        DomainEntity::Media,
        DomainEntity::Script,
        DomainEntity::Template,
    ] {
        domain_events::entity_changed(project_id, entity, "*", NotificationAction::Updated);
    }
}

fn require_file(path: &Path) -> Result<()> {
    if path.is_file() {
        Ok(())
    } else {
        bail!("no such file: {}", path.display())
    }
}

fn cleaned(values: Vec<String>) -> Vec<String> {
    values
        .into_iter()
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty())
        .collect()
}

fn split_list(value: &str) -> Vec<String> {
    cleaned(value.split(',').map(str::to_string).collect())
}

fn output(message: &str, data: Value) -> CommandOutput {
    CommandOutput {
        command: "",
        message: message.to_string(),
        data,
        progress: Vec::new(),
        notices: Vec::new(),
        json: false,
    }
}

fn command_name(command: &Command) -> &'static str {
    match command {
        Command::Rebuild { .. } => "rebuild",
        Command::Repair { .. } => "repair",
        Command::Render { .. } => "render",
        Command::Upload => "upload",
        Command::Push => "push",
        Command::Pull => "pull",
        Command::Post(_) => "post",
        Command::Media { .. } => "media",
        Command::Gallery(_) => "gallery",
        Command::Config { .. } => "config",
        Command::Project { .. } => "project",
        Command::Server(_) => "server",
        Command::Tui => "tui",
        Command::Lua { .. } => "lua",
        Command::Install => "install",
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use bds_core::model::ScriptKind;
    use clap::CommandFactory as _;
    use std::io::{Read as _, Write as _};
    use std::net::TcpListener;
    use std::thread;
    use tempfile::TempDir;

    struct Fixture {
        _root: TempDir,
        database_path: PathBuf,
        project_dir: PathBuf,
        home_dir: PathBuf,
    }

    impl Fixture {
        fn new(active: bool) -> Self {
            let root = tempfile::tempdir().unwrap();
            let database_path = root.path().join("app/bds.db");
            let project_dir = root.path().join("project");
            let home_dir = root.path().join("home");
            std::fs::create_dir_all(&project_dir).unwrap();
            std::fs::create_dir_all(&home_dir).unwrap();
            let db = open_database(&database_path).unwrap();
            if active {
                let project =
                    engine::project::create_project(db.conn(), "CLI Test", project_dir.to_str())
                        .unwrap();
                engine::project::set_active_project(db.conn(), &project.id).unwrap();
            }
            Self {
                _root: root,
                database_path,
                project_dir,
                home_dir,
            }
        }

        fn context(&self, stdin: &str) -> RunContext {
            RunContext {
                database_path: self.database_path.clone(),
                stdin: stdin.to_string(),
                home_dir: self.home_dir.clone(),
                executable_path: self._root.path().join("missing-bds-cli"),
            }
        }

        fn run(&self, args: &[&str], stdin: &str) -> Result<CommandOutput> {
            let cli = Cli::try_parse_from(std::iter::once("bds-cli").chain(args.iter().copied()))?;
            run(cli, self.context(stdin))
        }

        fn run_with_progress(&self, args: &[&str]) -> Result<(CommandOutput, Vec<CliProgress>)> {
            let cli = Cli::try_parse_from(std::iter::once("bds-cli").chain(args.iter().copied()))?;
            let updates = Arc::new(Mutex::new(Vec::new()));
            let sink = Arc::clone(&updates);
            let output = super::run_with_progress(
                cli,
                self.context(""),
                Some(Arc::new(move |update| sink.lock().unwrap().push(update))),
            )?;
            let updates = updates.lock().unwrap().clone();
            Ok((output, updates))
        }

        fn image(&self, name: &str) -> PathBuf {
            let path = self._root.path().join(name);
            std::fs::write(
                &path,
                include_bytes!(
                    "../../../fixtures/golden-generated-sites/rfc1437-sample/images/close.png"
                ),
            )
            .unwrap();
            path
        }
    }

    #[test]
    fn help_exposes_every_command_family_and_invalid_argv_fails() {
        let mut help = Vec::new();
        Cli::command().write_long_help(&mut help).unwrap();
        let help = String::from_utf8(help).unwrap();
        for command in [
            "rebuild", "repair", "render", "upload", "push", "pull", "post", "media", "gallery",
            "config", "project", "server", "tui", "lua", "install",
        ] {
            assert!(help.contains(command), "missing {command} from help");
        }
        let server = Cli::try_parse_from([
            "bds-cli",
            "server",
            "--bind",
            "127.0.0.2",
            "--port",
            "2233",
            "--database",
            "/tmp/bds.db",
            "--data-dir",
            "/tmp/bds",
        ])
        .unwrap();
        assert!(matches!(server.command, Command::Server(_)));
        assert!(Cli::try_parse_from(["bds-cli", "unknown"]).is_err());
        assert!(Cli::try_parse_from(["bds-cli", "render", "--incremental", "--force"]).is_err());
        assert!(Cli::try_parse_from(["bds-cli", "repair", "unknown"]).is_err());
    }

    #[test]
    fn human_progress_uses_a_live_bar_only_for_terminals() {
        let update = CliProgress {
            value: 0.5,
            message: "Rendering".into(),
        };
        let mut terminal = HumanProgress::new(true);
        let mut terminal_output = Vec::new();
        terminal.write(&update, &mut terminal_output).unwrap();
        terminal.finish(&mut terminal_output).unwrap();
        let terminal_output = String::from_utf8(terminal_output).unwrap();
        assert!(terminal_output.contains("Rendering:  50%|████████████"));
        assert!(terminal_output.starts_with("\r\x1b[2K"));

        let mut redirected = HumanProgress::new(false);
        let mut redirected_output = Vec::new();
        redirected.write(&update, &mut redirected_output).unwrap();
        assert_eq!(
            String::from_utf8(redirected_output).unwrap(),
            "[ 50%] Rendering\n"
        );
    }

    #[test]
    fn config_and_project_families_dispatch_success_and_failure() {
        let fixture = Fixture::new(false);
        assert!(fixture.run(&["config", "get", "missing"], "").is_err());

        let defaults = fixture.run(&["config", "list"], "").unwrap();
        assert_eq!(defaults.data["editor.default_mode"], "markdown");
        assert_eq!(defaults.data["editor.diff_view_style"], "inline");
        assert_eq!(defaults.data["ai.endpoint.online.api_key"], "<not set>");
        let removed_prefix = ["style", "."].concat();
        assert!(
            defaults
                .data
                .as_object()
                .unwrap()
                .keys()
                .all(|key| !key.starts_with(&removed_prefix))
        );
        assert!(
            !defaults
                .message
                .contains("app.search-index-rebuild-required")
        );
        assert_eq!(
            fixture
                .run(&["config", "get", "editor.default_mode"], "")
                .unwrap()
                .message,
            "markdown"
        );

        fixture
            .run(&["config", "set", "editor.mode", "markdown"], "")
            .unwrap();
        let read = fixture
            .run(&["--json", "config", "get", "editor.mode"], "")
            .unwrap();
        assert_eq!(read.data["value"], "markdown");
        assert!(read.to_string().contains("\"ok\":true"));

        let secret = fixture
            .run(&["config", "set", "service.api_key", "secret-token"], "")
            .unwrap();
        assert_eq!(secret.message, "service.api_key = <set>");
        assert!(!secret.to_string().contains("secret-token"));
        let secret = fixture
            .run(&["--json", "config", "get", "service.api_key"], "")
            .unwrap();
        assert_eq!(secret.data["value"], "<set>");
        assert!(!secret.to_string().contains("secret-token"));
        let secrets = fixture.run(&["--json", "config", "list"], "").unwrap();
        assert_eq!(secrets.data["service.api_key"], "<set>");
        assert!(!secrets.to_string().contains("secret-token"));

        let db = open_database(&fixture.database_path).unwrap();
        engine::settings::set(db.conn(), "ai.endpoint.online.api_key_configured", "true").unwrap();
        let configured = fixture.run(&["config", "list"], "").unwrap();
        assert_eq!(configured.data["ai.endpoint.online.api_key"], "<set>");
        assert!(
            !configured
                .message
                .contains("ai.endpoint.online.api_key_configured")
        );

        assert!(fixture.run(&["project", "switch", "missing"], "").is_err());

        let project = fixture._root.path().join("second");
        std::fs::create_dir_all(&project).unwrap();
        fixture
            .run(
                &[
                    "project",
                    "add",
                    project.to_str().unwrap(),
                    "--name",
                    "Second Blog",
                ],
                "",
            )
            .unwrap();
        fixture
            .run(&["project", "switch", "second-blog"], "")
            .unwrap();
        let listed = fixture.run(&["project", "list"], "").unwrap();
        assert!(listed.message.contains("Second Blog"));
    }

    #[test]
    fn post_family_supports_flags_json_fallback_language_and_one_notification() {
        let fixture = Fixture::new(true);
        assert!(fixture.run(&["post"], "").is_err());
        let created = fixture
            .run(
                &[
                    "post",
                    "--title",
                    "Über Rust",
                    "--content",
                    "Das ist ein Beitrag und die Sprache ist Deutsch.",
                    "--excerpt",
                    "Kurz",
                    "--tags",
                    "rust,cli",
                    "--no-translate",
                ],
                "",
            )
            .unwrap();
        assert_eq!(created.data["language"], "de");
        assert!(created.notices[0].contains("offline heuristic"));
        let db = open_database(&fixture.database_path).unwrap();
        let notifications =
            bds_core::db::queries::db_notification::list_unseen_cli_notifications(db.conn())
                .unwrap();
        assert_eq!(notifications.len(), 1);
        assert_eq!(notifications[0].entity_type, DomainEntity::Post);

        let json_post = fixture
            .run(
                &["post", "--stdin", "--no-translate"],
                r#"{"title":"JSON post","content":"body","language":"en","categories":["article"]}"#,
            )
            .unwrap();
        assert_eq!(json_post.data["slug"], "json-post");
        assert!(fixture.run(&["post", "--stdin"], "not json").is_err());
    }

    #[test]
    fn post_language_detection_uses_configured_airplane_endpoint() {
        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
        let endpoint = format!("http://{}", listener.local_addr().unwrap());
        thread::spawn(move || {
            let (mut stream, _) = listener.accept().unwrap();
            let mut request = [0_u8; 8192];
            let _ = stream.read(&mut request).unwrap();
            let body = r#"{"choices":[{"message":{"content":"{\"language_code\":\"fr\"}"}}],"usage":{"prompt_tokens":1,"completion_tokens":1}}"#;
            let response = format!(
                "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
                body.len(),
                body
            );
            stream.write_all(response.as_bytes()).unwrap();
        });

        let fixture = Fixture::new(true);
        let db = open_database(&fixture.database_path).unwrap();
        engine::ai::save_endpoint(
            db.conn(),
            &engine::ai::AiEndpointConfig {
                kind: engine::ai::AiEndpointKind::Airplane,
                url: endpoint,
                model: "local-model".into(),
                api_key: None,
            },
        )
        .unwrap();
        let created = fixture
            .run(
                &[
                    "--airplane",
                    "post",
                    "--title",
                    "Bonjour",
                    "--content",
                    "Texte sans accents",
                    "--no-translate",
                ],
                "",
            )
            .unwrap();
        assert_eq!(created.data["language"], "fr");
        assert!(created.notices.is_empty());
    }

    #[test]
    fn media_and_gallery_families_dispatch_shared_import_pipeline() {
        let fixture = Fixture::new(true);
        assert!(fixture.run(&["media", "missing.png"], "").is_err());
        let image = fixture.image("one.png");
        let imported = fixture
            .run(
                &[
                    "--airplane",
                    "media",
                    image.to_str().unwrap(),
                    "--language",
                    "en",
                ],
                "",
            )
            .unwrap();
        assert!(imported.message.contains("Imported media"));
        assert!(
            fixture
                .project_dir
                .join("media")
                .read_dir()
                .unwrap()
                .next()
                .is_some()
        );
        let imported_id = imported.data["id"].as_str().unwrap().to_string();
        let db = open_database(&fixture.database_path).unwrap();
        let stored =
            bds_core::db::queries::media::get_media_by_id(db.conn(), &imported_id).unwrap();
        assert!(fixture.project_dir.join(&stored.sidecar_path).is_file());
        fixture.run(&["rebuild"], "").unwrap();
        assert!(
            bds_core::db::queries::media::get_media_by_id(db.conn(), &imported_id).is_ok(),
            "filesystem rebuild must restore CLI-imported media"
        );

        let second = fixture.image("two.png");
        let gallery = fixture
            .run(
                &[
                    "--airplane",
                    "gallery",
                    "--title",
                    "Gallery",
                    "--language",
                    "en",
                    "--no-translate",
                    second.to_str().unwrap(),
                ],
                "",
            )
            .unwrap();
        assert_eq!(gallery.data["images"], 1);
        assert!(
            fixture
                .run(&["gallery", "--title", "Empty", "--no-translate"], "",)
                .is_err()
        );
    }

    #[test]
    fn rebuild_repair_and_render_families_dispatch_success_and_failure() {
        let fixture = Fixture::new(true);
        fixture.run(&["rebuild"], "").unwrap();
        fixture.run(&["rebuild", "--incremental"], "").unwrap();
        for part in ["post-links", "media-links", "thumbnails", "search"] {
            fixture.run(&["repair", part], "").unwrap();
        }
        let skipped = fixture.run(&["repair", "embeddings"], "").unwrap();
        assert_eq!(skipped.data["disabled"], true);
        let mut metadata = engine::meta::read_project_json(&fixture.project_dir).unwrap();
        metadata.semantic_similarity_enabled = true;
        engine::meta::write_project_json(&fixture.project_dir, &metadata).unwrap();
        let repaired = fixture.run(&["repair", "embeddings"], "").unwrap();
        assert_eq!(repaired.data["rebuilt"], 0);
        fixture.run(&["rebuild"], "").unwrap();
        metadata.semantic_similarity_enabled = false;
        engine::meta::write_project_json(&fixture.project_dir, &metadata).unwrap();
        fixture.run(&["render"], "").unwrap();
        let db = open_database(&fixture.database_path).unwrap();
        let (project, _) = active_project(&db).unwrap();
        bds_core::db::queries::generated_file_hash::upsert_generated_file_hash(
            db.conn(),
            &bds_core::model::GeneratedFileHash {
                project_id: project.id.clone(),
                relative_path: "legacy-output.txt".into(),
                content_hash: "legacy".into(),
                updated_at: 42,
            },
        )
        .unwrap();
        drop(db);
        fixture.run(&["render", "--force"], "").unwrap();
        let db = open_database(&fixture.database_path).unwrap();
        assert!(
            bds_core::db::queries::generated_file_hash::get_generated_file_hash(
                db.conn(),
                &project.id,
                "legacy-output.txt",
            )
            .is_ok()
        );
        fixture.run(&["render", "--incremental"], "").unwrap();
        assert!(fixture.project_dir.join("html/index.html").is_file());

        let no_project = Fixture::new(false);
        assert!(no_project.run(&["rebuild"], "").is_err());
        assert!(no_project.run(&["repair", "search"], "").is_err());
        assert!(no_project.run(&["render"], "").is_err());
    }

    #[test]
    fn long_running_commands_stream_progress_while_preserving_result_history() {
        let fixture = Fixture::new(true);
        for args in [
            &["rebuild"][..],
            &["rebuild", "--incremental"][..],
            &["render"][..],
            &["render", "--incremental"][..],
        ] {
            let (output, updates) = fixture.run_with_progress(args).unwrap();
            assert!(
                !updates.is_empty(),
                "{} did not stream progress",
                args.join(" ")
            );
            assert_eq!(updates.last().unwrap().value, 1.0);
            assert!(
                updates
                    .windows(2)
                    .all(|pair| pair[0].value <= pair[1].value),
                "{} progress moved backwards: {updates:?}",
                args.join(" ")
            );
            assert_eq!(output.progress.len(), updates.len());
        }
    }

    #[test]
    fn lua_family_runs_only_enabled_utility_scripts() {
        let fixture = Fixture::new(true);
        let db = open_database(&fixture.database_path).unwrap();
        let (project, _) = active_project(&db).unwrap();
        engine::script::create_script(
            db.conn(),
            &project.id,
            "Echo",
            ScriptKind::Utility,
            "function main(value) print(value); return value end",
            Some("main"),
        )
        .unwrap();
        engine::script::create_script(
            db.conn(),
            &project.id,
            "Macro",
            ScriptKind::Macro,
            "function render() return 'x' end",
            Some("render"),
        )
        .unwrap();
        let execution = fixture.run(&["lua", "echo", "hello"], "").unwrap();
        assert!(execution.message.contains("hello"));
        assert!(fixture.run(&["lua", "macro"], "").is_err());
        assert!(fixture.run(&["lua", "missing"], "").is_err());
    }

    #[test]
    fn external_and_launcher_families_have_actionable_failure_dispatch() {
        let fixture = Fixture::new(true);
        assert!(fixture.run(&["upload"], "").is_err());
        assert!(fixture.run(&["push"], "").is_err());
        assert!(fixture.run(&["pull"], "").is_err());
        assert!(
            fixture
                .run(&["--airplane", "push"], "")
                .unwrap_err()
                .to_string()
                .contains("airplane")
        );
        assert!(fixture.run(&["tui"], "").is_err());
        assert!(fixture.run(&["install"], "").is_err());
    }

    #[cfg(unix)]
    #[test]
    fn launcher_install_is_idempotent_and_overwrites_existing_file() {
        let fixture = Fixture::new(false);
        let executable = fixture._root.path().join("packaged-bds-cli");
        std::fs::write(&executable, b"binary").unwrap();
        let target = fixture.home_dir.join(".local/bin/bds-cli");
        std::fs::create_dir_all(target.parent().unwrap()).unwrap();
        std::fs::write(&target, b"old launcher").unwrap();
        let context = RunContext {
            executable_path: executable.clone(),
            ..fixture.context("")
        };
        run(
            Cli::try_parse_from(["bds-cli", "install"]).unwrap(),
            context.clone(),
        )
        .unwrap();
        run(
            Cli::try_parse_from(["bds-cli", "install"]).unwrap(),
            context,
        )
        .unwrap();
        assert!(target.is_symlink());
        assert_eq!(
            std::fs::read_link(target).unwrap(),
            executable.canonicalize().unwrap()
        );
    }
}
