use std::{
    ffi::OsString,
    os::unix::ffi::OsStringExt,
    path::{Path, PathBuf},
    process::Command,
    str::FromStr,
};

use clap::Parser;
use clingwrap::{
    config::{ConfigError, ConfigFile, ConfigLoader, ConfigValidator},
    runner::{CommandError, CommandRunner},
    tildepathbuf::TildePathBuf,
};
use log::{debug, info, trace};
use radicle::{Profile, node::AliasStore, rad::RemoteError, storage::ReadStorage};
use radicle_core::RepoId;
use radicle_oid::Oid;

use radicle_ci_adapter::{
    AdapterLibError, Author, EventCommonFields, EventType, PushEvent, Repository, Request, Response,
};
use serde::{Deserialize, Serialize};
use tempfile::{TempDir, tempdir};

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

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

fn fallible_main() -> Result<(), Box<dyn std::error::Error>> {
    env_logger::init_from_env("RAD_CI_LOG");
    debug!(
        "{} {} starts",
        env!("CARGO_PKG_NAME"),
        env!("CARGO_PKG_VERSION")
    );

    let args = Args::parse();
    let overrides = File::from(&args);

    let mut loader = ConfigLoader::default();
    loader.xdg(QUAL, ORG, APP);
    if let Some(filename) = &args.config {
        loader.require_yaml(filename);
    } else {
        loader.allow_yaml("config.yaml");
    };
    let config = loader
        .load(None, Some(overrides.clone()), &File::default())
        .map_err(RadCiError::Config)?;

    match &args.cmd {
        None | Some(Cmd::Run) => {
            let adapter = if args.bin.is_none() {
                detect_adapter(&args, &config)?
            } else {
                AdapterRunner::try_from(&args)?
            };

            if config.dry_run {
                println!("{adapter:#?}");
            } else {
                adapter.execute()?;
            }
        }
        Some(Cmd::Config) => {
            println!(
                "{}",
                serde_json::to_string_pretty(&config).map_err(RadCiError::ConfigToJson)?
            );
        }
    }

    Ok(())
}

/// Run Radicle CI locally.
///
/// Run a Radicle CI adapter, which simulates running CI locally. Depending on
/// the adapter, the run may actually be local (e.g., Ambient), or it may happen
/// on a remote computer. The adapter needs to be installed as a program locally,
/// and you need access for any remote CI machines if the adapter uses those.
///
/// This program automatically detects projects that want to use the Ambient
/// adapter, or you can use the `--bin` option to specify an arbitrary adapter
/// to use. (Please ask Lars to add detection for other adapters.)
#[derive(Debug, Parser)]
#[clap(version)]
struct Args {
    /// Use this configuration file instead of the default one.
    #[clap(long)]
    config: Option<PathBuf>,

    /// Do not run adapter for real, but report how it would be run.
    #[clap(long)]
    dry_run: bool,

    /// Source directory for project to run CI for.
    #[clap(long, default_value = ".")]
    source: PathBuf,

    /// The adapter binary to use. If this is not set, automatic detection
    /// is used.
    #[clap(long)]
    bin: Option<OsString>,

    /// The commit to run CI for. The commit MUST be in the local Radicle
    /// node storage.
    #[clap(long, default_value = "HEAD")]
    commit: String,

    /// Environment variable to set for adapter configuration file.
    #[clap(long)]
    env: Option<String>,

    /// Path to adapter configuration file. This will be made absolute.
    #[clap(long)]
    env_value: Option<PathBuf>,

    /// Use this Ambient image. This overrides the value from the configuration file.
    #[clap(long)]
    ambient_image: Option<PathBuf>,

    /// Subcommand to run.
    #[clap(subcommand)]
    cmd: Option<Cmd>,
}

impl Args {
    fn source(&self) -> Result<PathBuf, RadCiError> {
        canonicalize(&self.source)
    }

