Ctrl K
ComunidadGuías…

server/src/scheduler/tasks.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import logger from '../utils/logger';
import rulesRepo from '../db/repositories/rules';
import mediaItemsRepo from '../db/repositories/mediaItems';
import storageSnapshotsRepo from '../db/repositories/storageSnapshots';
import unraidSnapshotsRepo from '../db/repositories/unraidSnapshots';
import settingsRepo from '../db/repositories/settings';
import { loadInProgressConfig, isProtectedInProgress } from '../rules/inProgress';
import { getPressureUsages, readArrStorage, storageAvailability } from '../services/storage';
import { UnraidService } from '../services/unraid';
import { getDeletionService } from '../services/deletion';
import { processDueEpisodeDeletions } from '../services/episodeDeletions';
import { PlexUsersService } from '../services/plexUsers';
import { isSyncInProgress, runLibrarySync } from '../services/syncCoordinator';
import { logActivity } from '../db/repositories/activity';
import { evaluateNode, evaluateRuleConditions, upgradeToV2 } from '../rules/engine';
import { ruleUsesDiskPressure, type DiskPressureState } from '../rules/diskPressure';
import { ruleScopeMatches } from '../rules/scope';
import { buildEvaluationContext } from '../rules/context';
import { getNotificationService } from '../notifications';
import { sendHeartbeat } from '../services/telemetry';
import { refreshAnnouncements as fetchAnnouncementsFeed } from '../services/announcements';
import { resolveTargetBytes, GiB, type FsUsage, type TargetMode } from '../services/diskSpace';
import { DeletionAction } from '../rules/types';
import type { DiskPressureData } from '../notifications/templates';
import type { EvaluationContext } from '../rules/conditions';
import type { MediaItem, Rule } from '../types';

// ============================================================================
// Task Result Types
// ============================================================================

export interface TaskResult {
  success: boolean;
  taskName: string;
  startedAt: Date;
  completedAt: Date;
  durationMs: number;
  message?: string;
  error?: string;
  data?: Record<string, unknown>;
}

export interface ScanResult extends TaskResult {
  data: {
    itemsScanned: number;
    itemsFlagged: number;
    itemsProtected: number;
  };
}

export interface DeletionProcessingResult extends TaskResult {
  data: {
    itemsProcessed: number;
    itemsDeleted: number;
    spaceFreedBytes: number;
    errors: Array<{ itemId: number; error: string }>;
  };
}

export interface ReminderResult extends TaskResult {
  data: {
    remindersSent: number;
    itemsPendingDeletion: number;
  };
}

// ============================================================================
// Notification Service Dependency
// ============================================================================

export interface TaskDependencies {
  notificationService?: {
    notify(event: string, data: Record<string, unknown>): Promise<void>;
  };
}

let dependencies: TaskDependencies = {};

/**
 * Set task dependencies (notification service)
 */
export function setTaskDependencies(deps: TaskDependencies): void {
  dependencies = deps;
  logger.info('Task dependencies configured');
}

/**
 * Get current task dependencies
 */
export function getTaskDependencies(): TaskDependencies {
  return dependencies;
}

// ============================================================================
// Condition Evaluation (shared with scan route)
// ============================================================================

function getFieldValue(item: MediaItem, field: string): unknown {
  switch (field) {
    case 'type':
      return item.type;
    case 'title':
      return item.title;
    case 'file_size':
      return item.file_size;
    case 'size_gb':
      return item.file_size ? item.file_size / (1024 * 1024 * 1024) : null;
    case 'resolution':
      return item.resolution;
    case 'codec':
      return item.codec;
    case 'play_count':
      return item.play_count;
    case 'added_at':
      return item.added_at;
    case 'last_watched_at':
      return item.last_watched_at;
    case 'days_since_added':
      if (!item.added_at) return null;
      return Math.floor((Date.now() - new Date(item.added_at).getTime()) / (1000 * 60 * 60 * 24));
    case 'days_since_watched':
      if (!item.last_watched_at) return null;
      return Math.floor((Date.now() - new Date(item.last_watched_at).getTime()) / (1000 * 60 * 60 * 24));
    case 'never_watched':
      return item.play_count === 0;
    default:
      return undefined;
  }
}

function evaluateCondition(
  itemValue: unknown,
  operator: string,
  conditionValue: string | number | boolean
): boolean {
  switch (operator) {
    case 'equals':
      return itemValue === conditionValue;
    case 'not_equals':
      return itemValue !== conditionValue;
    case 'greater_than':
      return typeof itemValue === 'number' && typeof conditionValue === 'number' && itemValue > conditionValue;
    case 'less_than':
      return typeof itemValue === 'number' && typeof conditionValue === 'number' && itemValue < conditionValue;
    case 'contains':
      return typeof itemValue === 'string' && typeof conditionValue === 'string' && itemValue.toLowerCase().includes(conditionValue.toLowerCase());
    case 'not_contains':
      return typeof itemValue === 'string' && typeof conditionValue === 'string' && !itemValue.toLowerCase().includes(conditionValue.toLowerCase());
    case 'is_empty':
      return itemValue === null || itemValue === undefined || itemValue === '';
    case 'is_not_empty':
      return itemValue !== null && itemValue !== undefined && itemValue !== '';
    default:
      logger.warn(`Unknown operator: ${operator}`);
      return false;
  }
}

function evaluateConditions(
  item: MediaItem,
  conditions: Array<{ field: string; operator: string; value: string | number | boolean }>
): boolean {
  if (!item || conditions.length === 0) {
    return false;
  }

  for (const condition of conditions) {
    const itemValue = getFieldValue(item, condition.field);
    const matches = evaluateCondition(itemValue, condition.operator, condition.value);
    if (!matches) {
      return false;
    }
  }

  return true;
}

// ============================================================================
// Rule-triggered deletion queueing
// ============================================================================

/**
 * Rule info needed to queue an item for deletion.
 * Shared between scheduled scans, manual full scans, and "Run Rule Now".
 */
export interface QueueingRule {
  id: number;
  name: string;
  grace_period_days: number | null;
  deletion_action: string | null;
  reset_overseerr: boolean;
}

/**
 * Match recorded by a caller during a scan pass, to be batched into one
 * ITEMS_MARKED notification per scan.
 */
export interface QueuedMatch {
  item: MediaItem;
  rule: QueueingRule;
  deleteAfter: string;
}

/**
 * Queue a media item for deletion based on a rule match.
 * Sets all the metadata required by the deletion queue (marked_at, delete_after,
 * deletion_action, reset_overseerr, matched_rule_id) and logs to activity.
 *
 * Shared between scheduled scans (scheduler/tasks.ts) and manual scans (routes/scan.ts)
 * so rule-triggered deletions properly flow through the queue → history → notifications.
 */
