<?php

/**
 * Recurring Billing Hooks
 *
 * Registers WordPress hooks for the recurring billing system.
 * Uses the centralized ActionScheduler for all scheduled jobs.
 * 
 * Key scheduled jobs:
 * 1. HOOK_GENERATE_RENEWAL_INVOICE - Creates one renewal invoice at the configured lead time
 * 2. HOOK_GENERATE_UPCOMING_RENEWALS - Hourly recovery batch for missing renewal invoices
 * 3. HOOK_CHECK_OVERDUE_RENEWALS - Moves unpaid subscriptions through grace-period statuses
 * 4. HOOK_PROCESS_TRIAL_CONVERSIONS - Converts trial subscriptions to active
 *
 * @package ArraySubs\Features\RecurringBilling\Services
 * @since 1.0.0
 */

namespace ArraySubs\Features\RecurringBilling\Services;

defined('ABSPATH') || exit;

use ArraySubs\Features\PlanSwitching\Services\ProrationCalculator;
use ArraySubs\Features\Refunds\Services\RefundProcessor;
use ArraySubs\Supports\ActionScheduler;

/**
 * Recurring billing hooks integration
 */
class Hooks
{
    /**
     * Enable auto-loading via AbstractLoader
     *
     * @var bool
     */
    public static $loadable = true;

    /**
     * Hours before due date to generate renewal invoice
     */
    const INVOICE_HOURS_BEFORE_DUE = 6;

    /**
     * Days after due date that subscription remains ACTIVE with unpaid invoice.
     * After this grace period, subscription moves to ON-HOLD.
     * Set to 0 to immediately put on-hold when invoice is created.
     */
    const GRACE_DAYS_BEFORE_ON_HOLD = 3;

    /**
     * Days subscription stays ON-HOLD before being CANCELLED.
     * This grace period starts after the subscription enters on-hold status.
     */
    const GRACE_DAYS_BEFORE_CANCEL = 7;

    /**
     * Initialize hooks
     */
    public function __construct()
    {
        // Register the renewal processing action (scheduled by Action Scheduler)
        add_action(ActionScheduler::HOOK_PROCESS_RENEWAL, [$this, 'processRenewal'], 10, 1);

        // Register individual subscription expiration action
        add_action(ActionScheduler::HOOK_EXPIRE_SUBSCRIPTION, [$this, 'expireSubscription'], 10, 1);

        // Register trial conversion action
        add_action(ActionScheduler::HOOK_PROCESS_TRIAL_CONVERSIONS, [$this, 'processTrialConversions']);

        // Register upcoming renewals invoice generation recovery batch.
        add_action(ActionScheduler::HOOK_GENERATE_UPCOMING_RENEWALS, [$this, 'generateUpcomingRenewals']);

        // Register precise per-subscription invoice generation action
        add_action(ActionScheduler::HOOK_GENERATE_RENEWAL_INVOICE, [$this, 'generateRenewalInvoice'], 10, 1);

        // Register overdue renewals check action.
        add_action(ActionScheduler::HOOK_CHECK_OVERDUE_RENEWALS, [$this, 'checkOverdueRenewals']);

        // Clean up completed renewal actions
        add_action('action_scheduler_completed_action', [$this, 'cleanupCompletedRenewal'], 10, 1);

        // Schedule recurring jobs after init
        add_action('init', [$this, 'scheduleRecurringJobs'], 20);

        // Pending-cancellation lifecycle: stop renewal scheduling on the
        // way in, restore it on undo. Listening on the canonical actions
        // covers every entry point that schedules a cancel — the helper,
        // admin REST controller, customer portal, CancellationFlow REST,
        // and Refunds — without each having to know about the renewal
        // queue.
        add_action('arraysubs_data_waiting_cancellation', [$this, 'unscheduleRenewalsOnPendingCancellation'], 10, 1);
        add_action('arraysubs_data_cancellation_undone', [$this, 'rescheduleRenewalsOnUndo'], 10, 1);

        // Note: Renewal order payment handling is now centralized in
        // Subscriptions\Services\OrderIntegration via arraysubs_order_paid hook
    }

