'use strict';

/**
 * Resilient default-provider client.
 *
 * `getDefaultClient()` historically resolved a SINGLE provider (the user's
 * `walle_provider`) and returned a bare client with no fallback — so if that
 * one provider was unreachable (e.g. an off-VPN gateway returning "fetch
 * failed"), every Wall-E surface that uses the default (chat, coding, skills,
 * background loops) failed outright, even when the user had another working
 * provider configured.
 *
 * This module wraps an ORDERED chain of provider clients:
 *   1. the user's chosen default,
 *   2. any other enabled+credentialed provider they've set up (e.g. deepseek),
 *   3. the local coding-agent CLIs (claude-cli, then codex-cli) when installed.
 *
 * Per call it retries transient failures on the current provider, then falls
 * through to the next candidate on connectivity/auth failures, and notifies
 * when it had to fall back (or when nothing was reachable). Genuine request
 * errors (bad request, content) are surfaced immediately rather than masked by
 * trying a different provider.
 *
 * The module is dependency-injected (no direct brain/network/child_process
 * coupling in the core logic) so it is unit-testable with fakes.
 */

const { spawnSync } = require('child_process');
const { classifyProviderError } = require('./provider-error');

// Failure classes (from provider-error.js classifyProviderError) that are worth
// retrying on the SAME provider — they're usually transient.
const TRANSIENT_CLASSES = new Set(['network', 'timeout', 'provider_unavailable', 'rate_limited']);
// Failure classes that mean "this provider can't serve the request right now" —
// fall through to the next candidate. auth_error is included (misconfigured /
// expired credential) but is NOT retried on the same provider; likewise
// quota_exceeded (depleted account/credits, e.g. DeepSeek "402 Insufficient
// Balance") — retrying the same provider can't help, but another candidate can
// serve. Anything else (invalid_request, content filter, unknown) is a real
// error we must surface.
const FALLTHROUGH_CLASSES = new Set(['network', 'timeout', 'provider_unavailable', 'rate_limited', 'auth_error', 'quota_exceeded']);

function classify(err) {
  try {
    return classifyProviderError(err)?.type || 'unknown';
  } catch {
    return 'unknown';
  }
}

function isTransientClass(cls) { return TRANSIENT_CLASSES.has(cls); }
function shouldFallThroughClass(cls) { return FALLTHROUGH_CLASSES.has(cls); }
function defaultDelay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }

// A request that was CANCELLED (caller aborted, or an AbortError) is not a provider-health
// signal — the caller no longer wants a result. classifyProviderError() lumps "aborted" into
// 'timeout' (transient), which would otherwise make us retry + fall through the whole chain
// (re-aborting on each, since they share the aborted signal) and falsely report "every
// provider unreachable". So aborts must propagate immediately: no retry, no fallthrough, no
// health-failure, no notice.
function isAbortError(err, opts) {
  if (opts && opts.signal && opts.signal.aborted) return true;
  if (!err) return false;
  if (err.name === 'AbortError' || err.code === 'ABORT_ERR') return true;
  return false;
}

const _binCache = new Map();
// Is a CLI binary (claude / codex) on PATH? Memoized — presence doesn't change
// within a process. Injectable spawn/platform for tests.
function isCliBinaryAvailable(bin, { spawn = spawnSync, platform = process.platform, cache = _binCache } = {}) {
  if (cache.has(bin)) return cache.get(bin);
  let ok = false;
  try {
    const probe = platform === 'win32' ? 'where' : 'which';
    const result = spawn(probe, [bin], { stdio: 'ignore', timeout: 3000 });
    ok = !!result && result.status === 0;
  } catch {
    ok = false;
  }
  cache.set(bin, ok);
  return ok;
}