export function queueItemForDeletion(
  item: MediaItem,
  rule: QueueingRule
): { deleteAfter: string } {
  const gracePeriodDays = rule.grace_period_days ?? 7;
  const deletionAction = rule.deletion_action ?? 'unmonitor_and_delete';
  const resetOverseerr = rule.reset_overseerr ? 1 : 0;

  const now = new Date();
  const deleteAfter = new Date(now);
  deleteAfter.setDate(deleteAfter.getDate() + gracePeriodDays);
  const deleteAfterIso = deleteAfter.toISOString();

  // Preserve existing marked_at if the item is already queued (idempotent re-runs)
  const isAlreadyQueued = item.status === 'pending_deletion';
  const markedAt = isAlreadyQueued ? (item.marked_at ?? now.toISOString()) : now.toISOString();

  mediaItemsRepo.update(item.id, {
    status: 'pending_deletion',
    marked_at: markedAt,
    delete_after: deleteAfterIso,
    deletion_action: deletionAction,
    reset_overseerr: resetOverseerr,
    matched_rule_id: rule.id,
  } as any);

  try {
    logActivity({
      eventType: 'rule_match',
      action: 'item_queued',
      actorType: 'rule',
      actorId: rule.id.toString(),
      actorName: rule.name,
      targetType: 'media_item',
      targetId: item.id,
      targetTitle: item.title,
      metadata: JSON.stringify({
        gracePeriodDays,
        deleteAfter: deleteAfterIso,
        deletionAction,
        resetOverseerr: Boolean(resetOverseerr),
      }),
    });
  } catch (activityError) {
    logger.warn('Failed to log activity for rule-triggered queue:', activityError);
  }

  return { deleteAfter: deleteAfterIso };
}

/**
 * Fire a single batched ITEMS_MARKED notification for all items queued during a
 * scan. Groups items by rule so one Discord message summarizes the whole scan.
 * No-ops when there are no matches. Falls back to the global notification
 * service if task dependencies haven't been wired yet (e.g. the route fires
 * before the scheduler DI runs — DI still preferred when set for testability).
 */
export async function notifyItemsQueued(matches: QueuedMatch[]): Promise<void> {
  if (matches.length === 0) {
    return;
  }

  const notifier = dependencies.notificationService ?? {
    notify: (event: string, data: Record<string, unknown>) =>
      getNotificationService().notify(event as any, data).then(() => undefined),
  };

  const groupMap = new Map<
    number,
    {
      ruleId: number;
      ruleName: string;
      gracePeriodDays: number;
      deleteAfter: string;
      items: Array<{ id: number; title: string; type: string }>;
    }
  >();

  let latestDeleteAfter = matches[0]!.deleteAfter;
  for (const m of matches) {
    if (new Date(m.deleteAfter).getTime() > new Date(latestDeleteAfter).getTime()) {
      latestDeleteAfter = m.deleteAfter;
    }
    const existing = groupMap.get(m.rule.id);
    if (existing) {
      existing.items.push({ id: m.item.id, title: m.item.title, type: m.item.type });
    } else {
      groupMap.set(m.rule.id, {
        ruleId: m.rule.id,
        ruleName: m.rule.name,
        gracePeriodDays: m.rule.grace_period_days ?? 7,
        deleteAfter: m.deleteAfter,
        items: [{ id: m.item.id, title: m.item.title, type: m.item.type }],
      });
    }
  }

  const groups = Array.from(groupMap.values()).sort((a, b) => b.items.length - a.items.length);
  const count = matches.length;

  // Pick the grace period of the first (largest) group for the legacy top-level
  // fields, for back-compat with template fallbacks and downstream consumers.
  const primary = groups[0]!;

  try {
    await notifier.notify('ITEMS_MARKED', {
      groups,
      count,
      gracePeriodDays: primary.gracePeriodDays,
      deleteAfter: latestDeleteAfter,
    });
  } catch (notifyError) {
    logger.error('Failed to send batched ITEMS_MARKED notification:', notifyError);
  }
}

// ============================================================================
// Library Scan Task
// ============================================================================

/**
 * Run a full library scan and evaluate all items against configured rules.
 * Uses the database repos directly (same approach as manual scan in scan route).
 */
