use std::{
    ops::Not,
    path::Path,
    process::{Command, ExitStatus},
};

use blue_build_utils::{
    constants::{BLUE_BUILD, DOCKER_HOST, GITHUB_ACTIONS},
    container::{ContainerId, ImageRef},
    credentials::Credentials,
    get_env_var,
    secret::SecretArgs,
    semver::Version,
    string_vec,
};
use cached::proc_macro::once;
use colored::Colorize;
use comlexr::{cmd, pipe};
use log::{debug, info, trace, warn};
use miette::{Context, IntoDiagnostic, Result, bail};
use oci_client::Reference;
use serde::Deserialize;
use tempfile::TempDir;

use crate::{
    logging::CommandLogging,
    signal_handler::{ContainerRuntime, ContainerSignalId, DetachedContainer, add_cid, remove_cid},
};

use super::{
    opts::{
        BuildOpts, BuildTagPushOpts, CreateContainerOpts, ManifestCreateOpts, ManifestPushOpts,
        PruneOpts, PullOpts, PushOpts, RemoveContainerOpts, RemoveImageOpts, RunOpts, RunOptsEnv,
        RunOptsVolume, TagOpts, UntagOpts,
    },
    traits::{BuildDriver, DriverVersion, ImageStorageDriver, RunDriver},
};

#[derive(Debug, Deserialize)]
struct VerisonJsonClient {
    #[serde(alias = "Version")]
    version: Version,
}

#[derive(Debug, Deserialize)]
struct VersionJson {
    #[serde(alias = "Client")]
    client: VerisonJsonClient,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct ContextListItem {
    name: String,
}

#[derive(Debug)]
pub struct DockerDriver;

impl DockerDriver {
    fn setup() -> Result<()> {
        #[once(result = true, sync_writes = true)]
        fn exec() -> Result<()> {
            trace!("DockerDriver::setup()");

            if !DockerDriver::has_buildx() {
                bail!("Docker Buildx is required to use the Docker driver");
            }

            let ls_out = {
                let c = cmd!("docker", "buildx", "ls", "--format={{.Name}}");
                trace!("{c:?}");
                c
            }
            .output()
            .into_diagnostic()?;

            if !ls_out.status.success() {
                bail!("{}", String::from_utf8_lossy(&ls_out.stderr));
            }

            let ls_out = String::from_utf8(ls_out.stdout).into_diagnostic()?;

            trace!("{ls_out}");

            if !ls_out.lines().any(|line| line == BLUE_BUILD) {
                let remote = get_env_var(DOCKER_HOST).is_ok();
                if remote {
                    let context_list = get_context_list()?;
                    trace!("{context_list:#?}");

                    if context_list.iter().any(|ctx| ctx.name == BLUE_BUILD).not() {
                        let context_out = {
                            let c = cmd!(
                                "docker",
                                "context",
                                "create",
                                "--from=default",
                                format!("{BLUE_BUILD}0")
                            );
                            trace!("{c:?}");
                            c
                        }
                        .output()
                        .into_diagnostic()?;

                        if context_out.status.success().not() {
                            bail!("{}", String::from_utf8_lossy(&context_out.stderr));
                        }

                        let context_list = get_context_list()?;
                        trace!("{context_list:#?}");
                    }
                }

                let create_out = {
                    let c = cmd!(
                        "docker",
                        "buildx",
                        "create",
                        "--bootstrap",
                        "--driver=docker-container",
                        format!("--name={BLUE_BUILD}"),
                        if remote => format!("{BLUE_BUILD}0"),
                    );
                    trace!("{c:?}");
                    c
                }
                .output()
                .into_diagnostic()?;

                if !create_out.status.success() {
                    bail!("{}", String::from_utf8_lossy(&create_out.stderr));
                }
            }

            Ok(())
        }

        exec()
    }

