/**
 * Unified Scheduler - Processes scheduled items and delivers messages.
 *
 * Nudge-kind items are delivered as pre-written messages.
 * Task-kind items are executed by a sub-agent (falls back to nudge if unavailable).
 * Supports recurring items with automatic rescheduling.
 */

import type { Logger } from 'pino';
import type {
  ScallopDatabase,
  ScheduledItem,
  RecurringSchedule,
  BoardItemResult,
} from '../memory/db.js';
import { BoardService } from '../board/board-service.js';
import { computeBoardStatus } from '../board/types.js';
import type { GoalService } from '../goals/index.js';
import type { SessionManager } from '../agent/session.js';
import type { SubAgentExecutor } from '../subagent/index.js';
import { parseUserIdPrefix } from '../triggers/types.js';
import { formatProactiveMessage, type ProactiveFormatInput } from './proactive-format.js';
import { detectProactiveEngagement } from './feedback.js';
import { getRecentChatContext } from './chat-context.js';
import { wordOverlap } from '../utils/text-similarity.js';

/** How long to remember recently sent messages for dedup (2 hours).
 *  Extended from 30min so a recurring cognitive-layer signal that fires every
 *  60-90min doesn't slip through with rephrased wording. */
const SEND_DEDUP_WINDOW_MS = 2 * 60 * 60 * 1000;
/** Word overlap threshold to consider two proactive messages "the same".
 *  Lowered from 0.5 → 0.3 since LLM-generated nudges about the same topic
 *  often phrase it differently (e.g. "hope legs aren't sore" vs
 *  "how are you feeling after the leg workout"). */
const SEND_DEDUP_THRESHOLD = 0.3;
/** Hard floor on time between any two agent-sourced proactive sends to the
 *  same user. Bypassed for user-set reminders (they have explicit times).
 *  Honors PROACTIVE_MIN_GAP_MS (previously the env var was read by
 *  proactive-config but silently ignored here — a hard-coded 1h won). */
const MIN_AGENT_PROACTIVE_GAP_MS = (() => {
  const raw = process.env.PROACTIVE_MIN_GAP_MS;
  const parsed = raw ? parseInt(raw, 10) : NaN;
  return Number.isFinite(parsed) && parsed >= 0 ? parsed : 60 * 60 * 1000;
})();

/**
 * Strip internal markup, XML function calls, error prefixes, and thinking blocks
 * from raw sub-agent output so it's safe to show to the user.
 * Returns empty string if nothing meaningful remains.
 */
function sanitizeAgentResponse(raw: string): string {
  if (!raw) return '';

  let cleaned = raw
    // Strip <function_calls>...</function_calls> blocks (and unclosed ones)
    .replace(/<function_calls>[\s\S]*?(<\/function_calls>|$)/g, '')
    // Strip <invoke ...>...</invoke> blocks
    .replace(/<invoke[\s\S]*?(<\/invoke>|$)/g, '')
    // Strip <*> blocks (thinking, etc.)
    .replace(/<\w+[\s\S]*?(<\/antml:\w+>|$)/g, '')
    // Strip leaked <tool>...</tool>, <query>...</query>, <command>...</command> etc.
    .replace(/<(tool|query|command|result|output|search_query|input|args|parameters|tool_name|tool_input)\b[^>]*>[\s\S]*?<\/\1>/g, '')
    // Strip "Error: ..." lines (internal errors, not user-facing)
    .replace(/^Error:\s*.*/gm, '')
    // Collapse excess whitespace left behind by stripping
    .replace(/[ \t]{2,}/g, ' ')
    .replace(/\n{3,}/g, '\n\n')
    .trim();

  // If nothing meaningful remains, return empty
  if (cleaned.length === 0) return '';
  return cleaned;
}

/**
 * Handler for sending messages to users
 */
export type MessageHandler = (userId: string, message: string) => Promise<boolean>;

/**
 * Options for UnifiedScheduler
 */
