/**
 * @fileoverview Base European Parliament API Client
 *
 * Shared HTTP infrastructure for all EP API sub-clients:
 * LRU caching, token-bucket rate limiting, timeout/retry logic,
 * and structured audit/performance instrumentation.
 *
 * **ISMS Policies:**
 * - SC-002 (Secure Coding Standards)
 * - PE-001 (Performance Standards)
 * - AU-002 (Audit Logging and Monitoring)
 * - DP-001 (Data Protection and GDPR Compliance)
 *
 * @module clients/ep/baseClient
 * @see https://github.com/Hack23/ISMS-PUBLIC/blob/main/Secure_Development_Policy.md
 */

import { fetch } from 'undici';
import { LRUCache } from 'lru-cache';
import { RateLimiter } from '../../utils/rateLimiter.js';
import { withRetry, withTimeoutAndAbort, TimeoutError, DEFAULT_TIMEOUTS } from '../../utils/timeout.js';
import { performanceMonitor } from '../../utils/performance.js';
import { auditLogger } from '../../utils/auditLogger.js';
import { DEFAULT_RATE_LIMIT_PER_MINUTE, DEFAULT_API_URL, USER_AGENT } from '../../config.js';
import { createLinkedAbortController } from './abortUtils.js';

/** Exact hostnames that must never be used as API endpoints. */
const BLOCKED_HOSTS_EXACT = new Set(['localhost', '0.0.0.0', '::1']);

/**
 * Patterns matching private / link-local / loopback / IPv6-mapped-IPv4 address
 * prefixes. All patterns are tested against the bracket-stripped hostname.
 *
 * IPv6-mapped IPv4 patterns use the normalised hex form that the WHATWG URL
 * parser emits (e.g. `::ffff:127.0.0.1` → `::ffff:7f00:1`).
 */
const BLOCKED_HOST_PATTERNS = [
  /^fe[89ab][0-9a-f]:/i,
  /^f[cd][0-9a-f]{2}:/i,
  /^::ffff:7f/i,
  /^::ffff:a[0-9a-f]{2}:/i,
  /^::ffff:ac1[0-9a-f]:/i,
  /^::ffff:c0a8:/i,
  /^::ffff:a9fe:/i,
  /^127\./,
  /^169\.254\./,
  /^10\./,
  /^172\.(1[6-9]|2\d|3[01])\./,
  /^192\.168\./,
];

/**
 * Returns true when `host` is a loopback, link-local, or RFC-1918 address that
 * MUST NOT be used as an API endpoint (SSRF prevention).
 * @private
 */
function isBlockedHost(host: string): boolean {
  return BLOCKED_HOSTS_EXACT.has(host) || BLOCKED_HOST_PATTERNS.some(p => p.test(host));
}

/**
 * Validates an EP API base URL to prevent SSRF via environment variable or
 * constructor argument poisoning.
 *
 * Enforces HTTPS-only and blocks requests to localhost, loopback addresses
 * (127.0.0.0/8), and known internal IP ranges (link-local 169.254.x.x,
 * RFC-1918 10.x.x.x / 172.16-31.x.x / 192.168.x.x, and their IPv6-mapped
 * IPv4 equivalents), as well as IPv6 loopback/link-local/unique-local ranges.
 *
 * @param url   - The URL string to validate
 * @param label - Label used in error messages (defaults to `'EP_API_URL'`)
 * @returns The validated URL string (unchanged)
 * @throws {Error} If the URL uses a non-HTTPS scheme or targets an internal host
 *
 * @security SSRF Prevention – ISMS Policy: SC-002, NE-001
 */
export function validateApiUrl(url: string, label = 'EP_API_URL'): string {
  let parsed: URL;
  try {
    parsed = new URL(url);
  } catch {
    throw new Error(`${label} is not a valid URL`);
  }
  if (parsed.protocol !== 'https:') {
    throw new Error(`${label} must use HTTPS protocol`);
  }
  const rawHost = parsed.hostname.replace(/\.$/, '');
  const host = rawHost.startsWith('[') && rawHost.endsWith(']')
    ? rawHost.slice(1, -1)
    : rawHost;
  if (isBlockedHost(host)) {
    throw new Error(`${label} must not point to internal or loopback addresses`);
  }
  return url;
}

