// Phase F-2-B (B4): startup-token authentication for host CP.
//
// On boot: generate a 32-byte hex token (or reuse the file if it
// exists), write to `~/.olam/host-cp.token` with mode 0600, cache in
// memory. Middleware on all non-static, non-bootstrap routes validates
// the request via:
//   - Cookie `olam_host_cp_token=<value>`
//   - OR Authorization: Bearer <value>
// Reject 401 if neither matches.
//
// Threat model (T4 mitigation):
//   - Bound to 127.0.0.1:19000 only (compose.yaml). No public exposure.
//   - Single-user-per-host assumption; multi-user is Phase G+.
//   - Token file is chmod 600 owned by the operator. Browser tabs on
//     the same machine that try to hit :19000 are blocked unless they
//     have the token (cookie or header).
//   - /api/bootstrap returns the token unauthenticated. Rationale:
//     anything local that can hit 127.0.0.1:19000 can also read
//     ~/.olam/host-cp.token (same OS-level privilege boundary). This
//     just removes a UX friction step. NOT acceptable in multi-user
//     mode (Phase G+ uses cookie-with-Secure+HttpOnly via real auth).

import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';

export class StartupToken {
  /**
   * @param {object} opts
   * @param {string} opts.tokenPath              absolute path to the token file
   * @param {() => string} [opts.generate]      defaults to 32-byte hex via crypto.randomBytes
   * @param {(message: string) => void} [opts.log]
   * @param {typeof fs} [opts.fs]               injectable for tests
   */
  constructor({ tokenPath, generate, log = console.log, fs: fsImpl = fs }) {
    if (!tokenPath || !path.isAbsolute(tokenPath)) {
      throw new Error('StartupToken: tokenPath must be an absolute path');
    }
    this.tokenPath = tokenPath;
    this.generate = generate ?? (() => crypto.randomBytes(32).toString('hex'));
    this.log = log;
    this.fs = fsImpl;
    /** @type {string | null} */
    this.token = null;
  }

  /**
   * Ensure the token exists in memory + on disk. Call once at server
   * boot before listen(). Idempotent: subsequent calls return the
   * cached value.
   *
   * Behavior:
   *   - If tokenPath exists: read it, cache, return it. (Lifecycle
   *     CLI's `olam host-cp start` may have written the token before
   *     the container starts; we must reuse the operator-visible
   *     value, not regenerate it.)
   *   - Else: generate a new token, write file with mode 0600, return.
   *
   * @returns {string}
   */
  ensure() {
    if (this.token) return this.token;
    const dir = path.dirname(this.tokenPath);
    if (!this.fs.existsSync(dir)) {
      this.fs.mkdirSync(dir, { recursive: true });
    }
    if (this.fs.existsSync(this.tokenPath)) {
      const raw = this.fs.readFileSync(this.tokenPath, 'utf-8').trim();
      if (raw.length < 16) {
        // Defensive: a too-short token is almost certainly a corrupted
        // file. Regenerate rather than accept.
        this.log(`auth: existing token at ${this.tokenPath} too short (${raw.length}); regenerating`);
        this.token = this._writeNew();
      } else {
        this.token = raw;
        this.log(`auth: reused existing token at ${this.tokenPath}`);
      }
    } else {
      this.token = this._writeNew();
    }
    return this.token;
  }

  /** @private */
  _writeNew() {
    const t = this.generate();
    this.fs.writeFileSync(this.tokenPath, t, { mode: 0o600 });
    this.log(`auth: generated new token at ${this.tokenPath} (${t.length} chars)`);
    return t;
  }

  /**
   * Check request authorization. Constant-time comparison via
   * crypto.timingSafeEqual prevents timing-side-channel leaks of the
   * token's first-byte mismatches.
   *
   * @param {import('node:http').IncomingMessage} req
   * @returns {boolean}
   */
  isAuthorized(req) {
    if (!this.token) return false;

    // Bearer header
    const authHeader = req.headers['authorization'];
    if (typeof authHeader === 'string' && authHeader.startsWith('Bearer ')) {
      const got = authHeader.slice('Bearer '.length).trim();
      if (this._compare(got)) return true;
    }

    // Cookie
    const cookieHeader = req.headers['cookie'];
    if (typeof cookieHeader === 'string') {
      const cookies = parseCookies(cookieHeader);
      const got = cookies['olam_host_cp_token'];
      if (got && this._compare(got)) return true;
    }

    return false;
  }

  /** @private */
  _compare(got) {
    if (!this.token) return false;
    if (got.length !== this.token.length) return false;
    try {
      return crypto.timingSafeEqual(Buffer.from(got), Buffer.from(this.token));
    } catch {
      return false;
    }
  }
}

/**
 * Parse a Cookie request header into an object. Handles `; ` separators
 * and `=` value-may-contain-equals (e.g., base64). Empty values + cookies
 * without `=` are tolerated.
 *
 * @param {string} header
 * @returns {Record<string, string>}
 */
export function parseCookies(header) {
  /** @type {Record<string, string>} */
  const out = {};
  for (const pair of header.split(';')) {
    const trimmed = pair.trim();
    if (!trimmed) continue;
    const eq = trimmed.indexOf('=');
    if (eq === -1) {
      out[trimmed] = '';
    } else {
      out[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim();
    }
  }
  return out;
}
