//! MIME type lookup, detection and reverse lookup CLI.

// The CLI entry point needs process::exit for error codes.
#![allow(clippy::exit)]

mod output;

use clap::{Parser, Subcommand, ValueEnum};
use colored::Colorize;
use mimetype_cli::fs::DataDir;
use mimetype_cli::{
    detect_by_content, detect_by_magic, extensions_for, in_mediatypes, lookup_by_ext, module_label,
};
use output::{
    format_json, format_markdown_table, format_sarif, format_sarif_markdown, validate_markdown,
    validate_sarif, DetectionResult, OutputFormat,
};
use std::path::{Path, PathBuf};

/// MIME type lookup, detection and reverse lookup.
#[derive(Parser)]
#[command(
    name = "mimetype-cli",
    version = env!("CARGO_PKG_VERSION"),
    about = "MIME type lookup, detection and reverse lookup"
)]
struct Cli {
    /// Disable colours in output (useful for piping / scripts)
    #[arg(long, global = true)]
    plain: bool,

    /// Output format
    #[arg(long, short = 'f', global = true, default_value = "txt", value_enum)]
    format: CliFormat,

    /// Shorthand for --format json
    #[arg(short = 'j', global = true)]
    json: bool,

    /// Skip auto-generation of report.sarif and report.md files
    #[arg(long, global = true)]
    no_report: bool,

    /// Directory for auto-generated report files (default: current directory)
    #[arg(long, global = true)]
    output_dir: Option<PathBuf>,

    #[command(subcommand)]
    command: Commands,
}

/// Output format choices for clap.
#[derive(Clone, Copy, Debug, ValueEnum)]
enum CliFormat {
    /// Plain text (default)
    Txt,
    /// Markdown table
    Markdown,
    /// Structured JSON
    Json,
    /// SARIF v2.1.0 for static analysis
    Sarif,
}

impl From<CliFormat> for OutputFormat {
    fn from(f: CliFormat) -> Self {
        match f {
            CliFormat::Txt => Self::Text,
            CliFormat::Markdown => Self::Markdown,
            CliFormat::Json => Self::Json,
            CliFormat::Sarif => Self::Sarif,
        }
    }
}

/// Available subcommands.
#[derive(Subcommand)]
enum Commands {
    /// Detect MIME type of a file via extension and magic bytes
    File {
        /// Path to the file or directory
        path: PathBuf,
        /// Scan directories recursively
        #[arg(short = 'R', long)]
        recursive: bool,
    },
    /// Look up MIME type by file extension
    Ext {
        /// Extension with or without leading dot (png or .png)
        ext: String,
    },
    /// Reverse lookup: find file extensions for a MIME type
    Mime {
        /// MIME type string (e.g. image/png)
        mime: String,
    },
}

const RULE: &str = "\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}";

/// Pure detection — no I/O. Used by `cmd_file` after `DataDir` has read the
/// bytes through a capability-checked path.
fn detect_from_bytes(display: &str, bytes: &[u8]) -> DetectionResult {
    let by_ext: Option<String> =
        mime_guess::from_path(Path::new(display)).first().map(|m| m.to_string());

    let header_end = bytes.len().min(8192);
    let header = bytes.get(..header_end).unwrap_or_default();

    let by_magic = detect_by_magic(header);
    let by_content = if by_magic.is_none() {
        detect_by_content(header)
    } else {
        None
    };

    let detected = by_magic.as_deref().or(by_content.as_deref());
    let match_status = match (&by_ext, &detected) {
        (Some(e), Some(d)) if e.as_str() == *d => "match",
        (Some(_), Some(_)) => "mismatch",
        (Some(_), None) => "extension-only",
        (None, Some(_)) if by_magic.is_some() => "magic-only",
        (None, Some(_)) => "content-only",
        (None, None) => "unknown",
    };

    let resolved = by_magic
        .as_deref()
        .or(by_content.as_deref())
        .or(by_ext.as_deref())
        .unwrap_or("application/octet-stream")
        .to_owned();

    let mod_label = module_label(&resolved).to_owned();
    let exts = extensions_for(&resolved);
    let in_mt = in_mediatypes(&resolved);

    DetectionResult {
        path: display.to_owned(),
        mime_type: Some(resolved),
        by_extension: by_ext,
        by_magic,
        by_content,
        module: mod_label,
        extensions: exts,
        in_mediatypes: in_mt,
        match_status: match_status.to_owned(),
    }
}

