//! Cli commands implementation.

use std::ffi::OsString;
use std::fs::{self, File};
use std::path::{Path, PathBuf};

use serde_yaml::{Value, from_reader};

use crate::config::load::{LoadError, load_config};
use crate::config::schema::FileConfig;
use crate::core::cli::{Cli, Command};
use crate::core::config::{ResolveError, ResolvedConfig, discover, resolve_single};
use crate::core::exec::{Cmd, ExecError, Runner};

/// Errors produced by command execution and configuration resolution.
#[derive(Debug)]
pub enum KazeError {
    /// I/O failure while interacting with the filesystem.
    Io(std::io::Error),
    /// Failure while executing an external command.
    Exec(ExecError),
    /// Failure while loading configuration files.
    Load(LoadError),
    /// Failure while resolving configuration values.
    Resolve(ResolveError),
    /// Generic user-facing error message.
    Msg(String),
}

impl From<std::io::Error> for KazeError {
    fn from(value: std::io::Error) -> Self {
        Self::Io(value)
    }
}

impl From<ExecError> for KazeError {
    fn from(value: ExecError) -> Self {
        Self::Exec(value)
    }
}

impl From<LoadError> for KazeError {
    fn from(value: LoadError) -> Self {
        Self::Load(value)
    }
}

impl From<ResolveError> for KazeError {
    fn from(value: ResolveError) -> Self {
        Self::Resolve(value)
    }
}

/// Maps YAML parsing failures into user-facing errors.
impl From<serde_yaml::Error> for KazeError {
    fn from(value: serde_yaml::Error) -> Self {
        Self::Msg(format!("failed to load yaml: {value:?}"))
    }
}

/**
 * Runs the selected CLI command.
 *
 * # Errors
 * Returns a `KazeError` when configuration discovery, loading, resolution, or
 * command execution fails.
 */
pub fn run(cli: &Cli) -> Result<(), KazeError> {
    let ctx = discover(cli.project.as_deref(), cli)
        .map_err(|e| KazeError::Msg(format!("project_dir discovery failed: {e:?}")))?;

    // do not load config for `init` and `boards` commands
    let command = &cli.command;
    let file_cfg = match *command {
        Command::Init | Command::Boards => FileConfig::default(),
        _ => load_config(&ctx.project_dir)
            .map_err(|e| KazeError::Msg(format!("load failed: {e:?}")))?,
    };

    let rc = resolve_single(cli, &ctx, &file_cfg)
        .map_err(|e| KazeError::Msg(format!("resolve failed: {e:?}")))?;

    let runner = Runner {
        verbose: cli.verbose,
        dry_run: cli.dry_run,
    };

    if cli.preclean
        && matches!(
            command,
            &Command::Clean
                | &Command::Conf(_)
                | &Command::Build(_)
                | &Command::Run(_)
                | &Command::Flash(_)
        )
    {
        clean_dir_safe(&rc.build_dir, &rc.ctx.project_dir, cli.dry_run)?;
    }

    match *command {
        Command::Init => cmd_init(&rc.ctx.project_dir)?,
        Command::Profiles => cmd_profiles(&file_cfg),
        Command::Clean => cmd_clean_root(&rc.build_root, &rc.ctx.project_dir, cli.dry_run)?,
        Command::Conf(ref p) => cmd_conf(runner, &rc, &p.extra)?,
        Command::Build(ref p) => cmd_build(runner, &rc, &p.extra)?,
        Command::Flash(ref f) => cmd_flash(runner, &rc, &f.phase.extra)?,
        Command::Run(ref r) => cmd_run(runner, &rc, r.norebuild, &r.phase.extra)?,
        Command::Boards => cmd_west_boards(runner)?,
        Command::Runners => cmd_runners(&rc)?,
    }

    Ok(())
}

/**
 * Creates a default `kaze.toml` in the project root.
 *
 * # Arguments
 * - `project_dir`: Project root directory that will contain `kaze.toml`.
 *
 * # Returns
 * `Ok(())` after writing the template file.
 *
 * # Errors
 * Returns `KazeError` if the file already exists or the template cannot be
 * written to disk.
 */
