<?php
/**
 * DBA NewsBot Scheduler — Isolated WP-Cron for PE/Capital Markets news
 *
 * ═══════════════════════════════════════════════════════════════════════
 *  ZERO CONFLICT POLICY — Complete isolation from core Damalion scheduler
 * ═══════════════════════════════════════════════════════════════════════
 *
 *  CORE DAMALION (EU content)         │  PE NEWSBOT (US content)
 *  ─────────────────────────────────  │  ─────────────────────────────────
 *  Cron:   dba_daily_generation       │  Cron:   dba_pe_daily_generation
 *  Time:   06:00 UTC (07:00 CET)      │  Time:   15:00 UTC (10:00 AM NY)
 *  TZ:     Europe/Luxembourg          │  TZ:     America/New_York
 *  Days:   Mon/Wed/Sat                │  Days:   Mon/Wed/Fri
 *  Trans:  dba_generation_progress    │  Trans:  dba_pe_generation_progress
 *  Meta:   _dba_auto_created          │  Meta:   _dba_pe_auto_created
 *  Option: dba_last_run               │  Option: dba_pe_last_run
 *  Option: dba_schedule_settings      │  Option: dba_pe_settings
 *  History: (none)                    │  History: damalion_newsbot_history
 *
 *  Separation guarantees:
 *    - No shared cron hooks
 *    - No shared transient keys
 *    - No shared post meta keys
 *    - No shared option keys
 *    - 9-hour gap between core (06:00 UTC) and PE (15:00 UTC)
 *    - Safety interlock: PE waits if core is still running
 *    - Separate PHP classes, separate JS files, separate admin pages
 *
 * Timing rationale (10:00 AM New York):
 *   - 15:00 UTC = 10:00 AM EST / 10:00 AM EDT (auto via America/New_York)
 *   - Core batch finishes by ~06:10 UTC max → 9-hour safety margin
 *   - US market open hours — optimal for investor-facing content
 *   - US news from prior business day is fully available
 *   - Avoids typical backup windows (02:00–04:00 UTC)
 *   - Avoids Yoast indexing spikes after core posts
 *
 * ═══════════════════════════════════════════════════════════════════════
 *  AUTO-RETRY SYSTEM — Never lose a day
 * ═══════════════════════════════════════════════════════════════════════
 *
 *  If ANY failure occurs (exception, lock blocked, 0 posts created),
 *  the scheduler automatically reschedules itself:
 *
 *    Attempt 1: 15:00 UTC (normal cron)
 *    Attempt 2: 15:30 UTC (auto-retry +30 min)
 *    Attempt 3: 16:00 UTC (auto-retry +30 min)
 *    Attempt 4: 16:30 UTC (FINAL auto-retry +30 min)
 *
 *  After 4 total attempts (1 original + 3 retries), it stops to avoid
 *  infinite loops. Retry count resets on success or at midnight UTC.
 *
 *  If a retry finds that enough posts already exist (partial success
 *  from earlier attempt), it skips generation and marks success.
 *
 * @since 2.6.0
 * @since 2.6.1 — Changed to 15:00 UTC (10:00 AM New York) + post_date enforcement
 * @since 2.6.2 — Auto-retry system: 3 retries on any failure, 30 min apart
 */

if ( ! defined( 'ABSPATH' ) ) exit;

class DBA_NewsBot_Scheduler {

    private static $instance = null;

    // ═══════════════════════════════════════════════════════════════
    //  ZERO CONFLICT CONSTANTS — Must NEVER match core scheduler
    // ═══════════════════════════════════════════════════════════════
    const CRON_HOOK          = 'dba_pe_daily_generation';    // Core uses: dba_daily_generation
    const PROGRESS_TRANSIENT = 'dba_pe_generation_progress'; // Core uses: dba_generation_progress
    const LAST_RUN_OPTION    = 'dba_pe_last_run';            // Core uses: dba_last_run
    const SETTINGS_OPTION    = 'dba_pe_settings';            // Core uses: dba_schedule_settings

    // ═══════════════════════════════════════════════════════════════
    //  AUTO-RETRY CONSTANTS
    // ═══════════════════════════════════════════════════════════════
    const RETRY_TRANSIENT    = 'dba_pe_retry_count';  // Tracks attempts today
    const RETRY_MAX          = 3;                      // Max retries (4 total attempts)
    const RETRY_DELAY_SEC    = 1800;                   // 30 minutes between retries

