package com.airlock.iam.servicecontainer.app.application.configuration;

import com.airlock.iam.plugin.api.annotation.config.ConfigurationValidator;
import com.airlock.iam.plugin.api.annotation.config.Plugin;
import com.airlock.iam.plugin.api.annotation.lifecycle.AfterConfigActivation;
import com.airlock.iam.plugin.api.annotation.lifecycle.BeforePluginDeactivation;
import com.airlock.iam.plugin.api.annotation.property.PluginProperty;
import com.airlock.iam.plugin.api.annotation.property.StringProperty;
import com.airlock.iam.plugin.api.config.ConfigurationContext;
import com.airlock.iam.plugin.api.config.ConfigurationEnvironment;
import com.airlock.iam.plugin.api.config.Environment;
import com.airlock.iam.plugin.api.config.PluginConfiguration;
import com.airlock.iam.plugin.api.exception.ConfigurationException;
import com.airlock.iam.plugin.api.exception.InitializeException;
import com.airlock.iam.plugin.api.exception.PropertyConstraintViolationException;
import com.airlock.iam.plugin.api.valuereader.StringPropertyValueReader;
import com.airlock.iam.plugin.framework.misc.config.properties.ConfigHashCodeBuilder;
import com.airlock.iam.servicecontainer.api.application.configuration.task.TaskConfig;
import com.airlock.iam.servicecontainer.api.domain.model.task.Task;
import com.google.common.annotations.VisibleForTesting;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Calendar;
import java.util.Locale;
import java.util.Optional;
import org.apache.commons.lang3.StringUtils;

@Plugin(description = "Defines scheduling information about a task.")
public final class TaskSchedule {
   private static final DateTimeFormatter TASK_SCHEDULER_DATE_FORMAT = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm:ss");
   static final long INTERVAL_NEVER = -1L;
   static final long EXECUTE_NEVER = -1L;
   static final int START_TIME_NONE = -1;
   static final int START_TIME_AFTER_INTERVAL = -2;
   static final long HOURS_PER_DAY = 24L;
   static final long MINUTES_PER_HOUR = 60L;
   static final long SECONDS_PER_MINUTE = 60L;
   static final long MILLIS_PER_SECOND = 1000L;
   static final long MILLIS_PER_DAY = 86400000L;
   @PluginProperty(description = "The task to execute.", optional = false)
   private TaskConfig task;
   @StringProperty(
      description = "The time between two executions of the task. The interval can be specified in one of the following formats (where n is a positive integer: n days, n hours, n minutes, n seconds, never)",
      optional = false,
      suggestedValues = {
            "never",
            "10 seconds",
            "20 seconds",
            "30 seconds",
            "1 minute",
            "2 minutes",
            "5 minutes",
            "10 minutes",
            "15 minutes",
            "20 minutes",
            "30 minutes",
            "45 minutes",
            "1 hour",
            "2 hours",
            "3 hours",
            "4 hours",
            "5 hours",
            "6 hours",
            "8 hours",
            "10 hours",
            "12 hours",
            "1 day",
            "2 days",
            "3 days",
            "4 days",
            "5 days",
            "6 days",
            "7 days",
            "10 days",
            "14 days",
            "20 days",
            "30 days",
            "60 days",
            "90 days"
      }
   )
   private String interval;
   private static final String INTERVAL_PROPERTY = "interval";
   @StringProperty(
      description = "The time of the first execution of the task in the form hh:mm:ss (e.g. 14:48:56). Leave this value empty to execute the task the first time when the scheduler service is started. Set it to <i>after-interval</i> to execute the task for the first time after waiting as specified in <i>interval</i> after the service has been started.",
      optional = true,
      exampleValues = {"11:48:56", "after-interval"}
   )
   private String firstTime;
   private static final String FIRST_TIME_PROPERTY = "firstTime";
   @StringProperty(
      description = "A task name that is used in log statements and in the service console. It helps identifying the task. Do not use very long names.",
      optional = false,
      exampleValues = {"Task 1", "Example Task"}
   )
   private String name;
   public static final String NAME_PROPERTY = "name";
   private Integer configHash;
   private long intervalMillis;
   private long firstTimePropertyMillis;
   private long realFirstExecutionTimeMillis = -1L;
   private final Object executionDatesLock = new Object();
   private long latestExecutionStartDate = -1L;
   private Optional<Task> taskLogic = Optional.empty();