/** Default base URL for European Parliament Open Data Portal API v2 — derived from centralized config */
export const DEFAULT_EP_API_BASE_URL = DEFAULT_API_URL;
/**
 * Default HTTP request timeout in milliseconds — derived from the centralized
 * {@link DEFAULT_TIMEOUTS.EP_API_REQUEST_MS} constant in `timeout.ts` so there
 * is a single source of truth for the EP API request timeout value.
 */
export const DEFAULT_REQUEST_TIMEOUT_MS = DEFAULT_TIMEOUTS.EP_API_REQUEST_MS;
/** Whether automatic retry on transient failures is enabled by default */
export const DEFAULT_RETRY_ENABLED = true;
/** Default maximum number of retry attempts for failed requests */
export const DEFAULT_MAX_RETRIES = 2;
/** Default base retry delay in milliseconds (exponential backoff starting point) */
export const DEFAULT_RETRY_BASE_DELAY_MS = 1_000;
/** Default maximum retry delay in milliseconds (caps exponential backoff growth) */
export const DEFAULT_RETRY_MAX_DELAY_MS = 30_000;
/** Default cache time-to-live in milliseconds (15 minutes) */
export const DEFAULT_CACHE_TTL_MS = 900_000;
/** Default maximum number of entries in the LRU response cache */
export const DEFAULT_MAX_CACHE_SIZE = 500;
/** Default rate limit token bucket size (requests per interval) — derived from centralized config */
export const DEFAULT_RATE_LIMIT_TOKENS = DEFAULT_RATE_LIMIT_PER_MINUTE;
/** Default rate limit interval unit */
export const DEFAULT_RATE_LIMIT_INTERVAL = 'minute' as const;
/** Maximum allowed response body size in bytes (10 MiB, 10×1024×1024) to prevent memory exhaustion */
export const DEFAULT_MAX_RESPONSE_BYTES = 10_485_760;

/**
 * API Error thrown when European Parliament API requests fail.
 *
 * When thrown from {@link BaseEPClient}'s HTTP layer, the message always
 * includes the numeric HTTP status code and, when present, the HTTP reason
 * phrase (`statusText`). Some HTTP/2 responses omit `statusText`, in which
 * case the message contains only the status code — the status code is
 * therefore always surfaced in the message, never empty.
 *
 * Message format: `EP API request failed: <status>[ <statusText>]`
 *
 * @example
 * ```typescript
 * // HTTP/1.1 with a reason phrase
 * throw new APIError('EP API request failed: 404 Not Found', 404, { endpoint: '/meps/999999' });
 *
 * // HTTP/2 where statusText is empty
 * throw new APIError('EP API request failed: 404', 404, { endpoint: '/meps/999999' });
 * ```
 * @public
 */
export class APIError extends Error {
  constructor(
    message: string,
    public statusCode?: number,
    public details?: unknown
  ) {
    super(message);
    this.name = 'APIError';
  }
}

/**
 * JSON-LD response format from EP API.
 * @internal
 */
export interface JSONLDResponse<T = Record<string, unknown>>
  extends Record<string, unknown> {
  data: T[];
  '@context': unknown[];
}

/**
 * Configuration options for EP API clients.
 * @internal
 */
export interface EPClientConfig {
  /** Base URL for European Parliament API. @default DEFAULT_EP_API_BASE_URL */
  baseURL?: string;
  /** Cache time-to-live in milliseconds. @default 900000 */
  cacheTTL?: number;
  /** Maximum number of entries in LRU cache. @default 500 */
  maxCacheSize?: number;
  /** Custom rate limiter instance. */
  rateLimiter?: RateLimiter;
  /** Request timeout in milliseconds. @default DEFAULT_TIMEOUTS.EP_API_REQUEST_MS */
  timeoutMs?: number;
  /** Enable automatic retry on transient failures. @default true */
  enableRetry?: boolean;
  /** Maximum number of retry attempts. @default 2 */
  maxRetries?: number;
  /** Maximum allowed response body size in bytes. @default 10485760 (10 MiB) */
  maxResponseBytes?: number;
}

/**
 * Pre-built shared resources passed from a facade to its sub-clients
 * so they all operate on the same cache and rate-limiter bucket.
 * @internal
 */