    /**
     * Schedule all recurring jobs using the centralized ActionScheduler
     * Called on init to ensure Action Scheduler is initialized
     */
    public function scheduleRecurringJobs(): void
    {
        if (!ActionScheduler::isAvailable()) {
            return;
        }

        // Schedule trial conversions daily at 2am
        ActionScheduler::scheduleRecurring(
            ActionScheduler::HOOK_PROCESS_TRIAL_CONVERSIONS,
            strtotime('tomorrow 2am'),
            DAY_IN_SECONDS,
            [],
            ActionScheduler::GROUP_BILLING
        );

        // Schedule upcoming renewals check every hour
        ActionScheduler::scheduleRecurring(
            ActionScheduler::HOOK_GENERATE_UPCOMING_RENEWALS,
            time() + MINUTE_IN_SECONDS,
            HOUR_IN_SECONDS,
            [],
            ActionScheduler::GROUP_BILLING
        );

        // Schedule overdue renewals check every hour
        ActionScheduler::scheduleRecurring(
            ActionScheduler::HOOK_CHECK_OVERDUE_RENEWALS,
            time() + (2 * MINUTE_IN_SECONDS),
            HOUR_IN_SECONDS,
            [],
            ActionScheduler::GROUP_BILLING
        );
    }

    /**
     * Stop renewal scheduling when a subscription enters the
     * pending-cancellation window.
     *
     * The arraysubs_should_generate_renewal_invoice filter blocks the
     * hourly bulk batch from creating new invoices, but per-subscription
     * jobs queued by RenewalScheduler before the cancel was scheduled
     * (HOOK_GENERATE_RENEWAL_INVOICE + HOOK_PROCESS_RENEWAL) are still
     * sitting in Action Scheduler. This listener removes them so an
     * already-queued job cannot fire and charge the customer in the gap
     * between now and the scheduled cancel date.
     *
     * Listening on the canonical action means every cancel-scheduling
     * path benefits — the cancellation-helpers function, admin REST,
     * customer portal, CancellationFlow REST, and the Refunds-driven
     * cancel.
     *
     * Hook signature: arraysubs_data_waiting_cancellation ($subscription_id, ...)
     *
     * @param int $subscription_id Subscription ID.
     * @return void
     */
    public function unscheduleRenewalsOnPendingCancellation(int $subscription_id): void
    {
        if ($subscription_id <= 0) {
            return;
        }

        RenewalScheduler::unschedule($subscription_id);
    }

    /**
     * Restore renewal scheduling when a scheduled cancellation is undone.
     *
     * Without this, the subscription would silently stop renewing because
     * the per-subscription invoice/charge jobs were removed when the
     * cancel was scheduled. The hourly bulk cron is a safety net but can
     * miss a renewal due in the next ~hour.
     *
     * If the next payment date is in the past or unset, do nothing — the
     * processWaitingCancellation cron will not pick it up (the
     * cancellation meta is gone), and the bulk renewal cron will create
     * the invoice on its next tick if the subscription is due.
     *
     * Hook signature: arraysubs_data_cancellation_undone ($subscription_id)
     *
     * @param int $subscription_id Subscription ID.
     * @return void
     */
    public function rescheduleRenewalsOnUndo(int $subscription_id): void
    {
        if ($subscription_id <= 0) {
            return;
        }

        $next_payment = (string) get_post_meta($subscription_id, '_next_payment_date', true);
        if ($next_payment === '') {
            return;
        }

        $next_payment_ts = strtotime($next_payment);
        if ($next_payment_ts === false || $next_payment_ts <= time()) {
            return;
        }

        // RenewalScheduler::schedule() queues both the invoice
        // generation and the renewal processing jobs. Action Scheduler
        // dedupes by hook+args+timestamp, so calling this when nothing
        // is queued is safe.
        RenewalScheduler::schedule($subscription_id, $next_payment_ts);
    }