   private TaskSchedule(TaskConfig task, String interval, String firstTime, String name) {
      this.task = task;
      this.interval = interval;
      this.firstTime = firstTime;
      this.name = name;
   }

   @ConfigurationValidator
   static void validateConfiguration(PluginConfiguration config, ConfigurationEnvironment env, ConfigurationContext context, Environment validationEnvironment) throws ConfigurationException {
      String intervalString = StringPropertyValueReader.effectiveStringConfigValue(config, "interval", env, context);

      try {
         intervalStringToIntervalMillis(intervalString);
      } catch (NumberFormatException e) {
         throw new PropertyConstraintViolationException("interval", config, env, context, "The value \"" + intervalString + "\" is not a valid interval.", e);
      }

      String firstTimeString = StringPropertyValueReader.effectiveStringConfigValue(config, "firstTime", env, context);
      if (StringUtils.isNotBlank(firstTimeString)) {
         try {
            firstStartStringToMillisAfterMidnight(firstTimeString);
         } catch (NumberFormatException e) {
            throw new PropertyConstraintViolationException(
               "firstTime", config, env, context, "The value \"" + firstTimeString + "\" is not a valid interval.", e
            );
         }
      }
   }

   private void resetFirstExecutionDate() {
      synchronized (this.executionDatesLock) {
         this.latestExecutionStartDate = -1L;
         this.realFirstExecutionTimeMillis = -1L;
      }
   }

   @AfterConfigActivation
   void afterConfigActivation() throws InitializeException {
      int newConfigHash = ConfigHashCodeBuilder.reflectionHashCodeUncached(this);
      if (this.configHash == null || this.configHash != newConfigHash) {
         try {
            this.intervalMillis = intervalStringToIntervalMillis(this.interval);
         } catch (NumberFormatException e) {
            throw new InitializeException("The value \"" + this.interval + "\" is not a valid interval.", e);
         }

         if (StringUtils.isBlank(this.firstTime)) {
            this.firstTimePropertyMillis = -1L;
         } else {
            try {
               this.firstTimePropertyMillis = firstStartStringToMillisAfterMidnight(this.firstTime);
            } catch (NumberFormatException e) {
               throw new InitializeException("The value \"" + this.firstTime + "\" is not time.", e);
            }
         }

         if (this.taskLogic.isPresent() && this.taskLogic.get().isRunning()) {
            this.taskLogic.get().pleaseStop();
         }

         this.configHash = newConfigHash;
         this.taskLogic = Optional.empty();
         this.resetFirstExecutionDate();
      }
   }

   @BeforePluginDeactivation
   void deactivate() {
      if (this.taskLogic.isPresent() && this.taskLogic.get().isRunning()) {
         this.taskLogic.get().pleaseStop();
      }
   }

   public String getFirstTimeString() {
      if (this.realFirstExecutionTimeMillis == -1L) {
         return "-";
      }

      Instant firstStartTime = Instant.ofEpochMilli(this.realFirstExecutionTimeMillis);
      return ZonedDateTime.ofInstant(firstStartTime, ZoneId.systemDefault()).format(TASK_SCHEDULER_DATE_FORMAT);
   }

   public TimeUnit getInterval() {
      if (this.intervalMillis == -1L) {
         return new TimeUnit(-1L, "");
      } else {
         long tmp = this.intervalMillis / 1000L;
         if (tmp % 86400L == 0L) {
            long days = tmp / 86400L;
            return days == 1L ? new TimeUnit(days, "day") : new TimeUnit(days, "days");
         } else if (tmp % 3600L == 0L) {
            long hours = tmp / 3600L;
            return hours == 1L ? new TimeUnit(hours, "hour") : new TimeUnit(hours, "hours");
         } else if (tmp % 60L == 0L) {
            long minutes = tmp / 60L;
            return minutes == 1L ? new TimeUnit(minutes, "minute") : new TimeUnit(minutes, "minutes");
         } else {
            return tmp == 1L ? new TimeUnit(tmp, "second") : new TimeUnit(tmp, "seconds");
         }
      }
   }

