/**
 * This file is part of Eclipse Steady.
 *
 * 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.
 *
 * SPDX-License-Identifier: Apache-2.0
 * SPDX-FileCopyrightText: Copyright (c) 2018-2020 SAP SE or an SAP affiliate company and Eclipse Steady contributors
 */
package org.eclipse.steady.goals;

import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;

import org.apache.commons.configuration.Configuration;
import org.apache.commons.configuration.ConfigurationException;
import org.apache.logging.log4j.Logger;
import org.eclipse.steady.backend.BackendConnector;
import org.eclipse.steady.core.util.CoreConfiguration;
import org.eclipse.steady.shared.enums.GoalClient;
import org.eclipse.steady.shared.enums.GoalType;
import org.eclipse.steady.shared.json.JsonBuilder;
import org.eclipse.steady.shared.json.model.Application;
import org.eclipse.steady.shared.json.model.Space;
import org.eclipse.steady.shared.json.model.Tenant;
import org.eclipse.steady.shared.util.MemoryMonitor;
import org.eclipse.steady.shared.util.StopWatch;
import org.eclipse.steady.shared.util.StringList;
import org.eclipse.steady.shared.util.StringList.CaseSensitivity;
import org.eclipse.steady.shared.util.StringList.ComparisonMode;
import org.eclipse.steady.shared.util.StringUtil;
import org.eclipse.steady.shared.util.VulasConfiguration;

/**
 * Represents the execution of a goal, which is triggered by client components such as the CLI and the Maven plugin.
 * There exist different types of goals, e.g., {@link GoalType#CLEAN} or {@link GoalType#A2C}.
 *
 * Goal executions can be started and stopped manually using the methods {@link AbstractGoal#start()},
 * {@link AbstractGoal#stop()}, {@link AbstractGoal#stop(Exception)} and {@link AbstractGoal#upload(boolean)}.
 *
 * Goal executions can also be executed automatically using the methods
 * {@link AbstractGoal#executeSync()} or {@link AbstractGoal#executeAsync()}, which results in the sequential
 * execution of the above-mentioned methods {@link AbstractGoal#start()}, etc.
 *
 * Subclasses typically override the methods prepareExecution, executeTasks and cleanAfterExecution.
 *
 * See VULAS-204 in case we run into problems related to special characters in paths.
 */
public abstract class AbstractGoal implements Runnable {

  private static final Logger log =
      org.apache.logging.log4j.LogManager.getLogger(AbstractGoal.class);

  /** Constant <code>CLASS_EXT</code> */
  protected static final String[] CLASS_EXT = new String[] {"CLASS"};
  /** Constant <code>JAR_EXT</code> */
  protected static final String[] JAR_EXT = new String[] {"jar"};
  /** Constant <code>WAR_EXT</code> */
  protected static final String[] WAR_EXT = new String[] {"war"};
  /** Constant <code>JAR_WAR_EXT</code> */
  protected static final String[] JAR_WAR_EXT = new String[] {"jar", "war"};

  private GoalClient client = null;

  /**
   * Random identifier composed of the goal, current time millis and a random number (8 digits).
   */
  private String id = null;

  private final long createdAt = System.currentTimeMillis();

  /**
   * The goal-specific configuration.
   */
  private VulasConfiguration configuration;

  /**
   * The context in which the goal is going to be executed.
   */
  private GoalContext goalContext = null;

  /**
   * The goal executed.
   */
  private GoalType goalType = null;

  /**
   * The exception that terminated the goal execution (if any).
   * @see #stop(Exception)
   */
  private Exception exception = null;

  // Stop watch to determine runtime
  private StopWatch stopWatch = null;

  // Memory monitoring
  private MemoryMonitor memoThread = null;

  // System information (CPU, OS, JVM, etc.)
  private Map<String, String> systemInfo = new HashMap<String, String>();

  // Goal-specific stats (set from outside)
  private Map<String, Double> goalStats = new HashMap<String, Double>();

  private ExecutionObserver observer = null;

  /** Determines whether goal execution info will be uploaded. */
  private boolean goalUploadEnabled = true;