    /**
     * Generate renewal invoices for subscriptions that are due or will be due within 6 hours
     * This creates pending WooCommerce orders that customers can pay
     *
     * Logic:
     * - Finds subscriptions where next_payment_date <= 6 hours from now (includes past due)
     * - Excludes subscriptions that already have a pending renewal order
     * - Creates a new pending order for each qualifying subscription
     *
     * Uses lock to prevent concurrent batch execution.
     *
     * @return void
     */
    public function generateUpcomingRenewals(): void
    {
        // Acquire lock to prevent concurrent batch execution
        if (!ActionScheduler::acquireLock(ActionScheduler::HOOK_GENERATE_UPCOMING_RENEWALS, [])) {
            return;
        }

        try {
            $this->doGenerateUpcomingRenewals();
        } finally {
            ActionScheduler::releaseLock(ActionScheduler::HOOK_GENERATE_UPCOMING_RENEWALS, []);
        }
    }

    /**
     * Internal implementation of generateUpcomingRenewals
     *
     * @return void
     */
    private function doGenerateUpcomingRenewals(): void
    {
        // Get the configured invoice before due hours from settings
        $invoice_hours_before_due = arraysubs_get_invoice_hours_before_due();

        // Calculate the cutoff: any subscription due within the configured hours (or already past due)
        $future_cutoff = gmdate('Y-m-d H:i:s', strtotime('+' . $invoice_hours_before_due . ' hours'));

        // Find active subscriptions with next_payment_date up to X hours from now
        // This includes past-due subscriptions that haven't had invoices generated yet
        $args = [
            'post_type'      => 'arraysubs_data',
            'post_status'    => ['arraysubs-active', 'arraysubs-trial'],
            'posts_per_page' => 50, // Process in batches
            'meta_query'     => [
                [
                    'key'     => '_next_payment_date',
                    'value'   => $future_cutoff,
                    'compare' => '<=',
                    'type'    => 'DATETIME',
                ],
                [
                    'key'     => '_billing_period',
                    'value'   => 'lifetime',
                    'compare' => '!=',
                ],
            ],
        ];

        $subscriptions = get_posts($args);

        foreach ($subscriptions as $subscription_post) {
            $subscription_id = $subscription_post->ID;
            $next_payment = get_post_meta($subscription_id, '_next_payment_date', true);

            // Check if a pending renewal order already exists
            if ($this->hasPendingRenewalOrder($subscription_id)) {
                continue;
            }

            // SAFEGUARD: Check if next_payment_date is too far in the future
            // If invoice_hours_before_due >= subscription cycle, only generate invoice
            // when we're at least within 6 hours of the due date
            if (!$this->shouldGenerateInvoiceNow($subscription_id, $next_payment)) {
                continue;
            }

            /**
             * Filter whether to generate renewal invoice for a subscription.
             * 
             * Used by SkipRenewal feature to skip invoice generation for subscriptions
             * that are in skip mode or paused.
             *
             * @since 1.0.0
             * @param bool    $should_generate True to generate invoice, false to skip.
             * @param WP_Post $subscription    Subscription post object.
             */
            $should_generate = apply_filters('arraysubs_should_generate_renewal_invoice', true, $subscription_post);

            if (!$should_generate) {
                continue;
            }

            // Create renewal order (invoice)
            $processor = new RenewalProcessor();
            $result = $processor->createRenewalInvoice($subscription_id);
        }
    }

    /**
     * Check for overdue subscriptions and cancel them if unpaid for too long
     * Subscriptions with unpaid invoices older than 2 days are cancelled
     *
     * Uses lock to prevent concurrent batch execution.
     *
     * @return void
     */
    public function checkOverdueRenewals(): void
    {
        // Acquire lock to prevent concurrent batch execution
        if (!ActionScheduler::acquireLock(ActionScheduler::HOOK_CHECK_OVERDUE_RENEWALS, [])) {
            return;
        }

        try {
            $this->doCheckOverdueRenewals();
        } finally {
            ActionScheduler::releaseLock(ActionScheduler::HOOK_CHECK_OVERDUE_RENEWALS, []);
        }
    }

