#![allow(clippy::result_large_err)]
#![allow(unused_imports)]
#![allow(unused_variables)]
#![allow(dead_code)]
#![allow(unreachable_code)]

use std::{
    collections::HashMap,
    error::Error,
    io::Write,
    path::{Path, PathBuf},
    process::{exit, Command},
};

use radicle_ci_ambient::ambient::{DistilledPlan, ProjectsBuilder};
use radicle_ci_broker::msg::helper::AdminLog;

use clap::{Parser, ValueEnum};
use directories::ProjectDirs;
use log::{debug, info, trace};
use serde::{Deserialize, Serialize};
use tempfile::tempdir;

const APP: &str = "rad-ci";
const ORG: &str = "Radicle Project";
const QUAL: &str = "xyz.radicle";

// ANSI terminal control sequence to reset all colors. This will
// output as gibberish if the terminal isn't ANSI compatible.
const ANSI_RESET: &str = "\x1B[0m";

fn main() {
    if let Err(err) = fallible_main() {
        eprintln!("ERROR: {err}");
        let mut err = err.source();
        while let Some(underlying) = err {
            eprintln!("caused by: {underlying}");
            err = underlying.source();
        }
        exit(1);
    }
}

fn fallible_main() -> Result<(), RadCiError> {
    setup_logging();
    info!("program starts");
    let args = Args::parse();
    debug!("{args:#?}");

    let config = if let Some(filename) = &args.config {
        trace!("--config used on command line: {}", filename.display());
        ConfigFile::read(filename)?
    } else {
        let dirs = ProjectDirs::from(QUAL, ORG, APP).ok_or(RadCiError::ConfigDir)?;
        let filename = dirs.config_dir().join("config.yaml");
        if filename.exists() {
            trace!("read default configuration file: {}", filename.display());
            ConfigFile::read(&filename)?
        } else {
            trace!("use built-in default config");
            ConfigFile::default()
        }
    };
    debug!("{config:#?}");

    let cfg = args.runtime_config(&config)?;
    debug!("{cfg:#?}");

    match &args.cmd {
        None => RunCmd::default().run(&cfg)?,
        Some(Cmd::Run(x)) => x.run(&cfg)?,
        Some(Cmd::Config(x)) => x.run(&cfg)?,
    }

    if !args.vague {
        println!("{ANSI_RESET}\nEverything went fine.");
    }
    Ok(())
}

#[derive(Debug, Parser)]
#[clap(version)]
struct Args {
    /// Read this configuration file.
    #[clap(long)]
    config: Option<PathBuf>,

    /// Only pretend to run CI.
    #[clap(long)]
    dry_run: bool,

    /// Use this directory for the project source code, instead of the
    /// current working directory.
    #[clap(long, default_value = ".")]
    source: Option<PathBuf>,

    /// Use this CI engine.
    #[clap(long)]
    engine: Option<EngineKind>,

    /// Use this virtual machine image for Ambient.
    #[clap(long)]
    ambient_image: Option<PathBuf>,

    /// By default run-ci ends a successful run with an explicit message
    /// saying everything went OK. Use this option to turn that off.
    #[clap(long)]
    vague: bool,

    /// By default, run log is not shown for a successful run, only
    /// for a failed run. This option always shows it.
    #[clap(long)]
    verbose: bool,

    #[clap(subcommand)]
    cmd: Option<Cmd>,
}

