<?php
/**
 * Developer Sandbox interceptor.
 *
 * @package NimblyMail\SMTP
 */

namespace NimblyMail\SMTP\Sandbox;

defined( 'ABSPATH' ) || exit;

use NimblyMail\SMTP\Options;

/**
 * Class SandboxInterceptor
 *
 * Central decision point for the Developer Sandbox.
 *
 * WordPress' wp_mail() runs filters in this order: `wp_mail` (priority 1+) then
 * `pre_wp_mail` (priority 1+). Sandbox decisions therefore happen on the
 * `wp_mail` filter at priority 1 — early enough to rewrite the atts before
 * Logger's capture_email at PHP_INT_MAX records the email, and before the
 * Queue's nimblymail_pre_send (wp_mail priority 5) gets a chance to enqueue.
 *
 * The decided action ('log' | 'redirect' | 'bypassed') is stashed as a static
 * which Logger::capture_email and Logger::mark_success / mark_failure consult
 * to set status='sandboxed' / sandbox_mode='log|redirect|bypassed' on the log
 * row. For log mode, pre_wp_mail at priority 1 then short-circuits the actual
 * delivery — no SMTP connection, no API call.
 *
 * Decision flow on each wp_mail() call:
 *
 *   1. Per-request bypass flag set by REST `/test-email` → decision='bypassed'.
 *   2. sandbox.enabled disabled → decision=null, no-op.
 *   3. Allowlist matches every To, Cc, and Bcc recipient → decision='bypassed'.
 *      A single non-matching recipient anywhere in To/Cc/Bcc falls through
 *      to normal log/redirect handling instead.
 *   4. nimblymail_sandbox_should_intercept filter returns false → decision='bypassed'.
 *   5. mode === 'log' → decision='log', return $atts unchanged. pre_wp_mail
 *      short-circuits delivery. Logger marks status='sandboxed'.
 *   6. mode === 'redirect' → decision='redirect', rewrite $atts (To/CC/BCC,
 *      Subject prefix, X-NimblyMail-* headers), record originals. Logger
 *      marks status='sandboxed' on success.
 *   7. Any other mode value → decision='log' (fail closed rather than
 *      delivering live).
 *
 * @since 1.0.0
 * @since 1.0.4 Allowlist matching now requires every To/Cc/Bcc recipient to
 *              match, not just one To address; unrecognized mode values now
 *              fail closed to log mode instead of delivering live.
 */
class SandboxInterceptor {

    /**
     * Per-request bypass flag honoured for one wp_mail() invocation.
     *
     * @since 1.0.0
     *
     * @var bool
     */
    private static bool $bypass_next = false;

    /**
     * Sandbox decision stashed by maybe_rewrite for the current send.
     *
     * Possible values:
     *   - null: sandbox not active or hasn't decided yet.
     *   - 'log': log mode — pre_wp_mail will short-circuit.
     *   - 'redirect': redirect mode — atts were rewritten to safe_address.
     *   - 'bypassed': sandbox is on but this send was waved through (test
     *      email, allowlist, or filter override).
     *
     * @since 1.0.0
     *
     * @var string|null
     */
    private static ?string $current_decision = null;

    /**
     * Original recipients captured at wp_mail filter time for redirect mode.
     *
     * @since 1.0.0
     *
     * @var array{to: string, cc: string, bcc: string}|null
     */
    private static ?array $current_redirect_originals = null;

    /**
     * Register sandbox hooks.
     *
     * Two hook points:
     *   - wp_mail filter @ priority 1 — central decision and atts rewrite.
     *   - pre_wp_mail filter @ PHP_INT_MIN — short-circuits log-mode sends
     *     ahead of any other plugin's pre_wp_mail handler so the sandbox
     *     decision wins regardless of registration order.
     *
     * Also blocks the queue when sandbox is active so log-mode short-circuits
     * don't leak into Action Scheduler as queued, never-actually-sent jobs.
     *
     * @since 1.0.0
     *
     * @return void
     */
    public function init(): void {
        add_filter( 'wp_mail', array( $this, 'maybe_rewrite' ), 1 );
        add_filter( 'pre_wp_mail', array( $this, 'maybe_short_circuit' ), PHP_INT_MIN, 2 );
        add_filter( 'nimblymail_should_queue', array( $this, 'maybe_block_queue' ), 1 );
    }