export interface UnifiedSchedulerOptions {
  /** Database instance */
  db: ScallopDatabase;
  /** Logger instance */
  logger: Logger;
  /** Goal service for goal check-in context (optional) */
  goalService?: GoalService;
  /** Session manager for creating scheduler session */
  sessionManager?: SessionManager;
  /** Sub-agent executor for task-kind items (optional — falls back to nudge) */
  subAgentExecutor?: SubAgentExecutor;
  /** Check interval in milliseconds (default: 30 seconds) */
  interval?: number;
  /** Maximum age for expired items in milliseconds (default: 24 hours) */
  maxItemAge?: number;
  /** Handler to send messages to users */
  onSendMessage: MessageHandler;
  /** Callback to resolve IANA timezone for a user (defaults to server timezone) */
  getTimezone?: (userId: string) => string;
  /** Hard floor in ms between two agent-sourced proactive sends to the same
   *  user. Defaults to 1h. Set to 0 in tests that need multiple agent items
   *  to fire in a single tick. */
  minAgentProactiveGapMs?: number;
}

/**
 * UnifiedScheduler - Handles both user reminders and agent triggers
 */
export class UnifiedScheduler {
  private db: ScallopDatabase;
  private logger: Logger;
  private goalService?: GoalService;
  private sessionManager?: SessionManager;
  private subAgentExecutor?: SubAgentExecutor;
  private interval: number;
  private maxItemAge: number;
  private onSendMessage: MessageHandler;
  private getTimezone: (userId: string) => string;

  private boardService: BoardService;
  private schedulerSessionId: string | null = null;
  private intervalHandle: ReturnType<typeof setInterval> | null = null;
  private pendingImmediateHandle: ReturnType<typeof setImmediate> | null = null;
  private isRunning = false;
  private isStopping = false;
  private evaluating = false;
  private pendingEvaluation = false;
  /** Counter for periodic dedup consolidation (every ~10 min) */
  private evalTickCount = 0;
  /** Recently sent messages per user for send-time dedup: userId → [{message, time, source}].
   *  Hydrated from SQLite on start() so dedup history survives process restarts.
   *  Bounded by pruning empty arrays after each dedup check to keep memory flat. */
  private recentSends = new Map<string, { message: string; time: number; source: string }[]>();
  private minAgentProactiveGapMs: number;
  private static readonly MAX_RECENT_SEND_USERS = 500;

  constructor(options: UnifiedSchedulerOptions) {
    this.db = options.db;
    this.logger = options.logger.child({ component: 'unified-scheduler' });
    this.goalService = options.goalService;
    this.sessionManager = options.sessionManager;
    this.subAgentExecutor = options.subAgentExecutor;
    this.interval = options.interval ?? 30 * 1000; // 30 seconds
    this.maxItemAge = options.maxItemAge ?? 24 * 60 * 60 * 1000; // 24 hours
    this.onSendMessage = options.onSendMessage;
    this.getTimezone = options.getTimezone ?? (() => Intl.DateTimeFormat().resolvedOptions().timeZone);
    this.minAgentProactiveGapMs = options.minAgentProactiveGapMs ?? MIN_AGENT_PROACTIVE_GAP_MS;
    this.boardService = new BoardService(this.db, this.logger);
  }

  /**
   * Start the scheduler
   */
  async start(): Promise<void> {
    if (this.isRunning) {
      this.logger.warn('UnifiedScheduler already running');
      return;
    }

    this.isRunning = true;
    this.logger.info({ intervalMs: this.interval }, 'Starting UnifiedScheduler');

    // Create a persistent scheduler session for sub-agent parenting
    if (this.sessionManager) {
      try {
        const session = await this.sessionManager.createSession({ source: 'scheduler' });
        this.schedulerSessionId = session.id;
        this.logger.debug({ sessionId: session.id }, 'Scheduler session created');
      } catch (err) {
        this.logger.warn({ error: (err as Error).message }, 'Failed to create scheduler session — tasks will fall back to nudges');
      }
    }

    // Consolidate duplicate reminders on startup
    try {
      const removed = this.db.consolidateDuplicateScheduledItems();
      if (removed > 0) {
        this.logger.info({ duplicatesRemoved: removed }, 'Consolidated duplicate scheduled items on startup');
      }
    } catch (err) {
      this.logger.error({ error: (err as Error).message }, 'Failed to consolidate duplicates on startup');
    }

    // Hydrate the in-memory dedup map from SQLite so we don't lose history
    // on restart. Without this, a redeploy opens a SEND_DEDUP_WINDOW_MS
    // window in which the cognitive layer can re-emit similar nudges.
    try {
      const since = Date.now() - SEND_DEDUP_WINDOW_MS;
      const recent = this.db.getRecentProactiveSends(since);
      for (const r of recent) {
        const list = this.recentSends.get(r.userId) || [];
        list.push({ message: r.message, time: r.sentAt, source: r.source });
        this.recentSends.set(r.userId, list);
      }
      if (recent.length > 0) {
        this.logger.info({ entries: recent.length, users: this.recentSends.size }, 'Hydrated proactive send-dedup map from SQLite');
      }
      // Opportunistic prune: drop log rows older than 2× the window.
      this.db.pruneProactiveSendLog(Date.now() - 2 * SEND_DEDUP_WINDOW_MS);
    } catch (err) {
      this.logger.warn({ error: (err as Error).message }, 'Failed to hydrate proactive send log');
    }

    // Run immediately, then on interval
    this.evaluate().catch(err => {
      this.logger.error({ error: (err as Error).message }, 'Initial scheduler evaluation failed');
    });

    this.intervalHandle = setInterval(() => {
      this.evaluate().catch(err => {
        this.logger.error({ error: (err as Error).message }, 'Scheduler evaluation failed');
      });
    }, this.interval);
  }

