<?php
/**
 * Log Rotation.
 *
 * @package NimblyMail\SMTP
 */

namespace NimblyMail\SMTP\Log;

use NimblyMail\SMTP\Options;
use NimblyMail\SMTP\Queue\AttachmentStager;
use NimblyMail\SMTP\Queue\Manager as QueueManager;
use NimblyMail\SMTP\Queue\Model as QueueModel;

defined( 'ABSPATH' ) || exit;

/**
 * Class Rotation
 *
 * Handles automatic log cleanup.
 *
 * @since 1.0.0 Refactored SQL to use concatenation for table names.
 * @since 1.0.0 Replaced hardcoded debug retention constant with configurable setting.
 * @since 1.0.2 Guarded Action Scheduler calls with function_exists checks.
 * @since 1.0.3 reclaim_stuck_queue_rows() now re-enqueues an Action Scheduler
 *              action for every row it releases; added
 *              purge_orphaned_placeholder_rows() to sweep abandoned pending
 *              placeholder rows and their staged attachments.
 * @since 1.0.4 Log rows now fall back to a 90-day retention floor whenever
 *              rotation has never been explicitly configured — for this site
 *              directly, or via a network-level default on multisite — so a
 *              site nobody has configured can no longer grow the logs table
 *              without bound.
 */
class Rotation {

    public const CRON_HOOK = 'nimblymail_log_rotation';
    public const AS_GROUP  = 'nimblymail-smtp';

    /**
     * Site option recording that an admin has explicitly saved this site's
     * own rotation settings at least once.
     *
     * Its mere presence — not its value — is what matters: once set, the
     * default retention floor below stops applying to this site, even if its
     * saved rotation settings amount to "everything off". The timestamp is
     * kept only for auditability. See is_configured() for how this combines
     * with ROTATION_CONFIGURED_NETWORK_OPTION on multisite.
     *
     * @since 1.0.4
     *
     * @var string
     */
    public const ROTATION_CONFIGURED_OPTION = 'nimblymail_rotation_configured';

    /**
     * Network-wide site option recording that a super admin has explicitly
     * saved network-level rotation defaults at least once.
     *
     * A site with no rotation override of its own resolves its effective
     * by_amount/by_time from the network default (see Options::get()), so an
     * explicit network-level choice must also suppress the per-site floor —
     * otherwise every subsite would apply the floor on top of a network
     * admin's deliberately longer retention period.
     *
     * @since 1.0.4
     *
     * @var string
     */
    public const ROTATION_CONFIGURED_NETWORK_OPTION = 'nimblymail_rotation_configured_network';

    /**
     * Default failed-queue retention in days when no setting is configured.
     *
     * @since 1.0.0
     *
     * @var int
     */
    private const DEFAULT_FAILED_QUEUE_RETENTION_DAYS = 7;

    /**
     * Default stuck-processing reclaim window in minutes when no setting is configured.
     *
     * @since 1.0.0
     *
     * @var int
     */
    private const DEFAULT_STUCK_RECLAIM_MINUTES = 30;

    /**
     * Default log retention in days applied when rotation has never been
     * explicitly configured (see is_configured()).
     *
     * @since 1.0.4
     *
     * @var int
     */
    private const DEFAULT_LOG_RETENTION_DAYS = 90;

    /**
     * Age threshold (hours) after which an unfinished placeholder row
     * (payload never written) is treated as abandoned and swept, since
     * nothing else can ever claim or clean it up. See
     * Model::delete_orphaned_placeholders() for how these rows arise.
     *
     * @since 1.0.3
     *
     * @var int
     */
    private const ORPHANED_PLACEHOLDER_MAX_AGE_HOURS = 24;

