use std::fmt::Write;

use anyhow::{Context as _, anyhow};
use command::{
  KomodoCommandMode, run_komodo_command_with_sanitization,
  run_komodo_standard_command,
};
use formatting::format_serror;
use interpolate::Interpolator;
use komodo_client::{
  entities::{
    all_logs_success,
    docker::stack::SwarmStack,
    stack::{
      AdditionalEnvFile, ComposeFile, ComposeService,
      StackServiceNames,
    },
    update::Log,
  },
  parsers::parse_multiline_command,
};
use mogh_resolver::Resolve;
use periphery_client::api::{
  DeployStackResponse,
  swarm::{DeploySwarmStack, InspectSwarmStack, RemoveSwarmStacks},
};
use tracing::Instrument as _;

use crate::{
  config::periphery_config,
  helpers::push_extra_args,
  stack::{maybe_login_registry, validate_files, write::write_stack},
  state::docker_client,
};

/// Apply compose_cmd_wrapper to command if the subcommand is in wrapper_include list.
/// Returns Ok((command, wrapped)) where `wrapped` indicates if wrapper was applied.
fn maybe_wrap_command(
  command: String,
  wrapper: &str,
  wrapper_include: &[String],
  subcommand: &str,
) -> Result<(String, bool), Log> {
  // Skip wrapping if wrapper is empty or subcommand is not in include list
  if wrapper.is_empty()
    || !wrapper_include.iter().any(|c| c == subcommand)
  {
    return Ok((command, false));
  }

  // Validate wrapper contains placeholder
  if !wrapper.contains("[[COMPOSE_COMMAND]]") {
    return Err(Log::error(
      "Compose Command Wrapper",
      "compose_cmd_wrapper is configured but does not contain [[COMPOSE_COMMAND]] placeholder. The placeholder is required to inject the compose command.".to_string(),
    ));
  }

  Ok((wrapper.replace("[[COMPOSE_COMMAND]]", &command), true))
}

impl Resolve<crate::api::Args> for InspectSwarmStack {
  async fn resolve(
    self,
    _: &crate::api::Args,
  ) -> anyhow::Result<SwarmStack> {
    let client = docker_client().load();
    let client = client
      .iter()
      .next()
      .context("Could not connect to docker client")?;
    client.inspect_swarm_stack(self.stack).await
  }
}

impl Resolve<crate::api::Args> for RemoveSwarmStacks {
  #[instrument(
    "RemoveSwarmStacks",
    skip_all,
    fields(
      id = args.id.to_string(),
      core = args.core,
      stacks = serde_json::to_string(&self.stacks).unwrap_or_else(|e| e.to_string()),
    )
  )]
  async fn resolve(
    self,
    args: &crate::api::Args,
  ) -> anyhow::Result<Log> {
    let mut command = String::from("docker stack rm");
    // This defaults to true, only need when false
    if !self.detach {
      command += " --detach=false"
    }
    for stack in self.stacks {
      command += " ";
      command += &stack;
    }
    Ok(
      run_komodo_standard_command(
        "Remove Swarm Stacks",
        None,
        command,
      )
      .await,
    )
  }
}

