Ctrl K
ComunidadGuías…

server/src/routes/rules.ts

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

Archivo original ↗Preguntar a KiBot
import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import safeRegex from 'safe-regex';
import rulesRepo from '../db/repositories/rules';
import settingsRepo from '../db/repositories/settings';
import collectionsRepo from '../db/repositories/collections';
import { getDatabase } from '../db/index';
import { CreateRuleSchema, UpdateRuleSchema, RuleCondition, MediaItem, Rule } from '../types';
import {
  evaluateNode,
  evaluateRuleConditions,
  upgradeToV2,
  type EvaluationContext,
} from '../rules/engine';
import { buildEvaluationContext } from '../rules/context';
import { ruleScopeMatches } from '../rules/scope';
import { nodeUsesDiskPressure, ruleUsesDiskPressure } from '../rules/diskPressure';
import type { ConditionNode } from '../rules/types';
import type { RuleForecastInput } from '../services/forecast';
import { leavesOf } from '../rules/forecast';
import logger from '../utils/logger';
import { formatBytes } from '../utils/format';
import { toThumbnailUrl } from '../utils/posterUrl';
import {
  queueItemForDeletion,
  notifyItemsQueued,
  type QueuedMatch,
} from '../scheduler/tasks';
import { logActivity } from '../db/repositories/activity';
import { loadInProgressConfig, isProtectedInProgress, usersInProgress } from '../rules/inProgress';

// ============================================================================
// V2 Condition Schema + Safe Regex Validation
// ============================================================================

const REGEX_OPERATORS = new Set(['regex_match']);

/**
 * Recursively validate a v2 condition tree, rejecting unsafe regex patterns.
 * Throws a ZodError-shaped object on the first unsafe pattern found.
 */
function validateConditionTree(node: unknown, path: string[] = []): void {
  if (!node || typeof node !== 'object') {
    throw new Error(`Invalid condition node at ${path.join('.') || 'root'}`);
  }
  const n = node as Record<string, unknown>;
  if (n['kind'] === 'condition') {
    if (REGEX_OPERATORS.has(String(n['operator']))) {
      const pattern = String(n['value'] ?? '');
      if (!safeRegex(pattern)) {
        throw new UnsafeRegexError(pattern, path);
      }
    }
    return;
  }
  if (n['kind'] === 'group' && Array.isArray(n['children'])) {
    if (path.length > 40) {
      throw new Error(`Condition tree exceeds maximum nesting depth at ${path.join('.')}`);
    }
    (n['children'] as unknown[]).forEach((child, i) =>
      validateConditionTree(child, [...path, 'children', String(i)])
    );
    return;
  }
  throw new Error(`Unknown node kind at ${path.join('.') || 'root'}`);
}

class UnsafeRegexError extends Error {
  constructor(public readonly pattern: string, public readonly path: string[]) {
    super(`Unsafe regex pattern rejected: ${pattern}`);
  }
}

/**
 * Zod schema for v2 condition tree. Uses z.lazy for recursion.
 */
const ConditionLeafSchema: z.ZodType<unknown> = z.object({
  kind: z.literal('condition'),
  field: z.string().min(1),
  operator: z.string().min(1),
  value: z.unknown(),
  params: z.record(z.string(), z.unknown()).optional(),
});

const ConditionGroupSchema: z.ZodType<unknown> = z.lazy(() =>
  z.object({
    kind: z.literal('group'),
    logic: z.enum(['AND', 'OR', 'NOT']),
    children: z.array(z.union([ConditionLeafSchema, ConditionGroupSchema])),
  })
);

const ConditionNodeSchema: z.ZodType<unknown> = z.union([
  ConditionLeafSchema,
  ConditionGroupSchema,
]);

const V2ConditionsSchema = z.object({
  version: z.literal(2),
  root: ConditionNodeSchema,
});

// ============================================================================
// Helper Functions
// ============================================================================

/**
 * Transforms a rule from snake_case (database) to camelCase (client format)
 */
interface ClientRule {
  id: number;
  name: string;
  profileId: number | null;
  type: string;
  mediaType: 'all' | 'movie' | 'tv';
  /** Plex library section keys the rule targets. Empty = all libraries. */
  libraryKeys: string[];
  conditions: string;
  /**
   * v2 parsed condition tree — the stored JSON is upgraded on read so the
   * client always sees the nested-group form.
   */
  conditionsV2: { version: 2; root: ConditionNode };
  action: string;
  enabled: boolean;
  gracePeriodDays: number;
  deletionAction: string;
  resetOverseerr: boolean;
  priority: number;
  createdAt: string;
  updatedAt: string;
}

function toClientRule(rule: Rule): ClientRule {
  // Convert 'show' to 'tv' for client (client uses 'tv', server uses 'show')
  const clientMediaType = rule.media_type === 'show' ? 'tv' : (rule.media_type || 'all');
  let conditionsV2: { version: 2; root: ConditionNode };
  try {
    const parsed = JSON.parse(rule.conditions);
    conditionsV2 = upgradeToV2(parsed) as { version: 2; root: ConditionNode };
  } catch {
    conditionsV2 = { version: 2, root: { kind: 'group', logic: 'AND', children: [] } };
  }
  return {
    id: rule.id,
    name: rule.name,
    profileId: rule.profile_id,
    type: rule.type,
    mediaType: clientMediaType as 'all' | 'movie' | 'tv',
    libraryKeys: rule.library_keys ?? [],
    conditions: rule.conditions,
    conditionsV2,
    action: rule.action,
    enabled: rule.enabled,
    gracePeriodDays: rule.grace_period_days,
    deletionAction: rule.deletion_action,
    resetOverseerr: rule.reset_overseerr,
    priority: rule.priority,
    createdAt: rule.created_at,
    updatedAt: rule.updated_at,
  };
}

/**
 * Evaluates whether a media item matches all the given rule conditions.
 * All conditions must be true for the item to match (AND logic).
 */
