<?php

declare(strict_types=1);

namespace PrettyLinks\Pro\Expiration;

use PrettyLinks\Repositories\Links;

// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key
// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_value
// Custom plugin tables (prli_*): table names interpolated from $wpdb->prefix (trusted),
// user values bind through $wpdb->prepare(). "meta_key"/"meta_value" refer to our own
// prli_link_metas table, not wp_postmeta.

/**
 * Twice-daily sweep that trashes date-expired links whose expiration action
 * is `trash`, so a link nobody visits still leaves the admin on schedule.
 *
 * The on-the-fly path (ExpirationChecker, redirect-time) stays in place —
 * whichever gets to an expired link first wins; both funnel into
 * Links::softDelete(), which is idempotent for an already-trashed row
 * because the sweep query and the redirect router both skip trashed links.
 *
 * Scope: date-based expirations only. Click-count expirations stay
 * visit-time — their counter only moves on visits, so lazy IS exact there.
 */
class TrashSweeper
{
    public const HOOK = 'prli_trash_expired_links';

    public const BATCH = 100;

    /**
     * Ensure the twice-daily (12h) cron event exists.
     *
     * @return void
     */
    public function schedule(): void
    {
        if (!wp_next_scheduled(self::HOOK)) {
            wp_schedule_event(time() + HOUR_IN_SECONDS, 'twicedaily', self::HOOK);
        }
    }

    /**
     * Clear the cron event (fired from `prli_plugin_deactivating`).
     *
     * @return void
     */
    public static function unschedule(): void
    {
        wp_clear_scheduled_hook(self::HOOK);
    }

    /**
     * Cron callback: hand the sweep to the Resque worker so trashing many
     * links (events, filters, per-row updates) never runs inside the cron
     * request itself.
     *
     * Operational note: this only enqueues — the sweep runs when the Resque
     * worker next drains the queue. If the worker is stopped or badly backed
     * up, a date-expired trash-link that gets no visits stays routable in the
     * admin until the job runs. The redirect-time path (ExpirationChecker)
     * still trashes on the first visit regardless, so no visitor is ever
     * served an expired link; only the unvisited-link cleanup is deferred.
     *
     * @return void
     */
    public function enqueueSweep(): void
    {
        // No in-flight lock is needed: if a prior chain is still running when
        // the next cron fires, softDelete() skips already-trashed rows
        // (deleted_at IS NULL) and the keyset cursor makes every scan
        // monotonic, so overlapping sweeps are correct — at worst they re-scan
        // ranges, never double-trash.
        (new TrashExpiredLinksJob())->enqueue(['after_id' => 0]);
    }

    /**
     * Trash one batch of date-expired, trash-action links whose id is above
     * `$afterId` (keyset pagination).
     *
     * The keyset cursor — not an offset, not "lowest ids each time" — is what
     * keeps the re-enqueue chain both terminating and starvation-free: a row
     * blocked by `prli_pre_link_trash` keeps `deleted_at` NULL, so an
     * id-ascending "lowest first" query would re-serve it every batch and
     * either spin forever or wall off the trashable rows behind it. Advancing
     * the cursor past every id we've *examined* (trashed or blocked) means
     * each row is looked at once and the sweep always moves forward.
     *
     * @param  integer $afterId Only consider links with id greater than this.
     * @return array{trashed: int, lastId: int, moreRemain: bool}
     */
    public function sweepBatch(int $afterId = 0): array
    {
        global $wpdb;
        $t   = $wpdb->prefix;
        $ids = $wpdb->get_col(
            $wpdb->prepare(
                "SELECT DISTINCT l.id
                 FROM {$t}prli_links l
                 JOIN {$t}prli_link_metas en
                   ON en.link_id = l.id AND en.meta_key = 'enable_expire' AND en.meta_value = '1'
                 JOIN {$t}prli_link_metas ac
                   ON ac.link_id = l.id AND ac.meta_key = 'expire_action' AND ac.meta_value = 'trash'
                 JOIN {$t}prli_link_metas dt
                   ON dt.link_id = l.id AND dt.meta_key = 'expire_date'
                 LEFT JOIN {$t}prli_link_metas ty
                   ON ty.link_id = l.id AND ty.meta_key = 'expire_type'
                 WHERE l.deleted_at IS NULL
                   AND l.id > %d
                   AND (ty.meta_value IS NULL OR ty.meta_value = 'date')
                   AND dt.meta_value REGEXP '^[0-9]{4}-[0-9]{2}-[0-9]{2}'
                   AND dt.meta_value <= %s
                 ORDER BY l.id ASC
                 LIMIT %d",
                $afterId,
                current_time('mysql', false),
                self::BATCH
            )
        ) ?: [];

        $links   = new Links();
        $trashed = 0;
        $lastId  = $afterId;
        foreach ($ids as $id) {
            $id     = (int) $id;
            $lastId = $id;
            if ($links->softDelete($id)) {
                ++$trashed;
            }
        }

        return [
            'trashed'    => $trashed,
            'lastId'     => $lastId,
            // A full batch means more may lie beyond the cursor. Because the
            // cursor advances past blocked rows too, "more remain" no longer
            // needs to depend on trashed > 0 to avoid spinning.
            'moreRemain' => count($ids) === self::BATCH,
        ];
    }
}
