/**
 * Backup Worker
 *
 * Background worker that processes backup jobs from an in-memory queue.
 * Follows the same pattern as aiKeyHealthWorker.ts — class-based with
 * start/stop lifecycle and a polling loop.
 *
 * Responsibilities:
 * 1. Process manually-triggered backup jobs (enqueued via POST /api/backup/create)
 * 2. Check BackupSettings for automatic backup schedules
 * 3. Apply auto-delete policies (keepCount, autoDeleteAfter)
 * 4. Send success/failure email notifications (see services/backup/backupNotifications)
 */

import { getModels } from '../models';
import { exportCompanyData } from '../services/backup/dataExporter';
import { createBackupZip, generateManifest, cleanupTempDir, cleanupTempDirAsync, formatFileSize, resolveBackupFile, getBackupsDir } from '../services/backup/zipGenerator';
import { calculateNextBackupTime } from '../services/backup/scheduleUtils';
import { ALL_CATEGORY_IDS } from '../services/backup/categoryMap';
import { notificationService } from '../services/notificationService';
import { notifyBackupResult } from '../services/backup/backupNotifications';
import fs from 'fs';
import path from 'path';
import os from 'os';

// ============================================
// TYPES
// ============================================

interface BackupJob {
  backupId: string;
  companyId: string;
  scope: 'everything' | 'custom';
  categories: string[];
  companySelection: 'workspace' | 'specific';
  targetCompanyId?: string;
  type: 'manual' | 'automatic';
  createdBy: string;
  encrypted: boolean;
}

// ============================================
// BACKUP WORKER CLASS
// ============================================

class BackupWorker {
  private isRunning: boolean = false;
  private intervalId: ReturnType<typeof setInterval> | null = null;
  private readonly pollInterval: number = 30000; // 30 seconds
  private queue: string[] = []; // Backup document IDs to process
  private isProcessingQueue: boolean = false; // guards processQueue re-entry

  async start(): Promise<void> {
    if (this.isRunning) return;
    this.isRunning = true;
    console.log('[BackupWorker] Starting backup worker...');
    // Clean up orphaned 0-byte ZIP files and mark broken backups as failed
    await this.cleanupOrphanedFiles();
    this.intervalId = setInterval(() => this.runTick(), this.pollInterval);
    // Run first tick after a short delay
    setTimeout(() => this.runTick(), 5000);
  }

  async stop(): Promise<void> {
    this.isRunning = false;
    if (this.intervalId) {
      clearInterval(this.intervalId);
      this.intervalId = null;
    }
    console.log('[BackupWorker] Stopped backup worker');
  }

