/**
 * Thin HTTP wrappers around the OpenBao / Vault REST API.
 *
 * Used by:
 *   - the CLI wizard (admin-token-scoped calls: enable engine, write policy,
 *     create role, mint first token, smoke-test write/read)
 *   - the mcpd rotator (caller-token-scoped calls: mint successor, revoke
 *     predecessor, lookup-self for verification)
 *
 * Plain `fetch()` — no SDK dep, consistent with the OpenBaoDriver. All
 * functions accept an injectable `fetch` in a deps arg so tests can mock.
 */

export interface VaultDeps {
  fetch?: typeof globalThis.fetch;
  /** Optional Vault Enterprise namespace (X-Vault-Namespace header). */
  namespace?: string;
}

export interface VaultHealth {
  initialized: boolean;
  sealed: boolean;
  standby: boolean;
  version: string;
}

export interface MintedToken {
  /** The raw client token (treat as secret — surface to user only in wizard transcript). */
  clientToken: string;
  /** Accessor used to revoke without knowing the token value. */
  accessor: string;
  /** TTL in seconds reported by Vault. For periodic tokens this is the period. */
  leaseDuration: number;
  /** True iff Vault said the token is renewable. The wizard bails if false. */
  renewable: boolean;
  policies: string[];
}

function baseUrl(url: string): string {
  return url.replace(/\/+$/, '');
}

function headers(token: string | undefined, ns: string | undefined, withBody: boolean): Record<string, string> {
  const h: Record<string, string> = {};
  if (token !== undefined && token !== '') h['X-Vault-Token'] = token;
  if (ns !== undefined && ns !== '') h['X-Vault-Namespace'] = ns;
  if (withBody) h['Content-Type'] = 'application/json';
  return h;
}

async function readError(res: Response): Promise<string> {
  const text = await res.text().catch(() => '');
  try {
    const parsed = JSON.parse(text) as { errors?: string[] };
    if (Array.isArray(parsed.errors) && parsed.errors.length > 0) return parsed.errors.join('; ');
  } catch { /* fall through */ }
  return text;
}

/** GET /v1/sys/health. Returns a normalised shape; throws on network error. */
export async function verifyHealth(
  url: string,
  adminToken: string,
  deps: VaultDeps = {},
): Promise<VaultHealth> {
  const fetchImpl = deps.fetch ?? globalThis.fetch;
  // /sys/health returns 200/429/472/473/501/503 depending on state. All are
  // valid responses to parse; anything else is a hard error.
  const res = await fetchImpl(`${baseUrl(url)}/v1/sys/health`, {
    method: 'GET',
    headers: headers(adminToken, deps.namespace, false),
  });
  if (res.status >= 500 && res.status !== 501 && res.status !== 503) {
    throw new Error(`vault health: HTTP ${String(res.status)} ${await readError(res)}`);
  }
  const body = await res.json() as Partial<VaultHealth> & { version?: string };
  return {
    initialized: body.initialized ?? false,
    sealed: body.sealed ?? false,
    standby: body.standby ?? false,
    version: body.version ?? 'unknown',
  };
}

/**
 * Enable KV v2 at `mount` if not already mounted there. Idempotent.
 * Returns `true` if a mount was created, `false` if it was already present.
 */