  /*
   * Creates a new goal execution.
   * @param _app the context of this execution (can be null, i.e., unknown, in case of TEST)
   * @param _goal the goal of this execution
   * @see
   */
  /**
   * <p>Constructor for AbstractGoal.</p>
   *
   * @param _goal a {@link org.eclipse.steady.shared.enums.GoalType} object.
   */
  protected AbstractGoal(GoalType _goal) {
    this(_goal, true);
  }

  /**
   * <p>Constructor for AbstractGoal.</p>
   *
   * @param _goal a {@link org.eclipse.steady.shared.enums.GoalType} object.
   * @param _monitor_mem a boolean.
   */
  protected AbstractGoal(GoalType _goal, boolean _monitor_mem) {
    this.goalType = _goal;

    // Create memory monitor (if requested)
    if (_monitor_mem) this.memoThread = new MemoryMonitor();

    // Number of processors
    this.systemInfo.put(
        "runtime.availableProcessors",
        Integer.toString(Runtime.getRuntime().availableProcessors()));
  }

  /**
   * <p>Getter for the field <code>id</code>.</p>
   *
   * @return a {@link java.lang.String} object.
   */
  public synchronized String getId() {
    if (this.id == null) {
      if (this.client != null)
        this.id =
            this.client
                + "-"
                + this.goalType
                + "-"
                + this.createdAt
                + "-"
                + (int) Math.abs(Math.random() * 100000000);
      else
        this.id =
            this.goalType + "-" + this.createdAt + "-" + (int) Math.abs(Math.random() * 100000000);
    }
    return this.id;
  }

  /**
   * <p>Setter for the field <code>observer</code>.</p>
   *
   * @param observer a {@link org.eclipse.steady.goals.ExecutionObserver} object.
   */
  public void setObserver(ExecutionObserver observer) {
    this.observer = observer;
  }
  /**
   * <p>Getter for the field <code>goalType</code>.</p>
   *
   * @return a {@link org.eclipse.steady.shared.enums.GoalType} object.
   */
  public GoalType getGoalType() {
    return this.goalType;
  }
  /**
   * <p>getGoalClient.</p>
   *
   * @return a {@link org.eclipse.steady.shared.enums.GoalClient} object.
   */
  public GoalClient getGoalClient() {
    return this.client;
  }
  /**
   * <p>setGoalClient.</p>
   *
   * @param _client a {@link org.eclipse.steady.shared.enums.GoalClient} object.
   */
  public void setGoalClient(GoalClient _client) {
    this.client = _client;
  }

  /**
   * Returns true if this {@link AbstractGoal} is executed in the given {@link GoalClient}, false otherwise.
   *
   * @param _client a {@link org.eclipse.steady.shared.enums.GoalClient} object.
   * @return a boolean.
   */
  public boolean runsIn(GoalClient _client) {
    return this.client != null && _client != null && _client.equals(this.client);
  }

  // TODO (17/05/2017): Add callback parameter?
  /**
   * <p>executeAsync.</p>
   */
  public final void executeAsync() {
    final Thread t = new Thread(this, "vulas-" + this.goalType.toString().toLowerCase());
    t.start();
  }

  /**
   * <p>run.</p>
   */
  public final void run() {
    try {
      this.execute();
    } catch (GoalConfigurationException e) {
      log.error("Error while configuring " + this + ": " + e.getMessage(), e);
    } catch (GoalExecutionException e) {
      log.error("Error while executing " + this + ": " + e.getMessage(), e);
    }
  }

  /**
   * <p>executeSync.</p>
   *
   * @throws org.eclipse.steady.goals.GoalConfigurationException if any.
   * @throws org.eclipse.steady.goals.GoalExecutionException if any.
   */
  public final void executeSync() throws GoalConfigurationException, GoalExecutionException {
    this.execute();
  }

  private final void execute() throws GoalConfigurationException, GoalExecutionException {
    // Execute the goal and measure execution time
    try {
      this.start();
      this.executeTasks();
      this.stop();
    }
    // Throw as is
    catch (GoalConfigurationException gce) {
      this.stop(gce);
      this.skipGoalUpload(); // Do not upload in case of configuration problems
      throw gce;
    }
    // Throw as is
    catch (GoalExecutionException gee) {
      this.stop(gee);
      throw gee;
    }
    // Embedd in GoalExecutionException
    catch (Exception e) {
      this.stop(e);
      throw new GoalExecutionException(e);
    } finally {
      if (this.goalUploadEnabled) this.upload(false);
    }
  }

