/**
 * Background Jobs System
 * Manages scheduled tasks and periodic operations.
 */

import type { RowDataPacket } from 'mysql2';
import type { NotifyResult } from '../notifications/notificationManager.js';
import type { NotificationCategory } from '../notifications/pushNotificationService.js';

interface LapsedPlayerRow extends RowDataPacket {
  id: string;
  data: string;
  updated_at: Date;
}

// ============================================================================
// Types
// ============================================================================

export type JobCallback = () => void | Promise<void>;

export interface ScheduledJob {
  id: string;
  name: string;
  interval: number; // in milliseconds
  callback: JobCallback;
  timer: NodeJS.Timeout | null;
  lastRun: Date | null;
  nextRun: Date | null;
  runCount: number;
  enabled: boolean;
}

export interface JobStats {
  id: string;
  name: string;
  interval: number;
  enabled: boolean;
  lastRun: Date | null;
  nextRun: Date | null;
  runCount: number;
}

// ============================================================================
// Background Jobs Manager
// ============================================================================

/**
 * Manages scheduled background tasks.
 */
export class BackgroundJobManager {
  private jobs: Map<string, ScheduledJob> = new Map();
  private running = false;

  /**
   * Register a new job.
   */
  register(
    name: string,
    intervalMs: number,
    callback: JobCallback,
    runImmediately = false
  ): string {
    const id = `job_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;

    const job: ScheduledJob = {
      id,
      name,
      interval: intervalMs,
      callback,
      timer: null,
      lastRun: null,
      nextRun: null,
      runCount: 0,
      enabled: true,
    };

    this.jobs.set(id, job);
    console.log(`Registered job: ${name} (every ${intervalMs}ms)`);

    if (this.running) {
      this.startJob(job, runImmediately);
    }

    return id;
  }

  /**
   * Unregister a job.
   */
  unregister(jobId: string): boolean {
    const job = this.jobs.get(jobId);
    if (!job) return false;

    this.stopJob(job);
    this.jobs.delete(jobId);
    console.log(`Unregistered job: ${job.name}`);
    return true;
  }

  /**
   * Start all registered jobs.
   */
  startAll(): void {
    if (this.running) return;
    this.running = true;

    for (const job of this.jobs.values()) {
      if (job.enabled) {
        this.startJob(job);
      }
    }
    console.log(`Started ${this.jobs.size} background jobs`);
  }

  /**
   * Stop all running jobs.
   */
  stopAll(): void {
    if (!this.running) return;
    this.running = false;

    for (const job of this.jobs.values()) {
      this.stopJob(job);
    }
    console.log('Stopped all background jobs');
  }

  /**
   * Enable a specific job.
   */
  enableJob(jobId: string): boolean {
    const job = this.jobs.get(jobId);
    if (!job) return false;

    job.enabled = true;
    if (this.running && !job.timer) {
      this.startJob(job);
    }
    return true;
  }

  /**
   * Disable a specific job.
   */
  disableJob(jobId: string): boolean {
    const job = this.jobs.get(jobId);
    if (!job) return false;

    job.enabled = false;
    this.stopJob(job);
    return true;
  }

  /**
   * Run a job immediately (in addition to its schedule).
   */
  async runNow(jobId: string): Promise<boolean> {
    const job = this.jobs.get(jobId);
    if (!job) return false;

    await this.executeJob(job);
    return true;
  }

  /**
   * Get stats for all jobs.
   */
  getStats(): JobStats[] {
    return Array.from(this.jobs.values()).map((job) => ({
      id: job.id,
      name: job.name,
      interval: job.interval,
      enabled: job.enabled,
      lastRun: job.lastRun,
      nextRun: job.nextRun,
      runCount: job.runCount,
    }));
  }

  /**
   * Get stats for a specific job.
   */
  getJobStats(jobId: string): JobStats | null {
    const job = this.jobs.get(jobId);
    if (!job) return null;

    return {
      id: job.id,
      name: job.name,
      interval: job.interval,
      enabled: job.enabled,
      lastRun: job.lastRun,
      nextRun: job.nextRun,
      runCount: job.runCount,
    };
  }

  /**
   * Start a single job.
   */
  private startJob(job: ScheduledJob, runImmediately = false): void {
    if (job.timer) return;

    const runJob = async (): Promise<void> => {
      await this.executeJob(job);
    };

    if (runImmediately) {
      runJob().catch((e) => console.error(`Job ${job.name} error:`, e));
    }

    job.timer = setInterval(runJob, job.interval);
    job.nextRun = new Date(Date.now() + job.interval);
  }

  /**
   * Stop a single job.
   */
  private stopJob(job: ScheduledJob): void {
    if (job.timer) {
      clearInterval(job.timer);
      job.timer = null;
    }
    job.nextRun = null;
  }

  /**
   * Execute a job's callback.
   */
  private async executeJob(job: ScheduledJob): Promise<void> {
    try {
      await job.callback();
      job.lastRun = new Date();
      job.runCount++;
      job.nextRun = new Date(Date.now() + job.interval);
    } catch (error) {
      console.error(`Job ${job.name} failed:`, error);
    }
  }
}

// ============================================================================
// Global Instance
// ============================================================================

let jobManager: BackgroundJobManager | null = null;

export function getJobManager(): BackgroundJobManager {
  if (!jobManager) {
    jobManager = new BackgroundJobManager();
  }
  return jobManager;
}

// ============================================================================
// Re-engagement (lapsed-player) push reminders
// ============================================================================

/** Default absence window (hours) for re-engagement targeting. */
export const REENGAGE_MIN_HOURS = 24;
export const REENGAGE_MAX_HOURS = 72;

/**
 * A candidate considered for a re-engagement reminder. This is intentionally a
 * flat, DB-agnostic shape so the selection + send logic can be unit-tested with
 * an injected clock and an injected sender (no real DB, no real APNs).
 */
export interface ReEngagementCandidate {
  userId: string;
  /** Epoch ms of the player's last activity (DB updated_at). */
  lastActiveMs: number;
  /** APNs device token; candidates without one cannot be reminded. */
  deviceToken?: string;
  /** Character first name for message personalization. */
  characterName?: string;
  /** Character age (years), used for the "just turned N" variant. */
  ageYears?: number;
  /** True if the character recently had a birthday (age-based reminder). */
  hadRecentBirthday?: boolean;
}

export interface ReEngagementSendArgs {
  userId: string;
  deviceToken: string;
  title: string;
  body: string;
  category: NotificationCategory;
}

/** Signature for the notification sender injected into the sweep (testable). */
export type ReEngagementSender = (args: ReEngagementSendArgs) => Promise<NotifyResult>;

export interface ReEngagementWindowOptions {
  minHours?: number;
  maxHours?: number;
}

/**
 * Select the candidates whose last-active time falls inside the re-engagement
 * window (default 24-72h ago) AND who have a device token to notify.
 *
 * Players active < minHours ago are still engaged (don't nag them); players
 * absent > maxHours ago are treated as lapsed/churned and excluded so we don't
 * spam long-dormant accounts indefinitely. Boundaries are inclusive of minHours
 * and exclusive at the far edge by elapsed >= min && elapsed <= max.
 */
export function selectLapsedReEngagementTargets(
  candidates: ReEngagementCandidate[],
  nowMs: number,
  options: ReEngagementWindowOptions = {}
): ReEngagementCandidate[] {
  const minHours = options.minHours ?? REENGAGE_MIN_HOURS;
  const maxHours = options.maxHours ?? REENGAGE_MAX_HOURS;
  const minMs = minHours * 60 * 60 * 1000;
  const maxMs = maxHours * 60 * 60 * 1000;

  return candidates.filter((c) => {
    if (!c.deviceToken) return false;
    const elapsed = nowMs - c.lastActiveMs;
    return elapsed >= minMs && elapsed <= maxMs;
  });
}

/**
 * Build the reminder copy for a candidate. Uses the "just turned N" variant when
 * a recent birthday is flagged and an age is present, otherwise a generic
 * "a big decision awaits" hook.
 */
export function buildReEngagementMessage(candidate: ReEngagementCandidate): {
  title: string;
  body: string;
} {
  const name = candidate.characterName?.trim() || 'your character';
  if (candidate.hadRecentBirthday && typeof candidate.ageYears === 'number') {
    return {
      title: 'Happy Birthday!',
      body: `${name} just turned ${candidate.ageYears}. Come celebrate!`,
    };
  }
  return {
    title: 'Your life is waiting',
    body: `A big decision awaits in ${name}'s life.`,
  };
}