export async function ensureKvV2(
  url: string,
  adminToken: string,
  mount: string,
  deps: VaultDeps = {},
): Promise<boolean> {
  const fetchImpl = deps.fetch ?? globalThis.fetch;
  const clean = mount.replace(/^\/|\/$/g, '');
  // Check existing mounts
  const listRes = await fetchImpl(`${baseUrl(url)}/v1/sys/mounts`, {
    method: 'GET',
    headers: headers(adminToken, deps.namespace, false),
  });
  if (!listRes.ok) {
    throw new Error(`vault list mounts: HTTP ${String(listRes.status)} ${await readError(listRes)}`);
  }
  const mounts = await listRes.json() as Record<string, { type?: string; options?: { version?: string } }>;
  const key = `${clean}/`;
  const existing = mounts[key];
  if (existing !== undefined) {
    if (existing.type !== 'kv') {
      throw new Error(`mount at '${clean}/' exists but is type '${String(existing.type)}', not kv`);
    }
    // Accept either v2 or unspecified (older Vault treats kv without options as v1 — surface a clear error).
    if (existing.options?.version !== '2') {
      throw new Error(`mount '${clean}/' is KV but not v2 (version='${String(existing.options?.version)}'). Use a different mount.`);
    }
    return false;
  }
  // Mount it
  const mountRes = await fetchImpl(`${baseUrl(url)}/v1/sys/mounts/${clean}`, {
    method: 'POST',
    headers: headers(adminToken, deps.namespace, true),
    body: JSON.stringify({ type: 'kv', options: { version: '2' } }),
  });
  if (!mountRes.ok) {
    throw new Error(`vault mount ${clean}: HTTP ${String(mountRes.status)} ${await readError(mountRes)}`);
  }
  return true;
}

/** PUT /v1/sys/policies/acl/<name> with the provided HCL. Idempotent. */
export async function writePolicy(
  url: string,
  adminToken: string,
  name: string,
  hcl: string,
  deps: VaultDeps = {},
): Promise<void> {
  const fetchImpl = deps.fetch ?? globalThis.fetch;
  const res = await fetchImpl(`${baseUrl(url)}/v1/sys/policies/acl/${encodeURIComponent(name)}`, {
    method: 'PUT',
    headers: headers(adminToken, deps.namespace, true),
    body: JSON.stringify({ policy: hcl }),
  });
  if (!res.ok) {
    throw new Error(`vault write policy ${name}: HTTP ${String(res.status)} ${await readError(res)}`);
  }
}

export interface TokenRoleConfig {
  allowedPolicies: string[];
  /** Seconds. For `period`, pass 0 to omit. */
  period?: number;
  renewable?: boolean;
  orphan?: boolean;
}

/** POST /v1/auth/token/roles/<role>. Idempotent: upserts the role config. */
export async function ensureTokenRole(
  url: string,
  adminToken: string,
  role: string,
  cfg: TokenRoleConfig,
  deps: VaultDeps = {},
): Promise<void> {
  const fetchImpl = deps.fetch ?? globalThis.fetch;
  const body: Record<string, unknown> = {
    allowed_policies: cfg.allowedPolicies,
    renewable: cfg.renewable ?? true,
    orphan: cfg.orphan ?? false,
  };
  if (cfg.period !== undefined && cfg.period > 0) body.period = cfg.period;
  const res = await fetchImpl(`${baseUrl(url)}/v1/auth/token/roles/${encodeURIComponent(role)}`, {
    method: 'POST',
    headers: headers(adminToken, deps.namespace, true),
    body: JSON.stringify(body),
  });
  if (!res.ok) {
    throw new Error(`vault ensure role ${role}: HTTP ${String(res.status)} ${await readError(res)}`);
  }
}

/**
 * POST /v1/auth/token/create/<role>. Caller must hold a token with
 * `create` on that path (admin, or a previously-minted successor).
 */
export async function mintRoleToken(
  url: string,
  callerToken: string,
  role: string,
  deps: VaultDeps = {},
): Promise<MintedToken> {
  const fetchImpl = deps.fetch ?? globalThis.fetch;
  const res = await fetchImpl(`${baseUrl(url)}/v1/auth/token/create/${encodeURIComponent(role)}`, {
    method: 'POST',
    headers: headers(callerToken, deps.namespace, true),
    body: JSON.stringify({}),
  });
  if (!res.ok) {
    throw new Error(`vault mint role-token ${role}: HTTP ${String(res.status)} ${await readError(res)}`);
  }
  const body = await res.json() as {
    auth?: {
      client_token?: string;
      accessor?: string;
      lease_duration?: number;
      renewable?: boolean;
      policies?: string[];
    };
  };
  const a = body.auth;
  if (a?.client_token === undefined || a?.accessor === undefined) {
    throw new Error(`vault mint role-token ${role}: response missing auth.client_token or accessor`);
  }
  return {
    clientToken: a.client_token,
    accessor: a.accessor,
    leaseDuration: a.lease_duration ?? 0,
    renewable: a.renewable ?? false,
    policies: a.policies ?? [],
  };
}