function evaluateConditions(item: MediaItem, conditions: RuleCondition[]): boolean {
  if (conditions.length === 0) {
    return false; // No conditions means no match (safety)
  }

  for (const condition of conditions) {
    if (!evaluateSingleCondition(item, condition)) {
      return false;
    }
  }
  return true;
}

/**
 * Evaluates a single condition against a media item.
 */
function evaluateSingleCondition(item: MediaItem, condition: RuleCondition): boolean {
  const { field, operator, value } = condition;

  // Get the field value from the item (handle nested fields like 'type', 'status', etc.)
  const fieldValue = getFieldValue(item, field);

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


/**
 * Gets the value of a field from a media item.
 * Supports special computed fields like 'days_since_added', 'days_since_watched'.
 */
function getFieldValue(item: MediaItem, field: string): string | number | boolean | null {
  // Handle special computed fields
  switch (field) {
    case 'days_since_added': {
      if (!item.added_at) return null;
      const addedDate = new Date(item.added_at);
      const now = new Date();
      return Math.floor((now.getTime() - addedDate.getTime()) / (1000 * 60 * 60 * 24));
    }
    case 'days_since_watched': {
      if (!item.last_watched_at) return null;
      const watchedDate = new Date(item.last_watched_at);
      const now = new Date();
      return Math.floor((now.getTime() - watchedDate.getTime()) / (1000 * 60 * 60 * 24));
    }
    case 'size_gb': {
      if (!item.file_size) return null;
      return item.file_size / (1024 * 1024 * 1024);
    }
    case 'resolution_number': {
      // Parse resolution string to number: "1080p" → 1080, "4K" → 2160, "720p" → 720, "480p" → 480
      const res = String(item.resolution || '');
      if (res.toLowerCase().includes('4k') || res.includes('2160')) return 2160;
      const match = res.match(/(\d+)/);
      return match && match[1] ? parseInt(match[1], 10) : 0;
    }
    case 'year':
      return item.year || 0;
    case 'codec':
      return (item.codec || '').toLowerCase();
    case 'library_key':
      return item.library_key || '';
    case 'file_path':
      return item.file_path || '';
    case 'watched_by_count': {
      // Parse watched_by JSON array and count unique watchers
      try {
        const wb = item.watched_by;
        if (!wb) return 0;
        const parsed = typeof wb === 'string' ? JSON.parse(wb) : wb;
        return Array.isArray(parsed) ? parsed.length : 0;
      } catch { return 0; }
    }
    default:
      // Direct field access
      if (field in item) {
        return item[field as keyof MediaItem] as string | number | boolean | null;
      }
      return null;
  }
}

const router = Router();

// Validation middleware
function validateBody<T>(schema: z.ZodSchema<T>) {
  return (req: Request, res: Response, next: NextFunction) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      res.status(400).json({
        success: false,
        error: 'Validation failed',
        details: result.error.issues,
      });
      return;
    }
    req.body = result.data;
    next();
  };
}

// ============================================================================
// Rules Endpoints
// ============================================================================

// GET /api/rules - Get all rules
router.get('/', (_req: Request, res: Response) => {
  try {
    const rules = rulesRepo.rules.getAll();
    res.json({
      success: true,
      data: rules.map(toClientRule),
      total: rules.length,
    });
  } catch (error) {
    logger.error('Failed to get rules:', error);
    res.status(500).json({
      success: false,
      error: 'Failed to retrieve rules',
    });
  }
});

// GET /api/rules/enabled - Get only enabled rules
router.get('/enabled', (_req: Request, res: Response) => {
  try {
    const rules = rulesRepo.rules.getEnabled();
    res.json({
      success: true,
      data: rules.map(toClientRule),
      total: rules.length,
    });
  } catch (error) {
    logger.error('Failed to get enabled rules:', error);
    res.status(500).json({
      success: false,
      error: 'Failed to retrieve enabled rules',
    });
  }
});

