/**
 * Heartbeat scheduler — the imperative timer loop.
 *
 * The scheduler owns NO knowledge of conversations, prompts, or the
 * orchestrator. It only manages per-workspace timers: when armed (a workspace's
 * heartbeat is `enabled`), it schedules a fire after `intervalMinutes`; when the
 * fire's work completes, it re-arms (resets the timer). This is the "reset timer
 * after each run" semantics — the interval is measured from run-completion to
 * the next fire, not a fixed wall-clock schedule.
 *
 * The actual run work (create a conversation, send the task prompt, track the
 * run) is the `fire(workspaceId)` callback, injected by the heartbeat service.
 * This keeps the scheduler pure-ish (only I/O is the injected timers) and
 * testable with fake timers + a fake fire.
 */

/** A handle returned by `setTimeout`, opaque to the scheduler. */
export type TimerHandle = ReturnType<typeof setTimeout>;

/** Injectable timers — the only I/O effect the scheduler touches. */
export interface Timers {
  readonly now: () => number;
  readonly setTimeout: (fn: () => void, ms: number) => TimerHandle;
  readonly clearTimeout: (handle: TimerHandle | undefined) => void;
}

/** The real timers (used at runtime; overridable in tests). */
export const realTimers: Timers = {
  now: () => Date.now(),
  setTimeout: (fn, ms) => setTimeout(fn, ms),
  clearTimeout: (handle) => {
    if (handle !== undefined) clearTimeout(handle);
  },
};

export interface SchedulerDeps {
  readonly timers: Timers;
  /** Do the run work for a workspace. Resolves when the turn seals. */
  readonly fire: (workspaceId: string) => Promise<void>;
}

interface WorkspaceSchedule {
  /** Current interval (minutes), updated by `arm` on config changes. */
  intervalMinutes: number;
  /** Pending wait timer (null while a fire is in progress). */
  timer: TimerHandle | undefined;
  /** A fire is in progress for this workspace. */
  running: boolean;
  /** Scheduling is active (the workspace's heartbeat is enabled). */
  armed: boolean;
  /**
   * Absolute epoch-ms timestamp of the next scheduled fire, or null when no
   * fire is pending — the schedule is disarmed, or a run is in progress
   * (the next fire is scheduled only after the run completes). This is the
   * server-authoritative next-run time the `/heartbeat/next-run` endpoint
   * derives its response from.
   */
  nextFireMs: number | null;
}

const MINUTE_MS = 60_000;

/**
 * Manages per-workspace heartbeat timers. A single instance is owned by the
 * heartbeat service for its lifetime.
 */
export class HeartbeatScheduler {
  private readonly schedules = new Map<string, WorkspaceSchedule>();
  private readonly timers: Timers;
  private readonly fire: (workspaceId: string) => Promise<void>;

  constructor(deps: SchedulerDeps) {
    this.timers = deps.timers;
    this.fire = deps.fire;
  }

  /**
   * Arm (or re-arm) a workspace's schedule from its config. When `enabled`
   * is false the schedule is disarmed. When a config update arrives while a
   * run is in progress, the new `intervalMinutes` takes effect on the next
   * re-arm (the in-progress run is never cancelled here).
   */
  arm(
    workspaceId: string,
    config: {
      readonly enabled: boolean;
      readonly intervalMinutes: number;
    },
  ): void {
    if (!config.enabled) {
      this.disarm(workspaceId);
      return;
    }
    let schedule = this.schedules.get(workspaceId);
    if (schedule === undefined) {
      schedule = {
        intervalMinutes: config.intervalMinutes,
        timer: undefined,
        running: false,
        armed: true,
        nextFireMs: null,
      };
      this.schedules.set(workspaceId, schedule);
    } else {
      schedule.intervalMinutes = config.intervalMinutes;
      schedule.armed = true;
    }
    // If a fire is in progress, let it finish — it will re-arm with the
    // (possibly new) interval. Otherwise schedule the next fire now.
    if (!schedule.running) {
      this.clearTimer(schedule);
      this.scheduleNext(workspaceId, schedule);
    }
  }

  /** Stop scheduling for a workspace (clears a pending timer; an in-progress run finishes on its own). */
  disarm(workspaceId: string): void {
    const schedule = this.schedules.get(workspaceId);
    if (schedule === undefined) return;
    schedule.armed = false;
    this.clearTimer(schedule);
    this.schedules.delete(workspaceId);
  }

  /** Stop all schedules (deactivate). */
  disarmAll(): void {
    for (const workspaceId of [...this.schedules.keys()]) {
      this.disarm(workspaceId);
    }
  }

  /** Whether a schedule is currently armed (enabled) for a workspace. */
  isArmed(workspaceId: string): boolean {
    return this.schedules.get(workspaceId)?.armed ?? false;
  }

  /** Whether a fire is currently in progress for a workspace. */
  isRunning(workspaceId: string): boolean {
    return this.schedules.get(workspaceId)?.running ?? false;
  }

  /**
   * The absolute epoch-ms timestamp of the next scheduled fire for a workspace,
   * or null when no fire is pending — the heartbeat is disabled/disarmed, or a
   * run is in progress (the next fire is scheduled only after the run
   * completes). This is the server-authoritative next-run time.
   */
  nextFireAt(workspaceId: string): number | null {
    return this.schedules.get(workspaceId)?.nextFireMs ?? null;
  }

  private scheduleNext(workspaceId: string, schedule: WorkspaceSchedule): void {
    const ms = Math.max(MINUTE_MS, schedule.intervalMinutes * MINUTE_MS);
    // Record the absolute fire time (now + delay) BEFORE arming the timer
    // so `nextFireAt` reports it while the timer is pending.
    schedule.nextFireMs = this.timers.now() + ms;
    schedule.timer = this.timers.setTimeout(() => {
      this.onTick(workspaceId);
    }, ms);
  }

  private onTick(workspaceId: string): void {
    const schedule = this.schedules.get(workspaceId);
    // Race: the schedule was disarmed after the timer was queued.
    if (schedule === undefined || !schedule.armed) return;
    schedule.timer = undefined;
    // A fire is now in progress — no next run is queued yet (it's scheduled
    // only after the run completes), so report no pending fire time.
    schedule.nextFireMs = null;
    schedule.running = true;
    this.fire(workspaceId)
      .catch(() => {
        // The service records the run outcome; a thrown fire is logged
        // by the service. Swallow so the loop continues.
      })
      .finally(() => {
        const current = this.schedules.get(workspaceId);
        if (current === undefined) return;
        current.running = false;
        if (current.armed) {
          this.scheduleNext(workspaceId, current);
        }
      });
  }

  private clearTimer(schedule: WorkspaceSchedule): void {
    if (schedule.timer !== undefined) {
      this.timers.clearTimeout(schedule.timer);
      schedule.timer = undefined;
    }
    schedule.nextFireMs = null;
  }
}
