/**
 * Backup Scheduler for Onebox
 *
 * Uses @push.rocks/taskbuffer for cron-based scheduled backups
 * with GFS (Grandfather-Father-Son) time-window based retention scheme.
 */

import * as plugins from '../plugins.js';
import type {
  IBackupSchedule,
  IBackupScheduleCreate,
  IBackupScheduleUpdate,
  IService,
  IRetentionPolicy,
} from '../types.js';
import { RETENTION_PRESETS } from '../types.js';
import { logger } from '../logging.js';
import { getErrorMessage } from '../utils/error.js';
import type { Onebox } from './onebox.js';

export class BackupScheduler {
  private oneboxRef: Onebox;
  private taskManager!: plugins.taskbuffer.TaskManager;
  private scheduledTasks: Map<number, plugins.taskbuffer.Task> = new Map();
  private initialized = false;

  constructor(oneboxRef: Onebox) {
    this.oneboxRef = oneboxRef;
    // TaskManager is created in init() to avoid log spam before ready
  }

  /**
   * Initialize the scheduler and load enabled schedules
   */
  async init(): Promise<void> {
    if (this.initialized) {
      logger.warn('BackupScheduler already initialized');
      return;
    }

    try {
      // Create TaskManager here (not in constructor) to avoid "no cronjobs" log spam
      this.taskManager = new plugins.taskbuffer.TaskManager();

      // Add heartbeat task immediately to prevent "no cronjobs specified" log spam
      // This runs hourly and does nothing, but keeps taskbuffer happy
      const heartbeatTask = new plugins.taskbuffer.Task({
        name: 'backup-scheduler-heartbeat',
        taskFunction: async () => {
          // No-op heartbeat task
        },
      });
      this.taskManager.addAndScheduleTask(heartbeatTask, '0 * * * *'); // Hourly

      // Load all enabled schedules from database
      const schedules = await this.oneboxRef.database.getEnabledBackupSchedules();

      for (const schedule of schedules) {
        await this.registerTask(schedule);
      }

      // Add periodic archive prune task (runs daily at 3 AM)
      const pruneTask = new plugins.taskbuffer.Task({
        name: 'backup-archive-prune',
        taskFunction: async () => {
          await this.pruneArchive();
        },
      });
      this.taskManager.addAndScheduleTask(pruneTask, '0 3 * * *');

      // Start the task manager (activates cron scheduling)
      await this.taskManager.start();

      this.initialized = true;
      logger.info(`Backup scheduler started with ${schedules.length} enabled schedule(s)`);
    } catch (error) {
      logger.error(`Failed to initialize backup scheduler: ${getErrorMessage(error)}`);
      throw error;
    }
  }

  /**
   * Stop the scheduler
   */
  async stop(): Promise<void> {
    if (!this.initialized || !this.taskManager) return;

    try {
      await this.taskManager.stop();
      this.scheduledTasks.clear();
      this.initialized = false;
      logger.info('Backup scheduler stopped');
    } catch (error) {
      logger.error(`Failed to stop backup scheduler: ${getErrorMessage(error)}`);
    }
  }