    fn env_value(&self) -> Result<Option<PathBuf>, RadCiError> {
        if let Some(filename) = &self.env_value {
            let filename = canonicalize(filename)?;
            Ok(Some(filename))
        } else {
            Ok(None)
        }
    }
}

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

#[derive(Debug, Serialize)]
struct Config {
    #[allow(dead_code)]
    ambient_image: Option<PathBuf>,

    dry_run: bool,
}

impl Config {
    fn logdir(&self) -> PathBuf {
        PathBuf::from("/tmp/logs")
    }

    fn log(&self) -> PathBuf {
        PathBuf::from("/tmp/log.txt")
    }

    fn base_url(&self) -> String {
        format!("file://{}", self.logdir().display())
    }
}

#[derive(Debug, Clone, Default, Deserialize)]
struct File {
    ambient_image: Option<TildePathBuf>,

    #[serde(skip)]
    dry_run: bool,
}

impl<'a> ConfigFile<'a> for File {
    type Error = RadCiError;

    fn merge(&mut self, other: Self) -> Result<(), Self::Error> {
        if let Some(v) = &other.ambient_image {
            self.ambient_image = Some(v.clone());
        }
        self.dry_run = other.dry_run;
        Ok(())
    }
}

impl ConfigValidator for File {
    type File = File;
    type Valid = Config;
    type Error = RadCiError;

    fn validate(&self, runtime: &Self::File) -> Result<Self::Valid, Self::Error> {
        let runtime = runtime.clone();
        let ambient_image = runtime.ambient_image.map(|v| v.path().to_path_buf());
        Ok(Config {
            ambient_image: if let Some(filename) = ambient_image {
                Some(canonicalize(filename)?)
            } else {
                None
            },
            dry_run: runtime.dry_run,
        })
    }
}

impl From<&Args> for File {
    fn from(args: &Args) -> Self {
        Self {
            ambient_image: args
                .ambient_image
                .clone()
                .map(|path| TildePathBuf::new(path.to_path_buf())),
            dry_run: args.dry_run,
        }
    }
}

fn detect_adapter(args: &Args, config: &Config) -> Result<AdapterRunner, RadCiError> {
    if args.source.join(".radicle/ambient.yaml").exists() {
        AdapterRunner::ambient(args, config)
    } else {
        Err(RadCiError::Unknown(args.source.to_path_buf()))
    }
}

#[derive(Debug)]
struct AdapterRunner {
    bin: OsString,
    source: PathBuf,
    commit: String,
    env: String,
    env_value: PathBuf,

    // Temporary directory for generate configuration files for adapters.
    // We keep this as part of the struct so that when the struct is
    // dropped, so is the temporary directory.
    #[allow(dead_code)]
    tmpdir: TempDir,
}

impl AdapterRunner {
    fn osstring(bytes: &[u8]) -> OsString {
        OsString::from_vec(bytes.to_vec())
    }

    fn ambient(args: &Args, config: &Config) -> Result<Self, RadCiError> {
        fn adapter_config(
            tmpdir: &TempDir,
            config: &Config,
            image: &Path,
        ) -> Result<PathBuf, RadCiError> {
            let config = AmbientAdapterConfig {
                image: canonicalize(image)?,
                logdir: config.logdir(),
                log: config.log(),
                base_url: config.base_url(),
            };
            let config_str = serde_norway::to_string(&config).map_err(RadCiError::ConfigToYaml)?;
            let filename = tmpdir.path().join("radicle-ci-ambient.yaml");
            std::fs::write(&filename, config_str.as_bytes()).map_err(RadCiError::ConfigWrite)?;
            trace!("temp config: {}:\n{config:#?}", filename.display());
            Ok(filename)
        }

        trace!("set up Ambient adapter spec");
        let tmpdir = tempdir().map_err(RadCiError::TempDir)?;
        Ok(Self {
            bin: Self::osstring(b"radicle-ci-ambient"),
            source: args.source()?,
            commit: args.commit.clone(),
            env: "RADICLE_CI_AMBIENT".to_string(),
            env_value: if let Some(v) = args.env_value()? {
                trace!("use {} for Ambient adapter config", v.display());
                v
            } else if let Some(v) = &args.ambient_image {
                adapter_config(&tmpdir, config, v)?
            } else if let Some(v) = &config.ambient_image {
                adapter_config(&tmpdir, config, v)?
            } else {
                Err(RadCiError::AmbientImage)?
            },
            tmpdir,
        })
    }