  /**
   * Stop the scheduler. Safe to call mid-evaluate: in-flight evaluate() exits
   * at the next await point via the isStopping guard, and any deferred
   * setImmediate is cancelled so it can't fire after shutdown.
   */
  stop(): void {
    this.isStopping = true;
    if (this.intervalHandle) {
      clearInterval(this.intervalHandle);
      this.intervalHandle = null;
    }
    if (this.pendingImmediateHandle) {
      clearImmediate(this.pendingImmediateHandle);
      this.pendingImmediateHandle = null;
    }
    this.pendingEvaluation = false;
    this.isRunning = false;
    this.logger.info('UnifiedScheduler stopped');
  }

  /**
   * Evaluate and process due items
   */
  async evaluate(): Promise<void> {
    // Bail immediately if we're shutting down — a tick may have been queued
    // via setInterval or setImmediate moments before stop() was called.
    if (this.isStopping) return;

    // Prevent overlapping evaluations — if a previous tick is still processing
    // (e.g. waiting on sub-agent), defer this tick so it runs after the current one finishes
    if (this.evaluating) {
      this.pendingEvaluation = true;
      this.logger.debug('Deferring scheduler tick — previous evaluation still running');
      return;
    }
    this.evaluating = true;

    try {
      // First, expire old items
      const expiredCount = this.db.expireOldScheduledItems(this.maxItemAge);
      if (expiredCount > 0) {
        this.logger.debug({ count: expiredCount }, 'Expired old scheduled items');
      }

      // Periodic dedup consolidation (~every 10 minutes)
      this.evalTickCount++;
      if (this.evalTickCount % 20 === 0) {
        try {
          const removed = this.db.consolidateDuplicateScheduledItems();
          if (removed > 0) {
            this.logger.info({ duplicatesRemoved: removed }, 'Periodic duplicate consolidation');
          }
        } catch (err) {
          this.logger.warn({ error: (err as Error).message }, 'Periodic consolidation failed');
        }
      }

      // Atomically claim due items (marks them 'processing' so no other tick can grab them)
      const dueItems = this.db.claimDueScheduledItems();
      if (dueItems.length === 0) {
        return;
      }

      // Quiet hours: defer agent-sourced items between 10 PM and 8 AM user-local time.
      // User-sourced reminders always fire immediately (the user set the exact time).
      const tz = this.getTimezone(dueItems[0].userId);
      const hourNow = parseInt(
        new Intl.DateTimeFormat('en-US', { timeZone: tz, hour: '2-digit', hour12: false })
          .formatToParts(new Date())
          .find(p => p.type === 'hour')?.value ?? '12',
        10
      );
      const isQuietHours = hourNow >= 22 || hourNow < 8;

      if (isQuietHours) {
        const deferred: ScheduledItem[] = [];
        const ready: ScheduledItem[] = [];
        for (const item of dueItems) {
          if (item.source === 'agent') {
            // Reset to pending and reschedule to 8 AM in user's timezone.
            // Compute hours until 8 AM from the user's current hour.
            this.db.resetScheduledItemToPending(item.id);
            const hoursUntil8am = hourNow >= 8 ? (24 - hourNow + 8) : (8 - hourNow);
            const next8amMs = Date.now() + hoursUntil8am * 60 * 60 * 1000;
            this.db.updateScheduledItemBoard(item.id, { triggerAt: next8amMs });
            deferred.push(item);
          } else {
            ready.push(item);
          }
        }
        if (deferred.length > 0) {
          this.logger.debug({ count: deferred.length }, 'Deferred agent items to morning (quiet hours)');
        }
        if (ready.length === 0) return;
        // Replace dueItems with only user-sourced ready items
        dueItems.length = 0;
        dueItems.push(...ready);
      }

      this.logger.info({ count: dueItems.length }, 'Found due scheduled items');

      // Dependency check: skip items whose dependencies aren't done/archived
      const readyItems = dueItems.filter(item => {
        if (!item.dependsOn || item.dependsOn.length === 0) return true;
        for (const depId of item.dependsOn) {
          const dep = this.db.getScheduledItem(depId);
          if (!dep) continue; // deleted dependency counts as resolved
          const depBoard = computeBoardStatus(dep);
          if (depBoard !== 'done' && depBoard !== 'archived') {
            // Dependency not resolved — reset to pending with a delayed trigger
            this.db.resetScheduledItemToPending(item.id);
            this.db.updateScheduledItemBoard(item.id, {
              boardStatus: 'waiting',
              triggerAt: Date.now() + 60 * 60 * 1000, // retry in 1 hour
            });
            this.logger.debug({ itemId: item.id, blockedBy: depId }, 'Item blocked by dependency');
            return false;
          }
        }
        return true;
      });

      // Sort: tasks before nudges so task results establish dedup priority.
      // When a task fires first, its description gets recorded in recentSends,
      // so a companion nudge with similar wording gets suppressed.
      readyItems.sort((a, b) => {
        if (a.kind === 'task' && b.kind !== 'task') return -1;
        if (a.kind !== 'task' && b.kind === 'task') return 1;
        return 0;
      });

      for (const item of readyItems) {
        try {
          // Task-kind items with taskConfig: execute via sub-agent
          if (item.kind === 'task' && item.taskConfig && this.subAgentExecutor && this.schedulerSessionId) {
            await this.processTask(item);
          } else {
            // Nudge-kind items (or tasks without executor): send pre-written message
            await this.processNudge(item);
          }
          this.markItemFiredAndReschedule(item);
        } catch (err) {
          this.logger.error(
            { itemId: item.id, error: (err as Error).message },
            'Failed to process scheduled item'
          );
          this.db.resetScheduledItemToPending(item.id);
        }
      }
    } finally {
      this.evaluating = false;

      // If a tick was deferred while we were evaluating, run it now — unless
      // we're shutting down. Track the handle so stop() can cancel it.
      if (this.pendingEvaluation && !this.isStopping) {
        this.pendingEvaluation = false;
        this.logger.debug('Running deferred scheduler evaluation');
        this.pendingImmediateHandle = setImmediate(() => {
          this.pendingImmediateHandle = null;
          this.evaluate().catch(err => {
            this.logger.error({ error: (err as Error).message }, 'Deferred scheduler evaluation failed');
          });
        });
      }
    }
  }

