//! Project configuration discovery helpers.

use std::fmt;
use std::path::{Path, PathBuf};

use crate::config::schema::FileConfig;
use crate::core::cli::{Cli, Command};

/// Configuration discovery context.
#[derive(Debug, Clone)]
pub struct ConfigCtx {
    /// Resolved project root containing kaze.toml.
    pub project_dir: PathBuf,
    /// Current working directory at invocation time.
    pub cwd: PathBuf,
    /// Whether kaze was invoked from inside build/ directory.
    pub invoked_from_build: bool,
}

/// Errors from configuration discovery.
#[derive(Debug)]
pub enum ConfigError {
    /// I/O error during discovery.
    Io(std::io::Error),
    /// No kaze.toml found while searching upwards.
    NotFound {
        /// Directory where the upward search started.
        start: PathBuf,
    },
}

impl fmt::Display for ConfigError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match *self {
            Self::Io(ref e) => write!(f, "config error: {e}"),
            Self::NotFound { ref start } => write!(f, "config not found in: {}", start.display()),
        }
    }
}

impl std::error::Error for ConfigError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match *self {
            Self::Io(ref e) => Some(e),
            Self::NotFound { .. } => None,
        }
    }
}

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

/**
 * Discovers project configuration context.
 *
 * # Errors
 * Returns `ConfigError::Io` for filesystem failures and
 * `ConfigError::NotFound` when kaze.toml cannot be located.
 */
pub fn discover(project_override: Option<&Path>, cli: &Cli) -> Result<ConfigCtx, ConfigError> {
    let cwd = std::env::current_dir()?;
    let start = project_override.unwrap_or(&cwd);

    // ignore missing config file for `init` and `boards` commands
    let command = &cli.command;
    let project_dir = match *command {
        Command::Init | Command::Boards => cwd.clone(),
        _ => find_upwards(start, "kaze.toml").ok_or_else(|| ConfigError::NotFound {
            start: start.to_path_buf(),
        })?,
    };

    let invoked_from_build = cwd.starts_with(project_dir.join("build"));
    Ok(ConfigCtx {
        project_dir,
        cwd,
        invoked_from_build,
    })
}

/**
 * Walks upward from `start` looking for a marker file, returning the directory
 * that contains it.
 *
 * # Arguments
 * - `start`: Directory to begin the upward search from.
 * - `marker`: Marker filename to locate.
 *
 * # Returns
 * Directory containing the marker file, or `None` if not found.
 */
#[must_use]
fn find_upwards(start: &Path, marker: &str) -> Option<PathBuf> {
    let mut cur = start;
    loop {
        if cur.join(marker).is_file() {
            return Some(cur.to_path_buf());
        }
        cur = cur.parent()?;
    }
}

/// Resolved configuration values for a single profile.
#[derive(Debug, Clone)]
pub struct ResolvedConfig {
    /// Discovery context used to resolve paths.
    pub ctx: ConfigCtx,
    /// Selected profile name, if any.
    pub profile: Option<String>,
    /// Resolved board name.
    pub board: String,
    /// Resolved runner name.
    pub runner: Option<String>,

    /// Project build root path.
    pub build_root: PathBuf,
    /// Build directory for the resolved profile.
    pub build_dir: PathBuf,

    /// Whether the project uses sysbuild.
    pub sysbuild: bool,

    /// Zephyr workspace root, if detected.
    pub zephyr_ws: Option<PathBuf>,
    /// Zephyr base path, if detected.
    pub zephyr_base: Option<PathBuf>,

    /// Extra args for the configure step.
    pub args_conf: Vec<String>,
    /// Extra args for the build step.
    pub args_build: Vec<String>,
    /// Extra args for the flash step.
    pub args_flash: Vec<String>,
    /// Extra args for the run step.
    pub args_run: Vec<String>,
}

/// Errors during configuration resolution.
#[derive(Debug)]
pub enum ResolveError {
    /// No board can be resolved.
    NoBoardResolved,
    /// Profile name does not exist.
    UnknownProfile(String),
}