    /**
     * Internal implementation of checkOverdueRenewals
     * 
     * Two-phase grace period handling:
     * 1. ACTIVE subscriptions with unpaid invoices past due by GRACE_DAYS_BEFORE_ON_HOLD -> ON-HOLD
     * 2. ON-HOLD subscriptions past due by (GRACE_DAYS_BEFORE_ON_HOLD + GRACE_DAYS_BEFORE_CANCEL) -> CANCELLED
     * 3. Subscriptions with _waiting_cancellation past scheduled cancellation date -> CANCELLED
     *
     * @return void
     */
    private function doCheckOverdueRenewals(): void
    {
        // Phase 1: Move ACTIVE subscriptions to ON-HOLD after first grace period
        $this->processActiveToOnHold();

        // Phase 2: Move ON-HOLD subscriptions to CANCELLED after second grace period
        $this->processOnHoldToCancel();

        // Phase 3: Process subscriptions waiting for cancellation at end of billing period
        $this->processWaitingCancellation();
    }

    /**
     * Process active subscriptions that should be moved to on-hold
     * Subscriptions are moved to on-hold after grace_days_before_on_hold days past due
     *
     * @return void
     */
    private function processActiveToOnHold(): void
    {
        // Get grace period from settings
        $grace_days_on_hold = arraysubs_get_grace_days_before_on_hold();

        // Calculate cutoff: subscriptions past due by grace_days_before_on_hold days
        $on_hold_cutoff = gmdate('Y-m-d H:i:s', strtotime('-' . $grace_days_on_hold . ' days'));

        // Find ACTIVE subscriptions with overdue next_payment_date that have unpaid orders
        $args = [
            'post_type'      => 'arraysubs_data',
            'post_status'    => ['arraysubs-active', 'arraysubs-trial'],
            'posts_per_page' => 50,
            'meta_query'     => [
                [
                    'key'     => '_next_payment_date',
                    'value'   => $on_hold_cutoff,
                    'compare' => '<',
                    'type'    => 'DATETIME',
                ],
                [
                    'key'     => '_billing_period',
                    'value'   => 'lifetime',
                    'compare' => '!=',
                ],
            ],
        ];

        $subscriptions = get_posts($args);

        foreach ($subscriptions as $subscription_post) {
            $subscription_id = $subscription_post->ID;

            // Check if there's an unpaid renewal order
            if (!$this->hasUnpaidRenewalOrder($subscription_id)) {
                // No unpaid order - create one
                $processor = new RenewalProcessor();
                $processor->createRenewalInvoice($subscription_id);
                continue; // Give them time to pay this new invoice
            }

            // Move subscription to on-hold
            wp_update_post([
                'ID'          => $subscription_id,
                'post_status' => 'arraysubs-on-hold',
            ]);

            // Record when it was put on hold for accurate cancellation timing
            update_post_meta($subscription_id, '_on_hold_date', current_time('mysql', true));

            // Trigger action for email notification
            do_action('arraysubs_data_put_on_hold', $subscription_id);
        }
    }