export interface EPSharedResources {
  cache: LRUCache<string, Record<string, unknown>>;
  rateLimiter: RateLimiter;
  baseURL: string;
  timeoutMs: number;
  enableRetry: boolean;
  maxRetries: number;
  maxResponseBytes: number;
  /** Shared cache hit/miss counters so all sub-clients contribute to aggregate stats. */
  cacheCounters: { hits: number; misses: number };
}

/**
 * Base class for European Parliament API sub-clients.
 *
 * Holds the shared HTTP machinery: LRU cache, token-bucket rate limiter,
 * timeout/abort controller, and retry logic.  Sub-clients extend this class
 * and call the protected `get()` helper for all HTTP requests.
 *
 * @class BaseEPClient
 */
export class BaseEPClient {
  /** LRU cache for API responses. */
  protected readonly cache: LRUCache<string, Record<string, unknown>>;
  /** European Parliament API base URL. */
  protected readonly baseURL: string;
  /** Token bucket rate limiter. */
  protected readonly rateLimiter: RateLimiter;
  /** Request timeout in milliseconds. */
  protected readonly timeoutMs: number;
  /** Enable automatic retry on transient failures. */
  protected readonly enableRetry: boolean;
  /** Maximum number of retry attempts. */
  protected readonly maxRetries: number;
  /** Maximum allowed response body size in bytes. */
  protected readonly maxResponseBytes: number;
  /** Shared cache hit/miss counters (shared via EPSharedResources when used as sub-client). */
  private readonly cacheCounters: { hits: number; misses: number };

  /**
   * Resolves all EPClientConfig options to their final values with defaults applied.
   * Extracted to keep constructor complexity within limits.
   * @private
   */
  private static resolveConfig(config: EPClientConfig): {
    baseURL: string;
    cacheTTL: number;
    maxCacheSize: number;
    rateLimiter: RateLimiter;
    timeoutMs: number;
    enableRetry: boolean;
    maxRetries: number;
    maxResponseBytes: number;
  } {
    const rawBaseURL = config.baseURL ?? DEFAULT_EP_API_BASE_URL;
    validateApiUrl(rawBaseURL, 'config.baseURL');
    const baseURL = rawBaseURL.endsWith('/') ? rawBaseURL : `${rawBaseURL}/`;
    return {
      baseURL,
      cacheTTL: config.cacheTTL ?? DEFAULT_CACHE_TTL_MS,
      maxCacheSize: config.maxCacheSize ?? DEFAULT_MAX_CACHE_SIZE,
      rateLimiter:
        config.rateLimiter ??
        new RateLimiter({
          tokensPerInterval: DEFAULT_RATE_LIMIT_TOKENS,
          interval: DEFAULT_RATE_LIMIT_INTERVAL,
        }),
      timeoutMs: config.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
      enableRetry: config.enableRetry ?? DEFAULT_RETRY_ENABLED,
      maxRetries: config.maxRetries ?? DEFAULT_MAX_RETRIES,
      maxResponseBytes: config.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES,
    };
  }

  /**
   * Creates a BaseEPClient.
   *
   * When `shared` is provided the constructor reuses those pre-built resources
   * instead of allocating new ones; this is the mechanism used by the facade to
   * ensure all sub-clients share one cache and one rate-limiter.
   *
   * @param config - Client configuration (used when `shared` is absent)
   * @param shared - Pre-built shared resources (passed by facade to sub-clients)
   */
  constructor(config: EPClientConfig = {}, shared?: EPSharedResources) {
    if (shared !== undefined) {
      this.cache = shared.cache;
      this.rateLimiter = shared.rateLimiter;
      this.baseURL = shared.baseURL;
      this.timeoutMs = shared.timeoutMs;
      this.enableRetry = shared.enableRetry;
      this.maxRetries = shared.maxRetries;
      this.maxResponseBytes = shared.maxResponseBytes;
      this.cacheCounters = shared.cacheCounters;
    } else {
      const cfg = BaseEPClient.resolveConfig(config);
      this.baseURL = cfg.baseURL;
      this.cache = new LRUCache<string, Record<string, unknown>>({
        max: cfg.maxCacheSize,
        ttl: cfg.cacheTTL,
        allowStale: false,
        updateAgeOnGet: true,
      });
      this.rateLimiter = cfg.rateLimiter;
      this.timeoutMs = cfg.timeoutMs;
      this.enableRetry = cfg.enableRetry;
      this.maxRetries = cfg.maxRetries;
      this.maxResponseBytes = cfg.maxResponseBytes;
      this.cacheCounters = { hits: 0, misses: 0 };
    }
  }

