use anyhow::Result;
use clap::{Parser, Subcommand};
use colored::Colorize;
use std::path::{Path, PathBuf};

use optica::config::SiteConfig;
use optica::graph::PageStore;

#[derive(Parser)]
#[command(name = "cyber-publish")]
#[command(
    version,
    about = "A Rust-native static site publisher for the cyber knowledge graph"
)]
struct Cli {
    #[command(subcommand)]
    command: Commands,

    /// Path to config file
    #[arg(short, long, default_value = "publish.toml")]
    config: PathBuf,

    /// Increase verbosity (-v info, -vv debug, -vvv trace)
    #[arg(short, long, action = clap::ArgAction::Count)]
    verbose: u8,

    /// Suppress output
    #[arg(short, long)]
    quiet: bool,
}

#[derive(Subcommand)]
enum Commands {
    /// Build the static site
    Build {
        /// Logseq graph directory
        #[arg(default_value = ".")]
        input: PathBuf,

        /// Output directory
        #[arg(short, long)]
        output: Option<PathBuf>,

        /// Include non-public pages
        #[arg(long)]
        drafts: bool,

        /// Override base URL
        #[arg(long)]
        base_url: Option<String>,
    },

    /// Build and serve with live reload
    Serve {
        /// Logseq graph directory
        #[arg(default_value = ".")]
        input: PathBuf,

        /// Server port (overrides base_url port from config)
        #[arg(short, long)]
        port: Option<u16>,

        /// Bind address
        #[arg(short, long, default_value = "127.0.0.1")]
        bind: String,

        /// Disable live reload
        #[arg(long)]
        no_reload: bool,

        /// Open browser automatically
        #[arg(long)]
        open: bool,

        /// Include non-public pages
        #[arg(long)]
        drafts: bool,
    },

    /// Initialize a new publish.toml config
    Init {
        /// Directory to initialize in
        #[arg(default_value = ".")]
        path: PathBuf,
    },

    /// Validate graph and report broken links
    Check {
        /// Logseq graph directory
        #[arg(default_value = ".")]
        input: PathBuf,
    },
}

/// Try to extract port from a URL like "http://localhost:8888"
fn port_from_url(url: &str) -> Option<u16> {
    // Look for :PORT at the end
    if let Some(pos) = url.rfind(':') {
        let after_colon = &url[pos + 1..];
        // Strip trailing slash
        let port_str = after_colon.trim_end_matches('/');
        port_str.parse::<u16>().ok()
    } else {
        None
    }
}

fn resolve_config(cli_config: &PathBuf, input: &Path) -> (PathBuf, SiteConfig) {
    let config_path = if cli_config.is_relative() {
        input.join(cli_config)
    } else {
        cli_config.clone()
    };
    let mut config = SiteConfig::load(&config_path).unwrap_or_default();
    config.build.input_dir = input.to_path_buf();

    // Resolve output_dir relative to input_dir if it's relative
    if config.build.output_dir.is_relative() {
        config.build.output_dir = input.join(&config.build.output_dir);
    }

    (config_path, config)
}

fn main() -> Result<()> {
    let cli = Cli::parse();

    match cli.command {
        Commands::Build {
            input,
            output,
            drafts,
            base_url,
        } => {
            let (_config_path, mut config) = resolve_config(&cli.config, &input);
            if let Some(ref out) = output {
                config.build.output_dir = out.clone();
            }
            if let Some(ref url) = base_url {
                config.site.base_url = url.clone();
            }
            if drafts {
                config.content.public_only = false;
            }

            build_site(&config, cli.quiet)?;
        }
        Commands::Serve {
            input,
            port,
            bind,
            no_reload,
            open,
            drafts,
        } => {
            let (_config_path, mut config) = resolve_config(&cli.config, &input);

            // Resolve port: CLI flag > config base_url > default 8080
            let port = port
                .or_else(|| port_from_url(&config.site.base_url))
                .unwrap_or(8080);

            config.site.base_url = format!("http://{}:{}", bind, port);

            if drafts {
                config.content.public_only = false;
            }

            build_site(&config, cli.quiet)?;

            optica::server::serve(&config, &bind, port, !no_reload, open)?;
        }
        Commands::Init { path } => {
            std::fs::create_dir_all(&path)?;
            let config_path = path.join("publish.toml");
            if config_path.exists() {
                eprintln!("{} publish.toml already exists", "Warning:".yellow());
                return Ok(());
            }
            let default_config = include_str!("../default-config.toml");
            std::fs::write(&config_path, default_config)?;
            if !cli.quiet {
                println!(
                    "{} Created {}",
                    "Done!".green().bold(),
                    config_path.display()
                );
            }
        }
        Commands::Check { input } => {
            let (_config_path, config) = resolve_config(&cli.config, &input);
            check_site(&config)?;
        }
    }

    Ok(())
}