    #[must_use]
    pub fn has_buildx() -> bool {
        pipe!(cmd!("docker", "--help") | cmd!("grep", "buildx"))
            .status()
            .is_ok_and(|status| status.success())
    }
}

fn get_context_list() -> Result<Vec<ContextListItem>> {
    {
        let c = cmd!("docker", "context", "ls", "--format=json");
        trace!("{c:?}");
        c
    }
    .output()
    .into_diagnostic()
    .and_then(|out| {
        if out.status.success().not() {
            bail!("{}", String::from_utf8_lossy(&out.stderr));
        }
        String::from_utf8(out.stdout).into_diagnostic()
    })?
    .lines()
    .map(|line| serde_json::from_str(line).into_diagnostic())
    .collect()
}

impl DriverVersion for DockerDriver {
    // First docker verison to use buildkit
    // https://docs.docker.com/build/buildkit/
    const VERSION_REQ: &'static str = ">=23";

    fn version() -> Result<Version> {
        trace!("DockerDriver::version()");

        let output = {
            let c = cmd!("docker", "version", "-f", "json");
            trace!("{c:?}");
            c
        }
        .output()
        .into_diagnostic()?;

        let version_json: VersionJson = serde_json::from_slice(&output.stdout).into_diagnostic()?;

        Ok(version_json.client.version)
    }
}

impl BuildDriver for DockerDriver {
    fn build(opts: BuildOpts) -> Result<()> {
        trace!("DockerDriver::build({opts:#?})");

        let temp_dir = TempDir::new()
            .into_diagnostic()
            .wrap_err("Failed to create temporary directory for secrets")?;

        if opts.squash {
            warn!("Squash is deprecated for docker so this build will not squash");
        }

        let status = {
            let c = cmd!(
                "docker",
                "build",
                if let Some(platform) = opts.platform => [
                    "--platform",
                    platform.to_string(),
                ],
                "-t",
                opts.image.to_string(),
                "-f",
                for opts.secrets.args(&temp_dir)?,
                if opts.secrets.ssh() => "--ssh",
                if let Some(cache_from) = opts.cache_from.as_ref() => [
                    "--cache-from",
                    format!(
                        "type=registry,ref={registry}/{repository}",
                        registry = cache_from.registry(),
                        repository = cache_from.repository(),
                    ),
                ],
                if let Some(cache_to) = opts.cache_to.as_ref() => [
                    "--cache-to",
                    format!(
                        "type=registry,ref={registry}/{repository},mode=max",
                        registry = cache_to.registry(),
                        repository = cache_to.repository(),
                    ),
                ],
                opts.containerfile,
                ".",
            );
            trace!("{c:?}");
            c
        }
        .status()
        .into_diagnostic()?;

        if status.success() {
            info!("Successfully built {}", opts.image);
        } else {
            bail!("Failed to build {}", opts.image);
        }
        Ok(())
    }

    fn tag(opts: TagOpts) -> Result<()> {
        trace!("DockerDriver::tag({opts:#?})");

        let dest_image_str = opts.dest_image.to_string();

        let status = {
            let c = cmd!("docker", "tag", opts.src_image.to_string(), &dest_image_str);
            trace!("{c:?}");
            c
        }
        .status()
        .into_diagnostic()?;

        if status.success() {
            info!("Successfully tagged {}!", dest_image_str.bold().green());
        } else {
            bail!("Failed to tag image {}", dest_image_str.bold().red());
        }
        Ok(())
    }

    fn untag(opts: UntagOpts) -> Result<()> {
        trace!("DockerDriver::untag({opts:#?})");

        let ref_string = opts.image.to_string();

        let status = {
            let c = cmd!(
                "docker",
                "untag",
                &ref_string, // identify image by reference
                &ref_string, // remove this reference
            );
            trace!("{c:?}");
            c
        }
        .status()
        .into_diagnostic()?;

        if status.success() {
            info!("Successfully untagged {}", ref_string.bold().green());
        } else {
            bail!("Failed to untag image {}", ref_string.bold().red());
        }
        Ok(())
    }

    fn push(opts: PushOpts) -> Result<()> {
        trace!("DockerDriver::push({opts:#?})");

        let image_str = opts.image.to_string();

        let status = {
            let c = cmd!("docker", "push", &image_str);
            trace!("{c:?}");
            c
        }
        .status()
        .into_diagnostic()?;

        if status.success() {
            info!("Successfully pushed {}!", image_str.bold().green());
        } else {
            bail!("Failed to push image {}", image_str.bold().red());
        }
        Ok(())
    }