    /**
     * Mark the next wp_mail() call to bypass the sandbox entirely.
     *
     * Single-shot. The next wp_mail filter call consumes and clears the flag.
     *
     * @since 1.0.0
     *
     * @return void
     */
    public static function set_bypass(): void {
        self::$bypass_next = true;
    }

    /**
     * Clear the bypass flag.
     *
     * @since 1.0.0
     *
     * @return void
     */
    public static function clear_bypass(): void {
        self::$bypass_next = false;
    }

    /**
     * Read the current sandbox decision without clearing it.
     *
     * Used by Logger::capture_email to set sandbox_mode on insert.
     *
     * @since 1.0.0
     *
     * @return string|null One of 'log', 'redirect', 'bypassed', or null.
     */
    public static function peek_decision(): ?string {
        return self::$current_decision;
    }

    /**
     * Read and clear the current sandbox decision.
     *
     * Used by Logger::mark_success and mark_failure to override the row's
     * terminal status when redirect mode was active.
     *
     * @since 1.0.0
     *
     * @return string|null One of 'log', 'redirect', 'bypassed', or null.
     */
    public static function consume_decision(): ?string {
        $decision               = self::$current_decision;
        self::$current_decision = null;
        return $decision;
    }

    /**
     * Read and clear the originals captured during redirect rewriting.
     *
     * @since 1.0.0
     *
     * @return array{to: string, cc: string, bcc: string}|null
     */
    public static function consume_redirect_originals(): ?array {
        $originals                        = self::$current_redirect_originals;
        self::$current_redirect_originals = null;
        return $originals;
    }

    /**
     * Wp_mail filter handler — main sandbox decision and atts rewriter.
     *
     * Runs at priority 1 so capture_email at PHP_INT_MAX sees the rewritten
     * atts and the queue manager (priority 5) sees the rewritten atts too.
     *
     * @since 1.0.0
     * @since 1.0.4 An unrecognized sandbox.mode value (reachable via a
     *              misconfigured NIMBLYMAIL_SANDBOX_MODE constant) now falls
     *              back to log mode instead of delivering the email live.
     *
     * @param array $atts wp_mail() arguments (to, subject, message, headers, attachments).
     *
     * @return array Rewritten or unchanged atts.
     */
    public function maybe_rewrite( $atts ) {
        if ( ! is_array( $atts ) ) {
            return $atts;
        }

        // Reset state for this send.
        self::$current_decision           = null;
        self::$current_redirect_originals = null;

        if ( self::$bypass_next ) {
            self::$bypass_next = false;
            self::record_bypassed( $atts, 'test_email' );
            return $atts;
        }

        $options = Options::init();
        if ( ! $options->get( 'sandbox', 'enabled' ) ) {
            return $atts;
        }

        if ( $this->matches_allowlist( $atts ) ) {
            self::record_bypassed( $atts, 'allowlist' );
            return $atts;
        }

        /**
         * Filters whether the sandbox should intercept the current wp_mail() call.
         *
         * Returning false lets the email pass through unchanged even when sandbox
         * is enabled — useful for code-level exemptions.
         *
         * @since 1.0.0
         *
         * @param bool  $intercept Default true — sandbox will intercept.
         * @param array $atts      The wp_mail() $atts array.
         */
        $should_intercept = (bool) apply_filters( 'nimblymail_sandbox_should_intercept', true, $atts );
        if ( ! $should_intercept ) {
            self::record_bypassed( $atts, 'filter' );
            return $atts;
        }

        $mode = (string) $options->get( 'sandbox', 'mode' );

        if ( 'log' === $mode ) {
            self::$current_decision = 'log';
            return $atts;
        }

        if ( 'redirect' === $mode ) {
            $safe_address = (string) $options->get( 'sandbox', 'safe_address' );
            if ( '' === $safe_address || ! is_email( $safe_address ) ) {
                // Misconfiguration — REST validator should have prevented this.
                // Treat as log mode rather than leaking the real email.
                self::$current_decision = 'log';
                return $atts;
            }
            return $this->rewrite_for_redirect( $atts, $safe_address, $options );
        }

        // Unrecognized mode (e.g. a misconfigured NIMBLYMAIL_SANDBOX_MODE
        // constant, which bypasses REST's enum validation) — fail closed to
        // log mode rather than delivering live while sandbox.enabled is true.
        self::$current_decision = 'log';
        return $atts;
    }