fn print_colored_result(r: &DetectionResult) {
    let exts = if r.extensions.is_empty() {
        "\u{2014}".to_owned()
    } else {
        r.extensions.join("  ")
    };
    let mime = r.mime_type.as_deref().unwrap_or("unknown");

    println!("{}", RULE.dimmed());
    println!("{:<16} {}", "MIME type:".bold(), mime.green().bold());
    println!("{:<16} {}", "Module:".bold(), r.module.as_str().cyan());
    println!("{:<16} {}", "Extensions:".bold(), exts.yellow());
    println!(
        "{:<16} {}",
        "In mediatypes:".bold(),
        if r.in_mediatypes {
            "yes \u{2713}".green().to_string()
        } else {
            "\u{2014}".dimmed().to_string()
        }
    );

    if r.by_extension.is_some() || r.by_magic.is_some() || r.by_content.is_some() {
        println!("{}", RULE.dimmed());
        if let Some(ref e) = r.by_extension {
            let tag = if r.match_status == "match" {
                "  \u{2713} match".green().to_string()
            } else if r.match_status == "mismatch" {
                "  \u{2260} mismatch".red().to_string()
            } else {
                String::new()
            };
            println!("  {:<14} {e}{tag}", "by extension:".dimmed());
        }
        if let Some(ref m) = r.by_magic {
            println!("  {:<14} {m}", "by magic:".dimmed());
        }
        if let Some(ref c) = r.by_content {
            println!("  {:<14} {c}", "by content:".dimmed());
        }
    }
    println!("{}", RULE.dimmed());
}

/// One file the [`DataDir`] hands us, with its on-disk path for display only.
struct WalkedFile {
    /// Path relative to the [`DataDir`] root — used for `read()`.
    rel: PathBuf,
    /// Absolute or operator-supplied path — used for the `path` field of the
    /// emitted [`DetectionResult`] so JSON / SARIF / Markdown still look the
    /// way the operator expects.
    display: String,
}

/// Open the operator-supplied `path` as a [`DataDir`] root and enumerate
/// the files inside it.
///
/// For a single-file input, the parent directory becomes the boundary and
/// the lone file is returned as a one-element list. For a directory input,
/// the boundary is the directory itself and either every entry (recursive)
/// or just the first level is returned. Symlinks are not followed.
fn open_and_walk(
    path: &Path,
    recursive: bool,
) -> Option<(DataDir, Vec<WalkedFile>)> {
    if path.is_file() {
        let parent = path
            .parent()
            .filter(|p| !p.as_os_str().is_empty())
            .unwrap_or_else(|| Path::new("."));
        let dir = DataDir::open(parent).ok()?;
        let filename = path.file_name()?.to_string_lossy().into_owned();
        let display = path.display().to_string();
        return Some((
            dir,
            vec![WalkedFile {
                rel: PathBuf::from(&filename),
                display,
            }],
        ));
    }
    if !path.is_dir() {
        return None;
    }
    let dir = DataDir::open(path).ok()?;
    let walked: Vec<PathBuf> = if recursive {
        dir.walk().unwrap_or_default()
    } else {
        let mut acc = Vec::new();
        let _ = dir.for_each_file(|p, _| {
            acc.push(p.to_path_buf());
            Ok(())
        });
        acc
    };
    let files: Vec<WalkedFile> = walked
        .into_iter()
        .map(|rel| {
            let display = path.join(&rel).display().to_string();
            WalkedFile { rel, display }
        })
        .collect();
    Some((dir, files))
}

/// Read each entry's bytes through the capability boundary and run pure
/// detection against them. Kept separate from [`cmd_file`] for cognitive-
/// complexity discipline (CLAUDE.md hard threshold).
fn collect_results(dir: &DataDir, files: &[WalkedFile]) -> Vec<DetectionResult> {
    files
        .iter()
        .map(|f| {
            let bytes = dir.read(&f.rel.to_string_lossy()).unwrap_or_default();
            detect_from_bytes(&f.display, &bytes)
        })
        .collect()
}