  /**
   * Builds the full request URL from endpoint + optional params.
   * @private
   */
  private buildRequestUrl(
    endpoint: string,
    params?: Record<string, unknown>
  ): URL {
    const url = new URL(endpoint, this.baseURL);
    if (params === undefined) return url;

    for (const [key, value] of Object.entries(params)) {
      if (value === undefined || value === null) continue;
      let str: string;
      if (typeof value === 'string') {
        str = value;
      } else if (typeof value === 'number' || typeof value === 'boolean') {
        str = String(value);
      } else {
        str = JSON.stringify(value);
      }
      url.searchParams.append(key, str);
    }

    return url;
  }

  /**
   * Returns true when an error should trigger a retry.
   *
   * Retries on:
   * - 429 Too Many Requests (rate-limited by EP API; exponential backoff applied)
   * - 5xx Server Errors (transient server failures)
   * - Network / unknown errors
   *
   * Does NOT retry on 4xx client errors (except 429), nor on cancellation
   * (statusCode 0 — an aborted request must never be re-issued).
   * @private
   */
  private shouldRetryRequest(error: unknown): boolean {
    if (error instanceof TimeoutError) return false;
    if (error instanceof APIError) {
      const code = error.statusCode ?? 500;
      if (code === 0) return false;
      return code === 429 || code >= 500;
    }
    if (error instanceof SyntaxError) {
      return error.message.includes('Unexpected end of JSON input');
    }
    return true;
  }

  /**
   * Parses a byte buffer as JSON-LD.  Returns an empty JSON-LD shape for
   * zero-byte bodies (the EP API sends these for out-of-range offsets).
   * Non-empty bodies must contain valid JSON; any `SyntaxError` is allowed
   * to propagate so callers (including single-entity endpoints) fail fast
   * instead of receiving a misleading empty-list shape.
   * @private
   */
  private static parseJsonLdBytes(bytes: Uint8Array): JSONLDResponse {
    if (bytes.byteLength === 0) {
      return { data: [], '@context': [] };
    }
    return JSON.parse(new TextDecoder().decode(bytes)) as JSONLDResponse;
  }

  /**
   * Reads the response body as a stream, enforcing the response size cap.
   * Used as a fallback when the `content-length` header is absent (e.g. chunked
   * transfer encoding). Accumulates all chunks and parses the result as JSON.
   * @private
   */
  private async readStreamedBody<T>(response: Response): Promise<T> {
    const reader = (response.body as ReadableStream<Uint8Array> | null)?.getReader();
    if (!reader) {
      return response.json() as Promise<T>;
    }

    const chunks: Uint8Array[] = [];
    let totalBytes = 0;
    let readerReleased = false;
    try {
      for (;;) {
        const result = await reader.read();
        if (result.done) break;
        const { value } = result;
        totalBytes += value.byteLength;
        if (totalBytes > this.maxResponseBytes) {
          reader.releaseLock();
          readerReleased = true;
          await (response.body as ReadableStream<Uint8Array> | null)?.cancel();
          throw new APIError(
            `EP API response too large: exceeds limit of ${String(this.maxResponseBytes)} bytes`,
            413
          );
        }
        chunks.push(value);
      }
    } finally {
      if (!readerReleased) {
        reader.releaseLock();
      }
    }

    const combined = new Uint8Array(totalBytes);
    let offset = 0;
    for (const chunk of chunks) {
      combined.set(chunk, offset);
      offset += chunk.byteLength;
    }
    return BaseEPClient.parseJsonLdBytes(combined) as T;
  }

  /**
   * Treats a truly empty body as an empty JSON-LD shape; invalid JSON for
   * non-empty bodies is surfaced as an error.
   * @private
   */
  private static async parseResponseJson<T>(response: Response): Promise<T> {
    const raw = await response.text();

    if (raw.length === 0 || /^\s*$/.test(raw)) {
      return { data: [], '@context': [] } as unknown as T;
    }

    return JSON.parse(raw) as T;
  }