    fn pull(opts: PullOpts) -> Result<ContainerId> {
        trace!("DockerDriver::pull({opts:#?})");

        let image_str = opts.image.to_string();

        let mut command = cmd!(
            "docker",
            "pull",
            "--quiet",
            if let Some(platform) = opts.platform => format!("--platform={platform}"),
            &image_str,
        );

        info!("Pulling image {image_str}...");

        trace!("{command:?}");
        let output = command.output().into_diagnostic()?;

        if !output.status.success() {
            bail!("Failed to pull image {}", image_str.bold().red());
        }
        info!("Successfully pulled image {}", image_str.bold().green());
        let container_id = {
            let mut stdout = output.stdout;
            while stdout.pop_if(|byte| byte.is_ascii_whitespace()).is_some() {}
            ContainerId(String::from_utf8(stdout).into_diagnostic()?)
        };
        Ok(container_id)
    }

    fn login(server: &str) -> Result<()> {
        trace!("DockerDriver::login()");

        if let Some(Credentials::Basic { username, password }) = Credentials::get(server) {
            let output = pipe!(
                stdin = password.value();
                {
                    let c = cmd!(
                        "docker",
                        "login",
                        "-u",
                        &username,
                        "--password-stdin",
                        server,
                    );
                    trace!("{c:?}");
                    c
                }
            )
            .output()
            .into_diagnostic()?;

            if !output.status.success() {
                let err_out = String::from_utf8_lossy(&output.stderr);
                bail!("Failed to login for docker:\n{}", err_out.trim());
            }
            debug!("Logged into {server}");
        }

        Ok(())
    }

    fn prune(opts: PruneOpts) -> Result<()> {
        trace!("DockerDriver::prune({opts:?})");

        let (system, buildx) = std::thread::scope(
            |scope| -> std::thread::Result<(Result<ExitStatus>, Result<ExitStatus>)> {
                let system = scope.spawn(|| {
                    {
                        let c = cmd!(
                            "docker",
                            "system",
                            "prune",
                            "--force",
                            if opts.all => "--all",
                            if opts.volumes => "--volumes",
                        );
                        trace!("{c:?}");
                        c
                    }
                    .message_status("docker system prune", "Pruning Docker System")
                    .into_diagnostic()
                });

                let buildx = scope.spawn(|| {
                    Self::setup()?;

                    {
                        let c = cmd!(
                            "docker",
                            "buildx",
                            "prune",
                            "--force",
                            format!("--builder={BLUE_BUILD}"),
                            if opts.all => "--all",
                        );
                        trace!("{c:?}");
                        c
                    }
                    .message_status("docker buildx prune", "Pruning Docker Buildx")
                    .into_diagnostic()
                });

                Ok((system.join()?, buildx.join()?))
            },
        )
        .map_err(|e| miette::miette!("{e:?}"))?;

        if !system?.success() {
            bail!("Failed to prune docker system");
        }

        if !buildx?.success() {
            bail!("Failed to prune docker buildx");
        }

        Ok(())
    }

    fn manifest_create(opts: ManifestCreateOpts) -> Result<()> {
        let output = {
            let c = cmd!("docker", "manifest", "rm", opts.final_image.to_string());
            trace!("{c:?}");
            c
        }
        .output()
        .into_diagnostic()?;

        if output.status.success() {
            warn!(
                "Existing image manifest {} exists, removing...",
                opts.final_image
            );
        }

        let status = {
            let c = cmd!(
                "docker",
                "manifest",
                "create",
                opts.final_image.to_string(),
                for image in opts.image_list => image.to_string(),
            );
            trace!("{c:?}");
            c
        }
        .status()
        .into_diagnostic()?;

        if !status.success() {
            bail!("Failed to create manifest for {}", opts.final_image);
        }

        Ok(())
    }

    fn manifest_push(opts: ManifestPushOpts) -> Result<()> {
        let status = {
            let c = cmd!("docker", "manifest", "push", opts.final_image.to_string());
            trace!("{c:?}");
            c
        }
        .status()
        .into_diagnostic()?;

        if !status.success() {
            bail!("Failed to create manifest for {}", opts.final_image);
        }

        Ok(())
    }

