import type { Maybe } from '@/types/util.js';
import type { Logger } from '@/util/logging/LoggerFactory.js';
import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
import dayjs from 'dayjs';
import type { interfaces } from 'inversify';
import { isDate } from 'lodash-es';
import type { RecurrenceRule } from 'node-schedule';
import schedule from 'node-schedule';
import type { Task } from './Task.js';

type ScheduleRule = RecurrenceRule | Date | string | number;

export type TaskFactoryFn<OutType, Args extends unknown[] = []> = () => Task<
  Args,
  OutType
>;

type ScheduledTaskOptions = {
  visible?: boolean;
  runOnSchedule?: boolean;
  runAtStartup?: boolean;
};

export class ScheduledTask<
  TaskArgsType extends unknown[] = unknown[],
  TaskOutputType = unknown,
  Runnable extends Task<TaskArgsType, TaskOutputType> = Task<
    TaskArgsType,
    TaskOutputType
  >,
> {
  protected logger: Logger;
  protected scheduledJob: schedule.Job;

  private factory: interfaces.AutoFactory<Runnable>;
  private schedule: ScheduleRule;

  public running: boolean = false;
  public runAtStartup = false;
  public visible: boolean = true;
  public lastExecution?: Date;

  constructor(
    jobName: string,
    scheduleRule: ScheduleRule,
    taskFactory: interfaces.AutoFactory<Runnable>,
    private presetArgs: TaskArgsType,
    options?: ScheduledTaskOptions,
  ) {
    this.logger = LoggerFactory.child({
      task: jobName,
      className: this.constructor.name,
      caller: import.meta,
    });
    this.schedule = scheduleRule;
    this.factory = taskFactory;
    this.scheduledJob = schedule.scheduleJob(jobName, scheduleRule, () =>
      this.runJobInternal(),
    );

    this.visible = options?.visible ?? true;

    if (options?.runOnSchedule) {
      schedule.scheduleJob(jobName, dayjs().add(5, 'seconds').toDate(), () =>
        this.runJobInternal(),
      );
    }

    if (options?.runAtStartup) {
      this.runAtStartup = options.runAtStartup;
    }
  }

  get name() {
    return this.scheduledJob.name;
  }

  // Runs an instance of this task now, cancels the next invocation
  // and reschedules the job on the original schedule.
  // If background=true, this function will not return the underlying
  // Promise generated by the running job and all errors will be swallowed.
  async runNow(background: boolean = true): Promise<Maybe<TaskOutputType>> {
    this.scheduledJob.cancelNext(false);
    // Can't reschedule a one-off job
    const rescheduleCb = () =>
      isDate(this.schedule)
        ? void 0
        : this.scheduledJob.reschedule(this.schedule);
    if (background) {
      return new Promise<Maybe<TaskOutputType>>((resolve, reject) => {
        this.runJobInternal().then(resolve).catch(reject).finally(rescheduleCb);
      });
    } else {
      return this.runJobInternal(true).finally(rescheduleCb);
    }
  }

  cancel(reschedule: boolean = false) {
    this.scheduledJob.cancel(reschedule);
  }

  removeFromSchedule() {
    this.cancel(false);
  }

  nextExecution() {
    return this.scheduledJob.nextInvocation();
  }

  private async runJobInternal(
    rethrow: boolean = false,
  ): Promise<Maybe<TaskOutputType>> {
    this.running = true;
    const instance = this.factory();
    try {
      return await instance.run(...this.presetArgs);
    } catch (e) {
      this.logger.error(e, 'Error while running job: %s', instance.taskName);
      if (rethrow) throw e;
      return;
    } finally {
      this.running = false;
      this.lastExecution = new Date();
    }
  }
}