export async function scanLibraries(): Promise<ScanResult> {
  const startedAt = new Date();
  const taskName = 'scanLibraries';

  logger.info('Starting library scan task');

  // Create scan history record
  const scan = rulesRepo.scanHistory.start();
  const scanId = scan.id;

  try {
    // Log scan start to activity log
    try {
      logActivity({
        eventType: 'scan',
        action: 'started',
        actorType: 'scheduler',
        actorId: 'system',
        actorName: 'Scheduled Scan',
        targetType: null,
        targetId: null,
        targetTitle: null,
        metadata: JSON.stringify({ scanId }),
      });
    } catch (activityError) {
      logger.warn('Failed to log scan start activity:', activityError);
    }

    // Get all enabled rules. Rules with a Disk pressure condition are left to
    // the disk-pressure monitor, which only removes enough to recover.
    const enabledRules = rulesRepo.rules.getEnabled().filter((rule) => !ruleUsesDiskPressure(rule));
    logger.info(`Found ${enabledRules.length} enabled rule(s)`);

    // Get all monitored media items
    const mediaItems = mediaItemsRepo.fetchAll({ status: 'monitored' as any });
    logger.info(`Found ${mediaItems.length} monitored media item(s)`);

    // Load exclusion patterns
    const exclusionPatterns = loadExclusionPatterns();
    if (exclusionPatterns.length > 0) {
      logger.info(`Loaded ${exclusionPatterns.length} exclusion pattern(s)`);
    }
    const inProgress = loadInProgressConfig();

    // Built once for the whole scan — includes the watch lookup, without which
    // watched_by conditions treat every item as never-watched.
    const ctx: EvaluationContext = buildEvaluationContext();

    let itemsScanned = 0;
    let itemsFlagged = 0;
    let itemsProtected = 0;
    const queuedMatches: QueuedMatch[] = [];

    for (const item of mediaItems) {
      itemsScanned++;

      // Skip protected items
      if (item.is_protected) {
        itemsProtected++;
        continue;
      }

      // Skip items matching exclusion patterns
      if (exclusionPatterns.length > 0 && matchesExclusionPattern(item, exclusionPatterns)) {
        itemsProtected++;
        continue;
      }

      // Skip shows someone is part-way through (Settings → Safety)
      if (isProtectedInProgress(item, inProgress)) {
        itemsProtected++;
        continue;
      }

      // Check each rule
      for (const rule of enabledRules) {
        // Skip rule if the item is outside its scope (media type / libraries)
        if (!ruleScopeMatches(rule, item)) {
          continue;
        }

        const matches = evaluateRuleConditions(rule.conditions, item, ctx);

        if (matches) {
          logger.debug(`Rule "${rule.name}" matched item "${item.title}"`);

          switch (rule.action) {
            case 'flag':
              mediaItemsRepo.updateStatus(item.id, 'flagged');
              itemsFlagged++;
              break;
            case 'delete': {
              const { deleteAfter } = queueItemForDeletion(item, rule);
              queuedMatches.push({ item, rule, deleteAfter });
              itemsFlagged++;
              break;
            }
            case 'notify':
              logger.info(`Notification triggered for "${item.title}" by rule "${rule.name}"`);
              break;
          }

          // Only apply first matching rule
          break;
        }
      }
    }

    // One batched ITEMS_MARKED notification for the whole scan
    await notifyItemsQueued(queuedMatches);

    // Complete the scan history record
    rulesRepo.scanHistory.complete(scanId, itemsScanned, itemsFlagged);

    const completedAt = new Date();
    const durationMs = completedAt.getTime() - startedAt.getTime();

    // Send notification (respecting scan notification preference)
    const scanNotify = settingsRepo.getValue('notifications_scanNotify') || 'flagged_only';
    if (dependencies.notificationService && scanNotify !== 'never') {
      if (scanNotify === 'always' || itemsFlagged > 0) {
        try {
          await dependencies.notificationService.notify('SCAN_COMPLETE', {
            itemsScanned,
            itemsFlagged,
            itemsProtected,
            durationMs,
          });
        } catch (notifyError) {
          logger.error('Failed to send scan complete notification:', notifyError);
        }
      }
    }

    // Log scan completion to activity log
    try {
      logActivity({
        eventType: 'scan',
        action: 'completed',
        actorType: 'scheduler',
        actorId: 'system',
        actorName: 'Scheduled Scan',
        targetType: 'scan',
        targetId: scanId,
        targetTitle: null,
        metadata: JSON.stringify({ itemsScanned, itemsFlagged, itemsProtected, duration: durationMs }),
      });
    } catch (activityError) {
      logger.warn('Failed to log scan completion activity:', activityError);
    }

    logger.info(
      `Library scan completed: ${itemsScanned} items scanned, ` +
        `${itemsFlagged} flagged, ${itemsProtected} protected (${durationMs}ms)`
    );

    return {
      success: true,
      taskName,
      startedAt,
      completedAt,
      durationMs,
      message: `Scan completed successfully`,
      data: {
        itemsScanned,
        itemsFlagged,
        itemsProtected,
      },
    };
  } catch (error) {
    const completedAt = new Date();
    const durationMs = completedAt.getTime() - startedAt.getTime();
    const errorMessage = error instanceof Error ? error.message : String(error);

    logger.error('Library scan task failed:', error);

    // Update scan history with failure
    rulesRepo.scanHistory.fail(scanId);

    // Log scan failure to activity log
    try {
      logActivity({
        eventType: 'scan',
        action: 'failed',
        actorType: 'scheduler',
        actorId: 'system',
        actorName: 'Scheduled Scan',
        targetType: 'scan',
        targetId: scanId,
        targetTitle: null,
        metadata: JSON.stringify({ error: errorMessage, duration: durationMs }),
      });
    } catch (activityError) {
      logger.warn('Failed to log scan failure activity:', activityError);
    }

    // Send error notification
    if (dependencies.notificationService) {
      try {
        await dependencies.notificationService.notify('SCAN_ERROR', {
          error: errorMessage,
          phase: 'scanning',
          itemsScannedBeforeError: 0,
          timestamp: new Date().toISOString(),
        });
      } catch (notifyError) {
        logger.error('Failed to send scan error notification:', notifyError);
      }
    }

    return {
      success: false,
      taskName,
      startedAt,
      completedAt,
      durationMs,
      error: errorMessage,
      data: {
        itemsScanned: 0,
        itemsFlagged: 0,
        itemsProtected: 0,
      },
    };
  }
}

// ============================================================================
// Deletion Processing Task
// ============================================================================

/**
 * Process items that have passed their grace period and are ready for deletion.
 * Uses the DeletionService singleton directly.
 */
export async function processDeletionQueue(): Promise<DeletionProcessingResult> {
  const startedAt = new Date();
  const taskName = 'processDeletionQueue';

  logger.info('Starting deletion queue processing task');

  try {
    const deletionService = getDeletionService();

    // Snapshot the queue BEFORE deletion so we can enrich the notification with
    // item types and matched rule names (the DeletionResult stream only has title + id).
    const preSnapshot = await deletionService.getPendingDeletions();
    const snapshotById = new Map(preSnapshot.map((p) => [p.id, p]));

    // Process pending deletions (not a dry run)
    const results = await deletionService.processPendingDeletions(false);

    // Queued episode/season deletions expire on the same schedule as whole
    // items. A failure in that queue must not cost us the item deletions that
    // already ran, so it degrades to "nothing processed" and is logged.
    let episodeResult = { deleted: 0, freedBytes: 0, outcomes: [] as Array<{ episodeId: number; success: boolean; error?: string }> };
    try {
      episodeResult = await processDueEpisodeDeletions();
    } catch (episodeError) {
      logger.error('Failed to process queued episode deletions:', episodeError);
    }

    const itemsDeleted = results.filter((r) => r.success).length + episodeResult.deleted;
    const spaceFreedBytes =
      results
        .filter((r) => r.success && r.fileSizeFreed)
        .reduce((sum, r) => sum + (r.fileSizeFreed || 0), 0) + episodeResult.freedBytes;
    const errors = results
      .filter((r) => !r.success)
      .map((r) => ({ itemId: r.itemId, error: r.error || 'Unknown error' }))
      .concat(
        episodeResult.outcomes
          .filter((o) => !o.success)
          .map((o) => ({ itemId: o.episodeId, error: o.error || 'Unknown error' }))
      );

    const completedAt = new Date();
    const durationMs = completedAt.getTime() - startedAt.getTime();

    // Send notification if any items were deleted. Falls back to the global
    // notification service when DI hasn't been wired yet so the notification
    // never silently no-ops on us.
    if (itemsDeleted > 0) {
      const notifier = dependencies.notificationService ?? {
        notify: (event: string, data: Record<string, unknown>) =>
          getNotificationService().notify(event as any, data).then(() => undefined),
      };

      // Resolve rule names once per ruleId (sync DB lookups — cheap, small cache)
      const ruleNameCache = new Map<number, string>();
      const resolveRuleName = (ruleId: number | undefined): string | undefined => {
        if (ruleId === undefined) return undefined;
        if (!ruleNameCache.has(ruleId)) {
          const rule = rulesRepo.rules.getById(ruleId);
          ruleNameCache.set(ruleId, rule?.name ?? `Rule #${ruleId}`);
        }
        return ruleNameCache.get(ruleId);
      };

      const notifyItems = results
        .filter((r) => r.success)
        .map((r) => {
          const snap = snapshotById.get(r.itemId);
          return {
            title: r.title,
            type: snap?.mediaItem.type ?? 'unknown',
            ruleName: resolveRuleName(snap?.ruleId),
          };
        });

      try {
        await notifier.notify('DELETION_COMPLETE', {
          itemsDeleted,
          spaceFreedBytes,
          spaceFreedGB: (spaceFreedBytes / (1024 * 1024 * 1024)).toFixed(2),
          errors: errors.length,
          items: notifyItems,
        });
      } catch (notifyError) {
        logger.error('Failed to send deletion complete notification:', notifyError);
      }
    }

    logger.info(
      `Deletion queue processing completed: ${itemsDeleted} items deleted, ` +
        `${(spaceFreedBytes / (1024 * 1024 * 1024)).toFixed(2)}GB freed (${durationMs}ms)`
    );

    return {
      success: errors.length === 0,
      taskName,
      startedAt,
      completedAt,
      durationMs,
      message: `Processed ${results.length} items, deleted ${itemsDeleted}`,
      data: {
        itemsProcessed: results.length,
        itemsDeleted,
        spaceFreedBytes,
        errors,
      },
    };
  } catch (error) {
    const completedAt = new Date();
    const durationMs = completedAt.getTime() - startedAt.getTime();
    const errorMessage = error instanceof Error ? error.message : String(error);

    logger.error('Deletion queue processing task failed:', error);

    // Send error notification
    if (dependencies.notificationService) {
      try {
        await dependencies.notificationService.notify('DELETION_ERROR', {
          error: errorMessage,
          itemsProcessedBeforeError: 0,
          timestamp: new Date().toISOString(),
        });
      } catch (notifyError) {
        logger.error('Failed to send deletion error notification:', notifyError);
      }
    }

    return {
      success: false,
      taskName,
      startedAt,
      completedAt,
      durationMs,
      error: errorMessage,
      data: {
        itemsProcessed: 0,
        itemsDeleted: 0,
        spaceFreedBytes: 0,
        errors: [],
      },
    };
  }
}