    fn execute(&self) -> Result<(), RadCiError> {
        debug!("execute adapter: {self:#?}");
        let v = std::fs::read(&self.env_value).unwrap();
        debug!("adapter config: {:?}", Self::string(&v));

        let (_, repo_id) = radicle::rad::at(&self.source)
            .map_err(|err| RadCiError::RepoAt(self.source.clone(), err))?;
        let profile = Profile::load().map_err(RadCiError::Profile)?;
        let repo = profile
            .storage
            .repository(repo_id)
            .map_err(|err| RadCiError::OpenRepository(repo_id, Box::new(err)))?;

        let commit = Self::resolve_commit(&repo, &self.commit)?;
        trace!("commit={commit:?}");

        let trigger = Self::trigger_message(&profile, repo_id, commit)?;
        trace!("trigger message: {trigger:#?}");
        let trigger = serde_json::to_string(&trigger).map_err(RadCiError::ToJson)?;

        let mut cmd = Command::new(&self.bin);
        cmd.env(&self.env, &self.env_value);
        cmd.current_dir(&self.source);
        trace!("{cmd:#?}");
        let mut runner = CommandRunner::new(cmd);
        runner.feed_stdin(trigger.as_bytes());
        runner.capture_stdout();
        runner.capture_stderr();
        info!("Run adapter");
        let result = runner.execute();
        debug!("Result from running adapter: {result:#?}");
        match &result {
            Ok(output) => {
                let stdout = Self::string(&output.stdout);
                for line in stdout.lines() {
                    let resp =
                        Response::from_str(line).map_err(|err| RadCiError::response(line, err))?;
                    match resp {
                        Response::Triggered { info_url, .. } => {
                            if let Some(url) = info_url {
                                println!("Run log: {url}");
                            }
                        }
                        Response::Finished { result } => {
                            println!("Finished: {result}");
                        }
                    }
                }
            }
            Err(CommandError::CommandFailed { output, .. }) => {
                eprintln!(
                    "Adapter stderr:\n{}",
                    Self::indented(Self::string(&output.stderr))
                );
                Err(RadCiError::Failed)?;
            }
            Err(_) => Err(RadCiError::Failed)?,
        }

        Ok(())
    }

    fn string(bytes: &[u8]) -> String {
        String::from_utf8_lossy(bytes).to_string()
    }

    fn indented(s: impl AsRef<str>) -> String {
        let mut out = String::new();

        for line in s.as_ref().lines() {
            out.push_str("    ");
            out.push_str(line);
            out.push('\n');
        }

        out
    }

    fn trigger_message(
        profile: &Profile,
        repo_id: RepoId,
        commit: Oid,
    ) -> Result<Request, RadCiError> {
        const PROTOCOL_VERSION: usize = 1; // FIXME: this needs to be imported from `radicle-ci-broker`

        let req = Request::Trigger {
            common: EventCommonFields {
                version: PROTOCOL_VERSION,
                event_type: EventType::Push,
                repository: Repository {
                    id: repo_id,
                    name: "fixme".into(),
                    description: "fixme".into(),
                    private: true,
                    default_branch: "main".into(), // FIXME
                    delegates: vec![],
                },
            },
            push: Some(PushEvent {
                pusher: Self::did_to_author(profile)?,
                before: commit,
                after: commit,
                branch: "fixme".into(),
                commits: vec![commit],
            }),
            patch: None,
        };

        Ok(req)
    }