/**
 * Wrap an ordered chain of candidates into a single resilient client.
 *
 * @param {Array<{id:string,type:string,label?:string,client:object}>} chain
 *   Ordered candidates (index 0 = the user's default). Each `client` is a
 *   normal provider client (chat / chatStream / capabilities / type).
 * @param {object} deps
 * @param {object} [deps.health]      ProviderHealth instance (trackCall used).
 * @param {Function} [deps.isDown]    (id) => bool — skip providers already down.
 * @param {Function} [deps.onFallback] ({from,to,error}) => void — fired once when
 *   a non-primary candidate ends up serving the call.
 * @param {Function} [deps.onExhausted] ({chain,error}) => void — every candidate failed.
 * @param {number} [deps.maxRetries=2] Per-candidate transient retries.
 * @param {number} [deps.baseDelayMs=400] Backoff base (exponential).
 * @param {Function} [deps.sleep] Injectable delay (tests).
 */
function makeResilientClient(chain, deps = {}) {
  if (!Array.isArray(chain) || chain.length === 0) {
    throw new Error('makeResilientClient: chain must be a non-empty array');
  }
  const health = deps.health || null;
  const isDown = typeof deps.isDown === 'function' ? deps.isDown : () => false;
  const maxRetries = Number.isInteger(deps.maxRetries) ? deps.maxRetries : 2;
  const baseDelayMs = Number.isFinite(deps.baseDelayMs) ? deps.baseDelayMs : 400;
  const onFallback = typeof deps.onFallback === 'function' ? deps.onFallback : () => {};
  const onPrimaryOk = typeof deps.onPrimaryOk === 'function' ? deps.onPrimaryOk : () => {};
  const onExhausted = typeof deps.onExhausted === 'function' ? deps.onExhausted : () => {};
  const sleep = typeof deps.sleep === 'function' ? deps.sleep : defaultDelay;
  const primary = chain[0];

  function track(id, ok, err) {
    try { if (health && typeof health.trackCall === 'function') health.trackCall(id, ok, err); } catch {}
  }

  // The caller's opts.model belongs to the PRIMARY provider. When we fall through
  // to a different provider it must use ITS own default model, not the primary's
  // (e.g. don't hand "deepseek-chat" to the claude CLI). The primary keeps the
  // caller's model verbatim.
  function optsForCandidate(cand, opts) {
    if (cand.id === primary.id) return opts;
    const next = { ...opts };
    if (cand.model) next.model = cand.model;
    else delete next.model;
    return next;
  }

  // Active candidates for this call: drop providers health marked 'down', and —
  // when the call needs tool calls — drop providers that can't do tools (e.g. the
  // claude-cli tail, capabilities.tools === false), leaving those to the coding
  // orchestrator's own CLI fallback. Never return empty: fall back to the full
  // chain so we always attempt something.
  function activeChain(opts = {}) {
    const wantsTools = Array.isArray(opts.tools) && opts.tools.length > 0;
    let cands = chain.filter((c) => !isDown(c.id));
    if (wantsTools) {
      const toolCapable = cands.filter((c) => c.client?.capabilities?.tools !== false);
      if (toolCapable.length) cands = toolCapable;
    }
    return cands.length ? cands : chain.slice(-1);
  }

  // Run one candidate with transient retries. Returns {ok, res} or {ok:false, err, cls}.
  async function runCandidate(cand, callOpts, invoke) {
    let lastErr;
    let lastCls = 'unknown';
    for (let attempt = 0; attempt <= maxRetries; attempt++) {
      try {
        const res = await invoke(cand.client, callOpts);
        track(cand.id, true);
        return { ok: true, res };
      } catch (err) {
        // Caller cancelled — propagate at once (no retry/fallthrough/health-failure).
        if (isAbortError(err, callOpts)) throw err;
        const cls = classify(err);
        lastErr = err;
        lastCls = cls;
        track(cand.id, false, err);
        if (isTransientClass(cls) && attempt < maxRetries) {
          await sleep(baseDelayMs * (2 ** attempt));
          continue;
        }
        return { ok: false, err, cls };
      }
    }
    return { ok: false, err: lastErr, cls: lastCls };
  }

  async function callWithFallback(opts, invoke) {
    const cands = activeChain(opts);
    let lastErr;
    for (let i = 0; i < cands.length; i++) {
      const cand = cands[i];
      const out = await runCandidate(cand, optsForCandidate(cand, opts), invoke);
      if (out.ok) {
        if (cand.id !== primary.id) onFallback({ from: primary, to: cand, error: lastErr });
        else onPrimaryOk({ provider: cand });
        return out.res;
      }
      lastErr = out.err;
      // A genuine request error (not connectivity/auth) shouldn't be masked by
      // retrying a different provider — surface it.
      if (!shouldFallThroughClass(out.cls)) throw out.err;
    }
    onExhausted({ chain: cands, error: lastErr });
    throw lastErr || new Error('All default providers were unavailable');
  }

  async function* streamWithFallback(opts) {
    const cands = activeChain(opts);
    let lastErr;
    for (let i = 0; i < cands.length; i++) {
      const cand = cands[i];
      const callOpts = optsForCandidate(cand, opts);
      for (let attempt = 0; attempt <= maxRetries; attempt++) {
        let started = false;
        try {
          for await (const chunk of cand.client.chatStream(callOpts)) {
            if (!started) {
              started = true;
              track(cand.id, true);
              if (cand.id !== primary.id) onFallback({ from: primary, to: cand, error: lastErr });
              else onPrimaryOk({ provider: cand });
            }
            yield chunk;
          }
          return; // stream completed
        } catch (err) {
          // Caller cancelled — propagate at once (no retry/fallthrough/health-failure).
          if (isAbortError(err, callOpts)) throw err;
          const cls = classify(err);
          track(cand.id, false, err);
          // Mid-stream we can't switch providers without re-emitting — propagate.
          if (started) throw err;
          lastErr = err;
          if (isTransientClass(cls) && attempt < maxRetries) {
            await sleep(baseDelayMs * (2 ** attempt));
            continue; // retry same candidate
          }
          if (!shouldFallThroughClass(cls)) throw err;
          break; // move to next candidate
        }
      }
    }
    onExhausted({ chain: cands, error: lastErr });
    throw lastErr || new Error('All default providers were unavailable');
  }

  const wrapper = {
    type: primary.client.type,
    capabilities: primary.client.capabilities,
    _resilientChain: chain,
  };
  if (typeof primary.client.chat === 'function') {
    wrapper.chat = (opts = {}) => callWithFallback(opts, (client, callOpts) => client.chat(callOpts));
  }
  wrapper.chatStream = async function* chatStream(opts = {}) {
    yield* streamWithFallback(opts);
  };

  // Pass through any other provider methods/props (token counting, model lists,
  // etc.) to the primary client so the wrapper is a drop-in replacement.
  return new Proxy(wrapper, {
    get(target, prop) {
      if (prop in target) return target[prop];
      const value = primary.client[prop];
      return typeof value === 'function' ? value.bind(primary.client) : value;
    },
  });
}