export interface ReEngagementSweepResult {
  selected: number;
  sent: number;
  throttled: number;
  failed: number;
}

/**
 * Run one re-engagement sweep: select lapsed candidates, build their reminder
 * copy, and dispatch via the injected sender (which is expected to apply the
 * shared notification throttle). Returns a tally for observability/tests.
 *
 * The sender is injected (rather than importing notificationManager directly)
 * so tests can drive selection + throttle behavior without real APNs and the
 * production job can wire in the stub-safe notification path.
 */
export async function runReEngagementSweep(
  candidates: ReEngagementCandidate[],
  nowMs: number,
  send: ReEngagementSender,
  options: ReEngagementWindowOptions = {}
): Promise<ReEngagementSweepResult> {
  const targets = selectLapsedReEngagementTargets(candidates, nowMs, options);
  const result: ReEngagementSweepResult = {
    selected: targets.length,
    sent: 0,
    throttled: 0,
    failed: 0,
  };

  for (const candidate of targets) {
    const { title, body } = buildReEngagementMessage(candidate);
    try {
      const outcome = await send({
        userId: candidate.userId,
        deviceToken: candidate.deviceToken!,
        title,
        body,
        category: 'reminder',
      });
      if (outcome.sent) {
        result.sent++;
      } else if (outcome.reason === 'throttled') {
        result.throttled++;
      } else {
        result.failed++;
      }
    } catch (error) {
      console.error(`[ReEngage] Send failed for ${candidate.userId}:`, error);
      result.failed++;
    }
  }

  return result;
}

