package project.batch;

import java.util.Calendar;
import java.util.Date;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
import java.util.stream.Collectors;

import batch.controller.JobRequestor;
import batch.status.Job;
import common.db.JdbcSource;
import common.sql.QueryUtil;
import core.util.DateUtil;
import core.util.NumberUtil;
import project.db.model.FwJobRepeat;

/**
 * 定期起動起動バッチ
 *
 * @author Tadashi Nakayama
 */
public final class ScheduledBatch extends ProjectBatch {

	/**
	 * メイン処理
	 *
	 * @param args 引数
	 */
	public static void main(final String... args) {
		final var ret = ProjectPerform.start(ScheduledBatch.class, args);
		Runtime.getRuntime().exit(ret);
	}

	/**
	 * @see batch.base.Batch#getBatchName()
	 */
	@Override
	public String getBatchName() {
		return "定期起動起動バッチ";
	}

	/**
	 * @see batch.base.Batch#perform(java.lang.String...)
	 */
	@Override
	public int perform(final String... args) {
		final var scheduler = new Scheduler(Scheduler.getFutureJob());
		scheduler.scheduleAll();

		final var executor = Executors.newSingleThreadScheduledExecutor();
		executor.scheduleWithFixedDelay(scheduler, 120, 120, TimeUnit.SECONDS);
		return RET_SUCCESS;
	}

	/**
	 * スケジューラ
	 *
	 * @author Tadashi Nakayama
	 */
	private static final class Scheduler implements Runnable {
		/** スケジューラ */
		private final ScheduledExecutorService ses = Executors.newSingleThreadScheduledExecutor();

		/** タスクリスト */
		private final List<FutureJob> future;

		/**
		 * コンストラクタ
		 *
		 * @param val タスクリスト
		 */
		Scheduler(final List<FutureJob> val) {
			this.future = val;
		}

		/**
		 * タスクスケジュール
		 */
		public void scheduleAll() {
			for (final var job : this.future) {
				schedule(job);
			}
		}

		/**
		 * タスクスケジュール
		 *
		 * @param job タスク
		 */
		private void schedule(final FutureJob job) {
			final var delay = job.getDelay();
			if (0 < delay) {
				job.setFuture(this.ses.schedule(new JobCommand(job), delay, TimeUnit.SECONDS));
			}
		}

		/**
		 * タスク取得
		 *
		 * @return タスク
		 */
		public static List<FutureJob> getFutureJob() {
			try (var dao = JdbcSource.getDao()) {
				final var query = QueryUtil.getSqlFromFile(
						"SelectJobRepeat", ScheduledBatch.class);
				return dao.selectAll(FwJobRepeat.class, query).stream().
						map(FutureJob::new).collect(Collectors.toList());
			}
		}

		/**
		 * @see java.lang.Runnable#run()
		 */
		@Override
		public void run() {
			final var list = getFutureJob();
			var i = 0;
			var j = 0;
			while (i < this.future.size() || j < list.size()) {
				final var job1 = getFutureJobAt(i, this.future);
				final var job2 = getFutureJobAt(j, list);

				if (job1 == null) {
					// 新規追加された
					schedule(job2);
					j++;
				} else if (job2 == null) {
					// 削除された
					cancel(job1);
					i++;
				} else if (job1.getId().compareTo(job2.getId()) < 0) {
					// 削除された
					cancel(job1);
					i++;
				} else if (job1.getId().compareTo(job2.getId()) > 0) {
					// 新規追加された
					schedule(job2);
					j++;
				} else if (job1.getId().equals(job2.getId())) {
					if (job1.getVersion() == job2.getVersion()) {
						// 変更なし
						if (job1.getFuture() != null && job1.getFuture().isDone()) {
							schedule(job2);
						}

						if (job2.getFuture() == null) {
							job2.setFuture(job1.getFuture());
						}
					} else {
						// 変更された
						cancel(job1);
						schedule(job2);
					}
					i++;
					j++;
				}
			}
			this.future.clear();
			this.future.addAll(list);
		}

		/**
		 * ジョブ取得
		 *
		 * @param list ジョブリスト
		 * @param loc 位置
		 * @return ジョブ
		 */
		private FutureJob getFutureJobAt(final int loc, final List<FutureJob> list) {
			if (loc < list.size()) {
				return list.get(loc);
			}
			return null;
		}

