'use strict';
const brain = require('../brain');
const { runSkillWithFallback } = require('./skill-fallback');
const { findSkill: findLoaderSkill } = require('./skill-loader');
const { validateSkillRequirements } = require('./skill-validator');
const { decideSkillDispatch } = require('./skill-dispatch-decision');
const { getCurrentSkillSnapshot } = require('./skill-snapshot');
const { resolveInternalSkill } = require('./internal-skill-registry');
const {
  OWNER_ENV_KEY: SLACK_OWNER_ENV_KEY,
  getSlackOwnerRepairState,
  repairSlackOwnerIdentity,
} = require('../tools/slack-owner');
let telemetry;
try { telemetry = require('../telemetry'); } catch { telemetry = { trackError() {}, track() {} }; }

const DECISION_TELEMETRY_MIN_INTERVAL_MS = 60 * 60 * 1000;
const _lastDecisionTelemetry = new Map();
let _slackAlertMigrationDone = false;

function isBrainReady() {
  try {
    brain.getDb();
    return true;
  } catch {
    return false;
  }
}

function ensurePlannerBrainReady() {
  if (isBrainReady()) return true;
  try {
    brain.initDb();
    return true;
  } catch (err) {
    console.warn(`[skill-planner] Brain DB unavailable; skipping scheduled skills: ${err.message}`);
    return false;
  }
}

function shouldTrackDecisionTelemetry(skillName, decision) {
  if (!decision) return false;
  // Successful/attempted executions are already represented by `skill_exec`.
  // Emitting a dispatch decision for every due run turned the scheduler tick
  // into telemetry noise at scale; keep this stream for actionable skips and
  // rare state transitions instead.
  if (decision.decision === 'run' || decision.reason === 'due') return false;
  if (decision.reason === 'just_auto_disabled') return true;
  const key = `${skillName}:${decision.decision}:${decision.reason || ''}`;
  const now = Date.now();
  const last = _lastDecisionTelemetry.get(key) || 0;
  if (now - last < DECISION_TELEMETRY_MIN_INTERVAL_MS) return false;
  _lastDecisionTelemetry.set(key, now);
  return true;
}

// ── Service alerts ─────────────────────────────────────────────────────────
// Stored in kv_store as JSON array. Surfaces auth failures, version updates, etc.

function versionLag(current, latest) {
  const cur = String(current || '').split('.').map(n => Number(n) || 0);
  const lat = String(latest || '').split('.').map(n => Number(n) || 0);
  return {
    major: Math.max(0, (lat[0] || 0) - (cur[0] || 0)),
    minor: Math.max(0, (lat[1] || 0) - (cur[1] || 0)),
    patch: Math.max(0, (lat[2] || 0) - (cur[2] || 0)),
  };
}

function getServiceAlerts({ includeVersionUpdate = false } = {}) {
  try {
    const raw = brain.getKv('service_alerts');
    const alerts = raw ? JSON.parse(raw) : [];
    if (includeVersionUpdate) {
      try {
        const updateRaw = brain.getKv('update_available');
        if (updateRaw) {
          const update = JSON.parse(updateRaw);
          if (update.latest && update.current && update.latest !== update.current) {
            const lag = update.lag || versionLag(update.current, update.latest);
            const stale = lag.major > 0 || lag.minor > 0 || lag.patch >= 3;
            alerts.push({
              id: 'version_update', service: 'system', type: 'update_available',
              severity: stale ? 'warning' : 'info',
              message: stale
                ? `CTM / Wall-E ${update.latest} is available (you have ${update.current}). This install is behind the recommended version.`
                : `CTM / Wall-E ${update.latest} is available (you have ${update.current}).`,
              action: 'show_update_wizard',
              action_label: 'Upgrade',
              current: update.current,
              latest: update.latest,
              lag,
              created_at: update.checked_at,
            });
          }
        }
      } catch {}
    }
    return alerts;
  } catch { return []; }
}

function addServiceAlert(alert) {
  const alerts = getServiceAlerts();
  // Dedupe by service+type — only keep latest per service+type
  const existing = alerts.findIndex(a => a.service === alert.service && a.type === alert.type);
  if (existing >= 0) alerts.splice(existing, 1);
  alerts.push({
    ...alert,
    id: `${alert.service}:${alert.type}:${Date.now()}`,
    service: alert.service,
    type: alert.type,
    message: alert.message,
    action_url: alert.action_url || null,
    created_at: new Date().toISOString(),
  });
  // Cap at 20 alerts
  while (alerts.length > 20) alerts.shift();
  brain.setKv('service_alerts', JSON.stringify(alerts));
}

