<?php
/**
 * Failure Alert logic.
 *
 * @package NimblyMail\SMTP
 */

namespace NimblyMail\SMTP\Alerts;

defined( 'ABSPATH' ) || exit;

use NimblyMail\SMTP\Options;
use WP_Error;

/**
 * Class FailureAlert
 *
 * @since 1.0.0 Handles failure alert notifications via email. Added input sanitization for header injection prevention.
 * @since 1.0.6 Added a global burst-counter throttle alongside the per-hash dedup, so a
 *              multi-recipient outage can no longer send one alert per distinct recipient.
 */
class FailureAlert {

    /**
     * Options instance.
     *
     * @var Options
     */
    private Options $options;

    /**
     * Maximum number of alert emails allowed within BURST_WINDOW, across all
     * distinct errors combined - independent of the per-hash dedup above.
     *
     * @since 1.0.6
     *
     * @var int
     */
    private const BURST_LIMIT = 5;

    /**
     * Rolling window, in seconds, over which BURST_LIMIT applies.
     *
     * @since 1.0.6
     *
     * @var int
     */
    private const BURST_WINDOW = 15 * MINUTE_IN_SECONDS;

    /**
     * Transient key backing the global burst counter.
     *
     * @since 1.0.6
     *
     * @var string
     */
    private const BURST_KEY = 'nimblymail_alert_burst_count';

    /**
     * Object cache group used for the burst counter's atomic path.
     *
     * @since 1.0.6
     *
     * @var string
     */
    private const BURST_CACHE_GROUP = 'nimblymail';

    /**
     * Initialize hooks.
     *
     * @since 1.0.0 Added failure alert hooks.
     */
    public function init(): void {
        $this->options = Options::init();

        if ( ! $this->options->get( 'alerts', 'enabled' ) ) {
            return;
        }

        add_action( 'wp_mail_failed', array( $this, 'handle_failure' ) );
        add_action( 'nimblymail_mail_failed', array( $this, 'handle_failure' ) );
    }

    /**
     * Handle an email failure.
     *
     * Extracts error details, checks rate limiting, and dispatches an alert.
     * Called by both wp_mail_failed and nimblymail_mail_failed. Two independent
     * throttles apply: the per-hash dedup below allows only one alert per
     * unique error (same message, recipient, and subject) within 5 minutes,
     * and the burst counter caps total alert volume across all distinct
     * errors to BURST_LIMIT within BURST_WINDOW.
     *
     * @since 1.0.0 Added email failure handler.
     * @since 1.0.0 Suppress alerts while the Developer Sandbox is active.
     * @since 1.0.6 Added the burst-counter check. The per-hash dedup alone does not bound
     *              total alert volume during a multi-recipient outage, because $to_address
     *              and $subject are part of the hash, so every failed recipient produces a
     *              distinct hash and gets its own alert.
     *
     * @param WP_Error $error The WP_Error from wp_mail().
     */
    public function handle_failure( WP_Error $error ): void {
        if ( $this->options->get( 'sandbox', 'enabled' ) ) {
            return;
        }

        $error_message = $error->get_error_message();
        $error_data    = $error->get_error_data();

        $to_address = $error_data['to'] ?? 'Unknown';
        if ( is_array( $to_address ) ) {
            $to_address = implode( ', ', $to_address );
        }

        $subject = $error_data['subject'] ?? 'Unknown';

        // Create a hash to deduplicate this exact error.
        $hash = md5( $error_message . $to_address . $subject );

        $transient_key = 'nimblymail_alert_' . $hash;

        // Rate limit: Max 1 alert per 5 minutes per unique error.
        if ( get_transient( $transient_key ) ) {
            return;
        }

        set_transient( $transient_key, true, 5 * MINUTE_IN_SECONDS );

        if ( ! $this->register_burst() ) {
            return;
        }

        $this->send_alert( $to_address, $subject, $error_message );
    }

