// Bearer-secret management for plan-chat-service.mjs.
//
// Mirrors the agent-memory-service pattern from the sibling olam-agent-memory
// repo: a single 0600 file at ~/.olam/plan-chat-secret holds the bearer
// hex string. Helpers generate, read, and rotate atomically. Rotation
// writes to a tmpfile and renames; mid-rotation reads see either the old
// or new value, never a partial write.
//
// Inside the Docker container, os.homedir() → /root, but compose.yaml mounts
// ${HOME}/.olam → /data. Without an env override, the bearer would be written
// to /root/.olam/plan-chat-secret (container ephemeral layer) and lost on
// every `docker compose up --force-recreate` (i.e. every `olam upgrade`).
// OLAM_PLAN_CHAT_SECRET_PATH is set to /data/plan-chat-secret in compose.yaml
// and k8s/manifests/30-configmap.yaml so all reads/writes land in the
// bind-mounted host directory. On bare-host installs (no container) the env
// var is unset and the path falls back to ~/.olam/plan-chat-secret — no
// behaviour change. Mirrors precedent commit 5b21d1f2 (PR #440) for plan.db.

import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import crypto from 'node:crypto';
// Phase D (olam-config-store-unification): consult config.json's
// `cloud.secrets.plan-chat-secret` value before the legacy secret FILES.
// Dep-free reader (host-cp has no @olam/core dep) with container-aware /data
// path resolution — see config-reader.mjs header.
import { readConfigString, olamConfigDir } from './config-reader.mjs';

/**
 * Resolve the plan-chat-secret path: prefer <configDir>/secrets/plan-chat-secret
 * (new canonical location) over <configDir>/plan-chat-secret (legacy). The
 * config dir is container-aware (OLAM_HOME ?? container /data ?? ~/.olam) so the
 * bare-node and in-container layouts both resolve correctly. Inlined here
 * because host-cp is a pure .mjs package with no @olam/core dep.
 */
function resolvePlanChatSecretPath() {
  const olamHome = olamConfigDir();
  const newPath = path.join(olamHome, 'secrets', 'plan-chat-secret');
  if (fs.existsSync(newPath)) return newPath;
  const legacyPath = path.join(olamHome, 'plan-chat-secret');
  if (fs.existsSync(legacyPath)) return legacyPath;
  // Neither exists — return canonical so writes land in the right place.
  return newPath;
}

export const SECRET_PATH =
  process.env.OLAM_PLAN_CHAT_SECRET_PATH ?? resolvePlanChatSecretPath();
export const SECRET_DIR = path.dirname(SECRET_PATH);
const SECRET_BYTES = 32; // 64 hex chars
const SECRET_MODE = 0o600;

/**
 * Generate a fresh hex bearer (64 chars; 256 bits of entropy).
 */
export function generateSecret() {
  return crypto.randomBytes(SECRET_BYTES).toString('hex');
}

/** Read a bearer value out of a single secret FILE. Null if absent/empty. */
function readSecretFile(secretPath) {
  try {
    const value = fs.readFileSync(secretPath, 'utf8').trim();
    if (!value) return null;
    return value;
  } catch (err) {
    if (err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT') return null;
    throw err;
  }
}

/**
 * Read the plan-chat bearer. Returns null if absent. Throws on permission errors.
 *
 * Phase D precedence (mirrors resolver.ts getPlanChatSecret) for the DEFAULT
 * read path: the resolved secret FILE (SECRET_PATH — env override or the
 * canonical/legacy file) wins when present, then config.json
 * `cloud.secrets.plan-chat-secret`, then null. The file leg stays FIRST so an
 * operator's existing on-disk bearer (incl. the compose-mounted
 * OLAM_PLAN_CHAT_SECRET_PATH=/data/plan-chat-secret) is byte-for-byte
 * unchanged; config.json only fills in when no file exists yet.
 *
 * When called with an EXPLICIT secretPath (write/rotate read-backs, tests),
 * behaviour is file-only — no config.json leg — so callers that own a specific
 * path keep deterministic file semantics.
 */
export function readSecret(secretPath = SECRET_PATH) {
  const explicitPath = secretPath !== SECRET_PATH;
  const fromFile = readSecretFile(secretPath);
  if (fromFile !== null) return fromFile;
  if (explicitPath) return null; // explicit-path callers: file-only semantics
  const fromConfig = readConfigString('cloud.secrets.plan-chat-secret');
  return fromConfig; // string or null
}

/**
 * Write the bearer to disk atomically. Creates `~/.olam` if missing. Enforces
 * 0600 perms on the destination (older mode permissions on the tmpfile are
 * tightened immediately after write).
 */
export function writeSecret(value, secretPath = SECRET_PATH) {
  if (typeof value !== 'string' || value.length === 0) {
    throw new Error('plan-chat-secret: refusing to write empty bearer');
  }
  fs.mkdirSync(path.dirname(secretPath), { recursive: true, mode: 0o700 });
  const tmp = `${secretPath}.tmp-${process.pid}-${Date.now()}`;
  fs.writeFileSync(tmp, value + '\n', { mode: SECRET_MODE });
  try {
    fs.chmodSync(tmp, SECRET_MODE);
    fs.renameSync(tmp, secretPath);
  } catch (err) {
    try { fs.unlinkSync(tmp); } catch { /* swallow */ }
    throw err;
  }
}

/**
 * Read the bearer if it exists, else generate, write, and return it.
 * Idempotent across processes; first writer wins (rename is atomic).
 */
export function ensureSecret(secretPath = SECRET_PATH) {
  const existing = readSecret(secretPath);
  if (existing) return existing;
  const fresh = generateSecret();
  writeSecret(fresh, secretPath);
  return fresh;
}

/**
 * Rotate: generate a new bearer, write atomically, return the new value.
 * Callers should restart any running plan-chat-service so it re-reads.
 */
export function rotateSecret(secretPath = SECRET_PATH) {
  const fresh = generateSecret();
  writeSecret(fresh, secretPath);
  return fresh;
}

/**
 * Constant-time compare. Returns true iff both strings are non-empty and
 * byte-equal. Avoids leaking timing on bearer comparison.
 */
export function timingSafeEqual(a, b) {
  if (typeof a !== 'string' || typeof b !== 'string') return false;
  if (a.length === 0 || b.length === 0) return false;
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
}