    /**
     * Record that an admin has explicitly configured this site's own
     * rotation settings, if a settings payload touches that group.
     *
     * Called after every path that can persist site-level settings — the
     * settings REST endpoint, the per-site network override endpoint, and
     * the settings importers — so an imported or overridden rotation
     * preference is respected exactly like one set by hand, instead of being
     * silently overridden by the default retention floor. Only the first
     * such save is recorded — once set, the marker's timestamp is left
     * untouched so it keeps reflecting when rotation was first explicitly
     * configured.
     *
     * @since 1.0.4
     *
     * @param array $data Settings payload keyed by group, as accepted by
     *                     Options::set() — the final (post-validation,
     *                     post-filter) payload actually persisted.
     *
     * @return void
     */
    public static function maybe_mark_configured( array $data ): void {
        if ( ! array_key_exists( 'rotation', $data ) ) {
            return;
        }

        if ( get_option( self::ROTATION_CONFIGURED_OPTION ) ) {
            return;
        }

        update_option( self::ROTATION_CONFIGURED_OPTION, time() );
    }

    /**
     * Clear this site's rotation-configured marker.
     *
     * Called when the rotation group is reset to defaults rather than saved
     * with new values. A reset removes the site's stored rotation choice
     * entirely, so it must also stop being treated as "explicitly
     * configured" — otherwise a site could end up with neither a rotation
     * setting nor the default floor, exactly the unbounded-growth state this
     * feature exists to prevent. Safe to call even when no marker exists.
     *
     * @since 1.0.4
     *
     * @return void
     */
    public static function clear_configured_marker(): void {
        delete_option( self::ROTATION_CONFIGURED_OPTION );
    }

    /**
     * Record that a super admin has explicitly configured network-level
     * rotation defaults, if a settings payload touches that group.
     *
     * Called from the network settings REST endpoint after a successful
     * save. No-op outside multisite.
     *
     * @since 1.0.4
     *
     * @param array $data Network settings payload keyed by group, as
     *                     accepted by Options::set( ..., 'network' ) — the
     *                     final (post-validation, post-filter) payload
     *                     actually persisted.
     *
     * @return void
     */
    public static function maybe_mark_network_configured( array $data ): void {
        if ( ! is_multisite() || ! array_key_exists( 'rotation', $data ) ) {
            return;
        }

        if ( get_site_option( self::ROTATION_CONFIGURED_NETWORK_OPTION ) ) {
            return;
        }

        update_site_option( self::ROTATION_CONFIGURED_NETWORK_OPTION, time() );
    }

    /**
     * Whether rotation has been explicitly configured for this site, either
     * directly, via a network-level default, or via a stored 'rotation' key
     * with no marker recorded — whether that's a site that saved rotation
     * settings before the marker options existed, or a write path that
     * simply never called the marker methods.
     *
     * The fallback checks backfill the corresponding marker the first time
     * they find one, so later calls skip straight to the fast marker
     * lookups. Options::set() only ever persists sparse overrides — plugin
     * defaults are never written into the stored option — so a stored
     * 'rotation' key reliably means an admin saved that group at some point,
     * even if no marker was recorded for it. Only evaluated from run(),
     * which fires on the hourly rotation cron rather than every request, so
     * the fallback check's extra option reads on a genuinely unconfigured
     * site — where they can never resolve into a cached marker — stay
     * infrequent.
     *
     * @since 1.0.4
     *
     * @return bool
     */
    private static function is_configured(): bool {
        if ( get_option( self::ROTATION_CONFIGURED_OPTION ) ) {
            return true;
        }

        $stored = get_option( Options::OPTION_KEY, array() );
        if ( is_array( $stored ) && array_key_exists( 'rotation', $stored ) ) {
            update_option( self::ROTATION_CONFIGURED_OPTION, time() );
            return true;
        }

        if ( ! is_multisite() ) {
            return false;
        }

        if ( get_site_option( self::ROTATION_CONFIGURED_NETWORK_OPTION ) ) {
            return true;
        }

        $stored_network = get_site_option( Options::NETWORK_OPTION_KEY, array() );
        $network_values = is_array( $stored_network ) ? ( $stored_network['values'] ?? array() ) : array();

        if ( is_array( $network_values ) && array_key_exists( 'rotation', $network_values ) ) {
            update_site_option( self::ROTATION_CONFIGURED_NETWORK_OPTION, time() );
            return true;
        }

        return false;
    }

