use anyhow::Result;

use crate::core::{
    package::resolve_local_bin,
    resolve::{LocalBinProjectState, ProjectState, ResolveContext},
    types::{NativeLocalBinExecution, NativeScriptExecution, NativeScriptStep, PackageManager},
};

use super::{
    bin_resolver::resolve_local_bin_launcher,
    deno::{find_nearest_deno_project, plan_native_deno_task},
    plan::{FallbackReason, NativeDecision, NativePlan},
};

const SUPPORTED_NPM_PACKAGE_ENV_SUFFIXES: &[&str] = &["json"];
const SUPPORTED_NPM_CONFIG_ENV_SUFFIXES: &[&str] = &["user_agent"];

pub(super) fn plan_nr_from_state(
    pm: Option<PackageManager>,
    args: &[String],
    ctx: &ResolveContext,
    state: &ProjectState,
    has_if_present: bool,
) -> Result<NativeDecision> {
    if pm == Some(PackageManager::Deno) {
        return plan_deno_nr(args, ctx, has_if_present);
    }

    let Some(pkg) = state.nearest_package() else {
        return Ok(NativeDecision::Ineligible(
            FallbackReason::MissingNearestPackage,
        ));
    };

    if pm == Some(PackageManager::YarnBerry) && state.has_yarn_pnp_loader() {
        return Ok(NativeDecision::Ineligible(FallbackReason::YarnBerryPnp));
    }

    let scripts = pkg.manifest.scripts.unwrap_or_default();
    let script_name = args.first().cloned().unwrap_or_else(|| "start".to_string());
    let forwarded_args = args.iter().skip(1).cloned().collect::<Vec<_>>();

    let Some(script) = scripts.get(&script_name) else {
        if has_if_present {
            return Ok(NativeDecision::Eligible(NativePlan::Script(
                NativeScriptExecution {
                    package_root: pkg.root,
                    package_json_path: pkg.package_json_path,
                    script_name,
                    steps: Vec::new(),
                    forwarded_args,
                    bin_paths: state.bin_dirs().to_vec(),
                },
            )));
        }

        return Ok(NativeDecision::Ineligible(FallbackReason::MissingScript(
            script_name,
        )));
    };

    let mut steps = Vec::new();
    if let Err(reason) =
        push_step_if_present(&mut steps, &scripts, format!("pre{script_name}"), false)
    {
        return Ok(NativeDecision::Ineligible(reason));
    }
    if let Err(reason) = push_step(&mut steps, script_name.clone(), script, true) {
        return Ok(NativeDecision::Ineligible(reason));
    }
    if let Err(reason) =
        push_step_if_present(&mut steps, &scripts, format!("post{script_name}"), false)
    {
        return Ok(NativeDecision::Ineligible(reason));
    }

    Ok(NativeDecision::Eligible(NativePlan::Script(
        NativeScriptExecution {
            package_root: pkg.root,
            package_json_path: pkg.package_json_path,
            script_name,
            steps,
            forwarded_args,
            bin_paths: state.bin_dirs().to_vec(),
        },
    )))
}

pub(super) fn plan_nlx_from_local_bin_state(
    pm: Option<PackageManager>,
    args: &[String],
    state: &LocalBinProjectState,
) -> Result<NativeDecision> {
    let Some(bin_name) = args.first() else {
        return Ok(NativeDecision::Ineligible(
            FallbackReason::MissingLocalBinCommand,
        ));
    };

    if pm == Some(PackageManager::YarnBerry) && state.has_yarn_pnp_loader() {
        return Ok(NativeDecision::Ineligible(FallbackReason::YarnBerryPnp));
    }

    let bin_paths = state.bin_dirs().to_vec();
    let bin_path = if let Some(bin_path) = resolve_local_bin(bin_name, &bin_paths) {
        Some(bin_path)
    } else if pm == Some(PackageManager::Deno) {
        None
    } else {
        state.resolve_declared_package_bin(bin_name)?
    };
    plan_local_bin(bin_name, args, bin_paths, bin_path, pm)
}