  /**
   * Clean up orphaned 0-byte ZIP files and mark broken backups as failed.
   * Called once on worker startup to ensure consistency after crashes.
   */
  private async cleanupOrphanedFiles(): Promise<void> {
    const models = getModels();

    try {
      // Find all completed backups and verify their files exist and have content
      const completedBackups = await models.Backup.find({ status: 'completed' }).lean();

      for (const backup of completedBackups) {
        if (!backup.filePath) continue;

        // Resolve before condemning. The recorded path is absolute and derived
        // from process.cwd(), so launching the backend from a different working
        // directory invalidated every existing path and this sweep marked
        // healthy backups as failed. If the ZIP is still in the current backups
        // directory under the same name, heal the record and move on.
        const resolvedPath = resolveBackupFile(backup.filePath);
        if (resolvedPath && resolvedPath !== backup.filePath) {
          console.warn(
            `[BackupWorker][lifecycle] healed path for ${backup._id}: ` +
            `recorded=${backup.filePath} found=${resolvedPath} cwd=${process.cwd()}`
          );
          await models.Backup.findByIdAndUpdate(backup._id, { filePath: resolvedPath });
          backup.filePath = resolvedPath;
        }

        // Check if the file exists and has content
        if (!resolvedPath) {
          // Log the surrounding state before rewriting the record. Without the
          // directory listing there is no way to tell afterwards whether the
          // path was wrong or the file genuinely went away.
          const dir = path.dirname(backup.filePath);
          let siblings = '<unreadable>';
          try {
            siblings = fs.existsSync(dir)
              ? fs.readdirSync(dir).filter((f: string) => f.endsWith('.zip')).join(', ') || '<none>'
              : '<directory missing>';
          } catch { /* ignore */ }
          console.warn(
            `[BackupWorker][lifecycle] orphan detected for ${backup._id} (created ${backup.createdAt}): ` +
            `missing=${backup.filePath} recordedSize=${backup.size} cwd=${process.cwd()} ` +
            `dirContents=[${siblings}]`
          );
          console.warn(`[BackupWorker] Completed backup ${backup._id} has missing file: ${backup.filePath}. Marking as failed.`);
          await models.Backup.findByIdAndUpdate(backup._id, {
            status: 'failed',
            errorMessage: 'Backup file was deleted or not found on disk',
            size: 0,
          });
          continue;
        }

        const stats = fs.statSync(backup.filePath);
        if (stats.size < 22) {
          console.warn(`[BackupWorker] Completed backup ${backup._id} has invalid ZIP (${stats.size} bytes): ${backup.filePath}. Marking as failed.`);
          // Delete the invalid ZIP
          try { fs.unlinkSync(backup.filePath); } catch { /* ignore */ }
          await models.Backup.findByIdAndUpdate(backup._id, {
            status: 'failed',
            errorMessage: `Backup ZIP was too small (${stats.size} bytes) — likely empty or corrupt`,
            size: 0,
            filePath: '',
          });
        }
      }

      // Recover backups this sweep previously condemned by mistake.
      //
      // The loop above only ever looked at `completed` records, so once a
      // healthy backup had been flipped to `failed` because its cwd-derived
      // path stopped resolving, nothing revisited it — it stayed failed for
      // good even after the path issue was fixed. These records are recoverable
      // precisely because that branch does NOT clear `filePath` (unlike the
      // corrupt-ZIP branch below it, which blanks the path and deletes the
      // file), so the original location is still on the record.
      //
      // Deliberately narrow: matched on that exact errorMessage, and restored
      // ONLY when the ZIP is actually found and passes the same >=22-byte check
      // a fresh backup must pass. A genuinely missing or corrupt file stays
      // failed. Nothing here fabricates success.
      const wronglyFailed = await models.Backup.find({
        status: 'failed',
        errorMessage: 'Backup file was deleted or not found on disk',
      }).lean();

      for (const backup of wronglyFailed) {
        const recovered = resolveBackupFile(backup.filePath);
        if (!recovered) continue;

        const stats = fs.statSync(recovered);
        if (stats.size < 22) continue; // genuinely corrupt — leave it failed

        console.warn(
          `[BackupWorker][lifecycle] recovering wrongly-failed backup ${backup._id}: ` +
          `file=${recovered} size=${stats.size} cwd=${process.cwd()}`
        );
        await models.Backup.findByIdAndUpdate(backup._id, {
          status: 'completed',
          // Restored from the file itself — the sweep had zeroed it.
          size: stats.size,
          filePath: recovered,
          errorMessage: '',
        });
      }

      // Also clean up any ZIP files in the backups/ directory that don't correspond
      // to a database record (orphaned files from crashed processes).
      // getBackupsDir(), not an inline join: this block DELETES files, so it
      // must scan the same directory the writer used. With BACKUP_DIR set, an
      // inline cwd path would have pointed it somewhere else entirely.
      const backupsDir = getBackupsDir();
      if (fs.existsSync(backupsDir)) {
        const allBackups = await models.Backup.find({}).lean();
        const knownFiles = new Set(allBackups.map((b: any) => b.filePath).filter(Boolean));
        const zipFiles = fs.readdirSync(backupsDir).filter((f: string) => f.endsWith('.zip'));

        for (const file of zipFiles) {
          const fullPath = path.join(backupsDir, file);
          if (!knownFiles.has(fullPath)) {
            const stats = fs.statSync(fullPath);
            if (stats.size < 22) {
              console.warn(`[BackupWorker] Deleting orphaned 0-byte ZIP: ${file}`);
              try { fs.unlinkSync(fullPath); } catch { /* ignore */ }
            }
          }
        }
      }
    } catch (err) {
      console.error('[BackupWorker] Error during orphan cleanup:', err);
    }
  }