  /**
   * Create a new backup schedule
   */
  async createSchedule(request: IBackupScheduleCreate): Promise<IBackupSchedule> {
    // Validate based on scope type
    let serviceId: number | undefined;
    let serviceName: string | undefined;

    switch (request.scopeType) {
      case 'service':
        // Validate service exists
        if (!request.serviceName) {
          throw new Error('serviceName is required for service-specific schedules');
        }
        const service = await this.oneboxRef.database.getServiceByName(request.serviceName);
        if (!service) {
          throw new Error(`Service not found: ${request.serviceName}`);
        }
        serviceId = service.id!;
        serviceName = service.name;
        break;

      case 'pattern':
        // Validate pattern is provided
        if (!request.scopePattern) {
          throw new Error('scopePattern is required for pattern-based schedules');
        }
        // Validate pattern matches at least one service
        const matchingServices = await this.getServicesMatchingPattern(request.scopePattern);
        if (matchingServices.length === 0) {
          logger.warn(`Pattern "${request.scopePattern}" currently matches no services`);
        }
        break;

      case 'all':
        // No validation needed for global schedules
        break;

      default:
        throw new Error(`Invalid scope type: ${request.scopeType}`);
    }

    // Use provided cron expression
    const cronExpression = request.cronExpression;

    // Calculate next run time
    const nextRunAt = this.calculateNextRun(cronExpression);

    // Create schedule in database
    const schedule = await this.oneboxRef.database.createBackupSchedule({
      scopeType: request.scopeType,
      scopePattern: request.scopePattern,
      serviceId,
      serviceName,
      cronExpression,
      retention: request.retention,
      enabled: request.enabled !== false,
      lastRunAt: null,
      nextRunAt,
      lastStatus: null,
      lastError: null,
      createdAt: Date.now(),
      updatedAt: Date.now(),
    });

    // Register task if enabled
    if (schedule.enabled) {
      await this.registerTask(schedule);
    }

    const scopeDesc = this.getScopeDescription(schedule);
    const retentionDesc = this.getRetentionDescription(schedule.retention);
    logger.info(`Backup schedule created: ${schedule.id} for ${scopeDesc} (${retentionDesc})`);
    return schedule;
  }

  /**
   * Update an existing backup schedule
   */
  async updateSchedule(scheduleId: number, updates: IBackupScheduleUpdate): Promise<IBackupSchedule> {
    const schedule = await this.oneboxRef.database.getBackupScheduleById(scheduleId);
    if (!schedule) {
      throw new Error(`Backup schedule not found: ${scheduleId}`);
    }

    // Deschedule existing task if present
    await this.descheduleTask(scheduleId);

    // Update database
    await this.oneboxRef.database.updateBackupSchedule(scheduleId, updates);

    // Get updated schedule
    const updatedSchedule = (await this.oneboxRef.database.getBackupScheduleById(scheduleId))!;

    // Calculate new next run time if cron changed
    if (updates.cronExpression) {
      const nextRunAt = this.calculateNextRun(updatedSchedule.cronExpression);
      await this.oneboxRef.database.updateBackupSchedule(scheduleId, { nextRunAt });
    }

    // Re-register task if enabled
    if (updatedSchedule.enabled) {
      await this.registerTask(updatedSchedule);
    }

    logger.info(`Backup schedule updated: ${scheduleId}`);
    return (await this.oneboxRef.database.getBackupScheduleById(scheduleId))!;
  }

  /**
   * Delete a backup schedule
   */
  async deleteSchedule(scheduleId: number): Promise<void> {
    const schedule = await this.oneboxRef.database.getBackupScheduleById(scheduleId);
    if (!schedule) {
      throw new Error(`Backup schedule not found: ${scheduleId}`);
    }

    // Deschedule task
    await this.descheduleTask(scheduleId);

    // Delete from database
    await this.oneboxRef.database.deleteBackupSchedule(scheduleId);

    logger.info(`Backup schedule deleted: ${scheduleId}`);
  }

  async deleteSchedulesForService(serviceId: number): Promise<void> {
    const schedules = await this.oneboxRef.database.getBackupSchedulesByService(serviceId);
    for (const schedule of schedules) {
      await this.descheduleTask(schedule.id!);
      await this.oneboxRef.database.deleteBackupSchedule(schedule.id!);
      logger.info(`Backup schedule deleted for removed service: ${schedule.id}`);
    }
  }

  /**
   * Trigger immediate backup for a schedule
   */
  async triggerBackup(scheduleId: number): Promise<void> {
    const schedule = await this.oneboxRef.database.getBackupScheduleById(scheduleId);
    if (!schedule) {
      throw new Error(`Backup schedule not found: ${scheduleId}`);
    }

    logger.info(`Manually triggering backup for schedule ${scheduleId}`);
    await this.executeBackup(schedule);
  }