    fn build_tag_push(opts: BuildTagPushOpts) -> Result<Vec<String>> {
        trace!("DockerDriver::build_tag_push({opts:#?})");

        let temp_dir = TempDir::new()
            .into_diagnostic()
            .wrap_err("Failed to create temporary directory for secrets")?;

        if opts.squash {
            warn!("Squash is deprecated for docker so this build will not squash");
        }

        Self::setup()?;

        let final_images = get_final_images(opts);

        let first_image = final_images.first().unwrap();

        let status = build_tag_push_cmd(opts, first_image, &temp_dir)?
            .build_status(first_image, "Building Image")
            .into_diagnostic()?;

        if status.success() {
            if opts.push {
                info!("Successfully built and pushed image {first_image}");
            } else {
                info!("Successfully built image {first_image}");
            }
        } else {
            bail!("Failed to build image {}", first_image);
        }
        Ok(final_images)
    }
}

fn build_tag_push_cmd(
    opts: BuildTagPushOpts<'_>,
    first_image: &str,
    temp_dir: &TempDir,
) -> Result<Command> {
    let c = cmd!(
        "docker",
        "buildx",
        format!("--builder={BLUE_BUILD}"),
        "build",
        ".",
        for opts.secrets.args(temp_dir)?,
        if opts.secrets.ssh() => "--ssh",
        match &opts.image {
            ImageRef::Remote(_remote) if opts.push => [
                "--output",
                format!(
                    "type=image,name={first_image},push=true,compression={},oci-mediatypes=true",
                    opts.compression
                ),
            ],
            ImageRef::Remote(_remote)
                if get_env_var(GITHUB_ACTIONS).is_err()
                && opts.platform.len() <= 1 => "--load",
            ImageRef::LocalTar(archive_path) => [
                "--output",
                format!("type=oci,dest={}", archive_path.display()),
            ],
            _ => [],
        },
        for opts.image.remote_ref().map_or_else(Vec::new, |image| {
                opts.tags.iter().flat_map(|tag| {
                    vec![
                        "-t".to_string(),
                        format!("{}/{}:{tag}", image.resolve_registry(), image.repository())
                    ]
                }).collect()
            }),
        "--pull",
        for platform in opts.platform => [
            "--platform",
            platform.to_string(),
        ],
        "-f",
        opts.containerfile,
        if let Some(cache_from) = opts.cache_from.as_ref() => [
            "--cache-from",
            format!(
                "type=registry,ref={cache_from}",
            ),
        ],
        if let Some(cache_to) = opts.cache_to.as_ref() => [
            "--cache-to",
            format!(
                "type=registry,ref={cache_to},mode=max",
            ),
        ],
    );
    trace!("{c:?}");
    Ok(c)
}

fn get_final_images(opts: BuildTagPushOpts<'_>) -> Vec<String> {
    match &opts.image {
        ImageRef::Remote(image) => {
            if opts.tags.is_empty() {
                let image = image.to_string();
                string_vec![image]
            } else {
                opts.tags
                    .iter()
                    .map(|tag| format!("{}/{}:{tag}", image.resolve_registry(), image.repository()))
                    .collect()
            }
        }
        ImageRef::LocalTar(archive_path) => {
            string_vec![archive_path.display().to_string()]
        }
        ImageRef::Other(other) => {
            string_vec![&**other]
        }
    }
}

impl RunDriver for DockerDriver {
    fn run(opts: RunOpts) -> Result<ExitStatus> {
        trace!("DockerDriver::run({opts:#?})");

        let cid_path = TempDir::new().into_diagnostic()?;
        let cid_file = cid_path.path().join("cid");
        let cid = ContainerSignalId::new(&cid_file, ContainerRuntime::Docker, false);

        add_cid(&cid);

        let status = docker_run(opts, &cid_file, false)
            .build_status(opts.image, "Running container")
            .into_diagnostic()?;

        remove_cid(&cid);

        Ok(status)
    }

    fn run_output(opts: RunOpts) -> Result<std::process::Output> {
        trace!("DockerDriver::run_output({opts:#?})");

        let cid_path = TempDir::new().into_diagnostic()?;
        let cid_file = cid_path.path().join("cid");
        let cid = ContainerSignalId::new(&cid_file, ContainerRuntime::Docker, false);

        add_cid(&cid);

        let output = docker_run(opts, &cid_file, false)
            .output()
            .into_diagnostic()?;

        remove_cid(&cid);

        Ok(output)
    }