  /**
   * Enqueue a backup job for processing.
   */
  async enqueue(backupId: string): Promise<void> {
    if (!this.queue.includes(backupId)) {
      this.queue.push(backupId);
      console.log(`[BackupWorker] Enqueued backup: ${backupId}`);
    }
    // Start immediately rather than waiting for the next 30s tick. The tick had
    // already run processQueue() before this job was added, so a manual backup
    // sat in "queued" for up to a full poll interval with the UI spinning.
    // processQueue() guards against overlapping runs, so this is safe to call
    // while one is already in flight — the job is picked up by the active loop.
    void this.processQueue();
  }

  /**
   * Main processing loop.
   */
  private async runTick(): Promise<void> {
    if (!this.isRunning) return;

    try {
      // 1. Process any queued backup jobs
      await this.processQueue();

      // 2. Check for automatic backup schedules
      await this.checkAutoBackups();

      // 3. Apply auto-delete policies
      await this.applyAutoDeletePolicies();
    } catch (err) {
      console.error('[BackupWorker] Error in tick:', err);
    }
  }

  /**
   * Process all queued backup jobs.
   */
  private async processQueue(): Promise<void> {
    // enqueue() now kicks this off directly, so it can be entered while the
    // poll tick is already draining the queue. Without this guard the same
    // backup id could be shifted by two loops and processed twice.
    if (this.isProcessingQueue) return;
    this.isProcessingQueue = true;

    const models = getModels();

    try {
    while (this.queue.length > 0) {
      const backupId = this.queue.shift()!;

      try {
        const backup = await models.Backup.findById(backupId);
        if (!backup || backup.status === 'completed' || backup.status === 'failed') {
          continue;
        }

        await this.processBackup(backupId);
      } catch (err) {
        console.error(`[BackupWorker] Error processing backup ${backupId}:`, err);
      }
    }
    } finally {
      this.isProcessingQueue = false;
    }
  }

