<?php
namespace GriffinForms\Includes\Schedulers;

use GriffinForms\Traits\Singleton;

/**
 * Scheduler responsible for cleaning up expired temporary files in GriffinForms.
 *
 * This class schedules itself using WordPress's WP-Cron system to run periodically,
 * deleting expired temporary files in batches to manage resource usage. It uses a transient
 * lock to prevent overlapping runs. Files are deleted both from the filesystem (only if
 * located within the WordPress uploads directory) and from the database. 
 *
 * The scheduler provides filters to customize time budgets, batch sizes, and lock TTL,
 * as well as actions fired after cleanup runs to allow further customization and integration.
 */
class FileCleanupScheduler
{
    use Singleton;

    protected $config;
    protected $settings;
    protected $table;
    protected $log;

    protected function setConfig()
    {
        $this->config = \GriffinForms\Config::getInstance();
    }

    protected function setSettings()
    {
        $this->settings = \GriffinForms\Settings::getInstance();
    }

    protected function setTable()
    {
        $this->table = $this->config->getTable('files');
    }

    protected function setLog()
    {
        $this->log = \GriffinForms\Log::getInstance();
    }

    /** @var string Cron action hook name */
    protected $cron_hook = 'griffinforms/files/cleanup';

    /** @var string Transient lock key to avoid overlapping runs */
    protected $lock_key = 'griffinforms_files_cleanup_lock';

    /**
     * Register cron hook and ensure schedule exists.
     */
    public function register()
    {
        if (empty($this->table)) { $this->setTable(); }
        if (empty($this->log)) { $this->setLog(); }
        if (empty($this->settings)) { $this->setSettings(); }

        add_action($this->cron_hook, array($this, 'run'));

        // Ensure a schedule exists; use recurrence from settings.
        $recurrence = $this->getCleanupRecurrence();
        $next = wp_next_scheduled($this->cron_hook);
        if (!$next) {
            wp_schedule_event(time() + 60, $recurrence, $this->cron_hook);
            if (!empty($this->log)) {
                $this->log->add('info', 'file', 0, 0, '[FileCleanupScheduler] Scheduled cron hook: ' . $this->cron_hook . ' (recurrence=' . $recurrence . ')', 'scheduler');
            }
        } else {
            // If the recurrence changed, reschedule to match settings
            $current_recurrence = wp_get_schedule($this->cron_hook);
            if ($current_recurrence !== $recurrence) {
                wp_clear_scheduled_hook($this->cron_hook);
                wp_schedule_event(time() + 60, $recurrence, $this->cron_hook);
                if (!empty($this->log)) {
                    $this->log->add('info', 'file', 0, 0, '[FileCleanupScheduler] Rescheduled cron hook with new recurrence=' . $recurrence, 'scheduler');
                }
            }
        }
    }

    /**
     * Unschedule the cron. Call from plugin deactivation if needed.
     */
    public function unschedule()
    {
        wp_clear_scheduled_hook($this->cron_hook);
    }