impl Args {
    fn runtime_config(&self, config: &ConfigFile) -> Result<RuntimeConfig, RadCiError> {
        fn value(
            option: &Option<PathBuf>,
            file: &Option<PathBuf>,
        ) -> Result<Option<PathBuf>, RadCiError> {
            let chosen = if let Some(filename) = option.as_ref().or(file.as_ref()) {
                trace!("value: filename={}", filename.display());
                Ok(Some(abspath(filename)?))
            } else {
                Ok(None)
            };
            trace!("value: option={option:?} file={file:?} chosen={chosen:?}");
            chosen
        }

        fn abspath(path: &Path) -> Result<PathBuf, RadCiError> {
            let res = path
                .canonicalize()
                .map_err(|err| RadCiError::Canonicalize(path.into(), err));
            trace!("abspath: path={} res={:?}", path.display(), res);
            res
        }

        trace!("compute run-time configuration");

        let source = abspath(&self.source.clone().unwrap());
        trace!("source {source:?}");

        let image = value(&self.ambient_image, &config.ambient_image);
        trace!("ambient_image {image:?}");

        Ok(RuntimeConfig {
            dry_run: self.dry_run,
            verbose: self.verbose,
            source: source?,
            engine: self.engine,
            ambient_image: image?,
            ambient_state: None,
        })
    }
}

fn setup_logging() {
    const VAR: &str = "RAD_CI_LOG";
    if std::env::var(VAR).is_ok() {
        env_logger::init_from_env(VAR);
    } else {
        env_logger::builder()
            .filter_level(log::LevelFilter::Warn)
            .init();
    }
}

#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct ConfigFile {
    ambient_image: Option<PathBuf>,
}

impl ConfigFile {
    fn read(filename: &Path) -> Result<Self, RadCiError> {
        trace!("try to read configuration file {}", filename.display());
        let data =
            std::fs::read(filename).map_err(|err| RadCiError::ReadConfig(filename.into(), err))?;
        let config = serde_yml::from_slice(&data)
            .map_err(|err| RadCiError::ParseConfig(filename.into(), err))?;
        trace!("read configuration file OK");
        Ok(config)
    }
}

#[derive(Debug, Serialize)]
struct RuntimeConfig {
    dry_run: bool,
    verbose: bool,
    source: PathBuf,
    engine: Option<EngineKind>,
    ambient_image: Option<PathBuf>,
    ambient_state: Option<PathBuf>,
}

#[derive(Debug, Parser)]
enum Cmd {
    Config(ConfigCmd),
    Run(RunCmd),
}

#[derive(Debug, Parser)]
struct ConfigCmd {}

impl ConfigCmd {
    fn run(&self, cfg: &RuntimeConfig) -> Result<(), RadCiError> {
        info!("config cmd");
        println!("{}", serde_json::to_string_pretty(&cfg).unwrap());
        Ok(())
    }
}

#[derive(Debug, Default, Parser)]
struct RunCmd {}

impl RunCmd {
    fn run(&self, cfg: &RuntimeConfig) -> Result<(), RadCiError> {
        let mut chooser = EngineChooser::new(&cfg.source);
        if let Some(engine) = cfg.engine {
            chooser.force(engine);
        }

        let engine = chooser.pick()?;

        if cfg.dry_run {
            println!("{}", engine.to_json()?);
        } else {
            engine.run(cfg)?;
        }

        Ok(())
    }
}

#[derive(Debug, Copy, Clone, Serialize, ValueEnum, Eq, PartialEq)]
enum EngineKind {
    Ambient,
    Native,
}

struct EngineChooser {
    source: PathBuf,
    forced: Option<EngineKind>,
}

impl EngineChooser {
    fn new(source: &Path) -> Self {
        Self {
            source: source.into(),
            forced: None,
        }
    }

    fn force(&mut self, engine: EngineKind) {
        self.forced = Some(engine);
    }

    fn pick(&self) -> Result<Engine, RadCiError> {
        if let Some(engine) = &self.forced {
            self.pick_forced(engine)
        } else {
            self.pick_automatically()
        }
    }

    fn pick_forced(&self, engine: &EngineKind) -> Result<Engine, RadCiError> {
        let filename = self.filename_for_engine(engine)?;
        match engine {
            EngineKind::Ambient => Engine::ambient(&filename),
            EngineKind::Native => Engine::native(&filename),
        }
    }