fn build_site(config: &SiteConfig, quiet: bool) -> Result<()> {
    let start = std::time::Instant::now();

    if !quiet {
        println!("{} {}", "Building".cyan().bold(), config.site.title);
    }

    // Step 1: Scan root graph
    let discovered = optica::scanner::scan(&config.build.input_dir, &config.content)?;
    if !quiet {
        println!(
            "  {} Discovered {} pages, {} journals, {} media, {} files",
            "Scan".dimmed(),
            discovered.pages.len(),
            discovered.journals.len(),
            discovered.media.len(),
            discovered.files.len()
        );
    }

    // Step 2: Parse root graph
    let mut parsed_pages = optica::parser::parse_all(&discovered)?;
    if !quiet {
        println!("  {} Parsed {} pages", "Parse".dimmed(), parsed_pages.len());
    }

    // Step 3: Discover and scan subgraphs
    let subgraph_decls = optica::scanner::subgraph::discover_subgraphs(
        &parsed_pages,
        &config.build.input_dir,
    );

    if !subgraph_decls.is_empty() {
        let subgraph_namespaces: Vec<String> =
            subgraph_decls.iter().map(|d| d.name.clone()).collect();

        // Enforce namespace monopoly on root pages
        let evicted = optica::scanner::subgraph::enforce_namespace_monopoly(
            &mut parsed_pages,
            &subgraph_namespaces,
        );
        if !quiet && !evicted.is_empty() {
            for (id, reason) in &evicted {
                println!("  {} Evicted '{}': {}", "Monopoly".yellow(), id, reason);
            }
        }

        // Scan and parse each subgraph
        for decl in &subgraph_decls {
            let subgraph_files = optica::scanner::subgraph::scan_subgraph(decl)?;
            let sg_page_count = subgraph_files.iter().filter(|f| f.kind == optica::scanner::FileKind::Page).count();
            let sg_file_count = subgraph_files.iter().filter(|f| f.kind == optica::scanner::FileKind::File).count();

            // Find the declaring page so we can merge its metadata into the README
            let declaring_page = parsed_pages
                .iter()
                .find(|p| p.id == decl.declaring_page_id)
                .cloned();

            for file in &subgraph_files {
                let mut page = if file.kind == optica::scanner::FileKind::Page {
                    optica::parser::parse_file(file)?
                } else {
                    continue; // non-md handled below
                };

                // If this is the repo-root README (id matches declaring page),
                // merge the declaring page's metadata so tags/aliases/stake etc. are preserved.
                let decl_slug = optica::parser::slugify_page_name(&decl.declaring_page_id);
                if page.id == decl.declaring_page_id || page.id == decl_slug {
                    if let Some(ref decl_page) = declaring_page {
                        page.meta.tags = decl_page.meta.tags.clone();
                        page.meta.aliases = decl_page.meta.aliases.clone();
                        page.meta.properties = decl_page.meta.properties.clone();
                        page.meta.public = decl_page.meta.public;
                        page.meta.icon = decl_page.meta.icon.clone();
                        page.meta.stake = decl_page.meta.stake;
                        // Root graph content first, then README with explicit header
                        if !decl_page.content_md.trim().is_empty() {
                            let readme_content = std::mem::take(&mut page.content_md);
                            page.content_md = decl_page.content_md.clone();
                            page.content_md.push_str(&format!(
                                "\n\n---\n\n## from subgraph {}\n\n",
                                decl.name
                            ));
                            page.content_md.push_str(&readme_content);
                        }
                        // Merge outgoing links from the declaring page
                        for link in &decl_page.outgoing_links {
                            if !page.outgoing_links.contains(link) {
                                page.outgoing_links.push(link.clone());
                            }
                        }
                    }
                }

                parsed_pages.push(page);
            }

            // Remove the declaring page from root โ€” its slot is now taken by the README
            if declaring_page.is_some() {
                parsed_pages.retain(|p| {
                    !(p.id == decl.declaring_page_id && p.subgraph.is_none())
                });
            }

            // Parse non-markdown files via a temporary DiscoveredFiles
            let sg_files: Vec<_> = subgraph_files
                .into_iter()
                .filter(|f| f.kind == optica::scanner::FileKind::File)
                .collect();
            let sg_discovered = optica::scanner::DiscoveredFiles {
                pages: Vec::new(),
                journals: Vec::new(),
                media: Vec::new(),
                files: sg_files,
            };
            let sg_file_pages = optica::parser::parse_all(&sg_discovered)?;
            parsed_pages.extend(sg_file_pages);

            // Generate directory index pages for subdirectories without README
            let existing_ids: std::collections::HashSet<String> = parsed_pages
                .iter()
                .map(|p| p.id.clone())
                .collect();
            let mut seen_dirs: std::collections::HashSet<String> = std::collections::HashSet::new();
            for page in parsed_pages.iter() {
                if let Some(ref ns) = page.namespace {
                    if ns.starts_with(&decl.name) {
                        // Collect each directory level between subgraph root and this page
                        let after_root = ns.strip_prefix(&format!("{}/", decl.name)).unwrap_or("");
                        let mut accumulated = decl.name.clone();
                        for segment in after_root.split('/').filter(|s| !s.is_empty()) {
                            accumulated = format!("{}/{}", accumulated, segment);
                            seen_dirs.insert(accumulated.clone());
                        }
                    }
                }
            }
            for dir_name in &seen_dirs {
                let dir_slug = optica::parser::slugify_page_name(dir_name);
                if !existing_ids.contains(&dir_slug) {
                    let short_name = dir_name.rsplit('/').next().unwrap_or(dir_name);
                    parsed_pages.push(optica::parser::ParsedPage {
                        id: dir_slug,
                        meta: optica::parser::PageMeta {
                            title: dir_name.clone(),
                            properties: std::collections::HashMap::new(),
                            tags: vec![],
                            public: Some(true),
                            aliases: vec![],
                            date: None,
                            icon: None,
                            menu_order: None,
                            stake: None,
                        },
                        kind: optica::parser::PageKind::Page,
                        source_path: std::path::PathBuf::new(),
                        namespace: {
                            // Parent namespace: "trident/docs" โ†’ "trident"
                            let parent = dir_name.rsplitn(2, '/').nth(1).unwrap_or(&decl.name);
                            Some(parent.to_string())
                        },
                        subgraph: Some(decl.name.clone()),
                        content_md: format!("# {}\n", short_name),
                        outgoing_links: vec![],
                    });
                }
            }

            if !quiet {
                println!(
                    "  {} Subgraph '{}': {} pages, {} files",
                    "Scan".dimmed(),
                    decl.name,
                    sg_page_count,
                    sg_file_count
                );
            }
        }
    }

    // Step 4: Build graph
    let page_store = optica::graph::build_graph(parsed_pages)?;
    if !quiet {
        let total_links: usize = page_store.forward_links.values().map(|v| v.len()).sum();
        let public_count = page_store.public_pages(&config.content).len();
        let total_count = page_store.pages.len();
        println!(
            "  {} Built graph with {} links",
            "Graph".dimmed(),
            total_links
        );

        if config.content.public_only && public_count < total_count {
            println!(
                "  {} {}/{} pages are public (set default_public = true or add public:: true to pages)",
                "Filter".yellow(),
                public_count,
                total_count
            );
        }
    }

    // Step 4: Render
    let rendered = optica::render::render_all(&page_store, config)?;
    if !quiet {
        println!("  {} Rendered {} pages", "Render".dimmed(), rendered.len());
    }

    // Step 5: Output
    optica::output::write_output(&rendered, &page_store, config, &discovered)?;

    // Step 6: Copy subgraph media files
    if !subgraph_decls.is_empty() {
        for decl in &subgraph_decls {
            copy_subgraph_media(decl, &config.build.output_dir)?;
        }
    }

    let elapsed = start.elapsed();
    if !quiet {
        println!(
            "{} Built in {:.2}s โ†’ {}",
            "Done!".green().bold(),
            elapsed.as_secs_f64(),
            config.build.output_dir.display()
        );
    }

    Ok(())
}

