'use strict';
/**
 * Shared retry helper for channel polling — Item D of error-recovery deep-dive.
 *
 * Borrowed shape from OpenClaw's per-channel retry modules
 * (extensions/discord/src/retry.ts, extensions/qqbot/src/engine/api/retry.ts,
 * extensions/telegram/src/sendchataction-401-backoff.ts). Wall-E's
 * adaptation, scaled to the 3 existing channels:
 *
 *   - Slack DM (channels/slack-channel.js)
 *   - iMessage (channels/imessage-channel.js)
 *   - Telegram (channels/telegram-channel.js)
 *
 * What this module provides:
 *   - retryablePoll(channelName, pollFn, opts) — wrap an async poll with
 *     - exponential backoff on transient errors
 *     - structured recovery logging on each retry
 *     - hard cap to prevent infinite retry loops
 *   - classifyChannelError(err) — tag the error as retryable / auth /
 *     fatal so callers can branch
 *
 * Why a shared helper and not per-channel duplication?
 *   - Pre-this-commit each channel had bespoke try/catch with no
 *     consistent retry behaviour
 *   - Slack already has ensureValidToken which handles auth recovery
 *     specifically for OAuth tokens — channel-retry leaves that path
 *     intact, just adds a retry layer on top for transient network errors
 *   - iMessage relies on macOS's `imessage` CLI; failures are usually
 *     transient (briefly losing AppleEvents access) — retries help here
 *   - Telegram uses bot tokens (long-lived); failures are mostly network
 */

const recoveryLogger = require('../lib/recovery-logger');

const DEFAULT_MAX_ATTEMPTS = 3;
const DEFAULT_BACKOFF_MS = [1000, 3000, 10_000]; // short — we're a poll loop, not a one-shot

/**
 * Classify a channel error so callers can decide what to do.
 *
 *   'auth'      → token expired / unauthorized; channel should re-auth
 *                 (e.g. Slack ensureValidToken). NOT retried by this helper.
 *   'transient' → network error / 5xx / rate limit; retry is appropriate.
 *   'fatal'     → unrecoverable (config error, library API misuse).
 *                 NOT retried.
 */
function classifyChannelError(err) {
  if (!err) return 'fatal';
  const status = err.status ?? err.statusCode ?? 0;
  const msg = (err.message || err.body || '').toLowerCase();

  // Auth failures — let callers handle (Slack has its own refresh path)
  if (status === 401 || status === 403) return 'auth';
  if (msg.includes('invalid_auth')
      || msg.includes('unauthorized')
      || msg.includes('token_expired')
      || msg.includes('not_authed')) {
    return 'auth';
  }

  // Transient — retry
  if (status === 429 || (status >= 500 && status < 600)) return 'transient';
  if (msg.includes('econnreset')
      || msg.includes('econnrefused')
      || msg.includes('etimedout')
      || msg.includes('socket hang up')
      || msg.includes('rate limit')
      || msg.includes('try again')) {
    return 'transient';
  }

  // Default: treat unknown as fatal — don't loop on bugs
  return 'fatal';
}

/**
 * Retry-wrap a channel poll. Returns whatever pollFn returns on success.
 * Throws after `maxAttempts` retries OR if the error is classified non-transient.
 *
 * @param {string} channelName - 'slack_dm' | 'imessage' | 'telegram' | etc.
 * @param {Function} pollFn - async () => result
 * @param {Object} [opts]
 * @param {number} [opts.maxAttempts=3]
 * @param {number[]} [opts.backoffMs=[1s, 3s, 10s]]
 * @param {Function} [opts.delay=setTimeout-based] - injectable for tests
 */
async function retryablePoll(channelName, pollFn, opts = {}) {
  if (typeof pollFn !== 'function') {
    throw new Error('retryablePoll requires pollFn');
  }
  const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
  const schedule = Array.isArray(opts.backoffMs) && opts.backoffMs.length > 0
    ? opts.backoffMs
    : DEFAULT_BACKOFF_MS;
  const delayFn = opts.delay || ((ms) => new Promise(r => setTimeout(r, ms)));

  let lastError = null;
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    try {
      const result = await pollFn();
      // Success — log only if we had retried (no log on first-try success)
      if (attempt > 0) {
        try {
          recoveryLogger.info('channel-retry', 'poll_succeeded_after_retry', {
            channel: channelName,
            details: { attempt_index: attempt, total_attempts: maxAttempts },
          });
        } catch {}
      }
      return result;
    } catch (err) {
      lastError = err;
      const classification = classifyChannelError(err);
      if (classification !== 'transient' || attempt + 1 >= maxAttempts) {
        try {
          recoveryLogger.warn('channel-retry', 'poll_failed', {
            channel: channelName,
            details: {
              attempt_index: attempt,
              total_attempts: maxAttempts,
              classification,
              error: (err.message || '').slice(0, 200),
            },
          });
        } catch {}
        throw err;
      }
      // Transient — wait + retry
      const wait = schedule[Math.min(attempt, schedule.length - 1)];
      try {
        recoveryLogger.info('channel-retry', 'poll_retry', {
          channel: channelName,
          duration_ms: wait,
          details: {
            attempt_index: attempt,
            total_attempts: maxAttempts,
            classification,
            error: (err.message || '').slice(0, 200),
          },
        });
      } catch {}
      await delayFn(wait);
    }
  }
  // Should not reach here — loop either returns or throws — but for safety
  throw lastError || new Error(`channel "${channelName}" poll exhausted retries`);
}

module.exports = {
  retryablePoll,
  classifyChannelError,
  DEFAULT_BACKOFF_MS,
  DEFAULT_MAX_ATTEMPTS,
};