  /**
   * Process a single backup job through all its stages.
   */
  private async processBackup(backupId: string): Promise<void> {
    const models = getModels();
    const startTime = Date.now();

    try {
      const backup = await models.Backup.findById(backupId);
      if (!backup) return;

      const companyId = backup.companySelection === 'workspace'
        ? backup.companyId
        : (backup.targetCompanyId || backup.companyId);

      // Get company name for ZIP structure
      const company = await models.Company.findById(companyId);
      const companyName = company?.name?.replace(/[^a-zA-Z0-9]/g, '_') || 'Company';

      // Stage 1: Preparing
      await models.Backup.findByIdAndUpdate(backupId, {
        status: 'preparing',
        progress: 5,
      });

      // Create temp directory
      const tempDir = path.join(os.tmpdir(), `mengo-backup-${backupId}`);
      if (fs.existsSync(tempDir)) {
        await cleanupTempDirAsync(tempDir);
      }
      const companyDir = path.join(tempDir, 'companies', companyName);
      fs.mkdirSync(companyDir, { recursive: true });

      // Stage 2: Exporting data
      await models.Backup.findByIdAndUpdate(backupId, {
        status: 'exporting',
        progress: 15,
      });

      const stats = await exportCompanyData(
        companyId,
        backup.categories.length > 0 ? backup.categories : ALL_CATEGORY_IDS,
        companyDir,
        async (progress) => {
          await models.Backup.findByIdAndUpdate(backupId, {
            progress: Math.min(60, 15 + progress.progress),
          });
        },
      );

      // Generate manifest — use ALL_CATEGORY_IDS if scope is 'everything'
      const manifestCategories = backup.scope === 'everything' ? ALL_CATEGORY_IDS : backup.categories;
      generateManifest(tempDir, {
        version: '1.0',
        companyId,
        companyName: company?.name || 'Unknown',
        scope: backup.scope,
        categories: manifestCategories,
        companySelection: backup.companySelection,
        createdAt: new Date().toISOString(),
        createdBy: backup.createdBy,
      });

      // Stage 3: Compressing
      await models.Backup.findByIdAndUpdate(backupId, {
        status: 'compressing',
        progress: 70,
      });

      // Create backups directory. Resolved through getBackupsDir() so the
      // writer and every reader (the startup sweep, download, the orphan scan)
      // agree on one location, and so BACKUP_DIR moves all of them together.
      const backupsDir = getBackupsDir();
      if (!fs.existsSync(backupsDir)) {
        fs.mkdirSync(backupsDir, { recursive: true });
      }

      const zipFileName = `backup_${backupId}_${Date.now()}.zip`;
      const zipPath = path.join(backupsDir, zipFileName);

      const result = await createBackupZip(tempDir, zipPath);

      // Lifecycle audit trail. createBackupZip already validated the size and
      // computed a checksum, so this records the file as it stood at the exact
      // moment the record was about to be marked completed — the reference point
      // for deciding whether a later "file not found" means the ZIP was removed
      // afterwards or was never there.
      console.log(
        `[BackupWorker][lifecycle] zip written for ${backupId}: path=${zipPath} ` +
        `size=${result.size} existsNow=${fs.existsSync(zipPath)} cwd=${process.cwd()}`
      );

      // Stage 4: Completed
      const duration = Date.now() - startTime;
      await models.Backup.findByIdAndUpdate(backupId, {
        status: 'completed',
        progress: 100,
        size: result.size,
        checksum: result.checksum,
        filePath: zipPath,
        duration,
        stats: {
          files: stats.files,
          images: stats.images,
          videos: stats.videos,
          documents: stats.documents,
          csvs: stats.csvs,
          dbRecords: stats.dbRecords,
        },
      });

      // Clean up temp directory (async for Windows file locking)
      await cleanupTempDirAsync(tempDir);

      console.log(`[BackupWorker] Backup ${backupId} completed. Size: ${formatFileSize(result.size)}, Duration: ${duration}ms`);

      // Send notification if configured
      await this.sendNotification(backupId, 'completed');
    } catch (err: any) {
      console.error(`[BackupWorker] Backup ${backupId} failed:`, err);

      await models.Backup.findByIdAndUpdate(backupId, {
        status: 'failed',
        progress: 0,
        errorMessage: err.message || 'Unknown error occurred',
      });

      // Send failure notification
      await this.sendNotification(backupId, 'failed');
    }
  }