    fn pick_automatically(&self) -> Result<Engine, RadCiError> {
        for (filename, kind) in self.markers() {
            if filename.exists() {
                trace!("{} exists, assuming {:?}", filename.display(), kind);
                return self.pick_forced(&kind);
            } else {
                trace!("{} does not exist, thus not {:?}", filename.display(), kind);
            }
        }
        info!("no marker files exist, giving up trying to pick CI engine");
        Err(RadCiError::What)
    }

    const MARKERS: &[(&'static str, EngineKind)] = &[
        (".radicle/ambient.yaml", EngineKind::Ambient),
        (".radicle/native.yaml", EngineKind::Native),
    ];

    fn markers(&self) -> impl Iterator<Item = (PathBuf, EngineKind)> + use<'_> {
        Self::MARKERS
            .iter()
            .map(|(filename, engine)| (self.source.join(filename), *engine))
    }

    fn filename_for_engine(&self, engine: &EngineKind) -> Result<PathBuf, RadCiError> {
        let filenames: Vec<_> = self
            .markers()
            .filter_map(|(filename, kind)| {
                if kind == *engine {
                    Some(filename)
                } else {
                    None
                }
            })
            .collect();
        match filenames.len() {
            0 => Err(RadCiError::What),
            1 => Ok(filenames[0].clone()),
            _ => Err(RadCiError::What),
        }
    }
}

#[derive(Debug, Serialize)]
enum Engine {
    Ambient { distilled: DistilledPlan },
    Native { shell: String },
}

impl Engine {
    fn ambient(plan_filename: &Path) -> Result<Self, RadCiError> {
        let mut distilled = DistilledPlan::read(plan_filename)?;
        distilled.drop_post_plan();
        Ok(Self::Ambient { distilled })
    }

    fn native(plan_filename: &Path) -> Result<Self, RadCiError> {
        let plan = radicle_native_ci::runspec::RunSpec::from_file(plan_filename)
            .map_err(|err| RadCiError::ReadNativeSpec(plan_filename.into(), err))?;
        Ok(Self::Native { shell: plan.shell })
    }

    fn to_json(&self) -> Result<String, RadCiError> {
        serde_json::to_string_pretty(self).map_err(RadCiError::ReportJson)
    }

    fn run(&self, cfg: &RuntimeConfig) -> Result<(), RadCiError> {
        match self {
            Self::Ambient { distilled } => self.run_ambient(cfg, distilled)?,
            Self::Native { shell } => self.run_native(cfg, shell)?,
        }
        Ok(())
    }

    fn run_ambient(
        &self,
        cfg: &RuntimeConfig,
        distilled: &DistilledPlan,
    ) -> Result<(), RadCiError> {
        trace!("cfg={cfg:#?}");

        let image = cfg
            .ambient_image
            .as_ref()
            .ok_or(RadCiError::NoAmbientImage)?;
        trace!("image={}", image.display());

        let src = cfg
            .source
            .canonicalize()
            .map_err(|err| RadCiError::Canonicalize(cfg.source.clone(), err))?;
        let filename = src.join(".radicle/ambient.yaml");
        DistilledPlan::read(&filename)?;

        let project_name = project_name(&src)?;
        trace!("using project name {project_name}");

        let projects = ProjectsBuilder::default()
            .name(&project_name)
            .source(&src)
            .image(image)
            .artifact_max_size(1024 * 1024 * 1024)
            .cache_max_size(20 * 1024 * 1024 * 1024)
            .plan(distilled)
            .build();

        trace!("running ambient");
        let mut adminlog = AdminLog::capture();

        let tmp = tempdir().map_err(RadCiError::TempDir)?;
        let output = radicle_ci_ambient::ambient::run_ambient_ci(
            tmp.path(),
            &mut adminlog,
            false,
            &projects,
            None,
            &HashMap::new(),
            None,
        )?;
        trace!("ran ambient exit code: {}", output.exit);

        if output.exit != 0 || cfg.verbose {
            eprintln!("capturing adminglog");
            if let Some(buf) = adminlog.capture_buffer() {
                let buf = String::from_utf8_lossy(buf);
                println!("captured adminlog:\n{}", indent(&buf, 4));
                eprintln!("wrote captured adminlog");
            } else {
                eprintln!("failed to get captured adminlog");
                Err(RadCiError::AdminLogBuffer)?;
            }

            let run_log = String::from_utf8_lossy(&output.stdout);
            let run_log = indent(&run_log, 4);
            println!("run log from Ambient:");
            std::io::stdout()
                .write_all(run_log.as_bytes())
                .map_err(RadCiError::WriteLog)?;
        }

        if output.exit != 0 {
            return Err(RadCiError::AmbientFailed);
        }

        Ok(())
    }

