'use strict';
/**
 * Default backoff schedule, table-driven (Item E of error-recovery deep-dive).
 * Borrowed verbatim from OpenClaw's DEFAULT_ERROR_BACKOFF_SCHEDULE_MS
 * (src/cron/service/jobs.ts:41-47). Values picked to match the shape
 * of typical transient-failure decay: short retries early when most
 * failures clear quickly, long cooldowns later when failures are clearly
 * persistent.
 *
 * 1st failure → no backoff (`run` immediately on next tick — most
 *               transient errors clear instantly, e.g. brief network
 *               blip)
 * 2nd failure → 30s
 * 3rd failure → 60s
 * 4th failure → 5min
 * 5th failure → 15min
 * 6th+ failure → 60min (cap; failure-alert fires + auto_disable at 10)
 *
 * Per-skill override via SKILL.md `retry.backoff_schedule_ms` (Item A).
 */
const DEFAULT_BACKOFF_SCHEDULE_MS = [
  30_000,
  60_000,
  5 * 60_000,
  15 * 60_000,
  60 * 60_000,
];

function backoffForFailureCount(consecutiveFailures, schedule = DEFAULT_BACKOFF_SCHEDULE_MS) {
  if (consecutiveFailures < 2) return 0;
  // -2 because 1st failure has no backoff; 2nd failure → schedule[0].
  const idx = Math.min(consecutiveFailures - 2, schedule.length - 1);
  return schedule[Math.max(0, idx)] ?? schedule[schedule.length - 1];
}

/**
 * Pure decision function for skill dispatch.
 *
 * Borrowed from OpenClaw's `selectAgentHarness` pattern (selection.ts:71)
 * which returns a structured decision `{ harness, candidates, selectedReason }`.
 * Wall-E's adaptation: every skill on every tick gets a decision
 * `{ decision, reason, details }` so the UI can answer "why didn't slack-mentions
 * run at 14:32?" without reading source.
 *
 * Reasons:
 *   'run'             — dispatcher will execute this skill
 *   'not_due'         — interval hasn't elapsed since last_run
 *   'disabled'        — skill.enabled === 0 AND no auto_disabled_at
 *                       (user disabled, or never enabled)
 *   'auto_disabled'   — skill.enabled === 0 AND auto_disabled_at IS NOT NULL
 *                       (dispatcher previously hit consecutive-failure cap)
 *   'just_auto_disabled' — this tick marks the failure→disable transition;
 *                       caller flips enabled=0 and stamps auto_disabled_at
 *   'backed_off'      — exponential cooldown still in effect
 *   'no_trigger'      — trigger_type !== 'interval' (manual/webhook/event)
 *
 * Pure: no DB writes, no telemetry. Caller owns side-effects.
 *
 * @param {Object} skill - Row from skills table.
 * @param {Object} ctx
 * @param {number} ctx.now - epoch ms (for testability)
 * @param {Array<Object>} ctx.recentExecutions - last N skill_executions, newest first
 * @param {number} [ctx.consecutiveFailureCap=10] - threshold for auto-disable
 * @param {number} [ctx.backoffBaseMs=300000] - base for exp backoff (5min)
 * @param {number} [ctx.backoffMaxMs=7200000] - cap (2hr)
 * @returns {{ decision: 'run' | 'skip', reason: string, details?: Object }}
 */
