<?php
/**
 * Deliverability audit scheduler.
 *
 * @package NimblyMail\SMTP
 */

namespace NimblyMail\SMTP\Audit;

defined( 'ABSPATH' ) || exit;

/**
 * Class Scheduler
 *
 * Runs the deliverability audit on a weekly Action Scheduler cadence and
 * dispatches transition-based alert emails when a pillar regresses from
 * Pass/Warn into Fail. Alerting state is persisted in a single option so
 * repeat alerts for the same pillar are suppressed until the pillar
 * recovers.
 *
 * Background runs always invoke `HealthAuditor::audit( true )` directly;
 * the manual-recheck throttle does not apply to scheduler runs.
 *
 * @since 1.0.0
 */
final class Scheduler {

    /**
     * Action Scheduler hook for the recurring audit.
     *
     * @since 1.0.0
     *
     * @var string
     */
    public const HOOK = 'nimblymail_health_audit_run';

    /**
     * Action Scheduler group.
     *
     * @since 1.0.0
     *
     * @var string
     */
    public const GROUP = 'nimblymail-smtp-audit';

    /**
     * Auditor service.
     *
     * @since 1.0.0
     *
     * @var HealthAuditor
     */
    private HealthAuditor $auditor;

    /**
     * Constructor.
     *
     * @since 1.0.0
     *
     * @param HealthAuditor|null $auditor Optional auditor injection (for tests).
     */
    public function __construct( ?HealthAuditor $auditor = null ) {
        $this->auditor = $auditor ?? new HealthAuditor();
    }

    /**
     * Wire hooks.
     *
     * Schedules the recurring action on `init` (Action Scheduler's data store
     * is initialised at init priority 1, so scheduling earlier emits a
     * `_doing_it_wrong` notice). Binds the recurring hook to `run()` for
     * Action Scheduler to invoke.
     *
     * @since 1.0.0
     *
     * @return void
     */
    public function init(): void {
        add_action( self::HOOK, array( $this, 'run' ) );
        add_action( 'init', array( $this, 'maybe_schedule' ) );
    }

    /**
     * Schedule the recurring action when not already present.
     *
     * @since 1.0.0
     *
     * @return void
     */
    public function maybe_schedule(): void {
        if ( ! function_exists( 'as_next_scheduled_action' ) ) {
            return;
        }

        if ( false === as_next_scheduled_action( self::HOOK, null, self::GROUP ) ) {
            as_schedule_recurring_action( time() + HOUR_IN_SECONDS, WEEK_IN_SECONDS, self::HOOK, array(), self::GROUP );
        }
    }

    /**
     * Cancel all scheduled audit actions.
     *
     * Call this from the deactivation hook so we leave no orphan actions
     * in the queue when the plugin is disabled.
     *
     * @since 1.0.0
     *
     * @return void
     */
    public static function unschedule(): void {
        if ( function_exists( 'as_unschedule_all_actions' ) ) {
            as_unschedule_all_actions( self::HOOK, array(), self::GROUP );
        }
    }

    /**
     * Action Scheduler callback. Forces an audit and dispatches alerts.
     *
     * @since 1.0.0
     *
     * @return void
     */
    public function run(): void {
        $current = $this->auditor->audit( true );

        $this->dispatch_alerts( $current );
    }

    /**
     * Compare snapshots and email the admin when any pillar transitions
     * from Pass/Warn (or never-seen) to Fail.
     *
     * The per-pillar last-alert state is stored in the OPTION_LAST_ALERT
     * option as `{ pillar_id: timestamp }`. A pillar that fails on
     * consecutive runs alerts once; the entry is cleared when the pillar
     * recovers, so a future failure can alert again.
     *
     * @since 1.0.0
     *
     * @param AuditResult $current Snapshot from this run.
     *
     * @return void
     */
    private function dispatch_alerts( AuditResult $current ): void {
        $last_alert       = get_option( HealthAuditor::OPTION_LAST_ALERT, array() );
        $last_alert       = is_array( $last_alert ) ? $last_alert : array();
        $newly_failed     = array();
        $next_alert_state = $last_alert;
        $current_pillars  = $current->get_pillars();

        foreach ( $current_pillars as $id => $pillar ) {
            $is_fail     = PillarResult::STATUS_FAIL === $pillar->get_status();
            $was_alerted = ! empty( $last_alert[ $id ] );

            if ( $is_fail && ! $was_alerted ) {
                $newly_failed[ $id ]     = $pillar;
                $next_alert_state[ $id ] = time();
                continue;
            }

            // Pillar recovered — clear its alert state so a future failure
            // can produce a fresh alert.
            if ( ! $is_fail && $was_alerted ) {
                unset( $next_alert_state[ $id ] );
            }
        }

        if ( ! empty( $newly_failed ) ) {
            $this->send_alert_email( $current, $newly_failed );
        }

        if ( $next_alert_state !== $last_alert ) {
            update_option( HealthAuditor::OPTION_LAST_ALERT, $next_alert_state, false );
        }
    }