// ============================================================================
// Deletion Reminder Task
// ============================================================================

/**
 * Send notifications for items with upcoming deletions.
 * Uses the DeletionService singleton directly.
 */
export async function sendDeletionReminders(): Promise<ReminderResult> {
  const startedAt = new Date();
  const taskName = 'sendDeletionReminders';

  logger.info('Starting deletion reminder task');

  try {
    const deletionService = getDeletionService();

    // Falls back to the global notification service when DI hasn't been wired
    // (matches the pattern used by notifyItemsQueued and processDeletionQueue).
    const notifier = dependencies.notificationService ?? {
      notify: (event: string, data: Record<string, unknown>) =>
        getNotificationService().notify(event as any, data).then(() => undefined),
    };

    // Get items in deletion queue
    const queue = await deletionService.getQueue();

    // Filter items that are due for deletion within the next 24 hours
    const imminentDeletions = queue.filter((item) => item.daysRemaining <= 1);

    // Filter items that are due for deletion within the next 3 days (for warning)
    const upcomingDeletions = queue.filter(
      (item) => item.daysRemaining > 1 && item.daysRemaining <= 3
    );

    let remindersSent = 0;

    // Send imminent deletion notifications
    if (imminentDeletions.length > 0) {
      await notifier.notify('DELETION_IMMINENT', {
        items: imminentDeletions.map((item) => ({
          id: item.id,
          title: item.mediaItem.title,
          daysRemaining: item.daysRemaining,
        })),
        count: imminentDeletions.length,
        urgency: 'high',
      });
      remindersSent++;
    }

    // Send upcoming deletion warnings
    if (upcomingDeletions.length > 0) {
      await notifier.notify('DELETION_IMMINENT', {
        items: upcomingDeletions.map((item) => ({
          id: item.id,
          title: item.mediaItem.title,
          daysRemaining: item.daysRemaining,
        })),
        count: upcomingDeletions.length,
        urgency: 'medium',
      });
      remindersSent++;
    }

    const completedAt = new Date();
    const durationMs = completedAt.getTime() - startedAt.getTime();

    logger.info(
      `Deletion reminders sent: ${remindersSent} notifications, ` +
        `${queue.length} items pending deletion (${durationMs}ms)`
    );

    return {
      success: true,
      taskName,
      startedAt,
      completedAt,
      durationMs,
      message: `Sent ${remindersSent} reminder notifications`,
      data: {
        remindersSent,
        itemsPendingDeletion: queue.length,
      },
    };
  } catch (error) {
    const completedAt = new Date();
    const durationMs = completedAt.getTime() - startedAt.getTime();
    const errorMessage = error instanceof Error ? error.message : String(error);

    logger.error('Deletion reminder task failed:', error);

    return {
      success: false,
      taskName,
      startedAt,
      completedAt,
      durationMs,
      error: errorMessage,
      data: {
        remindersSent: 0,
        itemsPendingDeletion: 0,
      },
    };
  }
}

// ============================================================================
// Storage Snapshot Task
// ============================================================================

/**
 * Capture a storage snapshot and prune old ones.
 */
export async function captureStorageSnapshot(): Promise<TaskResult> {
  const startedAt = new Date();
  const taskName = 'captureStorageSnapshot';

  logger.info('Starting storage snapshot capture');

  try {
    const snapshot = storageSnapshotsRepo.capture();
    storageSnapshotsRepo.pruneOld(90);

    const completedAt = new Date();
    const durationMs = completedAt.getTime() - startedAt.getTime();

    logger.info(`Storage snapshot captured in ${durationMs}ms`);

    return {
      success: true,
      taskName,
      startedAt,
      completedAt,
      durationMs,
      message: 'Storage snapshot captured successfully',
      data: {
        totalSize: snapshot.total_size,
        itemCount: snapshot.item_count,
      },
    };
  } catch (error) {
    const completedAt = new Date();
    const durationMs = completedAt.getTime() - startedAt.getTime();
    const errorMessage = error instanceof Error ? error.message : String(error);

    logger.error('Storage snapshot capture failed:', error);

    return {
      success: false,
      taskName,
      startedAt,
      completedAt,
      durationMs,
      error: errorMessage,
    };
  }
}

/**
 * Capture a daily capacity snapshot for the trend/forecast UI, from Unraid
 * and from Sonarr/Radarr's drives, whichever are connected. Idempotent per
 * day per source.
 */
export async function captureUnraidCapacitySnapshot(): Promise<TaskResult> {
  const startedAt = new Date();
  const taskName = 'captureUnraidCapacitySnapshot';

  // Sonarr/Radarr drives: reading them records the day's snapshot.
  if (storageAvailability().arr && !unraidSnapshotsRepo.hasTodaySnapshot('arr')) {
    try {
      await readArrStorage();
      unraidSnapshotsRepo.pruneOld(400);
    } catch (error) {
      logger.warn('Sonarr/Radarr capacity snapshot failed:', error);
    }
  }

  const url = settingsRepo.getValue('unraid_url');
  const apiKey = settingsRepo.getValue('unraid_apiKey');

  if (!url || !apiKey) {
    const completedAt = new Date();
    return {
      success: true,
      taskName,
      startedAt,
      completedAt,
      durationMs: completedAt.getTime() - startedAt.getTime(),
      message: 'Unraid not configured, skipping',
    };
  }

  if (unraidSnapshotsRepo.hasTodaySnapshot()) {
    const completedAt = new Date();
    return {
      success: true,
      taskName,
      startedAt,
      completedAt,
      durationMs: completedAt.getTime() - startedAt.getTime(),
      message: 'Snapshot already captured today',
    };
  }

  try {
    const service = new UnraidService(url, apiKey);
    const stats = await service.getArrayStats();
    const KB = 1024;
    const total = stats.capacity.kilobytes.total * KB;
    const used = stats.capacity.kilobytes.used * KB;
    const free = stats.capacity.kilobytes.free * KB;

    unraidSnapshotsRepo.capture({ total, used, free });
    unraidSnapshotsRepo.pruneOld(400);

    const completedAt = new Date();
    return {
      success: true,
      taskName,
      startedAt,
      completedAt,
      durationMs: completedAt.getTime() - startedAt.getTime(),
      message: 'Unraid capacity snapshot captured',
      data: { totalBytes: total, usedBytes: used, freeBytes: free },
    };
  } catch (error) {
    const completedAt = new Date();
    const errorMessage = error instanceof Error ? error.message : String(error);
    logger.error('Unraid capacity snapshot failed:', error);
    return {
      success: false,
      taskName,
      startedAt,
      completedAt,
      durationMs: completedAt.getTime() - startedAt.getTime(),
      error: errorMessage,
    };
  }
}