impl Resolve<crate::api::Args> for DeploySwarmStack {
  #[instrument(
    "DeploySwarmStack",
    skip_all,
    fields(
      id = args.id.to_string(),
      core = args.core,
      stack = self.stack.name,
      repo = self.repo.as_ref().map(|repo| &repo.name),
    )
  )]
  async fn resolve(
    self,
    args: &crate::api::Args,
  ) -> Result<Self::Response, Self::Error> {
    let DeploySwarmStack {
      mut stack,
      repo,
      git_token,
      registry_token,
      mut replacers,
    } = self;

    let mut res = DeployStackResponse::default();

    let mut interpolator =
      Interpolator::new(None, &periphery_config().secrets);
    // Only interpolate Stack. Repo interpolation will be handled
    // by the CloneRepo / PullOrCloneRepo call.
    interpolator
      .interpolate_stack(&mut stack)?
      .push_logs(&mut res.logs);
    replacers.extend(interpolator.secret_replacers);

    // Env files are not supported by docker stack deploy so are ignored.
    let (run_directory, env_file_path) = match write_stack(
      &stack,
      repo.as_ref(),
      git_token,
      replacers.clone(),
      &mut res,
      args,
    )
    .await
    {
      Ok(res) => res,
      Err(e) => {
        res
          .logs
          .push(Log::error("Write Stack", format_serror(&e.into())));
        return Ok(res);
      }
    };

    // Canonicalize the path to ensure it exists, and is the cleanest path to the run directory.
    let run_directory = run_directory.canonicalize().context(
      "Failed to validate run directory on host after stack write (canonicalize error)",
    )?;

    validate_files(&stack, &run_directory, &mut res).await;
    if !all_logs_success(&res.logs) {
      return Ok(res);
    }

    let use_with_registry_auth =
      maybe_login_registry(&stack, registry_token, &mut res.logs)
        .await;
    if !all_logs_success(&res.logs) {
      return Ok(res);
    }

    // Pre deploy
    if !stack.config.pre_deploy.is_none() {
      let pre_deploy_path =
        run_directory.join(&stack.config.pre_deploy.path);
      let span = info_span!("ExecutePreDeploy");
      if let Some(log) = run_komodo_command_with_sanitization(
        "Pre Deploy",
        pre_deploy_path.as_path(),
        &stack.config.pre_deploy.command,
        KomodoCommandMode::Multiline,
        &replacers,
      )
      .instrument(span)
      .await
      {
        res.logs.push(log);
        if !all_logs_success(&res.logs) {
          return Ok(res);
        }
      };
    }

    let file_args = stack.compose_file_paths().join(" -c ");

    // This will be the last project name, which is the one that needs to be destroyed.
    // Might be different from the current project name, if user renames stack / changes to custom project name.
    let last_project_name = stack.project_name(false);
    let project_name = stack.project_name(true);

    // Parse wrapper configuration once for reuse
    let compose_cmd_wrapper =
      parse_multiline_command(&stack.config.compose_cmd_wrapper);
    // If wrapper_include is empty but wrapper is set, use default ["deploy"] for backward compatibility
    let default_include = vec![String::from("deploy")];
    let wrapper_include =
      if stack.config.compose_cmd_wrapper_include.is_empty()
        && !compose_cmd_wrapper.is_empty()
      {
        &default_include
      } else {
        &stack.config.compose_cmd_wrapper_include
      };

    let env_file_args = env_file_args(
      env_file_path,
      &stack.config.additional_env_files,
    )?;

    // Uses 'docker stack config' command to extract services (including image)
    // after performing interpolation
    {
      let command =
        format!("{env_file_args}docker stack config -c {file_args}",);
      let (command, wrapped) = match maybe_wrap_command(
        command,
        &compose_cmd_wrapper,
        wrapper_include,
        "config",
      ) {
        Ok(result) => result,
        Err(log) => {
          res.logs.push(log);
          return Ok(res);
        }
      };
      let mode = if wrapped || !env_file_args.is_empty() {
        KomodoCommandMode::Shell
      } else {
        KomodoCommandMode::Standard
      };
      let span = info_span!("GetStackConfig", command);
      let Some(config_log) = run_komodo_command_with_sanitization(
        "Stack Config",
        run_directory.as_path(),
        command,
        mode,
        &replacers,
      )
      .instrument(span)
      .await
      else {
        // Only reachable if command is empty,
        // not the case since it is provided above.
        unreachable!()
      };
      if !config_log.success {
        res.logs.push(config_log);
        return Ok(res);
      }
      let compose =
        serde_yaml_ng::from_str::<ComposeFile>(&config_log.stdout)
          .context("Failed to parse compose contents")?;
      // Store sanitized stack config output
      res.merged_config = Some(config_log.stdout);
      for (service_name, ComposeService { image, .. }) in
        compose.services
      {
        let image = image.unwrap_or_default();
        res.services.push(StackServiceNames {
          container_name: format!("{project_name}-{service_name}"),
          service_name,
          image,
          image_digest: None,
        });
      }
    }

    if stack.config.destroy_before_deploy
      // Also check if project name changed, which also requires taking down.
      || last_project_name != project_name
    {
      // Take down the existing stack.
      // This one tries to use the previously deployed project name, to ensure the right stack is taken down.
      remove_stack(&last_project_name, &mut res)
        .await
        .context("Failed to destroy existing stack")?;
    }

    // Run stack deploy
    let mut command = format!(
      "{env_file_args}docker stack deploy --detach=true -c {file_args}"
    );
    if use_with_registry_auth {
      command += " --with-registry-auth";
    }
    push_extra_args(&mut command, &stack.config.extra_args)?;
    write!(&mut command, " {project_name}")?;

    // Apply compose cmd wrapper if configured
    let (command, _) = match maybe_wrap_command(
      command,
      &compose_cmd_wrapper,
      wrapper_include,
      "deploy",
    ) {
      Ok(result) => result,
      Err(log) => {
        res.logs.push(log);
        return Ok(res);
      }
    };

    let span = info_span!("ExecuteStackDeploy");
    let Some(log) = run_komodo_command_with_sanitization(
      "Deploy Swarm Stack",
      run_directory.as_path(),
      command,
      KomodoCommandMode::Shell,
      &replacers,
    )
    .instrument(span)
    .await
    else {
      unreachable!()
    };

    res.deployed = log.success;
    res.logs.push(log);

    if res.deployed && !stack.config.post_deploy.is_none() {
      let post_deploy_path =
        run_directory.join(&stack.config.post_deploy.path);
      let span = info_span!("ExecutePostDeploy");
      if let Some(log) = run_komodo_command_with_sanitization(
        "Post Deploy",
        post_deploy_path.as_path(),
        &stack.config.post_deploy.command,
        KomodoCommandMode::Multiline,
        &replacers,
      )
      .instrument(span)
      .await
      {
        res.logs.push(log);
      };
    }

    Ok(res)
  }
}