fn cmd_init(project_dir: &Path) -> Result<(), KazeError> {
    let path = project_dir.join("kaze.toml");
    if path.exists() {
        return Err(KazeError::Msg("kaze.toml already exists".into()));
    }

    let template = r#"[project]
board = "nucleo_f767zi"
runner = "openocd"

[build]
root = "build"
"#;

    fs::write(&path, template)?;
    eprintln!("created {}", path.display());
    Ok(())
}

/**
 * Prints configured profiles or a placeholder when none exist.
 *
 * # Arguments
 * - `file_cfg`: Loaded configuration containing profile definitions.
 */
fn cmd_profiles(file_cfg: &FileConfig) {
    if file_cfg.profiles.is_empty() {
        println!("(no profiles configured)");
        return;
    }

    println!("Configured profiles:");
    for (i, name) in file_cfg.profiles.keys().enumerate() {
        println!("{i:>2}: {name}");
    }
}

/**
 * Cleans the build root directory with safety checks.
 *
 * # Arguments
 * - `build_root`: Root build directory to delete.
 * - `project_dir`: Project root used to validate the delete target.
 * - `dry_run`: When true, only log the delete target instead of removing it.
 *
 * # Returns
 * `Ok(())` once the cleanup completes or is skipped.
 *
 * # Errors
 * Returns `KazeError` if the delete target is outside the project root, points
 * at the project root, or cannot be removed.
 */
fn cmd_clean_root(build_root: &Path, project_dir: &Path, dry_run: bool) -> Result<(), KazeError> {
    clean_dir_safe(build_root, project_dir, dry_run)?;
    Ok(())
}

/**
 * Runs the configure phase (`cmake`) for the resolved configuration.
 *
 * # Arguments
 * - `runner`: Command runner configuration.
 * - `rc`: Resolved configuration for the active profile.
 * - `extra`: Extra arguments to append to the `cmake` invocation.
 *
 * # Returns
 * `Ok(())` after the configure command succeeds.
 *
 * # Errors
 * Returns `KazeError` if the build directory cannot be created or if the
 * configure command fails to execute.
 */
fn cmd_conf(runner: Runner, rc: &ResolvedConfig, extra: &[String]) -> Result<(), KazeError> {
    fs::create_dir_all(&rc.build_dir)?;

    // cmake -B <build_dir> -S <project_dir> -G Ninja -DBOARD=<board>
    let mut cmd = Cmd::new("cmake")
        .arg("-B")
        .arg(os(rc.build_dir.as_path()))
        .arg("-S")
        .arg(os(rc.ctx.project_dir.as_path()))
        .arg("-G")
        .arg("Ninja")
        .arg(format!("-DBOARD={}", rc.board));

    if let Some(zb) = rc.zephyr_base.as_ref() {
        cmd = cmd.arg(format!("-DZEPHYR_BASE={}", zb.display()));
    }

    if let Some(rn) = rc.runner.as_ref() {
        cmd = cmd.arg(format!("-DBOARD_FLASH_RUNNER={rn}"));
    }

    cmd = cmd.args(extra.iter().cloned());
    runner.run(&cmd)?;

    Ok(())
}

/**
 * Ensures a build directory is configured, running configure if needed.
 *
 * # Arguments
 * - `runner`: Command runner configuration.
 * - `rc`: Resolved configuration for the active profile.
 *
 * # Returns
 * `Ok(())` when the build directory is configured.
 *
 * # Errors
 * Returns `KazeError` if configuration fails.
 */
fn ensure_configured(runner: Runner, rc: &ResolvedConfig) -> Result<(), KazeError> {
    if !is_configured(&rc.build_dir) {
        cmd_conf(runner, rc, &rc.args_conf)?;
    }

    Ok(())
}

/**
 * Runs the build phase (`ninja`) for the resolved configuration.
 *
 * # Arguments
 * - `runner`: Command runner configuration.
 * - `rc`: Resolved configuration for the active profile.
 * - `extra`: Extra arguments to append to the `ninja` invocation.
 *
 * # Returns
 * `Ok(())` after a successful build.
 *
 * # Errors
 * Returns `KazeError` if configuration or the build command fails.
 */
fn cmd_build(runner: Runner, rc: &ResolvedConfig, extra: &[String]) -> Result<(), KazeError> {
    ensure_configured(runner, rc)?;

    // ninja -C <build_dir>
    let mut cmd = Cmd::new("ninja").arg("-C").arg(os(rc.build_dir.as_path()));

    // --- <extra>
    cmd = cmd.args(extra.iter().cloned());
    runner.run(&cmd)?;

    Ok(())
}