function dismissServiceAlert(alertId) {
  const alerts = getServiceAlerts().filter(a => a.id !== alertId);
  brain.setKv('service_alerts', JSON.stringify(alerts));
}

function clearServiceAlerts(service) {
  const alerts = getServiceAlerts().filter((a) => {
    if (service === 'slack') return !(a.service === 'slack' || (typeof a.service === 'string' && a.service.startsWith('slack-')));
    return a.service !== service;
  });
  brain.setKv('service_alerts', JSON.stringify(alerts));
}

function clearResolvedSlackHealthAlerts({ authenticated = false, ownerConfigured = false } = {}) {
  if (!authenticated) return { cleared: 0 };
  const before = getServiceAlerts();
  const after = before.filter((a) => {
    const service = String(a.service || '');
    const type = String(a.type || '');
    const message = String(a.message || '');
    const slackFamily = service === 'slack' || service.startsWith('slack-');
    if (!slackFamily) return true;
    if (type === 'auth_expired') return false;
    if (ownerConfigured && type === 'owner_identity_missing') return false;
    // `slack-sync` was an older Slack path. A disabled legacy alert should not
    // keep current Slack MCP/OAuth status red once slack-mentions is healthy.
    if (type === 'skill_disabled' && /\bslack-sync\b/.test(message + ' ' + service)) return false;
    return true;
  });
  if (after.length !== before.length) {
    brain.setKv('service_alerts', JSON.stringify(after));
  }
  return { cleared: before.length - after.length };
}

function _googleWorkspaceServiceKey(email, profileId) {
  const normalizedEmail = String(email || '').trim().toLowerCase();
  if (!normalizedEmail) return '';
  const normalizedProfile = String(profileId || 'default').trim().toLowerCase() || 'default';
  return `google_workspace:${normalizedProfile}:${normalizedEmail}`;
}

function _sameGoogleWorkspaceAlertAccount(alert, email, profileId) {
  const normalizedEmail = String(email || '').trim().toLowerCase();
  if (!normalizedEmail || !alert) return false;
  const normalizedProfile = String(profileId || 'default').trim().toLowerCase() || 'default';
  const service = String(alert.service || '').trim().toLowerCase();
  if (service === _googleWorkspaceServiceKey(normalizedEmail, normalizedProfile)) return true;
  const alertEmail = String(alert.account_email || alert.email || '').trim().toLowerCase();
  if (alertEmail !== normalizedEmail) return false;
  const alertProfile = String(alert.profile_id || alert.profileId || 'default').trim().toLowerCase() || 'default';
  return alertProfile === normalizedProfile;
}

function clearResolvedGoogleWorkspaceHealthAlerts({ email = '', profileId = null, authenticated = false } = {}) {
  if (!authenticated || !email) return { cleared: 0 };
  const before = getServiceAlerts();
  const after = before.filter((a) => {
    if (!_sameGoogleWorkspaceAlertAccount(a, email, profileId)) return true;
    const action = String(a.action || '');
    const type = String(a.type || '');
    const googleWorkspaceAuthAlert =
      action === 'gws_reauth'
      || type === 'auth_expired';
    return !googleWorkspaceAuthAlert;
  });
  if (after.length !== before.length) {
    brain.setKv('service_alerts', JSON.stringify(after));
  }
  return { cleared: before.length - after.length };
}

function _hasMissingSlackOwner(validation) {
  return !!(validation && Array.isArray(validation.missing)
    && validation.missing.some(m => m.type === 'env' && m.name === SLACK_OWNER_ENV_KEY));
}

function _addSlackOwnerIdentityAlert() {
  const state = getSlackOwnerRepairState();
  if (state.canRepair) {
    addServiceAlert({
      service: 'slack',
      type: 'owner_identity_missing',
      message: 'Slack sync needs to know which Slack user is you. Wall-E can fix this from your existing Slack connection.',
      action_label: 'Fix automatically',
      action: 'repair_slack_owner',
      action_endpoint: '/api/wall-e/slack/repair-owner',
    });
    return;
  }

  addServiceAlert({
    service: 'slack',
    type: 'owner_identity_missing',
    message: 'Slack sync needs to know which Slack user is you. Connect Slack so Wall-E can configure this automatically.',
    action_label: 'Connect Slack',
    action_url: '/setup.html#slack',
  });
}

/**
 * One-time migration: alerts fired before the attemptAuthRecovery fix used
 * the skill name as the service (e.g. service='slack-mentions') which meant
 * a user re-authenticating Slack via /setup.html#slack couldn't clear them,
 * because clearServiceAlerts('slack') never matched 'slack-mentions'. This
 * promotes any 'slack-*' alert to service='slack', deduping against any
 * fresh alert emitted after the fix landed. Safe to run repeatedly — it's
 * a no-op once no slack-* alerts remain.
 */
