/**
 * OpenBao (MPL 2.0 fork of HashiCorp Vault) driver for the KV v2 secrets engine.
 *
 * Uses the plain HTTP API — no third-party client — so we don't pick up a
 * Vault SDK licensing headache. Endpoints touched:
 *
 *   POST   <url>/v1/<mount>/data/<path>     -- write
 *   GET    <url>/v1/<mount>/data/<path>     -- read latest
 *   DELETE <url>/v1/<mount>/metadata/<path> -- full delete (all versions)
 *   LIST   <url>/v1/<mount>/metadata/        -- for migration
 *   POST   <url>/v1/auth/<mount>/login      -- kubernetes auth
 *
 * Auth strategies (`config.auth`):
 *   - `token` (default): static token loaded once via the injected
 *     SecretRefResolver from a Secret on the plaintext backend
 *     (`tokenSecretRef = { name, key }`). Cached for the driver's lifetime —
 *     no expiry handling.
 *   - `kubernetes`: log in to OpenBao's Kubernetes auth method using the
 *     pod's projected ServiceAccount token. Vault returns a client token +
 *     lease TTL; we cache it and renew lazily on TTL expiry, with a 60s
 *     grace window. No static credentials in the database — the bao-side
 *     role binds to the mcpd ServiceAccount + namespace.
 *
 * Path layout inside OpenBao:
 *   <mount>/<pathPrefix>/<secretName>
 * `mount` and `pathPrefix` come from the backend's `config` JSON; defaults are
 * `secret` and `mcpctl/`.
 */
import { readFile } from 'node:fs/promises';
import type { SecretBackendDriver, SecretData, ExternalRef, SecretRefResolver } from './types.js';

/** Best-effort read of a response body for error messages. Empty on parse failure. */
async function bodyText(res: Response): Promise<string> {
  try {
    const text = await res.text();
    // Trim huge HTML error pages to the first 400 chars
    return text.length > 400 ? `${text.slice(0, 400)}…` : text;
  } catch {
    return '';
  }
}

export interface OpenBaoConfigBase {
  url: string;
  mount?: string;
  pathPrefix?: string;
  namespace?: string;
}

export interface OpenBaoConfigToken extends OpenBaoConfigBase {
  auth?: 'token';
  tokenSecretRef: { name: string; key: string };
}

export interface OpenBaoConfigKubernetes extends OpenBaoConfigBase {
  auth: 'kubernetes';
  /** Vault role to login as (configured server-side at `auth/<authMount>/role/<role>`). */
  role: string;
  /** Auth method mount path. Defaults to `kubernetes`. */
  authMount?: string;
  /**
   * Filesystem path to the projected ServiceAccount token. Defaults to
   * `/var/run/secrets/kubernetes.io/serviceaccount/token` (the standard
   * mount). Override only for tests or non-default projections.
   */
  serviceAccountTokenPath?: string;
}

export type OpenBaoConfig = OpenBaoConfigToken | OpenBaoConfigKubernetes;

export interface OpenBaoDriverDeps {
  /** Injected HTTP fetcher — mockable in tests. */
  fetch?: typeof globalThis.fetch;
  /** Required only for `auth: 'token'`. */
  secretRefResolver?: SecretRefResolver;
  /** Override for the SA-token reader; tests use this to supply a fake JWT. */
  readServiceAccountToken?: (path: string) => Promise<string>;
  /** Clock for cache TTL — overridable in tests. */
  now?: () => number;
}

const SA_TOKEN_DEFAULT_PATH = '/var/run/secrets/kubernetes.io/serviceaccount/token';
const TOKEN_RENEW_GRACE_MS = 60_000;

export class OpenBaoDriver implements SecretBackendDriver {
  readonly kind = 'openbao';

  private readonly url: string;
  private readonly mount: string;
  private readonly pathPrefix: string;
  private readonly namespace: string | undefined;
  private readonly authStrategy: 'token' | 'kubernetes';
  private readonly tokenSecretRef: { name: string; key: string } | undefined;
  private readonly k8sRole: string | undefined;
  private readonly k8sAuthMount: string;
  private readonly k8sTokenPath: string;
  private readonly fetchImpl: typeof globalThis.fetch;
  private readonly resolver: SecretRefResolver | undefined;
  private readonly readSaToken: (path: string) => Promise<string>;
  private readonly nowFn: () => number;

