/**
 * API key store — credentials for services/agents acting on the user's behalf
 * (MCP operations, automation), distinct from the human admin password.
 *
 * Keys are stored HASHED (sha256) in <dataDir>/api-keys.json — the plaintext is
 * shown to the user exactly once at creation and is never recoverable from disk.
 * Verification is constant-time. Minting/revoking is gated by the admin password
 * at the route layer.
 */

import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { randomBytes, createHash, timingSafeEqual } from 'node:crypto';
import { getDataDir } from './dirs';

const KEY_PREFIX = 'forge_sk_';

export interface ApiKeyRecord {
  id: string;
  name: string;
  prefix: string;        // non-secret display prefix, e.g. "forge_sk_a1b2c3…"
  hash: string;          // sha256(full key)
  createdAt: string;
  lastUsedAt: string | null;
}

/** Public view — never includes the hash. */
export type ApiKeyPublic = Omit<ApiKeyRecord, 'hash'>;

function file(): string {
  return join(getDataDir(), 'api-keys.json');
}

// Parsed-records cache keyed by file mtime. verifyApiKey() runs on every /api/mcp
// POST, so re-reading + JSON.parse per call is wasted I/O. Any write (this process
// via persist(), or another worker) changes mtime, so the cache self-invalidates.
let _cache: { mtimeMs: number; keys: ApiKeyRecord[] } | null = null;

function load(): ApiKeyRecord[] {
  try {
    const fp = file();
    if (!existsSync(fp)) { _cache = null; return []; }
    const mtimeMs = statSync(fp).mtimeMs;
    if (_cache && _cache.mtimeMs === mtimeMs) return _cache.keys;
    const parsed = JSON.parse(readFileSync(fp, 'utf-8'));
    const keys = Array.isArray(parsed) ? parsed : [];
    _cache = { mtimeMs, keys };
    return keys;
  } catch {
    return [];
  }
}

function persist(keys: ApiKeyRecord[]): void {
  const dir = getDataDir();
  mkdirSync(dir, { recursive: true });
  // Atomic write: serialize to a fresh 0600 temp file in the same dir, then
  // rename over the target. A crash or a concurrent reader never sees a
  // half-written api-keys.json, and the rename is atomic on the same filesystem.
  const tmp = join(dir, `.api-keys.${randomBytes(6).toString('hex')}.tmp`);
  try {
    writeFileSync(tmp, JSON.stringify(keys, null, 2), { mode: 0o600 });
    renameSync(tmp, file());
    _cache = null; // invalidate; next load() re-reads at the new mtime
  } catch (e) {
    try { rmSync(tmp, { force: true }); } catch { /* leave nothing behind */ }
    throw e;
  }
}

function sha256(s: string): string {
  return createHash('sha256').update(s).digest('hex');
}

const toPublic = (r: ApiKeyRecord): ApiKeyPublic => ({
  id: r.id, name: r.name, prefix: r.prefix, createdAt: r.createdAt, lastUsedAt: r.lastUsedAt,
});

/** Create a new key. Returns the plaintext ONCE (caller must show + discard).
 *
 *  createApiKey/revokeApiKey load()→persist() the whole array, loading immediately
 *  before the write (same minimal in-process window as touchLastUsed). They do NOT
 *  take a file lock, so two WORKERS mutating concurrently still last-writer-win and
 *  one change is dropped. Acceptable here because minting/revoking is admin-gated and
 *  rare; if that stops holding, wrap the read-modify-write below in a short lock. */
export function createApiKey(name: string): { key: string; record: ApiKeyPublic } {
  const key = `${KEY_PREFIX}${randomBytes(24).toString('base64url')}`;
  const record: ApiKeyRecord = {
    id: randomBytes(6).toString('hex'),
    name: name.trim() || 'agent key',
    prefix: `${key.slice(0, KEY_PREFIX.length + 6)}…`,
    hash: sha256(key),
    createdAt: new Date().toISOString(),
    lastUsedAt: null,
  };
  const keys = load();
  keys.push(record);
  persist(keys);
  return { key, record: toPublic(record) };
}

export function listApiKeys(): ApiKeyPublic[] {
  return load().map(toPublic);
}

export function revokeApiKey(id: string): boolean {
  const keys = load();
  const idx = keys.findIndex((k) => k.id === id);
  if (idx < 0) return false;
  keys.splice(idx, 1);
  persist(keys);
  return true;
}

/** Bump one key's lastUsedAt by id, re-loading the freshest file immediately
 *  before the write. verifyApiKey must NOT persist the array it read before the
 *  hash compare: a concurrent createApiKey/revokeApiKey in another worker would
 *  be clobbered (last-writer-wins on the whole file). Re-loading here shrinks the
 *  window to the load→write span and, if the key was revoked meanwhile, leaves it
 *  deleted (find returns undefined → no write) instead of resurrecting it. */
function touchLastUsed(id: string, whenISO: string): void {
  const keys = load();
  const k = keys.find((r) => r.id === id);
  if (!k) return;
  k.lastUsedAt = whenISO;
  persist(keys);
}

/** Constant-time verification of a presented key. Bumps lastUsedAt (throttled). */
export function verifyApiKey(presented: string | null | undefined): boolean {
  if (!presented || !presented.startsWith(KEY_PREFIX)) return false;
  const presentedHash = Buffer.from(sha256(presented));
  const keys = load();
  let matched: ApiKeyRecord | undefined;
  for (const k of keys) {
    const stored = Buffer.from(k.hash);
    if (stored.length === presentedHash.length && timingSafeEqual(stored, presentedHash)) {
      matched = k;
      break;
    }
  }
  if (!matched) return false;
  // Throttle the lastUsedAt write to at most once a minute to avoid disk churn;
  // write through a fresh re-load (touchLastUsed) so a concurrent mint/revoke
  // isn't lost.
  const now = Date.now();
  // A corrupt (unparseable) lastUsedAt yields NaN; treat it as stale so the bad
  // value self-heals on the next use instead of freezing forever.
  const last = matched.lastUsedAt ? new Date(matched.lastUsedAt).getTime() : NaN;
  if (!Number.isFinite(last) || now - last > 60_000) {
    // Bookkeeping only — a failed lastUsedAt write (e.g. api-keys.json owned by a
    // different user after a local `forge mcp install`) must never turn a valid
    // key into an auth error.
    try { touchLastUsed(matched.id, new Date(now).toISOString()); } catch { /* ignore */ }
  }
  return true;
}