    /**
     * Process on-hold subscriptions that should be cancelled
     * Subscriptions are cancelled after grace_days_before_cancel days in on-hold status
     *
     * @return void
     */
    private function processOnHoldToCancel(): void
    {
        // Get grace periods from settings
        $grace_days_on_hold = arraysubs_get_grace_days_before_on_hold();
        $grace_days_cancel = arraysubs_get_grace_days_before_cancel();

        // Calculate cutoff: total grace period (on-hold grace + cancel grace)
        $total_grace_days = $grace_days_on_hold + $grace_days_cancel;
        $cancel_cutoff = gmdate('Y-m-d H:i:s', strtotime('-' . $total_grace_days . ' days'));

        // Find ON-HOLD subscriptions with next_payment_date older than total grace period
        $args = [
            'post_type'      => 'arraysubs_data',
            'post_status'    => ['arraysubs-on-hold'],
            'posts_per_page' => 50,
            'meta_query'     => [
                [
                    'key'     => '_next_payment_date',
                    'value'   => $cancel_cutoff,
                    'compare' => '<',
                    'type'    => 'DATETIME',
                ],
                [
                    'key'     => '_billing_period',
                    'value'   => 'lifetime',
                    'compare' => '!=',
                ],
            ],
        ];

        $subscriptions = get_posts($args);

        foreach ($subscriptions as $subscription_post) {
            $subscription_id = $subscription_post->ID;

            // Additional check: verify it's been on-hold long enough using _on_hold_date
            // This handles edge cases where subscription was manually put on-hold
            $on_hold_date = get_post_meta($subscription_id, '_on_hold_date', true);
            if (!empty($on_hold_date)) {
                $on_hold_timestamp = strtotime($on_hold_date);
                $cancel_after_timestamp = $on_hold_timestamp + ($grace_days_cancel * DAY_IN_SECONDS);

                if (time() < $cancel_after_timestamp) {
                    continue; // Not yet time to cancel based on on-hold date
                }
            }

            // Cancel the subscription due to non-payment
            $processor = new RenewalProcessor();
            $processor->cancelOverdueSubscription($subscription_id);
        }
    }

    /**
     * Process subscriptions waiting for cancellation that have reached end of billing period
     * These are subscriptions cancelled by admin/customer with "end of period" option
     * Uses _waiting_cancellation meta flag instead of a separate status
     *
     * @return void
     */
    private function processWaitingCancellation(): void
    {
        // Find subscriptions with _waiting_cancellation=true where scheduled date has passed
        $cutoff = current_time('mysql', true);

        $args = [
            'post_type'      => 'arraysubs_data',
            'post_status'    => ['arraysubs-active', 'arraysubs-trial'], // Only active/trial can be waiting
            'posts_per_page' => 50,
            'meta_query'     => [
                'relation' => 'AND',
                [
                    'key'     => '_waiting_cancellation',
                    'value'   => '1',
                    'compare' => '=',
                ],
                [
                    'key'     => '_cancellation_scheduled_date',
                    'value'   => $cutoff,
                    'compare' => '<=',
                    'type'    => 'DATETIME',
                ],
            ],
        ];

        $subscriptions = get_posts($args);

        foreach ($subscriptions as $subscription_post) {
            $subscription_id = $subscription_post->ID;

            update_post_meta($subscription_id, '_cancelled_date', current_time('mysql', true));
            update_post_meta($subscription_id, '_cancellation_type', 'end_of_period');

            wp_update_post([
                'ID'          => $subscription_id,
                'post_status' => 'arraysubs-cancelled',
            ]);

            $subscription = arraysubs_get_subscription($subscription_id);
            if (!$subscription instanceof \WP_Post || $subscription->post_status !== 'arraysubs-cancelled') {
                continue;
            }

            // Clear the waiting cancellation meta
            delete_post_meta($subscription_id, '_waiting_cancellation');
            delete_post_meta($subscription_id, '_cancellation_scheduled_date');

            // Unschedule all future actions
            \ArraySubs\Supports\ActionScheduler::unscheduleAllForSubscription($subscription_id);

            $this->handleEndOfPeriodRefund($subscription_id);

            // Trigger action for email notification and other integrations
            do_action('arraysubs_data_cancelled', $subscription_id, $subscription, 'end_of_period');
        }
    }