    /**
     * Pre_wp_mail filter handler — short-circuits log-mode sends.
     *
     * Hooked at PHP_INT_MIN. The log-mode decision is unconditional: if the
     * sandbox decided "do not deliver", we return true regardless of any
     * other handler's prior short-circuit so a misconfigured sibling plugin
     * cannot override the sandbox and ship a message the admin asked us to
     * suppress.
     *
     * @since 1.0.0
     *
     * @param null|bool $pre_wp_mail Current short-circuit value.
     * @param array     $atts        Unused — present for filter signature parity.
     *
     * @return null|bool True to short-circuit log mode; otherwise the original value.
     */
    public function maybe_short_circuit( $pre_wp_mail, $atts ) {
        unset( $atts );

        // Log-mode wins over any other short-circuit.
        if ( 'log' === self::$current_decision ) {
            return true;
        }

        return $pre_wp_mail;
    }

    /**
     * Block the queue from accepting sandboxed sends.
     *
     * Hooked at priority 1 on nimblymail_should_queue. When sandbox is
     * enabled, log mode short-circuits anyway and redirect mode is a single
     * synchronous send to safe_address — both pollute Action Scheduler if the
     * queue picks them up, so we suppress queueing universally while sandbox
     * is on.
     *
     * @since 1.0.0
     *
     * @param bool $should_queue Current decision.
     *
     * @return bool False to bypass the queue when sandbox is enabled.
     */
    public function maybe_block_queue( $should_queue ): bool {
        if ( Options::init()->get( 'sandbox', 'enabled' ) ) {
            return false;
        }
        return (bool) $should_queue;
    }

    /**
     * Rewrite $atts for redirect mode.
     *
     * Replaces To with safe_address, drops CC/BCC, prepends subject prefix,
     * injects X-NimblyMail-Sandbox/X-NimblyMail-Original-* headers, and
     * stashes the originals for Logger to persist.
     *
     * @since 1.0.0
     * @since 1.0.4 Cc/Bcc lines are now identified with the same parse
     *              wp_mail() uses, so forms like `Cc :` are stripped instead
     *              of surviving the rewrite and reaching the real recipient.
     *              Repeated Cc/Bcc lines are accumulated rather than
     *              overwritten when recording the originals.
     *
     * @param array   $atts          The original wp_mail() arguments.
     * @param string  $safe_address  The redirect target email.
     * @param Options $options       Cached Options instance.
     *
     * @return array Rewritten atts.
     */
    private function rewrite_for_redirect( array $atts, string $safe_address, Options $options ): array {
        // Snapshot the pre-rewrite atts so the nimblymail_sandbox_rewrite_atts
        // filter receives the original payload as its second argument, as
        // documented. Modifying $atts below must not affect $original_atts.
        $original_atts = $atts;

        $original_to = isset( $atts['to'] ) ? ( is_array( $atts['to'] ) ? implode( ', ', $atts['to'] ) : (string) $atts['to'] ) : '';

        $partitioned  = self::partition_headers( $atts['headers'] ?? array() );
        $kept_headers = $partitioned['kept'];
        $original_cc  = implode( ', ', $partitioned['cc'] );
        $original_bcc = implode( ', ', $partitioned['bcc'] );

        $site_url = function_exists( 'home_url' ) ? home_url() : '';

        $kept_headers[] = 'X-NimblyMail-Sandbox: redirect';
        $kept_headers[] = 'X-NimblyMail-Original-To: ' . self::sanitize_header_value( $original_to );
        if ( '' !== $original_cc ) {
            $kept_headers[] = 'X-NimblyMail-Original-Cc: ' . self::sanitize_header_value( $original_cc );
        }
        if ( '' !== $original_bcc ) {
            $kept_headers[] = 'X-NimblyMail-Original-Bcc: ' . self::sanitize_header_value( $original_bcc );
        }
        if ( '' !== $site_url ) {
            $kept_headers[] = 'X-NimblyMail-Site: ' . self::sanitize_header_value( $site_url );
        }

        $atts['to']      = $safe_address;
        $atts['headers'] = $kept_headers;

        $prefix = (string) $options->get( 'sandbox', 'subject_prefix' );
        if ( '' !== $prefix ) {
            $subject = (string) ( $atts['subject'] ?? '' );
            if ( '' === $subject || strpos( $subject, $prefix ) !== 0 ) {
                // Ensure exactly one space between prefix and subject when the
                // saved prefix was trimmed of trailing whitespace.
                $separator       = ( '' === $subject || ctype_space( substr( $prefix, -1 ) ) ) ? '' : ' ';
                $atts['subject'] = $prefix . $separator . $subject;
            }
        }

        self::$current_decision           = 'redirect';
        self::$current_redirect_originals = array(
            'to'  => $original_to,
            'cc'  => $original_cc,
            'bcc' => $original_bcc,
        );

        // Inject visual feedback banner.
        if ( apply_filters( 'nimblymail_sandbox_show_banner', true, $atts ) ) {
            $atts = $this->inject_banner( $atts, self::$current_redirect_originals );
        }

        /**
         * Filters the rewritten wp_mail() arguments after sandbox redirect.
         *
         * @since 1.0.0
         *
         * @param array $atts          The atts post-rewrite.
         * @param array $original_atts The atts as received before any rewriting.
         */
        $atts = (array) apply_filters( 'nimblymail_sandbox_rewrite_atts', $atts, $original_atts );

        return $atts;
    }