// GET /api/rules/suggestions - Get smart rule suggestions based on library analysis
// NOTE: This must be defined BEFORE /:id to avoid matching "suggestions" as an ID
router.get('/suggestions', async (_req: Request, res: Response) => {
  try {
    const mediaItemsRepo = await import('../db/repositories/mediaItems');
    // Suggestions estimate how much a proposed rule would reclaim, so they must
    // ignore tombstones for the same reason /preview does, and protected items,
    // which a rule never deletes.
    const inProgress = loadInProgressConfig();
    const items = mediaItemsRepo.default
      .fetchAll({ excludeDeleted: true })
      .filter((item) => !item.is_protected && !isProtectedInProgress(item, inProgress));

    const now = new Date();
    const suggestions: Array<{
      id: string;
      name: string;
      description: string;
      icon: string;
      matchCount: number;
      totalSize: number;
      totalSizeFormatted: string;
      conditions: RuleCondition[];
      mediaType: 'all' | 'movie' | 'show';
    }> = [];

    // 1. Never watched (added 30+ days ago, play_count = 0)
    const neverWatched = items.filter((item) => {
      if (item.play_count > 0) return false;
      if (!item.added_at) return false;
      const days = Math.floor((now.getTime() - new Date(item.added_at).getTime()) / (1000 * 60 * 60 * 24));
      return days >= 30;
    });
    if (neverWatched.length > 0) {
      const size = neverWatched.reduce((sum, i) => sum + (i.file_size || 0), 0);
      suggestions.push({
        id: 'never-watched',
        name: 'Never Watched',
        description: 'Added 30+ days ago, never played',
        icon: 'download',
        matchCount: neverWatched.length,
        totalSize: size,
        totalSizeFormatted: formatBytes(size),
        conditions: [
          { field: 'play_count', operator: 'equals', value: 0 },
          { field: 'days_since_added', operator: 'greater_than', value: 30 },
        ],
        mediaType: 'all',
      });
    }

    // 2. Watched once (watched exactly once, 60+ days ago)
    const watchedOnce = items.filter((item) => {
      if (item.play_count !== 1) return false;
      if (!item.last_watched_at) return false;
      const days = Math.floor((now.getTime() - new Date(item.last_watched_at).getTime()) / (1000 * 60 * 60 * 24));
      return days >= 60;
    });
    if (watchedOnce.length > 0) {
      const size = watchedOnce.reduce((sum, i) => sum + (i.file_size || 0), 0);
      suggestions.push({
        id: 'watched-once',
        name: 'Watched Once',
        description: 'Played once, 60+ days ago',
        icon: 'eye',
        matchCount: watchedOnce.length,
        totalSize: size,
        totalSizeFormatted: formatBytes(size),
        conditions: [
          { field: 'play_count', operator: 'equals', value: 1 },
          { field: 'days_since_watched', operator: 'greater_than', value: 60 },
        ],
        mediaType: 'all',
      });
    }

    // 3. Large files (10GB+, not watched in 30+ days)
    const largeFiles = items.filter((item) => {
      if (!item.file_size || item.file_size < 10 * 1024 * 1024 * 1024) return false; // 10GB
      if (!item.last_watched_at) return true;
      const days = Math.floor((now.getTime() - new Date(item.last_watched_at).getTime()) / (1000 * 60 * 60 * 24));
      return days >= 30;
    });
    if (largeFiles.length > 0) {
      const size = largeFiles.reduce((sum, i) => sum + (i.file_size || 0), 0);
      suggestions.push({
        id: 'large-files',
        name: 'Large Files',
        description: '10GB+ files, not watched in 30+ days',
        icon: 'hard-drive',
        matchCount: largeFiles.length,
        totalSize: size,
        totalSizeFormatted: formatBytes(size),
        conditions: [
          { field: 'size_gb', operator: 'greater_than', value: 10 },
          { field: 'days_since_watched', operator: 'greater_than', value: 30 },
        ],
        mediaType: 'all',
      });
    }

    // 4. Stale content (not watched in 90+ days)
    const stale = items.filter((item) => {
      if (!item.last_watched_at) {
        if (!item.added_at) return false;
        const days = Math.floor((now.getTime() - new Date(item.added_at).getTime()) / (1000 * 60 * 60 * 24));
        return days >= 90;
      }
      const days = Math.floor((now.getTime() - new Date(item.last_watched_at).getTime()) / (1000 * 60 * 60 * 24));
      return days >= 90;
    });
    if (stale.length > 0) {
      const size = stale.reduce((sum, i) => sum + (i.file_size || 0), 0);
      suggestions.push({
        id: 'stale',
        name: 'Stale Content',
        description: 'Not watched in 90+ days',
        icon: 'clock',
        matchCount: stale.length,
        totalSize: size,
        totalSizeFormatted: formatBytes(size),
        conditions: [{ field: 'days_since_watched', operator: 'greater_than', value: 90 }],
        mediaType: 'all',
      });
    }

    // 5. Old movies (movies watched once, 30+ days ago)
    const oldMovies = items.filter((item) => {
      if (item.type !== 'movie') return false;
      if (item.play_count !== 1) return false;
      if (!item.last_watched_at) return false;
      const days = Math.floor((now.getTime() - new Date(item.last_watched_at).getTime()) / (1000 * 60 * 60 * 24));
      return days >= 30;
    });
    if (oldMovies.length > 0) {
      const size = oldMovies.reduce((sum, i) => sum + (i.file_size || 0), 0);
      suggestions.push({
        id: 'old-movies',
        name: 'Old Movies',
        description: 'Movies watched once, 30+ days ago',
        icon: 'film',
        matchCount: oldMovies.length,
        totalSize: size,
        totalSizeFormatted: formatBytes(size),
        conditions: [
          { field: 'play_count', operator: 'equals', value: 1 },
          { field: 'days_since_watched', operator: 'greater_than', value: 30 },
        ],
        mediaType: 'movie',
      });
    }

    // 6. Completed TV shows (TV shows not watched in 60+ days)
    const completedShows = items.filter((item) => {
      if (item.type !== 'show') return false;
      if (!item.last_watched_at) return false;
      const days = Math.floor((now.getTime() - new Date(item.last_watched_at).getTime()) / (1000 * 60 * 60 * 24));
      return days >= 60;
    });
    if (completedShows.length > 0) {
      const size = completedShows.reduce((sum, i) => sum + (i.file_size || 0), 0);
      suggestions.push({
        id: 'completed-shows',
        name: 'Old TV Shows',
        description: 'TV shows not watched in 60+ days',
        icon: 'tv',
        matchCount: completedShows.length,
        totalSize: size,
        totalSizeFormatted: formatBytes(size),
        conditions: [{ field: 'days_since_watched', operator: 'greater_than', value: 60 }],
        mediaType: 'show',
      });
    }

    // 7. Low Quality Files (SD and 720p content)
    const lowQuality = items.filter((item) => {
      const res = String(item.resolution || '');
      let resNum = 0;
      if (res.toLowerCase().includes('4k') || res.includes('2160')) resNum = 2160;
      else {
        const m = res.match(/(\d+)/);
        resNum = m && m[1] ? parseInt(m[1], 10) : 0;
      }
      return resNum > 0 && resNum < 1080;
    });
    if (lowQuality.length > 0) {
      const size = lowQuality.reduce((sum, i) => sum + (i.file_size || 0), 0);
      suggestions.push({
        id: 'low-quality',
        name: 'Low Quality Files',
        description: 'Remove SD and 720p content to save space',
        icon: 'monitor',
        matchCount: lowQuality.length,
        totalSize: size,
        totalSizeFormatted: formatBytes(size),
        conditions: [
          { field: 'resolution_number', operator: 'less_than', value: 1080 },
        ],
        mediaType: 'all',
      });
    }

    // 8. Old Codec Cleanup (H.264 or similar)
    const oldCodec = items.filter((item) => {
      if (item.type !== 'movie') return false;
      const codec = (item.codec || '').toLowerCase();
      return codec.includes('h264') || codec.includes('h.264');
    });
    if (oldCodec.length > 0) {
      const size = oldCodec.reduce((sum, i) => sum + (i.file_size || 0), 0);
      suggestions.push({
        id: 'old-codec',
        name: 'Old Codec Cleanup',
        description: 'Remove files using older codecs like H.264 or MPEG',
        icon: 'film',
        matchCount: oldCodec.length,
        totalSize: size,
        totalSizeFormatted: formatBytes(size),
        conditions: [
          { field: 'codec', operator: 'contains', value: 'h264' },
        ],
        mediaType: 'movie',
      });
    }

    // 9. Classic Movies Never Rewatched (before 2015, not watched in 180+ days)
    const classicNeverRewatched = items.filter((item) => {
      if (item.type !== 'movie') return false;
      if (!item.year || item.year >= 2015) return false;
      if (!item.last_watched_at) return true; // Never watched counts
      const days = Math.floor((now.getTime() - new Date(item.last_watched_at).getTime()) / (1000 * 60 * 60 * 24));
      return days > 180;
    });
    if (classicNeverRewatched.length > 0) {
      const size = classicNeverRewatched.reduce((sum, i) => sum + (i.file_size || 0), 0);
      suggestions.push({
        id: 'classic-never-rewatched',
        name: 'Classic Movies Never Rewatched',
        description: "Movies released before 2015 that haven't been watched recently",
        icon: 'clock',
        matchCount: classicNeverRewatched.length,
        totalSize: size,
        totalSizeFormatted: formatBytes(size),
        conditions: [
          { field: 'year', operator: 'less_than', value: 2015 },
          { field: 'days_since_watched', operator: 'greater_than', value: 180 },
        ],
        mediaType: 'movie',
      });
    }

    // 10. Large 4K Files Watched Once (4K, play_count = 1, 20GB+)
    const large4kWatchedOnce = items.filter((item) => {
      if (item.type !== 'movie') return false;
      if (item.play_count !== 1) return false;
      if (!item.file_size || item.file_size < 20 * 1024 * 1024 * 1024) return false; // 20GB
      const res = String(item.resolution || '');
      let resNum = 0;
      if (res.toLowerCase().includes('4k') || res.includes('2160')) resNum = 2160;
      else {
        const m = res.match(/(\d+)/);
        resNum = m && m[1] ? parseInt(m[1], 10) : 0;
      }
      return resNum > 2000;
    });
    if (large4kWatchedOnce.length > 0) {
      const size = large4kWatchedOnce.reduce((sum, i) => sum + (i.file_size || 0), 0);
      suggestions.push({
        id: 'large-4k-watched-once',
        name: 'Large 4K Files Watched Once',
        description: 'Large 4K files that have only been watched once',
        icon: 'hard-drive',
        matchCount: large4kWatchedOnce.length,
        totalSize: size,
        totalSizeFormatted: formatBytes(size),
        conditions: [
          { field: 'resolution_number', operator: 'greater_than', value: 2000 },
          { field: 'play_count', operator: 'equals', value: 1 },
          { field: 'size_gb', operator: 'greater_than', value: 20 },
        ],
        mediaType: 'movie',
      });
    }

    // 11. Low play count (watched 2 or fewer times, 90+ days ago)
    const lowPlayCount = items.filter((item) => {
      if (item.play_count > 2) return false;
      if (!item.last_watched_at && !item.added_at) return false;
      const dateToCheck = item.last_watched_at || item.added_at;
      const days = Math.floor((now.getTime() - new Date(dateToCheck!).getTime()) / (1000 * 60 * 60 * 24));
      return days >= 90;
    });
    if (lowPlayCount.length > 0) {
      const size = lowPlayCount.reduce((sum, i) => sum + (i.file_size || 0), 0);
      suggestions.push({
        id: 'low-play-count',
        name: 'Rarely Watched',
        description: 'Played 2 or fewer times, 90+ days old',
        icon: 'eye',
        matchCount: lowPlayCount.length,
        totalSize: size,
        totalSizeFormatted: formatBytes(size),
        conditions: [
          { field: 'play_count', operator: 'less_than', value: 3 },
          { field: 'days_since_watched', operator: 'greater_than', value: 90 },
        ],
        mediaType: 'all',
      });
    }

    res.json({
      success: true,
      data: {
        suggestions,
        libraryStats: {
          totalItems: items.length,
          movies: items.filter((i) => i.type === 'movie').length,
          shows: items.filter((i) => i.type === 'show').length,
          totalSize: items.reduce((sum, i) => sum + (i.file_size || 0), 0),
        },
      },
    });
  } catch (error) {
    logger.error('Failed to get rule suggestions:', error);
    res.status(500).json({
      success: false,
      error: 'Failed to get rule suggestions',
    });
  }
});