    /**
     * Handle refund audit/processing when scheduled end-of-period cancellation fires.
     *
     * @param int $subscription_id Subscription ID.
     * @return void
     */
    private function handleEndOfPeriodRefund(int $subscription_id): void
    {
        $behavior = (string) get_post_meta($subscription_id, '_refund_cancellation_behavior', true);
        if ('' === $behavior && function_exists('arraysubs_get_refund_cancellation_behavior')) {
            $behavior = arraysubs_get_refund_cancellation_behavior();
        }

        if ('none' === $behavior) {
            $this->addRefundAuditNote(
                $subscription_id,
                __('No automatic refund was issued for this end-of-period cancellation because the refund policy was set to No Automatic Refund.', 'arraysubs')
            );
            return;
        }

        if ('end_of_period' !== $behavior) {
            return;
        }

        if (function_exists('arraysubs_is_prorated_refund_allowed') && !arraysubs_is_prorated_refund_allowed()) {
            $this->addRefundAuditNote(
                $subscription_id,
                __('No prorated refund was issued for this end-of-period cancellation because prorated refunds are disabled.', 'arraysubs')
            );
            return;
        }

        $proration = ProrationCalculator::calculateProrationRefund($subscription_id);
        if (empty($proration['success'])) {
            $this->addRefundAuditNote(
                $subscription_id,
                sprintf(
                    /* translators: %s: error message */
                    __('No prorated refund was issued for this end-of-period cancellation because the refund preview could not be calculated: %s', 'arraysubs'),
                    esc_html($proration['error'] ?? __('unknown error', 'arraysubs'))
                )
            );
            return;
        }

        if ((float) ($proration['refund_amount'] ?? 0) <= 0) {
            $this->addRefundAuditNote(
                $subscription_id,
                __('No prorated refund was issued for this end-of-period cancellation because no unused billing time remained.', 'arraysubs')
            );
            return;
        }

        $result = RefundProcessor::processProratedRefund(
            $subscription_id,
            __('End-of-period cancellation refund', 'arraysubs'),
            false
        );

        if (empty($result['success'])) {
            $this->addRefundAuditNote(
                $subscription_id,
                sprintf(
                    /* translators: %s: error message */
                    __('End-of-period cancellation refund was not processed: %s', 'arraysubs'),
                    esc_html($result['error'] ?? __('unknown error', 'arraysubs'))
                )
            );
        }
    }

    /**
     * Add a private system refund audit note.
     *
     * @param int    $subscription_id Subscription ID.
     * @param string $message         Note text.
     * @return void
     */
    private function addRefundAuditNote(int $subscription_id, string $message): void
    {
        if (! function_exists('arraysubs_add_subscription_note')) {
            return;
        }

        arraysubs_add_subscription_note(
            $subscription_id,
            $message,
            'system',
            0,
            [],
            'subscription',
            'refund'
        );
    }

    /**
     * Check if subscription has a pending renewal order
     *
     * @param int $subscription_id Subscription ID
     * @return bool True if pending renewal order exists
     */
    private function hasPendingRenewalOrder(int $subscription_id): bool
    {
        $args = [
            'post_type'      => 'shop_order',
            'post_status'    => ['wc-pending'],
            'posts_per_page' => 1,
            'meta_query'     => [
                'relation' => 'AND',
                [
                    'key'   => '_is_renewal_order',
                    'value' => 'yes',
                ],
                [
                    'key'   => '_subscription_id',
                    'value' => $subscription_id,
                ],
            ],
        ];

        // For HPOS compatibility
        if (
            class_exists('Automattic\WooCommerce\Utilities\OrderUtil') &&
            \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled()
        ) {
            $orders = wc_get_orders([
                'limit'      => 1,
                'status'     => ['pending'],
                'meta_query' => [
                    'relation' => 'AND',
                    [
                        'key'   => '_is_renewal_order',
                        'value' => 'yes',
                    ],
                    [
                        'key'   => '_subscription_id',
                        'value' => $subscription_id,
                    ],
                ],
            ]);
            return !empty($orders);
        }

        $orders = get_posts($args);
        return !empty($orders);
    }

