<?php

defined('ABSPATH') || exit;

abstract class MeprBatchMigrator
{
    /**
     * How many seconds the migrator should run for before sending a response back to the browser.
     *
     * It is set below 30 seconds, as some servers have this set as the PHP max execution time.
     *
     * @var int
     */
    const TIMEOUT = 25;

    /**
     * The start time.
     *
     * @var float
     */
    protected float $start;

    /**
     * The number of items processed since the start.
     *
     * @var integer
     */
    protected int $processed = 0;

    /**
     * The maximum number of items to retrieve in each batch.
     *
     * @var integer
     */
    protected int $limit = 1;

    /**
     * The cursor value (last seen primary key) for keyset pagination.
     *
     * @var integer
     */
    protected int $cursor = 0;

    /**
     * The items in the current batch.
     *
     * @var array
     */
    protected array $items = [];

    /**
     * True if we are processing the first batch.
     *
     * @var boolean
     */
    protected bool $first_batch = true;

    /**
     * Initializes a new instance of the MeprBatchMigrator class.
     *
     * @param integer $limit  The maximum number of items to retrieve in each batch.
     * @param integer $cursor The starting cursor (last seen primary key); 0 means start from the beginning.
     */
    public function __construct(int $limit, int $cursor = 0)
    {
        $this->limit  = $limit;
        $this->cursor = $cursor;
        $this->start  = microtime(true);
    }

    /**
     * Subclasses implement this. Return the next batch of rows fetched using
     * $this->cursor and $this->limit. Do NOT advance $this->cursor here — the
     * base advances it during the next iteration's bookkeeping, AFTER the caller
     * has processed these items, so a timeout mid-step re-fetches the unprocessed
     * batch rather than skipping it.
     *
     * A cursorless subclass (one whose cursor_column() returns null) must fetch
     * from a set that shrinks as its items are processed, since termination
     * relies on fetch_batch() eventually returning an empty array.
     *
     * @return array
     */
    abstract protected function fetch_batch(): array;

    /**
     * The property on each fetched row holding the keyset cursor — the primary
     * key paginated on, e.g. 'id'. Keyset subclasses declare this and let the
     * base advance the cursor for them. Returns null for cursorless subclasses
     * that paginate by set-difference (nothing to advance).
     *
     * @return string|null
     */
    protected function cursor_column(): ?string
    {
        return null;
    }

    /**
     * Advance the cursor to the last processed row's key, read from the property
     * named by cursor_column(). Called by the base in the bookkeeping step, when
     * $this->items holds the previously fetched batch the caller has processed.
     * A subclass with a composite or computed cursor may override this directly.
     *
     * @return void
     */
    protected function advance_cursor(): void
    {
        $column = $this->cursor_column();

        if (null === $column || empty($this->items)) {
            return;
        }

        // Reading by last key avoids mutating the array's internal pointer
        // (which end() would), keeping the read side-effect-free.
        $last = $this->items[array_key_last($this->items)];

        $this->cursor = (int) $last->{$column};
    }

    /**
     * Retrieves the maximum number of items to fetch in each batch.
     *
     * @return integer
     */
    public function get_limit(): int
    {
        return $this->limit;
    }

    /**
     * Retrieves the cursor value (last seen primary key) for keyset pagination.
     *
     * @return integer
     */
    public function get_cursor(): int
    {
        return $this->cursor;
    }

    /**
     * Is there at least one item in the current batch?
     *
     * @return boolean
     */
    public function has_items(): bool
    {
        return !empty($this->items);
    }

    /**
     * Get the current batch of items.
     *
     * @return array
     */
    public function get_items(): array
    {
        return $this->items;
    }

    /**
     * Fetch the next batch of items and determines if we can we proceed with processing the next batch.
     *
     * We can proceed if this is the first batch, or the projected time to complete the next batch will not take us over
     * the timeout.
     *
     * @return boolean
     */
    public function next_batch(): bool
    {
        if ($this->first_batch) {
            $this->first_batch = false;
        } else {
            MeprMigrator::free_memory();

            // Advance the cursor past the previously processed batch, using the
            // column each keyset subclass declares via cursor_column(); cursorless
            // subclasses declare none and this is a no-op.
            $this->advance_cursor();

            // Update the total count of items processed in this request.
            $this->processed += count($this->items);
        }

        $elapsed_time  = 0.0;
        $time_per_item = 0.0;

        if ($this->processed > 0) {
            $elapsed_time  = microtime(true) - $this->start;
            $time_per_item = $elapsed_time / $this->processed;

            // Calculate the number of items to process in the next batch.
            if ($time_per_item > 0) {
                $this->limit = MeprUtils::clamp(intval(4 / $time_per_item), 1, 500);
            }
        }

        $this->items = $this->fetch_batch();

        if (empty($this->items)) {
            return false;
        }

        if ($this->processed > 0) {
            $projected_batch_time = count($this->items) * $time_per_item;

            return ($elapsed_time + $projected_batch_time) < self::TIMEOUT;
        }

        return true;
    }
}