  /**
   * Check BackupSettings for automatic backup schedules that are due.
   *
   * Handles both:
   * - Transitioning a "scheduled" Backup to "queued" when the scheduled time arrives
   * - Creating a new "scheduled" Backup for the next cycle after processing
   */
  private async checkAutoBackups(): Promise<void> {
    const models = getModels();

    try {
      // Clean up stuck backups (in-progress for more than 30 minutes)
      // Note: 'scheduled' status is NOT included — scheduled backups are waiting
      // for their designated time, they are not stuck.
      const STUCK_THRESHOLD_MS = 30 * 60 * 1000; // 30 minutes
      const stuckCutoff = new Date(Date.now() - STUCK_THRESHOLD_MS);
      const stuckBackups = await models.Backup.find({
        status: { $in: ['queued', 'preparing', 'exporting', 'compressing'] },
        createdAt: { $lt: stuckCutoff },
      });

      for (const stuck of stuckBackups) {
        console.warn(`[BackupWorker] Marking stuck backup ${stuck._id} (status: ${stuck.status}, created: ${stuck.createdAt}) as failed`);
        await models.Backup.findByIdAndUpdate(stuck._id, {
          status: 'failed',
          errorMessage: 'Backup timed out — was stuck in progress for over 30 minutes',
          progress: 0,
        });
      }

      // Find all enabled auto-backup settings
      const settings = await models.BackupSettings.find({ enabled: true }).lean();
      console.log(`[BackupWorker] Checking auto-backups: ${settings.length} enabled settings found`);

      for (const setting of settings) {
        // Check if a backup is already in progress for this company
        const inProgress = await models.Backup.findOne({
          companyId: setting.companyId,
          status: { $in: ['queued', 'preparing', 'exporting', 'compressing'] },
        });

        if (inProgress) {
          console.log(`[BackupWorker] Skipping company ${setting.companyId}: backup already in progress`);
          continue;
        }

        if (!setting.nextBackupAt) {
          console.log(`[BackupWorker] Company ${setting.companyId}: no nextBackupAt set, calculating now`);
          // Calculate and set nextBackupAt if missing
          const nextTime = calculateNextBackupTime(setting);
          await models.BackupSettings.findByIdAndUpdate(setting._id, {
            nextBackupAt: nextTime,
          });

          // No placeholder Backup row is created here — the schedule is
          // nextBackupAt above. A record is written when the run starts.
          continue;
        }

        const now = new Date();
        const nextBackup = new Date(setting.nextBackupAt);
        console.log(`[BackupWorker] Company ${setting.companyId}: nextBackupAt=${nextBackup.toISOString()}, now=${now.toISOString()}, due=${nextBackup <= now}`);

        // Check if nextBackupAt has passed
        if (nextBackup <= now) {
          // Look for an existing "scheduled" backup for this company
          const scheduledBackup = await models.Backup.findOne({
            companyId: setting.companyId,
            status: 'scheduled',
            type: 'automatic',
          });

          let backupId: string;

          if (scheduledBackup) {
            // Transition the scheduled backup to queued
            await models.Backup.findByIdAndUpdate(scheduledBackup._id, {
              status: 'queued',
              progress: 0,
              name: `Auto Backup - ${now.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })} ${now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}`,
            });
            backupId = scheduledBackup._id.toString();
            console.log(`[BackupWorker] Transitioned scheduled backup ${backupId} to queued for company ${setting.companyId}`);
          } else {
            // No scheduled backup found — create a new one from scratch
            const backup = await models.Backup.create({
              name: `Auto Backup - ${now.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })} ${now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}`,
              companyId: setting.companyId,
              scope: setting.scope || 'everything',
              categories: (setting.scope === 'custom' && setting.categories?.length > 0) ? setting.categories : ALL_CATEGORY_IDS,
              companySelection: 'workspace',
              type: 'automatic',
              status: 'queued',
              progress: 0,
              createdBy: 'system',
              encrypted: setting.encrypted || false,
            });
            backupId = backup._id.toString();
            console.log(`[BackupWorker] Created automatic backup ${backupId} for company ${setting.companyId}`);
          }

          // Update next backup time
          const nextTime = calculateNextBackupTime(setting);
          await models.BackupSettings.findByIdAndUpdate(setting._id, {
            lastBackupAt: new Date(),
            nextBackupAt: nextTime,
          });

          // The next cycle is represented by nextBackupAt, not by a placeholder
          // Backup row — that row is what made the history show an entry before
          // any backup had run.
          console.log(`[BackupWorker] Next backup at: ${nextTime.toISOString()}`);
          await this.enqueue(backupId);
        }
      }
    } catch (err) {
      console.error('[BackupWorker] Error checking auto-backups:', err);
    }
  }

  // Next-backup-time calculation is in services/backup/scheduleUtils.ts
  // and used via the imported calculateNextBackupTime() function.

  /**
   * Apply auto-delete policies: remove old backups exceeding keepCount
   * and backups older than autoDeleteAfter.
   */
  private async applyAutoDeletePolicies(): Promise<void> {
    const models = getModels();

    try {
      const settings = await models.BackupSettings.find({ enabled: true }).lean();

      for (const setting of settings) {
        // Apply keepCount limit
        if (setting.keepCount && setting.keepCount > 0) {
          const backups = await models.Backup.find({
            companyId: setting.companyId,
            status: 'completed',
          })
            .sort({ createdAt: -1 })
            .lean();

          // Delete backups beyond the keep count
          if (backups.length > setting.keepCount) {
            const toDelete = backups.slice(setting.keepCount);
            for (const backup of toDelete) {
              await this.deleteBackup(backup._id.toString(), backup.filePath);
            }
          }
        }

        // Apply autoDeleteAfter age limit
        if (setting.autoDeleteAfter && setting.autoDeleteAfter !== 'never') {
          const ageMs = this.parseAutoDeleteDuration(setting.autoDeleteAfter);
          const cutoff = new Date(Date.now() - ageMs);

          const oldBackups = await models.Backup.find({
            companyId: setting.companyId,
            status: 'completed',
            createdAt: { $lt: cutoff },
          }).lean();

          for (const backup of oldBackups) {
            await this.deleteBackup(backup._id.toString(), backup.filePath);
          }
        }
      }
    } catch (err) {
      console.error('[BackupWorker] Error applying auto-delete policies:', err);
    }
  }