/// Write auto-generated report files (SARIF + Markdown) unless `--no-report`.
///
/// The capability boundary is the `out_dir` [`DataDir`]: filenames are
/// hard-coded (`report.sarif`, `report.md`) so there is no operator-supplied
/// component that could escape, but the kernel still enforces the boundary
/// against any pre-existing symlink at those names.
fn write_report_files(
    results: &[DetectionResult],
    out_dir: &DataDir,
    out_dir_display: &Path,
) {
    // Generate SARIF
    let sarif_json = match format_sarif(results) {
        Ok(json) => json,
        Err(e) => {
            eprintln!(
                "{} SARIF generation failed: {e}",
                "warning:".yellow().bold()
            );
            return;
        },
    };

    // Validate SARIF
    if let Err(e) = validate_sarif(&sarif_json) {
        eprintln!(
            "{} SARIF validation failed: {e}",
            "warning:".yellow().bold()
        );
        return;
    }

    let sarif_display = out_dir_display.join("report.sarif");
    if let Err(e) = out_dir.write("report.sarif", sarif_json.as_bytes()) {
        eprintln!(
            "{} cannot write {}: {e}",
            "warning:".yellow().bold(),
            sarif_display.display()
        );
    } else {
        eprintln!(
            "{} {}",
            "report:".dimmed(),
            sarif_display.display().to_string().dimmed()
        );
    }

    // Generate Markdown from SARIF
    let markdown = match format_sarif_markdown(&sarif_json) {
        Ok(md) => md,
        Err(e) => {
            eprintln!(
                "{} Markdown generation failed: {e}",
                "warning:".yellow().bold()
            );
            return;
        },
    };

    if let Err(e) = validate_markdown(&markdown) {
        eprintln!(
            "{} Markdown validation failed: {e}",
            "warning:".yellow().bold()
        );
        return;
    }

    let md_display = out_dir_display.join("report.md");
    if let Err(e) = out_dir.write("report.md", markdown.as_bytes()) {
        eprintln!(
            "{} cannot write {}: {e}",
            "warning:".yellow().bold(),
            md_display.display()
        );
    } else {
        eprintln!(
            "{} {}",
            "report:".dimmed(),
            md_display.display().to_string().dimmed()
        );
    }
}

fn cmd_file(
    path: &Path,
    recursive: bool,
    fmt: OutputFormat,
    no_report: bool,
    output_dir: &std::path::Path,
) {
    if !path.exists() {
        eprintln!(
            "{} file not found: {}",
            "error:".red().bold(),
            path.display()
        );
        std::process::exit(1);
    }

    let Some((dir, files)) = open_and_walk(path, recursive) else {
        eprintln!(
            "{} cannot open {} as a file or directory",
            "error:".red().bold(),
            path.display()
        );
        std::process::exit(1);
    };
    if files.is_empty() {
        eprintln!(
            "{} no files found in: {}",
            "error:".red().bold(),
            path.display()
        );
        std::process::exit(1);
    }

    let results = collect_results(&dir, &files);

    // ── Auto-generate report files ──────────────────────────────────────
    if !no_report {
        match DataDir::open_or_create(output_dir) {
            Ok(out_dir) => write_report_files(&results, &out_dir, output_dir),
            Err(e) => eprintln!(
                "{} cannot open --output-dir {}: {e}",
                "warning:".yellow().bold(),
                output_dir.display()
            ),
        }
    }

    emit_results(&results, fmt);
}

/// Emit the detection results to stdout in the chosen `fmt`. Extracted from
/// [`cmd_file`] for cognitive-complexity discipline.
fn emit_results(results: &[DetectionResult], fmt: OutputFormat) {
    match fmt {
        OutputFormat::Text => {
            for r in results {
                println!("File: {}", r.path.as_str().bold());
                print_colored_result(r);
            }
        },
        OutputFormat::Markdown => {
            // Use SARIF-based Markdown if possible, fall back to simple table.
            match format_sarif(results) {
                Ok(sarif_json) => match format_sarif_markdown(&sarif_json) {
                    Ok(md) => print!("{md}"),
                    Err(_) => print!("{}", format_markdown_table(results)),
                },
                Err(_) => print!("{}", format_markdown_table(results)),
            }
        },
        OutputFormat::Json => match format_json(results) {
            Ok(json) => println!("{json}"),
            Err(e) => {
                eprintln!("{} JSON serialization failed: {e}", "error:".red().bold());
                std::process::exit(1);
            },
        },
        OutputFormat::Sarif => match format_sarif(results) {
            Ok(sarif) => println!("{sarif}"),
            Err(e) => {
                eprintln!("{} SARIF generation failed: {e}", "error:".red().bold());
                std::process::exit(1);
            },
        },
    }
}