    // Default PE publishing days (Mon/Wed/Fri per instructions)
    const DEFAULT_POSTING_DAYS = array( 'monday', 'wednesday', 'friday' );

    // Publish time: 10:00 AM New York time (applies to ALL USA-related posts)
    const PUBLISH_HOUR_NY = 10;
    const PUBLISH_MIN_NY  = 0;
    const PUBLISH_TZ      = 'America/New_York';

    // UTC hour for the cron — 15:00 UTC = 10:00 AM EST (winter) / 11:00 AM (summer trigger, posts scheduled at 10 AM)
    const CRON_HOUR_UTC = 15;

    public static function get_instance() {
        if ( null === self::$instance ) self::$instance = new self();
        return self::$instance;
    }

    private function __construct() {
        add_action( self::CRON_HOOK, array( $this, 'run_pe_generation' ) );

        // ═══════════════════════════════════════════════════════════
        //  Self-healing cron — if the event vanishes, re-register.
        // ═══════════════════════════════════════════════════════════
        $pe_settings = get_option( self::SETTINGS_OPTION, array() );
        if ( ! empty( $pe_settings['enabled'] ) && ( ! wp_next_scheduled( self::CRON_HOOK ) || ! wp_get_schedule( self::CRON_HOOK ) ) ) {
            self::activate();
            error_log( 'DBA PE Scheduler: Self-healed — recurring cron event was missing, re-registered.' );
        }

        // ═══════════════════════════════════════════════════════════
        //  MISSED RUN RECOVERY — Never lose a day, even if the cron
        //  time already passed. Fires on next page load (admin or front).
        //
        //  Conditions (ALL must be true):
        //    1. PE is enabled
        //    2. Today is a posting day
        //    3. Cron time (15:00 UTC) has already passed
        //    4. Zero PE posts exist today
        //    5. No recovery already scheduled in the next 10 min
        //    6. Retries not exhausted (avoid infinite loop)
        // ═══════════════════════════════════════════════════════════
        if ( ! empty( $pe_settings['enabled'] ) ) {
            $this->maybe_recover_missed_run( $pe_settings );
        }
    }

    /**
     * Check if today's run was missed and schedule immediate recovery.
     */
    private function maybe_recover_missed_run( array $pe_settings ) {
        // Only check once per request (avoid double-fire on admin pages)
        if ( defined( 'DBA_PE_RECOVERY_CHECKED' ) ) return;
        define( 'DBA_PE_RECOVERY_CHECKED', true );

        // Is today a posting day?
        $posting_days = $pe_settings['posting_days'] ?? self::DEFAULT_POSTING_DAYS;
        try {
            $now   = new DateTime( 'now', new DateTimeZone( self::PUBLISH_TZ ) );
            $today = strtolower( $now->format( 'l' ) );
        } catch ( Exception $e ) {
            $today = strtolower( date( 'l' ) );
        }

        if ( ! in_array( $today, $posting_days, true ) ) return;

        // Has the cron time already passed today?
        $today_cron_utc = strtotime( 'today ' . self::CRON_HOUR_UTC . ':00:00 UTC' );
        if ( time() <= $today_cron_utc ) return; // Not yet — normal cron will handle it

        // Any PE posts created today?
        $existing = self::count_todays_pe_posts();
        if ( $existing > 0 ) return; // Posts exist — no recovery needed

        // Retries exhausted? Don't loop forever.
        if ( self::get_retry_count() > self::RETRY_MAX ) return;

        // Is a recovery already scheduled in the next 15 min?
        $next = wp_next_scheduled( self::CRON_HOOK );
        if ( $next && ( $next - time() ) < 900 ) return; // Already queued soon

        // ═══ FIRE RECOVERY — schedule for 60 seconds from now ═══
        $recovery_time = time() + 60;
        wp_schedule_single_event( $recovery_time, self::CRON_HOOK );

        error_log( sprintf(
            'DBA PE Scheduler: MISSED RUN RECOVERY — Today is %s, 0 PE posts, cron time passed. Firing in 60 sec at %s UTC.',
            ucfirst( $today ), gmdate( 'H:i:s', $recovery_time )
        ) );
    }

    /* =================================================================
     *  ACTIVATION / DEACTIVATION
     * ================================================================= */