function migrateSlackFamilyAlerts() {
  try {
    const alerts = getServiceAlerts();
    let changed = false;
    const seen = new Set();
    const migrated = [];
    for (const a of alerts) {
      if (typeof a.service === 'string' && a.service.startsWith('slack-')) {
        const key = `slack:${a.type}`;
        if (seen.has(key)) { changed = true; continue; } // drop duplicate
        seen.add(key);
        migrated.push({ ...a, service: 'slack', id: `slack:${a.type}:${Date.now()}` });
        changed = true;
      } else {
        const key = `${a.service}:${a.type}`;
        if (!seen.has(key)) { seen.add(key); migrated.push(a); }
      }
    }
    if (changed) {
      brain.setKv('service_alerts', JSON.stringify(migrated));
      console.log('[skill-planner] Migrated slack-* service alerts to service=slack');
    }
  } catch (e) {
    console.error('[skill-planner] migrateSlackFamilyAlerts error:', e.message);
  }
}

function runSlackAlertMigrationIfReady() {
  if (_slackAlertMigrationDone || !isBrainReady()) return;
  migrateSlackFamilyAlerts();
  _slackAlertMigrationDone = true;
}

// Run opportunistically at module load only after the brain singleton is ready.
// Some daemon paths import the planner before initDb(); touching KV there
// generated noisy "Database not initialized" telemetry.
runSlackAlertMigrationIfReady();

// Note: `isSkillBackedOff`, `isSkillDue`, and `getBackoffMs` were removed
// in favour of the pure-function `decideSkillDispatch` in
// skills/skill-dispatch-decision.js (Item C of skill-dispatch deep-dive).
// The decision function inlines its own backoff math for clarity and
// returns structured `{ decision, reason, details }` per skill per tick.

// ── Auth error detection and auto-recovery ──────────────────────────────────

const AUTH_ERROR_PATTERNS = [
  /invalid_auth/i, /token.*expir/i, /\b401\b/, /unauthorized/i,
  /not_authed/i, /token_revoked/i, /oauth.*error/i,
  /oauth token not found/i, /claude auth login/i, /anthropic_api_key/i,
  /could not resolve authentication method/i, /expected either apikey or authtoken/i,
  /authToken/i, /apiKey/i, /authentication/i,
];
const AI_PROVIDER_AUTH_BLOCK_KV = 'ai_provider_auth_block';
const AI_PROVIDER_AUTH_BLOCK_MS = 6 * 60 * 60 * 1000;
const KEYLESS_PROVIDER_TYPES = new Set(['claude-cli', 'codex-cli', 'gemini-cli', 'devbox', 'ollama', 'mlx']);
const KEYLESS_AUTH_METHODS = new Set(['claude_cli', 'oauth_proxy', 'codex_cli', 'gemini_cli', 'devbox']);
const PROVIDER_API_KEY_ENV = {
  anthropic: ['ANTHROPIC_API_KEY'],
  openai: ['OPENAI_API_KEY'],
  google: ['GOOGLE_API_KEY', 'GEMINI_API_KEY'],
  gemini: ['GOOGLE_API_KEY', 'GEMINI_API_KEY'],
  deepseek: ['DEEPSEEK_API_KEY'],
  openrouter: ['OPENROUTER_API_KEY'],
  groq: ['GROQ_API_KEY'],
  xai: ['XAI_API_KEY'],
  mistral: ['MISTRAL_API_KEY'],
};

function isAuthError(err) {
  if (err?.providerError?.type === 'auth_error' || err?.cause?.providerError?.type === 'auth_error') return true;
  const msg = [
    typeof err === 'string' ? err : err?.message,
    err?.cause?.message,
    err?.providerError?.rawMessage,
    err?.cause?.providerError?.rawMessage,
  ].filter(Boolean).join('\n');
  return AUTH_ERROR_PATTERNS.some(p => p.test(msg));
}

function getProviderAuthBlock(now = Date.now()) {
  try {
    const raw = brain.getKv(AI_PROVIDER_AUTH_BLOCK_KV);
    if (!raw) return null;
    const block = JSON.parse(raw);
    const until = Number(block?.until);
    if (!Number.isFinite(until) || until <= now) {
      brain.setKv(AI_PROVIDER_AUTH_BLOCK_KV, '');
      return null;
    }
    return {
      skill: block.skill || null,
      message: block.message || '',
      until,
      created_at: block.created_at || null,
      config_fingerprint: block.config_fingerprint || '',
    };
  } catch {
    return null;
  }
}

function clearProviderAuthBlock() {
  try { brain.setKv(AI_PROVIDER_AUTH_BLOCK_KV, ''); } catch {}
  try { clearServiceAlerts('ai_provider'); } catch {}
}

