//! This is a skeleton Radicle CI adapter for demonstration purposes.
//!
//! This program shows how to build a very simple adapter for Radicle
//! CI. As per the CI broker/adapter protocol, it reads a trigger
//! request message from its stdin, and writes two response messages
//! to its stdout. Based on the trigger, it clones the relevant Git
//! repository, and checks out the relevant Git commit, then runs
//! executed a silly CI plan. To keep things simple, and quick, in
//! this program the plan is "does the .radicle directory exist?". If
//! it exists, CI succeeds, otherwise it fails. If any of the previous
//! steps fails, CI also fails.
//!
//! Any errors are reported to stderr. If things go wrong very early,
//! the first response message may be a failure message, rather than a
//! "triggered" message. The CI broke will hopefully handle this.

#![allow(clippy::result_large_err)]

use std::{error::Error, fs::create_dir_all, path::Path, time::SystemTime};

use clap::Parser;
use html_page::{Element, Tag};
use tempfile::{tempdir, TempDir};
use time::{format_description::FormatItem, macros::format_description, OffsetDateTime};

use radicle::git::Oid;
use radicle::prelude::{Profile, ReadStorage, RepoId};

use radicle_ci_adapter::{
    get_sources, read_request, write_failed, write_succeeded, write_triggered, AdminLog, Patch,
    PatchEvent, PushEvent, Request, RunId, RunResult,
};

use radicle_ci_ambient::{
    ambient::{self, run_ambient_ci, CiOutput},
    config::Config,
    AdapterError,
};

// Name of environment variable that has filename for configuration
// file.
const CONFIG_ENV: &str = "RADICLE_CI_AMBIENT";

const CSS: &str = include_str!("style.css");

fn main() -> Result<(), AdapterError> {
    match fallible_main() {
        Ok(true) => write_succeeded().map_err(AdapterError::Succeded)?,
        Ok(false) => write_failed().map_err(AdapterError::Failed)?,
        Err(err) => {
            eprintln!("ERROR: {err}");
            let mut underlying = err.source();
            while let Some(err) = underlying {
                eprintln!("caused by: {err}");
                underlying = err.source();
            }
            write_failed().map_err(AdapterError::Failed)?;
            std::process::exit(1);
        }
    }

    Ok(())
}

fn fallible_main() -> Result<bool, AdapterError> {
    let mut adapter = Adapter::new()?;
    adapter.execute()
}

#[derive(Debug)]
struct Adapter {
    args: Args,
    config: Config,
    adminlog: AdminLog,
    tmp: TempDir,
}

impl Adapter {
    fn new() -> Result<Self, AdapterError> {
        let args = Args::parse();
        let config = Config::load_via_env(CONFIG_ENV)?;
        let adminlog = config.open_log()?;
        Ok(Self {
            args,
            config,
            adminlog,
            tmp: tempdir().map_err(AdapterError::TempDir)?,
        })
    }

    fn log<S: Into<String>>(&mut self, msg: S) {
        self.adminlog.writeln(msg.into().as_ref()).ok();
    }

    fn get_request() -> Result<Request, AdapterError> {
        read_request().map_err(AdapterError::ReadRequest)
    }

    fn get_repository_name(&mut self, repoid: &RepoId) -> Result<String, AdapterError> {
        let profile = Profile::load().map_err(AdapterError::Profile)?;
        let doc = profile
            .storage
            .get(*repoid)
            .map_err(|err| AdapterError::GetIdDoc(*repoid, err))?;

        let repository_name = if let Some(doc) = doc {
            doc.project()
                .map_err(AdapterError::GetProject)?
                .name()
                .to_string()
        } else {
            "unknown Radicle repository name".into()
        };
        self.log(format!("repository name: {repository_name:?}"));
        Ok(repository_name)
    }