  /**
   * Validates the Content-Type header of an API response.
   * Throws if the response is not JSON (e.g. HTML error pages from reverse proxies).
   * Cancels the response body before throwing to allow connection reuse.
   *
   * A missing or empty Content-Type header is treated as acceptable because
   * the EP API occasionally omits it on valid JSON responses, and rejecting
   * those would cause false-negative failures.
   * @private
   */
  private static validateContentType(response: Response): void {
    const contentType = response.headers.get('content-type') ?? '';
    const mediaType = contentType.split(';', 1)[0]?.trim().toLowerCase() ?? '';
    const isJsonContentType =
      mediaType === '' ||
      mediaType === 'application/json' ||
      mediaType.endsWith('+json');

    if (!isJsonContentType) {
      void response.body?.cancel().catch(() => undefined);
      throw new APIError(
        `EP API returned unexpected content-type: ${contentType}`,
        502
      );
    }
  }

  /**
   * Empty JSON-LD shape returned for bodyless responses (HTTP 204, content-length: 0).
   * @private
   */
  private static readonly EMPTY_JSONLD = Object.freeze({ data: [], '@context': [] });

  /**
   * Parses the response body based on content-length header.
   * Returns `undefined` when content-length is absent or non-finite (caller
   * should fall through to streamed-body parsing).
   * @private
   */
  private async parseByContentLength<T>(response: Response): Promise<T | undefined> {
    const contentLength = response.headers.get('content-length');
    if (contentLength === null) return undefined;

    const bytes = Number.parseInt(contentLength, 10);
    if (!Number.isFinite(bytes) || bytes < 0) return undefined;

    if (bytes === 0) {
      await response.body?.cancel();
      return BaseEPClient.EMPTY_JSONLD as unknown as T;
    }
    if (bytes > this.maxResponseBytes) {
      await response.body?.cancel();
      throw new APIError(
        `EP API response too large: ${String(bytes)} bytes exceeds limit of ${String(this.maxResponseBytes)} bytes`,
        413
      );
    }
    return BaseEPClient.parseResponseJson<T>(response);
  }

  /**
   * Computes the effective per-request timeout, honouring an optional floor.
   * @private
   */
  private effectiveTimeoutMs(minimumTimeoutMs?: number): number {
    const validMinimum =
      minimumTimeoutMs !== undefined &&
      Number.isFinite(minimumTimeoutMs) &&
      minimumTimeoutMs > 0
        ? minimumTimeoutMs
        : undefined;
    return validMinimum !== undefined
      ? Math.max(validMinimum, this.timeoutMs)
      : this.timeoutMs;
  }

  /**
   * Issues a single HTTP fetch under the linked-abort controller and
   * processes the response (status, content-type, size, body).
   * @private
   */
  private async issueRequest<T>(
    url: URL,
    linkedSignal: AbortSignal,
  ): Promise<T> {
    const response = await fetch(url.toString(), {
      headers: {
        Accept: 'application/ld+json',
        'User-Agent': USER_AGENT,
      },
      signal: linkedSignal,
    });

    if (!response.ok) {
      throw new APIError(
        `EP API request failed: ${String(response.status)}${response.statusText ? ` ${response.statusText}` : ''}`,
        response.status
      );
    }

    if (response.status === 204) {
      await response.body?.cancel();
      return BaseEPClient.EMPTY_JSONLD as unknown as T;
    }

    BaseEPClient.validateContentType(response);

    const clResult = await this.parseByContentLength<T>(response);
    if (clResult !== undefined) return clResult;

    return this.readStreamedBody<T>(response);
  }

  /**
   * Executes the HTTP fetch with timeout/abort support and response size guard.
   * @param url - Fully resolved request URL
   * @param endpoint - Relative endpoint path (for error messages)
   * @param minimumTimeoutMs - Optional per-request minimum timeout (ms).
   *   When provided, the effective timeout is `Math.max(minimumTimeoutMs, this.timeoutMs)`,
   *   so it acts as a floor that the global timeout can still extend.
   *   Use for known slow EP API endpoints (e.g. `procedures/feed`).
   * @param externalSignal - Optional caller-provided cancellation signal.
   *   When this signal fires mid-flight, the underlying fetch is aborted and
   *   {@link toAPIError} converts the cancellation to an `APIError` with
   *   `statusCode: 0` (distinct from the timeout-driven 408 path).
   * @private
   */
  private async fetchWithTimeout<T>(
    url: URL,
    endpoint: string,
    minimumTimeoutMs?: number,
    externalSignal?: AbortSignal,
  ): Promise<T> {
    const effectiveTimeout = this.effectiveTimeoutMs(minimumTimeoutMs);
    return withTimeoutAndAbort(
      async (signal) => {
        const linked = createLinkedAbortController(externalSignal);
        // Forward the timeout-driven signal to the linked controller so that
        // either source (timeout OR external cancellation) aborts the fetch.
        const onInternalAbort = (): void => { linked.controller.abort(); };
        if (signal.aborted) {
          linked.controller.abort();
        } else {
          signal.addEventListener('abort', onInternalAbort, { once: true });
        }
        try {
          return await this.issueRequest<T>(url, linked.controller.signal);
        } finally {
          signal.removeEventListener('abort', onInternalAbort);
          linked.cleanup();
        }
      },
      effectiveTimeout,
      `EP API request to ${endpoint} timed out after ${String(effectiveTimeout)}ms`
    );
  }

