/**
 * Pure-Function-Verteilung von Versand-Jobs über Tage hinweg.
 *
 * Wird aufgerufen wenn der User im Versand-Tab Limits aktiviert hat
 * (max/Tag, Versand-Fenster, oder Wochentag-Filter). Liefert pro Empfänger
 * einen `scheduledAt`-Zeitpunkt zurück, der dann 1:1 in `email_queue` landet.
 *
 * Alle Date-Operationen laufen in der lokalen Zeitzone des Date-Objekts —
 * d.h. Server-TZ im Backend, Browser-TZ beim UI-Estimate. Damit Versand zur
 * gewünschten Wand-Uhrzeit erfolgt, muss der Worker-Container in der
 * passenden TZ (z.B. Europe/Berlin) laufen, sonst kommt es zu Offset-Drift.
 */

export interface DistributeOptions {
  /** Anzahl der Empfänger, für die ein `scheduledAt` berechnet werden soll. */
  count: number;
  /** Frühester Versand-Zeitpunkt — kein Job vor diesem Datum. */
  startAt: Date;
  /** Optional: Mails/Stunde. Wirkt nur als Unter-Limit innerhalb des Fensters. */
  throttlePerHour?: number;
  /** Optional: Max Mails pro Kalendertag. Undefined = unbegrenzt. */
  maxPerDay?: number;
  /** Optional: Tages-Fenster Start, Format 'HH:MM'. Nur zusammen mit windowEnd. */
  windowStart?: string;
  /** Optional: Tages-Fenster Ende, Format 'HH:MM'. Muss > windowStart sein. */
  windowEnd?: string;
  /** Optional: Erlaubte Wochentage 0=So..6=Sa. Undefined/leer = alle 7 Tage. */
  allowedWeekdays?: number[];
}

export interface DistributeEstimate {
  /** Anzahl unterschiedlicher Kalendertage, auf die Jobs fallen. */
  totalDays: number;
  /** Zeitpunkt des letzten Jobs (null wenn count=0). */
  finishAt: Date | null;
  /** Durchschnittliche Mails pro Versand-Tag. */
  perDay: number;
  /** Warnung wenn die Verteilung praktisch unmöglich ist (z.B. Tages-Cap < 1). */
  warning?: string;
}

/** Prüft, ob mindestens ein Limit aktiv ist. Sonst gilt das alte Verhalten (alle gleichzeitig). */
export function hasDistributionLimits(o: DistributeOptions): boolean {
  if (o.maxPerDay !== undefined) return true;
  if (o.windowStart !== undefined && o.windowEnd !== undefined) return true;
  if (o.allowedWeekdays !== undefined && o.allowedWeekdays.length > 0 && o.allowedWeekdays.length < 7) {
    return true;
  }
  return false;
}

function hhmmToMinutes(s: string): number {
  const m = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(s);
  if (!m) throw new Error(`Ungültiges HH:MM-Format: ${s}`);
  return Number(m[1]) * 60 + Number(m[2]);
}

function effectiveSlotsPerDay(o: DistributeOptions, windowLengthMinutes: number): number {
  const slotsByThrottle = o.throttlePerHour
    ? Math.max(1, Math.floor((o.throttlePerHour * windowLengthMinutes) / 60))
    : Number.POSITIVE_INFINITY;
  const slotsByCap = o.maxPerDay ?? Number.POSITIVE_INFINITY;
  return Math.max(1, Math.min(slotsByCap, slotsByThrottle));
}

/** Setzt `cursor` auf den Anfang des nächsten erlaubten Wochentags. */
function advanceToNextAllowedDay(cursor: Date, allowedDays: Set<number>): void {
  cursor.setDate(cursor.getDate() + 1);
  cursor.setHours(0, 0, 0, 0);
  let guard = 0;
  while (!allowedDays.has(cursor.getDay()) && guard < 14) {
    cursor.setDate(cursor.getDate() + 1);
    guard++;
  }
  if (guard >= 14) {
    throw new Error('Keine erlaubten Wochentage gefunden');
  }
}

/**
 * Berechnet `count` Zeitpunkte unter Berücksichtigung aller aktiven Limits.
 *
 * Wenn keine Limits aktiv sind, liefert die Funktion `count` Kopien von
 * `startAt` zurück — der Worker übernimmt dann das übliche MIN_GAP_MS-Spacing.
 */