  /**
   * Returns the configuration of this goal execution. If the configuration has not been set before, a new instance of
   * {@link VulasConfiguration} is created and returned. As such, the configuration settings of different goal executions
   * can be isolated.
   *
   * @return a {@link org.eclipse.steady.shared.util.VulasConfiguration} object.
   */
  protected final synchronized VulasConfiguration getConfiguration() {
    if (this.configuration == null) this.configuration = new VulasConfiguration();
    return this.configuration;
  }

  /**
   * <p>Setter for the field <code>configuration</code>.</p>
   *
   * @param _c a {@link org.eclipse.steady.shared.util.VulasConfiguration} object.
   * @return a {@link org.eclipse.steady.goals.AbstractGoal} object.
   */
  public final synchronized AbstractGoal setConfiguration(VulasConfiguration _c) {
    this.configuration = _c;
    return this;
  }

  /**
   * Returns the context of this goal execution. If the context has not been set before, it is constructed
   * by reading tenant, space and app information from the configuration obtained from {@link AbstractGoal#getConfiguration()}.
   *
   * @return a {@link org.eclipse.steady.goals.GoalContext} object.
   */
  public final synchronized GoalContext getGoalContext() {
    if (this.goalContext == null) {
      final Configuration c = this.getConfiguration().getConfiguration();

      this.goalContext = new GoalContext();

      // Configuration
      this.goalContext.setVulasConfiguration(this.getConfiguration());

      // Tenant
      if (!this.getConfiguration().isEmpty(CoreConfiguration.TENANT_TOKEN))
        this.goalContext.setTenant(new Tenant(c.getString(CoreConfiguration.TENANT_TOKEN)));

      // Space
      if (!this.getConfiguration().isEmpty(CoreConfiguration.SPACE_TOKEN)) {
        final Space space = new Space();
        space.setSpaceToken(c.getString(CoreConfiguration.SPACE_TOKEN));
        this.goalContext.setSpace(space);
      }

      // App
      if (Application.canBuildApplication(
          c.getString(CoreConfiguration.APP_CTX_GROUP),
          c.getString(CoreConfiguration.APP_CTX_ARTIF),
          c.getString(CoreConfiguration.APP_CTX_VERSI))) {
        final Application a =
            new Application(
                c.getString(CoreConfiguration.APP_CTX_GROUP),
                c.getString(CoreConfiguration.APP_CTX_ARTIF),
                c.getString(CoreConfiguration.APP_CTX_VERSI));
        if (a.isComplete()) this.goalContext.setApplication(a);
        else log.warn("Incomplete application context: " + a.toString());
      }
    }
    return this.goalContext;
  }

  /**
   * <p>Setter for the field <code>goalContext</code>.</p>
   *
   * @param _ctx a {@link org.eclipse.steady.goals.GoalContext} object.
   */
  public final synchronized void setGoalContext(GoalContext _ctx) {
    this.goalContext = _ctx;
  }

  // >>>>> Methods that can/must be implemented by specific goals

  /**
   * Cleans the cache of the {@link BackendConnector}.
   * CAN be overridden in subclasses to perform additional, goal-specific checks and preparations.
   *
   * @throws org.eclipse.steady.goals.GoalConfigurationException if any.
   */
  protected void prepareExecution() throws GoalConfigurationException {
    BackendConnector.getInstance().cleanCache();
    try {
      this.getConfiguration().checkSettings();
    } catch (ConfigurationException e) {
      throw new GoalConfigurationException(e);
    }
  }

  /**
   * CAN be overridden in subclasses.
   *
   * @throws org.eclipse.steady.goals.GoalConfigurationException if any.
   */
  protected void checkPreconditions() throws GoalConfigurationException {}

  /**
   * MUST be overridden in subclasses to implement the goal-specific tasks.
   *
   * @throws java.lang.Exception if any.
   */
  protected abstract void executeTasks() throws Exception;

  /**
   * Empty implementation.
   *
   * CAN be overridden in subclasses in order to perform goal-specific clean-up.
   */
  protected void cleanAfterExecution() {}