    /**
     * Main janitor. Deletes expired temp files in small batches within a time budget.
     */
    public function run()
    {
        if (empty($this->table)) { $this->setTable(); }
        if (empty($this->log)) { $this->setLog(); }
        if (empty($this->settings)) { $this->setSettings(); }

        if (!empty($this->log)) {
            $this->log->add('info', 'file', 0, 0, '[FileCleanupScheduler] Cleanup run started', 'scheduler');
        }

        if (!$this->acquireLock()) {
            if (!empty($this->log)) {
                $this->log->add('info', 'file', 0, 0, '[FileCleanupScheduler] Skipped: another run is in progress', 'scheduler');
            }
            return; // another run is in progress
        }

        $started_at   = microtime(true);
        $time_budget  = (int) apply_filters('griffinforms/files/cleanup/time_budget', 15); // seconds
        $batch_limit  = (int) apply_filters('griffinforms/files/cleanup/batch_limit', 200);
        $retention_hr = $this->getRetentionHours();

        $deleted_files = 0;
        $failed_files  = 0;

        try {
            do {
                $rows = $this->queryExpiredTempFiles($batch_limit, $retention_hr);
                if (empty($rows)) {
                    break;
                }

                foreach ($rows as $row) {
                    $ok = $this->deleteFileRow($row);
                    if ($ok) { $deleted_files++; } else { $failed_files++; }

                    // Check budget periodically
                    if ((microtime(true) - $started_at) >= $time_budget) {
                        break 2; // exit both loops
                    }
                }
            } while (count($rows) === $batch_limit);
        } catch (\Throwable $t) {
            if (!empty($this->log)) {
                $this->log->add('error', 'file', 0, 0, '[FileCleanupScheduler] Exception during cleanup: ' . $t->getMessage(), 'scheduler');
            }
        } finally {
            $this->releaseLock();
        }

        /**
         * Action fired after a cleanup run completes.
         *
         * @param int $deleted_files Number of files deleted
         * @param int $failed_files  Number of files that failed to delete
         */
        do_action('griffinforms/files/cleanup/after_run', $deleted_files, $failed_files);

        if (!empty($this->log)) {
            $log_type = ($deleted_files > 0) ? 'success' : 'info';
            $this->log->add($log_type, 'file', 0, 0,
                sprintf('[FileCleanupScheduler] Cleanup finished: deleted=%d, failed=%d', $deleted_files, $failed_files),
                'scheduler'
            );
        }
    }

    /**
     * Acquire a transient lock to avoid concurrent runs.
     *
     * @return bool
     */
    protected function acquireLock()
    {
        $now      = time();
        $ttl      = (int) apply_filters('griffinforms/files/cleanup/lock_ttl', 15 * 60); // 15 min
        $existing = get_transient($this->lock_key);
        if ($existing && ($now - (int) $existing) < $ttl) {
            return false;
        }
        set_transient($this->lock_key, $now, $ttl);
        return true;
    }

    /**
     * Release the transient lock.
     */
    protected function releaseLock()
    {
        delete_transient($this->lock_key);
    }

    /**
     * Query a batch of expired temp files.
     * A file is considered purgeable if status='temp' and (expires_at <= now OR created_at older than retention).
     *
     * @param int $limit
     * @param int $retention_hr
     * @return array<int,array<string,mixed>>
     */
    protected function queryExpiredTempFiles($limit, $retention_hr)
    {
        global $wpdb;

        $limit        = max(1, (int) $limit);
        $retention_hr = max(1, (int) $retention_hr);

        $now_mysql = current_time('mysql', true); // UTC

        // Fallback cut-off by created_at to handle rows with NULL expires_at
        // $wpdb->prepare nested expression for interval calculation
        $interval_sql = $wpdb->prepare('DATE_SUB(%s, INTERVAL %d HOUR)', $now_mysql, $retention_hr);

        $sql = "SELECT id, path, url, token, expires_at FROM {$this->table}
                WHERE status = 'temp'
                  AND ( (expires_at IS NOT NULL AND expires_at <= %s)
                        OR (created_at <= {$interval_sql}) )
                ORDER BY id ASC
                LIMIT %d";

        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
        $query = $wpdb->prepare($sql, $now_mysql, $limit);
        $rows  = $wpdb->get_results($query, ARRAY_A);

        if (!empty($wpdb->last_error) && !empty($this->log)) {
            $this->log->add('error', 'file', 0, 0, '[FileCleanupScheduler] DB error in queryExpiredTempFiles: ' . $wpdb->last_error, 'scheduler');
        }

        // Added code: inspect each row's expires_at for suspiciously high expiry
        if (!empty($rows) && !empty($this->log)) {
            $max_retention_hours = 720; // 30 days max
            $now = time();
            foreach ($rows as $row) {
                if (!empty($row['expires_at'])) {
                    $expires_at_timestamp = strtotime($row['expires_at']);
                    if ($expires_at_timestamp > ($now + ($max_retention_hours * 3600))) {
                        $message = sprintf(
                            '[FileCleanupScheduler] Suspicious expires_at for file id=%d: expires_at=%s is more than %d hours in the future',
                            $row['id'],
                            $row['expires_at'],
                            $max_retention_hours
                        );
                        $this->log->add('warning', 'file', $row['id'], 0, $message, 'scheduler');
                    }
                }
            }
        }

        return is_array($rows) ? $rows : array();
    }

