// resolver.mjs — Phase A A1 (plan-chat-spa-supersedes-control-plane).
//
// Disambiguates a single opaque :id supplied on /plan/:id between
//
//   - a planning session (planning_sessions.session_id), or
//   - a crystallized world (planning_artifacts.crystallized_world_id), or
//   - unresolvable (returns {kind:'unresolved', canonical_id:null}).
//
// Used by plan-chat-spa's useResolveId hook (Phase A A2) so the SPA's
// cold-open path can mount the correct surface without trusting the
// id-shape (sentinel `sess_*` prefix is a hint, not authority — see
// plan-chat-spa-supersedes-control-plane.md K1 SEC-1).
//
// Single SQL query (UNION ALL) so resolution costs one round-trip even
// when the id misses both tables. Bearer auth + rate-limit live in the
// HTTP handler in plan-chat-service.mjs; this helper is pool-pure for
// unit testability.

/**
 * Validate the resolver :id shape. Mirrors plan-chat-service.mjs's
 * SCOPE_ID_RE; tightened to 6-80 chars so an enumeration attacker can't
 * grind through 1-5 char shapes.
 */
export const RESOLVE_ID_RE = /^[A-Za-z0-9._-]{6,80}$/;

/**
 * Resolve an opaque id against the chunks substrate.
 *
 * @param {{ query: (sql: string, params: unknown[]) => Promise<{ rows: unknown[] }> }} pool
 *   A pg-shaped pool. Tests pass a stub; production passes pg.Pool.
 * @param {string} id The candidate id.
 * @returns {Promise<{ kind: 'session' | 'world' | 'unresolved', canonical_id: string | null }>}
 */
export async function resolveId(pool, id) {
  if (typeof id !== 'string' || !RESOLVE_ID_RE.test(id)) {
    return { kind: 'unresolved', canonical_id: null };
  }

  // Single round-trip. Both branches return the same shape
  // (kind, canonical_id) so PG can UNION ALL them without coercion.
  //
  // Session branch wins on tie (LIMIT 1 + session ordered first) — a
  // session id colliding with a world id is unlikely in practice
  // (worldId is the random docker name; sessionId is uuid-shaped),
  // but the deterministic ordering closes the K1 collision risk
  // surfaced in pass 3 review.
  const sql = `
    SELECT kind, canonical_id FROM (
      SELECT 'session' AS kind, session_id AS canonical_id, 1 AS rank
        FROM planning_sessions
        WHERE session_id = $1
      UNION ALL
      SELECT 'world' AS kind, crystallized_world_id AS canonical_id, 2 AS rank
        FROM planning_artifacts
        WHERE crystallized_world_id = $1
    ) AS resolved
    ORDER BY rank
    LIMIT 1
  `;

  const result = await pool.query(sql, [id]);
  const row = result.rows && result.rows[0];
  if (!row) return { kind: 'unresolved', canonical_id: null };

  // Pool stub-friendly: tolerate column names emerging from pg's
  // case-insensitive identifier handling.
  const kind = row.kind ?? row.KIND;
  const canonical_id = row.canonical_id ?? row.CANONICAL_ID;
  if (kind !== 'session' && kind !== 'world') {
    return { kind: 'unresolved', canonical_id: null };
  }
  if (typeof canonical_id !== 'string' || canonical_id.length === 0) {
    return { kind: 'unresolved', canonical_id: null };
  }
  return { kind, canonical_id };
}

/**
 * Token-bucket rate limiter, per bearer principal. Closes the brute-
 * force enumeration vector that bearer auth alone leaves open (an
 * authenticated caller could otherwise grind through ids at
 * line-rate).
 *
 * 60 req/min per bearer. Single-process in-memory map (one host-cp
 * per host); a multi-instance deployment would need a shared store,
 * but plan-chat-service is single-tenant single-host by design.
 */
export function createRateLimiter({
  capacity = 60,
  windowMs = 60_000,
  now = () => Date.now(),
} = {}) {
  const buckets = new Map(); // key -> { tokens, lastRefill }

  function take(key) {
    const t = now();
    let bucket = buckets.get(key);
    if (!bucket) {
      bucket = { tokens: capacity, lastRefill: t };
      buckets.set(key, bucket);
    }
    // Refill proportional to elapsed time.
    const elapsed = t - bucket.lastRefill;
    if (elapsed > 0) {
      const refill = (elapsed / windowMs) * capacity;
      bucket.tokens = Math.min(capacity, bucket.tokens + refill);
      bucket.lastRefill = t;
    }
    if (bucket.tokens < 1) {
      return { allowed: false, retryAfterMs: Math.ceil((1 - bucket.tokens) * (windowMs / capacity)) };
    }
    bucket.tokens -= 1;
    return { allowed: true, retryAfterMs: 0 };
  }

  function reset() {
    buckets.clear();
  }

  return { take, reset };
}