// GET /api/rules/:id - Get a specific rule
router.get('/:id', (req: Request, res: Response) => {
  try {
    const id = parseInt(req.params['id'] as string, 10);
    if (isNaN(id)) {
      res.status(400).json({
        success: false,
        error: 'Invalid rule ID',
      });
      return;
    }

    const rule = rulesRepo.rules.getById(id);
    if (!rule) {
      res.status(404).json({
        success: false,
        error: `Rule not found: ${id}`,
      });
      return;
    }

    res.json({
      success: true,
      data: toClientRule(rule),
    });
  } catch (error) {
    logger.error('Failed to get rule:', error);
    res.status(500).json({
      success: false,
      error: 'Failed to retrieve rule',
    });
  }
});

/**
 * Middleware that validates any regex_match operators in the incoming rule
 * body. Supports both v1 (flat `conditions` array) and v2 (tree `root`).
 * Rejects unsafe patterns with a 400.
 */
function validateRegexSafety(req: Request, res: Response, next: NextFunction): void {
  try {
    const body = req.body;
    // v2 at top level: look for `root`
    if (body && typeof body === 'object' && 'root' in body && body.root) {
      validateConditionTree(body.root);
    }
    // v2 nested inside `conditions`: { version: 2, root: ... }
    if (
      body &&
      typeof body === 'object' &&
      body.conditions &&
      typeof body.conditions === 'object' &&
      !Array.isArray(body.conditions) &&
      body.conditions.root
    ) {
      validateConditionTree(body.conditions.root);
    }
    // v1: look for flat conditions array
    if (body && Array.isArray(body.conditions)) {
      for (const c of body.conditions) {
        if (c && REGEX_OPERATORS.has(String(c.operator))) {
          const pattern = String(c.value ?? '');
          if (!safeRegex(pattern)) {
            throw new UnsafeRegexError(pattern, ['conditions']);
          }
        }
      }
    }
    next();
  } catch (err) {
    if (err instanceof UnsafeRegexError) {
      res.status(400).json({
        success: false,
        error: `Unsafe regex pattern rejected: ${err.pattern}`,
      });
      return;
    }
    res.status(400).json({
      success: false,
      error: (err as Error).message,
    });
  }
}

