import { Injectable, Logger } from "@nestjs/common";
import { SchedulerRegistry } from "@nestjs/schedule";
import { CronJob } from "cron";
import { SettingsService } from "../settings/settings.service";
import type { ResolvedSetting } from "../settings/settings.service";

/**
 * When the two automatic envíos run.
 *
 * Both halves of /notificaciones used to be hardcoded: pólizas swept at 06:00
 * from a `@Cron` decorator, servicios had no automatic run at all and had to
 * be clicked. Neither could be changed without a redeploy. This service owns
 * the cadence for both, stores it in `app_settings`, and re-installs the job
 * the moment an operator saves — no restart.
 *
 * The owning services register their handler at boot rather than this service
 * importing them: `NotificationsService` and `RenewalsService` would otherwise
 * have to be injected here, and this file is imported by both.
 */

export const SCHEDULE_TIME_ZONE = "America/Tijuana";

export type ScheduleKind = "servicios" | "polizas";

export const SCHEDULE_KINDS: ScheduleKind[] = ["servicios", "polizas"];

export interface NotificationSchedule {
  enabled: boolean;
  /** Local hour/minute in `SCHEDULE_TIME_ZONE`, not UTC — the office thinks
   *  in Tijuana time and DST would otherwise drift the run by an hour. */
  hour: number;
  minute: number;
  /** 0 = Sunday … 6 = Saturday. Empty means every day. */
  weekdays: number[];
}

export interface ResolvedSchedule extends ResolvedSetting<NotificationSchedule> {
  /** The cron expression the value compiles to, shown in the UI so the
   *  operator can see exactly what was installed. */
  cron: string;
  /** Next fire time, or null when disabled. */
  nextRun: string | null;
}

/**
 * Defaults preserve what each half did before this existed: pólizas keeps its
 * 06:00 daily sweep, servicios stays OFF. Turning a mass send on is an
 * operator decision — a default that starts mailing 260 customers on its own
 * after a deploy is not a default, it's an incident.
 */
const DEFAULTS: Record<ScheduleKind, NotificationSchedule> = {
  servicios: { enabled: false, hour: 7, minute: 0, weekdays: [1, 3, 5] },
  polizas: { enabled: true, hour: 6, minute: 0, weekdays: [] },
};

/** Human label used in log lines and audit entries. */
export const SCHEDULE_LABELS: Record<ScheduleKind, string> = {
  servicios: "envíos de servicios",
  polizas: "avisos de renovación",
};

export function scheduleCron(schedule: NotificationSchedule): string {
  const dow = schedule.weekdays.length
    ? [...new Set(schedule.weekdays)].sort((a, b) => a - b).join(",")
    : "*";
  return `${schedule.minute} ${schedule.hour} * * ${dow}`;
}

/** Reject anything that would compile to a cron we can't install. Returns the
 *  normalized value, or a message naming the offending field. */
export function parseSchedule(
  raw: unknown,
): { ok: true; value: NotificationSchedule } | { ok: false; error: string } {
  const v = raw as Partial<NotificationSchedule> | null;
  if (!v || typeof v !== "object") return { ok: false, error: "Horario inválido." };
  const hour = Number(v.hour);
  const minute = Number(v.minute);
  if (!Number.isInteger(hour) || hour < 0 || hour > 23) {
    return { ok: false, error: "La hora debe estar entre 0 y 23." };
  }
  if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
    return { ok: false, error: "Los minutos deben estar entre 0 y 59." };
  }
  const weekdays = Array.isArray(v.weekdays) ? v.weekdays.map(Number) : [];
  if (weekdays.some((d) => !Number.isInteger(d) || d < 0 || d > 6)) {
    return { ok: false, error: "Los días deben estar entre 0 (domingo) y 6." };
  }
  return {
    ok: true,
    value: {
      enabled: !!v.enabled,
      hour,
      minute,
      weekdays: [...new Set(weekdays)].sort((a, b) => a - b),
    },
  };
}

@Injectable()
export class NotificationScheduleService {
  private readonly logger = new Logger(NotificationScheduleService.name);
  private readonly handlers = new Map<ScheduleKind, () => Promise<unknown>>();

  constructor(
    private readonly settings: SettingsService,
    private readonly registry: SchedulerRegistry,
  ) {}

  /**
   * Called once per kind at boot by the service that owns the sweep. Installs
   * the job immediately so a freshly started process honours the stored
   * cadence without waiting for someone to open the UI.
   */
  async register(kind: ScheduleKind, handler: () => Promise<unknown>) {
    this.handlers.set(kind, handler);
    await this.apply(kind);
  }

  async get(kind: ScheduleKind): Promise<ResolvedSchedule> {
    const resolved = await this.settings.notificationSchedule(
      kind,
      DEFAULTS[kind],
    );
    const cron = scheduleCron(resolved.value);
    return { ...resolved, cron, nextRun: this.nextRun(kind) };
  }

  async getAll(): Promise<Record<ScheduleKind, ResolvedSchedule>> {
    const entries = await Promise.all(
      SCHEDULE_KINDS.map(async (k) => [k, await this.get(k)] as const),
    );
    return Object.fromEntries(entries) as Record<ScheduleKind, ResolvedSchedule>;
  }

  async set(
    kind: ScheduleKind,
    schedule: NotificationSchedule,
    userId: string,
  ): Promise<ResolvedSchedule> {
    await this.settings.setNotificationSchedule(kind, schedule, userId);
    await this.apply(kind);
    return this.get(kind);
  }

  /** (Re)install the cron job for one kind from whatever is stored now. */
  private async apply(kind: ScheduleKind): Promise<void> {
    const handler = this.handlers.get(kind);
    if (!handler) return;

    this.remove(kind);

    const { value } = await this.settings.notificationSchedule(
      kind,
      DEFAULTS[kind],
    );
    if (!value.enabled) {
      this.logger.log(`Horario de ${SCHEDULE_LABELS[kind]}: desactivado.`);
      return;
    }

    const cron = scheduleCron(value);
    const job = new CronJob(
      cron,
      () => {
        void handler().catch((error) =>
          this.logger.error(
            `Falló la corrida programada de ${SCHEDULE_LABELS[kind]}: ` +
              `${(error as Error).message}`,
          ),
        );
      },
      null,
      false,
      SCHEDULE_TIME_ZONE,
    );
    this.registry.addCronJob(this.jobName(kind), job);
    job.start();
    this.logger.log(
      `Horario de ${SCHEDULE_LABELS[kind]}: ${cron} (${SCHEDULE_TIME_ZONE}).`,
    );
  }

  private remove(kind: ScheduleKind): void {
    const name = this.jobName(kind);
    // `deleteCronJob` throws when the job was never installed, which is the
    // normal case on first apply — presence check instead of try/catch so a
    // real failure still surfaces.
    if (!this.registry.doesExist("cron", name)) return;
    this.registry.getCronJob(name).stop();
    this.registry.deleteCronJob(name);
  }

  private nextRun(kind: ScheduleKind): string | null {
    const name = this.jobName(kind);
    if (!this.registry.doesExist("cron", name)) return null;
    const next = this.registry.getCronJob(name).nextDate();
    return next ? next.toJSDate().toISOString() : null;
  }

  private jobName(kind: ScheduleKind): string {
    return `notification-schedule:${kind}`;
  }
}