   public long computeNextExecutionDate(long checkIntervalStart) {
      synchronized (this.executionDatesLock) {
         if (this.intervalMillis == -1L) {
            return -1L;
         }

         if (this.realFirstExecutionTimeMillis == -1L) {
            switch ((int)this.firstTimePropertyMillis) {
               case -2:
                  this.realFirstExecutionTimeMillis = checkIntervalStart + this.intervalMillis;
                  break;
               case -1:
                  this.realFirstExecutionTimeMillis = checkIntervalStart;
                  break;
               default:
                  long nextStartTime = getMidnightMillis(checkIntervalStart) + this.firstTimePropertyMillis;
                  this.realFirstExecutionTimeMillis = nextStartTime >= checkIntervalStart ? nextStartTime : nextStartTime + 86400000L;
            }

            return this.realFirstExecutionTimeMillis;
         } else if (this.realFirstExecutionTimeMillis < checkIntervalStart) {
            long timeSinceFirstExecution = checkIntervalStart - this.realFirstExecutionTimeMillis;
            int intervalSteps = (int)Math.ceil((double)timeSinceFirstExecution / this.intervalMillis);
            return this.realFirstExecutionTimeMillis + intervalSteps * this.intervalMillis;
         } else {
            return this.realFirstExecutionTimeMillis;
         }
      }
   }

   public void setStarted() {
      synchronized (this.executionDatesLock) {
         this.latestExecutionStartDate = System.currentTimeMillis();
         if (this.realFirstExecutionTimeMillis == -1L) {
            this.realFirstExecutionTimeMillis = this.latestExecutionStartDate;
         }
      }
   }

   public long getLatestExecutionStartDate() {
      synchronized (this.executionDatesLock) {
         return this.latestExecutionStartDate;
      }
   }

   public boolean isRunning() {
      return this.getTask().isRunning();
   }

   private static long getMidnightMillis(long dateMillis) {
      Calendar cal = Calendar.getInstance();
      cal.setTimeInMillis(dateMillis);
      cal.set(11, 0);
      cal.set(12, 0);
      cal.set(13, 0);
      cal.set(14, 0);
      return cal.getTimeInMillis();
   }

   private static long intervalStringToIntervalMillis(String taskIntervalString) throws NumberFormatException {
      if (taskIntervalString.trim().equalsIgnoreCase("never")) {
         return -1L;
      }

      if (taskIntervalString.trim().endsWith("days") || taskIntervalString.trim().endsWith("day")) {
         int dayPos = taskIntervalString.indexOf("day");
         String numStr = taskIntervalString.substring(0, dayPos).trim();
         long num = Long.parseLong(numStr);
         if (num <= 0L) {
            throw new NumberFormatException("negative interval");
         } else {
            return num * 86400000L;
         }
      } else if (taskIntervalString.trim().endsWith("hours") || taskIntervalString.trim().endsWith("hour")) {
         int hourPos = taskIntervalString.indexOf("hour");
         String numStr = taskIntervalString.substring(0, hourPos).trim();
         long num = Long.parseLong(numStr);
         if (num <= 0L) {
            throw new NumberFormatException("negative interval");
         } else {
            return num * 3600000L;
         }
      } else if (taskIntervalString.trim().endsWith("minutes")
         || taskIntervalString.trim().endsWith("minute")
         || taskIntervalString.trim().endsWith("min")
         || taskIntervalString.trim().endsWith("mins")) {
         int minPos = taskIntervalString.indexOf("min");
         String numStr = taskIntervalString.substring(0, minPos).trim();
         long num = Long.parseLong(numStr);
         if (num <= 0L) {
            throw new NumberFormatException("negative interval");
         } else {
            return num * 60000L;
         }
      } else if (!taskIntervalString.trim().endsWith("seconds")
         && !taskIntervalString.trim().endsWith("second")
         && !taskIntervalString.trim().endsWith("sec")
         && !taskIntervalString.trim().endsWith("secs")) {
         throw new NumberFormatException("unknown interval format");
      } else {
         int secPos = taskIntervalString.indexOf("sec");
         String numStr = taskIntervalString.substring(0, secPos).trim();
         long num = Long.parseLong(numStr);
         if (num <= 0L) {
            throw new NumberFormatException("negative interval");
         } else {
            return num * 1000L;
         }
      }
   }