    /**
     * Check if subscription has an unpaid renewal order
     *
     * @param int $subscription_id Subscription ID
     * @return bool True if unpaid renewal order exists
     */
    private function hasUnpaidRenewalOrder(int $subscription_id): bool
    {
        // For HPOS compatibility
        if (
            class_exists('Automattic\WooCommerce\Utilities\OrderUtil') &&
            \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled()
        ) {
            $orders = wc_get_orders([
                'limit'      => 1,
                'status'     => ['pending', 'failed', 'on-hold'],
                'meta_query' => [
                    'relation' => 'AND',
                    [
                        'key'   => '_is_renewal_order',
                        'value' => 'yes',
                    ],
                    [
                        'key'   => '_subscription_id',
                        'value' => $subscription_id,
                    ],
                ],
            ]);
            return !empty($orders);
        }

        $args = [
            'post_type'      => 'shop_order',
            'post_status'    => ['wc-pending', 'wc-failed', 'wc-on-hold'],
            'posts_per_page' => 1,
            'meta_query'     => [
                'relation' => 'AND',
                [
                    'key'   => '_is_renewal_order',
                    'value' => 'yes',
                ],
                [
                    'key'   => '_subscription_id',
                    'value' => $subscription_id,
                ],
            ],
        ];

        $orders = get_posts($args);
        return !empty($orders);
    }

    /**
     * Check if invoice should be generated now for a subscription
     * 
     * SAFEGUARD: If the configured invoice_before_due duration is >= the subscription cycle,
     * only generate the invoice when we're within 6 hours of the due date.
     * This prevents creating invoices immediately after the previous one was paid.
     *
     * @param int    $subscription_id Subscription ID
     * @param string $next_payment    Next payment date (Y-m-d H:i:s format)
     * @return bool True if invoice should be generated now
     */
    private function shouldGenerateInvoiceNow(int $subscription_id, string $next_payment): bool
    {
        // Minimum hours before due date to generate invoice (safeguard)
        $minimum_hours_before_due = 6;

        // Get subscription billing cycle in hours
        $billing_interval = (int) get_post_meta($subscription_id, '_billing_interval', true) ?: 1;
        $billing_period = get_post_meta($subscription_id, '_billing_period', true) ?: 'month';

        $cycle_hours = $this->getBillingCycleInHours($billing_interval, $billing_period);

        // Get configured invoice before due hours
        $configured_hours = arraysubs_get_invoice_hours_before_due();

        // If configured duration is >= billing cycle, use the minimum safeguard
        if ($configured_hours >= $cycle_hours) {
            $effective_hours = $minimum_hours_before_due;
        } else {
            $effective_hours = $configured_hours;
        }

        // Check if we're within the effective hours of the due date
        $next_payment_timestamp = strtotime($next_payment);
        $cutoff_timestamp = time() + ($effective_hours * HOUR_IN_SECONDS);

        // Invoice should be generated if next_payment is <= cutoff (includes past due)
        return $next_payment_timestamp <= $cutoff_timestamp;
    }

    /**
     * Convert billing cycle to hours
     *
     * @param int    $interval Billing interval (1, 2, 3, etc.)
     * @param string $period   Billing period (day, week, month, year)
     * @return int Hours in the billing cycle
     */
    private function getBillingCycleInHours(int $interval, string $period): int
    {
        $hours_per_period = [
            'day'   => 24,
            'week'  => 24 * 7,
            'month' => 24 * 30,  // Approximate
            'year'  => 24 * 365, // Approximate
        ];

        $period_hours = $hours_per_period[$period] ?? (24 * 30); // Default to month

        return $interval * $period_hours;
    }