    /**
     * Inject a visual "Sandboxed" banner into the email message.
     *
     * Detects if the email is HTML or Plain Text and prepends a branded
     * notification banner to the body. This provides visual feedback to the
     * developer about the original recipients during redirection.
     *
     * @since 1.0.0
     *
     * @param array $atts      The current wp_mail() arguments.
     * @param array $originals The original recipients [to, cc, bcc] before redirection.
     *
     * @return array The modified wp_mail() arguments with the banner injected.
     */
    private function inject_banner( array $atts, array $originals ): array {
        $to      = $originals['to'] ?? '';
        $message = (string) ( $atts['message'] ?? '' );
        $is_html = $this->is_html( $atts );

        if ( $is_html ) {
            $banner = $this->get_html_banner( $to );
            if ( stripos( $message, '<body' ) !== false ) {
                $atts['message'] = preg_replace_callback(
                    '/<body[^>]*>/i',
                    static function ( $m ) use ( $banner ) {
                        return $m[0] . $banner;
                    },
                    $message,
                    1
                );
            } else {
                $atts['message'] = $banner . $message;
            }
        } else {
            $banner          = $this->get_text_banner( $to );
            $atts['message'] = $banner . $message;
        }

        return $atts;
    }

    /**
     * Detect if the current email is intended as HTML.
     *
     * @since 1.0.0
     *
     * @param array $atts The wp_mail() arguments.
     *
     * @return bool True if HTML.
     */
    private function is_html( array $atts ): bool {
        $headers = $atts['headers'] ?? array();
        if ( is_string( $headers ) ) {
            $headers = explode( "\n", str_replace( "\r", "\n", $headers ) );
        }

        foreach ( (array) $headers as $header ) {
            if ( stripos( $header, 'content-type:' ) === 0 && stripos( $header, 'text/html' ) !== false ) {
                return true;
            }
        }

        return 'text/html' === apply_filters( 'wp_mail_content_type', 'text/plain' );
    }

    /**
     * Get the HTML banner markup.
     *
     * @since 1.0.0
     *
     * @param string $original_to The original recipient.
     *
     * @return string HTML banner.
     */
    private function get_html_banner( string $original_to ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- Consumed by views/sandbox-banner.php via include scope.
        ob_start();
        include NIMBLYMAIL_PATH . 'views/sandbox-banner.php';
        return (string) ob_get_clean();
    }

    /**
     * Get the plain text banner.
     *
     * @since 1.0.0
     *
     * @param string $original_to The original recipient.
     *
     * @return string Text banner.
     */
    private function get_text_banner( string $original_to ): string {
        $line = str_repeat( '-', 60 );
        return sprintf(
            "%s\n%s\n%s %s\n%s\n\n",
            $line,
            strtoupper( __( 'NimblyMail Sandbox', 'nimblymail-smtp' ) ),
            __( 'Original recipient:', 'nimblymail-smtp' ),
            $original_to,
            $line
        );
    }

    /**
     * Record a bypass decision and fire the bypassed action.
     *
     * @since 1.0.0
     *
     * @param array  $atts   wp_mail() arguments.
     * @param string $reason 'test_email', 'allowlist', or 'filter'.
     *
     * @return void
     */
    private static function record_bypassed( array $atts, string $reason ): void {
        self::$current_decision = 'bypassed';

        /**
         * Fires when the sandbox decided not to intercept a wp_mail() call.
         *
         * @since 1.0.0
         *
         * @param array  $atts   The wp_mail() $atts array.
         * @param string $reason 'test_email', 'allowlist', or 'filter'.
         */
        do_action( 'nimblymail_sandbox_bypassed', $atts, $reason );
    }