		/**
		 * キャンセル処理
		 *
		 * @param job ジョブ
		 */
		private void cancel(final FutureJob job) {
			final var f = job.getFuture();
			if (f != null) {
				f.cancel(false);
				job.setFuture(null);
			}
		}
	}

	/**
	 * ジョブ依頼コマンド
	 *
	 * @author Tadashi Nakayama
	 */
	private static final class JobCommand implements Runnable {
		/** ジョブ */
		private final FutureJob future;

		/**
		 * コンストラクタ
		 *
		 * @param fj FutureJob
		 */
		JobCommand(final FutureJob fj) {
			this.future = fj;
		}

		/**
		 * @see java.lang.Runnable#run()
		 */
		@Override
		public void run() {
			if (this.future.getVersion() == getVersion(this.future.getId())) {
				JobRequestor.requestJob(getJob());
			}
		}

		/**
		 * バージョン取得
		 *
		 * @param val ID
		 * @return バージョン
		 */
		private int getVersion(final Long val) {
			try (var dao = JdbcSource.getDao()) {
				final var query = QueryUtil.getSqlFromFile("SelectVersion", this.getClass());
				final var ret = dao.select(Integer.class, query, val);
				if (ret != null) {
					return ret;
				}
				return 0;
			}
		}

		/**
		 * Job作成
		 *
		 * @return Job
		 */
		private Job getJob() {
			final var job = new Job();
			job.setExecParam(this.future.getBatParam());
			job.setGamenParam(this.future.getDispParam());
			job.setJobId(this.future.getJobId());
			job.setJobName(this.future.getJobName());
			job.setDateTime(DateUtil.getDateTime());
			job.setUid(this.future.getUpdateId());
			return job;
		}
	}

	/**
	 * スケジュールジョブ
	 *
	 * @author Tadashi Nakayama
	 */
	public static class FutureJob {

		/** FwJobRepeat */
		private final FwJobRepeat repeat;

		/** タスク */
		private Future<?> future;

		/**
		 * コンストラクタ
		 *
		 * @param rep FwJobRepeat
		 */
		public FutureJob(final FwJobRepeat rep) {
			this.repeat = rep;
		}

		/**
		 * @return the future
		 */
		public Future<?> getFuture() {
			return this.future;
		}

		/**
		 * @param val the future to set
		 */
		public void setFuture(final Future<?> val) {
			this.future = val;
		}

		/**
		 * Id取得
		 *
		 * @return Id
		 */
		public Long getId() {
			return this.repeat.getId();
		}

		/**
		 * JobId取得
		 *
		 * @return JobId
		 */
		public String getJobId() {
			return this.repeat.getJobId();
		}

		/**
		 * JobName取得
		 *
		 * @return JobName
		 */
		public String getJobName() {
			return this.repeat.getJobName();
		}

		/**
		 * BatParam取得
		 *
		 * @return BatParam
		 */
		public String getBatParam() {
			return this.repeat.getBatParam();
		}

		/**
		 * DispParam取得
		 *
		 * @return DispParam
		 */
		public String getDispParam() {
			return this.repeat.getDispParam();
		}

		/**
		 * UpdateId取得
		 *
		 * @return UpdateId
		 */
		public String getUpdateId() {
			return this.repeat.getUpdateId();
		}

		/**
		 * Version取得
		 *
		 * @return Version
		 */
		public int getVersion() {
			return this.repeat.getVersion();
		}

		/**
		 * 初期実行迄時間取得
		 *
		 * @return 初期実行迄時間
		 */
		public long getDelay() {
			var ret = 0L;
			final var now = new Date();
			if ("day".equalsIgnoreCase(this.repeat.getInterval())) {
				ret = getDayDelay(now, DateUtil.calcDay(now, 1));
			} else if ("week".equalsIgnoreCase(this.repeat.getInterval())) {
				ret = getWeekDelay(now);
			} else if ("month".equalsIgnoreCase(this.repeat.getInterval())) {
				ret = getMonthDelay(now, this.repeat.getYearMonthDay(), null);
			} else if ("year".equalsIgnoreCase(this.repeat.getInterval())) {
				ret = getYearDelay(now);
			} else if ("next".equalsIgnoreCase(this.repeat.getInterval())) {
				ret = getNextDelay(now);
			}
			return ret;
		}