    /**
     * Send the alert email via PHP mail() — bypasses the active mailer to
     * avoid recursion through the very transport that may itself be misconfigured.
     *
     * @since 1.0.0
     *
     * @param AuditResult                 $result        Snapshot from this run.
     * @param array<string, PillarResult> $failed_pillars Pillars that newly transitioned to Fail.
     *
     * @return void
     */
    private function send_alert_email( AuditResult $result, array $failed_pillars ): void {
        $admin_email = (string) get_option( 'admin_email' );
        if ( '' === $admin_email ) {
            return;
        }

        // Strip CRLF before any value is interpolated into a header line —
        // a malicious or malformed blogname must not be able to inject
        // additional headers into the outgoing alert.
        $site_name = $this->sanitize_for_email(
            wp_specialchars_decode( (string) get_option( 'blogname' ), ENT_QUOTES )
        );
        $subject   = $this->sanitize_for_email(
            sprintf(
                /* translators: %s: site name. */
                __( '[%s] Email deliverability issue detected', 'nimblymail-smtp' ),
                $site_name
            )
        );

        $body = sprintf(
            /* translators: %s: audited domain. */
            __( "NimblyMail's weekly deliverability audit found one or more failed pillars for %s.\n\n", 'nimblymail-smtp' ),
            $result->get_domain()
        );
        $body .= __( "Failed pillars:\n", 'nimblymail-smtp' );
        $body .= "----------------------------------------\n";

        foreach ( $failed_pillars as $id => $pillar ) {
            $body .= sprintf( "  %s: %s\n", strtoupper( $id ), $pillar->get_message() );
            if ( null !== $pillar->get_fix() ) {
                $body .= sprintf( "    %s %s\n", __( 'Suggested record:', 'nimblymail-smtp' ), $pillar->get_fix() );
            }
        }

        $body .= "----------------------------------------\n\n";
        $body .= __( "Open the Deliverability page for full details and copy-to-clipboard records:\n", 'nimblymail-smtp' );
        $body .= admin_url( 'admin.php?page=nimblymail-smtp-deliverability' );

        $headers = array(
            'Content-Type: text/plain; charset=UTF-8',
            sprintf( 'From: NimblyMail Alerts <wordpress@%s>', wp_parse_url( home_url(), PHP_URL_HOST ) ),
        );

        /**
         * Filters the recipient address for the deliverability health alert.
         *
         * @since 1.0.0
         *
         * @param string      $admin_email     Site admin email address.
         * @param AuditResult $result          The current audit snapshot.
         * @param array       $failed_pillars  Map of pillar id => PillarResult that newly failed.
         */
        $recipient = (string) apply_filters( 'nimblymail_health_alert_recipient', $admin_email, $result, $failed_pillars );

        // Re-sanitize after the filter — a third-party hook may legitimately
        // or accidentally return a value with embedded CRLF, which would
        // reach mail() headers unescaped.
        $recipient = $this->sanitize_for_email( $recipient );

        if ( '' === $recipient ) {
            return;
        }

        // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, Generic.PHP.NoSilencedErrors.Discouraged
        @mail( $recipient, $subject, $body, implode( "\r\n", $headers ) );

        /**
         * Fires after a deliverability health alert is dispatched.
         *
         * @since 1.0.0
         *
         * @param string      $recipient      Recipient address.
         * @param AuditResult $result         The audit snapshot.
         * @param array       $failed_pillars Map of pillar id => PillarResult that newly failed.
         */
        do_action( 'nimblymail_health_alert_sent', $recipient, $result, $failed_pillars );
    }

    /**
     * Strip CRLF from a string before it is interpolated into an email header
     * or subject line.
     *
     * Mirrors the helper in `FailureAlert::sanitize_for_email()` — keeping
     * the logic local avoids an out-of-scope cross-class dependency for
     * what is one line of defensive sanitization.
     *
     * @since 1.0.0
     *
     * @param string $value Raw value.
     *
     * @return string Sanitized value with CRLF stripped.
     */
    private function sanitize_for_email( string $value ): string {
        $value = str_replace( array( "\r", "\n" ), '', $value );
        return sanitize_text_field( $value );
    }
}
