<?php
/**
 * Sandbox allowlist matcher.
 *
 * @package NimblyMail\SMTP
 */

namespace NimblyMail\SMTP\Sandbox;

defined( 'ABSPATH' ) || exit;

/**
 * Class Allowlist
 *
 * Parses the user-supplied allowlist textarea (one pattern per line) and
 * matches recipient addresses against it. Supports two pattern shapes:
 *
 *   - exact email     dev@example.com
 *   - domain wildcard *@mycompany.com
 *
 * Matching is case-insensitive. matches() answers "is any address in this
 * one recipient value allowlisted?"; matches_all() answers "is every address
 * across these recipient values allowlisted?" and is what SandboxInterceptor
 * uses to decide whether a whole send — To, Cc, and Bcc together — bypasses
 * the sandbox.
 *
 * @since 1.0.0
 * @since 1.0.4 Added matches_all() for the full-send bypass decision across
 *              To, Cc, and Bcc.
 */
class Allowlist {

    /**
     * Parsed pattern list.
     *
     * Each entry is an associative array:
     *   [ 'type' => 'exact'|'domain', 'value' => string ]
     *
     * Values are stored lowercased. The `domain` type's value is the part
     * after the leading `*@` (already stripped).
     *
     * @since 1.0.0
     *
     * @var array<int, array{type: string, value: string}>
     */
    private array $patterns = array();

    /**
     * Build an allowlist from the raw textarea value.
     *
     * Accepts either a newline-separated string or a pre-split array of
     * lines. Lines that are empty or start with `#` are dropped. Whitespace
     * is trimmed off each line. Patterns are normalised to lowercase.
     *
     * @since 1.0.0
     *
     * @param string|array $raw Raw textarea content (string) or array of lines.
     */
    public function __construct( $raw = '' ) {
        if ( is_array( $raw ) ) {
            $lines = $raw;
        } else {
            $lines = preg_split( '/\r\n|\r|\n/', (string) $raw );
        }

        if ( ! is_array( $lines ) ) {
            return;
        }

        foreach ( $lines as $line ) {
            if ( ! is_string( $line ) ) {
                continue;
            }

            $trimmed = trim( $line );
            if ( '' === $trimmed || str_starts_with( $trimmed, '#' ) ) {
                continue;
            }

            $lower = strtolower( $trimmed );

            if ( str_starts_with( $lower, '*@' ) ) {
                $domain = self::idn_to_ascii( substr( $lower, 2 ) );
                if ( '' !== $domain && false === strpos( $domain, '*' ) ) {
                    $this->patterns[] = array(
                        'type'  => 'domain',
                        'value' => $domain,
                    );
                }
                continue;
            }

            $canonical = self::canonicalize_address( $lower );
            if ( '' === $canonical ) {
                continue;
            }

            $this->patterns[] = array(
                'type'  => 'exact',
                'value' => $canonical,
            );
        }
    }

    /**
     * Whether any pattern is configured.
     *
     * Empty allowlists never match — callers can short-circuit with this
     * helper to avoid extracting recipients from the recipient fields
     * unnecessarily.
     *
     * @since 1.0.0
     *
     * @return bool True when at least one pattern is configured.
     */
    public function is_empty(): bool {
        return empty( $this->patterns );
    }

    /**
     * Match a single recipient value against the configured patterns.
     *
     * Accepts a wp_mail()-style recipient value — To, Cc, or Bcc — which can
     * be either a string (possibly comma-separated, possibly with display
     * names) or an array of such strings. If any extracted address matches
     * any pattern, returns true; otherwise false. An empty allowlist always
     * returns false.
     *
     * @since 1.0.0
     * @since 1.0.4 Generalised from the To field to any recipient value, and
     *              picks up extract_addresses()' greedy display-name
     *              stripping, so an entry holding several bracketed addresses
     *              is judged on the last one like wp_mail() delivers it.
     *
     * @param string|array $recipient_field A raw wp_mail() recipient value.
     *
     * @return bool True when at least one of its recipients is on the allowlist.
     */
    public function matches( $recipient_field ): bool {
        if ( $this->is_empty() ) {
            return false;
        }

        $addresses = self::extract_addresses( $recipient_field );
        if ( empty( $addresses ) ) {
            return false;
        }

        foreach ( $addresses as $address ) {
            $address_lower = strtolower( $address );
            $canonical     = self::canonicalize_address( $address_lower );
            $domain_at     = strrpos( $canonical, '@' );
            $domain        = false === $domain_at ? '' : substr( $canonical, $domain_at + 1 );

            foreach ( $this->patterns as $pattern ) {
                if ( 'exact' === $pattern['type'] && $pattern['value'] === $canonical ) {
                    return true;
                }
                if ( 'domain' === $pattern['type'] && '' !== $domain && $pattern['value'] === $domain ) {
                    return true;
                }
            }
        }

        return false;
    }