    fn did_to_author(profile: &Profile) -> Result<Author, RadCiError> {
        let did = profile.public_key;
        let alias = profile.aliases().alias(&did);
        Ok(Author {
            id: did.into(),
            alias,
        })
    }

    fn resolve_commit(
        repo: &radicle::storage::git::Repository,
        gitref: &str,
    ) -> Result<Oid, RadCiError> {
        trace!("resolve {gitref}");
        let oid = if let Ok(oid) = Oid::from_str(gitref) {
            oid
        } else {
            let object = repo
                .backend
                .revparse_single(gitref)
                .map_err(|err| RadCiError::resolve_commit(gitref, &repo.id, err))?;
            Oid::from(object.id())
        };

        trace!("resolved {gitref} to {oid}");
        Ok(oid)
    }
}

#[derive(Debug, Serialize)]
struct AmbientAdapterConfig {
    image: PathBuf,
    logdir: PathBuf,
    log: PathBuf,
    base_url: String,
}

impl TryFrom<&Args> for AdapterRunner {
    type Error = RadCiError;
    fn try_from(args: &Args) -> Result<Self, Self::Error> {
        Ok(Self {
            bin: args.bin.clone().ok_or(RadCiError::Missing("--bin"))?,
            source: args.source.clone(),
            commit: args.commit.clone(),
            env: args.env.clone().ok_or(RadCiError::Missing("--env"))?,
            env_value: args
                .env_value
                .clone()
                .ok_or(RadCiError::Missing("--env-value"))?,
            tmpdir: tempdir().map_err(RadCiError::TempDir)?,
        })
    }
}

fn canonicalize(filename: impl AsRef<Path>) -> Result<PathBuf, RadCiError> {
    let filename = filename.as_ref();
    filename
        .canonicalize()
        .map_err(|err| RadCiError::Absolute(filename.to_path_buf(), err))
}

#[derive(Debug, thiserror::Error)]
enum RadCiError {
    #[error("can't find repository ID for {0}")]
    RepoAt(PathBuf, #[source] RemoteError),

    #[error("can't load Radicle node profile")]
    Profile(#[source] radicle::profile::Error),

    #[error("can't open repository {0}")]
    OpenRepository(RepoId, #[source] Box<dyn std::error::Error>),

    #[error("failed to resolve commit id {0:?} in repository {1}")]
    ResolveCommit(
        String,
        RepoId,
        #[source] Box<dyn std::error::Error + Send + Sync>,
    ),

    #[error("can't turn trigger message to JSON")]
    ToJson(#[source] serde_json::Error),

    #[error("adapter failed")]
    Failed,

    #[error("can't detect adapter to use for for {0}")]
    Unknown(PathBuf),

    #[error("missing option for generic adapter: {0}")]
    Missing(&'static str),

    #[error("can't make path absolute: {0}")]
    Absolute(PathBuf, #[source] std::io::Error),

    #[error("can't parse response messages {0:?}")]
    Response(String, #[source] Box<AdapterLibError>),

    #[error("program in configuration files")]
    Config(#[source] ConfigError),

    #[error("can't represent configuration as JSON")]
    ConfigToJson(#[source] serde_json::Error),

    #[error("can't represent configuration as YAML")]
    ConfigToYaml(#[source] serde_norway::Error),

    #[error("can't create a temporary directory")]
    TempDir(#[source] std::io::Error),

    #[error("can't write configuration to temporary file")]
    ConfigWrite(#[source] std::io::Error),

    #[error("don't know what Ambient image to use")]
    AmbientImage,
}

impl RadCiError {
    fn resolve_commit<S: Into<String>>(
        commit: S,
        repo: &RepoId,
        err: radicle::git::raw::Error,
    ) -> Self {
        Self::ResolveCommit(commit.into(), *repo, Box::new(err))
    }

    fn response(s: impl Into<String>, err: AdapterLibError) -> Self {
        Self::Response(s.into(), Box::new(err))
    }
}