    /**
     * Test the configured allowlist against every recipient of the current
     * send.
     *
     * @since 1.0.0
     * @since 1.0.4 Now requires every To, Cc, and Bcc address to match the
     *              allowlist before bypassing the sandbox — previously only
     *              the To field was checked, and any single matching To
     *              address bypassed the sandbox for the whole send,
     *              including unrelated Cc/Bcc recipients that were never
     *              even considered.
     *
     * @param array $atts wp_mail() arguments.
     *
     * @return bool True when every To, Cc, and Bcc recipient matches the allowlist.
     */
    private function matches_allowlist( array $atts ): bool {
        $raw = (string) Options::init()->get( 'sandbox', 'allowlist' );
        if ( '' === $raw ) {
            return false;
        }
        $allowlist = new Allowlist( $raw );
        if ( $allowlist->is_empty() ) {
            return false;
        }
        $cc_bcc = self::extract_cc_bcc( $atts );
        return $allowlist->matches_all( array( $atts['to'] ?? '', $cc_bcc['cc'], $cc_bcc['bcc'] ) );
    }

    /**
     * Extract the Cc and Bcc header values from wp_mail() arguments.
     *
     * @since 1.0.4
     *
     * @param array $atts wp_mail() arguments.
     *
     * @return array{cc: string, bcc: string} Comma-joined Cc and Bcc values, empty string when absent.
     */
    private static function extract_cc_bcc( array $atts ): array {
        $partitioned = self::partition_headers( $atts['headers'] ?? array() );

        return array(
            'cc'  => implode( ', ', $partitioned['cc'] ),
            'bcc' => implode( ', ', $partitioned['bcc'] ),
        );
    }

    /**
     * Split raw wp_mail() headers into Cc values, Bcc values, and the rest.
     *
     * Accepts either the string or array form wp_mail() accepts. Cc/Bcc names
     * are resolved by split_header(), which mirrors wp_mail()'s own parser,
     * and repeated Cc/Bcc lines are accumulated instead of overwriting each
     * other. Anything this parser misses that wp_mail() would still honour is
     * a recipient that escapes the allowlist check and survives the redirect
     * rewrite, so it gets delivered live — which is why the allowlist check
     * and the rewrite both go through here rather than parsing separately.
     *
     * @since 1.0.4
     *
     * @param string|array $headers Raw $atts['headers'] value.
     *
     * @return array{kept: array<int, string>, cc: array<int, string>, bcc: array<int, string>} Non-recipient header lines and the accumulated Cc/Bcc values.
     */
    private static function partition_headers( $headers ): array {
        if ( is_string( $headers ) ) {
            $headers = preg_split( '/\r\n|\r|\n/', $headers );
        }
        if ( ! is_array( $headers ) ) {
            $headers = array();
        }

        $partitioned = array(
            'kept' => array(),
            'cc'   => array(),
            'bcc'  => array(),
        );

        foreach ( $headers as $header ) {
            if ( ! is_string( $header ) || '' === trim( $header ) ) {
                continue;
            }

            $parts = self::split_header( $header );

            if ( null !== $parts && ( 'cc' === $parts[0] || 'bcc' === $parts[0] ) ) {
                if ( '' !== $parts[1] ) {
                    $partitioned[ $parts[0] ][] = $parts[1];
                }
                continue;
            }

            $partitioned['kept'][] = $header;
        }

        return $partitioned;
    }

    /**
     * Split a raw header line into its lowercased name and trimmed content.
     *
     * Mirrors wp_mail()'s own parse: the name is everything before the first
     * colon, then trimmed, so `Cc : x` is recognised exactly as wp_mail()
     * recognises it.
     *
     * @since 1.0.4
     *
     * @param string $header Raw header line.
     *
     * @return array{0: string, 1: string}|null Lowercased name and content, or null when the line is not a header.
     */
    private static function split_header( string $header ): ?array {
        if ( strpos( $header, ':' ) === false ) {
            return null;
        }

        list( $name, $content ) = explode( ':', trim( $header ), 2 );

        return array( strtolower( trim( $name ) ), trim( $content ) );
    }

    /**
     * Strip CR/LF characters from a header value to prevent header injection.
     *
     * @since 1.0.0
     *
     * @param string $value Raw header value.
     *
     * @return string Sanitised value.
     */
    private static function sanitize_header_value( string $value ): string {
        return str_replace( array( "\r", "\n" ), ' ', $value );
    }
}
