// Phase F-2-B (B3): per-world secret cache.
//
// Pattern lifted from `packages/cloudflare-worker/src/index.ts:428-446`
// (`getContainerSecret`). CF Worker uses Durable Object storage with a
// 1h TTL; host CP uses an in-memory Map with a 5min TTL (D2 — demoted
// from 1h after the security review pass).
//
// The cache invalidates on two paths:
//   1. TTL expiry (lazy: checked on each `get(worldId)` call)
//   2. Docker events stream (eager: docker-events.mjs subscribes to
//      `restart` / `stop` events and calls `invalidate(worldId)` —
//      M2 ship gate is "docker restart <world>; within 10s, proxy
//      call returns 200 not 401").

/**
 * @typedef {object} CacheEntry
 * @property {string} secret
 * @property {number} expiresAt   epoch ms
 */

export class SecretCache {
  /**
   * @param {object} opts
   * @param {number} [opts.ttlSec]              cache TTL in seconds (default 300 = 5min)
   * @param {() => number} [opts.now]           clock injectable for tests
   * @param {(message: string) => void} [opts.log]   logger injectable for tests
   */
  constructor({ ttlSec = 300, now = Date.now, log = console.log } = {}) {
    this.ttlMs = ttlSec * 1000;
    /** @type {Map<string, CacheEntry>} */
    this.entries = new Map();
    this.now = now;
    this.log = log;
  }

  /**
   * Look up a cached secret. Returns null if absent OR expired (TTL check
   * is lazy — caller must re-fetch and call set()). The expiry path emits
   * a debug log so cache-miss observability is wired in from day one.
   *
   * @param {string} worldId
   * @returns {string | null}
   */
  get(worldId) {
    const entry = this.entries.get(worldId);
    if (!entry) return null;
    if (entry.expiresAt <= this.now()) {
      // Lazy expiry — clean up to keep the map tidy. Don't log per-call;
      // would be noisy under load.
      this.entries.delete(worldId);
      return null;
    }
    return entry.secret;
  }

  /**
   * Cache a freshly-fetched secret. Overrides any prior entry. The
   * `set` path is the only place TTL is reset — ensures a cache hit
   * never extends beyond ttlMs from the most recent fetch.
   *
   * @param {string} worldId
   * @param {string} secret
   */
  set(worldId, secret) {
    this.entries.set(worldId, {
      secret,
      expiresAt: this.now() + this.ttlMs,
    });
  }

  /**
   * Eager invalidation. Called by docker-events.mjs on `restart` / `stop`
   * events. Returns true if an entry was present (test-observable).
   *
   * @param {string} worldId
   * @returns {boolean}
   */
  invalidate(worldId) {
    const had = this.entries.has(worldId);
    if (had) {
      this.entries.delete(worldId);
      this.log(`secret-cache: invalidated ${worldId}`);
    }
    return had;
  }

  /**
   * Drop everything. Used at shutdown for clean teardown; also useful
   * in tests.
   */
  clear() {
    this.entries.clear();
  }

  /**
   * Snapshot of cached worldIds (for /health diagnostics + tests).
   * Returns just the keys — never the secrets themselves.
   *
   * @returns {string[]}
   */
  worldIds() {
    return [...this.entries.keys()];
  }
}