// ============================================================================
// Exclusion Pattern Helpers
// ============================================================================

export interface ExclusionPattern {
  field: 'title' | 'type';
  operator: 'contains' | 'equals' | 'starts_with' | 'ends_with' | 'regex';
  value: string;
}

/**
 * Load exclusion patterns from settings
 */
export function loadExclusionPatterns(): ExclusionPattern[] {
  try {
    const raw = settingsRepo.getValue('exclusion_patterns');
    if (!raw) return [];
    return JSON.parse(raw) as ExclusionPattern[];
  } catch {
    return [];
  }
}

/**
 * Check if a media item matches any exclusion pattern
 */
export function matchesExclusionPattern(item: MediaItem, patterns: ExclusionPattern[]): boolean {
  for (const pattern of patterns) {
    const fieldValue = pattern.field === 'title' ? item.title : item.type;
    if (!fieldValue) continue;

    let matches = false;
    switch (pattern.operator) {
      case 'contains':
        matches = fieldValue.toLowerCase().includes(pattern.value.toLowerCase());
        break;
      case 'equals':
        matches = fieldValue.toLowerCase() === pattern.value.toLowerCase();
        break;
      case 'starts_with':
        matches = fieldValue.toLowerCase().startsWith(pattern.value.toLowerCase());
        break;
      case 'ends_with':
        matches = fieldValue.toLowerCase().endsWith(pattern.value.toLowerCase());
        break;
      case 'regex':
        try {
          matches = new RegExp(pattern.value, 'i').test(fieldValue);
        } catch {
          matches = false;
        }
        break;
    }

    if (matches) return true;
  }
  return false;
}

// ============================================================================
// Plex Users Sync Task
// ============================================================================

/**
 * Sync Plex users (owner + shared/home users) to the local database.
 * Requires Plex URL and token to be configured in settings.
 */
export async function syncPlexUsers(): Promise<TaskResult> {
  const startedAt = new Date();
  const taskName = 'syncPlexUsers';

  logger.info('Starting Plex users sync task');

  try {
    const plexUrl = settingsRepo.getValue('plex_url');
    const plexToken = settingsRepo.getValue('plex_token');

    if (!plexUrl || !plexToken) {
      const completedAt = new Date();
      const durationMs = completedAt.getTime() - startedAt.getTime();
      logger.warn('Plex users sync skipped: Plex not configured');
      return {
        success: true,
        taskName,
        startedAt,
        completedAt,
        durationMs,
        message: 'Skipped — Plex not configured',
        data: { usersSynced: 0 },
      };
    }

    const service = new PlexUsersService(plexUrl, plexToken);
    const users = await service.syncUsers();

    const completedAt = new Date();
    const durationMs = completedAt.getTime() - startedAt.getTime();

    logger.info(`Plex users sync completed: ${users.length} users (${durationMs}ms)`);

    return {
      success: true,
      taskName,
      startedAt,
      completedAt,
      durationMs,
      message: `Synced ${users.length} Plex user(s)`,
      data: { usersSynced: users.length },
    };
  } catch (error) {
    const completedAt = new Date();
    const durationMs = completedAt.getTime() - startedAt.getTime();
    const errorMessage = error instanceof Error ? error.message : String(error);

    logger.error('Plex users sync task failed:', error);

    return {
      success: false,
      taskName,
      startedAt,
      completedAt,
      durationMs,
      error: errorMessage,
      data: { usersSynced: 0 },
    };
  }
}

// ============================================================================
// Plex Library Sync Task
// ============================================================================

/**
 * Pull the catalog from Plex into the local DB on a schedule, so users don't
 * have to click "Sync Library" manually to pick up new movies/shows. Shares
 * the syncCoordinator's in-progress flag with the manual button so the two
 * can never run at the same time.
 */