/// Copy media/binary files from a subgraph repo to output/media/{subgraph}/.
/// Only copies files with known media extensions.
fn copy_subgraph_media(
    decl: &optica::scanner::subgraph::SubgraphDecl,
    output_dir: &Path,
) -> Result<()> {
    use globset::{Glob, GlobSetBuilder};
    use walkdir::WalkDir;

    let media_output = output_dir.join("media").join(&decl.name);

    // Build exclude set
    let mut builder = GlobSetBuilder::new();
    for pattern in &decl.exclude_patterns {
        if let Ok(glob) = Glob::new(pattern) {
            builder.add(glob);
        }
    }
    let exclude_set = builder.build()?;

    let media_exts = [
        "png", "jpg", "jpeg", "gif", "svg", "webp", "ico", "bmp", "avif",
        "mp4", "webm", "ogg", "mp3", "wav", "flac",
        "pdf", "zip", "tar", "gz", "woff", "woff2", "ttf", "eot",
    ];

    for entry in WalkDir::new(&decl.repo_path)
        .into_iter()
        .filter_map(|e| e.ok())
        .filter(|e| e.file_type().is_file())
    {
        let path = entry.path();
        let relative = path
            .strip_prefix(&decl.repo_path)
            .unwrap_or(path);

        if exclude_set.is_match(relative) {
            continue;
        }

        let ext = path
            .extension()
            .map(|e| e.to_string_lossy().to_lowercase())
            .unwrap_or_default();

        if !media_exts.contains(&ext.as_str()) {
            continue;
        }

        let dest = media_output.join(relative);
        if let Some(parent) = dest.parent() {
            std::fs::create_dir_all(parent)?;
        }
        std::fs::copy(path, &dest)?;
    }

    Ok(())
}