/** POST /v1/auth/token/revoke-accessor. Idempotent — revoking an unknown accessor returns 204. */
export async function revokeAccessor(
  url: string,
  callerToken: string,
  accessor: string,
  deps: VaultDeps = {},
): Promise<void> {
  const fetchImpl = deps.fetch ?? globalThis.fetch;
  const res = await fetchImpl(`${baseUrl(url)}/v1/auth/token/revoke-accessor`, {
    method: 'POST',
    headers: headers(callerToken, deps.namespace, true),
    body: JSON.stringify({ accessor }),
  });
  // 204 = revoked, 400 = already revoked/unknown (treat as noop)
  if (!res.ok && res.status !== 400) {
    throw new Error(`vault revoke-accessor: HTTP ${String(res.status)} ${await readError(res)}`);
  }
}

/** GET /v1/auth/token/lookup-self. Returns accessor + remaining TTL on the caller's token. */
export async function lookupSelf(
  url: string,
  callerToken: string,
  deps: VaultDeps = {},
): Promise<{ accessor: string; ttl: number; policies: string[] }> {
  const fetchImpl = deps.fetch ?? globalThis.fetch;
  const res = await fetchImpl(`${baseUrl(url)}/v1/auth/token/lookup-self`, {
    method: 'GET',
    headers: headers(callerToken, deps.namespace, false),
  });
  if (!res.ok) {
    throw new Error(`vault lookup-self: HTTP ${String(res.status)} ${await readError(res)}`);
  }
  const body = await res.json() as { data?: { accessor?: string; ttl?: number; policies?: string[] } };
  return {
    accessor: body.data?.accessor ?? '',
    ttl: body.data?.ttl ?? 0,
    policies: body.data?.policies ?? [],
  };
}

/**
 * Round-trip smoke test: write a marker secret, read it back, delete metadata.
 * Used by the wizard to prove the minted token's policy is wired correctly
 * before reporting success to the user.
 */
export async function testWriteReadDelete(
  url: string,
  callerToken: string,
  mount: string,
  relPath: string,
  deps: VaultDeps = {},
): Promise<void> {
  const fetchImpl = deps.fetch ?? globalThis.fetch;
  const dataUrl = `${baseUrl(url)}/v1/${mount}/data/${relPath.replace(/^\//, '')}`;
  const metaUrl = `${baseUrl(url)}/v1/${mount}/metadata/${relPath.replace(/^\//, '')}`;

  const writeRes = await fetchImpl(dataUrl, {
    method: 'POST',
    headers: headers(callerToken, deps.namespace, true),
    body: JSON.stringify({ data: { marker: 'mcpctl-smoke', at: new Date().toISOString() } }),
  });
  if (!writeRes.ok) {
    throw new Error(`vault smoke write ${relPath}: HTTP ${String(writeRes.status)} ${await readError(writeRes)}`);
  }

  const readRes = await fetchImpl(dataUrl, {
    method: 'GET',
    headers: headers(callerToken, deps.namespace, false),
  });
  if (!readRes.ok) {
    throw new Error(`vault smoke read ${relPath}: HTTP ${String(readRes.status)} ${await readError(readRes)}`);
  }
  const readBody = await readRes.json() as { data?: { data?: { marker?: string } } };
  if (readBody.data?.data?.marker !== 'mcpctl-smoke') {
    throw new Error(`vault smoke: read-back didn't match written marker`);
  }

  const delRes = await fetchImpl(metaUrl, {
    method: 'DELETE',
    headers: headers(callerToken, deps.namespace, false),
  });
  if (!delRes.ok && delRes.status !== 404) {
    throw new Error(`vault smoke delete ${relPath}: HTTP ${String(delRes.status)} ${await readError(delRes)}`);
  }
}