    fn execute(&mut self) -> Result<bool, AdapterError> {
        if self.args.show_config {
            eprintln!("{:#?}", self.config);
            return Ok(false);
        }

        self.log(format!("radicle-ci-ambient starts: {:#?}", self.config));

        // Read the trigger request message and extract the repository ID
        // and commit from it, if possible.
        let req = Self::get_request()?;
        let repoid = req.repo();
        let commit = req.commit().map_err(AdapterError::Commit)?;

        // Find project name.
        let repository_name = self.get_repository_name(&repoid)?;
        let project_name = if self.config.trust_repository_name() {
            repository_name
        } else {
            repoid.to_string()
        };
        self.log(format!(
            "project name: {project_name:?} {}",
            if self.config.trust_repository_name() {
                "trusted repo name"
            } else {
                "repo id"
            }
        ));

        // Create a temporary directory and a sub-directory `src` where
        // we'll put the source code from Git.
        let src = self.tmp.path().join("src");
        get_sources(&mut self.adminlog, self.args.dry_run, repoid, commit, &src)?;

        // Info about this CI run.
        let mut info = RunInfoBuilder::default();

        // Invent an adapter run ID and tell it to the CI broker.
        let run_id = RunId::default();
        info.set_adapter_run_id(&run_id);
        let project_log_dir = self.config.logdir.join(&project_name);
        if !project_log_dir.exists() {
            self.log("need to create log directory");
            create_dir_all(&project_log_dir)
                .map_err(|err| AdapterError::CreateDirs(project_log_dir, err))?;
            self.log("created log directory");
        }
        let run_log_relative = format!("{project_name}/{run_id}.html");

        let url = self
            .config
            .base_url
            .clone()
            .map(|base| format!("{base}/{run_log_relative}"));
        write_triggered(&run_id, url.as_deref()).map_err(AdapterError::Triggered)?;

        // Execute CI in the source directory.
        let result = self.execute_ci(info, &req, &project_name, &run_log_relative, &src);
        self.log(format!(
            "radicle-ci-ambient ends: Ambient result is {result:?}"
        ));

        if let Err(err) = &result {
            self.log(format!("ERROR: {err}"));
            let mut err = err.source();
            while let Some(cause) = err {
                self.log(format!("caused by {cause}"));
                err = cause.source();
            }
        }

        result
    }

    fn get_distilled_plan(&self, src: &Path) -> Result<ambient::DistilledPlan, AdapterError> {
        let mut distilled = if self.args.dry_run {
            ambient::DistilledPlan::default()
        } else {
            let filename = src.join(".radicle/ambient.yaml");
            ambient::DistilledPlan::read(&filename)?
        };

        if !self.config.allow_post_plan() {
            distilled.drop_post_plan();
        }
        Ok(distilled)
    }

    fn execute_ci(
        &mut self,
        mut info: RunInfoBuilder,
        trigger: &Request,
        project_name: &str,
        run_log_relative: &str,
        src: &Path,
    ) -> Result<bool, AdapterError> {
        self.log(format!("execute_ci: src={}", src.display()));

        let distilled = self.get_distilled_plan(src)?;
        let projects = ambient::ProjectsBuilder::default()
            .name(project_name)
            .source(Path::new("src"))
            .image(if self.config.ssh_target().is_some() {
                self.config
                    .remote_image
                    .as_ref()
                    .unwrap_or(&self.config.image)
            } else {
                &self.config.image
            })
            .plan(&distilled)
            .build();

        let ssh_remote = self.config.ssh_target();
        let output = run_ambient_ci(
            self.tmp.path(),
            &mut self.adminlog,
            self.args.dry_run,
            &projects,
            self.config.extra_ambient_config(),
            self.config.extra_ambient_config_values(),
            ssh_remote,
        )?;
        if output.exit == 0 {
            info.set_result(RunResult::Success);
        } else {
            info.set_result(RunResult::Failure);
        }

        let info = info.build()?;
        let exit = output.exit;
        self.write_log_files(&info, trigger, project_name, run_log_relative, output)?;

        if self.args.dry_run {
            self.log("execute_ci: Ambient is pretending to be OK");
            Ok(true)
        } else if exit == 0 {
            self.log("execute_ci: Ambient is OK");
            Ok(true)
        } else {
            self.log(format!("ERROR: ambient exit code {exit}"));
            Ok(false)
        }
    }

