/*
 * Apache 2.0 License
 *
 * Copyright (c) Sebastian Katzer 2017
 *
 * This file contains Original Code and/or Modifications of Original Code
 * as defined in and that are subject to the Apache License
 * Version 2.0 (the 'License'). You may not use this file except in
 * compliance with the License. Please obtain a copy of the License at
 * http://opensource.org/licenses/Apache-2.0/ and read it before using this
 * file.
 *
 * The Original Code and all software distributed under the License are
 * distributed on an 'AS IS' basis, WITHOUT WARRANTY OF ANY KIND, EITHER
 * EXPRESS OR IMPLIED, AND APPLE HEREBY DISCLAIMS ALL SUCH WARRANTIES,
 * INCLUDING WITHOUT LIMITATION, ANY WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE, QUIET ENJOYMENT OR NON-INFRINGEMENT.
 * Please see the License for the specific language governing rights and
 * limitations under the License.
 */

package de.appplant.cordova.plugin.notification.trigger;

import java.util.Calendar;
import java.util.Date;
import java.util.List;

import static de.appplant.cordova.plugin.notification.trigger.DateTrigger.Unit.DAY;
import static de.appplant.cordova.plugin.notification.trigger.DateTrigger.Unit.HOUR;
import static de.appplant.cordova.plugin.notification.trigger.DateTrigger.Unit.MINUTE;
import static de.appplant.cordova.plugin.notification.trigger.DateTrigger.Unit.MONTH;
import static de.appplant.cordova.plugin.notification.trigger.DateTrigger.Unit.WEEK;
import static de.appplant.cordova.plugin.notification.trigger.DateTrigger.Unit.YEAR;
import static java.util.Calendar.DAY_OF_WEEK;
import static java.util.Calendar.WEEK_OF_MONTH;
import static java.util.Calendar.WEEK_OF_YEAR;

/**
 * Trigger for date matching components.
 */
public class MatchTrigger extends IntervalTrigger {

  // Used to determine the interval
  private static Unit[] INTERVALS = { null, MINUTE, HOUR, DAY, MONTH, YEAR };

  // Maps these crap where Sunday is the 1st day of the week
  private static int[] WEEKDAYS = { 0, 2, 3, 4, 5, 6, 7, 1 };

  // Maps these crap where Sunday is the 1st day of the week
  private static int[] WEEKDAYS_REV = { 0, 7, 1, 2, 3, 4, 5, 6 };

  // The date matching components
  private final List<Integer> matchers;

  // The special matching components
  private final List<Integer> specials;

  private static Unit getUnit(List<Integer> matchers, List<Integer> specials) {
    Unit unit1 = INTERVALS[1 + matchers.indexOf(null)], unit2 = null;

    if (specials.get(0) != null) {
      unit2 = WEEK;
    }

    if (unit2 == null)
      return unit1;

    return (unit1.compareTo(unit2) < 0) ? unit2 : unit1;
  }

  /**
   * Date matching trigger from now.
   *
   * @param matchers Describes the date matching parts.
   *                 { day: 15, month: ... }
   * @param specials Describes the date matching parts.
   *                 { weekday: 1, weekOfMonth: ... }
   */
  public MatchTrigger(List<Integer> matchers, List<Integer> specials) {
    super(1, getUnit(matchers, specials));

    if (specials.get(0) != null) {
      specials.set(0, WEEKDAYS[specials.get(0)]);
    }

    this.matchers = matchers;
    this.specials = specials;
  }

  /**
   * Gets the date from where to start calculating the initial trigger date.
   */
  private Calendar getBaseTriggerDate(Date date) {
    Calendar cal = getCal(date);

    cal.set(Calendar.SECOND, 0);

    if (matchers.get(0) != null) {
      cal.set(Calendar.MINUTE, matchers.get(0));
    } else {
      cal.set(Calendar.MINUTE, 0);
    }

    if (matchers.get(1) != null) {
      cal.set(Calendar.HOUR_OF_DAY, matchers.get(1));
    } else {
      cal.set(Calendar.HOUR_OF_DAY, 0);
    }

    if (matchers.get(2) != null) {
      cal.set(Calendar.DAY_OF_MONTH, matchers.get(2));
    }

    if (matchers.get(3) != null) {
      cal.set(Calendar.MONTH, matchers.get(3) - 1);
    }

    if (matchers.get(4) != null) {
      cal.set(Calendar.YEAR, matchers.get(4));
    }

    return cal;
  }