export async function syncPlexLibrary(): Promise<TaskResult> {
  const startedAt = new Date();
  const taskName = 'syncPlexLibrary';

  logger.info('Starting scheduled Plex library sync');

  // Early-exit if another sync is already running so we don't write an orphan
  // 'started' activity entry that never gets a matching 'completed'. The
  // isSyncInProgress() check and the runLibrarySync() call below are both
  // synchronous in JS's single-threaded loop, so the coordinator's internal
  // 'busy' guard is only reached if we somehow miss here — handled below
  // as a defensive fallback.
  if (isSyncInProgress()) {
    const completedAt = new Date();
    const durationMs = completedAt.getTime() - startedAt.getTime();
    logger.warn('Scheduled Plex sync skipped: another sync is already running');
    try {
      logActivity({
        eventType: 'scan',
        action: 'skipped',
        actorType: 'scheduler',
        actorId: 'system',
        actorName: 'Scheduled Plex Sync',
        targetType: null,
        targetId: null,
        targetTitle: null,
        metadata: JSON.stringify({ reason: 'another sync is already running' }),
      });
    } catch (activityError) {
      logger.warn('Failed to log Plex sync skipped activity:', activityError);
    }
    return {
      success: true,
      taskName,
      startedAt,
      completedAt,
      durationMs,
      message: 'Skipped — another sync was already running',
      data: { skipped: true },
    };
  }

  try {
    logActivity({
      eventType: 'scan',
      action: 'started',
      actorType: 'scheduler',
      actorId: 'system',
      actorName: 'Scheduled Plex Sync',
      targetType: null,
      targetId: null,
      targetTitle: null,
      metadata: null,
    });
  } catch (activityError) {
    logger.warn('Failed to log Plex sync start activity:', activityError);
  }

  const outcome = await runLibrarySync();
  const completedAt = new Date();
  const durationMs = completedAt.getTime() - startedAt.getTime();

  // Defensive: a coordinator-level 'busy' should be impossible here given the
  // pre-check, but handle it anyway so the run is still represented in the log.
  if (outcome.status === 'busy') {
    try {
      logActivity({
        eventType: 'scan',
        action: 'skipped',
        actorType: 'scheduler',
        actorId: 'system',
        actorName: 'Scheduled Plex Sync',
        targetType: null,
        targetId: null,
        targetTitle: null,
        metadata: JSON.stringify({ reason: 'coordinator reported busy after pre-check' }),
      });
    } catch (activityError) {
      logger.warn('Failed to log Plex sync skipped activity:', activityError);
    }
    return {
      success: true,
      taskName,
      startedAt,
      completedAt,
      durationMs,
      message: 'Skipped — coordinator reported busy',
      data: { skipped: true },
    };
  }

  if (outcome.status === 'failed') {
    try {
      logActivity({
        eventType: 'scan',
        action: 'failed',
        actorType: 'scheduler',
        actorId: 'system',
        actorName: 'Scheduled Plex Sync',
        targetType: null,
        targetId: null,
        targetTitle: null,
        metadata: JSON.stringify({ error: outcome.error, duration: durationMs }),
      });
    } catch (activityError) {
      logger.warn('Failed to log Plex sync failure activity:', activityError);
    }

    return {
      success: false,
      taskName,
      startedAt,
      completedAt,
      durationMs,
      error: outcome.error,
    };
  }

  const result = outcome.result!;
  const errorCount = result.errors.length;

  try {
    logActivity({
      eventType: 'scan',
      action: 'completed',
      actorType: 'scheduler',
      actorId: 'system',
      actorName: 'Scheduled Plex Sync',
      targetType: null,
      targetId: null,
      targetTitle: null,
      metadata: JSON.stringify({
        itemsScanned: result.itemsScanned,
        itemsAdded: result.itemsAdded,
        itemsUpdated: result.itemsUpdated,
        errors: errorCount,
        duration: durationMs,
      }),
    });
  } catch (activityError) {
    logger.warn('Failed to log Plex sync completion activity:', activityError);
  }

  logger.info(
    `Scheduled Plex sync completed: ${result.itemsScanned} scanned, ` +
      `${result.itemsAdded} added, ${result.itemsUpdated} updated, ${errorCount} errors (${durationMs}ms)`
  );

  return {
    success: errorCount === 0,
    taskName,
    startedAt,
    completedAt,
    durationMs,
    message: `Synced ${result.itemsScanned} items from Plex`,
    data: {
      itemsScanned: result.itemsScanned,
      itemsAdded: result.itemsAdded,
      itemsUpdated: result.itemsUpdated,
      errors: errorCount,
    },
  };
}

// ============================================================================
// Task Registry
// ============================================================================

// ============================================================================
// Disk-Pressure Reactive Monitor
// ============================================================================

/** Read the configured media paths for the disk-pressure monitor. */
export function loadDiskPressurePaths(): string[] {
  try {
    const raw = settingsRepo.getValue('diskPressure_paths');
    if (!raw) return [];
    const parsed = JSON.parse(raw);
    return Array.isArray(parsed) ? parsed.filter((p) => typeof p === 'string' && p.trim().length > 0) : [];
  } catch {
    return [];
  }
}

interface DiskPressureConfig {
  targetMode: TargetMode;
  targetValue: number;
  criticalValue: number;
  bufferGb: number;
  observeOnly: boolean;
  softGraceDays: number;
  criticalGraceDays: number;
  criticalAutoProcess: boolean;
  deletionAction: DeletionAction;
  maxItemsPerRun: number;
  maxGbPerRun: number;
  unwatchedDays: number;
}

function loadDiskPressureConfig(): DiskPressureConfig {
  const action = settingsRepo.getValue('diskPressure_deletionAction') as DeletionAction | null;
  const validAction = action && Object.values(DeletionAction).includes(action)
    ? action
    : DeletionAction.UNMONITOR_AND_DELETE;
  return {
    targetMode: (settingsRepo.getValue('diskPressure_targetMode') as TargetMode) || 'percent',
    targetValue: settingsRepo.getNumber('diskPressure_targetValue', 10),
    criticalValue: settingsRepo.getNumber('diskPressure_criticalValue', 5),
    bufferGb: settingsRepo.getNumber('diskPressure_bufferGb', 50),
    observeOnly: settingsRepo.getBoolean('diskPressure_observeOnly', true),
    softGraceDays: settingsRepo.getNumber('diskPressure_softGraceDays', 7),
    criticalGraceDays: settingsRepo.getNumber('diskPressure_criticalGraceDays', 0),
    criticalAutoProcess: settingsRepo.getBoolean('diskPressure_criticalAutoProcess', false),
    deletionAction: validAction,
    maxItemsPerRun: settingsRepo.getNumber('diskPressure_maxItemsPerRun', 25),
    maxGbPerRun: settingsRepo.getNumber('diskPressure_maxGbPerRun', 500),
    unwatchedDays: settingsRepo.getNumber('diskPressure_unwatchedDays', 90),
  };
}

/**
 * Rank candidate items for reclamation, reusing the recommendations ordering
 * (oldest-watched first, then largest). Excludes protected / already-pending
 * items and anything matching the user's exclusion patterns. When a breached
 * path is given, items on that filesystem are preferred.
 */
/** Whether an item is stored on the breached filesystem, as far as can be told. */
function isOnFilesystem(item: MediaItem, fs: FsUsage): boolean {
  const prefixes = [fs.path, ...(fs.pathPrefixes ?? [])].filter((p) => p.length > 0);
  if (item.file_path && prefixes.some((p) => item.file_path!.startsWith(p))) return true;
  // Sonarr/Radarr report their own container paths, which often differ from
  // the paths stored here. A drive only one app uses at least says which kind.
  if (fs.apps?.length === 1) return fs.apps[0] === 'sonarr' ? item.type === 'show' : item.type === 'movie';
  return false;
}

/** A reclaim candidate, and the Disk pressure rule that picked it (null for the built-in pick). */
interface PressureCandidate {
  item: MediaItem;
  rule: Rule | null;
}

/** Items no cleanup may touch: protected, already queued, excluded, or part-watched. */
function pressureCandidateFilter(): (item: MediaItem) => boolean {
  const exclusionPatterns = loadExclusionPatterns();
  const inProgress = loadInProgressConfig();
  return (item) =>
    !item.is_protected &&
    item.status !== 'pending_deletion' &&
    !(exclusionPatterns.length > 0 && matchesExclusionPattern(item, exclusionPatterns)) &&
    !isProtectedInProgress(item, inProgress);
}

function rankCandidates(candidates: PressureCandidate[], breached: FsUsage): PressureCandidate[] {
  return candidates.sort(({ item: a }, { item: b }) => {
    // Prefer items stored on the breached filesystem (so we free the right one)
    const aOnPath = isOnFilesystem(a, breached) ? 0 : 1;
    const bOnPath = isOnFilesystem(b, breached) ? 0 : 1;
    if (aOnPath !== bOnPath) return aOnPath - bOnPath;
    // Oldest watched first
    const aDate = a.last_watched_at ? new Date(a.last_watched_at).getTime() : 0;
    const bDate = b.last_watched_at ? new Date(b.last_watched_at).getTime() : 0;
    if (aDate !== bDate) return aDate - bDate;
    // Then largest first
    return (b.file_size || 0) - (a.file_size || 0);
  });
}