// POST /api/rules - Create a new rule
router.post('/', validateRegexSafety, validateBody(CreateRuleSchema), (req: Request, res: Response) => {
  try {
    const rule = rulesRepo.rules.create(req.body);
    res.status(201).json({
      success: true,
      data: toClientRule(rule),
      message: 'Rule created successfully',
    });
  } catch (error) {
    logger.error('Failed to create rule:', error);
    res.status(500).json({
      success: false,
      error: 'Failed to create rule',
    });
  }
});

// PUT /api/rules/:id - Update a rule
router.put('/:id', validateRegexSafety, validateBody(UpdateRuleSchema), (req: Request, res: Response) => {
  try {
    const id = parseInt(req.params['id'] as string, 10);
    if (isNaN(id)) {
      res.status(400).json({
        success: false,
        error: 'Invalid rule ID',
      });
      return;
    }

    const rule = rulesRepo.rules.update(id, req.body);
    if (!rule) {
      res.status(404).json({
        success: false,
        error: `Rule not found: ${id}`,
      });
      return;
    }

    res.json({
      success: true,
      data: toClientRule(rule),
      message: 'Rule updated successfully',
    });
  } catch (error) {
    logger.error('Failed to update rule:', error);
    res.status(500).json({
      success: false,
      error: 'Failed to update rule',
    });
  }
});

// DELETE /api/rules/:id - Delete a rule
router.delete('/:id', (req: Request, res: Response) => {
  try {
    const id = parseInt(req.params['id'] as string, 10);
    if (isNaN(id)) {
      res.status(400).json({
        success: false,
        error: 'Invalid rule ID',
      });
      return;
    }

    const deleted = rulesRepo.rules.delete(id);
    if (!deleted) {
      res.status(404).json({
        success: false,
        error: `Rule not found: ${id}`,
      });
      return;
    }

    res.json({
      success: true,
      message: 'Rule deleted successfully',
    });
  } catch (error) {
    logger.error('Failed to delete rule:', error);
    res.status(500).json({
      success: false,
      error: 'Failed to delete rule',
    });
  }
});

// PATCH /api/rules/:id/toggle - Toggle rule enabled/disabled
router.patch('/:id/toggle', (req: Request, res: Response) => {
  try {
    const id = parseInt(req.params['id'] as string, 10);
    if (isNaN(id)) {
      res.status(400).json({
        success: false,
        error: 'Invalid rule ID',
      });
      return;
    }

    // Use the enabled value from request body if provided, otherwise toggle
    const { enabled } = req.body;
    let rule;
    if (typeof enabled === 'boolean') {
      rule = rulesRepo.rules.update(id, { enabled });
    } else {
      rule = rulesRepo.rules.toggle(id);
    }

    if (!rule) {
      res.status(404).json({
        success: false,
        error: `Rule not found: ${id}`,
      });
      return;
    }

    res.json({
      success: true,
      data: toClientRule(rule),
      message: `Rule ${rule.enabled ? 'enabled' : 'disabled'}`,
    });
  } catch (error) {
    logger.error('Failed to toggle rule:', error);
    res.status(500).json({
      success: false,
      error: 'Failed to toggle rule',
    });
  }
});