/**
 * Ensures the project is built, invoking the build phase as needed.
 *
 * # Arguments
 * - `runner`: Command runner configuration.
 * - `rc`: Resolved configuration for the active profile.
 *
 * # Returns
 * `Ok(())` after the build completes.
 *
 * # Errors
 * Returns `KazeError` if the build command fails.
 */
fn ensure_built(runner: Runner, rc: &ResolvedConfig) -> Result<(), KazeError> {
    cmd_build(runner, rc, &rc.args_build)?;
    Ok(())
}

/**
 * Runs the flash phase (`west flash`) for the resolved configuration.
 *
 * # Arguments
 * - `runner`: Command runner configuration.
 * - `rc`: Resolved configuration for the active profile.
 * - `extra`: Extra arguments to append to the `west flash` invocation.
 *
 * # Returns
 * `Ok(())` after flashing completes.
 *
 * # Errors
 * Returns `KazeError` if the build step fails or the flash command fails.
 */
fn cmd_flash(runner: Runner, rc: &ResolvedConfig, extra: &[String]) -> Result<(), KazeError> {
    ensure_built(runner, rc)?;

    // west flash -d <build_dir>
    let mut cmd = Cmd::new("west")
        .arg("flash")
        .arg("-d")
        .arg(os(rc.build_dir.as_path()));

    // [--runner <runner>]
    if let Some(rn) = rc.runner.as_ref() {
        cmd = cmd.arg("--runner").arg(rn);
    }

    cmd = cmd.args(extra.iter().cloned());
    runner.run(&cmd)?;

    Ok(())
}

/**
 * Runs the configured target, rebuilding unless `norebuild` is set.
 *
 * # Arguments
 * - `runner`: Command runner configuration.
 * - `rc`: Resolved configuration for the active profile.
 * - `norebuild`: When true, skip the pre-run build step.
 * - `extra`: Extra arguments to pass to the simulator binary.
 *
 * # Returns
 * `Ok(())` after the run command completes.
 *
 * # Errors
 * Returns `KazeError` if the build step fails or the run command fails.
 */
fn cmd_run(
    runner: Runner,
    rc: &ResolvedConfig,
    norebuild: bool,
    extra: &[String],
) -> Result<(), KazeError> {
    if !norebuild {
        ensure_built(runner, rc)?;
    }

    // try running `zephyr/zephyr.exe` or `zephyr/zephyr` if found
    let candidates = [
        PathBuf::from("zephyr/zephyr.exe"),
        PathBuf::from("zephyr/zephyr"),
    ];

    for rel in candidates {
        let full = rc.build_dir.join(&rel);
        if full.is_file() {
            let cmd = Cmd::new(os(&rel))
                .cwd(rc.build_dir.clone())
                .args(extra.iter().cloned());
            runner.run(&cmd)?;
            return Ok(());
        }
    }

    // otherwise fallback to ninja run
    let cmd = Cmd::new("ninja")
        .arg("-C")
        .arg(os(rc.build_dir.as_path()))
        .arg("run");

    runner.run(&cmd)?;

    Ok(())
}

/**
 * Prints the flash runners detected in the build output's `runners.yaml`.
 *
 * # Arguments
 * - `rc`: Resolved configuration for the active profile.
 *
 * # Errors
 * Returns a `KazeError` when the runners file is missing or cannot be parsed.
 */
fn cmd_runners(rc: &ResolvedConfig) -> Result<(), KazeError> {
    let yaml_path = &rc.build_dir.join("zephyr/runners.yaml");
    if !yaml_path.is_file() {
        return Err(KazeError::Msg(
            "Runner config not found in build dir. Try buidling the project first with `kaze \
             build`."
                .into(),
        ));
    }

    let runners_yml = read_runners_yaml(yaml_path.as_path())?;
    let runners = yaml_str_list(&runners_yml, "runners");
    let flash_runner = yaml_str(&runners_yml, "flash-runner");

    let board = rc.board.clone();
    println!("Runners available for `{board}`:\n");
    if runners.is_empty() {
        println!("None.\n");
    } else {
        for (i, rn) in runners.iter().enumerate() {
            println!("{i:>2}: {rn}");
        }
        println!();
    }

    if let Some(rn) = flash_runner {
        println!("Configured flash runner: {rn}");
    }

    Ok(())
}