function selectDiskPressureCandidates(unwatchedDays: number, breached: FsUsage): PressureCandidate[] {
  const allowed = pressureCandidateFilter();
  const candidates = mediaItemsRepo
    .getUnwatched(unwatchedDays)
    .filter(allowed)
    .map((item) => ({ item, rule: null }));
  return rankCandidates(candidates, breached);
}

/**
 * Candidates from the rules that use the Disk pressure condition. Like the
 * scan, an item goes to the first rule (by priority) that matches it.
 */
function selectRuleCandidates(rules: Rule[], pressure: DiskPressureState, breached: FsUsage): PressureCandidate[] {
  const parsed: Array<{ rule: Rule; root: ReturnType<typeof upgradeToV2>['root'] }> = [];
  for (const rule of rules) {
    try {
      parsed.push({ rule, root: upgradeToV2(JSON.parse(rule.conditions)).root });
    } catch (error) {
      logger.warn(`Disk-pressure: skipped rule "${rule.name}", its conditions could not be read`, error);
    }
  }

  const allowed = pressureCandidateFilter();
  const ctx: EvaluationContext = { ...buildEvaluationContext(), diskPressure: pressure };
  const candidates: PressureCandidate[] = [];
  for (const item of mediaItemsRepo.fetchAll({ status: 'monitored' as MediaItem['status'] })) {
    if (!allowed(item)) continue;
    const match = parsed.find(({ rule, root }) => ruleScopeMatches(rule, item) && evaluateNode(root, item, ctx));
    if (match) candidates.push({ item, rule: match.rule });
  }
  return rankCandidates(candidates, breached);
}

/**
 * Reactive disk-pressure monitor. Runs frequently; when free space on a
 * configured filesystem drops below the soft/critical threshold, it reclaims
 * just enough lowest-value content to recover (or, in observe-only mode, only
 * reports what it would do). Self-disables when `diskPressure_enabled` is off.
 */
export async function monitorDiskPressure(): Promise<TaskResult> {
  const startedAt = new Date();
  const taskName = 'monitorDiskPressure';
  const done = (message: string, data?: Record<string, unknown>, success = true): TaskResult => {
    const completedAt = new Date();
    return { success, taskName, startedAt, completedAt, durationMs: completedAt.getTime() - startedAt.getTime(), message, data };
  };

  if (!settingsRepo.getBoolean('diskPressure_enabled', false)) {
    return done('Disk-pressure monitoring disabled');
  }

  // Don't fight the nightly scan/sync over the same items.
  if (isSyncInProgress() || rulesRepo.scanHistory.getRunning()) {
    return done('Scan or sync in progress, skipping disk-pressure check');
  }

  // Typed-in folder paths win; otherwise watch the chosen storage source
  // (Unraid's array, or Sonarr/Radarr's media drives).
  const paths = loadDiskPressurePaths();
  const { usages, source } = await getPressureUsages(paths);
  if (!source) {
    return done('No media paths or storage source configured for disk-pressure monitoring');
  }

  const cfg = loadDiskPressureConfig();
  if (usages.length === 0) {
    return done(source === 'paths' ? 'Could not read any configured paths' : `Could not read storage from ${source}`, undefined, true);
  }

  // Find the most-breached filesystem (largest deficit vs its soft target).
  type Breach = { fs: FsUsage; severity: 'soft' | 'critical'; softTarget: number; criticalTarget: number; deficit: number };
  let worst: Breach | null = null;
  for (const fs of usages) {
    const softTarget = resolveTargetBytes(fs, cfg.targetMode, cfg.targetValue);
    const criticalTarget = resolveTargetBytes(fs, cfg.targetMode, cfg.criticalValue);
    const severity = fs.freeBytes < criticalTarget ? 'critical' : fs.freeBytes < softTarget ? 'soft' : null;
    if (!severity) continue;
    const deficit = softTarget - fs.freeBytes;
    if (!worst || deficit > worst.deficit) {
      worst = { fs, severity, softTarget, criticalTarget, deficit };
    }
  }

  if (!worst) {
    return done('All filesystems above target', { checked: usages.length });
  }

  const { fs, severity, softTarget, deficit } = worst;
  const targetBytes = severity === 'critical' ? worst.criticalTarget : softTarget;
  // Reclaim enough to clear the soft target plus a buffer so we don't re-trigger.
  const reclaimGoal = deficit + cfg.bufferGb * GiB;
  const maxBytes = cfg.maxGbPerRun * GiB;

  // Rules with a Disk pressure condition choose what goes; without any, the
  // built-in pick takes anything unwatched for long enough. With one watched
  // drive (one pool, one array) everything is on it; otherwise a rule only
  // matches items known to be on the drive that's low.
  const pressureRules = rulesRepo.rules
    .getEnabled()
    .filter((rule) => rule.action === 'delete' && ruleUsesDiskPressure(rule));
  const pressure: DiskPressureState = {
    severity,
    isOnDrive: usages.length === 1 ? () => true : (item) => isOnFilesystem(item, fs),
  };

  // What's already queued on this drive gets freed when its grace period ends,
  // so it counts toward the goal; otherwise every check would queue another
  // batch until the grace period ran out. Under critical pressure only what
  // goes within the critical grace period counts.
  const freedBy = severity === 'critical' ? startedAt.getTime() + cfg.criticalGraceDays * 86_400_000 : Infinity;
  const alreadyQueued = mediaItemsRepo
    .getPendingDeletion()
    .filter((item) => {
      const action = (item as MediaItem & { deletion_action?: string | null }).deletion_action;
      if (action === DeletionAction.UNMONITOR_ONLY || !pressure.isOnDrive(item)) return false;
      return !item.delete_after || new Date(item.delete_after).getTime() <= freedBy;
    })
    .reduce((sum, item) => sum + (item.file_size || 0), 0);
  if (alreadyQueued >= reclaimGoal) {
    return done(`Enough is already queued for ${severity} pressure on ${fs.path}`, {
      severity,
      path: fs.path,
      alreadyQueuedBytes: alreadyQueued,
    });
  }

  const ranked =
    pressureRules.length > 0
      ? selectRuleCandidates(pressureRules, pressure, fs)
      : selectDiskPressureCandidates(cfg.unwatchedDays, fs);

  // A rule's own deletion action applies to what it picked.
  const actionFor = (c: PressureCandidate) => (c.rule?.deletion_action as DeletionAction | undefined) ?? cfg.deletionAction;

  // Pick items until we've projected enough reclaim or hit a safety cap.
  const chosen: PressureCandidate[] = [];
  let projected = 0;
  for (const candidate of ranked) {
    if (chosen.length >= cfg.maxItemsPerRun) break;
    if (alreadyQueued + projected >= reclaimGoal) break;
    const bytes = actionFor(candidate) !== DeletionAction.UNMONITOR_ONLY ? candidate.item.file_size || 0 : 0;
    if (bytes > 0 && projected + bytes > maxBytes && chosen.length > 0) break;
    chosen.push(candidate);
    projected += bytes;
  }

  // Critical pressure always uses the critical grace period. Otherwise a rule
  // keeps its own grace period, and the built-in pick uses the soft one.
  const graceFor = (c: PressureCandidate) =>
    severity === 'critical' ? cfg.criticalGraceDays : (c.rule?.grace_period_days ?? cfg.softGraceDays);

  // Queue (unless observe-only). Suppress per-item notifications so we emit a
  // single DISK_PRESSURE_TRIGGERED event below instead of ITEMS_MARKED noise.
  let itemsQueued = 0;
  if (!cfg.observeOnly && chosen.length > 0) {
    const deletionService = getDeletionService();
    for (const candidate of chosen) {
      const { item, rule } = candidate;
      try {
        await deletionService.markForDeletion(item.id, {
          gracePeriodDays: graceFor(candidate),
          deletionAction: actionFor(candidate),
          skipNotification: true,
          ...(rule ? { ruleId: rule.id, resetOverseerr: Boolean(rule.reset_overseerr) } : {}),
        });
        itemsQueued++;
      } catch (error) {
        logger.warn(`Disk-pressure: failed to queue item ${item.id}: ${error instanceof Error ? error.message : String(error)}`);
      }
    }

    // Critical + auto-process: reclaim immediately rather than waiting for grace.
    if (severity === 'critical' && cfg.criticalAutoProcess && itemsQueued > 0) {
      try {
        await deletionService.processPendingDeletions(false);
      } catch (error) {
        logger.error('Disk-pressure: critical auto-process failed:', error);
      }
    }
  }

  // Emit the event (Discord + webhooks). Use DI notifier when wired.
  const notifier = dependencies.notificationService ?? {
    notify: (event: string, data: Record<string, unknown>) =>
      getNotificationService().notify(event as any, data).then(() => undefined),
  };
  const eventData: DiskPressureData = {
    severity,
    path: fs.path,
    freeBytes: fs.freeBytes,
    totalBytes: fs.totalBytes,
    targetBytes,
    deficitBytes: Math.max(0, deficit),
    observeOnly: cfg.observeOnly,
    itemsQueued,
    projectedReclaimBytes: projected,
    deletionAction: chosen[0] ? actionFor(chosen[0]) : cfg.deletionAction,
    items: chosen.map(({ item: i }) => ({ id: i.id, title: i.title, type: i.type, sizeBytes: i.file_size || 0 })),
    timestamp: startedAt.toISOString(),
  };
  try {
    await notifier.notify('DISK_PRESSURE_TRIGGERED', eventData as unknown as Record<string, unknown>);
  } catch (error) {
    logger.error('Disk-pressure: failed to send notification:', error);
  }

  // Activity log
  try {
    logActivity({
      eventType: 'disk_pressure',
      action: cfg.observeOnly
        ? `Disk pressure (${severity}) on ${fs.path} — ${chosen.length} item(s) would be reclaimed (observe-only)`
        : `Disk pressure (${severity}) on ${fs.path} — queued ${itemsQueued} item(s)`,
      actorType: 'scheduler',
      actorName: 'Disk-pressure monitor',
      metadata: JSON.stringify({
        severity,
        path: fs.path,
        freeBytes: fs.freeBytes,
        targetBytes,
        itemsQueued,
        observeOnly: cfg.observeOnly,
        projectedReclaimBytes: projected,
        rules: pressureRules.map((rule) => rule.name),
      }),
    });
  } catch (error) {
    logger.warn('Disk-pressure: failed to log activity:', error);
  }

  return done(
    cfg.observeOnly
      ? `Observe-only: ${chosen.length} item(s) flagged for ${severity} pressure on ${fs.path}`
      : `Queued ${itemsQueued} item(s) for ${severity} pressure on ${fs.path}`,
    { severity, path: fs.path, itemsQueued, projectedReclaimBytes: projected, observeOnly: cfg.observeOnly }
  );
}