  /**
   * Wraps a fetch call with the configured retry policy.
   * @param url - Fully resolved request URL
   * @param endpoint - Relative endpoint path (for error messages)
   * @param minimumTimeoutMs - Optional per-request minimum timeout (ms)
   * @param externalSignal - Optional caller-provided cancellation signal
   * @private
   */
  private async fetchWithRetry<T>(
    url: URL,
    endpoint: string,
    minimumTimeoutMs?: number,
    externalSignal?: AbortSignal,
  ): Promise<T> {
    return withRetry(
      () => this.fetchWithTimeout<T>(url, endpoint, minimumTimeoutMs, externalSignal),
      {
        maxRetries: this.enableRetry ? this.maxRetries : 0,
        retryDelayMs: DEFAULT_RETRY_BASE_DELAY_MS,
        maxDelayMs: DEFAULT_RETRY_MAX_DELAY_MS,
        shouldRetry: (error) => this.shouldRetryRequest(error),
      }
    );
  }

  /**
   * Converts a caught error to a typed {@link APIError}.
   *
   * - `TimeoutError` → `APIError(..., 408)` (the internal per-request
   *   timeout fired before the response completed).
   * - Cancellation triggered by an *external* `AbortSignal` →
   *   `APIError(..., 0, { cause: <signal.reason> })`. Status 0 is distinct
   *   from the timeout (408) and rate-limit (429) paths so callers can
   *   distinguish budget cancellations from upstream failures and skip
   *   retries via {@link shouldRetryRequest}.
   *
   * @param error - The caught error
   * @param endpoint - Relative endpoint path (for error messages)
   * @param externalSignal - The caller-provided signal (if any). When the
   *   signal is already aborted at the time of catch, the error is treated
   *   as a cancellation regardless of its concrete shape (some runtimes
   *   surface a generic `AbortError` rather than re-throwing `signal.reason`).
   * @private
   */
  private toAPIError(
    error: unknown,
    endpoint: string,
    externalSignal?: AbortSignal,
  ): APIError {
    if (externalSignal?.aborted === true) {
      return new APIError(
        `EP API request aborted: ${endpoint}`,
        0,
        { cause: externalSignal.reason as unknown }
      );
    }
    if (error instanceof TimeoutError) {
      const actualTimeout = error.timeoutMs ?? this.timeoutMs;
      return new APIError(
        `EP API request to ${endpoint} timed out after ${String(actualTimeout)}ms`,
        408,
        { timeoutMs: actualTimeout }
      );
    }
    if (error instanceof APIError) return error;
    return new APIError(
      `EP API request error: ${error instanceof Error ? error.message : 'Unknown error'}`,
      undefined,
      error
    );
  }