// ============================================================================
// Account deletion purge (Apple-required functional account deletion)
// ============================================================================

/**
 * A candidate considered for permanent deletion. Flat, DB-agnostic shape so the
 * selection + delete logic can be unit-tested with an injected clock and an
 * injected deleter (no real DB).
 */
export interface DeletionCandidate {
  userId: string;
  /**
   * ISO timestamp when the account became eligible for permanent purge
   * (deletion-confirm time + 30 days). null/undefined => NOT scheduled.
   */
  deletionScheduledAt?: string | null;
  /**
   * Whether the player is currently connected/online. Online players are
   * skipped (never delete someone mid-session). Optional; absent => treated as
   * offline. The authoritative guard is still the past-due check.
   */
  connected?: boolean;
}

/**
 * Select ONLY the candidates that are safe to permanently delete:
 *   1. Have a `deletionScheduledAt` set (opted in to deletion), AND
 *   2. that timestamp is a valid date in the PAST (grace period elapsed), AND
 *   3. are not currently connected/online.
 *
 * Any candidate without a scheduled deletion, with a future date, with an
 * unparseable date, or that is online is excluded. This is the single guarded
 * gate for the destructive purge path.
 */
export function selectExpiredDeletions(
  candidates: DeletionCandidate[],
  nowMs: number
): DeletionCandidate[] {
  return candidates.filter((c) => {
    if (c.connected) return false;
    if (!c.deletionScheduledAt) return false;
    const scheduledMs = Date.parse(c.deletionScheduledAt);
    if (Number.isNaN(scheduledMs)) return false;
    // Strictly past-due: scheduled time must be at or before now.
    return scheduledMs <= nowMs;
  });
}

export interface AccountDeletionPurgeResult {
  /** Number of candidates that passed the past-due/opted-in/offline gate. */
  selected: number;
  /** Number successfully deleted. */
  deleted: number;
  /** Number whose delete threw (left in place; retried next sweep). */
  failed: number;
}

/** Signature for the per-player deleter injected into the purge sweep. */
export type AccountDeleter = (userId: string) => Promise<void>;

/**
 * Run one account-deletion purge sweep: select past-due opted-in offline
 * accounts and permanently delete each via the injected deleter. Returns a
 * tally for observability/tests.
 *
 * SAFETY: selection goes through selectExpiredDeletions, so an account is only
 * ever deleted when it has a past-due `deletionScheduledAt` and is offline.
 * Idempotent — re-running after a successful purge finds nothing (the rows are
 * gone) and a partial failure simply retries the still-present rows next sweep.
 * Each deletion is logged with the userId + its scheduled date.
 */
export async function runAccountDeletionPurge(
  candidates: DeletionCandidate[],
  nowMs: number,
  deletePlayer: AccountDeleter
): Promise<AccountDeletionPurgeResult> {
  const targets = selectExpiredDeletions(candidates, nowMs);
  const result: AccountDeletionPurgeResult = {
    selected: targets.length,
    deleted: 0,
    failed: 0,
  };

  for (const target of targets) {
    try {
      await deletePlayer(target.userId);
      result.deleted++;
      console.log(
        `[AccountPurge] Permanently deleted account ${target.userId} ` +
          `(scheduled ${target.deletionScheduledAt})`
      );
    } catch (error) {
      console.error(`[AccountPurge] Failed to delete ${target.userId}:`, error);
      result.failed++;
    }
  }

  return result;
}

