<?php

declare(strict_types=1);

namespace KimaAI\Database;

use KimaAI\Database\Migrations\Version2026012801;

defined('ABSPATH') || exit;

final class Migrator {

  public const OPTION_DB_VERSION = 'kimaai_db_version';
  public const OPTION_PENDING = 'kimaai_db_update_pending';
  public const LOCK_TRANSIENT = 'kimaai_db_update_lock';

  /**
   * Bump this when you ship any schema/migration change.
   * Use an integer so comparisons are easy.
   */
  public const CODE_DB_VERSION = 2026012801;

  /**
   * Map version => callback.
   *
   * @return array<int, callable>
   */
  public static function updates(): array {
    return [
      2026012801 => [Version2026012801::class, 'up'],
    ];
  }

  /**
   * Registers migration hooks.
   */
  public static function hooks(): void {
    // Resume runner (single step per run).
    add_action('kimaai_db_update_runner', [self::class, 'runNextPendingStep']);

    // Run update check now (admin/cron/cli only).
    add_action('init', [self::class, 'maybeUpdate'], 20);
  }

  /**
   * Applies schema on activation.
   */
  public static function activate(): void {
    // On first install/activation: apply schema and mark version.
    SchemaRegistry::applySchema();
    update_option(self::OPTION_DB_VERSION, self::CODE_DB_VERSION, true);
    delete_option(self::OPTION_PENDING);
  }

  /**
   * Checks whether pending migrations should run.
   */
  public static function maybeUpdate(): void {
    $is_cli = defined('WP_CLI') && WP_CLI;
    $is_cron = defined('DOING_CRON') && DOING_CRON;
    $is_admin = is_admin();

    if (!$is_admin && !$is_cli && !$is_cron) {
      return;
    }

    SchemaRegistry::applySchema();

    $db_version = (int) get_option(self::OPTION_DB_VERSION, 0);

    // Up to date: nothing to do.
    if ($db_version >= self::CODE_DB_VERSION) {
      return;
    }

    // Don't clobber an existing queue (important for resume safety).
    $pending = get_option(self::OPTION_PENDING, null);

    if (!is_array($pending) || empty($pending)) {
      // Build queue only when missing/empty.
      $pending = self::buildPendingUpdates($db_version);

      if (!empty($pending)) {
        update_option(self::OPTION_PENDING, $pending, false);
      }
    }

    // If still no pending, just ensure db_version isn't stuck below code version.
    // (This can happen when schema changes are dbDelta-only and you forgot to add a migration file.)
    if (!is_array($pending) || empty($pending)) {
      return;
    }

    // Schedule background continuation (AS preferred).
    self::scheduleRunnerSoon();

    // UX + cron-disabled hardening: run one step immediately in admin (only).
    if ($is_admin) {
      self::runNextPendingStep();
    }
  }

  /**
   * Build the pending list of versions that must run.
   *
   * @return int[]
   */
  private static function buildPendingUpdates(int $db_version): array {
    $updates = self::updates();
    ksort($updates);

    $pending = [];
    foreach ($updates as $ver => $cb) {
      $ver = (int) $ver;
      if ($ver > $db_version && $ver <= self::CODE_DB_VERSION) {
        $pending[] = $ver;
      }
    }

    return $pending;
  }

  /**
   * Checks whether Action Scheduler functions are available.
   */
  private static function hasActionScheduler(): bool {
    return function_exists('as_schedule_single_action')
    || function_exists('as_enqueue_async_action')
    || function_exists('as_next_scheduled_action');
  }

  /**
   * Schedules the migration runner.
   */
  private static function scheduleRunnerSoon(): void {
    $hook = 'kimaai_db_update_runner';
    $group = 'kimaai';

    // Prefer Action Scheduler if available.
    if (self::hasActionScheduler()) {
      // Prevent duplicates if we can check.
      if (function_exists('as_next_scheduled_action') && as_next_scheduled_action($hook, [], $group)) {
        return;
      }

      // Schedule ASAP (now + 10 sec).
      if (function_exists('as_schedule_single_action')) {
        as_schedule_single_action(time() + 10, $hook, [], $group);
        return;
      }

      // Fallback: async enqueue.
      if (function_exists('as_enqueue_async_action')) {
        as_enqueue_async_action($hook, [], $group);
        return;
      }
      // If neither scheduler exists, fall through to WP-Cron.
    }

    // Fallback: WP-Cron.
    if (wp_next_scheduled($hook)) {
      return;
    }
    wp_schedule_single_event(time() + 10, $hook);
  }

  /**
   * Run exactly one migration step per invocation (resume-friendly).
   */
  public static function runNextPendingStep(): void {
    // Lock to avoid concurrent migrations.
    if (get_transient(self::LOCK_TRANSIENT)) {
      return;
    }
    // Keep lock relatively short; each call should do one small step.
    set_transient(self::LOCK_TRANSIENT, 1, 2 * MINUTE_IN_SECONDS);

    try {
      $pending = get_option(self::OPTION_PENDING, []);
      if (!is_array($pending) || empty($pending)) {
        // Nothing pending: cleanup (but avoid writing every request in maybeUpdate()).
        delete_option(self::OPTION_PENDING);
        update_option(
          self::OPTION_DB_VERSION,
          max((int) get_option(self::OPTION_DB_VERSION, 0), self::CODE_DB_VERSION),
          true
        );
        return;
      }

      $next_version = (int) array_shift($pending);

      $updates = self::updates();
      if (!isset($updates[$next_version]) || !is_callable($updates[$next_version])) {
        // Unknown migration version; persist remaining queue and stop safely.
        update_option(self::OPTION_PENDING, $pending, false);
        return;
      }

      // Run migration (must be idempotent).
      call_user_func($updates[$next_version]);

      // Mark version as completed.
      update_option(self::OPTION_DB_VERSION, $next_version, true);

      if (!empty($pending)) {
        // Persist remaining queue and schedule next step.
        update_option(self::OPTION_PENDING, $pending, false);
        self::scheduleRunnerSoon();
      }
      else {
        // All done; finalize.
        delete_option(self::OPTION_PENDING);
        update_option(self::OPTION_DB_VERSION, self::CODE_DB_VERSION, true);
      }
    } finally {
      delete_transient(self::LOCK_TRANSIENT);
    }
  }
}