		/**
		 * 差分（秒）取得
		 *
		 * @param now 現在日時
		 * @param target 対象日
		 * @return 差分（秒）
		 */
		private long toDelay(final Date now, final Date target) {
			if (target != null) {
				final var dt = DateUtil.toDateTime(DateUtil.format(target, DateUtil.FORMAT_DATE)
								+ this.repeat.getHour() + this.repeat.getMinute());
				return (dt.getTime() - now.getTime()) / 1000;
			}
			return 0;
		}

		/**
		 * 指定月内の日付取得
		 *
		 * @param cal 指定月のカレンダー
		 * @param day 日
		 * @return 指定月内日
		 */
		private int getDayIn(final Calendar cal, final String day) {
			return Math.min(cal.getActualMaximum(Calendar.DATE), NumberUtil.toInt(day, 0));
		}

		/**
		 * 次起動間隔（日次）取得
		 *
		 * @param now 現在日時
		 * @param next 次起動日
		 * @return 次起動間隔（日次）
		 */
		private long getDayDelay(final Date now, final Date next) {
			if ((this.repeat.getHour() + this.repeat.getMinute()).compareTo(
					DateUtil.format(now, "HHmm")) > 0) {
				return toDelay(now, now);
			}
			return toDelay(now, next);
		}

		/**
		 * 次起動間隔（週次）取得
		 *
		 * @param now 現在日時
		 * @return 次起動間隔
		 */
		private long getWeekDelay(final Date now) {
			final var cal = Calendar.getInstance();
			cal.setTime(now);
			final var diff =
					NumberUtil.toInt(this.repeat.getWeek(), 0) - cal.get(Calendar.DAY_OF_WEEK);
			if (0 < diff) {
				return toDelay(now, DateUtil.calcDay(now, diff));
			} else if (diff < 0) {
				return toDelay(now, DateUtil.calcDay(now, diff + 7));
			}
			return getDayDelay(now, DateUtil.calcDay(now, 7));
		}

		/**
		 * 次起動間隔（月次）取得
		 *
		 * @param now 現在日時
		 * @param day 日
		 * @param next 次起動日
		 * @return 次起動間隔（月次）
		 */
		private long getMonthDelay(final Date now, final String day, final Date next) {
			final var cal = Calendar.getInstance();
			cal.setTime(now);
			final var diff = getDayIn(cal, day) - cal.get(Calendar.DAY_OF_MONTH);
			if (diff > 0) {
				return toDelay(now, DateUtil.calcDay(now, diff));
			}

			final Supplier<Date> supplier = () -> {
				cal.add(Calendar.MONTH, 1);
				cal.set(Calendar.DAY_OF_MONTH, getDayIn(cal, day));
				return cal.getTime();
			};
			final var dt = Objects.requireNonNullElseGet(next, supplier);
			return (diff < 0) ? toDelay(now, dt) : getDayDelay(now, dt);
		}

		/**
		 * 次起動間隔（年次）取得
		 *
		 * @param now 現在日時
		 * @return 次起動間隔（年次）
		 */
		private long getYearDelay(final Date now) {
			final var ymd = this.repeat.getYearMonthDay();
			final var loc = ymd.offsetByCodePoints(0, 2);

			final var cal = Calendar.getInstance();
			cal.setTime(now);
			final var diff =
					NumberUtil.toInt(ymd.substring(0, loc), 0) - cal.get(Calendar.MONTH) - 1;
			if (0 < diff) {
				cal.add(Calendar.MONTH, diff);
				cal.set(Calendar.DAY_OF_MONTH, getDayIn(cal, ymd.substring(loc)));
				return toDelay(now, cal.getTime());
			}

			cal.add(Calendar.YEAR, 1);
			cal.set(Calendar.MONTH, NumberUtil.toInt(ymd.substring(0, loc), 0) - 1);
			cal.set(Calendar.DAY_OF_MONTH, getDayIn(cal, ymd.substring(loc)));
			if (diff < 0) {
				return toDelay(now, cal.getTime());
			}

			return getMonthDelay(now, ymd.substring(loc), cal.getTime());
		}

		/**
		 * 次起動間隔（次回）取得
		 *
		 * @param now 現在日時
		 * @return 次起動間隔（次回）
		 */
		private long getNextDelay(final Date now) {
			final var dt = DateUtil.toDate(this.repeat.getYearMonthDay());
			final var diff = dt.compareTo(DateUtil.toDate(now));
			if (diff > 0) {
				return toDelay(now, dt);
			} else if (diff < 0) {
				return 0;
			}
			return getDayDelay(now, null);
		}
	}
}