  /**
   * Process a nudge item — send pre-written message directly
   */
  private async processNudge(item: ScheduledItem): Promise<void> {
    this.logger.debug(
      { itemId: item.id, source: item.source, type: item.type },
      'Processing nudge'
    );

    await this.sendFormattedMessage(item, item.message);
  }

  /**
   * Process a task item — execute via sub-agent, send result to user.
   * Silently skips if sub-agent fails (no fallback to raw task description).
   */
  private async processTask(item: ScheduledItem): Promise<void> {
    const config = item.taskConfig!;
    this.logger.info(
      { itemId: item.id, goal: config.goal, tools: config.tools },
      'Executing task via sub-agent'
    );

    try {
      // Fetch recent chat context so the sub-agent is aware of ongoing conversations
      const chatContext = getRecentChatContext(this.db);

      // Grounding directive: scheduled tasks run unattended, so a sub-agent that
      // skips its data tools and improvises output goes unnoticed (e.g. the daily
      // YouTube report that fabricated subscriber counts for weeks). Spell out
      // that the listed tools are the source of truth.
      const toolList = (config.tools ?? []).join(', ');
      const groundedTask = toolList
        ? `${config.goal}\n\nIMPORTANT: Use your tools (${toolList}) to gather the REAL data this task needs. Do not estimate, extrapolate, or invent any numbers. If the data cannot be retrieved, deliver a short honest report saying what failed instead.`
        : config.goal;

      const result = await this.subAgentExecutor!.spawnAndWait(
        this.schedulerSessionId!,
        {
          task: groundedTask,
          label: `scheduled:${item.type}`,
          skills: config.tools,
          modelTier: config.modelTier ?? 'fast',
          // 420s, not 180s: thinking-heavy models (kimi, local qwen) regularly
          // need >3min for multi-tool tasks — at 180s the report runs were
          // timing out and producing nothing at all.
          timeoutSeconds: 420,
          waitForResult: true,
          recentChatContext: chatContext?.formattedContext,
        },
      );

      // Store result on the item for morning digest / audit
      this.db.updateScheduledItemResult(item.id, {
        response: result.response,
        completedAt: Date.now(),
        iterationsUsed: result.iterationsUsed,
      });

      // Send sanitized result to user
      const clean = sanitizeAgentResponse(result.response);
      if (clean) {
        await this.sendFormattedMessage(item, clean, 'task_result');
        // Record task description & goal for dedup so companion nudges get suppressed
        this.recordSend(item.userId, item.message, item.source);
        if (config.goal && config.goal !== item.message) {
          this.recordSend(item.userId, config.goal, item.source);
        }
      } else {
        // Sub-agent produced no user-facing output (error, empty, or all-markup response).
        // Don't send the raw task description — it reads like an internal to-do, not a message.
        this.logger.warn({ itemId: item.id }, 'Sub-agent returned no user-facing content, skipping message');
      }
    } catch (err) {
      // Infrastructure failure (no provider, session error, etc.) — don't confuse user
      // with a raw task description. Just log and skip.
      this.logger.error(
        { itemId: item.id, error: (err as Error).message },
        'Sub-agent execution failed, skipping message'
      );
    }
  }