function _parseHeaders(value) {
  if (!value) return undefined;
  if (typeof value === 'object' && !Array.isArray(value)) return value;
  if (typeof value !== 'string') return undefined;
  try {
    const parsed = JSON.parse(value);
    return parsed && typeof parsed === 'object' ? parsed : undefined;
  } catch {
    return undefined;
  }
}

function _configFromRow(row = {}) {
  const config = {};
  if (row.api_key_encrypted) config.apiKey = row.api_key_encrypted;
  if (row.base_url) config.baseUrl = row.base_url;
  const headers = _parseHeaders(row.custom_headers);
  if (headers && Object.keys(headers).length > 0) config.customHeaders = headers;
  return config;
}

const _ENV_KEY_BY_TYPE = {
  anthropic: ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN'],
  openai: ['OPENAI_API_KEY'],
  google: ['GOOGLE_API_KEY', 'GEMINI_API_KEY'],
  deepseek: ['DEEPSEEK_API_KEY'],
  moonshot: ['MOONSHOT_API_KEY'],
};

function _hasEnvKey(type, env = process.env) {
  const names = _ENV_KEY_BY_TYPE[String(type || '').toLowerCase()] || [];
  return names.some((name) => !!env[name]);
}

/**
 * Build the ordered candidate chain for the default client.
 *
 * @param {object} deps
 * @param {object} deps.brain                Wall-E brain (listModelProviders / getModelProviderWithKey).
 * @param {Function} deps.createClient       (type, config) => client.
 * @param {Function} deps.buildPrimary       () => {id,type,client} | null — the user's default,
 *                                            built by client.js with its full auth_method logic.
 * @param {Function} [deps.isCliAvailable]   (bin) => bool.
 * @param {object} [deps.env]
 * @returns {Array<{id,type,label,client}>}
 */