  /**
   * Parse auto-delete duration string to milliseconds.
   */
  private parseAutoDeleteDuration(duration: string): number {
    const map: Record<string, number> = {
      '7d': 7 * 24 * 60 * 60 * 1000,
      '15d': 15 * 24 * 60 * 60 * 1000,
      '30d': 30 * 24 * 60 * 60 * 1000,
      '90d': 90 * 24 * 60 * 60 * 1000,
      '6mo': 180 * 24 * 60 * 60 * 1000,
      '1yr': 365 * 24 * 60 * 60 * 1000,
    };
    return map[duration] || 0;
  }

  /**
   * Delete a backup document and its associated ZIP file.
   */
  private async deleteBackup(backupId: string, filePath?: string): Promise<void> {
    const models = getModels();

    try {
      await models.Backup.findByIdAndDelete(backupId);

      // Delete the ZIP file from disk
      if (filePath && fs.existsSync(filePath)) {
        fs.unlinkSync(filePath);
      }
    } catch (err) {
      console.error(`[BackupWorker] Error deleting backup ${backupId}:`, err);
    }
  }

  /**
   * Send the completion/failure email for a backup.
   *
   * Called from processBackup() *after* the backup's final status has already
   * been written, so nothing here can affect whether the backup itself is
   * recorded as successful. notifyBackupResult never throws and handles its own
   * configuration, recipients, retries and logging; this wrapper keeps the
   * belt-and-braces try/catch the previous implementation had.
   */
  private async sendNotification(backupId: string, status: 'completed' | 'failed'): Promise<void> {
    try {
      const models = getModels();
      const backup = await models.Backup.findById(backupId);
      if (!backup) return;

      const setting = await models.BackupSettings.findOne({
        companyId: backup.companyId,
      });

      if (setting?.notifyOnComplete && setting.notifyEmail) {
        // Log notification (email integration can be added later)
        console.log(`[BackupWorker] Notification: Backup ${backupId} ${status}. Would send email to ${setting.notifyEmail}`);
      }

      // In-app notification for the org's admins. Unlike the email above this
      // is not gated on notifyOnComplete — a failed backup is something an
      // admin needs to see, and the bell is opt-out by category in Phase 5.
      const succeeded = status === 'completed';
      void notificationService.notifyOrgRole(backup.companyId, 'admin', {
        type: succeeded ? 'backup.completed' : 'backup.failed',
        message: succeeded
          ? `Backup finished — ${formatFileSize(backup.size || 0)} across ${backup.stats?.dbRecords ?? 0} records.`
          : `Backup did not complete: ${backup.errorMessage || 'unknown error'}.`,
        entityType: 'backup',
        entityId: String(backupId),
        actionUrl: '/backup-restore',
        // Deliberately no actorUserId: whoever triggered the backup still wants
        // to hear how it went, and `createdBy` is 'system' for scheduled runs.
      });
      await notifyBackupResult({ backupId, status });
    } catch (err) {
      console.error(`[BackupWorker] Error sending notification for ${backupId}:`, err);
    }
  }
}

// ============================================
// SINGLETON EXPORT
// ============================================

let workerInstance: BackupWorker | null = null;

export function getBackupWorker(): BackupWorker {
  if (!workerInstance) {
    workerInstance = new BackupWorker();
  }
  return workerInstance;
}

export function startBackupWorker(): void {
  const worker = getBackupWorker();
  worker.start().catch((err) => {
    console.error('[BackupWorker] Failed to start:', err);
  });
}