interface DeletionPlayerRow extends RowDataPacket {
  id: string;
  data: string;
}

/**
 * Production purge job body. Loads candidate accounts from the DB, builds the
 * flat candidate list (parsing deletionScheduledAt + connection status out of
 * the JSON blob / connection_status column), and runs runAccountDeletionPurge
 * against the real per-player deleter.
 *
 * The SQL pre-filter is a best-effort narrowing: we only pull rows whose JSON
 * blob mentions a non-null deletionScheduledAt. The authoritative past-due /
 * offline gate is applied in selectExpiredDeletions, so even if the SQL filter
 * over-selects, nothing is deleted unless it is genuinely past-due and offline.
 */
export async function runAccountDeletionPurgeJob(): Promise<AccountDeletionPurgeResult> {
  const { query } = await import('../../database/pool.js');
  const { deletePlayer } = await import('../../database/players.js');

  // Pre-filter to rows that have a scheduled deletion marker in their blob.
  // LIKE on the JSON text is a cheap narrowing; precise parsing happens below.
  const rows = await query<DeletionPlayerRow[]>(
    `SELECT id, data FROM players
       WHERE data LIKE '%"deletionScheduledAt"%'
         AND connection_status = 'disconnected'`
  );

  const candidates: DeletionCandidate[] = [];
  for (const row of rows) {
    let parsed: Record<string, unknown> = {};
    try {
      parsed = JSON.parse(row.data) as Record<string, unknown>;
    } catch {
      continue;
    }
    const scheduled = parsed.deletionScheduledAt;
    if (typeof scheduled !== 'string' || scheduled.length === 0) {
      continue;
    }
    candidates.push({
      userId: row.id,
      deletionScheduledAt: scheduled,
      // SQL already filtered to disconnected; treat as offline.
      connected: false,
    });
  }

  if (candidates.length === 0) {
    return { selected: 0, deleted: 0, failed: 0 };
  }

  const result = await runAccountDeletionPurge(candidates, Date.now(), deletePlayer);
  if (result.selected > 0) {
    console.log(
      `[Job] Account deletion purge: selected=${result.selected} ` +
        `deleted=${result.deleted} failed=${result.failed}`
    );
  }
  return result;
}

// ============================================================================
// Common Jobs
// ============================================================================

/**
 * Register common maintenance jobs.
 */
