use aws_config::SdkConfig;
use hyper::client::HttpConnector;
use std::fs::File;
use std::io::Write;
use std::path::Path;

use serde_json::Value;

use anyhow::Result;
use aws_sdk_s3::{primitives::ByteStream, Client};
use indicatif::{ProgressBar, ProgressStyle};

use crate::error::Error;

pub const BAR_FORMAT: &str = "[{elapsed_precise}] {bar:40.cyan/blue} ({bytes_per_sec}) {bytes:>7}/{total_bytes:7} {msg} [ETA {eta}]";
// Get a token for S3 and return the result
// If something breaks, return an error
pub async fn s3_auth(
  shasta_token: &str,
  shasta_base_url: &str,
  shasta_root_cert: &[u8],
) -> Result<Value, Error> {
  // STS
  let client_builder = reqwest::Client::builder()
    .add_root_certificate(reqwest::Certificate::from_pem(shasta_root_cert)?);

  // Build client
  let client = if let Ok(socks5_env) = std::env::var("SOCKS5") {
    // socks5 proxy
    log::debug!("SOCKS5 enabled");
    let socks5proxy = reqwest::Proxy::all(socks5_env).unwrap();

    // rest client to authenticate
    client_builder.proxy(socks5proxy).build().unwrap()
  } else {
    client_builder.build().unwrap()
  };

  let api_url = shasta_base_url.to_owned() + "/sts/token";

  let resp = client
    .put(api_url)
    .bearer_auth(shasta_token)
    .send()
    .await?
    .error_for_status()
    .map_err(|e| {
      Error::Message(format!(
        "ERROR - could not authenticate to S3 server. Reason:\n{}",
        e
      ))
    })?;

  let sts_value = resp.json::<serde_json::Value>().await.unwrap();

  log::debug!("-- STS Token retrieved --");
  log::debug!("Debug - STS token:\n{:#?}", sts_value);
  // SET AUTH ENVS
  std::env::set_var(
    "AWS_SESSION_TOKEN",
    sts_value["Credentials"]["SessionToken"].as_str().unwrap(),
  );
  std::env::set_var(
    "AWS_ACCESS_KEY_ID",
    sts_value["Credentials"]["AccessKeyId"].as_str().unwrap(),
  );
  std::env::set_var(
    "AWS_SECRET_ACCESS_KEY",
    sts_value["Credentials"]["SecretAccessKey"]
      .as_str()
      .unwrap(),
  );

  Ok(sts_value)
}

async fn setup_client(sts_value: &Value) -> Client {
  use aws_smithy_runtime::client::http::hyper_014::HyperClientBuilder;

  // Default provider fallback to us-east-1 since CSM doesn't use the concept of regions
  let region_provider =
    aws_config::meta::region::RegionProviderChain::default_provider()
      .or_else("us-east-1");
  let config: SdkConfig;

  if let Ok(socks5_env) = std::env::var("SOCKS5") {
    log::debug!("SOCKS5 enabled");

    let mut http_connector: HttpConnector = hyper::client::HttpConnector::new();
    http_connector.enforce_http(false);

    let socks_http_connector = hyper_socks2::SocksConnector {
      proxy_addr: socks5_env.to_string().parse::<hyper::Uri>().unwrap(), // scheme is required by HttpConnector
      auth: None,
      connector: http_connector.clone(),
    };
    // let smithy_connector = aws_smithy_client::hyper_ext::Adapter::builder()
    //     // Optionally set things like timeouts as well
    //     .connector_settings(
    //         aws_smithy_client::http_connector::ConnectorSettings::builder()
    //             .connect_timeout(std::time::Duration::from_secs(10))
    //             .build(),
    //     )
    //     .build(socks_http_connector);
    let http_client = HyperClientBuilder::new().build(socks_http_connector);

    config = aws_config::from_env()
      .region(region_provider)
      .http_client(http_client)
      .endpoint_url(sts_value["Credentials"]["EndpointURL"].as_str().unwrap())
      .app_name(aws_config::AppName::new("manta").unwrap())
      // .no_credentials()
      .load()
      .await;
  } else {
    config = aws_config::from_env()
      .region(region_provider)
      .endpoint_url(sts_value["Credentials"]["EndpointURL"].as_str().unwrap())
      .app_name(aws_config::AppName::new("manta").unwrap())
      // .no_credentials()
      .load()
      .await;
  }

  let client = aws_sdk_s3::Client::from_conf(
    aws_sdk_s3::Client::new(&config)
      .config()
      .to_builder()
      .force_path_style(true)
      .build(),
  );
  client
}
/// Gets the size of a given object in S3
/// path of the object: s3://bucket/key
/// returns i64 or error
pub async fn s3_get_object_size(
  sts_value: &Value,
  key: &str,
  bucket: &str,
) -> Result<i64, Error> {
  let client = setup_client(sts_value).await;
  match client.get_object().bucket(bucket).key(key).send().await {
    Ok(object) => Ok(object.content_length().unwrap()),
    Err(e) => Err(Error::Message(format!(
      "Error, unable to get object size from s3. Error msg: {}",
      e
    ))),
  }
}