    /**
     * Delete file from FS (if inside uploads) and remove DB row.
     *
     * @param array $row { id:int, path:string, token:string }
     * @return bool
     */
    protected function deleteFileRow($row)
    {
        global $wpdb;

        $id   = isset($row['id']) ? (int) $row['id'] : 0;

        if ($id <= 0) {
            if (!empty($this->log)) {
                $this->log->add('error', 'file', 0, 0, '[FileCleanupScheduler] Invalid row id for deletion', 'scheduler');
            }
            return false;
        }

        $path = isset($row['path']) ? (string) $row['path'] : '';

        $fs_deleted = true;
        if ($path !== '' && file_exists($path)) {
            // Safety: only delete within WP uploads
            $uploads      = wp_get_upload_dir();
            $uploads_base = isset($uploads['basedir']) ? (string) $uploads['basedir'] : '';
            if ($uploads_base !== '') {
                $uploads_base_norm = wp_normalize_path(trailingslashit($uploads_base));
                $file_path_norm    = wp_normalize_path($path);
                if (strpos($file_path_norm, $uploads_base_norm) !== 0) {
                    if (!empty($this->log)) {
                        $this->log->add('info', 'file', 0, 0, '[FileCleanupScheduler] Skipping FS delete outside uploads: ' . $file_path_norm, 'scheduler');
                    }
                } else {
                    wp_delete_file($path); // WP-native; filterable and handles related sizes
                    $fs_deleted = ! file_exists($path);
                    if ($fs_deleted) {
                        if (!empty($this->log)) {
                            $this->log->add('success', 'file', 0, 0, '[FileCleanupScheduler] Deleted file from FS: ' . $file_path_norm, 'scheduler');
                        }
                    } else {
                        if (!empty($this->log)) {
                            $this->log->add('error', 'file', 0, 0, '[FileCleanupScheduler] FS delete reported failure for id=' . $id, 'scheduler');
                        }
                    }
                }
            } else {
                if (!empty($this->log)) {
                    $this->log->add('error', 'file', 0, 0, '[FileCleanupScheduler] Uploads base dir unavailable; skipping FS delete', 'scheduler');
                }
            }
        }

        // Remove DB row regardless of FS result to avoid infinite retries.
        $deleted = (bool) $wpdb->delete($this->table, array('id' => $id), array('%d'));

        if (!$deleted) {
            if (!empty($this->log)) {
                $this->log->add('error', 'file', 0, 0, '[FileCleanupScheduler] DB delete failed for id=' . $id . (empty($wpdb->last_error) ? '' : ('; error=' . $wpdb->last_error)), 'scheduler');
            }
        } else {
            if (!empty($this->log)) {
                $this->log->add('success', 'file', 0, 0, '[FileCleanupScheduler] Removed DB row id=' . $id, 'scheduler');
            }
        }

        return ($deleted && $fs_deleted);
    }

    /**
     * Get temp retention hours from Files Storage settings, with sane fallback.
     */
    protected function getRetentionHours(): int
    {
        $default = 48;
        if (empty($this->settings)) { $this->setSettings(); }
        if (!empty($this->settings) && method_exists($this->settings, 'getOption')) {
            $opt = $this->settings->getOption('files_temp_retention_hours');
            if (is_numeric($opt)) {
                $val = (int) $opt;
                if ($val >= 1 && $val <= 720) { // clamp to 30 days max
                    return $val;
                }
            }
        }
        return $default;
    }

    /**
     * Get cleanup recurrence from Files Storage settings (hourly|twicedaily|daily), fallback hourly.
     */
    protected function getCleanupRecurrence(): string
    {
        $allowed = array('hourly', 'twicedaily', 'daily');
        if (empty($this->settings)) { $this->setSettings(); }
        if (!empty($this->settings) && method_exists($this->settings, 'getOption')) {
            $val = (string) $this->settings->getOption('files_cleanup_recurrence');
            $val = strtolower($val);
            if (in_array($val, $allowed, true)) {
                return $val;
            }
        }
        return 'hourly';
    }
}