impl fmt::Display for ResolveError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match *self {
            Self::NoBoardResolved => write!(f, "no board resolved"),
            Self::UnknownProfile(ref profile) => write!(f, "unknown profile: {profile}"),
        }
    }
}

impl std::error::Error for ResolveError {}

/**
 * Resolves a single configuration profile.
 *
 * # Errors
 * Returns `ResolveError::UnknownProfile` when an explicit profile is unknown and
 * `ResolveError::NoBoardResolved` when no board can be determined.
 *
 * # Panics
 * Panics if profile metadata is inconsistent with selection results
 * (e.g. a selected profile is missing from the config).
 */
pub fn resolve_single(
    cli: &Cli,
    ctx: &ConfigCtx,
    cfg: &FileConfig,
) -> Result<ResolvedConfig, ResolveError> {
    let profile = select_profile(cli, cfg)?;

    // base values from global project config
    let mut board = cfg.project.board.clone();
    let mut runner = cfg.project.runner.clone();

    // apply profile overrides
    if let Some(ref p) = profile {
        let prof = cfg
            .profiles
            .get(p)
            .ok_or_else(|| ResolveError::UnknownProfile(p.clone()))?;
        if prof.board.is_some() {
            board.clone_from(&prof.board);
        }
        if prof.runner.is_some() {
            runner.clone_from(&prof.runner);
        }
    }

    // apply CLI overrides
    if cli.board.is_some() {
        board.clone_from(&cli.board);
    }
    if cli.runner.is_some() {
        runner.clone_from(&cli.runner);
    }

    // ignore board resolution for `init` and `boards` commands
    let command = &cli.command;
    let board_resolved = match *command {
        Command::Init | Command::Boards => String::default(),
        _ => board.ok_or(ResolveError::NoBoardResolved)?,
    };

    // build dir resolution
    let build_root = ctx.project_dir.join(&cfg.build.root);
    let build_dir = profile
        .as_ref()
        .map_or_else(|| build_root.clone(), |p| build_root.join(p));

    // sysbuild detection
    let sysbuild = is_sysbuild(&ctx.project_dir);

    // zephyr workspace discovery
    let (zephyr_ws, zephyr_base) = discover_zephyr(&ctx.project_dir, cfg);

    // extra args
    let mut args_conf = cfg.args.conf.to_vec();
    let mut args_build = cfg.args.build.to_vec();
    let mut args_flash = cfg.args.flash.to_vec();
    let mut args_run = cfg.args.run.to_vec();

    if let Some(ref p) = profile {
        let prof = cfg.profiles.get(p).expect("checked above");
        args_conf.extend(prof.args.conf.to_vec());
        args_build.extend(prof.args.build.to_vec());
        args_flash.extend(prof.args.flash.to_vec());
        args_run.extend(prof.args.run.to_vec());
    }

    Ok(ResolvedConfig {
        ctx: ctx.clone(),
        profile,
        board: board_resolved,
        runner,
        build_root,
        build_dir,
        sysbuild,
        zephyr_ws,
        zephyr_base,
        args_conf,
        args_build,
        args_flash,
        args_run,
    })
}

/**
 * Resolves configuration for all profiles.
 *
 * # Returns
 * Vector of per-profile results in profile iteration order.
 */
#[must_use]
pub fn resolve_all(
    cli: &Cli,
    ctx: &ConfigCtx,
    cfg: &FileConfig,
) -> Vec<Result<ResolvedConfig, ResolveError>> {
    if cfg.profiles.is_empty() {
        return vec![resolve_single(cli, ctx, cfg)];
    }
    cfg.profiles
        .keys()
        .cloned()
        .map(|p| {
            let mut cli2 = cli.clone();
            cli2.profile = Some(p);
            cli2.all = false;
            resolve_single(&cli2, ctx, cfg)
        })
        .collect()
}