function decideSkillDispatch(skill, ctx) {
  if (!skill) throw new Error('decideSkillDispatch requires skill');
  const now = ctx?.now ?? Date.now();
  const recent = Array.isArray(ctx?.recentExecutions) ? ctx.recentExecutions : [];
  const cap = ctx?.consecutiveFailureCap ?? 10;

  // Backoff schedule resolution (Items E + A of error-recovery deep-dive):
  //   1. ctx.backoffSchedule explicitly passed                  (highest priority)
  //   2. skill.retry.backoff_schedule_ms (parsed from SKILL.md, Item A)
  //   3. DEFAULT_BACKOFF_SCHEDULE_MS table (Item E)             (default)
  //
  // Legacy ctx.backoffBaseMs / ctx.backoffMaxMs are still honored for
  // tests that override the entire backoff calculation, but are
  // deprecated for new callers — prefer passing a schedule.
  let schedule = DEFAULT_BACKOFF_SCHEDULE_MS;
  if (Array.isArray(ctx?.backoffSchedule) && ctx.backoffSchedule.length > 0) {
    schedule = ctx.backoffSchedule;
  } else if (skill.retry && Array.isArray(skill.retry.backoff_schedule_ms) && skill.retry.backoff_schedule_ms.length > 0) {
    schedule = skill.retry.backoff_schedule_ms;
  }
  // Legacy support: if caller passes baseMs/maxMs (old formula), synthesize
  // a schedule by computing `[base, base*2, base*4, ..., min(base*2^N, max)]`.
  // Used by older tests; new code should pass a schedule directly.
  if (ctx?.backoffBaseMs != null) {
    const base = ctx.backoffBaseMs;
    const max = ctx.backoffMaxMs ?? 7200000;
    const synth = [];
    for (let i = 0; i < 10; i++) {
      const v = Math.min(base * Math.pow(2, i), max);
      synth.push(v);
      if (v >= max) break;
    }
    schedule = synth;
  }

  // Trigger type check (only 'interval' is auto-dispatched today; others are
  // handled by their own gates — webhook by HTTP route, manual by API call).
  const triggerType = skill.trigger_type || 'interval';
  if (triggerType !== 'interval') {
    return { decision: 'skip', reason: 'no_trigger', details: { trigger_type: triggerType } };
  }

  // Disabled state — distinguish auto-disable (failure history) from
  // user/operator disable. The decision reason matters for the UI: an
  // auto-disabled skill is recoverable by re-enabling; a user-disabled
  // one was an explicit choice.
  if (!skill.enabled) {
    if (skill.auto_disabled_at) {
      return {
        decision: 'skip',
        reason: 'auto_disabled',
        details: { since: skill.auto_disabled_at, why: skill.auto_disabled_reason || 'consecutive_failures' },
      };
    }
    return { decision: 'skip', reason: 'disabled' };
  }

  // Count consecutive failures from recent executions (newest first).
  let consecutiveFailures = 0;
  for (const exec of recent) {
    if (exec.status === 'failure') consecutiveFailures++;
    else break;
  }

  // Auto-disable transition: hit cap THIS tick. Caller is responsible for
  // flipping enabled=0 and stamping auto_disabled_at.
  if (consecutiveFailures >= cap) {
    return {
      decision: 'skip',
      reason: 'just_auto_disabled',
      details: { consecutive_failures: consecutiveFailures, cap },
    };
  }

  // Exponential backoff (Items E + A): table-driven schedule.
  // First failure → no backoff (immediate retry next tick).
  // 2nd-Nth → schedule[idx]; 6th+ → cap at last entry.
  if (consecutiveFailures >= 2) {
    const backoffMs = backoffForFailureCount(consecutiveFailures, schedule);
    const lastExec = recent[0];
    const lastFinishedAt = lastExec
      ? new Date(lastExec.created_at || lastExec.started_at || 0).getTime()
      : 0;
    const nextAllowedAt = lastFinishedAt + backoffMs;
    if (now < nextAllowedAt) {
      return {
        decision: 'skip',
        reason: 'backed_off',
        details: {
          consecutive_failures: consecutiveFailures,
          next_allowed_at: new Date(nextAllowedAt).toISOString(),
          backoff_ms: backoffMs,
          schedule_source: ctx?.backoffSchedule ? 'ctx'
            : (skill.retry?.backoff_schedule_ms ? 'skill' : 'default'),
        },
      };
    }
  }

  // Interval check
  if (skill.last_run) {
    const config = (() => {
      try { return skill.trigger_config ? JSON.parse(skill.trigger_config) : {}; }
      catch { return {}; }
    })();
    const intervalMs = config.interval_ms || 300000; // default 5min
    const lastRunMs = new Date(skill.last_run).getTime();
    const elapsed = now - lastRunMs;
    if (elapsed < intervalMs) {
      return {
        decision: 'skip',
        reason: 'not_due',
        details: { interval_ms: intervalMs, elapsed_ms: elapsed, remaining_ms: intervalMs - elapsed },
      };
    }
  }

  return { decision: 'run', reason: 'due' };
}

module.exports = {
  decideSkillDispatch,
  DEFAULT_BACKOFF_SCHEDULE_MS,
  backoffForFailureCount,
};