// POST /api/rules/:id/run - Run a rule manually (scan library and mark matching items)
router.post('/:id/run', async (req: Request, res: Response) => {
  try {
    const id = parseInt(req.params['id'] as string, 10);
    if (isNaN(id)) {
      res.status(400).json({
        success: false,
        error: 'Invalid rule ID',
      });
      return;
    }

    const rule = rulesRepo.rules.getById(id);
    if (!rule) {
      res.status(404).json({
        success: false,
        error: `Rule not found: ${id}`,
      });
      return;
    }

    // Running it now would queue every match; the disk-pressure monitor runs
    // it when space is low and only takes what it needs.
    if (ruleUsesDiskPressure(rule)) {
      res.status(400).json({
        success: false,
        error: 'This rule uses Disk pressure, so it runs on its own when disk space is low.',
      });
      return;
    }

    // Get all media items. Tombstones are excluded for the same reason as in
    // /preview — a soft-deleted row still satisfies the conditions that got it
    // deleted, and re-queueing it would be a no-op at best.
    const mediaItemsRepo = await import('../db/repositories/mediaItems');
    const mediaItems = mediaItemsRepo.default.fetchAll({ excludeDeleted: true });

    // Filter by rule scope (media type + targeted libraries)
    const filteredItems = mediaItems.filter((item) => ruleScopeMatches(rule, item));

    // Build evaluation context once for this run (v2 engine — supports nested
    // groups, new operators, collection_membership, watched_by_user)
    const ctx: EvaluationContext = buildEvaluationContext();

    // Evaluate each media item against the rule conditions via the v2 engine.
    // evaluateRuleConditions handles v1→v2 upgrade + tree walking internally.
    const matchingItems: typeof mediaItems = [];
    const inProgress = loadInProgressConfig();
    for (const item of filteredItems) {
      // Skip protected items, and shows someone is part-way through
      if (item.is_protected || isProtectedInProgress(item, inProgress)) {
        continue;
      }
      try {
        if (evaluateRuleConditions(rule.conditions, item, ctx)) {
          matchingItems.push(item);
        }
      } catch (evalError) {
        logger.error(`Failed to evaluate rule ${rule.id} against item ${item.id}:`, evalError);
      }
    }

    const now = new Date();
    const markedAt = now.toISOString();

    // Apply the rule action to matching items
    let processed = 0;
    let failed = 0;
    const results: { id: number; title: string; action: string; success: boolean }[] = [];
    const queuedMatches: QueuedMatch[] = [];

    for (const item of matchingItems) {
      try {
        switch (rule.action) {
          case 'flag':
            mediaItemsRepo.default.update(item.id, {
              status: 'flagged',
              marked_at: markedAt,
              matched_rule_id: rule.id,
            } as any);
            try {
              logActivity({
                eventType: 'rule_match',
                action: 'item_flagged',
                actorType: 'rule',
                actorId: rule.id.toString(),
                actorName: rule.name,
                targetType: 'media_item',
                targetId: item.id,
                targetTitle: item.title,
              });
            } catch (activityError) {
              logger.warn('Failed to log activity for manual rule run (flag):', activityError);
            }
            results.push({ id: item.id, title: item.title, action: 'flagged', success: true });
            processed++;
            break;
          case 'delete': {
            // Use the shared helper so the item gets full queue metadata AND
            // an activity log entry with actorType='rule' (powers the Activity
            // Log "Rule" filter and the Discord ITEMS_MARKED notification).
            const { deleteAfter } = queueItemForDeletion(item as any, rule);
            queuedMatches.push({ item: item as any, rule, deleteAfter });
            results.push({ id: item.id, title: item.title, action: 'pending_deletion', success: true });
            processed++;
            break;
          }
          case 'notify':
            // For notify action, just mark them as flagged but don't delete
            // In a full implementation, this would trigger a notification
            results.push({ id: item.id, title: item.title, action: 'notified', success: true });
            processed++;
            break;
        }
      } catch (itemError) {
        logger.error(`Failed to process item ${item.id}:`, itemError);
        results.push({ id: item.id, title: item.title, action: rule.action, success: false });
        failed++;
      }
    }

    // Fire one ITEMS_MARKED Discord notification covering everything this rule queued
    await notifyItemsQueued(queuedMatches);

    logger.info(`Rule ${id} (${rule.name}) run complete: ${processed} processed, ${failed} failed`);

    res.json({
      success: true,
      data: {
        rule: {
          id: rule.id,
          name: rule.name,
          action: rule.action,
        },
        summary: {
          totalScanned: mediaItems.length,
          matched: matchingItems.length,
          processed,
          failed,
        },
        results,
      },
      message: `Rule executed: ${processed} items ${rule.action === 'flag' ? 'flagged' : rule.action === 'delete' ? 'marked for deletion' : 'notified'}`,
    });
  } catch (error) {
    logger.error('Failed to run rule:', error);
    res.status(500).json({
      success: false,
      error: 'Failed to run rule',
    });
  }
});

// ============================================================================
// Preview Endpoint
// ============================================================================

// POST /api/rules/preview - Preview which items would match a rule (before saving)
// Accepts either v1 (legacy flat conditions) or v2 (nested tree) payloads.
const PreviewRuleSchema = z.object({
  mediaType: z.enum(['all', 'movie', 'show', 'tv']).optional(),
  libraryKeys: z.array(z.string().min(1)).max(100).optional(),
  // Paging through the matched samples (largest first).
  sampleOffset: z.number().int().min(0).optional(),
  sampleLimit: z.number().int().min(1).max(50).optional(),
  // Rules never act on protected items; callers can leave them out of samples.
  includeProtectedSamples: z.boolean().optional(),
  // v2
  version: z.literal(2).optional(),
  root: ConditionNodeSchema.optional(),
  // v1 (legacy)
  conditions: z
    .array(
      z.object({
        field: z.string(),
        operator: z.string(),
        value: z.union([z.string(), z.number(), z.boolean(), z.array(z.unknown())]),
      })
    )
    .optional(),
  logic: z.enum(['AND', 'OR']).optional(),
});