  /**
   * Get all schedules
   */
  async getAllSchedules(): Promise<IBackupSchedule[]> {
    return await this.oneboxRef.database.getAllBackupSchedules();
  }

  /**
   * Get schedule by ID
   */
  async getScheduleById(id: number): Promise<IBackupSchedule | null> {
    return await this.oneboxRef.database.getBackupScheduleById(id);
  }

  /**
   * Get schedules for a service
   */
  async getSchedulesForService(serviceName: string): Promise<IBackupSchedule[]> {
    const service = await this.oneboxRef.database.getServiceByName(serviceName);
    if (!service) {
      return [];
    }
    return await this.oneboxRef.database.getBackupSchedulesByService(service.id!);
  }

  /**
   * Get retention presets
   */
  getRetentionPresets(): typeof RETENTION_PRESETS {
    return RETENTION_PRESETS;
  }

  // ========== Private Methods ==========

  /**
   * Register a task for a schedule
   */
  private async registerTask(schedule: IBackupSchedule): Promise<void> {
    const taskName = `backup-${schedule.id}`;

    const task = new plugins.taskbuffer.Task({
      name: taskName,
      taskFunction: async () => {
        await this.executeBackup(schedule);
      },
    });

    // Add and schedule the task
    this.taskManager.addAndScheduleTask(task, schedule.cronExpression);
    this.scheduledTasks.set(schedule.id!, task);

    // Update next run time in database
    await this.updateNextRunTime(schedule.id!);

    logger.debug(`Registered backup task: ${taskName} with cron: ${schedule.cronExpression}`);
  }

  /**
   * Deschedule a task
   */
  private async descheduleTask(scheduleId: number): Promise<void> {
    const task = this.scheduledTasks.get(scheduleId);
    if (task) {
      await this.taskManager.descheduleTask(task);
      this.scheduledTasks.delete(scheduleId);
      logger.debug(`Descheduled backup task for schedule ${scheduleId}`);
    }
  }

  /**
   * Execute a backup for a schedule
   */
  private async executeBackup(schedule: IBackupSchedule): Promise<void> {
    const scopeDesc = this.getScopeDescription(schedule);
    const servicesToBackup = await this.getServicesForSchedule(schedule);

    if (servicesToBackup.length === 0) {
      logger.warn(`No services to backup for schedule ${schedule.id} (${scopeDesc})`);
      await this.oneboxRef.database.updateBackupSchedule(schedule.id!, {
        lastRunAt: Date.now(),
        lastStatus: 'success',
        lastError: 'No matching services found',
      });
      await this.updateNextRunTime(schedule.id!);
      return;
    }

    const retentionDesc = this.getRetentionDescription(schedule.retention);
    logger.info(`Executing scheduled backup for ${scopeDesc}: ${servicesToBackup.length} service(s) (${retentionDesc})`);

    let successCount = 0;
    let failCount = 0;
    const errors: string[] = [];

    for (const service of servicesToBackup) {
      try {
        // Create backup with schedule ID
        await this.oneboxRef.backupManager.createBackup(service.name, {
          scheduleId: schedule.id,
        });

        // Apply time-window based retention policy for this service
        await this.applyRetention(schedule, service.id!);

        successCount++;
        logger.success(`Scheduled backup completed for ${service.name}`);
      } catch (error) {
        const errorMessage = getErrorMessage(error);
        logger.error(`Scheduled backup failed for ${service.name}: ${errorMessage}`);
        errors.push(`${service.name}: ${errorMessage}`);
        failCount++;
      }
    }

    // Update schedule status
    const lastStatus = failCount === 0 ? 'success' : 'failed';
    const lastError = errors.length > 0 ? errors.join('; ') : null;

    await this.oneboxRef.database.updateBackupSchedule(schedule.id!, {
      lastRunAt: Date.now(),
      lastStatus,
      lastError,
    });

    if (failCount === 0) {
      logger.success(`Scheduled backup completed for ${scopeDesc}: ${successCount} service(s)`);
    } else {
      logger.warn(`Scheduled backup partially failed for ${scopeDesc}: ${successCount} succeeded, ${failCount} failed`);
    }

    // Update next run time
    await this.updateNextRunTime(schedule.id!);
  }