    /**
     * Check and increment the global alert burst counter.
     *
     * A second, independent throttle on top of the per-hash dedup: it bounds
     * total alert volume across all distinct errors within BURST_WINDOW,
     * so a provider outage hitting many different recipients still only
     * sends up to BURST_LIMIT alerts, not one per recipient.
     *
     * @since 1.0.6
     * @since 1.0.6 Added an atomic path via wp_cache_add()/wp_cache_incr() for sites
     *              running a persistent object cache (Redis/Memcached), where INCR is
     *              a native atomic op — closing the race where overlapping requests
     *              (e.g. concurrent Action Scheduler workers processing the same
     *              outage) could each read the count before either wrote it back.
     *              Sites without a persistent cache keep the previous best-effort,
     *              non-atomic transient read-then-write: on the default per-request
     *              object cache there is no cross-request primitive to make this
     *              atomic, and the resulting drift stays small and bounded by the
     *              concurrency of whatever is generating the failures, which the
     *              issue's own acceptance criteria already accepts.
     *
     * @return bool True if this failure may proceed to send an alert, false if the burst limit has already been reached.
     */
    private function register_burst(): bool {
        if ( wp_using_ext_object_cache() ) {
            wp_cache_add( self::BURST_KEY, 0, self::BURST_CACHE_GROUP, self::BURST_WINDOW );
            $count = wp_cache_incr( self::BURST_KEY, 1, self::BURST_CACHE_GROUP );

            return false !== $count && $count <= self::BURST_LIMIT;
        }

        $count = (int) get_transient( self::BURST_KEY );

        if ( $count >= self::BURST_LIMIT ) {
            return false;
        }

        set_transient( self::BURST_KEY, $count + 1, self::BURST_WINDOW );

        return true;
    }

    /**
     * Sanitize a string for safe inclusion in email content.
     *
     * Strips carriage returns and newlines, then applies sanitize_text_field()
     * to prevent header injection via error data embedded in the alert body.
     *
     * @since 1.0.0 Added header injection prevention.
     *
     * @param string $value The raw string to sanitize.
     *
     * @return string The sanitized string.
     */
    private function sanitize_for_email( string $value ): string {
        $value = str_replace( array( "\r", "\n" ), '', $value );
        return sanitize_text_field( $value );
    }

    /**
     * Send the alert email.
     *
     * Uses PHP's native mail() to bypass the broken SMTP transport and avoid
     * infinite loops if wp_mail() itself is the source of failure.
     *
     * Applies four extensibility hooks so Pro add-ons can modify recipients,
     * subject, and body, and react after a successful send.
     *
     * @since 1.0.0
     *
     * @param string $failed_to      The intended recipient of the failed email.
     * @param string $failed_subject The subject of the failed email.
     * @param string $error_message  The error message.
     *
     * @return void
     */
    private function send_alert( string $failed_to, string $failed_subject, string $error_message ): void {
        // Sanitize error data to prevent header injection via embedded newlines.
        $failed_to      = $this->sanitize_for_email( $failed_to );
        $failed_subject = $this->sanitize_for_email( $failed_subject );
        $error_message  = $this->sanitize_for_email( $error_message );

        $to = $this->options->get( 'alerts', 'email_to' );

        if ( empty( $to ) ) {
            $to = get_option( 'admin_email' );
        }

        $cc  = (string) $this->options->get( 'alerts', 'cc' );
        $bcc = (string) $this->options->get( 'alerts', 'bcc' );

        $site_name = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES );
        $subject   = sprintf( '[%s] Email Delivery Failure', $site_name );

        $message  = "An email failed to send from your website.\n\n";
        $message .= "Details:\n";
        $message .= "----------------------------------------\n";
        $message .= sprintf( "Time: %s\n", current_time( 'mysql' ) );
        $message .= sprintf( "Intended Recipient: %s\n", $failed_to );
        $message .= sprintf( "Subject: %s\n", $failed_subject );
        $message .= sprintf( "Error Message: %s\n", $error_message );
        $message .= "----------------------------------------\n\n";
        $message .= "Please check your NimblyMail settings and email logs for more information.\n";
        $message .= admin_url( 'admin.php?page=nimblymail-smtp#/logs' );