function providerAuthFingerprint(authState) {
  if (!authState) return '';
  try {
    return JSON.stringify({
      provider: authState.providerType || null,
      auth_method: authState.authMethod || null,
      key_source: authState.keySource || null,
      key_present: !!authState.keyPresent,
      db_provider_id: authState.providerId || null,
      db_updated_at: authState.providerUpdatedAt || null,
      env_var: authState.envVar || null,
    });
  } catch {
    return '';
  }
}

function setProviderAuthBlock(skillName, err, now = Date.now(), authState = null) {
  const msg = [
    typeof err === 'string' ? err : err?.message,
    err?.cause?.message,
    err?.providerError?.rawMessage,
    err?.cause?.providerError?.rawMessage,
  ].filter(Boolean).join('\n');
  const configState = authState || resolveExternalProviderAuthState({});
  const block = {
    skill: skillName || null,
    message: msg.slice(0, 500),
    until: now + AI_PROVIDER_AUTH_BLOCK_MS,
    created_at: new Date(now).toISOString(),
    config_fingerprint: providerAuthFingerprint(configState),
  };
  try { brain.setKv(AI_PROVIDER_AUTH_BLOCK_KV, JSON.stringify(block)); } catch {}
  addServiceAlert({
    service: 'ai_provider',
    type: 'auth_missing',
    message: 'AI provider credentials are missing or expired. Update the provider credentials on the Setup page.',
    action_url: '/setup.html',
  });
  try {
    telemetry.track('skill_auth_blocked', {
      skill: skillName,
      cooldown_seconds: Math.round(AI_PROVIDER_AUTH_BLOCK_MS / 1000),
    });
  } catch {}
  return block;
}

function providerKeyFromEnv(providerType) {
  const names = PROVIDER_API_KEY_ENV[providerType] || [];
  for (const name of names) {
    if (process.env[name]) return { name, present: true };
  }
  return { name: names[0] || null, present: false };
}

function providerKeyFromDb(providerType) {
  try {
    const row = typeof brain.getProviderByType === 'function'
      ? brain.getProviderByType(providerType)
      : null;
    return {
      row,
      present: !!(row && row.api_key_encrypted),
      authMethod: row?.auth_method || null,
      providerId: row?.id || null,
      updatedAt: row?.updated_at || null,
    };
  } catch {
    return { row: null, present: false, authMethod: null };
  }
}

function resolveExternalProviderAuthState(opts = {}) {
  let providerType = '';
  let authMethod = '';
  try {
    const { getDefaultProviderType, getDefaultAuthMethod } = require('../llm/client');
    providerType = String(opts.provider || opts.providerType || getDefaultProviderType() || '').toLowerCase();
    authMethod = String(opts.authMethod || opts.auth_method || getDefaultAuthMethod(providerType) || '').toLowerCase();
  } catch {
    providerType = String(opts.provider || opts.providerType || process.env.WALLE_PROVIDER || 'anthropic').toLowerCase();
    authMethod = String(opts.authMethod || opts.auth_method || process.env.WALLE_AUTH_METHOD || '').toLowerCase();
  }
  const effectiveAuthMethod = authMethod || 'api_key';
  if (!providerType) {
    return { ok: true, providerType, authMethod: effectiveAuthMethod, reason: 'unknown-provider' };
  }
  if (KEYLESS_PROVIDER_TYPES.has(providerType) || KEYLESS_AUTH_METHODS.has(effectiveAuthMethod)) {
    return { ok: true, providerType, authMethod: effectiveAuthMethod, reason: 'keyless-auth', keySource: 'keyless', keyPresent: true };
  }
  if (effectiveAuthMethod !== 'api_key') {
    return { ok: true, providerType, authMethod: effectiveAuthMethod, reason: 'non-api-key-auth', keySource: 'external-auth', keyPresent: true };
  }

  const dbKey = providerKeyFromDb(providerType);
  const envKey = providerKeyFromEnv(providerType);
  if (dbKey.present || envKey.present) {
    return {
      ok: true,
      providerType,
      authMethod: effectiveAuthMethod,
      reason: dbKey.present ? 'db-key' : 'env-key',
      keySource: dbKey.present ? 'db' : 'env',
      keyPresent: true,
      providerId: dbKey.providerId || null,
      providerUpdatedAt: dbKey.updatedAt || null,
      envVar: dbKey.present ? null : envKey.name,
    };
  }

  // Unknown custom providers may not use an API-key shape. Only fail closed
  // when the provider is one we know needs a key, or when a DB row explicitly
  // exists but has no key.
  if (!PROVIDER_API_KEY_ENV[providerType] && !dbKey.row) {
    return { ok: true, providerType, authMethod: effectiveAuthMethod, reason: 'custom-provider-unchecked' };
  }

  const keyHint = envKey.name ? ` or ${envKey.name}` : '';
  return {
    ok: false,
    providerType,
    authMethod: effectiveAuthMethod,
    envVar: envKey.name,
    keySource: dbKey.row ? 'db' : (envKey.name ? 'env' : 'unknown'),
    keyPresent: false,
    providerId: dbKey.providerId || null,
    providerUpdatedAt: dbKey.updatedAt || null,
    message: `AI provider "${providerType}" is configured for API-key auth, but no key is saved in Setup${keyHint}.`,
  };
}