export function distributeJobs(opts: DistributeOptions): Date[] {
  if (opts.count <= 0) return [];

  if (!hasDistributionLimits(opts)) {
    return Array.from({ length: opts.count }, () => new Date(opts.startAt));
  }

  const allowedDays = new Set<number>(
    opts.allowedWeekdays && opts.allowedWeekdays.length > 0
      ? opts.allowedWeekdays
      : [0, 1, 2, 3, 4, 5, 6],
  );
  if (allowedDays.size === 0) {
    throw new Error('allowedWeekdays muss mindestens einen Tag enthalten');
  }

  const winStartMin = opts.windowStart ? hhmmToMinutes(opts.windowStart) : 0;
  const winEndMin = opts.windowEnd ? hhmmToMinutes(opts.windowEnd) : 24 * 60;
  if (winEndMin <= winStartMin) {
    throw new Error('windowEnd muss nach windowStart liegen');
  }
  const winLenMin = winEndMin - winStartMin;

  const slotsPerDay = effectiveSlotsPerDay(opts, winLenMin);
  const intervalMin = slotsPerDay > 1 ? winLenMin / slotsPerDay : 0;

  const cursor = new Date(opts.startAt);
  cursor.setSeconds(0, 0);

  const startMinutesInDay = cursor.getHours() * 60 + cursor.getMinutes();
  let nextSlotIdx = 0;

  if (allowedDays.has(cursor.getDay()) && startMinutesInDay <= winEndMin) {
    if (startMinutesInDay <= winStartMin) {
      nextSlotIdx = 0;
    } else if (intervalMin > 0) {
      nextSlotIdx = Math.ceil((startMinutesInDay - winStartMin) / intervalMin);
    } else {
      nextSlotIdx = slotsPerDay;
    }
    if (nextSlotIdx >= slotsPerDay) {
      advanceToNextAllowedDay(cursor, allowedDays);
      nextSlotIdx = 0;
    }
  } else {
    advanceToNextAllowedDay(cursor, allowedDays);
    nextSlotIdx = 0;
  }

  const result: Date[] = [];
  while (result.length < opts.count) {
    const totalMin = winStartMin + Math.round(nextSlotIdx * intervalMin);
    const slot = new Date(cursor);
    slot.setHours(Math.floor(totalMin / 60), totalMin % 60, 0, 0);
    result.push(slot);

    nextSlotIdx++;
    if (nextSlotIdx >= slotsPerDay) {
      advanceToNextAllowedDay(cursor, allowedDays);
      nextSlotIdx = 0;
    }
  }

  return result;
}

/**
 * Liefert eine kompakte Vorschau für die UI-Estimate-Box, ohne das volle
 * Date-Array allozieren zu müssen. Bei sehr grossen `count`-Werten ist das
 * spürbar günstiger als `distributeJobs().length`.
 */
export function estimateDistribution(opts: DistributeOptions): DistributeEstimate {
  if (opts.count <= 0) return { totalDays: 0, finishAt: null, perDay: 0 };

  if (!hasDistributionLimits(opts)) {
    return {
      totalDays: 1,
      finishAt: new Date(opts.startAt),
      perDay: opts.count,
    };
  }

  const allowedDays = new Set<number>(
    opts.allowedWeekdays && opts.allowedWeekdays.length > 0
      ? opts.allowedWeekdays
      : [0, 1, 2, 3, 4, 5, 6],
  );
  const winStartMin = opts.windowStart ? hhmmToMinutes(opts.windowStart) : 0;
  const winEndMin = opts.windowEnd ? hhmmToMinutes(opts.windowEnd) : 24 * 60;
  if (winEndMin <= winStartMin) {
    return { totalDays: 0, finishAt: null, perDay: 0, warning: 'Ungültiges Zeitfenster' };
  }
  const winLenMin = winEndMin - winStartMin;
  const slotsPerDay = effectiveSlotsPerDay(opts, winLenMin);

  const totalDays = Math.ceil(opts.count / slotsPerDay);

  const cursor = new Date(opts.startAt);
  cursor.setSeconds(0, 0);
  const startMinutesInDay = cursor.getHours() * 60 + cursor.getMinutes();
  if (!allowedDays.has(cursor.getDay()) || startMinutesInDay > winEndMin) {
    advanceToNextAllowedDay(cursor, allowedDays);
  }

  let daysCovered = 1;
  while (daysCovered < totalDays) {
    advanceToNextAllowedDay(cursor, allowedDays);
    daysCovered++;
  }

  const slotsLastDay = opts.count - slotsPerDay * (totalDays - 1);
  const intervalMin = slotsPerDay > 1 ? winLenMin / slotsPerDay : 0;
  const lastSlotMin = winStartMin + Math.round((slotsLastDay - 1) * intervalMin);
  const finishAt = new Date(cursor);
  finishAt.setHours(Math.floor(lastSlotMin / 60), lastSlotMin % 60, 0, 0);

  const perDay = Math.min(opts.count, slotsPerDay);
  return { totalDays, finishAt, perDay };
}