    fn html_log_prelude(
        &self,
        info: &RunInfo,
        trigger: &Request,
        ci_output: &CiOutput,
    ) -> Vec<Element> {
        fn result(v: &RunResult) -> String {
            v.to_string().to_uppercase()
        }

        fn repo_id(v: &Request) -> String {
            v.repo().to_string()
        }

        fn repo_name(v: &Request) -> String {
            match v {
                Request::Trigger { common, .. } => common.repository.name.to_string(),
                _ => "<unknown repo name".to_string(),
            }
        }

        fn run_id(v: &RunId) -> String {
            v.to_string()
        }

        fn timestamp(when: &Option<SystemTime>) -> String {
            if let Some(when) = when.as_ref() {
                const DATE_FORMAT: &[FormatItem<'_>] =
                    format_description!("[year]-[month]-[day] [hour]:[minute]:[second]Z");

                let odt = OffsetDateTime::from(*when);
                odt.format(DATE_FORMAT)
                    .unwrap_or_else(|_| "<unknown timestamp".to_string())
            } else {
                "<unknown timestamp".into()
            }
        }

        fn pretty_trigger(v: &Request) -> String {
            serde_json::to_string_pretty(v).unwrap_or_else(|_| "error formatting JSON".into())
        }

        fn commit_element(oid: &Oid) -> Element {
            if let Some((prefix, suffix)) = oid.to_string().split_at_checked(7) {
                Element::new(Tag::Span)
                    .with_child(
                        Element::new(Tag::Span)
                            .with_class("commit-id-prefix")
                            .with_text(prefix),
                    )
                    .with_child(
                        Element::new(Tag::Span)
                            .with_class("commit-id-suffix")
                            .with_text(suffix),
                    )
            } else {
                Element::new(Tag::Span)
                    .with_class("commit-id-prefix")
                    .with_text(oid.to_string())
            }
        }

        fn commit(v: &Request) -> Element {
            match v {
                Request::Trigger {
                    push: Some(PushEvent { after, .. }),
                    ..
                } => commit_element(after),

                Request::Trigger {
                    patch:
                        Some(PatchEvent {
                            patch: Patch { after, .. },
                            ..
                        }),
                    ..
                } => commit_element(after),

                _ => Element::new(Tag::Span)
                    .with_class("commit-id-unknown")
                    .with_text("<unknown commit>"),
            }
        }

        fn log(v: &[u8]) -> String {
            if v.is_empty() {
                "<empty log>".into()
            } else {
                String::from_utf8_lossy(v).to_string()
            }
        }

        let ended = SystemTime::now();
        let (started, duration) = {
            let duration = if let Ok(duration) = info.started.elapsed() {
                format!("{} seconds", duration.as_secs())
            } else {
                "<unknown duration>".to_string()
            };
            (timestamp(&Some(info.started)), duration)
        };

        vec![
            Element::new(Tag::H2).with_text("Radicle info"),
            Element::new(Tag::Ul)
                .with_class("radicle-info")
                .with_child(
                    Element::new(Tag::Li).with_text("Result: ").with_child(
                        Element::new(Tag::Span)
                            .with_class(match info.result {
                                RunResult::Success => "result-success",
                                RunResult::Failure => "result-failure",
                                _ => "result-unknown",
                            })
                            .with_text(result(&info.result)),
                    ),
                )
                .with_child(
                    Element::new(Tag::Li)
                        .with_text("Repository: ")
                        .with_child(
                            Element::new(Tag::Span)
                                .with_class("repo-id")
                                .with_text(repo_id(trigger)),
                        )
                        .with_text(" ")
                        .with_child(Element::new(Tag::Span).with_class("repo-name"))
                        .with_text(repo_name(trigger)),
                )
                .with_child(
                    Element::new(Tag::Li)
                        .with_text("Commit: ")
                        .with_child(commit(trigger)),
                )
                .with_child(
                    Element::new(Tag::Li)
                        .with_text("Adapter run ID: ")
                        .with_child(
                            Element::new(Tag::Span)
                                .with_class("run-id")
                                .with_text(run_id(&info.adapter_run_id)),
                        ),
                )
                .with_child(
                    Element::new(Tag::Li)
                        .with_text("Duration: ")
                        .with_text(duration.to_string())
                        .with_child(Element::new(Tag::Br))
                        .with_text(&started)
                        .with_text(" -- ")
                        .with_text(timestamp(&Some(ended))),
                ),
            Element::new(Tag::Details)
                .with_class("radicle-info")
                .with_child(Element::new(Tag::Summary).with_text("Trigger message"))
                .with_child(Element::new(Tag::Pre).with_text(pretty_trigger(trigger))),
            Element::new(Tag::Details)
                .with_class("radicle-info")
                .with_child(Element::new(Tag::Summary).with_text("Ambient stdout"))
                .with_child(Element::new(Tag::Pre).with_text(log(&ci_output.stdout))),
            Element::new(Tag::Details)
                .with_class("radicle-info")
                .with_child(Element::new(Tag::Summary).with_text("Ambient stderr"))
                .with_child(Element::new(Tag::Pre).with_text(log(&ci_output.stderr))),
        ]
    }

    fn write_log_files(
        &mut self,
        info: &RunInfo,
        trigger: &Request,
        project_name: &str,
        run_log_relative: &str,
        ci_output: CiOutput,
    ) -> Result<(), AdapterError> {
        let logdir = self.config.logdir.clone();
        if !logdir.exists() {
            self.log("create log directory");
            std::fs::create_dir_all(&logdir)
                .map_err(|err| AdapterError::CreateDirs(logdir, err))?;
        }

        // Build prelude for HTML run log with information relevant to Radicle.
        // Everything else comes from Ambient.
        let prelude = self.html_log_prelude(info, trigger, &ci_output);
        let html = ci_output
            .run_log
            .with_css(CSS)
            .with_title(format!("CI run log: {project_name}"))
            .with_prelude(prelude)
            .to_html()
            .to_string();

        self.log("write HTML run log to disk");
        let filename = self
            .config
            .logdir
            .join(run_log_relative)
            .with_extension("html");
        std::fs::write(&filename, html.as_bytes())
            .map_err(|err| AdapterError::WriteRunLog(filename.clone(), err))?;
        Ok(())
    }
}

#[derive(Debug, Parser)]
#[clap(version = env!("VERSION"))]
struct Args {
    #[clap(long)]
    dry_run: bool,

    #[clap(long)]
    show_config: bool,
}

struct RunInfoBuilder {
    started: SystemTime,
    adapter_run_id: Option<RunId>,
    result: Option<RunResult>,
}

impl Default for RunInfoBuilder {
    fn default() -> Self {
        Self {
            started: SystemTime::now(),
            adapter_run_id: None,
            result: None,
        }
    }
}

impl RunInfoBuilder {
    fn build(self) -> Result<RunInfo, AdapterError> {
        Ok(RunInfo {
            started: self.started,
            adapter_run_id: self
                .adapter_run_id
                .ok_or(AdapterError::Missing("adapter_run_id"))?,
            result: self.result.ok_or(AdapterError::Missing("result"))?,
        })
    }

    fn set_adapter_run_id(&mut self, id: &RunId) {
        self.adapter_run_id = Some(id.clone());
    }

    fn set_result(&mut self, result: RunResult) {
        self.result = Some(result);
    }
}

#[allow(dead_code)]
struct RunInfo {
    started: SystemTime,
    adapter_run_id: RunId,
    result: RunResult,
}
