/**
 * Background loop that drives `SecretBackendRotator` on a 24h cadence.
 *
 * - On `start()`: scan all rotatable backends. For each that is overdue
 *   (never rotated OR last rotation > 24h ago), kick rotation immediately.
 *   Then schedule a per-backend setTimeout for the next tick.
 * - On `stop()`: clear every pending timer. Called from the graceful-shutdown
 *   hook so restarts don't leak timers or interrupt an in-flight rotation.
 *
 * Jitter (±10 min by default) keeps multiple mcpd replicas from hammering
 * OpenBao simultaneously if someone scales the Deployment up.
 *
 * Failures are swallowed with a warn log — the next scheduled tick will
 * retry. The rotator service itself writes `lastRotationError` to the row
 * so operators see the failure in `describe`.
 */
import type { SecretBackend } from '@prisma/client';
import type { SecretBackendService } from './secret-backend.service.js';
import type { SecretBackendRotator } from './secret-backend-rotator.service.js';

export interface SecretBackendRotatorLoopDeps {
  backends: SecretBackendService;
  rotator: SecretBackendRotator;
  /** Millisecond jitter applied to the 24h base interval; defaults to ±600_000 (10 min). */
  jitterMs?: number;
  /** Override in tests. */
  setTimeout?: (cb: () => void, ms: number) => NodeJS.Timeout;
  clearTimeout?: (t: NodeJS.Timeout) => void;
  log?: { info: (msg: string) => void; warn: (msg: string) => void };
}

const DEFAULT_INTERVAL_MS = 24 * 3600 * 1000;
const DEFAULT_JITTER_MS = 10 * 60 * 1000;

export class SecretBackendRotatorLoop {
  private readonly timers = new Map<string, NodeJS.Timeout>();
  private readonly setT: (cb: () => void, ms: number) => NodeJS.Timeout;
  private readonly clearT: (t: NodeJS.Timeout) => void;
  private readonly log: { info: (msg: string) => void; warn: (msg: string) => void };
  private stopped = false;

  constructor(private readonly deps: SecretBackendRotatorLoopDeps) {
    this.setT = deps.setTimeout ?? ((cb, ms) => global.setTimeout(cb, ms));
    this.clearT = deps.clearTimeout ?? ((t) => global.clearTimeout(t));
    this.log = deps.log ?? {
      // eslint-disable-next-line no-console
      info: (m) => console.log(`[rotator] ${m}`),
      // eslint-disable-next-line no-console
      warn: (m) => console.warn(`[rotator] ${m}`),
    };
  }

  async start(): Promise<void> {
    const backends = (await this.deps.backends.list())
      .filter((b) => this.deps.rotator.isRotatable(b));

    if (backends.length === 0) {
      this.log.info('no rotatable backends registered — loop idle');
      return;
    }
    this.log.info(`starting rotation loop for ${String(backends.length)} backend(s)`);

    for (const b of backends) {
      // Boot-time health check: catches "upstream re-init invalidated our
      // stored token" the moment mcpd starts, not 24 hours later when the
      // scheduled rotation finally fires. Logs loudly with explicit
      // remediation; the rotator service has already persisted the same
      // message to tokenMeta.lastRotationError so `describe secretbackend`
      // surfaces it too.
      this.deps.rotator.healthCheck(b.id)
        .then((res) => {
          if (!res.ok) {
            // eslint-disable-next-line no-console
            console.error(JSON.stringify({
              level: 'fatal',
              kind: 'BACKEND_TOKEN_DEAD',
              backend: b.name,
              message: res.message ?? 'unknown',
            }));
            this.log.warn(`backend '${b.name}' health check failed: ${res.message ?? 'unknown'}`);
          }
        })
        .catch((err) => {
          this.log.warn(`backend '${b.name}' health check threw: ${err instanceof Error ? err.message : String(err)}`);
        });

      if (this.deps.rotator.isOverdue(b)) {
        this.log.info(`backend '${b.name}' is overdue — rotating now`);
        this.runOnce(b.id, b.name).catch((err) => {
          this.log.warn(`initial rotation of '${b.name}' failed: ${err instanceof Error ? err.message : String(err)}`);
        });
      }
      this.schedule(b);
    }
  }

  stop(): void {
    this.stopped = true;
    for (const [, t] of this.timers) this.clearT(t);
    this.timers.clear();
    this.log.info('rotation loop stopped');
  }

  /** Test hook — force a rotation + rescheduling for one backend. */
  async rotateNow(backendId: string): Promise<void> {
    const backend = await this.deps.backends.getById(backendId);
    await this.runOnce(backendId, backend.name);
    this.schedule(backend);
  }

  private schedule(backend: SecretBackend): void {
    if (this.stopped) return;
    // Clear any existing timer for this backend
    const prev = this.timers.get(backend.id);
    if (prev !== undefined) this.clearT(prev);

    const delay = this.nextDelayMs(backend);
    const t = this.setT(() => {
      this.runOnce(backend.id, backend.name)
        .catch((err) => this.log.warn(`scheduled rotation of '${backend.name}' failed: ${err instanceof Error ? err.message : String(err)}`))
        .finally(() => {
          // Re-fetch to pick up latest tokenMeta (nextRenewalAt) for the next delay calc.
          if (this.stopped) return;
          this.deps.backends.getById(backend.id)
            .then((b) => this.schedule(b))
            .catch((err) => this.log.warn(`re-schedule lookup for '${backend.name}' failed: ${err instanceof Error ? err.message : String(err)}`));
        });
    }, delay);
    this.timers.set(backend.id, t);
  }

  private async runOnce(backendId: string, name: string): Promise<void> {
    try {
      await this.deps.rotator.rotateOne(backendId);
      this.log.info(`rotated '${name}' successfully`);
    } catch (err) {
      // Error already recorded in tokenMeta by rotator; just log.
      throw err;
    }
  }

  private nextDelayMs(backend: SecretBackend): number {
    const cfg = backend.config as { rotation?: { intervalHours?: number } };
    const baseMs = cfg.rotation?.intervalHours !== undefined
      ? cfg.rotation.intervalHours * 3600 * 1000
      : DEFAULT_INTERVAL_MS;
    const jitter = this.deps.jitterMs ?? DEFAULT_JITTER_MS;
    // Uniform in [-jitter, +jitter]
    const offset = (Math.random() * 2 - 1) * jitter;
    return Math.max(60_000, Math.floor(baseMs + offset));
  }
}