  /**
   * Executes a cached, rate-limited GET request to the EP API.
   *
   * @template T - Expected response type (extends `Record<string, unknown>`)
   * @param endpoint - API endpoint path (relative to `baseURL`)
   * @param params - Optional query parameters
   * @param minimumTimeoutMs - Optional per-request minimum timeout in milliseconds.
   *   When provided, the effective timeout is `Math.max(minimumTimeoutMs, this.timeoutMs)`,
   *   so the global timeout (set via `--timeout` or `EP_REQUEST_TIMEOUT_MS`) can still
   *   extend it beyond the per-endpoint minimum.
   *   Use for known slow EP API endpoints such as `procedures/feed`.
   * @param abortSignal - Optional caller-provided cancellation signal. When
   *   already aborted on entry the request is rejected immediately *before*
   *   consuming a rate-limiter token (so a cancelled fan-out does not starve
   *   the bucket). When aborted mid-flight, the underlying fetch is
   *   cancelled and the rejection is surfaced as `APIError(..., 0, { cause })`.
   * @returns Promise resolving to the typed API response
   * @throws {APIError} On HTTP errors, network failures, parse failures, or
   *   cancellation (statusCode 0). Aborts are NOT retried.
   * @protected
   */
  protected async get<T extends Record<string, unknown>>(
    endpoint: string,
    params?: Record<string, unknown>,
    minimumTimeoutMs?: number,
    abortSignal?: AbortSignal,
  ): Promise<T> {
    if (abortSignal?.aborted === true) {
      auditLogger.logError(
        'ep_api_request_aborted',
        { endpoint, phase: 'pre-request' },
        `EP API request aborted: ${endpoint}`,
      );
      throw new APIError(
        `EP API request aborted: ${endpoint}`,
        0,
        { cause: abortSignal.reason as unknown }
      );
    }

    const rlResult = await this.rateLimiter.removeTokens(1);
    if (!rlResult.allowed) {
      throw new APIError(
        `Rate limit exceeded. Retry after ${String(rlResult.retryAfterMs)}ms`,
        429,
        { retryAfterMs: rlResult.retryAfterMs, remainingTokens: rlResult.remainingTokens }
      );
    }

    const cacheKey = this.getCacheKey(endpoint, params);

    const cacheStart = performance.now();
    const cached = this.cache.get(cacheKey);
    if (cached !== undefined) {
      this.cacheCounters.hits++;
      performanceMonitor.recordDuration('ep_api_cache_hit', performance.now() - cacheStart);
      return cached as T;
    }
    this.cacheCounters.misses++;

    const url = this.buildRequestUrl(endpoint, params);
    const requestStart = performance.now();

    try {
      const data = await this.fetchWithRetry<T>(url, endpoint, minimumTimeoutMs, abortSignal);
      performanceMonitor.recordDuration('ep_api_request', performance.now() - requestStart);
      this.cache.set(cacheKey, data);
      return data;
    } catch (error: unknown) {
      performanceMonitor.recordDuration('ep_api_request_failed', performance.now() - requestStart);
      const apiError = this.toAPIError(error, endpoint, abortSignal);
      if (apiError.statusCode === 0) {
        auditLogger.logError(
          'ep_api_request_aborted',
          { endpoint, phase: 'in-flight' },
          apiError.message,
          performance.now() - requestStart,
        );
      }
      throw apiError;
    }
  }

  /**
   * Generates a deterministic cache key.
   * @param endpoint - API endpoint path
   * @param params - Optional query parameters
   * @returns JSON string used as cache key
   * @private
   */
  private getCacheKey(
    endpoint: string,
    params?: Record<string, unknown>
  ): string {
    const sortedParams = params !== undefined
      ? Object.fromEntries(Object.entries(params).sort(([a], [b]) => a.localeCompare(b)))
      : undefined;
    return JSON.stringify({ endpoint, params: sortedParams });
  }

  /**
   * Clears all entries from the LRU cache.
   * @public
   */
  clearCache(): void {
    this.cache.clear();
  }

  /**
   * Evicts a single cache entry matching the given endpoint and params.
   * Sub-clients use this when they detect that a successfully-fetched payload
   * is a content-pending sentinel that must not be served from cache for the
   * remainder of the TTL — eviction lets availability recover as soon as the
   * upstream document is enriched.
   *
   * @param endpoint - API endpoint path (same value passed to {@link get})
   * @param params - Optional query parameters (same value passed to {@link get})
   * @protected
   */
  protected evictFromCache(endpoint: string, params?: Record<string, unknown>): void {
    this.cache.delete(this.getCacheKey(endpoint, params));
  }

  /**
   * Returns cache statistics for monitoring and debugging.
   * @returns `{ size, maxSize, hitRate, hits, misses }`
   * @public
   */
  getCacheStats(): { size: number; maxSize: number; hitRate: number; hits: number; misses: number } {
    const { hits, misses } = this.cacheCounters;
    const total = hits + misses;
    return {
      size: this.cache.size,
      maxSize: this.cache.max,
      hitRate: total > 0 ? hits / total : 0,
      hits,
      misses,
    };
  }
}