function recordProviderAuthBlockedDecision(skillName, authBlock, reason = 'provider_auth_blocked', extraDetails = {}) {
  const blockedDecision = {
    decision: 'skip',
    reason,
    details: {
      retry_at: new Date(authBlock.until).toISOString(),
      blocked_by_skill: authBlock.skill,
      ...extraDetails,
    },
  };
  try { brain.recordSkillLastDecision(skillName, blockedDecision); } catch {}
  if (shouldTrackDecisionTelemetry(skillName, blockedDecision)) {
    try {
      telemetry.track('skill_dispatch_decision', {
        skill: skillName,
        decision: blockedDecision.decision,
        reason: blockedDecision.reason,
      });
    } catch {}
  }
  return blockedDecision;
}

function preflightExternalProviderAuth(skillName, opts = {}, now = Date.now()) {
  const authState = resolveExternalProviderAuthState(opts);
  const existingBlock = getProviderAuthBlock(now);
  if (existingBlock) {
    const missingKeyBlock = /no key is saved in Setup/i.test(existingBlock.message || '');
    const currentFingerprint = providerAuthFingerprint(authState);
    const configChanged = !!(currentFingerprint && existingBlock.config_fingerprint
      && currentFingerprint !== existingBlock.config_fingerprint);
    if (authState.ok && (missingKeyBlock || configChanged)) {
      clearProviderAuthBlock();
      return null;
    }
    return {
      reason: 'provider_auth_blocked',
      block: existingBlock,
      authState,
    };
  }
  if (authState.ok) return null;

  const err = new Error(authState.message);
  err.providerError = {
    type: 'auth_error',
    provider: authState.providerType,
    userMessage: authState.message,
  };
  return {
    reason: 'provider_auth_missing',
    block: setProviderAuthBlock(skillName, err, now, authState),
    authState,
  };
}

async function attemptAuthRecovery(skillName, err) {
  const msg = typeof err === 'string' ? err : (err?.message || '');

  // Route by skill-family first, then message pattern. slack-mcp already
  // does one in-flight refresh before throwing, so by the time we get here
  // the message may read "Slack token expired and auto-refresh failed…" —
  // a string that contains none of our previous substring gates. Triggering
  // recovery on skill name ('slack-*') catches those cases without relying
  // on the brittle message-substring match.
  const isSlackFamily = skillName && skillName.startsWith('slack-')
    || msg.includes('invalid_auth')
    || msg.includes('Slack API')
    || msg.includes('Slack token');

  if (isSlackFamily) {
    // If slack-mcp's in-flight refresh already ran and failed, the token file
    // has been cleared — ensureValidToken() will return false immediately.
    // Still worth calling: handles the case where slack-mcp never got a
    // chance to refresh (e.g. network error before 401 retry).
    try {
      const { ensureValidToken } = require('../tools/slack-mcp');
      const refreshed = await ensureValidToken();
      if (refreshed) {
        console.log(`[skill-planner] Auto-refreshed Slack token for "${skillName}"`);
        clearServiceAlerts('slack');
        return true;
      }
    } catch {}
    // Recovery failed — alert the user on the 'slack' service (NOT the
    // skill name) so a single re-auth clears alerts from every slack-*
    // skill (slack-mentions, slack-dms, slack-search-history, …).
    addServiceAlert({
      service: 'slack',
      type: 'auth_expired',
      message: 'Slack token expired and auto-refresh failed. Re-authenticate Slack on the Setup page.',
      action_url: '/setup.html#slack',
    });
    return false;
  }

  if (/anthropic|openai|gemini|google|deepseek|moonshot|kimi|claude auth|anthropic_api_key|api[_-]?key|apikey|auth[_-]?token|authtoken|authentication method|oauth token not found/i.test(msg)) {
    addServiceAlert({
      service: 'ai_provider',
      type: 'auth_missing',
      message: 'AI provider credentials are missing or expired. Update the provider credentials on the Setup page.',
      action_url: '/setup.html',
    });
    return false;
  }

  // Generic MCP auth error — alert user
  const service = skillName.replace(/^ingest-/, '').replace(/-history$/, '');
  addServiceAlert({
    service,
    type: 'auth_expired',
    message: `Authentication failed for ${service}. Check credentials on the Setup page.`,
    action_url: '/setup.html',
  });
  return false;
}