// ============================================================================
// Telemetry Heartbeat
// ============================================================================

/**
 * Send the anonymous install-count heartbeat.
 *
 * Always reports success: a heartbeat that could not be delivered — because
 * telemetry is off, because it is not due yet, or because the machine is
 * offline — is a normal outcome, not a failed scheduled task. Nobody should
 * see a red job in Prunerr because a counter somewhere missed a tick.
 */
export async function sendTelemetryHeartbeat(): Promise<TaskResult> {
  const startedAt = new Date();
  const taskName = 'sendTelemetryHeartbeat';

  const result = await sendHeartbeat();

  const completedAt = new Date();
  const messages: Record<string, string> = {
    disabled: 'Telemetry is disabled — nothing sent',
    'not-due': 'Heartbeat not due yet',
    failed: 'Heartbeat could not be delivered — will retry on the next run',
  };

  return {
    success: true,
    taskName,
    startedAt,
    completedAt,
    durationMs: completedAt.getTime() - startedAt.getTime(),
    message: result.sent ? 'Heartbeat sent' : messages[result.reason ?? 'failed'],
    data: { sent: result.sent, reason: result.reason ?? null },
  };
}

// ============================================================================
// Announcements Feed
// ============================================================================

/**
 * Refresh the "What's new" feed.
 *
 * Always reports success for the same reason the heartbeat does: a feed that
 * could not be fetched — switched off, not due, or offline — is a normal
 * outcome, and the cached copy keeps serving the panel in the meantime.
 */
export async function refreshAnnouncements(): Promise<TaskResult> {
  const startedAt = new Date();
  const taskName = 'refreshAnnouncements';

  const result = await fetchAnnouncementsFeed();

  const completedAt = new Date();
  const messages: Record<string, string> = {
    disabled: 'Announcements are disabled — nothing fetched',
    'not-due': 'Feed refresh not due yet',
    failed: 'Feed could not be fetched — the cached copy stays in use',
  };

  return {
    success: true,
    taskName,
    startedAt,
    completedAt,
    durationMs: completedAt.getTime() - startedAt.getTime(),
    message: result.fetched ? `Feed refreshed (${result.count ?? 0} entries)` : messages[result.reason ?? 'failed'],
    data: { fetched: result.fetched, count: result.count ?? null, reason: result.reason ?? null },
  };
}

export type TaskFunction = () => Promise<TaskResult>;

export const taskRegistry: Record<string, TaskFunction> = {
  syncPlexLibrary,
  scanLibraries,
  processDeletionQueue,
  sendDeletionReminders,
  captureStorageSnapshot,
  captureUnraidCapacitySnapshot,
  syncPlexUsers,
  monitorDiskPressure,
  sendTelemetryHeartbeat,
  refreshAnnouncements,
};

/**
 * Get a task function by name
 */
export function getTask(taskName: string): TaskFunction | undefined {
  return taskRegistry[taskName];
}

/**
 * Get all available task names
 */
export function getAvailableTasks(): string[] {
  return Object.keys(taskRegistry);
}