/**
 * Chooses the active profile based on CLI flags and available profiles.
 *
 * # Arguments
 * - `cli`: Parsed CLI options.
 * - `cfg`: Loaded configuration for profile lookup.
 *
 * # Returns
 * Selected profile name (or `None` when profiles are absent).
 *
 * # Errors
 * Returns `ResolveError::UnknownProfile` when a requested profile does not exist.
 */
fn select_profile(cli: &Cli, cfg: &FileConfig) -> Result<Option<String>, ResolveError> {
    if cfg.profiles.is_empty() {
        return Ok(None);
    }

    if cli.all {
        return Ok(Some(default_profile(cfg)));
    }

    if let Some(p) = cli.profile.as_deref() {
        if !cfg.profiles.contains_key(p) {
            return Err(ResolveError::UnknownProfile(p.to_owned()));
        }

        return Ok(Some(p.to_owned()));
    }

    Ok(Some(default_profile(cfg)))
}

/**
 * Picks the configured default profile, or falls back to the first entry.
 *
 * # Arguments
 * - `cfg`: Loaded configuration containing profiles.
 *
 * # Returns
 * Selected profile name.
 */
fn default_profile(cfg: &FileConfig) -> String {
    if let Some(p) = cfg.project.default_profile.as_deref() {
        if cfg.profiles.contains_key(p) {
            return p.to_owned();
        }
    }

    cfg.profiles.keys().next().expect("non-empty").clone()
}

/**
 * Returns true when sysbuild configuration files/directories are present.
 *
 * # Arguments
 * - `project_dir`: Project root directory.
 *
 * # Returns
 * `true` when sysbuild configuration is detected, `false` otherwise.
 */
fn is_sysbuild(project_dir: &Path) -> bool {
    project_dir.join("sysbuild.conf").is_file() || project_dir.join("sysbuild").is_dir()
}

/**
 * Resolves Zephyr workspace/base from config, environment, or workspace scan.
 *
 * # Arguments
 * - `project_dir`: Project root used for workspace discovery.
 * - `cfg`: Loaded configuration for explicit workspace/base overrides.
 *
 * # Returns
 * Tuple of `(workspace, base)` when detected, otherwise `(None, None)`.
 */
fn discover_zephyr(project_dir: &Path, cfg: &FileConfig) -> (Option<PathBuf>, Option<PathBuf>) {
    // 1) explicit config
    let ws = cfg.zephyr.workspace.as_ref().map(PathBuf::from);
    let base = cfg.zephyr.base.as_ref().map(PathBuf::from);

    if ws.is_some() || base.is_some() {
        return (
            ws.clone(),
            base.or_else(|| ws.as_ref().map(|w| w.join("zephyr"))),
        );
    }

    // 2) env vars
    if let Some(zb) = std::env::var_os("ZEPHYR_BASE") {
        let base_env = PathBuf::from(zb);
        let ws_env = base_env
            .parent()
            .and_then(|p| p.parent().map(std::path::Path::to_path_buf));

        return (ws_env, Some(base_env));
    }

    // 3) try finding workspace
    if let Some(ws_find) = find_zephyr_workspace(project_dir) {
        return (Some(ws_find.clone()), Some(ws_find.join("zephyr")));
    }

    (None, None)
}

/**
 * Walks upward looking for a `.west` workspace root.
 *
 * # Arguments
 * - `start`: Directory to begin the upward search from.
 *
 * # Returns
 * Workspace root path if found, otherwise `None`.
 */
fn find_zephyr_workspace(start: &Path) -> Option<PathBuf> {
    let mut cur = start;
    loop {
        if cur.join(".west").is_dir() {
            return Some(cur.to_path_buf());
        }
        cur = cur.parent()?;
    }
}

#[cfg(test)]
mod tests {
    use std::sync::atomic::{AtomicUsize, Ordering};

    use clap::Parser;

