<?php

namespace FluentCrm\App\Hooks\Handlers;

use FluentCrm\App\Models\Campaign;
use FluentCrm\App\Models\CampaignEmail;
use FluentCrm\App\Services\CampaignProcessor;
use FluentCrm\App\Services\ExternalIntegrations\Maintenance;
use FluentCrm\App\Services\Helper;
use FluentCrm\App\Services\Libs\FileSystem;
use FluentCrm\App\Services\Libs\Mailer\Handler;
use FluentCrm\App\Services\Libs\Mailer\MultiThreadHandler;

/**
 *  Scheduler Class
 *
 * @package FluentCrm\App\Hooks
 *
 * @version 1.0.0
 */
class Scheduler
{

    public static function register()
    {
        /*
         * Migrating from CRON to Action Scheduler for Every Minutes Tasks
         */
        add_action('fluentcrm_scheduled_minute_tasks', function () {
            // Auto-migration: ensure the Action Scheduler recurring action exists.
            if (!as_has_scheduled_action('fluentcrm_scheduled_every_minute_tasks', [], 'fluent-crm')) {
                Helper::debugLog('Migrating Every Minute CRON to Action Scheduler for FluentCRM');
                as_schedule_recurring_action(time(), 60, 'fluentcrm_scheduled_every_minute_tasks', [], 'fluent-crm');
                return;
            }

            // WP-Cron is a TRUE fallback: only take over when Action Scheduler
            // has actually stalled. _fcrm_last_scheduler is written by
            // Scheduler::process() on every successful AS-driven minute tick;
            // if it's fresh, AS owns this minute and we no-op here.
            $lastScheduler = fluentCrmGetOptionCache('_fcrm_last_scheduler');
            if ($lastScheduler && (time() - $lastScheduler) <= 70) {
                return;
            }

            // AS appears stalled (or has never run on this site yet) — take
            // over via the same locked entry point AS uses. The atomic lock
            // inside process() prevents two concurrent WP-Cron runners both
            // deciding to take over from racing each other.
            self::process();
        });

        // This is required to instantly send emails for regular email handler.
        // The atomic lock inside Handler::isSystemOk() (acquired before any
        // expensive work) is the authoritative guard against concurrent and
        // duplicate sends, so we invoke the sender directly — a losing racer
        // bails cheaply at the lock. No cron-timing pre-check is needed here.
        add_action('wp_ajax_nopriv_fluentcrm-post-campaigns-send-now', function () {
            (new \FluentCrm\App\Services\Libs\Mailer\Handler())->handle();

            nocache_headers();
            wp_send_json_success([
                'message'   => 'success',
                'timestamp' => time()
            ]);
        });

        // For Multi Threaded Emails Internal Ajax. Same as above — the atomic
        // lock inside MultiThreadHandler::isSystemOk() guards against concurrent
        // runners, so we call the handler directly and let the loser bail at the
        // lock. The experimental-flag check stays here to avoid constructing the
        // handler at all when multi-threading is disabled.
        add_action('wp_ajax_nopriv_fluentcrm-post-multi-thread-send-now', function () {
            if (Helper::isExperimentalEnabled('multi_threading_emails')) {
                (new MultiThreadHandler())->handle();
            }

            nocache_headers();
            wp_send_json_success([
                'message'   => 'success',
                'timestamp' => time()
            ]);
        });

        add_action('fluentcrm_scheduled_every_minute_tasks', array(__CLASS__, 'process'));
        add_action('fluentcrm_scheduled_hourly_tasks', array(__CLASS__, 'processHourly'));
        add_action('fluentcrm_scheduled_five_minute_tasks', array(__CLASS__, 'processFiveMinutes'));
        add_action('fluentcrm_process_contact_jobs', array(__CLASS__, 'processForSubscriber'), 999, 1);
        add_action('fluentcrm_scheduled_weekly_tasks', array(__CLASS__, 'processWeekly'));
        add_action('fluent_crm_send_multi_thread_emails', array(__CLASS__, 'processMultiThreadEmails'), 10);

        add_action('fluent_crm_cancel_multi_thread_mailing', function () {
            as_unschedule_all_actions('fluent_crm_send_multi_thread_emails');
            return true;
        });

        /*
         *  Clean up schedule that means removing from database-  tasks by action scheduler
         * Clean up before last 7 days logs generated by action scheduler
         * this action will be triggered daily and will remove all the logs generated before 7 days
         */
        add_action('fluent_crm_ascheduler_runs_daily', function () {
            Cleanup::maybeRemoveOldScheuledActionLogs();
        });

    }