  /**
   * Send a formatted message to the user.
   * For agent-sourced items, applies per-channel proactive formatting.
   */
  private async sendFormattedMessage(
    item: ScheduledItem,
    message: string,
    sourceOverride?: 'task_result' | 'inner_thoughts' | 'gap_scanner',
  ): Promise<void> {
    // Hard min-gap throttle for agent-sourced sends: never two within
    // MIN_AGENT_PROACTIVE_GAP_MS. User-set reminders bypass — they have
    // explicit times the user chose.
    if (item.source === 'agent' && this.isWithinMinAgentGap(item.userId)) {
      this.logger.info({ itemId: item.id, userId: item.userId }, 'Suppressing agent proactive — within min-gap of last agent send');
      this.db.recordProactiveDecision({
        userId: item.userId,
        stage: 'deliver',
        outcome: 'suppressed',
        reason: 'min_gap',
        detail: { itemId: item.id },
      });
      // Push trigger forward so it can fire after the gap window.
      try {
        this.db.updateScheduledItemBoard(item.id, { triggerAt: Date.now() + this.minAgentProactiveGapMs });
        this.db.resetScheduledItemToPending(item.id);
      } catch (err) {
        this.logger.warn({ err: (err as Error).message, itemId: item.id }, 'Failed to defer min-gap-throttled item');
      }
      return;
    }

    // Send-time dedup: skip if a similar message was sent recently to this user
    if (this.isDuplicateSend(item.userId, message)) {
      this.logger.info({ itemId: item.id, userId: item.userId }, 'Skipping proactive message — similar one sent recently');
      this.db.recordProactiveDecision({
        userId: item.userId,
        stage: 'deliver',
        outcome: 'suppressed',
        reason: 'send_dedup',
        detail: { itemId: item.id },
      });
      return;
    }
    this.recordSend(item.userId, message, item.source);
    this.db.recordProactiveDecision({
      userId: item.userId,
      stage: 'deliver',
      outcome: 'queued',
      reason: 'queued',
      detail: { itemId: item.id, source: item.source },
    });

    if (item.source === 'agent') {
      const { channel } = parseUserIdPrefix(item.userId);

      if (channel === 'telegram' || channel === 'api' || !channel) {
        // Parse gapType from item context if available
        let gapType: string | undefined;
        let urgency: 'low' | 'medium' | 'high' = 'low';
        let source: 'inner_thoughts' | 'gap_scanner' | 'task_result' = sourceOverride ?? 'gap_scanner';
        if (item.context) {
          try {
            const ctx = JSON.parse(item.context) as Record<string, unknown>;
            gapType = ctx.gapType as string | undefined;
            if (ctx.urgency === 'high' || ctx.urgency === 'medium' || ctx.urgency === 'low') {
              urgency = ctx.urgency as 'low' | 'medium' | 'high';
            }
            if (!sourceOverride) {
              // Unified evaluator writes source: 'proactive_evaluator'.
              // Classify by gapType: session follow-ups → inner_thoughts, rest → gap_scanner.
              if (ctx.source === 'inner_thoughts' || ctx.source === 'proactive_evaluator') {
                source = gapType === 'unresolved_thread' ? 'inner_thoughts' : 'gap_scanner';
              }
            }
          } catch {
            // Context parsing failed, use defaults
          }
        }

        const formatInput: ProactiveFormatInput = {
          message,
          gapType,
          urgency,
          source,
        };

        const formatted = formatProactiveMessage((channel ?? 'telegram') as 'telegram' | 'api', formatInput);
        await this.onSendMessage(item.userId, formatted);
        return;
      }
    }

    // User-sourced items or unknown channels: send as-is
    await this.onSendMessage(item.userId, message);
  }