fn plan_deno_nr(
    args: &[String],
    ctx: &ResolveContext,
    has_if_present: bool,
) -> Result<NativeDecision> {
    let selection = args.first().cloned().unwrap_or_else(|| "start".to_string());
    let forwarded_args = args.iter().skip(1).cloned().collect::<Vec<_>>();
    let Some(project) = find_nearest_deno_project(ctx.cwd())? else {
        return Ok(NativeDecision::Ineligible(
            FallbackReason::MissingNearestDenoProject,
        ));
    };

    Ok(
        match plan_native_deno_task(&project, &selection, &forwarded_args, has_if_present) {
            Ok(exec) => NativeDecision::Eligible(NativePlan::DenoTask(exec)),
            Err(reason) => NativeDecision::Ineligible(FallbackReason::DenoTask(reason.to_string())),
        },
    )
}

fn plan_local_bin(
    bin_name: &str,
    args: &[String],
    bin_paths: Vec<std::path::PathBuf>,
    bin_path: Option<std::path::PathBuf>,
    pm: Option<PackageManager>,
) -> Result<NativeDecision> {
    let Some(bin_path) = bin_path else {
        return Ok(NativeDecision::Ineligible(FallbackReason::MissingLocalBin));
    };

    Ok(NativeDecision::Eligible(NativePlan::LocalBin(
        NativeLocalBinExecution {
            bin_name: bin_name.to_string(),
            launcher: resolve_local_bin_launcher(&bin_path)?,
            forwarded_args: args.iter().skip(1).cloned().collect(),
            bin_paths,
            package_manager: pm.unwrap_or(PackageManager::Npm),
        },
    )))
}

fn push_step_if_present(
    steps: &mut Vec<NativeScriptStep>,
    scripts: &std::collections::BTreeMap<String, String>,
    event_name: String,
    forward_args: bool,
) -> std::result::Result<(), FallbackReason> {
    let Some(command) = scripts.get(&event_name) else {
        return Ok(());
    };

    push_step(steps, event_name, command, forward_args)
}

fn push_step(
    steps: &mut Vec<NativeScriptStep>,
    event_name: String,
    command: &str,
    forward_args: bool,
) -> std::result::Result<(), FallbackReason> {
    if let Some(pattern) = unsupported_pattern(command) {
        return Err(FallbackReason::UnsupportedScriptEnv {
            event_name,
            pattern,
        });
    }

    steps.push(NativeScriptStep {
        event_name,
        command: command.to_string(),
        forward_args,
    });
    Ok(())
}

fn unsupported_pattern(script: &str) -> Option<&'static str> {
    if contains_unsupported_prefixed_env(script, "npm_package_", SUPPORTED_NPM_PACKAGE_ENV_SUFFIXES)
    {
        return Some("npm_package_");
    }

    if contains_unsupported_prefixed_env(script, "npm_config_", SUPPORTED_NPM_CONFIG_ENV_SUFFIXES) {
        return Some("npm_config_");
    }

    None
}

fn contains_unsupported_prefixed_env(
    script: &str,
    prefix: &str,
    supported_suffixes: &[&str],
) -> bool {
    let mut search_from = 0;
    while let Some(offset) = script[search_from..].find(prefix) {
        let prefix_start = search_from + offset + prefix.len();
        let rest = &script[prefix_start..];
        let supported = supported_suffixes.iter().any(|suffix| {
            rest.starts_with(suffix)
                && rest[suffix.len()..]
                    .chars()
                    .next()
                    .is_none_or(|ch| !matches!(ch, 'a'..='z' | 'A'..='Z' | '0'..='9' | '_'))
        });
        if !supported {
            return true;
        }
        search_from = prefix_start;
    }

    false
}

#[cfg(test)]
mod tests {
    use super::unsupported_pattern;

    #[test]
    fn unsupported_pattern_allows_supported_npm_env_expansions() {
        assert_eq!(unsupported_pattern("echo $npm_package_json"), None);
        assert_eq!(unsupported_pattern("echo $npm_config_user_agent"), None);
    }

    #[test]
    fn unsupported_pattern_flags_unknown_npm_env_expansions() {
        assert_eq!(
            unsupported_pattern("echo $npm_package_name"),
            Some("npm_package_")
        );
        assert_eq!(
            unsupported_pattern("echo $npm_config_registry"),
            Some("npm_config_")
        );
    }
}