    public static function process()
    {
        wp_raise_memory_limit('admin');

        // In-process re-entrance guard (cheap; complements the cross-process lock below).
        if (did_action('fluentcrm_process_scheduled_tasks_init')) {
            return false;
        }

        // Atomic cross-process mutex. Prevents concurrent AS + WP-Cron + AJAX
        // runners from all reaching Handler->handle() at the same time. The
        // downstream BaseHandler also has its own lock — this outer guard
        // avoids wasted PHP bootstraps for the loser of the race.
        if (!self::acquireLock('minute_scheduler', 90)) {
            return false;
        }

        try {
            // _fcrm_last_scheduler stays as the success-timestamp signal used
            // by the WP-Cron fallback in register() to detect a stalled Action
            // Scheduler. It is no longer the gate that prevents re-entry —
            // that role belongs to the atomic lock above.
            fluentCrmSetOptionCache('_fcrm_last_scheduler', time(), 50);
            do_action('fluentcrm_process_scheduled_tasks_init');

            (new Handler)->handle();
        } finally {
            self::releaseLock('minute_scheduler');
        }

        return true;
    }

    /**
     * Browser-ping fallback for the every-minute task.
     *
     * Triggered from the admin app's periodic ping (ReportingController::ping,
     * fired ~every 50s while any CRM page is open). It is a TRUE last-resort
     * fallback: it only takes over when Action Scheduler (and the WP-Cron
     * fallback) have stalled, detected by the same _fcrm_last_scheduler
     * freshness signal the WP-Cron fallback in register() uses. When AS is
     * healthy this returns after a single option read, so it is safe to call on
     * every ping and for every admin who has the dashboard open — it does NOT
     * run cron more often than once per minute on a healthy site.
     *
     * All concurrency safety lives in process(): its atomic cross-process lock
     * means that even with many tabs/users pinging at once, at most one runner
     * sends emails, and the _fcrm_last_scheduler stamp written there throttles
     * takeovers to roughly once per minute. This only advances the minute task
     * (the email-sending pipeline); the heavier hourly/five-minute tasks keep
     * their own WP-Cron/AS schedules.
     *
     * @return bool True if it took over and ran the minute task, false otherwise.
     */
    public static function maybeProcessFromBrowserPing()
    {
        // Action Scheduler owns this task; only step in when it has actually
        // stalled. Same 70s threshold as the WP-Cron fallback in register().
        $lastScheduler = fluentCrmGetOptionCache('_fcrm_last_scheduler');
        if ($lastScheduler && (time() - $lastScheduler) <= 70) {
            return false;
        }

        return self::process();
    }

    public static function processForSubscriber($subscriber)
    {
        if (!is_object($subscriber) || empty($subscriber->id)) {
            return false;
        }

        if (!defined('FLUENTCRM_DOING_BULK_IMPORT')) {
            // @todo: Implement this immediately
            (new Handler)->processSubscriberEmail($subscriber->id);
        }

        return true;
    }

    public static function processHourly()
    {
        // Atomic mutex. Closes the duplicate-event leak in markArchiveCampaigns():
        // without this, two concurrent hourly runners both pass the SELECT,
        // both UPDATE rows to 'archived' (idempotent), and both fire
        // fluent_crm/campaign_archived for the same campaign — causing
        // listeners (webhooks, metrics, notifications) to fire twice.
        if (!self::acquireLock('hourly_scheduler', 300)) {
            return;
        }

        try {
            self::markArchiveCampaigns();
            self::maybeCleanupCsvFiles();
            do_action('fluent_crm_process_automation');
        } finally {
            self::releaseLock('hourly_scheduler');
        }
    }


    public static function markArchiveCampaigns()
    {
        // get the scheduled or working  campaigns where scheduled_at is five minutes ago
        $campaigns = Campaign::whereIn('status', ['working', 'scheduled'])
            ->whereDoesntHave('emails', function ($query) {
                $query->whereIn('status', ['scheduling', 'pending', 'scheduled', 'processing', 'draft']);
                return $query;
            })
            ->withoutGlobalScope('type')
            ->whereIn('type', fluentCrmAutoProcessCampaignTypes())
            ->where('scheduled_at', '<', gmdate('Y-m-d H:i:s', current_time('timestamp') - 300))
            ->get();

        if (!$campaigns->isEmpty()) {

            Campaign::whereIn('id', array_unique($campaigns->pluck('id')->toArray()))
                ->withoutGlobalScope('type')
                ->update([
                    'status' => 'archived'
                ]);

            foreach ($campaigns as $campaign) {
                do_action('fluent_crm/campaign_archived', $campaign);
            }

            return true;
        }

        return false;
    }