  // Cached vault token + when (epoch ms) it should be considered expired and refetched.
  private cachedToken: string | undefined;
  private cachedTokenExpiresAt: number = Number.POSITIVE_INFINITY;

  constructor(config: OpenBaoConfig, deps: OpenBaoDriverDeps) {
    this.url = config.url.replace(/\/+$/, '');
    this.mount = (config.mount ?? 'secret').replace(/^\/|\/$/g, '');
    this.pathPrefix = (config.pathPrefix ?? 'mcpctl').replace(/^\/|\/$/g, '');
    if (config.namespace !== undefined) this.namespace = config.namespace;

    this.authStrategy = config.auth ?? 'token';
    if (this.authStrategy === 'kubernetes') {
      const k = config as OpenBaoConfigKubernetes;
      if (!k.role) throw new Error('openbao kubernetes auth: `role` is required');
      this.k8sRole = k.role;
      this.k8sAuthMount = (k.authMount ?? 'kubernetes').replace(/^\/|\/$/g, '');
      this.k8sTokenPath = k.serviceAccountTokenPath ?? SA_TOKEN_DEFAULT_PATH;
    } else {
      const t = config as OpenBaoConfigToken;
      if (!t.tokenSecretRef) throw new Error('openbao token auth: `tokenSecretRef` is required');
      if (deps.secretRefResolver === undefined) {
        throw new Error('openbao token auth: secretRefResolver dependency is required');
      }
      this.tokenSecretRef = t.tokenSecretRef;
      this.k8sAuthMount = 'kubernetes';
      this.k8sTokenPath = SA_TOKEN_DEFAULT_PATH;
    }

    this.fetchImpl = deps.fetch ?? globalThis.fetch;
    if (deps.secretRefResolver !== undefined) this.resolver = deps.secretRefResolver;
    this.readSaToken = deps.readServiceAccountToken ?? ((path) => readFile(path, 'utf-8').then((s) => s.trim()));
    this.nowFn = deps.now ?? (() => Date.now());
  }

  async read(input: { name: string; externalRef: ExternalRef; data: SecretData }): Promise<SecretData> {
    const path = this.pathFor(input.name);
    const res = await this.request('GET', `/v1/${this.mount}/data/${path}`);
    if (res.status === 404) {
      throw new Error(`OpenBao: secret '${input.name}' not found at ${path}`);
    }
    if (!res.ok) throw new Error(`OpenBao read ${path}: HTTP ${res.status} ${await bodyText(res)}`);
    const body = await res.json() as { data?: { data?: SecretData } };
    return body.data?.data ?? {};
  }

  async write(input: { name: string; data: SecretData }): Promise<{ externalRef: ExternalRef; storedData: SecretData }> {
    const path = this.pathFor(input.name);
    const res = await this.request('POST', `/v1/${this.mount}/data/${path}`, { data: input.data });
    if (!res.ok) throw new Error(`OpenBao write ${path}: HTTP ${res.status} ${await bodyText(res)}`);
    return { externalRef: `${this.mount}/${path}`, storedData: {} };
  }

  async delete(input: { name: string; externalRef: ExternalRef }): Promise<void> {
    const path = this.pathFor(input.name);
    const res = await this.request('DELETE', `/v1/${this.mount}/metadata/${path}`);
    if (!res.ok && res.status !== 404) {
      throw new Error(`OpenBao delete ${path}: HTTP ${res.status} ${await bodyText(res)}`);
    }
  }

  async list(): Promise<Array<{ name: string; externalRef: ExternalRef }>> {
    const listPath = this.pathPrefix === '' ? '' : `${this.pathPrefix}/`;
    const res = await this.request('LIST', `/v1/${this.mount}/metadata/${listPath}`);
    if (res.status === 404) return [];
    if (!res.ok) throw new Error(`OpenBao list: HTTP ${res.status} ${await bodyText(res)}`);
    const body = await res.json() as { data?: { keys?: string[] } };
    const keys = body.data?.keys ?? [];
    return keys
      .filter((k) => !k.endsWith('/'))
      .map((k) => ({
        name: k,
        externalRef: `${this.mount}/${this.pathPrefix === '' ? '' : `${this.pathPrefix}/`}${k}`,
      }));
  }

