/*
 * Copyright 2015 Netflix, Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *    http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.netflix.spinnaker.igor.jenkins.service;

import static net.logstash.logback.argument.StructuredArguments.kv;
import static org.springframework.http.HttpStatus.NOT_FOUND;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.netflix.spinnaker.fiat.model.resources.Permissions;
import com.netflix.spinnaker.igor.build.model.GenericBuild;
import com.netflix.spinnaker.igor.build.model.GenericGitRevision;
import com.netflix.spinnaker.igor.exceptions.ArtifactNotFoundException;
import com.netflix.spinnaker.igor.exceptions.BuildJobError;
import com.netflix.spinnaker.igor.exceptions.QueuedJobDeterminationError;
import com.netflix.spinnaker.igor.jenkins.client.JenkinsClient;
import com.netflix.spinnaker.igor.jenkins.client.model.Build;
import com.netflix.spinnaker.igor.jenkins.client.model.BuildArtifact;
import com.netflix.spinnaker.igor.jenkins.client.model.BuildDependencies;
import com.netflix.spinnaker.igor.jenkins.client.model.JobConfig;
import com.netflix.spinnaker.igor.jenkins.client.model.JobList;
import com.netflix.spinnaker.igor.jenkins.client.model.Project;
import com.netflix.spinnaker.igor.jenkins.client.model.ProjectsList;
import com.netflix.spinnaker.igor.jenkins.client.model.QueuedJob;
import com.netflix.spinnaker.igor.jenkins.client.model.ScmDetails;
import com.netflix.spinnaker.igor.model.BuildServiceProvider;
import com.netflix.spinnaker.igor.model.Crumb;
import com.netflix.spinnaker.igor.service.BuildOperations;
import com.netflix.spinnaker.igor.service.BuildProperties;
import com.netflix.spinnaker.kork.core.RetrySupport;
import com.netflix.spinnaker.kork.exceptions.SpinnakerException;
import com.netflix.spinnaker.kork.web.exceptions.NotFoundException;
import com.netflix.spinnaker.security.AuthenticatedRequest;
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
import java.io.InputStream;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.util.UriUtils;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
import retrofit.RetrofitError;
import retrofit.client.Header;
import retrofit.client.Response;

@Slf4j
public class JenkinsService implements BuildOperations, BuildProperties {
  private final ObjectMapper objectMapper = new ObjectMapper();
  private final String serviceName;
  private final JenkinsClient jenkinsClient;
  private final Boolean csrf;
  private final RetrySupport retrySupport = new RetrySupport();
  private final Permissions permissions;
  private final CircuitBreaker circuitBreaker;

  public JenkinsService(
      String jenkinsHostId,
      JenkinsClient jenkinsClient,
      Boolean csrf,
      Permissions permissions,
      CircuitBreakerRegistry circuitBreakerRegistry) {
    this.serviceName = jenkinsHostId;
    this.jenkinsClient = jenkinsClient;
    this.csrf = csrf;
    this.permissions = permissions;
    this.circuitBreaker =
        circuitBreakerRegistry.circuitBreaker(
            "jenkins-" + jenkinsHostId,
            CircuitBreakerConfig.custom()
                .ignoreException(
                    (e) -> {
                      if (e instanceof RetrofitError) {
                        RetrofitError re = (RetrofitError) e;
                        return re.getKind() == RetrofitError.Kind.HTTP
                            && re.getResponse().getStatus() == 404;
                      }
                      return false;
                    })
                .build());
  }

  @Override
  public String getName() {
    return this.serviceName;
  }

  private String encode(String uri) {
    return UriUtils.encodeFragment(uri, "UTF-8");
  }

  public ProjectsList getProjects() {
    return circuitBreaker.executeSupplier(
        () -> {
          ProjectsList projectsList =
              AuthenticatedRequest.allowAnonymous(() -> jenkinsClient.getProjects());
          if (projectsList == null || projectsList.getList() == null) {
            return new ProjectsList();
          }
          List<Project> projects =
              projectsList.getList().stream()
                  .flatMap(this::recursiveGetProjects)
                  .collect(Collectors.toList());
          ProjectsList projectList = new ProjectsList();
          projectList.setList(projects);
          return projectList;
        });
  }

  private Stream<Project> recursiveGetProjects(Project project) {
    return recursiveGetProjects(project, "");
  }

  private Stream<Project> recursiveGetProjects(Project project, String prefix) {
    String projectName = prefix + project.getName();
    if (project.getList() == null || project.getList().isEmpty()) {
      project.setName(projectName);
      return Stream.of(project);
    }
    return project.getList().stream().flatMap(p -> recursiveGetProjects(p, projectName + "/job/"));
  }

  public JobList getJobs() {
    return circuitBreaker.executeSupplier(jenkinsClient::getJobs);
  }

  public String getCrumb() {
    if (csrf) {
      return circuitBreaker.executeSupplier(
          () -> {
            Crumb crumb = jenkinsClient.getCrumb();
            if (crumb != null) {
              return crumb.getCrumb();
            }
            return null;
          });
    }
    return null;
  }

  @Override
  public List<Build> getBuilds(String jobName) {
    return circuitBreaker.executeSupplier(
        () ->
            AuthenticatedRequest.allowAnonymous(() -> jenkinsClient.getBuilds(encode(jobName)))
                .getList());
  }

  public BuildDependencies getDependencies(String jobName) {
    return circuitBreaker.executeSupplier(() -> jenkinsClient.getDependencies(encode(jobName)));
  }

  public Build getBuild(String jobName, Integer buildNumber) {
    return circuitBreaker.executeSupplier(
        () -> jenkinsClient.getBuild(encode(jobName), buildNumber));
  }

  @Override
  public GenericBuild getGenericBuild(String jobName, int buildNumber) {
    return getBuild(jobName, buildNumber).genericBuild(jobName);
  }

  @Override
  public int triggerBuildWithParameters(String job, Map<String, String> queryParameters) {
    Response response = buildWithParameters(job, queryParameters);
    if (response.getStatus() != 201) {
      throw new BuildJobError("Received a non-201 status when submitting job '" + job + "'");
    }

    log.info("Submitted build job '{}'", kv("job", job));
    String queuedLocation =
        response.getHeaders().stream()
            .filter(h -> h.getName() != null)
            .filter(h -> h.getName().toLowerCase().equals("location"))
            .map(Header::getValue)
            .findFirst()
            .orElseThrow(
                () ->
                    new QueuedJobDeterminationError(
                        "Could not find Location header for job '" + job + "'"));

    int lastSlash = queuedLocation.lastIndexOf('/');
    return Integer.parseInt(queuedLocation.substring(lastSlash + 1));
  }

  @Override
  public Permissions getPermissions() {
    return permissions;
  }

  private ScmDetails getGitDetails(String jobName, Integer buildNumber) {
    return retrySupport.retry(
        () -> {
          try {
            return jenkinsClient.getGitDetails(encode(jobName), buildNumber);
          } catch (RetrofitError e) {
            // assuming that a conversion error is unlikely to succeed on retry
            if (e.getKind() == RetrofitError.Kind.CONVERSION) {
              log.warn(
                  "Unable to deserialize git details for build " + buildNumber + " of " + jobName,
                  e);
              return null;
            } else {
              throw e;
            }
          }
        },
        10,
        1000,
        false);
  }

  public Build getLatestBuild(String jobName) {
    return circuitBreaker.executeSupplier(() -> jenkinsClient.getLatestBuild(encode(jobName)));
  }

  @Override
  public QueuedJob queuedBuild(String master, int item) {
    try {
      return circuitBreaker.executeSupplier(() -> jenkinsClient.getQueuedItem(item));
    } catch (RetrofitError e) {
      if (e.getResponse() != null && e.getResponse().getStatus() == NOT_FOUND.value()) {
        throw new NotFoundException(
            String.format("Queued job '%s' not found for master '%s'.", item, master));
      }
      throw e;
    }
  }

  public Response build(String jobName) {
    return circuitBreaker.executeSupplier(
        () -> jenkinsClient.build(encode(jobName), "", getCrumb()));
  }

  public Response buildWithParameters(String jobName, Map<String, String> queryParams) {
    return circuitBreaker.executeSupplier(
        () -> jenkinsClient.buildWithParameters(encode(jobName), queryParams, "", getCrumb()));
  }

  @Override
  public JobConfig getJobConfig(String jobName) {
    return circuitBreaker.executeSupplier(() -> jenkinsClient.getJobConfig(encode(jobName)));
  }

  @Override
  public Map<String, Object> getBuildProperties(String job, GenericBuild build, String fileName) {
    if (StringUtils.isEmpty(fileName)) {
      return new HashMap<>();
    }
    Map<String, Object> map = new HashMap<>();
    try {
      String path = getArtifactPathFromBuild(job, build.getNumber(), fileName);
      try (InputStream propertyStream =
          this.getPropertyFile(job, build.getNumber(), path).getBody().in()) {
        if (fileName.endsWith(".yml") || fileName.endsWith(".yaml")) {
          Yaml yml = new Yaml(new SafeConstructor());
          map = yml.load(propertyStream);
        } else if (fileName.endsWith(".json")) {
          map = objectMapper.readValue(propertyStream, new TypeReference<Map<String, Object>>() {});
        } else {
          Properties properties = new Properties();
          properties.load(propertyStream);
          map =
              properties.entrySet().stream()
                  .collect(Collectors.toMap(e -> e.getKey().toString(), Map.Entry::getValue));
        }
      }
    } catch (NotFoundException e) {
      throw e;
    } catch (Exception e) {
      log.error("Unable to get igorProperties '{}'", kv("job", job), e);
    }
    return map;
  }

  private String getArtifactPathFromBuild(String job, int buildNumber, String fileName) {
    return retrySupport.retry(
        () ->
            this.getBuild(job, buildNumber).getArtifacts().stream()
                .filter(a -> a.getFileName().equals(fileName))
                .map(BuildArtifact::getRelativePath)
                .findFirst()
                .orElseThrow(
                    () -> {
                      log.error(
                          "Unable to get igorProperties: Could not find build artifact matching requested filename '{}' on '{}' build '{}",
                          kv("fileName", fileName),
                          kv("master", serviceName),
                          kv("buildNumber", buildNumber));
                      return new ArtifactNotFoundException(serviceName, job, buildNumber, fileName);
                    }),
        5,
        2000,
        false);
  }

  private Response getPropertyFile(String jobName, Integer buildNumber, String fileName) {
    return retrySupport.retry(
        () -> {
          try {
            return jenkinsClient.getPropertyFile(encode(jobName), buildNumber, fileName);
          } catch (RetrofitError e) {
            // retry on network issue, 404 and 5XX
            if (e.getKind() == RetrofitError.Kind.NETWORK
                || (e.getKind() == RetrofitError.Kind.HTTP
                    && (e.getResponse().getStatus() == 404
                        || e.getResponse().getStatus() >= 500))) {
              throw e;
            }
            SpinnakerException ex = new SpinnakerException(e);
            ex.setRetryable(false);
            throw ex;
          }
        },
        5,
        Duration.ofSeconds(2),
        false);
  }

  public Response stopRunningBuild(String jobName, Integer buildNumber) {
    return circuitBreaker.executeSupplier(
        () -> jenkinsClient.stopRunningBuild(encode(jobName), buildNumber, "", getCrumb()));
  }

  public Response stopQueuedBuild(String queuedBuild) {
    return circuitBreaker.executeSupplier(
        () -> jenkinsClient.stopQueuedBuild(queuedBuild, "", getCrumb()));
  }

  @Override
  public BuildServiceProvider getBuildServiceProvider() {
    return BuildServiceProvider.JENKINS;
  }

  @Override
  public List<GenericGitRevision> getGenericGitRevisions(String job, GenericBuild build) {
    ScmDetails scmDetails = getGitDetails(job, build.getNumber());
    return scmDetails.genericGitRevisions();
  }
}
