// Copyright (c) 2026 defconxt. All rights reserved.
// Licensed under AGPL-3.0 — see LICENSE file for details.
// NERF is a trademark of defconxt.

/**
 * retry-client.js — LLM client with fallback retry logic for the legacy CLI layer.
 *
 * Wraps makeClient() with:
 *  - Same-model exponential backoff retries for transient errors
 *  - Fallback model switching when same-model retries are exhausted
 *  - Immediate throw on permanent errors (auth, billing)
 *
 * Error classification patterns duplicated from error-classifier.ts
 * (plain JS, no TS imports).
 *
 * @module gateway/retry-client
 */

import { makeClient } from './client.js';

const debug = process.env.NERF_DEBUG === '1'
  ? (/** @type {string} */ msg) => process.stderr.write(`[retry-client] ${msg}\n`)
  : () => {};

// ---------------------------------------------------------------------------
// Error classification (duplicated from error-classifier.ts — ~5 regexes)
// ---------------------------------------------------------------------------

const PERMANENT_RE = /auth|unauthorized|forbidden|invalid.*key|invalid.*api|billing|quota exceeded|account/i;
const RATE_LIMIT_RE = /rate.?limit|too many requests|429/i;
const NETWORK_RE = /network|ECONNRESET|ETIMEDOUT|ECONNREFUSED|socket hang up|fetch failed|connection.*reset|dns/i;
const SERVER_RE = /internal server error|500|502|503|overloaded|server_error|api_error|service.?unavailable/i;
const CONNECTION_RE = /terminated|connection.?refused|other side closed|EPIPE|network.?(?:is\s+)?unavailable|stream_exhausted/i;
const STREAM_RE = /Unexpected end of JSON|Unexpected token.*JSON|Expected double-quoted property name|SyntaxError.*JSON/i;

/**
 * Classify an error message as transient or permanent.
 *
 * @param {string} msg - Error message text
 * @returns {{ transient: boolean, retryAfterMs: number }}
 */
export function classifyErrorLegacy(msg) {
  if (!msg) return { transient: false, retryAfterMs: 0 };

  const isPermanent = PERMANENT_RE.test(msg);
  const isRateLimit = RATE_LIMIT_RE.test(msg);

  // Rate limit is transient (takes priority over permanent signals)
  if (isRateLimit) {
    const resetMatch = msg.match(/reset in (\d+)s/i);
    const delayMs = resetMatch ? Number(resetMatch[1]) * 1000 : 60_000;
    return { transient: true, retryAfterMs: delayMs };
  }

  // Permanent: auth, billing, quota
  if (isPermanent) return { transient: false, retryAfterMs: 0 };

  // Network errors
  if (NETWORK_RE.test(msg)) return { transient: true, retryAfterMs: 3_000 };

  // Server errors
  if (SERVER_RE.test(msg)) return { transient: true, retryAfterMs: 30_000 };

  // Connection errors
  if (CONNECTION_RE.test(msg)) return { transient: true, retryAfterMs: 15_000 };

  // Stream truncation
  if (STREAM_RE.test(msg)) return { transient: true, retryAfterMs: 15_000 };

  // Unknown: not transient by default
  return { transient: false, retryAfterMs: 0 };
}

/**
 * Check if an error message represents a transient error.
 * @param {string} msg
 * @returns {boolean}
 */
export function isTransientError(msg) {
  return classifyErrorLegacy(msg).transient;
}

// ---------------------------------------------------------------------------
// Retry client factory
// ---------------------------------------------------------------------------

/** Default same-model retry limit */
const SAME_MODEL_MAX_RETRIES = 2;

/**
 * Create a client with automatic retry and fallback model switching.
 *
 * Returns an object with the same shape as makeClient() ({client, model})
 * plus a switchToFallback() method for manual control.
 *
 * The client proxy intercepts messages.create() and messages.stream()
 * to add retry logic.
 *
 * @param {import('./config-validate.js').GatewayConfig} config
 * @param {string[]} fallbacks - Fallback model specs (e.g. ["openrouter/deepseek/deepseek-r1"])
 * @param {Object} [opts]
 * @param {number} [opts.maxSameModelRetries=2]
 * @param {number} [opts.baseDelayMs=1000] - Base delay for exponential backoff (0 for tests)
 * @returns {Promise<{ client: Object, model: string, switchToFallback: Function }>}
 */
export async function makeRetryClient(config, fallbacks = [], opts = {}) {
  const maxSameModelRetries = opts.maxSameModelRetries ?? SAME_MODEL_MAX_RETRIES;
  const baseDelayMs = opts.baseDelayMs ?? 1_000;

  let { client, model } = await makeClient(config);
  let fallbackIndex = 0;
  const fallbackList = [...fallbacks];

  /**
   * Switch to the next fallback model.
   * @returns {boolean} true if switched, false if no more fallbacks
   */
  async function switchToFallback() {
    if (fallbackIndex >= fallbackList.length) return false;

    const nextSpec = fallbackList[fallbackIndex++];
    debug(`Switching to fallback model: ${nextSpec}`);

    // Parse the model spec to build a config override
    const overrideConfig = { ...config, backend: 'pi-ai' };
    const parts = nextSpec.split('/');
    if (parts.length >= 2) {
      overrideConfig.pi_provider = parts[0];
      overrideConfig.pi_model = parts.slice(1).join('/');
    } else {
      overrideConfig.pi_model = nextSpec;
    }

    const result = await makeClient(overrideConfig);
    client = result.client;
    model = result.model;
    return true;
  }

  /**
   * Sleep for a given duration.
   * @param {number} ms
   */
  function sleep(ms) {
    if (ms <= 0) return Promise.resolve();
    return new Promise(resolve => setTimeout(resolve, ms));
  }

  /**
   * Wrap an async LLM call with retry and fallback logic.
   * @param {Function} fn - The underlying client call
   * @param {Array} args - Arguments to pass
   * @returns {Promise<any>}
   */
  async function withRetry(fn, args) {
    let sameModelRetries = 0;

    while (true) {
      try {
        return await fn.apply(client.messages, args);
      } catch (err) {
        const errMsg = err.message || String(err);
        const classification = classifyErrorLegacy(errMsg);

        // Permanent error: throw immediately
        if (!classification.transient) {
          debug(`Permanent error, not retrying: ${errMsg}`);
          throw err;
        }

        // Transient: try same-model retry with backoff
        if (sameModelRetries < maxSameModelRetries) {
          sameModelRetries++;
          const delay = baseDelayMs * Math.pow(2, sameModelRetries - 1);
          debug(`Transient error (attempt ${sameModelRetries}/${maxSameModelRetries}): ${errMsg}. Retrying in ${delay}ms`);
          await sleep(delay);
          continue;
        }

        // Same-model retries exhausted: try fallback
        const switched = await switchToFallback();
        if (switched) {
          debug(`Same-model retries exhausted. Switched to fallback: ${model}`);
          sameModelRetries = 0;
          continue;
        }

        // No more fallbacks: throw the original error
        debug(`All fallbacks exhausted. Throwing: ${errMsg}`);
        throw err;
      }
    }
  }

  // Build a proxied client that intercepts messages.create and messages.stream
  const proxyClient = {
    messages: {
      create(...args) {
        return withRetry(client.messages.create, args);
      },
      stream(...args) {
        // For streaming, we wrap the initial call with retry.
        // If the stream fails mid-way, the caller handles that.
        return withRetry(client.messages.stream, args);
      },
    },
  };

  return {
    get client() { return proxyClient; },
    get model() { return model; },
    switchToFallback,
  };
}
