<?php

namespace AIPT\Core\Database;

/**
 * Periodic cleanup of old job log records to prevent unbounded table growth.
 *
 * Defends the `aipt_job_logs` table against the bloat pattern surfaced on
 * a customer site (62 MB / 37,042 rows). Combines four mitigations:
 *
 *   1. Configurable retention via the `aipt_automation_job_log_retention_days`
 *      filter (default 7 days, was 30).
 *   2. Chunked DELETE with LIMIT so a large prune can never lock the table.
 *   3. Per-run row cap so one cron tick cannot stall behind a 100 MB sweep
 *      — leftover rows are caught up by subsequent ticks.
 *   4. Transient mutex + error_log on failure so concurrent runs serialise
 *      and silent breakage no longer goes unnoticed.
 *
 * A self-heal entry point (`ensure_scheduled()`) is exposed for Init to
 * call on every admin_init: if the cron event is ever dropped (host cron
 * outage, plugin update race), it is re-armed on the next admin page load
 * without requiring a deactivate / reactivate cycle.
 *
 * @since 2.6.0
 */
class JobLogCleaner {

    /**
     * Cron hook name.
     */
    const CRON_HOOK = 'aipt_clean_job_logs';

    /**
     * Default retention period in days.
     *
     * Logs older than this are eligible for deletion. The actual cutoff
     * is filterable via `aipt_automation_job_log_retention_days` so high
     * volume sites can shorten it and debug-heavy sites can lengthen it
     * without code edits.
     */
    const RETENTION_DAYS = 7;

    /**
     * Maximum rows deleted per DELETE statement.
     *
     * Keeps individual statements short so the table is never held under
     * a long write lock. Tuned for InnoDB row locking — 1000 rows fits
     * comfortably in a single transaction.
     */
    public const DELETE_CHUNK_SIZE = 1000;

    /**
     * Maximum rows deleted in a single cron run.
     *
     * Bounds the worst-case runtime of one cleanup tick. On the customer
     * site (37k rows) this still clears the entire backlog in one tick;
     * on a much larger bloat (e.g. 500k rows) it caps the run at ~100k
     * and lets subsequent ticks finish the job.
     */
    public const MAX_ROWS_PER_RUN = 100000;

    /**
     * Transient key used to serialise concurrent cleanup runs.
     *
     * Manual triggers (e.g. an admin "Run cleanup now" action) and the
     * scheduled cron should not race; whichever acquires the lock wins
     * and the other returns early.
     */
    private const LOCK_TRANSIENT = 'aipt_job_log_cleaner_lock';

    /**
     * Lock TTL in seconds. Generous enough for a worst-case 100k-row
     * cleanup, short enough that a crashed worker does not keep the lock
     * forever.
     */
    private const LOCK_TTL_SECONDS = 300;

    /**
     * Job logs table name.
     *
     * @var string
     */
    private $table;

    /**
     * Constructor.
     */
    public function __construct() {
        global $wpdb;
        $this->table = $wpdb->prefix . 'aipt_job_logs';
    }

    /**
     * Schedule the daily cleanup cron job (activation hook entry).
     *
     * @return bool Whether scheduling succeeded.
     */
    public function schedule(): bool {
        try {
            if ( defined( 'DISABLE_WP_CRON' ) && \DISABLE_WP_CRON ) {
                return false;
            }

            if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
                $scheduled = wp_schedule_event( time(), 'daily', self::CRON_HOOK );
                if ( false === $scheduled ) {
                    return false;
                }
            }

            return true;
        } catch ( \Exception $e ) {
            return false;
        }
    }

    /**
     * Re-arm the cleanup schedule if it has been lost.
     *
     * Hooked from `admin_init` so that any admin page load on a site whose
     * cron event was dropped (host outage, race during plugin update, a
     * misbehaving uninstall hook) restores the schedule without requiring
     * a reactivation cycle. No-op when the event is already armed or when
     * DISABLE_WP_CRON is true (no point scheduling something WP-Cron will
     * never run).
     *
     * @return bool True when the event is (re-)scheduled or already armed.
     */
    public static function ensure_scheduled(): bool {
        try {
            if ( defined( 'DISABLE_WP_CRON' ) && \DISABLE_WP_CRON ) {
                return false;
            }

            if ( wp_next_scheduled( self::CRON_HOOK ) ) {
                return true;
            }

            return false !== wp_schedule_event( time() + MINUTE_IN_SECONDS, 'daily', self::CRON_HOOK );
        } catch ( \Exception $e ) {
            return false;
        }
    }

    /**
     * Unschedule the cleanup cron job.
     *
     * @return bool Whether unscheduling succeeded.
     */
    public function unschedule(): bool {
        try {
            $timestamp = wp_next_scheduled( self::CRON_HOOK );
            if ( $timestamp ) {
                $unscheduled = wp_unschedule_event( $timestamp, self::CRON_HOOK );
                if ( false === $unscheduled ) {
                    return false;
                }
            }

            return true;
        } catch ( \Exception $e ) {
            return false;
        }
    }

    /**
     * Resolve the active retention threshold in days.
     *
     * @return int A positive integer; defaults to {@see self::RETENTION_DAYS}
     *             if the filter returns a non-positive value.
     */
    private function retention_days(): int {
        $value = (int) apply_filters( 'aipt_automation_job_log_retention_days', self::RETENTION_DAYS );
        return $value > 0 ? $value : self::RETENTION_DAYS;
    }

    /**
     * Delete job log records older than the retention period.
     *
     * Iterates with `DELETE ... LIMIT` chunks so the table is never held
     * under a long write lock; a per-run cap bounds the worst-case runtime
     * so a single cron tick can never stall on multi-gigabyte bloat.
     *
     * @return int|false Total number of deleted records (across chunks)
     *                   or false on a database-level error.
     */
    public function clean_old_records() {
        global $wpdb;

        if ( get_transient( self::LOCK_TRANSIENT ) ) {
            return 0;
        }

        if ( ! set_transient( self::LOCK_TRANSIENT, time(), self::LOCK_TTL_SECONDS ) ) {

            error_log( '[AIPT] JobLogCleaner: failed to acquire lock transient; aborting run to avoid race.' );
            return 0;
        }

        $total_deleted = 0;

        try {
            $cutoff_date = gmdate( 'Y-m-d H:i:s', strtotime( '-' . $this->retention_days() . ' days' ) );

            while ( $total_deleted < self::MAX_ROWS_PER_RUN ) {
                $query = $wpdb->prepare(

                    "DELETE FROM {$this->table} WHERE created_at < %s LIMIT %d",
                    $cutoff_date,
                    self::DELETE_CHUNK_SIZE
                );

                $deleted = $wpdb->query( $query );

                if ( false === $deleted ) {

                    return false;
                }

                if ( 0 === (int) $deleted ) {

                    break;
                }

                $total_deleted += (int) $deleted;
            }

            if ( $total_deleted >= self::MAX_ROWS_PER_RUN ) {

                error_log( sprintf(
                    '[AIPT] JobLogCleaner: hit MAX_ROWS_PER_RUN=%d in one tick; subsequent ticks will finish the backlog.',
                    self::MAX_ROWS_PER_RUN
                ) );
            }

            return $total_deleted;
        } catch ( \Exception $e ) {

            return false;
        } finally {
            delete_transient( self::LOCK_TRANSIENT );
        }
    }

    /**
     * Static cron callback.
     */
    public static function cron_callback(): void {
        try {
            $cleaner = new self();
            $cleaner->clean_old_records();
        } catch ( \Exception $e ) {

        }
    }
}