    /**
     * Initialize rotation hooks.
     *
     * The recurring action is scheduled inside an `init` callback because
     * Action Scheduler's data store is initialised on `init` priority 1 —
     * scheduling earlier (e.g. directly on plugins_loaded) emits a
     * _doing_it_wrong notice.
     */
    public function init(): void {
        add_action( self::CRON_HOOK, array( $this, 'run' ) );
        add_action( 'init', array( $this, 'maybe_schedule' ) );
    }

    /**
     * Schedule the recurring rotation action when not already scheduled.
     *
     * @since 1.0.0
     * @since 1.0.2 Bails when Action Scheduler is unavailable instead of fataling.
     *
     * @return void
     */
    public function maybe_schedule(): void {
        if ( ! function_exists( 'as_next_scheduled_action' ) ) {
            return;
        }

        if ( false === as_next_scheduled_action( self::CRON_HOOK, null, self::AS_GROUP ) ) {
            as_schedule_recurring_action( time(), HOUR_IN_SECONDS, self::CRON_HOOK, array(), self::AS_GROUP );
        }
    }

    /**
     * Unschedule the recurring rotation action.
     *
     * @since 1.0.0
     * @since 1.0.2 Bails when Action Scheduler is unavailable instead of fataling.
     *
     * @return void
     */
    public static function unschedule(): void {
        if ( function_exists( 'as_unschedule_all_actions' ) ) {
            as_unschedule_all_actions( self::CRON_HOOK, array(), self::AS_GROUP );
        }
    }

    /**
     * Run the rotation process.
     *
     * @since 1.0.0
     * @since 1.0.3 Added purge_orphaned_placeholder_rows() to the sweep.
     * @since 1.0.4 Applies the default log retention floor whenever rotation
     *              has never been explicitly configured — at the site level
     *              or, on multisite, at the network level — mirroring the
     *              queue table's existing hardcoded-fallback pattern.
     *
     * @return void
     */
    public function run(): void {
        $options = Options::init();

        if ( $options->get( 'rotation', 'by_amount' ) ) {
            $limit = (int) $options->get( 'rotation', 'by_amount_limit' );
            if ( $limit > 0 ) {
                $this->rotate_by_count( $limit );
            }
        }

        if ( $options->get( 'rotation', 'by_time' ) ) {
            $days = (int) $options->get( 'rotation', 'by_time_days' );
            if ( $days > 0 ) {
                $this->rotate_by_age( $days );
            }
        }

        if ( ! self::is_configured() ) {
            $this->rotate_by_age( self::DEFAULT_LOG_RETENTION_DAYS );
        }

        $this->cleanup_debug_logs( $options );
        $this->purge_failed_queue_rows( $options );
        $this->reclaim_stuck_queue_rows( $options );
        $this->purge_orphaned_placeholder_rows();
    }

    /**
     * Delete permanently-failed queue rows older than the retention window
     * and remove their staging directories.
     *
     * @since 1.0.0
     *
     * @param Options $options The Options instance.
     *
     * @return void
     */
    private function purge_failed_queue_rows( Options $options ): void {
        $days = (int) $options->get( 'queue', 'failed_retention_days' );
        if ( $days <= 0 ) {
            $days = self::DEFAULT_FAILED_QUEUE_RETENTION_DAYS;
        }

        $deleted_ids = QueueModel::delete_expired_failed( $days );
        foreach ( $deleted_ids as $queue_id ) {
            AttachmentStager::cleanup( (int) $queue_id );
        }
    }