    /**
     * @return void
     */
    public static function processWeekly()
    {
        (new Maintenance())->maybeProcessData();

        // Clear email_body from historical 'sent' rows to reclaim disk space.
        // Loop a LIMIT-bounded UPDATE so each statement's row-lock footprint
        // stays small (an unbounded UPDATE on a multi-million-row table holds
        // locks for minutes and stalls report/dashboard SELECTs) while still
        // draining the full backlog in this tick. Going direct to $wpdb skips
        // ORM overhead on what is effectively the same repeated statement.
        try {
            global $wpdb;
            $table         = $wpdb->prefix . 'fc_campaign_emails';
            $chunkSize     = 50000;
            $maxIterations = 100; // safety cap — up to ~5M rows per weekly tick

            for ($i = 0; $i < $maxIterations; $i++) {
                $affected = (int) $wpdb->query(
                    "UPDATE {$table} SET email_body = '' WHERE status = 'sent' AND email_body != '' LIMIT {$chunkSize}"
                );

                if ($affected < $chunkSize || fluentCrmIsMemoryExceeded()) {
                    break;
                }
            }
        } catch (\Exception $e) {
            Helper::debugLog('processWeekly', 'email_body cleanup deferred: ' . $e->getMessage(), 'extended');
        }
    }

    /**
     * Discover and process pending campaigns.
     *
     * Called by cron/Action Scheduler. Handles housekeeping (stale email reset),
     * finds campaigns ready to process, and kicks off processing. For continuous
     * processing, use processCampaignById() via the AJAX handler.
     *
     * @return bool
     */
    public static function processFiveMinutes()
    {
        // Cheap time-based pre-check — skips the lock-acquire round trip when
        // the function is called more frequently than the work needs to run.
        $lastRun = fluentCrmGetOptionCache('_fcrm_last_five_minutes_run', 30);
        if ($lastRun && (time() - $lastRun) < 60) {
            return false;
        }

        // Atomic mutex. The throttle above is non-atomic so two near-simultaneous
        // callers can both pass it; the lock guarantees that only one actually
        // proceeds into discovery + processing.
        if (!self::acquireLock('five_minute_scheduler', 180)) {
            return false;
        }

        try {
            fluentCrmSetOptionCache('_fcrm_last_five_minutes_run', time(), 60);

            self::resetStaleProcessingEmails(100, 'processFiveMinutes');

            $cutOutTime = gmdate('Y-m-d H:i:s', current_time('timestamp') + 360);

            $campaigns = Campaign::whereIn('status', ['pending-scheduled', 'processing'])
                ->withoutGlobalScope('type')
                ->whereIn('type', fluentCrmAutoProcessCampaignTypes())
                ->orderBy('scheduled_at', 'ASC')
                ->where('scheduled_at', '<=', $cutOutTime)
                ->limit(2)
                ->get();

            if ($campaigns->isEmpty()) {
                do_action('fluent_crm_process_automation');
                do_action('fluentcrm_scheduled_hourly_tasks');
                return false;
            }

            $firstCampaign = $campaigns->first();

            if ($firstCampaign->status == 'pending-scheduled') {
                $firstCampaign->status = 'processing';
                $firstCampaign->save();
            }

            $result = self::processCampaignById($firstCampaign->id);

            // If first campaign is done and there are more queued, chain the next one.
            // Skip if memory is low (aborted) to avoid cascading failures.
            if (!$result && count($campaigns) > 1 && !fluentCrmIsMemoryExceeded()) {
                // Verify first campaign actually finished (not just aborted)
                $firstCampaign = Campaign::withoutGlobalScope('type')->find($firstCampaign->id);
                if ($firstCampaign && $firstCampaign->status != 'processing') {
                    $nextCampaign = $campaigns->last();
                    if ($nextCampaign->status == 'pending-scheduled') {
                        $nextCampaign->status = 'processing';
                        $nextCampaign->save();
                    }
                    self::fireCampaignProcessingChain($nextCampaign->id);
                }
            }

            return $result;
        } finally {
            self::releaseLock('five_minute_scheduler');
        }
    }