/**
 * Run all enabled skills that are due.
 */
async function runDueSkills(opts = {}) {
  if (!ensurePlannerBrainReady()) return { executed: 0, memoriesCreated: 0, skipped: 'db_unavailable' };
  runSlackAlertMigrationIfReady();

  // Iterate ALL skills — including disabled ones — so decideSkillDispatch
  // emits per-skill decisions. The decision function itself filters
  // disabled/auto_disabled/backed_off; we only proceed to dispatch when
  // `decision === 'run'`.
  //
  // Item B: prefer the per-tick snapshot if the caller threads one in,
  // so this runDueSkills sees the same enable-state as parallel loops in
  // the same tick. Falls back to a fresh snapshot if no caller threads.
  const snapshot = opts.skillSnapshot || getCurrentSkillSnapshot({ brain });
  const skills = snapshot.skills;
  let executed = 0;
  let memoriesCreated = 0;

  for (const skill of skills) {
    try {
      const recentExecs = brain.listSkillExecutions({ skill_id: skill.id, limit: 10 });
      // Item A (error-recovery): merge loader-side retry config so per-skill
      // backoff schedules from SKILL.md flow through. The loader skill is
      // looked up by name; if absent, decideSkillDispatch falls through to
      // the default schedule.
      let retryFromLoader = null;
      try {
        const ls = findLoaderSkill(skill.name);
        if (ls && ls.retry) retryFromLoader = ls.retry;
      } catch {}
      const skillForDecision = retryFromLoader
        ? Object.assign({}, skill, { retry: retryFromLoader })
        : skill;
      const decision = decideSkillDispatch(skillForDecision, { now: Date.now(), recentExecutions: recentExecs });

      // Persist decision so UI / API can render "why didn't X run".
      try { brain.recordSkillLastDecision(skill.name, decision); } catch {}
      // Telemetry: structured event per decision per tick. Operator can
      // grep "skill_dispatch_decision skill=X reason=backed_off" without
      // reading source.
      if (shouldTrackDecisionTelemetry(skill.name, decision)) {
        try { telemetry.track('skill_dispatch_decision', { skill: skill.name, decision: decision.decision, reason: decision.reason }); } catch {}
      }
      // Recovery logger: long-form structured trace for post-incident
      // debugging (Items E + A of error-recovery deep-dive).
      if (decision.reason === 'backed_off') {
        try {
          require('../lib/recovery-logger').warn('skill-dispatch', 'backoff_applied', {
            skill: skill.name,
            duration_ms: decision.details?.backoff_ms,
            details: {
              consecutive_failures: decision.details?.consecutive_failures,
              next_allowed_at: decision.details?.next_allowed_at,
              schedule_source: decision.details?.schedule_source,
            },
          });
        } catch {}
      }

      // Auto-disable transition: this is the moment we flip enabled=0 and
      // stamp auto_disabled_at. Subsequent ticks will see reason='auto_disabled'.
      if (decision.reason === 'just_auto_disabled') {
        try {
          brain.updateSkill(skill.id, {
            enabled: 0,
            auto_disabled_at: new Date().toISOString(),
            auto_disabled_reason: 'consecutive_failures',
          });
        } catch {}
        console.log(`[skill-planner] Auto-disabled skill "${skill.name}" after ${decision.details?.consecutive_failures || 'many'} consecutive failures`);
        addServiceAlert({
          service: skill.name,
          type: 'skill_disabled',
          message: `Skill "${skill.name}" disabled after repeated failures. Check the setup page.`,
          action_url: '/setup.html',
        });
        continue;
      }

      if (decision.decision !== 'run') continue;

      // Preflight `requires` (bins/env/mcp). Fail fast with a Service Alert
      // so the user gets an actionable message instead of a runtime crash
      // surfaced only after multiple failures.
      // Tolerant of skills that don't exist on disk (no `requires` to check).
      try {
        const loaderSkill = findLoaderSkill(skill.name);
        const hasRequires = loaderSkill && loaderSkill.requires &&
          (loaderSkill.requires.bins || loaderSkill.requires.env || loaderSkill.requires.mcp);
        if (hasRequires) {
          let validation = validateSkillRequirements(loaderSkill);
          if (!validation.valid && _hasMissingSlackOwner(validation)) {
            const repaired = repairSlackOwnerIdentity({ persist: true });
            if (repaired.ok) {
              console.log(`[skill-planner] Repaired ${SLACK_OWNER_ENV_KEY} from Slack OAuth token for "${skill.name}"`);
              clearServiceAlerts('slack');
              validation = validateSkillRequirements(loaderSkill);
            }
          }
          if (!validation.valid) {
            if (_hasMissingSlackOwner(validation)) {
              console.log(`[skill-planner] Preflight failed for "${skill.name}": Slack owner identity missing`);
              _addSlackOwnerIdentityAlert();
              continue;
            }
            const items = validation.missing
              .map(m => `${m.type}=${m.name}`)
              .join(', ');
            const firstSuggestion = validation.missing[0]
              ? validation.missing[0].suggestion
              : 'Check skill requirements';
            console.log(`[skill-planner] Preflight failed for "${skill.name}": ${items}`);
            addServiceAlert({
              service: skill.name,
              type: 'requires_missing',
              message: `Skill "${skill.name}" cannot run: missing ${items}. ${firstSuggestion}`,
              action_url: '/setup.html',
            });
            continue;
          }
        }
      } catch (err) {
        // Validator should never throw, but if it does, log and continue with
        // the existing dispatch -- preflight is a hint, not a hard gate.
        console.warn(`[skill-planner] Preflight error for "${skill.name}": ${err.message}`);
        try { telemetry.trackError('skill-preflight', err, { skill: skill.name }); } catch {}
      }

      // Handle internal (script) skills (no Claude call needed). Prefer the
      // schema field; fall back to the legacy `INTERNAL_SKILL:<name>` token
      // in prompt_template for back-compat with rows that pre-date the
      // execution column and skipped the migration backfill.
      const isScriptSkill = skill.execution === 'script'
        || (skill.prompt_template && skill.prompt_template.startsWith('INTERNAL_SKILL:'));
      if (isScriptSkill) {
        // Module name lives in either prompt_template (legacy) or skill.name
        // (new path). Both sources should agree post-migration.
        const moduleName = (skill.prompt_template && skill.prompt_template.startsWith('INTERNAL_SKILL:'))
          ? skill.prompt_template.split(':')[1]
          : skill.name;
        const startMs = Date.now();
        try {
          console.log(`[skill-planner] Running internal skill: ${skill.name}`);
          let result;

          // Item D: registry-based dispatch replaces the hardcoded
          // `if moduleName === 'slack-ingest'` switch + manual filesystem
          // fallback. Bundled skills auto-register at module load
          // (internal-skill-registry.js); ad-hoc plugins can call
          // registerInternalSkill at runtime.
          const resolved = resolveInternalSkill(moduleName);
          if (!resolved) {
            console.log(`[skill-planner] Unknown internal skill module: ${moduleName}`);
            continue;
          }
          const skillLog = (msg) => console.log(`[${moduleName}] ${msg}`);
          result = await resolved.factory({ brain, log: skillLog }) || {};
          const durationMs = Date.now() - startMs;
          try {
            brain.updateSkillStats(skill.id, !result.error);
            brain.insertSkillExecution({
              skill_id: skill.id,
              status: result.error ? 'failure' : 'success',
              tool_calls: '[]',
              tool_results: JSON.stringify(result).slice(0, 10000),
              memories_created: result.messagesIngested || result.updated || 0,
              duration_ms: durationMs,
              error: result.error || null,
              model: 'internal',
              decision_reason: 'due',
            });
          } catch {}
          // Per-skill execution telemetry. Per-execution detail goes here so
          // we can answer "which skill ran in the last hour, with what
          // status?" from telemetry alone -- no DB query needed.
          try {
            telemetry.track('skill_exec', {
              skill: skill.name,
              kind: 'internal',
              status: result.error ? 'failure' : 'success',
              duration_ms: durationMs,
              memories_created: result.messagesIngested || result.updated || 0,
            });
          } catch {}
          executed++;
          memoriesCreated += result.messagesIngested || 0;
          if (result.done) {
            brain.updateSkill(skill.id, { enabled: 0 });
            console.log(`[skill-planner] Skill "${skill.name}" complete, disabled`);
          }
        } catch (err) {
          const durationMs = Date.now() - startMs;
          console.error(`[skill-planner] Internal skill "${skill.name}" failed:`, err.message);
          telemetry.trackError('skill-planner', err, { skill: skill.name, type: 'internal' });
          try { brain.updateSkillStats(skill.id, false); } catch {}
          try {
            brain.insertSkillExecution({
              skill_id: skill.id, status: 'failure', tool_calls: '[]', tool_results: '[]',
              memories_created: 0, duration_ms: durationMs, error: err.message,
              model: 'internal', decision_reason: 'due',
            });
          } catch {}
          try {
            telemetry.track('skill_exec', {
              skill: skill.name,
              kind: 'internal',
              status: 'failure',
              duration_ms: durationMs,
              memories_created: 0,
            });
          } catch {}
          // Attempt auto-recovery for auth errors
          if (isAuthError(err)) {
            await attemptAuthRecovery(skill.name, err);
          }
        }
        continue;
      }

      // External skills (Claude/LLM-based). Validate the provider credential
      // shape before entering the fallback runner so a missing API key pauses
      // the scheduler once instead of producing recurring per-skill failures.
      const extStart = Date.now();
      try {
        // Item F: walks per-skill model_candidates list on non-transient
        // failure. Falls back to single-model behaviour when no candidates
        // declared (the default).
        // Item E (multi-agent deep-dive): dispatch_source is 'scheduled'
        // for the cron-driven path so telemetry can group runs by source.
        // Item A of provider-routing deep-dive: cron-job model override
        // takes precedence over skill-manifest model_candidates. When
        // the user has configured a per-job model (via setup endpoint),
        // it flows through opts.modelOverride here. We synthesize a
        // model_candidates array: [override.model, ...override.modelCandidates]
        // and pass it to runSkillWithFallback, which prefers caller's
        // candidates over skill-manifest candidates.
        const callOpts = {
          ...opts,
          decision_reason: 'due',
          dispatch_source: opts.dispatch_source || 'scheduled',
        };
        if (opts.modelOverride) {
          const override = opts.modelOverride;
          const list = [];
          if (override.model) list.push(override.model);
          if (Array.isArray(override.modelCandidates)) {
            for (const m of override.modelCandidates) {
              if (m && !list.includes(m)) list.push(m);
            }
          }
          if (list.length) callOpts.model_candidates = list;
        }
        const authPreflight = preflightExternalProviderAuth(skill.name, callOpts);
        if (authPreflight) {
          recordProviderAuthBlockedDecision(skill.name, authPreflight.block, authPreflight.reason, {
            provider: authPreflight.authState?.providerType || null,
            auth_method: authPreflight.authState?.authMethod || null,
          });
          continue;
        }
        console.log(`[skill-planner] Running skill: ${skill.name}`);
        const result = await runSkillWithFallback(skill, callOpts);
        clearProviderAuthBlock();
        executed++;
        memoriesCreated += result.memoriesCreated;
        console.log(`[skill-planner] Skill "${skill.name}": ${result.memoriesCreated} memories, ${result.toolCalls} tool calls, ${result.duration}ms`);
        try {
          telemetry.track('skill_exec', {
            skill: skill.name,
            kind: 'external',
            status: 'success',
            duration_ms: result.duration || (Date.now() - extStart),
            memories_created: result.memoriesCreated || 0,
            tool_calls: result.toolCalls || 0,
          });
        } catch {}
      } catch (err) {
        console.error(`[skill-planner] Skill "${skill.name}" failed:`, err.message);
        const authFailure = isAuthError(err);
        if (!authFailure) {
          telemetry.trackError('skill-planner', err, { skill: skill.name, type: 'external' });
        }
        try { brain.updateSkillStats(skill.id, false); } catch {}
        try {
          telemetry.track('skill_exec', {
            skill: skill.name,
            kind: 'external',
            status: 'failure',
            duration_ms: Date.now() - extStart,
            memories_created: 0,
          });
        } catch {}
        if (authFailure) {
          await attemptAuthRecovery(skill.name, err);
          setProviderAuthBlock(skill.name, err);
        }
      }
    } catch (outerErr) {
      // Catch-all: never let one skill crash the entire loop
      console.error(`[skill-planner] Unexpected error processing skill "${skill.name}":`, outerErr.message);
      telemetry.trackError('skill-planner', outerErr, { skill: skill.name, type: 'outer' });
    }
  }

  // Telemetry
  if (executed > 0) {
    try {
      const telemetry = require('../telemetry');
      telemetry.track('skills_run', { executed, memories_created: memoriesCreated });
    } catch {}
  }

  return { executed, memoriesCreated };
}

// `isSkillDue` was inlined into `decideSkillDispatch` (Item C).

module.exports = {
  runDueSkills,
  getServiceAlerts,
  addServiceAlert,
  dismissServiceAlert,
  clearServiceAlerts,
  clearResolvedSlackHealthAlerts,
  clearResolvedGoogleWorkspaceHealthAlerts,
  _test: {
    isAuthError,
    getProviderAuthBlock,
    setProviderAuthBlock,
    clearProviderAuthBlock,
    resolveExternalProviderAuthState,
    preflightExternalProviderAuth,
    shouldTrackDecisionTelemetry,
    versionLag,
  },
};