    /**
     * Release rows stuck in the 'processing' state past the configured timeout.
     *
     * Model::release_stuck_processing() only flips the DB status back to
     * pending; nothing else scans status='pending', so each released row
     * must also get a fresh Action Scheduler action here or it would sit
     * orphaned forever after a worker crash.
     *
     * @since 1.0.0
     * @since 1.0.3 Re-enqueue an Action Scheduler action for every released
     *              row instead of relying on the DB status flip alone.
     *
     * @param Options $options The Options instance.
     *
     * @return void
     */
    private function reclaim_stuck_queue_rows( Options $options ): void {
        $minutes = (int) $options->get( 'queue', 'stuck_reclaim_minutes' );
        if ( $minutes <= 0 ) {
            $minutes = self::DEFAULT_STUCK_RECLAIM_MINUTES;
        }

        $released = QueueModel::release_stuck_processing( $minutes );
        if ( empty( $released ) || ! function_exists( 'as_enqueue_async_action' ) ) {
            return;
        }

        foreach ( $released as $row ) {
            as_enqueue_async_action(
                QueueManager::RUNNER_HOOK,
                array( $row['id'] ),
                QueueManager::AS_GROUP,
                false,
                $row['priority']
            );
        }
    }

    /**
     * Delete abandoned pending placeholder rows and their staged attachments.
     *
     * @since 1.0.3
     *
     * @return void
     */
    private function purge_orphaned_placeholder_rows(): void {
        $deleted_ids = QueueModel::delete_orphaned_placeholders( self::ORPHANED_PLACEHOLDER_MAX_AGE_HOURS );
        foreach ( $deleted_ids as $queue_id ) {
            AttachmentStager::cleanup( (int) $queue_id );
        }
    }

    /**
     * Rotate by row count.
     *
     * @since 1.0.0 Refactored SQL to use concatenation for table name.
     *
     * @param int $limit Max rows to keep.
     */
    private function rotate_by_count( int $limit ): void {
        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $wpdb->prefix is internal; user input bound via prepare().
        $wpdb->query(
            $wpdb->prepare(
                "DELETE FROM {$wpdb->prefix}nimblymail_logs
                 WHERE id NOT IN (
                     SELECT id FROM (
                         SELECT id FROM {$wpdb->prefix}nimblymail_logs ORDER BY id DESC LIMIT %d
                     ) AS tmp
                 )",
                $limit
            )
        );
    }

    /**
     * Rotate by age.
     *
     * @since 1.0.0 Refactored SQL to use concatenation for table name.
     *
     * @param int $days Max age in days.
     */
    private function rotate_by_age( int $days ): void {
        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $wpdb->prefix is internal; user input bound via prepare().
        $wpdb->query(
            $wpdb->prepare(
                "DELETE FROM {$wpdb->prefix}nimblymail_logs WHERE created_at < DATE_SUB(NOW(), INTERVAL %d DAY)",
                $days
            )
        );
    }

    /**
     * Null out debug_log on rows older than the configured retention period.
     *
     * Reads the retention period from the debug_events_retention_days setting.
     *
     * @since 1.0.0 Refactored SQL to use concatenation for table name.
     * @since 1.0.0 Replaced hardcoded constant with debug_events_retention_days setting.
     *
     * @param Options $options The Options instance.
     *
     * @return void
     */
    private function cleanup_debug_logs( Options $options ): void {
        global $wpdb;
        $days = (int) $options->get( 'general', 'debug_events_retention_days' );

        if ( $days <= 0 ) {
            return;
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $wpdb->prefix is internal; user input bound via prepare().
        $wpdb->query(
            $wpdb->prepare(
                "UPDATE {$wpdb->prefix}nimblymail_logs SET debug_log = NULL WHERE debug_log IS NOT NULL AND created_at < DATE_SUB(NOW(), INTERVAL %d DAY)",
                $days
            )
        );
    }
}