/**
 * Loads the YAML document containing runner metadata.
 *
 * # Arguments
 * - `path`: Path to the YAML file in the build output.
 *
 * # Returns
 * Parsed YAML document as a `serde_yaml::Value`.
 *
 * # Errors
 * Returns a `KazeError` when the file cannot be opened or parsed.
 */
fn read_runners_yaml(path: &Path) -> Result<Value, KazeError> {
    let f = File::open(path)?;
    Ok(from_reader(f)?)
}

/**
 * Reads a string field from a YAML mapping.
 *
 * # Arguments
 * - `v`: YAML document to read from.
 * - `key`: Mapping key to extract.
 *
 * # Returns
 * The mapped string if present and of the correct type.
 */
fn yaml_str(v: &Value, key: &str) -> Option<String> {
    v.get(key)?.as_str().map(std::convert::Into::into)
}

/**
 * Reads a list of strings from a YAML mapping.
 *
 * # Arguments
 * - `v`: YAML document to read from.
 * - `key`: Mapping key to extract.
 *
 * # Returns
 * A list of strings (empty if the key is missing or the type is unexpected).
 */
fn yaml_str_list(v: &Value, key: &str) -> Vec<String> {
    v.get(key)
        .and_then(|x| x.as_sequence())
        .into_iter()
        .flatten()
        .filter_map(|x| x.as_str().map(std::convert::Into::into))
        .collect()
}

/**
 * Lists Zephyr boards via `west boards`.
 *
 * # Arguments
 * - `runner`: Command runner configuration.
 *
 * # Returns
 * `Ok(())` after the command succeeds.
 *
 * # Errors
 * Returns `KazeError` if the `west` command fails.
 */
fn cmd_west_boards(runner: Runner) -> Result<(), KazeError> {
    let cmd = Cmd::new("west").arg("boards");
    runner.run(&cmd)?;
    Ok(())
}

/**
 * Returns true when a `CMake` cache is present in the build directory.
 *
 * # Arguments
 * - `build_dir`: Build directory to probe.
 *
 * # Returns
 * `true` when `CMakeCache.txt` exists, `false` otherwise.
 */
fn is_configured(build_dir: &Path) -> bool {
    build_dir.join("CMakeCache.txt").is_file()
}

/**
 * Removes a directory if it is inside the project root and not the root itself.
 *
 * # Arguments
 * - `dir`: Directory to remove.
 * - `project_dir`: Project root used as the safety boundary.
 * - `dry_run`: When true, only log the removal.
 *
 * # Returns
 * `Ok(())` once deletion is complete or skipped.
 *
 * # Errors
 * Returns `KazeError` when the delete target is outside the project root, is
 * the project root itself, or the filesystem operation fails.
 */
fn clean_dir_safe(dir: &Path, project_dir: &Path, dry_run: bool) -> Result<(), KazeError> {
    if !dir.exists() && !dry_run {
        return Ok(());
    }

    let proj = canonical_or(project_dir);
    let target = canonical_or(dir);

    if !target.starts_with(&proj) {
        return Err(KazeError::Msg(format!(
            "refusing to delete outside project dir: {}",
            target.display()
        )));
    }

    if proj == target {
        return Err(KazeError::Msg(format!(
            "refusing to delete project_root: {}",
            target.display()
        )));
    }

    if dry_run {
        println!("Cleaning {}", target.display());
    } else {
        fs::remove_dir_all(&target)?;
        eprintln!("removed {}", target.display());
    }

    Ok(())
}

/**
 * Returns the canonical path or falls back to the input on error.
 *
 * # Arguments
 * - `p`: Path to canonicalize.
 *
 * # Returns
 * Canonicalized path when available, otherwise the original path.
 */
fn canonical_or(p: &Path) -> PathBuf {
    p.canonicalize().unwrap_or_else(|_| p.to_path_buf())
}

/**
 * Converts a `Path` to an owned `OsString`.
 *
 * # Arguments
 * - `p`: Path to convert.
 *
 * # Returns
 * Owned `OsString` representing the provided path.
 */
fn os(p: &Path) -> OsString {
    p.as_os_str().to_os_string()
}