export function registerMaintenanceJobs(manager: BackgroundJobManager): void {
  // Cleanup rate limiters every minute
  manager.register(
    'rate_limiter_cleanup',
    60_000,
    () => {
      console.log('[Job] Cleaning up rate limiters');
      // Rate limiter cleanup happens automatically via setInterval in rate_limiter.ts
    },
    false
  );

  // Log performance report every 5 minutes
  manager.register(
    'performance_report',
    5 * 60_000,
    async () => {
      console.log('[Job] Logging performance report');
      // Import dynamically to avoid circular dependencies
      const { logPerformanceReport } = await import('../../monitoring/performance.js');
      logPerformanceReport();
    },
    false
  );

  // Health check every minute
  manager.register(
    'health_check',
    60_000,
    async () => {
      const { getHealthChecker } = await import('../../monitoring/health_check.js');
      const checker = getHealthChecker();
      const health = await checker.performFullCheck();
      if (health.status !== 'healthy') {
        console.warn(`[Job] Health check: ${health.status}`);
      }
    },
    false
  );

  // Iterate offline games every minute
  // This simulates game ticks for disconnected players so their games continue
  manager.register(
    'iterate_offline_games',
    60_000,
    async () => {
      try {
        const { iterateGames: iterateGamesLoop, initLifeSim } = await import('../../game/engine/LoopManager.js');
        const { loadGames, loadGameAsync, saveGameAsync } = await import('../../database/players.js');
        const { getOfflineQueue } = await import('./offline_queue.js');

        // Get offline queue for checking offline players
        const offlineQueue = getOfflineQueue();

        // Use the loop manager's iterateGames with our offline queue integration
        await iterateGamesLoop(loadGames, loadGameAsync, saveGameAsync);

        // Log offline queue stats periodically
        const stats = offlineQueue.getStats();
        if (stats.offlinePlayers > 0) {
          console.log(
            `[Job] Offline queue: ${stats.offlinePlayers} players, ` +
            `${stats.totalQueuedEvents} queued events`
          );
        }
      } catch (error) {
        console.error('[Job] Error iterating offline games:', error);
      }
    },
    false
  );

  // Re-engagement reminders for lapsed players, every 6 hours.
  // Finds players whose last activity is in the 24-72h window and fires a
  // throttle-respecting 'reminder' push (stub-safe if APNs isn't configured).
  manager.register(
    're_engage_lapsed_players',
    6 * 60 * 60_000, // every 6 hours
    async () => {
      try {
        const { query } = await import('../../database/pool.js');
        const { notify } = await import('../notifications/notificationManager.js');

        // Pull disconnected players whose last activity is in the lapsed window.
        // We over-select on time at the SQL layer (>= maxHours ago, <= minHours
        // ago) and let selectLapsedReEngagementTargets apply the precise window
        // + device-token filter so the targeting logic stays in one tested place.
        const rows = await query<LapsedPlayerRow[]>(
          `SELECT id, data, updated_at FROM players
             WHERE connection_status = 'disconnected'
               AND updated_at <= DATE_SUB(NOW(), INTERVAL ? HOUR)
               AND updated_at >= DATE_SUB(NOW(), INTERVAL ? HOUR)`,
          [REENGAGE_MIN_HOURS, REENGAGE_MAX_HOURS]
        );

        const candidates: ReEngagementCandidate[] = [];
        for (const row of rows) {
          let parsed: Record<string, unknown> = {};
          try {
            parsed = JSON.parse(row.data) as Record<string, unknown>;
          } catch {
            continue;
          }
          const character = (parsed.c ?? {}) as Record<string, unknown>;
          candidates.push({
            userId: row.id,
            lastActiveMs: new Date(row.updated_at).getTime(),
            deviceToken: typeof parsed.deviceToken === 'string' ? parsed.deviceToken : undefined,
            characterName:
              typeof character.firstname === 'string' ? character.firstname : undefined,
            ageYears: typeof character.ageYears === 'number' ? character.ageYears : undefined,
          });
        }

        if (candidates.length === 0) return;

        const result = await runReEngagementSweep(
          candidates,
          Date.now(),
          (args) =>
            notify({
              userId: args.userId,
              deviceToken: args.deviceToken,
              isBackgrounded: true, // lapsed = app not in foreground
              payload: {
                title: args.title,
                body: args.body,
                category: args.category,
                sound: 'default',
              },
            })
        );

        if (result.sent > 0 || result.selected > 0) {
          console.log(
            `[Job] Re-engagement sweep: selected=${result.selected} ` +
            `sent=${result.sent} throttled=${result.throttled} failed=${result.failed}`
          );
        }
      } catch (error) {
        console.error('[Job] Error running re-engagement sweep:', error);
      }
    },
    false
  );

  // Purge accounts whose 30-day deletion grace period has elapsed, every hour.
  // Apple-required functional account deletion: handleDeleteAccount persists a
  // deletionScheduledAt (now + 30 days); this job permanently deletes accounts
  // whose deletionScheduledAt is in the PAST (and only those — see
  // selectExpiredDeletions). Idempotent and logged per deletion.
  manager.register(
    'purge_deleted_accounts',
    60 * 60_000, // 1 hour
    async () => {
      try {
        await runAccountDeletionPurgeJob();
      } catch (error) {
        console.error('[Job] Error purging deleted accounts:', error);
      }
    },
    false
  );

  // Cleanup expired offline queues every hour
  manager.register(
    'offline_queue_cleanup',
    60 * 60_000, // 1 hour
    async () => {
      try {
        const { getOfflineQueue } = await import('./offline_queue.js');
        const queue = getOfflineQueue();
        const expired = queue.cleanupExpired();
        if (expired > 0) {
          console.log(`[Job] Cleaned up ${expired} expired offline queues`);
        }
      } catch (error) {
        console.error('[Job] Error cleaning up offline queues:', error);
      }
    },
    false
  );
}

// ============================================================================
// Export
// ============================================================================

export const backgroundJobs = {
  BackgroundJobManager,
  getJobManager,
  registerMaintenanceJobs,
};