    /**
     * Process subscription renewal (called by Action Scheduler)
     *
     * Uses the lock system to prevent concurrent execution of the same renewal.
     *
     * @param int $subscription_id Subscription ID to process
     * @return void
     */
    public function processRenewal(int $subscription_id): void
    {
        // Acquire lock to prevent concurrent execution
        if (!ActionScheduler::acquireLock(ActionScheduler::HOOK_PROCESS_RENEWAL, [$subscription_id])) {
            return;
        }

        try {
            $processor = new RenewalProcessor();
            $processor->process($subscription_id);
        } finally {
            // Always release lock, even on error
            ActionScheduler::releaseLock(ActionScheduler::HOOK_PROCESS_RENEWAL, [$subscription_id]);
        }
    }

    /**
     * Generate a renewal invoice for one subscription.
     *
     * @param int $subscription_id Subscription ID.
     * @return void
     */
    public function generateRenewalInvoice(int $subscription_id): void
    {
        if (!ActionScheduler::acquireLock(ActionScheduler::HOOK_GENERATE_RENEWAL_INVOICE, [$subscription_id])) {
            return;
        }

        try {
            $subscription = arraysubs_get_subscription($subscription_id);

            if (!$subscription instanceof \WP_Post) {
                return;
            }

            if (!in_array($subscription->post_status, ['arraysubs-active', 'arraysubs-trial'], true)) {
                return;
            }

            if ($this->hasPendingRenewalOrder($subscription_id)) {
                return;
            }

            $next_payment = get_post_meta($subscription_id, '_next_payment_date', true);

            if (empty($next_payment) || !$this->shouldGenerateInvoiceNow($subscription_id, $next_payment)) {
                return;
            }

            $should_generate = apply_filters('arraysubs_should_generate_renewal_invoice', true, $subscription);

            if (!$should_generate) {
                return;
            }

            $processor = new RenewalProcessor();
            $processor->createRenewalInvoice($subscription_id);
        } finally {
            ActionScheduler::releaseLock(ActionScheduler::HOOK_GENERATE_RENEWAL_INVOICE, [$subscription_id]);
        }
    }

    /**
     * Handle individual subscription expiration (called by Action Scheduler).
     *
     * Uses the lock system to prevent concurrent execution.
     *
     * @param int $subscription_id Subscription ID to expire.
     * @return void
     */
    public function expireSubscription(int $subscription_id): void
    {
        if (!ActionScheduler::acquireLock(ActionScheduler::HOOK_EXPIRE_SUBSCRIPTION, [$subscription_id])) {
            return;
        }

        try {
            $subscription = arraysubs_get_subscription($subscription_id);

            if (!$subscription instanceof \WP_Post) {
                return;
            }

            // Fixed-period end dates override grace/on-hold drift, so on-hold
            // subscriptions with a reached fixed end date must still expire.
            if (!in_array($subscription->post_status, ['arraysubs-active', 'arraysubs-trial', 'arraysubs-on-hold'], true)) {
                return;
            }

            $checker = new ExpirationChecker();

            if ($checker->shouldExpire($subscription_id)) {
                $checker->expire($subscription_id);
            }
        } finally {
            ActionScheduler::releaseLock(ActionScheduler::HOOK_EXPIRE_SUBSCRIPTION, [$subscription_id]);
        }
    }

    /**
     * Process trial conversions
     *
     * Uses lock to prevent concurrent batch execution.
     *
     * @return void
     */
    public function processTrialConversions(): void
    {
        // Acquire lock to prevent concurrent batch execution
        if (!ActionScheduler::acquireLock(ActionScheduler::HOOK_PROCESS_TRIAL_CONVERSIONS, [])) {
            return;
        }

        try {
            $converter = new TrialConverter();
            $converter->processTrialConversions();
        } finally {
            ActionScheduler::releaseLock(ActionScheduler::HOOK_PROCESS_TRIAL_CONVERSIONS, []);
        }
    }

    /**
     * Cleanup completed renewal actions
     *
     * @param int $action_id Action Scheduler action ID
     * @return void
     */
    public function cleanupCompletedRenewal(int $action_id): void
    {
        // Placeholder for future cleanup logic if needed
    }
}