  /**
   * Empty implementation.
   *
   * @return a {@link java.lang.Object} object.
   */
  protected Object getResultObject() {
    return null;
  }

  // <<<<< Methods that can/must be implemented by specific goals

  /**
   * Starts the goal execution.
   *
   * @throws org.eclipse.steady.goals.GoalConfigurationException if any.
   */
  public void start() throws GoalConfigurationException {
    // Start time taking
    this.stopWatch = new StopWatch(this.toString()).start();

    // Monitor mem consumption?
    if (this.memoThread != null) {
      final Thread t = new Thread(this.memoThread, "memo");
      t.setPriority(Thread.MIN_PRIORITY);
      t.start();
    }

    // Prepare the execution
    this.prepareExecution();

    // Check whether all conditions to execute the tasks are met
    this.checkPreconditions();

    this.stopWatch.lap("Completed goal preparation", false);
  }

  /**
   * Stops the goal execution, i.e., takes the time and stops the monitoring thread (if any).
   */
  public void stop() {
    // Already stopped?
    if (!this.stopWatch.isRunning())
      throw new IllegalStateException("Goal execution already finished");

    // Stop!
    else {
      this.stopMemo();
      this.stopWatch.lap("Completed execution", true);

      this.cleanAfterExecution();
      this.stopWatch.lap("Completed clean-up", false);

      this.stopWatch.stop();

      this.notifyObserver();
    }
  }

  /**
   * Stops the goal execution in response to the provided exception.
   *
   * @param _e a {@link java.lang.Exception} object.
   * @see #stop()
   */
  public void stop(Exception _e) {
    // Already stopped?
    if (!this.stopWatch.isRunning())
      throw new IllegalStateException("Goal execution already finished");

    // Stop!
    else {
      this.stopMemo();

      this.exception = _e;
      this.stopWatch.stop(_e);

      this.notifyObserver();
    }
  }

  private void notifyObserver() {
    if (this.observer != null) this.observer.callback(this);
  }

  private final void stopMemo() {
    if (this.memoThread != null) this.memoThread.stop();
  }

  /**
   * <p>addGoalStats.</p>
   *
   * @param _prefix a {@link java.lang.String} object.
   * @param _stats a {@link java.util.Map} object.
   */
  public void addGoalStats(String _prefix, Map<String, Long> _stats) {
    for (Map.Entry<String, Long> entry : _stats.entrySet()) {
      this.addGoalStats(
          (_prefix == null || _prefix.equals("") ? entry.getKey() : _prefix + "." + entry.getKey()),
          entry.getValue());
    }
  }

  /**
   * <p>addGoalStats.</p>
   *
   * @param _key a {@link java.lang.String} object.
   * @param _val a long.
   */
  public void addGoalStats(String _key, long _val) {
    this.addGoalStats(_key, (double) _val);
  }

  /**
   * <p>addGoalStats.</p>
   *
   * @param _key a {@link java.lang.String} object.
   * @param _val a int.
   */
  public void addGoalStats(String _key, int _val) {
    this.addGoalStats(_key, (double) _val);
  }

  /**
   * <p>addGoalStats.</p>
   *
   * @param _key a {@link java.lang.String} object.
   * @param _val a double.
   */
  public void addGoalStats(String _key, double _val) {
    this.goalStats.put(_key, new Double(_val));
  }

  /**
   * <p>toString.</p>
   *
   * @return a {@link java.lang.String} object.
   */
  public String toString() {
    final StringBuffer b = new StringBuffer();
    b.append("Goal [id=").append(this.getId()).append(", type=").append(this.getGoalType());
    if (this.goalContext != null) b.append(", ctx=").append(this.goalContext.toString());
    b.append("]");
    return b.toString();
  }