    /**
     * Match a set of recipient values against the configured patterns,
     * requiring every extracted address to match.
     *
     * Where matches() is satisfied by a single hit, this requires all of
     * them. The sandbox bypass decision covers the entire send, so one
     * non-allowlisted address anywhere in To, Cc, or Bcc has to be enough to
     * keep the whole send sandboxed.
     *
     * @since 1.0.4
     *
     * @param array<int, string|array> $recipient_fields Raw wp_mail() recipient values (e.g. To, Cc, Bcc), each a string or array.
     *
     * @return bool True when every extracted address matches at least one pattern.
     */
    public function matches_all( array $recipient_fields ): bool {
        if ( $this->is_empty() ) {
            return false;
        }

        $combined = array();
        foreach ( $recipient_fields as $recipient_field ) {
            $combined = array_merge( $combined, self::extract_addresses( $recipient_field ) );
        }

        if ( empty( $combined ) ) {
            return false;
        }

        foreach ( $combined as $address ) {
            if ( ! $this->matches( $address ) ) {
                return false;
            }
        }

        return true;
    }

    /**
     * Canonicalize an email address for comparison.
     *
     * Converts the domain to ASCII via IDN (UTS46) so users with the same
     * mailbox cannot bypass the allowlist by switching between Unicode and
     * punycode. Strips any `+tag` suffix on the local part so that adding
     * `user@example.com` matches `user+anything@example.com` (and vice
     * versa) — without this, an admin who allowlists their bare address
     * still leaks every plus-aliased variant to the live mail server in
     * redirect mode.
     *
     * @since 1.0.0
     *
     * @param string $address Lowercased email address.
     *
     * @return string Canonical form, or empty string when the input is malformed.
     */
    private static function canonicalize_address( string $address ): string {
        $at = strrpos( $address, '@' );
        if ( false === $at ) {
            return '';
        }

        $local  = substr( $address, 0, $at );
        $domain = substr( $address, $at + 1 );

        $plus = strpos( $local, '+' );
        if ( false !== $plus ) {
            $local = substr( $local, 0, $plus );
        }

        $domain = self::idn_to_ascii( $domain );
        if ( '' === $domain || '' === $local ) {
            return '';
        }

        return $local . '@' . $domain;
    }

    /**
     * Convert a domain to ASCII (punycode) form.
     *
     * Uses ext-intl's UTS#46 implementation when available; falls back to the
     * input unchanged for sites without the extension. The fallback path is
     * acceptable because matching is then host-vs-host on the user-supplied
     * forms — at worst a UTF-8 domain is compared as-is against another UTF-8
     * domain, which is the original behavior.
     *
     * @since 1.0.0
     *
     * @param string $domain Lowercased domain.
     *
     * @return string ASCII domain.
     */
    private static function idn_to_ascii( string $domain ): string {
        if ( '' === $domain ) {
            return '';
        }

        if ( function_exists( 'idn_to_ascii' ) ) {
            $ascii = @idn_to_ascii( $domain, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
            if ( is_string( $ascii ) && '' !== $ascii ) {
                return strtolower( $ascii );
            }
        }

        return $domain;
    }

    /**
     * Extract bare email addresses from a wp_mail()-style recipient value.
     *
     * Handles array form, comma-separated string, and `Name <email>` entries
     * by stripping any display-name component. Empty entries are skipped.
     *
     * @since 1.0.0
     * @since 1.0.4 Display-name stripping now matches greedily like wp_mail(),
     *              so an entry holding several bracketed addresses resolves to
     *              the one actually delivered to instead of the first one.
     *
     * @param string|array $recipient_field A raw wp_mail() recipient value (To, Cc, or Bcc).
     *
     * @return array<int, string> List of extracted addresses (may be empty).
     */
    private static function extract_addresses( $recipient_field ): array {
        if ( is_array( $recipient_field ) ) {
            $entries = $recipient_field;
        } else {
            $entries = explode( ',', (string) $recipient_field );
        }

        $result = array();

        foreach ( $entries as $entry ) {
            if ( ! is_string( $entry ) ) {
                continue;
            }
            $trimmed = trim( $entry );
            if ( '' === $trimmed ) {
                continue;
            }

            // Strip optional display name: "Name <email@host>" → "email@host".
            // Greedy, exactly like wp_mail(): when an entry holds more than
            // one bracketed address the LAST one is what reaches PHPMailer,
            // so that is the address the allowlist has to judge.
            if ( preg_match( '/(.*)<(.+)>/', $trimmed, $matches ) ) {
                $trimmed = trim( $matches[2] );
            }

            if ( '' !== $trimmed ) {
                $result[] = $trimmed;
            }
        }

        return $result;
    }
}