fn check_site(config: &SiteConfig) -> Result<()> {
    println!("{} {}", "Checking".cyan().bold(), config.site.title);

    let discovered = optica::scanner::scan(&config.build.input_dir, &config.content)?;
    let mut parsed_pages = optica::parser::parse_all(&discovered)?;

    // Discover and scan subgraphs (same as build_site)
    let subgraph_decls = optica::scanner::subgraph::discover_subgraphs(
        &parsed_pages,
        &config.build.input_dir,
    );
    if !subgraph_decls.is_empty() {
        let subgraph_namespaces: Vec<String> =
            subgraph_decls.iter().map(|d| d.name.clone()).collect();
        let evicted = optica::scanner::subgraph::enforce_namespace_monopoly(
            &mut parsed_pages,
            &subgraph_namespaces,
        );
        for (id, reason) in &evicted {
            println!("  {} Evicted '{}': {}", "Monopoly".yellow(), id, reason);
        }
        for decl in &subgraph_decls {
            let subgraph_files = optica::scanner::subgraph::scan_subgraph(decl)?;
            let declaring_page = parsed_pages
                .iter()
                .find(|p| p.id == decl.declaring_page_id)
                .cloned();
            for file in &subgraph_files {
                if file.kind == optica::scanner::FileKind::Page {
                    let mut page = optica::parser::parse_file(file)?;
                    if page.id == decl.declaring_page_id || page.id == optica::parser::slugify_page_name(&decl.declaring_page_id) {
                        if let Some(ref dp) = declaring_page {
                            page.meta.tags = dp.meta.tags.clone();
                            page.meta.aliases = dp.meta.aliases.clone();
                            page.meta.properties = dp.meta.properties.clone();
                            page.meta.public = dp.meta.public;
                            page.meta.icon = dp.meta.icon.clone();
                            page.meta.stake = dp.meta.stake;
                            if !dp.content_md.trim().is_empty() {
                                let readme_content = std::mem::take(&mut page.content_md);
                                page.content_md = dp.content_md.clone();
                                page.content_md.push_str(&format!(
                                    "\n\n---\n\n## from subgraph {}\n\n",
                                    decl.name
                                ));
                                page.content_md.push_str(&readme_content);
                            }
                            for link in &dp.outgoing_links {
                                if !page.outgoing_links.contains(link) {
                                    page.outgoing_links.push(link.clone());
                                }
                            }
                        }
                    }
                    parsed_pages.push(page);
                }
            }
            if declaring_page.is_some() {
                parsed_pages.retain(|p| {
                    !(p.id == decl.declaring_page_id && p.subgraph.is_none())
                });
            }
            let sg_files: Vec<_> = subgraph_files
                .into_iter()
                .filter(|f| f.kind == optica::scanner::FileKind::File)
                .collect();
            let sg_discovered = optica::scanner::DiscoveredFiles {
                pages: Vec::new(),
                journals: Vec::new(),
                media: Vec::new(),
                files: sg_files,
            };
            parsed_pages.extend(optica::parser::parse_all(&sg_discovered)?);
            println!(
                "  {} Subgraph '{}' scanned",
                "Scan".dimmed(),
                decl.name
            );
        }
    }

    let page_store = optica::graph::build_graph(parsed_pages)?;

    let public_count = page_store.public_pages(&config.content).len();
    let total_count = page_store.pages.len();
    println!(
        "  {} {}/{} pages pass public filter",
        "Pages".dimmed(),
        public_count,
        total_count
    );

    // Broken links grouped by subgraph
    let mut broken_by_subgraph: std::collections::HashMap<String, Vec<(String, String)>> =
        std::collections::HashMap::new();

    for (page_id, links) in &page_store.forward_links {
        if !PageStore::is_page_public(&page_store.pages[page_id], &config.content) {
            continue;
        }
        let subgraph_name = page_store
            .subgraph_pages
            .iter()
            .find(|(_, ids)| ids.contains(page_id))
            .map(|(name, _)| name.clone())
            .unwrap_or_else(|| "root".to_string());

        for link in links {
            if !page_store.pages.contains_key(link) {
                broken_by_subgraph
                    .entry(subgraph_name.clone())
                    .or_default()
                    .push((page_id.clone(), link.clone()));
            }
        }
    }

    let total_broken: usize = broken_by_subgraph.values().map(|v| v.len()).sum();
    if total_broken == 0 {
        println!("{} No broken links found!", "OK".green().bold());
    } else {
        for (sg, broken) in &broken_by_subgraph {
            println!(
                "\n  {} [{}] {} broken link(s):",
                "Broken:".red(),
                sg,
                broken.len()
            );
            for (from, to) in broken {
                println!("    {} โ†’ {}", from, to);
            }
        }
        println!(
            "\n{} {} broken link(s) found",
            "Warning:".yellow().bold(),
            total_broken
        );
    }

    // Crystal metadata validation
    let mut crystal_warnings = 0;
    for (page_id, page) in &page_store.pages {
        if page_store.stub_pages.contains(page_id) {
            continue;
        }
        // Skip crystal validation for subgraph pages
        if page.subgraph.is_some() {
            continue;
        }
        for warn in optica::validator::validate_page(page) {
            println!(
                "  {} {} โ€” {}",
                "Invalid:".red(),
                warn.source_path.display(),
                warn.message
            );
            crystal_warnings += 1;
        }
    }

    if crystal_warnings == 0 {
        println!(
            "{} Crystal metadata valid on all pages!",
            "OK".green().bold()
        );
    } else {
        println!(
            "\n{} {} crystal metadata warning(s) found",
            "Warning:".yellow().bold(),
            crystal_warnings
        );
    }

    Ok(())
}

Dimensions

trident/src/main.rs
rs/rsc/src/main.rs
hemera/cli/src/main.rs
nebu/cli/src/main.rs

Local Graph