// Phase F-2-B (B5): SSE concurrent-connection gate + path detection.
//
// Background. Each open SSE proxy holds:
//   - A Node http.ClientRequest to the per-world CP (one fd)
//   - The browser's incoming socket (one fd)
// Plus the Node event loop wakes on every chunk. With N worlds × M tabs
// × Sse-per-tab, the FD budget grows linearly. P3 budgets ≤100 concurrent
// SSE proxies; P4 caps at 50 + returns 503 with Retry-After: 30 above
// that. Below the cap there's no impact.
//
// Cap semantics:
//   - increment() returns true if we're allowed to open; false → reject.
//   - decrement() is idempotent + fire-once via the FiredFlag pattern
//     because Node emits both 'close' and 'finish' on a normal stream
//     end. Without idempotency the counter would underflow.
//
// SSE detection is path-based (cheap; runs before opening upstream).
// Two patterns are SSE today:
//   /api/stream                    — per-world CP's existing SSE feed
//   /api/world/<id>/bootstrap-progress — placeholder for B7's UI strip
//                                       (per-world CP route lands later)

const SSE_PATH_PATTERNS = [
  /\/api\/stream(?:\/|$|\?)/,
  /\/bootstrap-progress(?:\/|$|\?)/,
  /\/api\/logs(?:\/|$|\?)/,
];

/**
 * Detect whether an upstream subPath represents an SSE stream. The
 * subPath is the value emitted by `parseProxyPath()` — i.e., everything
 * AFTER `/api/world/<id>`. So we match on the inner route, not the
 * `/api/world/<id>` prefix.
 *
 * @param {string} subPath
 * @returns {boolean}
 */
export function isSsePath(subPath) {
  return SSE_PATH_PATTERNS.some((re) => re.test(subPath));
}

export class SseGate {
  /**
   * @param {object} opts
   * @param {number} [opts.maxConcurrent]    default 50 (P4 cap)
   * @param {(message: string) => void} [opts.log]
   */
  constructor({ maxConcurrent = 50, log = console.log } = {}) {
    if (maxConcurrent < 1) {
      throw new Error('SseGate: maxConcurrent must be >= 1');
    }
    this.maxConcurrent = maxConcurrent;
    this.active = 0;
    this.log = log;
  }

  /**
   * Try to acquire a slot. If at cap, returns null + writes a 503 to
   * res. Caller MUST check the return value.
   *
   * @param {import('node:http').ServerResponse} res
   * @returns {{ release: () => void } | null}
   */
  acquire(res) {
    if (this.active >= this.maxConcurrent) {
      res.writeHead(503, {
        'Content-Type': 'application/json; charset=utf-8',
        'Retry-After': '30',
      });
      res.end(JSON.stringify({
        error: 'sse_capacity_reached',
        active: this.active,
        cap: this.maxConcurrent,
        retry_after_sec: 30,
        message: 'host CP has reached the SSE concurrent-connection cap. Retry after the indicated delay or close idle SPA tabs.',
      }));
      this.log(`sse-gate: 503 — cap reached (active=${this.active}, cap=${this.maxConcurrent})`);
      return null;
    }
    this.active++;
    let released = false;
    const release = () => {
      if (released) return;
      released = true;
      this.active--;
    };
    return { release };
  }

  /** Diagnostics for /health. */
  stats() {
    return {
      active: this.active,
      cap: this.maxConcurrent,
    };
  }
}

/**
 * Wire SSE-gate teardown to a ServerResponse's lifecycle. Node's
 * http response emits 'close' (client disconnected) AND 'finish'
 * (response.end() called) on different code paths. We want decrement
 * exactly once per acquire(), regardless of which event fires first.
 *
 * The release closure is already idempotent (released flag). Wiring
 * both events covers every termination path:
 *   - browser closes tab     → 'close' on res
 *   - upstream EOF + res.end → 'finish' on res
 *   - error in proxy         → 'close' on res (Node fires close on errors)
 *
 * @param {import('node:http').ServerResponse} res
 * @param {() => void} release
 */
export function wireRelease(res, release) {
  res.on('close', release);
  res.on('finish', release);
}