    fn run_detached(opts: RunOpts) -> Result<DetachedContainer> {
        trace!("DockerDriver::run_detached({opts:#?})");

        let cid_path = TempDir::new().into_diagnostic()?;
        let cid_file = cid_path.path().join("cid");

        let cid = ContainerSignalId::new(&cid_file, ContainerRuntime::Docker, opts.privileged);
        let run_cmd = docker_run(opts, &cid_file, true);

        DetachedContainer::start(cid, run_cmd)
    }

    fn create_container(opts: CreateContainerOpts) -> Result<ContainerId> {
        trace!("DockerDriver::create_container({opts:?})");

        let output = {
            let c = cmd!("docker", "create", opts.image.to_string(), "bash",);
            trace!("{c:?}");
            c
        }
        .output()
        .into_diagnostic()?;

        if !output.status.success() {
            let err_out = String::from_utf8_lossy(&output.stderr);
            bail!(
                "Failed to create container from image {}:\n{}",
                opts.image,
                err_out.trim()
            );
        }

        Ok(ContainerId(
            String::from_utf8(output.stdout.trim_ascii().to_vec()).into_diagnostic()?,
        ))
    }

    fn remove_container(opts: RemoveContainerOpts) -> Result<()> {
        trace!("DockerDriver::remove_container({opts:?})");

        let output = {
            let c = cmd!("docker", "rm", opts.container_id);
            trace!("{c:?}");
            c
        }
        .output()
        .into_diagnostic()?;

        if !output.status.success() {
            let err_out = String::from_utf8_lossy(&output.stderr);
            bail!(
                "Failed to remove container {}:\n{}",
                opts.container_id,
                err_out.trim()
            );
        }

        Ok(())
    }
}

impl ImageStorageDriver for DockerDriver {
    fn remove_image(opts: RemoveImageOpts) -> Result<()> {
        trace!("DockerDriver::remove_image({opts:?})");

        let output = {
            let c = cmd!("docker", "rmi", opts.image.to_string());
            trace!("{c:?}");
            c
        }
        .output()
        .into_diagnostic()?;

        if !output.status.success() {
            let err_out = String::from_utf8_lossy(&output.stderr);
            bail!(
                "Failed to remove the image {}:\n{}",
                opts.image,
                err_out.trim()
            );
        }

        Ok(())
    }

    fn list_images(_privileged: bool) -> Result<Vec<Reference>> {
        #[derive(Deserialize, Debug)]
        #[serde(rename_all = "PascalCase")]
        struct Image {
            repository: String,
            tag: String,
        }

        let output = {
            let c = cmd!("docker", "images", "--format", "json");
            trace!("{c:?}");
            c
        }
        .output()
        .into_diagnostic()?;

        if !output.status.success() {
            let err_out = String::from_utf8_lossy(&output.stderr);
            bail!("Failed to list images:\n{}", err_out.trim());
        }

        let images: Vec<Image> = String::from_utf8_lossy(&output.stdout)
            .lines()
            .map(|line| serde_json::from_str::<Image>(line).into_diagnostic())
            .collect::<Result<_>>()?;

        images
            .into_iter()
            .filter(|image| image.repository != "<none>" && image.tag != "<none>")
            .map(|image| {
                format!("{}:{}", image.repository, image.tag)
                    .parse::<Reference>()
                    .into_diagnostic()
                    .with_context(|| format!("While parsing {image:?}"))
            })
            .collect()
    }
}

fn docker_run(opts: RunOpts, cid_file: &Path, detach: bool) -> Command {
    let command = cmd!(
        "docker",
        "run",
        "--cidfile",
        cid_file,
        if opts.privileged => "--privileged",
        if opts.remove => "--rm",
        if detach => "--detach",
        if opts.pull => "--pull=always",
        if let Some(user) = opts.user.as_ref() => format!("--user={user}"),
        for RunOptsVolume { path_or_vol_name, container_path } in opts.volumes.iter() => [
            "--volume",
            format!("{path_or_vol_name}:{container_path}"),
        ],
        for RunOptsEnv { key, value } in opts.env_vars.iter() => [
            "--env",
            format!("{key}={value}"),
        ],
        opts.image,
        for arg in opts.args.iter() => &**arg,
    );
    trace!("{command:?}");

    command
}