    use super::*;
    use crate::config::schema::{
        BuildCfg, FileConfig, PhaseArgsCfg, ProfileCfg, ProjectCfg, ZephyrCfg,
    };

    fn make_temp_dir(label: &str) -> PathBuf {
        static COUNTER: AtomicUsize = AtomicUsize::new(0);
        let mut dir = std::env::temp_dir();
        dir.push(format!(
            "nishikaze-core-config-{}-{}",
            label,
            COUNTER.fetch_add(1, Ordering::Relaxed)
        ));
        if dir.exists() {
            std::fs::remove_dir_all(&dir).expect("clean temp dir");
        }
        std::fs::create_dir_all(&dir).expect("create temp dir");
        dir
    }

    fn base_cfg() -> FileConfig {
        FileConfig {
            project: ProjectCfg {
                board: Some("b1".into()),
                runner: Some("r1".into()),
                default_profile: Some("dev".into()),
                name: None,
            },
            build: BuildCfg {
                root: "build".into(),
                link_compile_commands: None,
            },
            zephyr: ZephyrCfg::default(),
            args: PhaseArgsCfg::default(),
            ..FileConfig::default()
        }
    }

    fn ctx() -> ConfigCtx {
        ConfigCtx {
            project_dir: PathBuf::from("/tmp/project"),
            cwd: PathBuf::from("/tmp/project"),
            invoked_from_build: false,
        }
    }

    #[test]
    fn select_profile_unknown_returns_error() {
        let mut cfg = base_cfg();
        cfg.profiles.insert("dev".into(), ProfileCfg::default());
        let mut cli = Cli::try_parse_from(["kaze", "clean"]).expect("parse ok");
        cli.profile = Some("missing".into());
        let err = select_profile(&cli, &cfg).expect_err("unknown profile");
        assert!(matches!(err, ResolveError::UnknownProfile(_)));
    }

    #[test]
    fn select_profile_default_used_when_all_and_profiles_exist() {
        let mut cfg = base_cfg();
        cfg.profiles.insert("dev".into(), ProfileCfg::default());
        cfg.profiles.insert("prod".into(), ProfileCfg::default());

        let mut cli = Cli::try_parse_from(["kaze", "clean"]).expect("parse ok");
        cli.all = true;

        let selected = select_profile(&cli, &cfg).expect("select ok");
        assert_eq!(selected.as_deref(), Some("dev"));
    }

    #[test]
    fn resolve_single_errors_without_board() {
        let mut cfg = base_cfg();
        cfg.project.board = None;
        cfg.profiles.insert("dev".into(), ProfileCfg::default());

        let cli = Cli::try_parse_from(["kaze", "clean"]).expect("parse ok");
        let err = resolve_single(&cli, &ctx(), &cfg).expect_err("missing board");
        assert!(matches!(err, ResolveError::NoBoardResolved));
    }

    #[test]
    fn resolve_single_applies_profile_overrides() {
        let mut cfg = base_cfg();
        cfg.profiles.insert(
            "dev".into(),
            ProfileCfg {
                board: Some("b2".into()),
                runner: Some("r2".into()),
                args: PhaseArgsCfg::default(),
            },
        );

        let mut cli = Cli::try_parse_from(["kaze", "clean"]).expect("parse ok");
        cli.profile = Some("dev".into());

        let rc = resolve_single(&cli, &ctx(), &cfg).expect("resolve ok");
        assert_eq!(rc.board, "b2");
        assert_eq!(rc.runner.as_deref(), Some("r2"));
    }

    #[test]
    fn resolve_single_cli_overrides_profile() {
        let mut cfg = base_cfg();
        cfg.profiles.insert(
            "dev".into(),
            ProfileCfg {
                board: Some("b2".into()),
                runner: Some("r2".into()),
                args: PhaseArgsCfg::default(),
            },
        );

        let mut cli = Cli::try_parse_from(["kaze", "clean"]).expect("parse ok");
        cli.profile = Some("dev".into());
        cli.board = Some("b3".into());
        cli.runner = Some("r3".into());

        let rc = resolve_single(&cli, &ctx(), &cfg).expect("resolve ok");
        assert_eq!(rc.board, "b3");
        assert_eq!(rc.runner.as_deref(), Some("r3"));
    }