  /**
   * Apply time-window based retention policy
   * Works correctly regardless of backup frequency (cron schedule)
   */
  private async applyRetention(schedule: IBackupSchedule, serviceId: number): Promise<void> {
    // Get all backups for this schedule and service
    const allBackups = await this.oneboxRef.database.getBackupsByService(serviceId);
    const backups = allBackups.filter(b => b.scheduleId === schedule.id);

    if (backups.length === 0) {
      return;
    }

    const { hourly, daily, weekly, monthly } = schedule.retention;
    const now = Date.now();
    const toKeep = new Set<number>();

    // Hourly: Keep up to N most recent backups from last 24 hours
    if (hourly > 0) {
      const recentBackups = backups
        .filter(b => now - b.createdAt < 24 * 60 * 60 * 1000)
        .sort((a, b) => b.createdAt - a.createdAt)
        .slice(0, hourly);
      recentBackups.forEach(b => toKeep.add(b.id!));
    }

    // Daily: Keep oldest backup per day for last N days
    if (daily > 0) {
      for (let i = 0; i < daily; i++) {
        const dayStart = this.getStartOfDay(now, i);
        const dayEnd = dayStart + 24 * 60 * 60 * 1000;
        const dayBackups = backups.filter(b =>
          b.createdAt >= dayStart && b.createdAt < dayEnd
        );
        if (dayBackups.length > 0) {
          // Keep oldest from this day (most representative)
          const oldest = dayBackups.sort((a, b) => a.createdAt - b.createdAt)[0];
          toKeep.add(oldest.id!);
        }
      }
    }

    // Weekly: Keep oldest backup per week for last N weeks
    if (weekly > 0) {
      for (let i = 0; i < weekly; i++) {
        const weekStart = this.getStartOfWeek(now, i);
        const weekEnd = weekStart + 7 * 24 * 60 * 60 * 1000;
        const weekBackups = backups.filter(b =>
          b.createdAt >= weekStart && b.createdAt < weekEnd
        );
        if (weekBackups.length > 0) {
          const oldest = weekBackups.sort((a, b) => a.createdAt - b.createdAt)[0];
          toKeep.add(oldest.id!);
        }
      }
    }

    // Monthly: Keep oldest backup per month for last N months
    if (monthly > 0) {
      for (let i = 0; i < monthly; i++) {
        const { start, end } = this.getMonthRange(now, i);
        const monthBackups = backups.filter(b =>
          b.createdAt >= start && b.createdAt < end
        );
        if (monthBackups.length > 0) {
          const oldest = monthBackups.sort((a, b) => a.createdAt - b.createdAt)[0];
          toKeep.add(oldest.id!);
        }
      }
    }

    // Delete anything not in toKeep
    for (const backup of backups) {
      if (!toKeep.has(backup.id!)) {
        try {
          await this.oneboxRef.backupManager.deleteBackup(backup.id!);
          const backupRef = backup.snapshotId || backup.filename;
          logger.info(`Deleted backup ${backupRef} (retention policy)`);
        } catch (error) {
          const backupRef = backup.snapshotId || backup.filename;
          logger.warn(`Failed to delete old backup ${backupRef}: ${getErrorMessage(error)}`);
        }
      }
    }
  }

  /**
   * Get start of day (midnight) for N days ago
   */
  private getStartOfDay(now: number, daysAgo: number): number {
    const date = new Date(now);
    date.setDate(date.getDate() - daysAgo);
    date.setHours(0, 0, 0, 0);
    return date.getTime();
  }