  /**
   * Check if a similar message was sent to this user recently.
   */
  private isDuplicateSend(userId: string, message: string): boolean {
    const now = Date.now();
    const recent = this.recentSends.get(userId);
    if (!recent) return false;

    // Prune expired entries. If all expired, drop the user entry entirely so
    // the outer Map doesn't grow without bound for users who never return.
    const valid = recent.filter(r => now - r.time < SEND_DEDUP_WINDOW_MS);
    if (valid.length === 0) {
      this.recentSends.delete(userId);
    } else if (valid.length !== recent.length) {
      this.recentSends.set(userId, valid);
    }

    return valid.some(r => wordOverlap(r.message, message, { minWordLength: 2 }) >= SEND_DEDUP_THRESHOLD);
  }

  /**
   * Has an agent-sourced proactive message been sent to this user within the
   * min-gap window? User-source items are excluded from the check.
   */
  private isWithinMinAgentGap(userId: string): boolean {
    if (this.minAgentProactiveGapMs <= 0) return false;
    const recent = this.recentSends.get(userId);
    if (!recent) return false;
    const cutoff = Date.now() - this.minAgentProactiveGapMs;
    return recent.some(r => r.source === 'agent' && r.time >= cutoff);
  }

  /**
   * Record a message as sent for future dedup checks. Caps the per-user list
   * and the top-level user count to prevent unbounded growth. Also persists
   * to SQLite so the dedup state survives a restart.
   */
  private recordSend(userId: string, message: string, source: string = 'agent'): void {
    const now = Date.now();
    const list = this.recentSends.get(userId) || [];
    list.push({ message, time: now, source });
    // Per-user cap: only the last 20 messages matter for dedup.
    if (list.length > 20) list.splice(0, list.length - 20);
    this.recentSends.set(userId, list);

    // Persist for restart-time hydration. Best-effort: failure to persist
    // shouldn't block the send.
    try {
      this.db.recordProactiveSend(userId, message, source, now);
    } catch (err) {
      this.logger.warn({ err: (err as Error).message }, 'Failed to persist proactive send log entry');
    }

    // Global cap: evict the oldest user entry (Map iteration order is
    // insertion order) if we've somehow accumulated too many.
    while (this.recentSends.size > UnifiedScheduler.MAX_RECENT_SEND_USERS) {
      const oldest = this.recentSends.keys().next().value;
      if (oldest === undefined) break;
      this.recentSends.delete(oldest);
    }
  }