  /**
   * Creates a JSON string representing this goal execution.
   *
   * @throws java.lang.IllegalStateException
   * @return a {@link java.lang.String} object.
   */
  public String toJson() throws IllegalStateException {
    final StringBuilder b = new StringBuilder();
    b.append("{\"executionId\":\"").append(this.getId()).append("\"");
    b.append(",\"goal\":\"").append(this.goalType).append("\"");
    b.append(",\"startedAtClient\":\"")
        .append(StringUtil.formatDate(this.stopWatch.getStartMillis()))
        .append("\"");
    b.append(",\"clientVersion\":").append(JsonBuilder.escape(CoreConfiguration.getVulasRelease()));

    // Exception (if any) and stacktrace
    if (exception != null && exception.getMessage() != null)
      b.append(",\"exception\":")
          .append(
              JsonBuilder.escape(
                  this.exception
                      .getMessage()
                      .substring(0, Math.min(this.exception.getMessage().length(), 255))));

    // Runtime in nano secs
    if (this.stopWatch.isRunning()) b.append(",\"runtimeNano\":-1");
    else b.append(",\"runtimeNano\":").append(this.stopWatch.getRuntime());

    // Memory info (can be -1 if not monitored)
    if (this.memoThread != null) {
      b.append(",\"memMax\":").append(this.memoThread.getJvmMax());
      b.append(",\"memUsedMax\":").append(this.memoThread.getMaxUsed());
      b.append(",\"memUsedAvg\":").append(this.memoThread.getAvgUsed());
    }

    // Goal configuration
    b.append(",\"configuration\":[");
    int c = 0;
    final Iterator<String> iter = this.getConfiguration().getConfiguration().getKeys("vulas");
    while (iter.hasNext()) {
      final String key = iter.next();
      final String[] value = this.getConfiguration().getConfiguration().getStringArray(key);
      if (c++ > 0) b.append(",");
      b.append("{\"source\":\"GOAL_CONFIG\",\"name\":")
          .append(JsonBuilder.escape(key))
          .append(",\"value\":")
          .append(JsonBuilder.escape(StringUtil.join(value, ",")))
          .append("}");
    }
    b.append("]");

    // Goal statistics
    b.append(",\"statistics\":{");
    c = 0;
    for (Map.Entry<String, Double> entry : this.goalStats.entrySet()) {
      if (c++ > 0) b.append(",");
      b.append(JsonBuilder.escape(entry.getKey())).append(":").append(entry.getValue());
    }
    b.append("}");

    // System info
    final StringList env_whitelist =
        this.getConfiguration()
            .getStringList(VulasConfiguration.ENV_VARS, VulasConfiguration.ENV_VARS_CUSTOM);
    final StringList sys_whitelist =
        this.getConfiguration()
            .getStringList(VulasConfiguration.SYS_PROPS, VulasConfiguration.SYS_PROPS_CUSTOM);

    // A subset of environment variables
    this.systemInfo.putAll(
        env_whitelist.filter(
            System.getenv(), true, ComparisonMode.EQUALS, CaseSensitivity.CASE_INSENSITIVE));

    // A subset of system properties
    for (Object key : System.getProperties().keySet()) {
      final String key_string = (String) key;
      if (sys_whitelist.contains(
          key_string, ComparisonMode.STARTSWITH, CaseSensitivity.CASE_INSENSITIVE))
        this.systemInfo.put(key_string, System.getProperty(key_string));
    }

    b.append(",\"systemInfo\":[");
    c = 0;
    for (Map.Entry<String, String> entry : this.systemInfo.entrySet()) {
      if (c++ > 0) b.append(",");
      b.append("{\"source\":\"SYSTEM_INFO\",\"name\":")
          .append(JsonBuilder.escape(entry.getKey()))
          .append(",\"value\":")
          .append(JsonBuilder.escape(entry.getValue()))
          .append("}");
    }
    b.append("]}");
    return b.toString();
  }

  /**
   * <p>skipGoalUpload.</p>
   */
  protected final void skipGoalUpload() {
    this.goalUploadEnabled = false;
  }

  /**
   * Uploads the JSON presentation of this goal execution to the backend.
   * Returns true if everything went fine (upload succeeded or is not necessary), false otherwise.
   *
   * @param _before a boolean.
   * @return a boolean.
   */
  public boolean upload(boolean _before) {
    boolean ret = false;
    try {
      AbstractGoal.log.info("Uploading goal execution info ...");
      ret =
          BackendConnector.getInstance().uploadGoalExecution(this.getGoalContext(), this, _before);
      AbstractGoal.log.info("Uploaded goal execution info");
    } catch (Exception e) {
      AbstractGoal.log.error("Error while uploading goal execution info: " + e.getMessage());
    }
    return ret;
  }
}