  /**
   * Get start of week (Sunday midnight) for N weeks ago
   */
  private getStartOfWeek(now: number, weeksAgo: number): number {
    const date = new Date(now);
    date.setDate(date.getDate() - (weeksAgo * 7) - date.getDay());
    date.setHours(0, 0, 0, 0);
    return date.getTime();
  }

  /**
   * Get month range for N months ago
   */
  private getMonthRange(now: number, monthsAgo: number): { start: number; end: number } {
    const date = new Date(now);
    date.setMonth(date.getMonth() - monthsAgo);
    date.setDate(1);
    date.setHours(0, 0, 0, 0);
    const start = date.getTime();

    date.setMonth(date.getMonth() + 1);
    const end = date.getTime();

    return { start, end };
  }

  /**
   * Update next run time for a schedule
   */
  private async updateNextRunTime(scheduleId: number): Promise<void> {
    const schedule = await this.oneboxRef.database.getBackupScheduleById(scheduleId);
    if (!schedule) return;

    const nextRunAt = this.calculateNextRun(schedule.cronExpression);
    await this.oneboxRef.database.updateBackupSchedule(scheduleId, { nextRunAt });
  }

  /**
   * Calculate next run time from cron expression
   */
  private calculateNextRun(cronExpression: string): number {
    try {
      // Get next scheduled runs from task manager
      const scheduledTasks = this.taskManager.getScheduledTasks();

      // Find our task and get its next run
      for (const taskInfo of scheduledTasks) {
        if (taskInfo.schedule === cronExpression && taskInfo.nextRun) {
          return taskInfo.nextRun.getTime();
        }
      }

      // Fallback: parse cron and calculate next occurrence
      // Simple implementation for common patterns
      const now = new Date();
      const parts = cronExpression.split(' ');

      if (parts.length === 5) {
        const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;

        // For daily schedules (e.g., "0 2 * * *")
        if (dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
          const nextRun = new Date(now);
          nextRun.setHours(parseInt(hour), parseInt(minute), 0, 0);
          if (nextRun <= now) {
            nextRun.setDate(nextRun.getDate() + 1);
          }
          return nextRun.getTime();
        }

        // For weekly schedules (e.g., "0 2 * * 0")
        if (dayOfMonth === '*' && month === '*' && dayOfWeek !== '*') {
          const targetDay = parseInt(dayOfWeek);
          const nextRun = new Date(now);
          nextRun.setHours(parseInt(hour), parseInt(minute), 0, 0);
          const currentDay = now.getDay();
          let daysUntilTarget = (targetDay - currentDay + 7) % 7;
          if (daysUntilTarget === 0 && nextRun <= now) {
            daysUntilTarget = 7;
          }
          nextRun.setDate(nextRun.getDate() + daysUntilTarget);
          return nextRun.getTime();
        }

        // For monthly schedules (e.g., "0 2 1 * *")
        if (dayOfMonth !== '*' && month === '*' && dayOfWeek === '*') {
          const targetDay = parseInt(dayOfMonth);
          const nextRun = new Date(now);
          nextRun.setDate(targetDay);
          nextRun.setHours(parseInt(hour), parseInt(minute), 0, 0);
          if (nextRun <= now) {
            nextRun.setMonth(nextRun.getMonth() + 1);
          }
          return nextRun.getTime();
        }

        // For yearly schedules (e.g., "0 2 1 1 *")
        if (dayOfMonth !== '*' && month !== '*' && dayOfWeek === '*') {
          const targetMonth = parseInt(month) - 1; // JavaScript months are 0-indexed
          const targetDay = parseInt(dayOfMonth);
          const nextRun = new Date(now);
          nextRun.setMonth(targetMonth, targetDay);
          nextRun.setHours(parseInt(hour), parseInt(minute), 0, 0);
          if (nextRun <= now) {
            nextRun.setFullYear(nextRun.getFullYear() + 1);
          }
          return nextRun.getTime();
        }
      }

      // Default: next day at 2 AM
      const fallback = new Date(now);
      fallback.setDate(fallback.getDate() + 1);
      fallback.setHours(2, 0, 0, 0);
      return fallback.getTime();
    } catch {
      // On any error, return tomorrow at 2 AM
      const fallback = new Date();
      fallback.setDate(fallback.getDate() + 1);
      fallback.setHours(2, 0, 0, 0);
      return fallback.getTime();
    }
  }