    /**
     * Reset rows stuck in 'processing' back to 'pending' so they get re-claimed.
     *
     * An unbounded mass UPDATE on (status='processing' AND updated_at < cutoff)
     * locks a wide range and deadlocks against the row-level SELECT ... FOR
     * UPDATE claims that the mailer Handler / MultiThreadHandler hold while
     * sending. We instead drain in bounded chunks by primary key.
     *
     * We deliberately do NOT order the SELECT: ORDER BY id would push MySQL
     * onto PRIMARY (full id-walk looking for sparse matches on a multi-million
     * row table) instead of the (status, scheduled_at) index, which contains
     * only the small currently-'processing' slice. Each chunk drains rows out
     * of the predicate, so the next iteration naturally finds different rows
     * without an explicit order.
     *
     * Any deadlock that still slips through is harmless — remaining rows will
     * be picked up on the next caller's tick.
     *
     * @param int    $maxAgeSeconds Rows older than this (in 'processing') get reset.
     * @param string $callerContext Used in the deferred-log message.
     * @return int Number of rows reset back to pending.
     */
    public static function resetStaleProcessingEmails($maxAgeSeconds = 100, $callerContext = '')
    {
        try {
            // If a sender lock is still fresh, a batch is likely active or just
            // yielded. Resetting 'processing' rows during that window risks
            // requeueing work owned by the live sender and increases row-lock
            // contention with SELECT ... FOR UPDATE / sent-status updates.
            if (self::hasFreshEmailSenderLock($maxAgeSeconds)) {
                return 0;
            }

            $staleCutoff = gmdate('Y-m-d H:i:s', current_time('timestamp') - (int) $maxAgeSeconds);
            $chunkSize   = 200;
            $maxChunks   = 50; // up to 10k rows per call; subsequent calls drain the rest
            $recovered   = 0;

            for ($i = 0; $i < $maxChunks; $i++) {
                $staleIds = CampaignEmail::where('status', 'processing')
                    ->where('updated_at', '<', $staleCutoff)
                    ->limit($chunkSize)
                    ->pluck('id')
                    ->toArray();

                if (empty($staleIds)) {
                    break;
                }

                $updated = CampaignEmail::whereIn('id', $staleIds)
                    ->where('status', 'processing')
                    ->update([
                        'status' => 'pending'
                    ]);

                if ($updated === false) {
                    global $wpdb;
                    Helper::debugLog($callerContext ?: 'resetStaleProcessingEmails', 'Stale email reset deferred: ' . $wpdb->last_error, 'extended');
                    break;
                }

                $recovered += (int) $updated;

                if (count($staleIds) < $chunkSize || fluentCrmIsMemoryExceeded()) {
                    break;
                }
            }

            if ($recovered) {
                Helper::debugLog($callerContext ?: 'resetStaleProcessingEmails', 'Recovered ' . $recovered . ' stale processing emails older than ' . (int) $maxAgeSeconds . ' seconds', 'extended');
            }

            return $recovered;
        } catch (\Exception $e) {
            Helper::debugLog($callerContext ?: 'resetStaleProcessingEmails', 'Stale email reset deferred: ' . $e->getMessage(), 'extended');
            return 0;
        }
    }

    /**
     * Avoid stale-row recovery while a sender still appears active.
     *
     * Sender locks are refreshed by BaseHandler::refreshLock() between claimed
     * batches. We check all sender lock keys because regular, multi-threaded,
     * and CLI senders can all own rows in fc_campaign_emails.
     *
     * @param int $maxAgeSeconds
     * @return bool
     */
    private static function hasFreshEmailSenderLock($maxAgeSeconds)
    {
        // Use at least 60 seconds so a very small caller-provided stale window
        // does not make recovery race an otherwise healthy sender.
        $freshWindow = max(60, (int) $maxAgeSeconds);

        // Compare everything against one timestamp for consistent decisions
        // across all sender lock keys checked below.
        $now = time();

        foreach (['fluentcrm_is_sending_emails', 'fluentcrm_is_sending_multi_emails', 'fluentcrm_is_sending_cli_emails'] as $lockKey) {
            // Read the lock straight from its wp_options row. BaseHandler's
            // acquireLock()/refreshLock() store the timestamp there via
            // Helper::acquireDbLock()/refreshDbLock() on every environment, so we
            // must NOT use getInstantOption() here: on object-cache sites it reads
            // the fc_instant_options group, which the DB lock never writes to, and
            // would miss a live sender — letting recovery reset its rows.
            $lockedAt = Helper::getDbLockTimestamp($lockKey);

            // A non-empty timestamp inside the freshness window means a sender
            // appears active, so stale recovery should defer to the next tick.
            if ($lockedAt && ($now - $lockedAt) <= $freshWindow) {
                return true;
            }
        }

        // No fresh sender lock was found. Recovery may safely inspect stale rows.
        return false;
    }