  /**
   * Mark item as fired and reschedule if recurring
   */
  private markItemFiredAndReschedule(item: ScheduledItem): void {
    this.db.markScheduledItemFired(item.id);
    this.logger.info(
      { itemId: item.id, userId: item.userId, source: item.source, type: item.type, kind: item.kind },
      'Scheduled item fired'
    );

    // Handle recurring items
    if (item.recurring) {
      const nextTriggerAt = this.calculateNextOccurrence(item.recurring, item.userId);
      if (nextTriggerAt) {
        // Skip reschedule if a similar item already exists (prevents duplication)
        if (this.db.hasSimilarPendingScheduledItem(item.userId, item.message)) {
          this.logger.debug({ itemId: item.id }, 'Skipping reschedule - similar item already pending');
          return;
        }
        // Create a new item for the next occurrence (preserves board fields)
        this.db.addScheduledItem({
          userId: item.userId,
          sessionId: item.sessionId,
          source: item.source,
          kind: item.kind,
          type: item.type,
          message: item.message,
          context: item.context,
          triggerAt: nextTriggerAt,
          recurring: item.recurring,
          sourceMemoryId: item.sourceMemoryId,
          taskConfig: item.taskConfig,
          boardStatus: 'scheduled',
          priority: item.priority,
          labels: item.labels,
          goalId: item.goalId,
        });
        this.logger.debug(
          { itemId: item.id, nextTrigger: new Date(nextTriggerAt).toISOString() },
          'Rescheduled recurring item'
        );
      }
    }

    // Reschedule goal check-ins through goal service
    if (item.type === 'goal_checkin' && item.context && this.goalService) {
      try {
        const context = JSON.parse(item.context);
        if (context.goalId) {
          this.goalService.rescheduleCheckin(context.goalId).catch(err => {
            this.logger.error({ goalId: context.goalId, error: (err as Error).message }, 'Failed to reschedule goal check-in');
          });
          this.logger.debug({ goalId: context.goalId }, 'Goal check-in rescheduled');
        }
      } catch {
        // Context parsing failed, skip rescheduling
      }
    }
  }

  /**
   * Calculate the next occurrence for a recurring schedule
   */
  private calculateNextOccurrence(schedule: RecurringSchedule, userId: string): number | null {
    const tz = this.getTimezone(userId);

    // Get current date/time components in the user's timezone
    const now = new Date();
    const formatter = new Intl.DateTimeFormat('en-US', {
      timeZone: tz,
      year: 'numeric', month: '2-digit', day: '2-digit',
      hour: '2-digit', minute: '2-digit', second: '2-digit',
      hour12: false,
      weekday: 'short',
    });
    const parts = formatter.formatToParts(now);
    const getPart = (type: string) => parts.find(p => p.type === type)?.value || '';

    const userYear = parseInt(getPart('year'), 10);
    const userMonth = parseInt(getPart('month'), 10) - 1;
    const userDay = parseInt(getPart('day'), 10);
    const userHour = parseInt(getPart('hour'), 10);
    const userMinute = parseInt(getPart('minute'), 10);

    // Guard against NaN from unexpected Intl.DateTimeFormat output
    if ([userYear, userMonth, userDay, userHour, userMinute].some(Number.isNaN)) {
      this.logger.warn({ tz }, 'Failed to parse timezone date parts, falling back to system time');
      return now.getTime() + 24 * 60 * 60 * 1000;
    }

    const dayNames: Record<string, number> = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
    const userDayOfWeek = dayNames[getPart('weekday')] ?? now.getDay();

    // Helper: build a UTC timestamp for a given date in the user's timezone.
    // Uses Intl.DateTimeFormat.formatToParts offset-diff pattern so it's
    // independent of the Node process's system timezone.
    const toUtc = (y: number, m: number, d: number, h: number, min: number): number => {
      const approxUtcMs = Date.UTC(y, m, d, h, min);
      const partsFmt = new Intl.DateTimeFormat('en-US', {
        timeZone: tz,
        hourCycle: 'h23',
        year: 'numeric', month: '2-digit', day: '2-digit',
        hour: '2-digit', minute: '2-digit', second: '2-digit',
      });
      const p = partsFmt.formatToParts(new Date(approxUtcMs));
      const g = (t: string) => parseInt(p.find(x => x.type === t)?.value || '0', 10);
      const tzAsUtcMs = Date.UTC(g('year'), g('month') - 1, g('day'), g('hour'), g('minute'), g('second'));
      const offsetMs = tzAsUtcMs - approxUtcMs;
      return approxUtcMs - offsetMs;
    };

    // Start from "today" in user timezone, at the scheduled time.
    // Use UTC date math so advance() doesn't depend on system TZ.
    let targetDay = userDay;
    let targetMonth = userMonth;
    let targetYear = userYear;
    const advance = () => {
      const d = new Date(Date.UTC(targetYear, targetMonth, targetDay + 1));
      targetDay = d.getUTCDate();
      targetMonth = d.getUTCMonth();
      targetYear = d.getUTCFullYear();
    };
    const getDow = () => new Date(Date.UTC(targetYear, targetMonth, targetDay)).getUTCDay();

    switch (schedule.type) {
      case 'daily':
        advance(); // tomorrow
        break;

      case 'weekly':
        if (schedule.dayOfWeek !== undefined) {
          let daysUntil = schedule.dayOfWeek - userDayOfWeek;
          if (daysUntil <= 0) daysUntil += 7;
          for (let i = 0; i < daysUntil; i++) advance();
        } else {
          for (let i = 0; i < 7; i++) advance();
        }
        break;

      case 'weekdays':
        advance();
        while (getDow() === 0 || getDow() === 6) advance();
        break;

      case 'weekends':
        advance();
        while (getDow() !== 0 && getDow() !== 6) advance();
        break;

      default:
        return null;
    }

    return toUtc(targetYear, targetMonth, targetDay, schedule.hour, schedule.minute);
  }