    public static function activate() {
        if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
            $today_15utc = strtotime( 'today ' . self::CRON_HOUR_UTC . ':00:00 UTC' );
            $next_run    = ( time() < $today_15utc ) ? $today_15utc
                         : strtotime( 'tomorrow ' . self::CRON_HOUR_UTC . ':00:00 UTC' );

            wp_schedule_event( $next_run, 'daily', self::CRON_HOOK );
            error_log( 'DBA PE Scheduler: Cron activated — next run at '
                . gmdate( 'Y-m-d H:i:s', $next_run ) . ' UTC (10:00 AM New York)' );
        }
    }

    public static function deactivate() {
        $timestamp = wp_next_scheduled( self::CRON_HOOK );
        if ( $timestamp ) {
            wp_unschedule_event( $timestamp, self::CRON_HOOK );
            error_log( 'DBA PE Scheduler: Cron deactivated' );
        }
        delete_transient( self::RETRY_TRANSIENT );
    }

    /* =================================================================
     *  AUTO-RETRY ENGINE
     * ================================================================= */

    /**
     * Get today's retry attempt number (0 = first run, 1+ = retries).
     */
    private static function get_retry_count() {
        $count = get_transient( self::RETRY_TRANSIENT );
        return ( $count === false ) ? 0 : intval( $count );
    }

    /**
     * Increment retry counter. Expires at midnight UTC — fresh start tomorrow.
     */
    private static function increment_retry() {
        $count = self::get_retry_count() + 1;
        $seconds_until_midnight = strtotime( 'tomorrow 00:00:00 UTC' ) - time();
        set_transient( self::RETRY_TRANSIENT, $count, max( 60, $seconds_until_midnight ) );
        return $count;
    }

    /**
     * Reset retry counter on success.
     */
    private static function reset_retry() {
        delete_transient( self::RETRY_TRANSIENT );
    }

    /**
     * Schedule a retry. Returns false if max retries exceeded.
     */
    private function schedule_retry( string $reason ) {
        $attempt = self::increment_retry();

        if ( $attempt > self::RETRY_MAX ) {
            error_log( sprintf(
                'DBA PE Scheduler: ALL %d ATTEMPTS EXHAUSTED. Last failure: %s. Giving up until tomorrow.',
                self::RETRY_MAX + 1, $reason
            ) );
            update_option( self::LAST_RUN_OPTION, array(
                'timestamp'         => time(),
                'created'           => 0,
                'failed'            => 0,
                'error'             => 'All ' . ( self::RETRY_MAX + 1 ) . ' attempts failed. Last: ' . $reason,
                'retries_exhausted' => true,
            ), false );
            return false;
        }

        $retry_time = time() + self::RETRY_DELAY_SEC;
        wp_schedule_single_event( $retry_time, self::CRON_HOOK );

        error_log( sprintf(
            'DBA PE Scheduler: RETRY %d/%d scheduled at %s UTC (+%d min). Reason: %s',
            $attempt, self::RETRY_MAX,
            gmdate( 'H:i:s', $retry_time ),
            self::RETRY_DELAY_SEC / 60,
            $reason
        ) );

        return true;
    }

    /**
     * Count PE posts already created today (avoid duplicate batches on retry).
     */
    public static function count_todays_pe_posts() {
        $today = current_time( 'Y-m-d' );
        $posts = get_posts( array(
            'post_type'      => 'post',
            'post_status'    => array( 'publish', 'draft', 'pending', 'future' ),
            'meta_key'       => '_dba_pe_auto_created',
            'meta_value'     => '1',
            'date_query'     => array( array(
                'after'     => $today . ' 00:00:00',
                'before'    => $today . ' 23:59:59',
                'inclusive'  => true,
            ) ),
            'posts_per_page' => 50,
            'fields'         => 'ids',
        ) );
        return count( $posts );
    }

    /* =================================================================
     *  DAILY GENERATION — Entry point from WP-Cron
     * ================================================================= */

    /**
     * Main cron callback.
     *
     * AUTO-RETRY on any failure:
     *   - Conflict Center lock blocked  → retry +30 min
     *   - Generator throws exception    → retry +30 min
     *   - 0 posts created (all failed)  → retry +30 min
     *   - Max 3 retries (4 total). Counter resets on success or midnight.
     */
    public function run_pe_generation() {

        $retry_num = self::get_retry_count();
        $is_retry  = ( $retry_num > 0 );

        error_log( sprintf(
            'DBA PE Scheduler: === CRON CALLBACK at %s UTC === %s',
            gmdate( 'Y-m-d H:i:s' ),
            $is_retry ? "(RETRY #{$retry_num} of " . self::RETRY_MAX . ")" : '(attempt #1)'
        ) );

        // ─── GATE 1: Master switch ───
        $pe_settings = get_option( self::SETTINGS_OPTION, array() );
        if ( empty( $pe_settings['enabled'] ) ) {
            error_log( 'DBA PE Scheduler: DISABLED — no retry.' );
            return;
        }

        // ─── GATE 2: Posting day check (skip on retries — already validated) ───
        $posting_days = $pe_settings['posting_days'] ?? self::DEFAULT_POSTING_DAYS;
        try {
            $now   = new DateTime( 'now', new DateTimeZone( self::PUBLISH_TZ ) );
            $today = strtolower( $now->format( 'l' ) );
        } catch ( Exception $e ) {
            $today = strtolower( date( 'l' ) );
        }

        if ( ! $is_retry && ! in_array( $today, $posting_days, true ) ) {
            error_log( "DBA PE Scheduler: Today ({$today}) is not a posting day. Skipping." );
            return;
        }

        // ─── GATE 3: Already have enough posts? (applies to ALL runs, not just retries) ───
        $existing = self::count_todays_pe_posts();
        $target   = max( 1, intval( $pe_settings['posts_per_day'] ?? 6 ) );

        if ( $existing >= $target ) {
            error_log( "DBA PE Scheduler: Already have {$existing}/{$target} posts today. SKIPPING." );
            if ( $is_retry ) {
                self::reset_retry();
            }
            return;
        }

        if ( $is_retry ) {
            error_log( "DBA PE Scheduler: Retry #{$retry_num} — only {$existing}/{$target} posts. Running batch." );
        }

        error_log( "DBA PE Scheduler: Starting PE generation for {$today}." );

        // ─── GATE 4: Conflict Center lock ───
        // Pass empty cron_hook so only our own schedule_retry reschedules (prevents double-schedule)
        if ( class_exists( 'DBA_Conflict_Center' ) && ! DBA_Conflict_Center::acquire( 'pe_newsbot', '' ) ) {
            $this->schedule_retry( 'Conflict Center lock held by another module' );
            return;
        }

        // Execution limits
        @set_time_limit( 300 );
        @ini_set( 'memory_limit', '256M' );

        // ─── RUN GENERATOR ───
        $batch_success = false;
        $results       = null;

        try {
            $generator = DBA_NewsBot_Generator::get_instance();
            $results   = $generator->run_daily_batch();

            $created = $results['created'] ?? 0;
            $failed  = $results['failed']  ?? 0;

            update_option( self::LAST_RUN_OPTION, array(
                'timestamp' => time(),
                'created'   => $created,
                'failed'    => $failed,
                'roundup'   => $results['roundup_id'] ?? 0,
                'retry_num' => $retry_num,
            ), false );

            error_log( sprintf(
                'DBA PE Scheduler: Batch done — Created: %d, Failed: %d, Roundup: #%d %s',
                $created, $failed, $results['roundup_id'] ?? 0,
                $is_retry ? "(retry #{$retry_num})" : ''
            ) );

            // At least 1 post = success
            $batch_success = ( $created > 0 );

        } catch ( \Throwable $e ) {
            error_log( 'DBA PE Scheduler FATAL: ' . $e->getMessage()
                . ' in ' . $e->getFile() . ':' . $e->getLine() );

            update_option( self::LAST_RUN_OPTION, array(
                'timestamp' => time(),
                'created'   => 0,
                'failed'    => 0,
                'error'     => $e->getMessage(),
                'retry_num' => $retry_num,
            ), false );

        } finally {
            // ALWAYS release lock — success or failure
            if ( class_exists( 'DBA_Conflict_Center' ) ) {
                DBA_Conflict_Center::release( 'pe_newsbot' );
            }
        }

        // ─── AUTO-RETRY DECISION ───
        if ( $batch_success ) {
            self::reset_retry();
            error_log( 'DBA PE Scheduler: SUCCESS — retry counter reset.' );
        } else {
            $reason = 'Unknown failure';
            if ( $results === null ) {
                $reason = 'Generator threw fatal exception';
            } elseif ( ( $results['created'] ?? 0 ) === 0 ) {
                $reason = ( $results['failed'] ?? 0 ) . ' posts failed, 0 created';
            }
            $this->schedule_retry( $reason );
        }
    }

    /* =================================================================
     *  PUBLISH TIME HELPER — 10:00 AM New York for ALL USA posts
     * ================================================================= */

    /**
     * Get the publish datetime for today at 10:00 AM New York time.
     *
     * Returns both local (NY) and GMT strings for wp_insert_post().
     * Used by DBA_NewsBot_Generator when creating individual + roundup posts.
     *
     * @param int $post_index  Post index in batch (0, 1, 2...) or legacy minute offset
     * @return array { 'post_date' => string, 'post_date_gmt' => string }
     */
    public static function get_publish_datetime( int $post_index = 0 ) {
        $stagger = function_exists( 'dba_get_post_stagger_minutes' )
            ? dba_get_post_stagger_minutes() : 20;

        if ( $post_index > 0 && $stagger >= 5 && $post_index <= 30 && ( $post_index % 2 ) === 0 ) {
            $offset_minutes = $post_index;
        } else {
            $offset_minutes = $post_index * $stagger;
        }

        // CAP offset at 30 minutes — roundup (index 15) was getting 15*20=300 min = 5 hours!
        $offset_minutes = min( $offset_minutes, 30 );

        try {
            $ny_tz   = new DateTimeZone( self::PUBLISH_TZ );
            $utc     = new DateTimeZone( 'UTC' );
            $now_ny  = new DateTime( 'now', $ny_tz );

            $publish = new DateTime( 'now', $ny_tz );
            $publish->setTime( self::PUBLISH_HOUR_NY, self::PUBLISH_MIN_NY, 0 );

            if ( $offset_minutes > 0 ) {
                $publish->modify( "+{$offset_minutes} minutes" );
            }

            // LATE RUN FIX: If scheduled time is already past, publish NOW + small stagger.
            // This prevents "Missed schedule" when batch runs late (manual trigger, retry, etc.)
            if ( $publish < $now_ny ) {
                $publish = clone $now_ny;
                if ( $offset_minutes > 0 ) {
                    $publish->modify( "+{$offset_minutes} minutes" );
                }
                error_log( 'DBA PE Scheduler: Late run detected — publishing at ' . $publish->format( 'H:i' ) . ' NY instead of 10:00 AM' );
            }

            $local = $publish->format( 'Y-m-d H:i:s' );
            $publish->setTimezone( $utc );
            $gmt = $publish->format( 'Y-m-d H:i:s' );

            return array(
                'post_date'     => $local,
                'post_date_gmt' => $gmt,
            );
        } catch ( Exception $e ) {
            error_log( 'DBA PE Scheduler: Failed to compute publish datetime: ' . $e->getMessage() );
            return array(
                'post_date'     => current_time( 'mysql' ),
                'post_date_gmt' => current_time( 'mysql', true ),
            );
        }
    }

    /* =================================================================
     *  INFO HELPERS — For admin display
     * ================================================================= */

    public static function get_next_run_info() {
        $next = wp_next_scheduled( self::CRON_HOOK );
        if ( ! $next ) return 'Not scheduled';

        try {
            $dt = new DateTime( '@' . $next );
            $dt->setTimezone( new DateTimeZone( self::PUBLISH_TZ ) );
            return $dt->format( 'l, F j, Y \a\t H:i' ) . ' (' . self::PUBLISH_TZ . ')';
        } catch ( Exception $e ) {
            return gmdate( 'Y-m-d H:i:s', $next ) . ' UTC';
        }
    }

    public static function get_publish_time_info() {
        return sprintf( '%02d:%02d AM New York (America/New_York)', self::PUBLISH_HOUR_NY, self::PUBLISH_MIN_NY );
    }

    public static function get_posting_days_string() {
        $pe_settings = get_option( self::SETTINGS_OPTION, array() );
        $days = $pe_settings['posting_days'] ?? self::DEFAULT_POSTING_DAYS;
        return ! empty( $days ) ? implode( ', ', array_map( 'ucfirst', $days ) ) : 'None configured';
    }

    public static function get_last_run_info() {
        return get_option( self::LAST_RUN_OPTION, array() );
    }

    /**
     * Get retry status for admin display.
     *
     * @return array { 'count' => int, 'max' => int, 'exhausted' => bool }
     */
    public static function get_retry_status() {
        $count = self::get_retry_count();
        return array(
            'count'     => $count,
            'max'       => self::RETRY_MAX,
            'exhausted' => ( $count > self::RETRY_MAX ),
        );
    }

    public static function is_core_batch_running() {
        $core_progress = get_transient( 'dba_generation_progress' );
        if ( ! $core_progress ) return false;

        $status  = $core_progress['current_status'] ?? '';
        $updated = $core_progress['updated'] ?? 0;

        if ( $status === 'running' && ( time() - $updated ) < 600 ) {
            return true;
        }
        return false;
    }
}