function buildDefaultProviderChain(deps = {}) {
  const { brain, createClient, buildPrimary } = deps;
  const isCliAvailable = deps.isCliAvailable || isCliBinaryAvailable;
  // (type) => default model id for that provider. Fallback providers use their OWN
  // default model, never the primary's. Returns undefined for CLI types (let the CLI choose).
  const resolveModelForType = typeof deps.resolveModelForType === 'function' ? deps.resolveModelForType : () => undefined;
  const env = deps.env || process.env;
  const chain = [];
  const seen = new Set();

  function add(cand) {
    if (!cand || !cand.client || !cand.id || seen.has(cand.id)) return;
    seen.add(cand.id);
    chain.push(cand);
  }

  // 1. The user's chosen default (built upstream with full auth handling).
  let primaryType = null;
  try {
    const primary = typeof buildPrimary === 'function' ? buildPrimary() : null;
    if (primary && primary.client && primary.id) {
      add(primary);
      primaryType = primary.type || null;
    }
  } catch {}

  // 2. Other enabled, credentialed providers the user has set up — most recently
  //    updated first. Only api_key-auth rows with a usable key qualify here; CLI-
  //    auth providers are covered by the tail below.
  try {
    const rows = (typeof brain.listModelProviders === 'function' ? brain.listModelProviders() : []) || [];
    const enabled = rows.filter((r) => r && r.enabled && r.type);
    const withKeys = enabled.map((r) => {
      try {
        return (typeof brain.getModelProviderWithKey === 'function' && brain.getModelProviderWithKey(r.id)) || r;
      } catch {
        return r;
      }
    });
    withKeys.sort((a, b) => String(b.updated_at || '').localeCompare(String(a.updated_at || '')));
    for (const row of withKeys) {
      const id = row.id || `${row.type}-default`;
      if (seen.has(id)) continue;
      if (row.type === primaryType) continue; // primary already represents this type
      const authMethod = row.auth_method || 'api_key';
      if (authMethod !== 'api_key') continue; // CLI/oauth handled elsewhere
      if (!row.api_key_encrypted && !_hasEnvKey(row.type, env)) continue;
      let client;
      try {
        client = createClient(row.type, _configFromRow(row));
      } catch {
        continue;
      }
      add({ id, type: row.type, label: row.name || row.type, client, model: resolveModelForType(row.type) });
    }
  } catch {}

  // 3. Coding-agent CLI tail (subscription billing, no key, works off-VPN).
  if (!seen.has('claude-cli') && isCliAvailable('claude')) {
    try { add({ id: 'claude-cli', type: 'claude-cli', label: 'Claude CLI', client: createClient('claude-cli', {}), model: resolveModelForType('claude-cli') }); } catch {}
  }
  if (!seen.has('codex-cli') && isCliAvailable('codex')) {
    try { add({ id: 'codex-cli', type: 'codex-cli', label: 'Codex CLI', client: createClient('codex-cli', {}), model: resolveModelForType('codex-cli') }); } catch {}
  }

  return chain;
}

module.exports = {
  makeResilientClient,
  buildDefaultProviderChain,
  isCliBinaryAvailable,
  _internals: {
    classify,
    isTransientClass,
    shouldFallThroughClass,
    _configFromRow,
    _hasEnvKey,
    TRANSIENT_CLASSES,
    FALLTHROUGH_CLASSES,
  },
};