  async healthCheck(): Promise<{ ok: boolean; detail?: string }> {
    try {
      const res = await this.request('GET', '/v1/sys/health');
      return { ok: res.ok, detail: `HTTP ${res.status}` };
    } catch (err) {
      return { ok: false, detail: err instanceof Error ? err.message : String(err) };
    }
  }

  private pathFor(name: string): string {
    const safe = encodeURIComponent(name);
    return this.pathPrefix === '' ? safe : `${this.pathPrefix}/${safe}`;
  }

  private async getToken(): Promise<string> {
    if (this.cachedToken !== undefined && this.nowFn() < this.cachedTokenExpiresAt - TOKEN_RENEW_GRACE_MS) {
      return this.cachedToken;
    }

    if (this.authStrategy === 'token') {
      // Static token from a plaintext Secret. No TTL — cache for the driver's lifetime.
      const token = await this.resolver!.resolve(this.tokenSecretRef!.name, this.tokenSecretRef!.key);
      this.cachedToken = token;
      this.cachedTokenExpiresAt = Number.POSITIVE_INFINITY;
      return token;
    }

    // Kubernetes auth: read the projected SA JWT, exchange it for a Vault token.
    const jwt = await this.readSaToken(this.k8sTokenPath);
    const loginUrl = `${this.url}/v1/auth/${this.k8sAuthMount}/login`;
    const headers: Record<string, string> = { 'Content-Type': 'application/json' };
    if (this.namespace !== undefined) headers['X-Vault-Namespace'] = this.namespace;
    const res = await this.fetchImpl(loginUrl, {
      method: 'POST',
      headers,
      body: JSON.stringify({ role: this.k8sRole, jwt }),
    });
    if (!res.ok) {
      const text = await res.text().catch(() => '');
      throw new Error(`OpenBao kubernetes login (role=${this.k8sRole!}): HTTP ${String(res.status)} ${text}`);
    }
    const body = await res.json() as { auth?: { client_token?: string; lease_duration?: number } };
    const clientToken = body.auth?.client_token;
    if (clientToken === undefined || clientToken === '') {
      throw new Error(`OpenBao kubernetes login: response missing auth.client_token`);
    }
    // lease_duration is seconds; 0 means token doesn't expire (rare for k8s auth).
    const leaseSec = body.auth?.lease_duration ?? 0;
    this.cachedToken = clientToken;
    this.cachedTokenExpiresAt = leaseSec > 0
      ? this.nowFn() + leaseSec * 1000
      : Number.POSITIVE_INFINITY;
    return clientToken;
  }

  private async request(method: string, path: string, body?: unknown): Promise<Response> {
    const token = await this.getToken();
    const headers: Record<string, string> = { 'X-Vault-Token': token };
    if (this.namespace !== undefined) headers['X-Vault-Namespace'] = this.namespace;
    if (body !== undefined) headers['Content-Type'] = 'application/json';

    const init: RequestInit = { method, headers };
    if (body !== undefined) init.body = JSON.stringify(body);

    const res = await this.fetchImpl(`${this.url}${path}`, init);

    // If the cached token expired between cache-check and request (k8s clock
    // skew, server-side revocation, etc.), purge cache and retry once.
    if (res.status === 403 && this.cachedToken !== undefined) {
      this.cachedToken = undefined;
      this.cachedTokenExpiresAt = 0;
      const fresh = await this.getToken();
      const retryHeaders: Record<string, string> = { 'X-Vault-Token': fresh };
      if (this.namespace !== undefined) retryHeaders['X-Vault-Namespace'] = this.namespace;
      if (body !== undefined) retryHeaders['Content-Type'] = 'application/json';
      const retryInit: RequestInit = { method, headers: retryHeaders };
      if (body !== undefined) retryInit.body = JSON.stringify(body);
      return this.fetchImpl(`${this.url}${path}`, retryInit);
    }
    return res;
  }
}