   private static long firstStartStringToMillisAfterMidnight(String firstStartString) throws NumberFormatException {
      if (StringUtils.isBlank(firstStartString)) {
         return -1L;
      }

      String input = firstStartString.trim().toLowerCase(Locale.ENGLISH);
      if (!"after-interval".equals(input) && !"afterinterval".equals(input) && !"after_interval".equals(input)) {
         if (input.length() != 8) {
            throw new NumberFormatException("Invalid format. Expecting hh:mm:ss");
         }

         long hours = Long.parseLong(input.substring(0, 2));
         if (hours >= 0L && hours < 24L) {
            long mins = Long.parseLong(input.substring(3, 5));
            if (mins >= 0L && mins < 60L) {
               long secs = Long.parseLong(input.substring(6, 8));
               if (secs >= 0L && secs < 60L) {
                  return hours * 60L * 60L * 1000L + mins * 60L * 1000L + secs * 1000L;
               } else {
                  throw new NumberFormatException("Invalid seconds: 0..59 allowed.");
               }
            } else {
               throw new NumberFormatException("Invalid minutes: 0..59 allowed.");
            }
         } else {
            throw new NumberFormatException("Invalid hours: 0..23 allowed.");
         }
      } else {
         return -2L;
      }
   }

   public String getTaskName() {
      return StringUtils.isBlank(this.name) ? "-" : this.name;
   }

   @VisibleForTesting
   void setIntervalMillis(long newIntervalMillis) {
      this.intervalMillis = newIntervalMillis;
   }

   @VisibleForTesting
   void setFirstTimePropertyMillis(long firstTimePropertyMillis) {
      this.firstTimePropertyMillis = firstTimePropertyMillis;
   }

   @VisibleForTesting
   void setRealFirstExecutionTimeMillis(long realFirstExecutionTimeMillis) {
      this.realFirstExecutionTimeMillis = realFirstExecutionTimeMillis;
   }

   public TaskConfig getTaskConfig() {
      return this.task;
   }

   public Task getTask() {
      if (this.taskLogic.isEmpty()) {
         this.taskLogic = Optional.of(this.task.getLogic());
      }

      return this.taskLogic.get();
   }

   @SuppressFBWarnings(justification = "generated code")
   public static TaskSchedule.TaskScheduleBuilder builder() {
      return new TaskSchedule.TaskScheduleBuilder();
   }

   @SuppressFBWarnings(justification = "generated code")
   public TaskSchedule() {
   }

   @SuppressFBWarnings(justification = "generated code")
   public static class TaskScheduleBuilder {
      @SuppressFBWarnings(justification = "generated code")
      private TaskConfig task;
      @SuppressFBWarnings(justification = "generated code")
      private String interval;
      @SuppressFBWarnings(justification = "generated code")
      private String firstTime;
      @SuppressFBWarnings(justification = "generated code")
      private String name;

      @SuppressFBWarnings(justification = "generated code")
      TaskScheduleBuilder() {
      }

      @SuppressFBWarnings(justification = "generated code")
      public TaskSchedule.TaskScheduleBuilder task(TaskConfig task) {
         this.task = task;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public TaskSchedule.TaskScheduleBuilder interval(String interval) {
         this.interval = interval;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public TaskSchedule.TaskScheduleBuilder firstTime(String firstTime) {
         this.firstTime = firstTime;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public TaskSchedule.TaskScheduleBuilder name(String name) {
         this.name = name;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public TaskSchedule build() {
         return new TaskSchedule(this.task, this.interval, this.firstTime, this.name);
      }

      @SuppressFBWarnings(justification = "generated code")
      @Override
      public String toString() {
         return "TaskSchedule.TaskScheduleBuilder(task="
            + this.task
            + ", interval="
            + this.interval
            + ", firstTime="
            + this.firstTime
            + ", name="
            + this.name
            + ")";
      }
   }
}