/// Gets an object from S3
///
/// # Needs
/// - `sts_value` the temporary S3 token obtained from STS via `s3_auth()`
/// - `object_path` path within the bucket in S3 of the object e.g. `392o1h-1-234-w1/manifest.json`
/// - `bucket` bucket where the object is contained.
/// - `destination_path` <p>path in the local filesystem where the file will be downloaded to
///         e.g. `/tmp/my_images/392o1h-1-234-w1` the file will be downloaded then to
///             `/tmp/my_images/392o1h-1-234-w1/manifest.json`</p>
/// # Returns
///   * String: full path of the object downloaded OR
///   * Box<dyn Error>: descriptive error if not possible to download or to store the object
pub async fn s3_download_object(
  sts_value: &Value,
  object_path: &str,
  bucket: &str,
  destination_path: &str,
) -> Result<String, Error> {
  let client = setup_client(sts_value).await;

  let filename = Path::new(object_path).file_name().unwrap();
  let file_path = Path::new(destination_path).join(filename);
  log::debug!("Create directory '{}'", destination_path);

  std::fs::create_dir_all(destination_path).map_err(|e| {
    Error::Message(format!(
      "Error creating directory {}: {}",
      destination_path, e
    ))
  })?;

  log::debug!("Created directory '{}' successfully", destination_path);

  let mut file = File::create(&file_path).map_err(|e| {
    Error::Message(format!(
      "Error creating file {}: {}",
      &file_path.to_string_lossy(),
      e
    ))
  })?;

  log::debug!(
    "Created file '{}' successfully",
    &file_path.to_string_lossy()
  );

  let mut object = client
    .get_object()
    .bucket(bucket)
    .key(object_path)
    .send()
    .await
    .map_err(|e| {
      Error::Message(format!(
        "ERROR - could not download S3 object.\nReason:\n{}",
        e.to_string(),
      ))
    })?;

  let bar_size = object.content_length().unwrap();
  let bar = ProgressBar::new(bar_size as u64);
  bar.set_style(ProgressStyle::with_template(BAR_FORMAT).unwrap());

  while let Some(bytes) = object.body.try_next().await.map_err(|e| {
    Error::Message(format!(
      "ERROR - Could not finish s3 object download.\nReason:\n{}",
      e.to_string()
    ))
  })? {
    let bytes = file.write(&bytes)?;
    bar.inc(bytes as u64);
  }
  bar.finish();
  Ok(file_path.to_string_lossy().to_string())
}

/// Uploads an object to S3
///
/// # Needs
/// - `sts_value` the temporary S3 token obtained from STS via `s3_auth()`
/// - `object_path` path within the bucket in S3 of the object e.g. `392o1h-1-234-w1/manifest.json`
/// - `bucket` bucket where the object will be stored
/// - `file_path` <p>path in the local filesystem where the file is located
/// # Returns
///   * String: size the object uploaded OR
///   * Box<dyn Error>: descriptive error if not possible to upload the object
pub async fn s3_upload_object(
  sts_value: &Value,
  object_path: &str,
  bucket: &str,
  file_path: &str,
) -> Result<String, Error> {
  let client = setup_client(sts_value).await;

  let body = ByteStream::from_path(Path::new(&file_path)).await;

  match client
    .put_object()
    .bucket(bucket)
    .key(object_path)
    .body(body.unwrap())
    .send()
    .await
  {
    Ok(put_object_output) => {
      log::debug!("Uploaded file '{}' successfully", &file_path);
      Ok(put_object_output.e_tag.unwrap())
    }
    Err(error) => Err(Error::Message(format!(
      "Error uploading file {}: {}",
      &file_path, error
    ))),
  }
}

/// Removes an object from S3
///
/// # Needs
/// - `sts_value` the temporary S3 token obtained from STS via `s3_auth()`
/// - `object_path` path within the bucket in S3 of the object e.g. `392o1h-1-234-w1/manifest.json`
/// - `bucket` bucket where the object will be stored
/// # Returns
///   * String: size the object uploaded OR
///   * Box<dyn Error>: descriptive error if not possible to upload the object
pub async fn s3_remove_object(
  sts_value: &Value,
  object_path: &str,
  bucket: &str,
) -> Result<String, Error> {
  let client = setup_client(sts_value).await;

  match client
    .delete_object()
    .bucket(bucket)
    .key(object_path)
    .send()
    .await
  {
    Ok(_file) => {
      log::debug!("Cleaned file '{}' successfully", &object_path);
      Ok(String::from("client"))
    }
    Err(error) => Err(Error::Message(format!(
      "Error cleaning file {}: {}",
      &object_path, error
    ))),
  }
}