    #[test]
    fn resolve_all_resolves_each_profile() {
        let mut cfg = base_cfg();
        cfg.profiles.insert("dev".into(), ProfileCfg::default());
        cfg.profiles.insert(
            "prod".into(),
            ProfileCfg {
                board: Some("b2".into()),
                runner: None,
                args: PhaseArgsCfg::default(),
            },
        );

        let cli = Cli::try_parse_from(["kaze", "clean"]).expect("parse ok");
        let results = resolve_all(&cli, &ctx(), &cfg);
        assert_eq!(results.len(), 2);
        assert!(results.iter().all(Result::is_ok));
    }

    #[test]
    fn is_sysbuild_detects_conf_and_dir() {
        let dir = make_temp_dir("sysbuild-detect");
        assert!(!is_sysbuild(&dir));

        std::fs::write(dir.join("sysbuild.conf"), "").expect("write sysbuild.conf");
        assert!(is_sysbuild(&dir));

        std::fs::remove_file(dir.join("sysbuild.conf")).expect("remove sysbuild.conf");
        std::fs::create_dir_all(dir.join("sysbuild")).expect("mkdir sysbuild");
        assert!(is_sysbuild(&dir));
    }

    #[test]
    fn discover_zephyr_prefers_explicit_config() {
        let dir = make_temp_dir("zephyr-explicit");
        let mut cfg = base_cfg();
        cfg.zephyr.workspace = Some(dir.join("ws").to_string_lossy().into_owned());
        cfg.zephyr.base = Some(dir.join("zb").to_string_lossy().into_owned());

        let (ws, base) = discover_zephyr(&dir, &cfg);
        assert_eq!(ws, Some(dir.join("ws")));
        assert_eq!(base, Some(dir.join("zb")));
    }

    #[test]
    fn discover_zephyr_uses_env_base() {
        let dir = make_temp_dir("zephyr-env");
        let base = dir.join("west").join("zephyr");
        std::fs::create_dir_all(&base).expect("mkdir zephyr base");

        let old = std::env::var_os("ZEPHYR_BASE");
        // SAFETY: Test controls process environment; no concurrent access here.
        unsafe {
            std::env::set_var("ZEPHYR_BASE", &base);
        }

        let cfg = base_cfg();
        let (ws, zb) = discover_zephyr(&dir, &cfg);
        assert_eq!(zb, Some(base.clone()));
        let expected_ws = base
            .parent()
            .expect("base has parent")
            .parent()
            .expect("base has grandparent")
            .to_path_buf();
        assert_eq!(ws, Some(expected_ws));

        if let Some(old) = old {
            // SAFETY: Restoring environment to prior state within this test.
            unsafe {
                std::env::set_var("ZEPHYR_BASE", old);
            }
        } else {
            // SAFETY: Removing test-scoped environment override.
            unsafe {
                std::env::remove_var("ZEPHYR_BASE");
            }
        }
    }

    #[test]
    fn discover_zephyr_finds_workspace() {
        let dir = make_temp_dir("zephyr-workspace");
        let ws = dir.join("ws");
        std::fs::create_dir_all(ws.join(".west")).expect("mkdir .west");

        let cfg = base_cfg();
        let (found_ws, found_base) = discover_zephyr(&ws, &cfg);
        assert_eq!(found_ws, Some(ws.clone()));
        assert_eq!(found_base, Some(ws.join("zephyr")));
    }

    #[test]
    fn discover_zephyr_none_when_absent() {
        let dir = make_temp_dir("zephyr-none");
        let cfg = base_cfg();
        let (ws, base) = discover_zephyr(&dir, &cfg);
        assert_eq!(ws, None);
        assert_eq!(base, None);
    }
}