/// Prefix docker stack deploy command.
///
/// Produces either
///     - Empty string (no env files defined)
///     - 'set -a && . .env1 && . .env2 && '
fn env_file_args(
  env_file_path: Option<&str>,
  additional_env_files: &[AdditionalEnvFile],
) -> anyhow::Result<String> {
  let mut res = String::new();

  // Add additional env files (except komodo's own, which comes last)
  for file in additional_env_files
    .iter()
    .filter(|f| env_file_path != Some(f.path.as_str()))
  {
    let path = &file.path;
    write!(res, ". {path} && ").with_context(|| {
      format!("Failed to write env source arg for {path}")
    })?;
  }

  // Add komodo's env file last for highest priority
  if let Some(file) = env_file_path {
    write!(res, ". {file} && ").with_context(|| {
      format!("Failed to write env source arg for {file}")
    })?;
  }

  if !res.is_empty() {
    // Add 'set -a' so vars are auto exported to child 'docker stack' process.
    Ok(format!("set -a && {res}"))
  } else {
    Ok(res)
  }
}

#[instrument("RemoveStack", skip(res))]
async fn remove_stack(
  stack: &str,
  res: &mut DeployStackResponse,
) -> anyhow::Result<()> {
  let log = run_komodo_standard_command(
    "Remove Stack",
    None,
    format!("docker stack rm --detach=false {stack}"),
  )
  .await;
  let success = log.success;
  res.logs.push(log);
  if !success {
    return Err(anyhow!(
      "Failed to remove existing stack with docker stack rm. Stopping run."
    ));
  }
  Ok(())
}