    /**
     * Process a specific campaign by ID.
     *
     * Can be called directly from the AJAX handler for continuous chaining
     * without re-discovering campaigns or running housekeeping.
     *
     * @param int $campaignId
     * @return bool True if more processing is needed, false if done.
     */
    public static function processCampaignById($campaignId)
    {
        // Per-campaign scheduler lock. processCampaignById has two entry points
        // — the AJAX self-trigger fluentcrm-post-campaigns-emails-processing
        // (which bypasses processFiveMinutes' scheduler-level lock entirely)
        // and processFiveMinutes itself (which holds five_minute_scheduler).
        // Without this guard, fireCampaignProcessingChain could pile up
        // overlapping AJAX requests for the same campaign that all reach
        // CampaignProcessor and bail at its per-campaign lock — wasted PHP
        // bootstraps. Lock name is per-campaign so different campaigns still
        // process in parallel. TTL matches the set_time_limit(120) below.
        $lockName = 'campaign_chain_' . (int)$campaignId;
        if (!self::acquireLock($lockName, 120)) {
            return false;
        }

        try {
            if (function_exists('set_time_limit')) {
                @set_time_limit(120);
            }

            $campaign = Campaign::withoutGlobalScope('type')->find($campaignId);
            if (!$campaign) {
                return false;
            }

            $campaignProcessingChunk = (int)apply_filters('fluent_crm/five_minute_campaign_processing_chunk', 20, $campaign);
            if ($campaignProcessingChunk < 1) {
                $campaignProcessingChunk = 1;
            }

            $runTime = fluentCrmMaxRunTime() - 5;
            $campaign = (new CampaignProcessor($campaignId))->processEmails($campaignProcessingChunk, $runTime);

            if (fluentCrmIsMemoryExceeded()) {
                return false;
            }

            if ($campaign && $campaign->status == 'processing') {
                self::fireCampaignProcessingChain($campaignId);
                return true;
            }

            return false;
        } finally {
            self::releaseLock($lockName);
        }
    }

    /**
     * Fire a background AJAX request to continue processing a specific campaign.
     *
     * @param int $campaignId
     */
    private static function fireCampaignProcessingChain($campaignId)
    {
        $url = add_query_arg([
            'action'      => 'fluentcrm-post-campaigns-emails-processing',
            'campaign_id' => $campaignId,
            'time'        => time()
        ], admin_url('admin-ajax.php'));

        \FluentCrm\App\Services\Libs\Mailer\Handler::fireNonBlockingRequest($url, [
            'retry' => 1
        ]);
    }

    public static function maybeCleanupCsvFiles()
    {
        $dir = FileSystem::getDir();

        // loop through files in directory
        foreach (glob($dir . '/fluentcrm-*.csv') as $filename) {
            // check if file was created before last 30 minutes
            if (time() - filectime($filename) >= 1800) {
                wp_delete_file($filename); // delete file
            }
        }
    }

    public static function processMultiThreadEmails()
    {
        (new MultiThreadHandler())->handle();
        return true;
    }

    /**
     * Atomically claim a scheduler-level lock so two runners can't enter the
     * same critical section concurrently (e.g. Action Scheduler + WP-Cron
     * minute ticks landing in the same second).
     *
     * Backed by a conditional UPDATE on wp_options keyed off a timestamp
     * (Helper::acquireDbLock). The UPDATE succeeds only if the row is unclaimed
     * or its stored timestamp is older than $ttl, so a crashed runner's lock
     * self-recovers after the TTL. This is used on every environment — we no
     * longer take a wp_cache_add() fast path, because that primitive is not
     * atomic under all object-cache drop-ins (e.g. LiteSpeed), which let
     * concurrent runners all acquire the same lock. See Helper::acquireDbLock().
     *
     * @param string $name Lock identifier appended to the option key.
     * @param int    $ttl  Seconds before a held lock is considered abandoned.
     * @return bool True if the lock was acquired by this process.
     */
    private static function acquireLock($name, $ttl)
    {
        return Helper::acquireDbLock('_fluentcrm_lock_' . $name, $ttl);
    }

    /**
     * Release a scheduler-level lock previously acquired by acquireLock().
     * Safe to call even if the lock was not held by this process — the worst
     * case is freeing the slot a tick early.
     */
    private static function releaseLock($name)
    {
        Helper::releaseDbLock('_fluentcrm_lock_' . $name);
    }
}
