'use strict';

/**
 * Chat-call model fallback wrapper — Item C of provider-routing deep-dive.
 *
 * `chat()` itself handles transient failures via llm/retry.js (429, 5xx,
 * etc.). On NON-transient failure (refused, empty content, garbage
 * output, hard auth fail), the chat call propagates the error. This
 * wrapper sits ABOVE chat() and walks a model_candidates list in that
 * case, mirroring skill-fallback.js for skills.
 *
 * Use case: Haiku refuses or returns empty → automatically retry with
 * Sonnet. The user gets a real answer instead of an empty reply.
 *
 * Trigger conditions for fallback (non-transient):
 *   - chat() throws AND error message doesn't match transient pattern
 *   - chat() returns reply='' (empty content) AND not silentReply
 *   - chat() returns garbage (e.g. just "Summary:" — already filtered
 *     by ctm-tooltip work, but cheap to double-check)
 *
 * Resolution priority for candidates:
 *   1. opts.model_candidates (caller passes explicit list)
 *   2. opts.model alone (single-element fallback)
 *   3. Empty list — no fallback, behaves identically to bare chat()
 *
 * Wraps chat() — does NOT replace it. Existing callers stay unchanged;
 * opt in by calling chatWithFallback instead.
 */

const { ROUTING_REASONS, buildRoutingDecision } = require('./skills/routing-reason-codes');

let _recoveryLogger;
function _getLogger() {
  if (_recoveryLogger === undefined) {
    try { _recoveryLogger = require('./lib/recovery-logger'); }
    catch { _recoveryLogger = null; }
  }
  return _recoveryLogger;
}

const TRANSIENT_RE = /(429|rate.?limit|5\d\d|timeout|ECONNRESET|ECONNREFUSED|ETIMEDOUT|fetch failed|aborted)/i;

function _isTransient(err) {
  if (!err) return false;
  const msg = err.message || String(err);
  return TRANSIENT_RE.test(msg);
}

function _isEmptyOrGarbage(result) {
  if (!result) return true;
  // silentReply is intentional (BOOT_OK / NO_REPLY / etc.) — not garbage
  if (result.silentReply) return false;
  const text = (result.reply || '').trim();
  if (!text) return true;
  // 1-3 char replies that aren't silent-reply are likely refused/garbage
  if (text.length <= 3) return true;
  return false;
}

/**
 * Resolve the fallback candidates from opts. Returns an array; empty
 * means "no fallback, single attempt with caller's model."
 */
function _resolveCandidates(opts = {}) {
  if (Array.isArray(opts.model_candidates) && opts.model_candidates.length > 0) {
    return opts.model_candidates.slice();
  }
  if (opts.model) return [opts.model];
  return [];
}

/**
 * Wrap a chat() call with model-candidate fallback. Same signature +
 * return shape as the underlying chat(); adds `attempts` field with
 * the per-candidate trace.
 *
 * @param {Function} chatFn - The underlying chat() function
 * @param {string} message
 * @param {object} [opts]
 * @param {string[]} [opts.model_candidates] - Fallback list
 * @returns {Promise<object>} chat() result + { attempts: [...] }
 */
async function chatWithFallback(chatFn, message, opts = {}) {
  const candidates = _resolveCandidates(opts);
  const attempts = [];

  // No candidates → behave identically to bare chat()
  if (candidates.length === 0) {
    const result = await chatFn(message, opts);
    return { ...result, attempts: [] };
  }

  let lastError = null;
  for (let i = 0; i < candidates.length; i++) {
    const model = candidates[i];
    const isFirst = i === 0;
    const reason = isFirst ? (opts._reason || ROUTING_REASONS.PINNED) : ROUTING_REASONS.FALLBACK;
    const startedAt = Date.now();

    try {
      const result = await chatFn(message, {
        ...opts,
        model,
        model_candidates: undefined,
        _fallbackRetry: i,
      });
      const durationMs = Date.now() - startedAt;
      const isGarbage = _isEmptyOrGarbage(result);
      attempts.push({
        model,
        reason,
        durationMs,
        success: !isGarbage,
        empty: isGarbage,
      });

      if (isGarbage && i < candidates.length - 1) {
        const logger = _getLogger();
        if (logger) {
          logger.warn('chat-fallback', 'empty_or_garbage_reply', buildRoutingDecision({
            model, provider: result?.provider, reason, attempt: i + 1, durationMs,
          }));
        }
        // Try next candidate
        continue;
      }
      // Either success or last attempt — return the result + trace
      return { ...result, attempts };
    } catch (err) {
      const durationMs = Date.now() - startedAt;
      attempts.push({
        model,
        reason,
        durationMs,
        success: false,
        error: err.message,
        transient: _isTransient(err),
      });
      lastError = err;
      if (_isTransient(err)) {
        // chat()'s internal withRetry already exhausted — go to next candidate
        const logger = _getLogger();
        if (logger) {
          logger.warn('chat-fallback', 'transient_exhausted', buildRoutingDecision({
            model, reason, attempt: i + 1, durationMs,
          }));
        }
      }
      if (i < candidates.length - 1) continue;
    }
  }

  // All candidates exhausted
  if (lastError) {
    lastError.attempts = attempts;
    throw lastError;
  }
  // All candidates returned empty/garbage
  return {
    reply: '',
    model: candidates[candidates.length - 1],
    provider: null,
    attempts,
    silentReply: false,
    error: 'All chat fallback candidates returned empty or garbage replies',
  };
}

module.exports = {
  chatWithFallback,
  _resolveCandidates,
  _isTransient,
  _isEmptyOrGarbage,
};
