/**
 * Rotator for wizard-provisioned OpenBao backends.
 *
 * Flow on every tick:
 *   1. Read the CURRENT mcpd token from its backing plaintext Secret.
 *   2. Use that token to mint a SUCCESSOR via `auth/token/create/<role>`
 *      (the `app-mcpd` policy grants the caller exactly this path).
 *   3. Verify the successor with `auth/token/lookup-self`.
 *   4. Persist the successor in the same Secret (overwriting the old value).
 *   5. Revoke the predecessor by accessor (best-effort; old tokens expire on
 *      their own anyway).
 *   6. Update `tokenMeta` on the SecretBackend row with the new timestamps.
 *
 * On any failure: old token remains in place, `tokenMeta.lastRotationError`
 * is populated, the exception is re-thrown. Old tokens still have ~29 days
 * of remaining TTL by design (ttl=720h, rotation cadence=24h), so a few
 * days of rotation failures are survivable without a user outage.
 */
import type { SecretBackend } from '@prisma/client';
import {
  mintRoleToken,
  lookupSelf,
  revokeAccessor,
  type VaultDeps,
  type MintedToken,
} from '@mcpctl/shared';
import type { SecretBackendService } from './secret-backend.service.js';
import type { SecretService } from './secret.service.js';

/** Shape of `SecretBackend.config` we require for rotation. */
export interface RotatableOpenBaoConfig {
  url: string;
  auth?: 'token';
  mount?: string;
  pathPrefix?: string;
  namespace?: string;
  tokenSecretRef: { name: string; key: string };
  rotation: {
    enabled: true;
    tokenRole: string;
    intervalHours?: number;
  };
}

/** Shape we store in `SecretBackend.tokenMeta`. */
export interface TokenMeta {
  generatedAt?: string;
  nextRenewalAt?: string;
  validUntil?: string;
  lastRotationAt?: string;
  lastRotationError?: string | null;
  currentAccessor?: string;
  rotatable?: boolean;
}

export interface SecretBackendRotatorDeps {
  backends: SecretBackendService;
  secrets: SecretService;
  fetch?: typeof globalThis.fetch;
  now?: () => Date;
}

export class SecretBackendRotator {
  private readonly now: () => Date;

  constructor(private readonly deps: SecretBackendRotatorDeps) {
    this.now = deps.now ?? (() => new Date());
  }

  /** True iff this backend is a wizard-provisioned token-auth openbao with rotation enabled. */
  isRotatable(backend: SecretBackend): boolean {
    if (backend.type !== 'openbao') return false;
    const cfg = backend.config as Partial<RotatableOpenBaoConfig>;
    return (cfg.auth ?? 'token') === 'token'
      && cfg.rotation?.enabled === true
      && typeof cfg.rotation?.tokenRole === 'string'
      && typeof cfg.tokenSecretRef?.name === 'string';
  }