  /**
   * Gets the date when to trigger the notification.
   *
   * @param base The date from where to calculate the trigger date.
   *
   * @return null if there's none trigger date.
   */
  private Date getTriggerDate(Date base) {
    Calendar cal = getBaseTriggerDate(base);
    Calendar now = getCal(base);

    if (cal.compareTo(now) >= 0)
      return applySpecials(cal);

    if (unit == null || cal.get(Calendar.YEAR) < now.get(Calendar.YEAR))
      return null;

    if (cal.get(Calendar.MONTH) < now.get(Calendar.MONTH)) {
      switch (unit) {
        case MINUTE:
        case HOUR:
        case DAY:
        case WEEK:
          if (matchers.get(4) == null) {
            addToDate(cal, now, Calendar.YEAR, 1);
            break;
          } else
            return null;
        case YEAR:
          addToDate(cal, now, Calendar.YEAR, 1);
          break;
      }
    } else if (cal.get(Calendar.DAY_OF_YEAR) < now.get(Calendar.DAY_OF_YEAR)) {
      switch (unit) {
        case MINUTE:
        case HOUR:
          if (matchers.get(3) == null) {
            addToDate(cal, now, Calendar.MONTH, 1);
            break;
          } else if (matchers.get(4) == null) {
            addToDate(cal, now, Calendar.YEAR, 1);
            break;
          } else
            return null;
        case MONTH:
          addToDate(cal, now, Calendar.MONTH, 1);
          break;
        case YEAR:
          addToDate(cal, now, Calendar.YEAR, 1);
          break;
      }
    } else if (cal.get(Calendar.HOUR_OF_DAY) < now.get(Calendar.HOUR_OF_DAY)) {
      switch (unit) {
        case MINUTE:
          if (matchers.get(2) == null) {
            addToDate(cal, now, Calendar.DAY_OF_YEAR, 1);
            break;
          } else if (matchers.get(3) == null) {
            addToDate(cal, now, Calendar.MONTH, 1);
            break;
          } else
            return null;
        case HOUR:
          if (cal.get(Calendar.MINUTE) < now.get(Calendar.MINUTE)) {
            addToDate(cal, now, Calendar.HOUR_OF_DAY, 1);
          } else {
            addToDate(cal, now, Calendar.HOUR_OF_DAY, 0);
          }
          break;
        case DAY:
        case WEEK:
          addToDate(cal, now, Calendar.DAY_OF_YEAR, 1);
          break;
        case MONTH:
          addToDate(cal, now, Calendar.MONTH, 1);
          break;
        case YEAR:
          addToDate(cal, now, Calendar.YEAR, 1);
          break;
      }
    } else if (cal.get(Calendar.MINUTE) < now.get(Calendar.MINUTE)) {
      switch (unit) {
        case MINUTE:
          addToDate(cal, now, Calendar.MINUTE, 1);
          break;
        case HOUR:
          addToDate(cal, now, Calendar.HOUR_OF_DAY, 1);
          break;
        case DAY:
        case WEEK:
          addToDate(cal, now, Calendar.DAY_OF_YEAR, 1);
          break;
        case MONTH:
          addToDate(cal, now, Calendar.MONTH, 1);
          break;
        case YEAR:
          addToDate(cal, now, Calendar.YEAR, 1);
          break;
      }
    }

    return applySpecials(cal);
  }

  private Date applySpecials(Calendar cal) {
    if (specials.get(2) != null && !setWeekOfMonth(cal))
      return null;

    if (specials.get(0) != null && !setDayOfWeek(cal))
      return null;

    return cal.getTime();
  }

  /**
   * Gets the next trigger date.
   *
   * @param base The date from where to calculate the trigger date.
   *
   * @return null if there's none next trigger date.
   */
  @Override
  public Date getNextTriggerDate(Date base) {
    Date date = base;

    if (getOccurrence() > 1) {
      Calendar cal = getCal(base);
      addInterval(cal);
      date = cal.getTime();
    }

    incOccurrence();

    return getTriggerDate(date);
  }

  /**
   * Sets the field value of now to date and adds by count.
   */
  private void addToDate(Calendar cal, Calendar now, int field, int count) {
    cal.set(field, now.get(field));
    cal.add(field, count);
  }

  /**
   * Set the day of the year but ensure that the calendar does point to a
   * date in future.
   *
   * @param cal The calendar to manipulate.
   *
   * @return true if the operation could be made.
   */
  private boolean setDayOfWeek(Calendar cal) {
    cal.setFirstDayOfWeek(Calendar.MONDAY);
    int day = WEEKDAYS_REV[cal.get(DAY_OF_WEEK)];
    int month = cal.get(Calendar.MONTH);
    int year = cal.get(Calendar.YEAR);
    int dayToSet = WEEKDAYS_REV[specials.get(0)];

    if (matchers.get(2) != null)
      return false;

    if (day > dayToSet) {
      if (specials.get(2) == null) {
        cal.add(WEEK_OF_YEAR, 1);
      } else if (matchers.get(3) == null) {
        cal.add(Calendar.MONTH, 1);
      } else if (matchers.get(4) == null) {
        cal.add(Calendar.YEAR, 1);
      } else
        return false;
    }

    cal.set(Calendar.SECOND, 0);
    cal.set(DAY_OF_WEEK, specials.get(0));

    if (matchers.get(3) != null && cal.get(Calendar.MONTH) != month)
      return false;

    // noinspection RedundantIfStatement
    if (matchers.get(4) != null && cal.get(Calendar.YEAR) != year)
      return false;

    return true;
  }

  /**
   * Set the week of the month but ensure that the calendar does point to a
   * date in future.
   *
   * @param cal The calendar to manipulate.
   *
   * @return true if the operation could be made.
   */
  private boolean setWeekOfMonth(Calendar cal) {
    int week = cal.get(WEEK_OF_MONTH);
    int year = cal.get(Calendar.YEAR);
    int weekToSet = specials.get(2);

    if (week > weekToSet) {
      if (matchers.get(3) == null) {
        cal.add(Calendar.MONTH, 1);
      } else if (matchers.get(4) == null) {
        cal.add(Calendar.YEAR, 1);
      } else
        return false;

      if (matchers.get(4) != null && cal.get(Calendar.YEAR) != year)
        return false;
    }

    int month = cal.get(Calendar.MONTH);

    cal.set(WEEK_OF_MONTH, weekToSet);

    if (cal.get(Calendar.MONTH) != month) {
      cal.set(Calendar.DAY_OF_MONTH, 1);
      cal.set(Calendar.MONTH, month);
    } else if (matchers.get(2) == null && week != weekToSet) {
      cal.set(DAY_OF_WEEK, 2);
    }

    return true;
  }
}