  /**
   * Get services that match a schedule based on its scope type
   */
  private async getServicesForSchedule(schedule: IBackupSchedule): Promise<IService[]> {
    const allServices = await this.oneboxRef.database.getAllServices();

    switch (schedule.scopeType) {
      case 'all':
        return allServices;

      case 'pattern':
        if (!schedule.scopePattern) return [];
        return await this.getServicesMatchingPattern(schedule.scopePattern);

      case 'service':
        if (!schedule.serviceId) return [];
        const service = allServices.find(s => s.id === schedule.serviceId);
        return service ? [service] : [];

      default:
        return [];
    }
  }

  /**
   * Get services that match a glob pattern
   */
  private async getServicesMatchingPattern(pattern: string): Promise<IService[]> {
    const allServices = await this.oneboxRef.database.getAllServices();
    return allServices.filter(s => this.matchesGlobPattern(s.name, pattern));
  }

  /**
   * Simple glob pattern matching (supports * and ?)
   */
  private matchesGlobPattern(text: string, pattern: string): boolean {
    // Convert glob pattern to regex
    // Escape special regex characters except * and ?
    const regexPattern = pattern
      .replace(/[.+^${}()|[\]\\]/g, '\\$&')  // Escape special chars
      .replace(/\*/g, '.*')                   // * matches any characters
      .replace(/\?/g, '.');                   // ? matches single character

    const regex = new RegExp(`^${regexPattern}$`, 'i');
    return regex.test(text);
  }

  /**
   * Get human-readable description of a schedule's scope
   */
  private getScopeDescription(schedule: IBackupSchedule): string {
    switch (schedule.scopeType) {
      case 'all':
        return 'all services';
      case 'pattern':
        return `pattern "${schedule.scopePattern}"`;
      case 'service':
        return `service "${schedule.serviceName}"`;
      default:
        return 'unknown scope';
    }
  }

  /**
   * Get human-readable description of retention policy
   */
  private getRetentionDescription(retention: IRetentionPolicy): string {
    return `H:${retention.hourly} D:${retention.daily} W:${retention.weekly} M:${retention.monthly}`;
  }

  /**
   * Prune the containerarchive repository to reclaim storage.
   * Uses the most generous retention policy across all schedules.
   */
  private async pruneArchive(): Promise<void> {
    const archive = this.oneboxRef.backupManager.archive;
    if (!archive) return;

    try {
      // Compute the most generous retention across all schedules
      const schedules = await this.oneboxRef.database.getAllBackupSchedules();

      // Default minimums if no schedules exist
      let maxDays = 7;
      let maxWeeks = 4;
      let maxMonths = 12;

      for (const schedule of schedules) {
        if (schedule.retention.daily > maxDays) maxDays = schedule.retention.daily;
        if (schedule.retention.weekly > maxWeeks) maxWeeks = schedule.retention.weekly;
        if (schedule.retention.monthly > maxMonths) maxMonths = schedule.retention.monthly;
      }

      const result = await archive.prune(
        {
          keepDays: maxDays,
          keepWeeks: maxWeeks,
          keepMonths: maxMonths,
        },
        false, // not dry run
      );

      if (result.removedSnapshots > 0 || result.freedBytes > 0) {
        const freedMB = Math.round(result.freedBytes / (1024 * 1024) * 10) / 10;
        logger.info(
          `Archive prune: removed ${result.removedSnapshots} snapshot(s), ` +
          `${result.removedPacks} pack(s), freed ${freedMB} MB`
        );
      }
    } catch (error) {
      logger.warn(`Archive prune failed: ${getErrorMessage(error)}`);
    }
  }
}