  /**
   * Execute one rotation pass on the given backend. Returns the freshly
   * recorded `tokenMeta`. Throws on any failure — callers decide whether to
   * log + move on (loop) or propagate (manual trigger).
   */
  async rotateOne(backendId: string): Promise<TokenMeta> {
    const backend = await this.deps.backends.getById(backendId);
    if (!this.isRotatable(backend)) {
      throw new Error(`SecretBackend '${backend.name}' is not rotatable (need type=openbao, auth=token, rotation.enabled=true)`);
    }
    const cfg = backend.config as unknown as RotatableOpenBaoConfig;
    const meta = (backend.tokenMeta as unknown as TokenMeta | null | undefined) ?? {};

    const vaultDeps: VaultDeps = {};
    if (this.deps.fetch !== undefined) vaultDeps.fetch = this.deps.fetch;
    if (cfg.namespace !== undefined) vaultDeps.namespace = cfg.namespace;

    // 1. Read current token from the backing plaintext Secret.
    const secretRow = await this.deps.secrets.getByName(cfg.tokenSecretRef.name);
    const data = await this.deps.secrets.resolveData(secretRow);
    const currentToken = data[cfg.tokenSecretRef.key];
    if (currentToken === undefined || currentToken === '') {
      const err = new Error(`rotation: current token missing at ${cfg.tokenSecretRef.name}/${cfg.tokenSecretRef.key}`);
      await this.recordError(backendId, meta, err.message);
      throw err;
    }
    const oldAccessor = meta.currentAccessor;

    let minted: MintedToken;
    try {
      // 2. Mint successor.
      minted = await mintRoleToken(cfg.url, currentToken, cfg.rotation.tokenRole, vaultDeps);
      if (!minted.renewable) {
        throw new Error(`minted token from role '${cfg.rotation.tokenRole}' is not renewable — check the token role's renewable + period settings`);
      }

      // 3. Verify successor works (belt-and-suspenders — if bao returned a token
      //    that can't auth back, we'd lock ourselves out on persist).
      await lookupSelf(cfg.url, minted.clientToken, vaultDeps);

      // 4. Persist successor in the same Secret. Update in-place — we keep
      //    the other keys (if any) intact.
      const nextData = { ...data, [cfg.tokenSecretRef.key]: minted.clientToken };
      await this.deps.secrets.update(secretRow.id, { data: nextData });
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      // Classify "current token is dead" (HTTP 403 from mint OR lookup-self).
      // This happens when the upstream OpenBao was re-initialized — every
      // pre-existing token is invalidated, including ours. The rotator can
      // never self-heal from this state because it needs the (dead) token
      // to mint a successor. Surface explicit remediation so the operator
      // doesn't have to spelunk through 500s to figure it out.
      const tokenDead = /HTTP 403|permission denied|invalid token|HTTP 401/i.test(msg);
      const wrapped = tokenDead
        ? new Error(
            `BACKEND_TOKEN_DEAD: rotator could not authenticate to ${cfg.url} as the stored token. ` +
            `This is unrecoverable from inside mcpd — likely cause: OpenBao was re-initialized and all old tokens are invalid. ` +
            `Remediation: mint a fresh token under role '${cfg.rotation.tokenRole}' using a working OpenBao admin token, ` +
            `then \`mcpctl create secret ${cfg.tokenSecretRef.name} --data ${cfg.tokenSecretRef.key}=<new-token> --force\`. ` +
            `Original error: ${msg}`,
            // Preserve the original stack trace via Error.cause so the inner
            // failure (network vs OpenBao API vs DNS) is recoverable from the
            // wrapped throw.
            { cause: err })
        : err;
      const wrappedMsg = wrapped instanceof Error ? wrapped.message : String(wrapped);
      await this.recordError(backendId, meta, wrappedMsg);
      // Loud, structured log so the operator sees it in `kubectl logs deploy/mcpd`.
      // eslint-disable-next-line no-console
      console.error(JSON.stringify({
        level: 'fatal',
        kind: tokenDead ? 'BACKEND_TOKEN_DEAD' : 'BACKEND_ROTATION_FAILED',
        backend: backend.name,
        url: cfg.url,
        message: wrappedMsg,
      }));
      throw wrapped;
    }

    // 5. Revoke predecessor (best-effort — old tokens expire anyway).
    if (oldAccessor !== undefined && oldAccessor !== '') {
      try {
        await revokeAccessor(cfg.url, minted.clientToken, oldAccessor, vaultDeps);
      } catch (err) {
        // Log but don't fail the rotation — the new token is already live.
        const msg = err instanceof Error ? err.message : String(err);
        // eslint-disable-next-line no-console
        console.warn(`rotation: revoke old accessor '${oldAccessor}' on backend '${backend.name}' failed (continuing): ${msg}`);
      }
    }

    // 6. Record success in tokenMeta.
    const now = this.now();
    const intervalHours = cfg.rotation.intervalHours ?? 24;
    const nextMeta: TokenMeta = {
      generatedAt: now.toISOString(),
      nextRenewalAt: new Date(now.getTime() + intervalHours * 3600 * 1000).toISOString(),
      validUntil: minted.leaseDuration > 0
        ? new Date(now.getTime() + minted.leaseDuration * 1000).toISOString()
        : undefined as unknown as string, // typed but optional; undefined drops on JSON round-trip
      lastRotationAt: now.toISOString(),
      lastRotationError: null,
      currentAccessor: minted.accessor,
      rotatable: true,
    };
    // Strip undefined so JSON is clean.
    const cleanMeta: Record<string, unknown> = {};
    for (const [k, v] of Object.entries(nextMeta)) {
      if (v !== undefined) cleanMeta[k] = v;
    }
    await this.deps.backends.updateTokenMeta(backendId, cleanMeta);
    return nextMeta;
  }