fn print_info_text(mime: &str) {
    let exts = {
        let v = extensions_for(mime);
        if v.is_empty() {
            "\u{2014}".to_owned()
        } else {
            v.join("  ")
        }
    };

    println!("{}", RULE.dimmed());
    println!("{:<16} {}", "MIME type:".bold(), mime.green().bold());
    println!("{:<16} {}", "Module:".bold(), module_label(mime).cyan());
    println!("{:<16} {}", "Extensions:".bold(), exts.yellow());
    println!(
        "{:<16} {}",
        "In mediatypes:".bold(),
        if in_mediatypes(mime) {
            "yes \u{2713}".green().to_string()
        } else {
            "\u{2014}".dimmed().to_string()
        }
    );
    println!("{}", RULE.dimmed());
}

fn cmd_ext(ext: &str, fmt: OutputFormat) {
    let clean = ext.trim_start_matches('.');
    let mimes = lookup_by_ext(clean);

    if mimes.is_empty() {
        eprintln!(
            "{} no MIME type found for extension: .{clean}",
            "error:".red().bold(),
        );
        std::process::exit(1);
    }

    match fmt {
        OutputFormat::Text => {
            println!("Extension: {}", format!(".{clean}").bold());
            for mime in &mimes {
                print_info_text(mime);
            }
        },
        OutputFormat::Json => {
            let results: Vec<DetectionResult> = mimes
                .iter()
                .map(|m| DetectionResult {
                    path: format!("*.{clean}"),
                    mime_type: Some(m.clone()),
                    by_extension: Some(m.clone()),
                    by_magic: None,
                    by_content: None,
                    module: module_label(m).to_owned(),
                    extensions: extensions_for(m),
                    in_mediatypes: in_mediatypes(m),
                    match_status: "extension-lookup".to_owned(),
                })
                .collect();
            match format_json(&results) {
                Ok(json) => println!("{json}"),
                Err(e) => {
                    eprintln!("{} {e}", "error:".red().bold());
                    std::process::exit(1);
                },
            }
        },
        _ => {
            println!("Extension: .{clean}");
            for mime in &mimes {
                println!("{mime}");
            }
        },
    }
}

fn cmd_mime(mime: &str, fmt: OutputFormat) {
    match fmt {
        OutputFormat::Text => {
            println!("Reverse lookup: {}", mime.bold());
            print_info_text(mime);
        },
        OutputFormat::Json => {
            let result = DetectionResult {
                path: String::new(),
                mime_type: Some(mime.to_owned()),
                by_extension: None,
                by_magic: None,
                by_content: None,
                module: module_label(mime).to_owned(),
                extensions: extensions_for(mime),
                in_mediatypes: in_mediatypes(mime),
                match_status: "reverse-lookup".to_owned(),
            };
            match format_json(&[result]) {
                Ok(json) => println!("{json}"),
                Err(e) => {
                    eprintln!("{} {e}", "error:".red().bold());
                    std::process::exit(1);
                },
            }
        },
        _ => {
            println!("Reverse lookup: {mime}");
            let exts = extensions_for(mime);
            if exts.is_empty() {
                println!("  No extensions found");
            } else {
                println!("  Extensions: {}", exts.join(", "));
            }
        },
    }
}

fn main() {
    let cli = Cli::parse();

    if cli.plain {
        colored::control::set_override(false);
    }

    let fmt: OutputFormat = if cli.json {
        OutputFormat::Json
    } else {
        cli.format.into()
    };

    // Suppress colors for non-text formats
    if fmt != OutputFormat::Text {
        colored::control::set_override(false);
    }

    let output_dir = cli.output_dir.unwrap_or_else(|| PathBuf::from("."));

    match &cli.command {
        Commands::File { path, recursive } => {
            cmd_file(path.as_path(), *recursive, fmt, cli.no_report, &output_dir);
        },
        Commands::Ext { ext } => cmd_ext(ext, fmt),
        Commands::Mime { mime } => cmd_mime(mime, fmt),
    }
}