router.post('/preview', validateBody(PreviewRuleSchema), async (req: Request, res: Response) => {
  try {
    const {
      mediaType,
      libraryKeys,
      sampleOffset = 0,
      sampleLimit = 10,
      includeProtectedSamples = true,
    } = req.body as {
      mediaType?: string;
      libraryKeys?: string[];
      sampleOffset?: number;
      sampleLimit?: number;
      includeProtectedSamples?: boolean;
    };

    // Build v2 tree from payload
    let v2;
    try {
      v2 = upgradeToV2(req.body);
      validateConditionTree(v2.root);
    } catch (validationError) {
      if (validationError instanceof UnsafeRegexError) {
        res.status(400).json({
          success: false,
          error: `Unsafe regex pattern rejected: ${validationError.pattern}`,
        });
        return;
      }
      res.status(400).json({
        success: false,
        error: (validationError as Error).message,
      });
      return;
    }

    // Fetch all media items. excludeDeleted keeps soft-deleted tombstones out
    // of the count — without it a rule that has already cleaned up its library
    // reports every past deletion as a fresh match.
    const mediaItemsRepo = await import('../db/repositories/mediaItems');
    const allItems = mediaItemsRepo.default.fetchAll({ excludeDeleted: true });

    // Normalize mediaType: client 'tv' → server 'show'
    const normalizedType = mediaType === 'tv' ? 'show' : mediaType;
    const typeFiltered =
      !normalizedType || normalizedType === 'all'
        ? allItems
        : allItems.filter((item) => item.type === normalizedType);

    // Restrict to targeted Plex libraries when the rule names any. Items with
    // no library_key (legacy rows) fall outside a library-restricted rule,
    // mirroring ruleScopeMatches.
    const items =
      libraryKeys && libraryKeys.length > 0
        ? typeFiltered.filter(
            (item) => item.library_key != null && libraryKeys.includes(item.library_key)
          )
        : typeFiltered;

    // Build watch lookup from cache (one-time prefetch for this run). A Disk
    // pressure rule is previewed as if space were critically low, so it shows
    // what the monitor could pick from.
    const diskPressure = nodeUsesDiskPressure(v2.root as ConditionNode);
    const ctx: EvaluationContext = {
      ...buildEvaluationContext(),
      ...(diskPressure ? { diskPressure: { severity: 'critical' as const, isOnDrive: () => true } } : {}),
    };

    // Evaluate
    const allMatching = items.filter((item) => evaluateNode(v2.root as ConditionNode, item, ctx));

    // Items already sitting in the deletion queue still satisfy the conditions,
    // but they aren't new work — surface them separately so the headline count
    // means "what this rule would newly pick up".
    const alreadyPending = allMatching.filter((i) => i.status === 'pending_deletion').length;
    const matching = allMatching.filter((i) => i.status !== 'pending_deletion');

    // Separate protected vs queueable. Shows someone is part-way through are
    // skipped too (Settings → Safety) and counted on their own.
    const inProgress = loadInProgressConfig();
    const isInProgress = (i: (typeof matching)[number]) => !i.is_protected && isProtectedInProgress(i, inProgress);
    const isSkipped = (i: (typeof matching)[number]) => Boolean(i.is_protected) || isInProgress(i);
    const wouldSkipProtected = matching.filter((i) => i.is_protected).length;
    const wouldSkipInProgress = matching.filter(isInProgress).length;
    const wouldQueue = matching.length - wouldSkipProtected - wouldSkipInProgress;

    // Reclaimable space counts only what the rule would actually delete —
    // protected and in-progress items are skipped, so their size is never freed.
    const totalBytes = matching
      .filter((i) => !isSkipped(i))
      .reduce((sum, i) => sum + (i.file_size || 0), 0);
    const storageFreedGB = totalBytes / (1024 * 1024 * 1024);

    // One page of matches, largest first, so the whole match list can be
    // browsed. sampleTotal is the size of the list being paged.
    const sampleable = includeProtectedSamples ? matching : matching.filter((i) => !isSkipped(i));
    const samples = [...sampleable]
      .sort((a, b) => (b.file_size || 0) - (a.file_size || 0))
      .slice(sampleOffset, sampleOffset + sampleLimit)
      .map((item) => ({
        id: item.id,
        title: item.title,
        size: item.file_size || 0,
        rating: item.rating_imdb ?? item.rating_tmdb ?? null,
        posterUrl: toThumbnailUrl(item.poster_url) || null,
        isProtected: Boolean(item.is_protected),
        inProgress: isInProgress(item),
        // Who is part-way through it, so the preview can say why it's kept.
        inProgressFor: usersInProgress(item, inProgress.recentDays),
        reason: describeMatchReason(v2.root as ConditionNode),
      }));

    res.json({
      success: true,
      data: {
        totalMatches: matching.length,
        wouldQueue,
        wouldSkipProtected,
        wouldSkipInProgress,
        alreadyPending,
        storageFreedGB: Math.round(storageFreedGB * 100) / 100,
        totalSize: totalBytes,
        totalSizeFormatted: formatBytes(totalBytes),
        samples,
        sampleTotal: sampleable.length,
        diskPressure,
        ...(diskPressure ? { diskPressureEnabled: settingsRepo.getBoolean('diskPressure_enabled', false) } : {}),
      },
    });
  } catch (error) {
    logger.error('Failed to preview rule:', error);
    res.status(500).json({
      success: false,
      error: 'Failed to preview rule',
    });
  }
});

/** Conditions and days-since values a rule forecast will take on. */
const FORECAST_MAX_LEAVES = 50;
const FORECAST_MAX_DAY_VALUES = 20;

function forecastable(root: ConditionNode): boolean {
  const leaves = leavesOf(root);
  if (leaves.length > FORECAST_MAX_LEAVES) return false;
  return leaves.every(
    (leaf) =>
      !(leaf.field === 'days_since_added' || leaf.field === 'days_since_watched') ||
      !Array.isArray(leaf.value) ||
      leaf.value.length <= FORECAST_MAX_DAY_VALUES
  );
}

const RuleForecastSchema = z.object({
  version: z.literal(2),
  root: ConditionNodeSchema,
  mediaType: z.enum(['all', 'movie', 'show', 'tv']).optional(),
  libraryKeys: z.array(z.string().min(1)).max(100).optional(),
  deletionAction: z.string().max(64).optional(),
  /** The saved rule being edited, to compare against. */
  ruleId: z.number().int().positive().optional(),
});

/**
 * POST /api/rules/forecast
 * What a rule (as being edited) would make eligible by the next scan, in three
 * months and in a year, judged on its own, and the same for the saved version
 * when editing.
 */