/// Uploads an object to S3 using the multipart method
///
/// # Needs
/// - `sts_value` the temporary S3 token obtained from STS via `s3_auth()`
/// - `object_path` path within the bucket in S3 of the object e.g. `392o1h-1-234-w1/manifest.json`
/// - `bucket` bucket where the object will be stored
/// - `file_path` <p>path in the local filesystem where the file is located
/// # Returns
///   * String: size the object uploaded OR
///   * Box<dyn Error>: descriptive error if not possible to upload the object
pub async fn s3_multipart_upload_object(
  sts_value: &Value,
  object_path: &str,
  bucket: &str,
  file_path: &str,
) -> Result<String, Error> {
  use aws_sdk_s3::operation::create_multipart_upload::CreateMultipartUploadOutput;
  use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
  use aws_smithy_types::byte_stream::Length;

  let client = setup_client(sts_value).await;

  //In bytes, minimum chunk size of 5MB. Increase CHUNK_SIZE to send larger chunks.
  const CHUNK_SIZE: u64 = 1024 * 1024 * 5;
  const MAX_CHUNKS: u64 = 10000;

  // create multipart upload
  let multipart_upload_res: CreateMultipartUploadOutput = client
    .create_multipart_upload()
    .bucket(bucket)
    .key(object_path)
    .send()
    .await
    .map_err(|e| {
      Error::Message(format!(
        "ERROR - Could not create multipart object.\nReason:\n{}",
        e.to_string()
      ))
    })?;

  let upload_id = multipart_upload_res.upload_id().unwrap();

  // Get details of the upload, this is needed because multipart uploads
  // are tricky and have a minimum chunk size of 5MB
  let path = Path::new(&file_path);
  let file_size = std::fs::metadata(path)
    .map_err(|e| {
      Error::Message(format!(
        "ERROR - Could not get file size from '{}'.\nReason\n{}",
        file_path,
        e.to_string()
      ))
    })?
    .len();

  let mut chunk_count = (file_size / CHUNK_SIZE) + 1;
  let mut size_of_last_chunk = file_size % CHUNK_SIZE;
  if size_of_last_chunk == 0 {
    size_of_last_chunk = CHUNK_SIZE;
    chunk_count -= 1;
  }

  let bar = ProgressBar::new(file_size);
  bar.set_style(ProgressStyle::with_template(BAR_FORMAT).unwrap());

  if file_size == 0 {
    return Err(Error::Message("Bad file size.".to_string()));
  }
  if chunk_count > MAX_CHUNKS {
    return Err(Error::Message(
      "Too many chunks! Try increasing your chunk size.".to_string(),
    ));
  }

  let mut upload_parts: Vec<CompletedPart> = Vec::new();

  for chunk_index in 0..chunk_count {
    let this_chunk = if chunk_count - 1 == chunk_index {
      size_of_last_chunk
    } else {
      CHUNK_SIZE
    };
    let stream = ByteStream::read_from()
      .path(path)
      .offset(chunk_index * CHUNK_SIZE)
      .length(Length::Exact(this_chunk))
      .build()
      .await
      .map_err(|e| {
        Error::Message(format!(
          "ERROR - Could not read file '{}'.\nReason:\n{}",
          path.display(),
          e
        ))
      })?;
    //Chunk index needs to start at 0, but part numbers start at 1.
    let part_number = (chunk_index as i32) + 1;
    let upload_part_res = client
      .upload_part()
      .key(object_path)
      .bucket(bucket)
      .upload_id(upload_id)
      .body(stream)
      .part_number(part_number)
      .send()
      .await
      .map_err(|e| {
        Error::Message(format!(
          "ERROR - could not upload to S3.\nReason:\n{}",
          e.to_string()
        ))
      })?;
    upload_parts.push(
      CompletedPart::builder()
        .e_tag(upload_part_res.e_tag.unwrap_or_default())
        .part_number(part_number)
        .build(),
    );
    bar.inc(this_chunk);
  }
  // complete the multipart upload
  let completed_multipart_upload: CompletedMultipartUpload =
    CompletedMultipartUpload::builder()
      .set_parts(Some(upload_parts))
      .build();

  let _complete_multipart_upload_res = client
    .complete_multipart_upload()
    .bucket(bucket)
    .key(object_path)
    .multipart_upload(completed_multipart_upload)
    .upload_id(upload_id)
    .send()
    .await
    .map_err(|e| {
      Error::Message(format!(
        "ERROR - could not upload to S3.\nReason:\n{}",
        e.to_string()
      ))
    })?;

  bar.finish();

  Ok(_complete_multipart_upload_res.e_tag.clone().unwrap())
}