  /**
   * Probe the backend's stored token by calling `auth/token/lookup-self`
   * (cheap, idempotent). Returns `{ok:true}` if the token is valid, or
   * `{ok:false, message}` with a clear remediation message if dead. Used
   * by the loop on startup so an OpenBao re-init that invalidated all old
   * tokens shows up in mcpd logs immediately, not 24 hours later when the
   * scheduled rotation finally runs.
   */
  async healthCheck(backendId: string): Promise<{ ok: boolean; message?: string }> {
    const backend = await this.deps.backends.getById(backendId);
    if (!this.isRotatable(backend)) return { ok: true };
    const cfg = backend.config as unknown as RotatableOpenBaoConfig;
    const vaultDeps: VaultDeps = {};
    if (this.deps.fetch !== undefined) vaultDeps.fetch = this.deps.fetch;
    if (cfg.namespace !== undefined) vaultDeps.namespace = cfg.namespace;
    try {
      const secretRow = await this.deps.secrets.getByName(cfg.tokenSecretRef.name);
      const data = await this.deps.secrets.resolveData(secretRow);
      const token = data[cfg.tokenSecretRef.key];
      if (token === undefined || token === '') {
        return { ok: false, message: `Stored token at ${cfg.tokenSecretRef.name}/${cfg.tokenSecretRef.key} is empty` };
      }
      await lookupSelf(cfg.url, token, vaultDeps);
      return { ok: true };
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      const tokenDead = /HTTP 403|permission denied|invalid token|HTTP 401/i.test(msg);
      const wrapped = tokenDead
        ? `BACKEND_TOKEN_DEAD: ${cfg.url} rejected the stored token (likely upstream re-init). ` +
          `Remediation: mint a fresh token under role '${cfg.rotation.tokenRole}' and run ` +
          `\`mcpctl create secret ${cfg.tokenSecretRef.name} --data ${cfg.tokenSecretRef.key}=<new-token> --force\`. ` +
          `Original: ${msg}`
        : `health check failed: ${msg}`;
      // Persist on the row so describe shows it.
      const meta = (backend.tokenMeta as unknown as TokenMeta | null | undefined) ?? {};
      await this.recordError(backendId, meta, wrapped).catch(() => undefined);
      return { ok: false, message: wrapped };
    }
  }

  /** Is this backend overdue for rotation? Used by the loop on startup. */
  isOverdue(backend: SecretBackend): boolean {
    const meta = (backend.tokenMeta as unknown as TokenMeta | null | undefined) ?? {};
    if (meta.lastRotationAt === undefined) return true;
    const last = new Date(meta.lastRotationAt).getTime();
    if (Number.isNaN(last)) return true;
    const cfg = backend.config as Partial<RotatableOpenBaoConfig>;
    const intervalHours = cfg.rotation?.intervalHours ?? 24;
    return this.now().getTime() - last > intervalHours * 3600 * 1000;
  }

  private async recordError(backendId: string, prev: TokenMeta, message: string): Promise<void> {
    const nextMeta: Record<string, unknown> = { ...prev, lastRotationError: message };
    try {
      await this.deps.backends.updateTokenMeta(backendId, nextMeta);
    } catch (inner) {
      // Don't mask the original error — just log the DB failure.
      // eslint-disable-next-line no-console
      console.warn(`rotation: failed to persist lastRotationError (${message}): ${inner instanceof Error ? inner.message : String(inner)}`);
    }
  }
}