  /**
   * Manually trigger evaluation (for testing)
   */
  async evaluateNow(): Promise<void> {
    await this.evaluate();
  }

  /**
   * Check for user engagement with recently-fired proactive items.
   * Call this when a user sends a message to detect engagement.
   *
   * For each recently-fired agent item within the engagement window,
   * marks it as 'acted' to close the trust feedback loop.
   */
  checkEngagement(userId: string): void {
    try {
      const items = this.db.getScheduledItemsByUser(userId);
      const actedIds = detectProactiveEngagement(userId, items);
      for (const id of actedIds) {
        this.db.markScheduledItemActed(id);
      }
      if (actedIds.length > 0) {
        this.logger.debug({ userId, actedCount: actedIds.length }, 'Proactive engagement detected');
      }
    } catch (err) {
      this.logger.warn({ error: (err as Error).message, userId }, 'Engagement detection failed');
    }
  }

  /**
   * Get count of pending items
   */
  getPendingCount(): number {
    return this.db.getDueScheduledItems().length;
  }

  /**
   * Check if scheduler is running
   */
  getIsRunning(): boolean {
    return this.isRunning;
  }

  /**
   * Send a morning digest of unnotified completed items.
   * Call this at quiet hours end (e.g., 7 AM) to consolidate overnight results.
   */
  async sendMorningDigest(userId: string): Promise<number> {
    const unnotified = this.db.getUnnotifiedCompletedItems(userId);
    if (unnotified.length === 0) return 0;

    const lines: string[] = ['While you were away:'];

    for (const item of unnotified) {
      const result = item.result;
      const title = item.message;
      if (result) {
        // Sanitize and truncate the result for the digest
        const clean = sanitizeAgentResponse(result.response);
        if (clean) {
          const truncated = clean.length > 200 ? clean.slice(0, 200) + '…' : clean;
          lines.push(`- ${title}: ${truncated}`);
        } else {
          // Result was all internal markup/errors — just show the title
          lines.push(`- ${title}`);
        }
      } else {
        lines.push(`- ${title}`);
      }

      // Mark as notified
      if (result) {
        const updatedResult: BoardItemResult = { ...result, notifiedAt: Date.now() };
        this.db.updateScheduledItemResult(item.id, updatedResult);
      }
    }

    if (lines.length > 1) {
      await this.onSendMessage(userId, lines.join('\n'));
    }

    this.logger.info({ userId, count: unnotified.length }, 'Sent morning digest');
    return unnotified.length;
  }

  /**
   * Get the board service instance (for use by cognitive layer)
   */
  getBoardService(): BoardService {
    return this.boardService;
  }
}