    fn run_native(&self, cfg: &RuntimeConfig, shell: &str) -> Result<(), RadCiError> {
        let shell = format!("set -xeuo pipefail\nunset RUST_TOOLCHAIN\n{shell}");
        debug!("execute bash script:\n{shell}");
        debug!("execute in {}", cfg.source.display());
        // We run bash directly with Command here, not using the
        // runcmd function, so that output goes to our stdout/stderr
        // directly and isn't captured.
        let exit = Command::new("bash")
            .args(["-c", &shell])
            .current_dir(&cfg.source)
            .spawn()
            .map_err(RadCiError::BashFailed)?
            .wait()
            .map_err(RadCiError::BashFailed)?;
        if !exit.success() {
            Err(RadCiError::NativeFailed)
        } else {
            Ok(())
        }
    }
}

fn project_name(src: &Path) -> Result<String, RadCiError> {
    if let Ok((_, repoid)) = radicle::rad::at(src) {
        Ok(format!("{repoid}"))
    } else {
        src.file_name()
            .map(|name| name.to_string_lossy().to_string())
            .ok_or(RadCiError::ProjectName(src.into()))
    }
}

fn indent(s: &str, n: usize) -> String {
    let mut out = String::new();
    for line in s.lines() {
        for _ in 0..n {
            out.push(' ');
        }
        out.push_str(line);
        out.push('\n');
    }
    out
}

#[derive(Debug, thiserror::Error)]
enum RadCiError {
    #[error("can't determine what CI engine to emulate")]
    What,

    #[error("failed to read native CI adapter run specification file {0}")]
    ReadNativeSpec(PathBuf, #[source] radicle_native_ci::runspec::RunSpecError),

    #[error("failed to serialize engine choice report to JSON")]
    ReportJson(#[source] serde_json::Error),

    #[error("failed to run Bash to execute native CI snippet")]
    BashFailed(#[source] std::io::Error),

    #[error("native CI shell snippet failed")]
    NativeFailed,

    #[error("CI run under Ambient failed")]
    AmbientFailed,

    #[error("failed to write log to stdout")]
    WriteLog(#[source] std::io::Error),

    #[error("failed to make directory path name canonical: {0}")]
    Canonicalize(PathBuf, #[source] std::io::Error),

    #[error("run time configuration lacks Ambient image")]
    NoAmbientImage,

    #[error("failed to read configuration file {0}")]
    ReadConfig(PathBuf, #[source] std::io::Error),

    #[error("failed to understand configuration file {0}")]
    ParseConfig(PathBuf, #[source] serde_yml::Error),

    #[error(transparent)]
    Ambient(#[from] radicle_ci_ambient::AdapterError),

    #[error("can't determine configuration directory")]
    ConfigDir,

    #[error("can't determine project name from source directory path name {0}")]
    ProjectName(PathBuf),

    #[error("programming error: not admin log buffer")]
    AdminLogBuffer,

    #[error("failed to create a temporary directory")]
    TempDir(#[source] std::io::Error),
}