router.post('/forecast', validateBody(RuleForecastSchema), async (req: Request, res: Response) => {
  try {
    const { mediaType, libraryKeys, deletionAction, ruleId } = req.body as z.infer<typeof RuleForecastSchema>;
    let v2;
    try {
      v2 = upgradeToV2(req.body);
      validateConditionTree(v2.root);
    } catch (validationError) {
      res.status(400).json({ success: false, error: (validationError as Error).message });
      return;
    }

    // Every number in a days-since condition adds days to try for every item,
    // so a rule built to be slow could hold up the server. Real rules use a few.
    if (!forecastable(v2.root as ConditionNode)) {
      res.status(400).json({ success: false, error: 'This rule is too complex to forecast' });
      return;
    }

    const { forecastSingleRule } = await import('../services/forecast');
    const inputs: RuleForecastInput[] = [{ root: v2.root as ConditionNode, mediaType, libraryKeys, deletionAction }];
    const savedRule = ruleId ? rulesRepo.rules.getById(ruleId) : null;
    if (savedRule) {
      try {
        inputs.push({
          root: upgradeToV2(JSON.parse(savedRule.conditions)).root as ConditionNode,
          mediaType: savedRule.media_type,
          libraryKeys: savedRule.library_keys ?? [],
          deletionAction: savedRule.deletion_action,
        });
      } catch (error) {
        logger.warn('Could not forecast the saved rule', { message: (error as Error).message });
      }
    }

    const [proposed, saved = null] = forecastSingleRule(inputs);
    res.json({ success: true, data: { proposed, saved } });
  } catch (error) {
    logger.error('Failed to forecast rule:', error);
    res.status(500).json({ success: false, error: 'Failed to forecast rule' });
  }
});

function describeMatchReason(root: ConditionNode): string {
  if (root.kind === 'condition') {
    return `${root.field} ${root.operator}`;
  }
  return `${root.logic} of ${root.children.length} condition(s)`;
}

// ============================================================================
// Profiles Endpoints
// ============================================================================

// GET /api/rules/profiles - Get all profiles
router.get('/profiles/all', (_req: Request, res: Response) => {
  try {
    const profiles = rulesRepo.profiles.getAll();
    res.json({
      success: true,
      data: profiles,
      total: profiles.length,
    });
  } catch (error) {
    logger.error('Failed to get profiles:', error);
    res.status(500).json({
      success: false,
      error: 'Failed to retrieve profiles',
    });
  }
});

// GET /api/rules/profiles/active - Get active profile
router.get('/profiles/active', (_req: Request, res: Response) => {
  try {
    const profile = rulesRepo.profiles.getActive();
    if (!profile) {
      res.status(404).json({
        success: false,
        error: 'No active profile found',
      });
      return;
    }

    res.json({
      success: true,
      data: profile,
    });
  } catch (error) {
    logger.error('Failed to get active profile:', error);
    res.status(500).json({
      success: false,
      error: 'Failed to retrieve active profile',
    });
  }
});

// POST /api/rules/profiles - Create a new profile
const CreateProfileSchema = z.object({
  name: z.string().min(1),
});

router.post('/profiles', validateBody(CreateProfileSchema), (req: Request, res: Response) => {
  try {
    const profile = rulesRepo.profiles.create(req.body.name);
    res.status(201).json({
      success: true,
      data: profile,
      message: 'Profile created successfully',
    });
  } catch (error) {
    logger.error('Failed to create profile:', error);
    res.status(500).json({
      success: false,
      error: 'Failed to create profile',
    });
  }
});

// PATCH /api/rules/profiles/:id/activate - Set active profile
router.patch('/profiles/:id/activate', (req: Request, res: Response) => {
  try {
    const id = parseInt(req.params['id'] as string, 10);
    if (isNaN(id)) {
      res.status(400).json({
        success: false,
        error: 'Invalid profile ID',
      });
      return;
    }

    const profile = rulesRepo.profiles.setActive(id);
    if (!profile) {
      res.status(404).json({
        success: false,
        error: `Profile not found: ${id}`,
      });
      return;
    }

    res.json({
      success: true,
      data: profile,
      message: 'Profile activated',
    });
  } catch (error) {
    logger.error('Failed to activate profile:', error);
    res.status(500).json({
      success: false,
      error: 'Failed to activate profile',
    });
  }
});

// DELETE /api/rules/profiles/:id - Delete a profile
router.delete('/profiles/:id', (req: Request, res: Response) => {
  try {
    const id = parseInt(req.params['id'] as string, 10);
    if (isNaN(id)) {
      res.status(400).json({
        success: false,
        error: 'Invalid profile ID',
      });
      return;
    }

    const deleted = rulesRepo.profiles.delete(id);
    if (!deleted) {
      res.status(404).json({
        success: false,
        error: `Profile not found: ${id}`,
      });
      return;
    }

    res.json({
      success: true,
      message: 'Profile deleted successfully',
    });
  } catch (error) {
    logger.error('Failed to delete profile:', error);
    res.status(500).json({
      success: false,
      error: 'Failed to delete profile',
    });
  }
});

// ============================================================================
// Deletion History Endpoints
// ============================================================================

// GET /api/rules/history/deletions - Get deletion history
router.get('/history/deletions', (req: Request, res: Response) => {
  try {
    const limit = parseInt(req.query['limit'] as string, 10) || 100;
    const offset = parseInt(req.query['offset'] as string, 10) || 0;

    const history = rulesRepo.deletionHistory.getAll(limit, offset);
    res.json({
      success: true,
      data: history,
      total: history.length,
      limit,
      offset,
    });
  } catch (error) {
    logger.error('Failed to get deletion history:', error);
    res.status(500).json({
      success: false,
      error: 'Failed to retrieve deletion history',
    });
  }
});

// GET /api/rules/history/deletions/stats - Get deletion statistics
router.get('/history/deletions/stats', (_req: Request, res: Response) => {
  try {
    const stats = rulesRepo.deletionHistory.getStats();
    res.json({
      success: true,
      data: stats,
    });
  } catch (error) {
    logger.error('Failed to get deletion stats:', error);
    res.status(500).json({
      success: false,
      error: 'Failed to retrieve deletion statistics',
    });
  }
});

export default router;