        // Pro postscript — contextual upsell on a moment when the recipient
        // is already thinking about delivery reliability. Suppressed when Pro
        // is active so paying customers aren't sold to. Recipients may include
        // a network admin who can act on it, so the gate is the raw
        // is_pro_active() check (not the per-user can_show_upsell()).
        if ( ! nimblymail()->is_pro_active() ) {
            $message .= "\n\n--\n";
            $message .= "Tip: NimblyMail Pro adds automatic failover — when the\n";
            $message .= "primary mailer fails, it retries through a secondary\n";
            $message .= "provider so a transient outage never reaches your inbox.\n";
            $message .= nimblymail()->get_pro_url( 'failure-alert' ) . "\n";
        }

        $log = array(
            'to'            => $failed_to,
            'subject'       => $failed_subject,
            'error_message' => $error_message,
        );

        /**
         * Filters the list of recipients for a failure alert email.
         *
         * The $recipients array is a numeric array with three elements:
         * index 0 is the primary To address, index 1 is the Cc address,
         * and index 2 is the Bcc address. Any element may be an empty string.
         *
         * @since 1.0.0
         *
         * @param array $recipients Numeric array of recipients [ to, cc, bcc ].
         * @param array $log        Context about the failed email that triggered the alert.
         *
         * @return array
         */
        $recipients = apply_filters( 'nimblymail_alert_recipients', array( $to, $cc, $bcc ), $log );

        // Re-sanitize after the filter: a third-party hook may return unvalidated data,
        // and CRLF in any of these values would reach mail() headers unescaped.
        $to  = $this->sanitize_for_email( (string) ( $recipients[0] ?? $to ) );
        $cc  = $this->sanitize_for_email( (string) ( $recipients[1] ?? $cc ) );
        $bcc = $this->sanitize_for_email( (string) ( $recipients[2] ?? $bcc ) );

        /**
         * Filters the subject line of a failure alert email.
         *
         * @since 1.0.0
         *
         * @param string $subject The alert email subject.
         * @param array  $log     Context about the failed email that triggered the alert.
         *
         * @return string
         */
        $subject = $this->sanitize_for_email(
            (string) apply_filters( 'nimblymail_alert_subject', $subject, $log )
        );

        /**
         * Filters the body of a failure alert email.
         *
         * @since 1.0.0
         *
         * @param string $body The plain-text alert email body.
         * @param array  $log  Context about the failed email that triggered the alert.
         *
         * @return string
         */
        $message = (string) apply_filters( 'nimblymail_alert_body', $message, $log );

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

        if ( ! empty( $cc ) ) {
            $headers[] = sprintf( 'Cc: %s', $cc );
        }

        if ( ! empty( $bcc ) ) {
            $headers[] = sprintf( 'Bcc: %s', $bcc );
        }

        // Use PHP's native mail() to bypass the likely broken SMTP transport.
        // Doing this prevents an infinite loop if wp_mail() fails here too.
        // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, Generic.PHP.NoSilencedErrors.Discouraged
        $sent = @mail( $to, $subject, $message, implode( "\r\n", $headers ) );

        if ( $sent ) {
            /**
             * Fires after a failure alert email is successfully sent.
             *
             * Suitable for logging or analytics. Only fires on a successful
             * mail() call — not triggered when mail() returns false.
             *
             * @since 1.0.0
             *
             * @param array  $recipients Numeric array of recipients [ to, cc, bcc ] as sent.
             * @param string $subject    The alert email subject as sent.
             * @param array  $log        Context about the failed email that triggered the alert.
             */
            do_action( 'nimblymail_alert_sent', array( $to, $cc, $bcc ), $subject, $log );
        }
    }
}
