import { $hook, $inject, Alepha } from "alepha";
import { type DateTime, DateTimeProvider } from "alepha/datetime";
import { $logger } from "alepha/logger";
import { type Cron, parseCronExpression } from "cron-schedule";

export class CronProvider {
  protected readonly dt = $inject(DateTimeProvider);
  protected readonly alepha = $inject(Alepha);
  protected readonly log = $logger();
  protected readonly cronJobs: Array<CronJob> = [];

  public getCronJobs(): Array<CronJob> {
    return this.cronJobs;
  }

  protected readonly start = $hook({
    on: "start",
    handler: () => {
      if (this.alepha.isServerless()) {
        this.log.info("Ignoring cron jobs in serverless environment");
        return;
      }

      for (const cron of this.cronJobs) {
        if (!cron.running) {
          cron.running = true;
          this.log.debug(
            `Starting cron task '${cron.name}' with '${cron.expression}'`,
          );
          this.run(cron);
        }
      }
    },
  });

  protected readonly stop = $hook({
    on: "stop",
    handler: () => {
      for (const cron of this.cronJobs) {
        this.abort(cron);
      }
    },
  });

  /**
   * Generic serverless cron trigger. Vercel's platform-emitted entry
   * point fires `serverless:cron` with the job name; we run the matching
   * job in-process. On long-running runtimes this listener is harmless
   * (no one fires the event).
   */
  protected readonly onServerlessCron = $hook({
    on: "serverless:cron",
    handler: async ({ name }) => {
      await this.trigger(name);
    },
  });

  protected boot(name: string | CronJob) {
    const cron =
      typeof name === "string"
        ? this.cronJobs.find((c) => c.name === name)
        : name;

    if (!cron) {
      return;
    }

    cron.running = true;

    this.log.debug(
      `Starting cron task '${cron.name}' with '${cron.expression}'`,
    );

    this.run(cron);
  }

  public abort(name: string | CronJob): void {
    const cron =
      typeof name === "string"
        ? this.cronJobs.find((c) => c.name === name)
        : name;

    if (!cron?.running) {
      return;
    }

    cron.running = false;
    cron.abort?.abort();
    this.log.debug(`Cron task '${cron.name}' stopped`);
  }

  /**
   * Registers a cron job.
   *
   * It's automatically done when using the `$scheduler` primitive but can also be used manually.
   */
  public createCronJob(
    name: string,
    expression: string,
    handler: (context: { now: DateTime }) => Promise<void>,
    start?: boolean,
  ): void {
    const cron: CronJob = {
      name,
      cron: parseCronExpression(expression),
      expression,
      handler,
      loop: true,
      abort: new AbortController(),
    };

    this.cronJobs.push(cron);

    if (start && this.alepha.isStarted()) {
      this.boot(cron);
    }
  }

  protected run(task: CronJob, now = this.dt.now()): void {
    if (!task.running) {
      return;
    }

    const [next] = task.cron.getNextDates(1, now.toDate());
    if (!next) {
      return;
    }

    const duration = next.getTime() - now.toDate().getTime();
    const abort = new AbortController();
    task.abort = abort;

    this.dt
      .wait(duration, {
        now: now.valueOf(),
        signal: abort.signal,
      })
      .then(() => {
        if (!task.running) {
          this.log.trace("Cron task stopped before execution");
          return;
        }

        this.log.trace("Running cron task");

        if (task.executing) {
          this.log.warn(
            `Cron task '${task.name}' is still running, skipping this invocation`,
          );
          if (task.loop) {
            this.run(task, this.dt.of(next));
          }
          return;
        }

        task.executing = true;
        task
          .handler({ now: this.dt.of(next) })
          .catch((err) => {
            if (task.onError) {
              task.onError(err);
            } else {
              this.log.error("Error in cron task:", err);
            }
          })
          .finally(() => {
            task.executing = false;
          });

        if (task.loop) {
          this.run(task, this.dt.of(next));
        }
      })
      .catch((err) => {
        this.log.warn("Issue during cron waiting timer", err as Error);
      });
  }

  /**
   * Trigger a specific cron job by name.
   */
  public async trigger(name: string): Promise<void> {
    const job = this.cronJobs.find((j) => j.name === name);
    if (!job) {
      this.log.warn(`Cron job '${name}' not found`);
      return;
    }
    await this.runJobs([job], this.dt.now());
  }

  /**
   * Trigger all registered cron jobs.
   */
  public async triggerAll(): Promise<void> {
    await this.runJobs(this.cronJobs, this.dt.now());
  }

  /**
   * Run multiple cron jobs in parallel.
   */
  protected async runJobs(jobs: CronJob[], now: DateTime): Promise<void> {
    const results = await Promise.allSettled(
      jobs.map(async (job) => {
        this.log.debug(`Running cron job '${job.name}'`);
        try {
          await job.handler({ now });
          this.log.debug(`Cron job '${job.name}' completed`);
        } catch (error) {
          this.log.error(`Cron job '${job.name}' failed`, error);
          throw error;
        }
      }),
    );

    const failures = results.filter((r) => r.status === "rejected");
    if (failures.length > 0) {
      this.log.error(`${failures.length}/${jobs.length} cron jobs failed`);
    }
  }
}

export interface CronJob {
  name: string;
  expression: string;
  handler: (context: { now: DateTime }) => Promise<void>;
  cron: Cron;
  loop: boolean;
  running?: boolean;
  executing?: boolean;
  onError?: (error: Error) => void;
  abort?: AbortController;
}
