<?php
/**
 * TitanCart Mailer — Transactional Email Engine.
 *
 * Responsible for sending all order-related emails:
 *   1. New Order Admin Notification  — fires on `titancart_payment_processed` (success only)
 *   2. Order Confirmation (customer) — fires on `titancart_payment_processed` (success only)
 *   3. Status Update (customer)      — fires on `titancart_order_status_changed`
 *
 * HOW TEMPLATES WORK
 * ------------------
 * Each email is a PHP template in templates/emails/.
 * Templates receive variables via extract() and are wrapped in a shared
 * base layout (templates/emails/base.php) which supplies the HTML skeleton,
 * header, and footer.
 *
 * CUSTOMISATION
 * -------------
 * Copy any template into your theme under:
 *   your-theme/titancart/emails/
 * TitanCart's TemplateLoader will prefer the theme copy automatically.
 *
 * @package TitanCart\Modules\Orders
 */

namespace TitanCart\Modules\Orders;

use TitanCart\Storefront\TemplateLoader;
use TitanCart\Services\SettingsService;
use TitanCart\Modules\Downloadables\DownloadableRepository;

defined( 'ABSPATH' ) || exit;

class Mailer {

    /** @var string Site name used in email subjects / footer. */
    private string $site_name;

    /** @var string From name for outgoing emails. */
    private string $from_name;

    /** @var string From address for outgoing emails. */
    private string $from_email;

    /** @var string Company address / phone for footer. */
    private string $company_address;
    private string $company_phone;
    private string $company_phone_2;
    private string $company_website;
    private string $company_year;

    /**
     * In-request stash of just-issued download keys, keyed by order_id.
     *
     * Populated by the `titancart_downloadable_keys_issued` listener in the
     * sync-gateway flow (where `set_order_status('processing')` fires the
     * issuance hook BEFORE `titancart_payment_processed` fires the email).
     * Consumed by send_order_confirmation() — once consumed, the entry is
     * unset so a subsequent call doesn't re-embed the same block.
     *
     * Static (not instance) because the Mailer is instantiated by both the
     * OrdersModule (for normal email flow) and PaymentsModule's listener
     * registration. Static keeps a single shared stash per request.
     *
     * @var array<int, array<int, array{token:string, key_id:int, file_id:int, order_item_id:int}>>
     */
    private static array $pending_downloads = [];

    /**
     * Transient TTL (in seconds) for the "confirmation already sent" marker.
     * Used by the keys-issued listener to disambiguate sync (in-request) vs
     * async (later-request) gateway flows. 24 hours is comfortably longer
     * than any realistic async capture window — PayPal redirects and Stripe
     * webhooks settle in seconds to minutes; a 24h ceiling means we never
     * confuse a fresh order with a re-issuance.
     */
    private const CONFIRM_SENT_TTL = 24 * HOUR_IN_SECONDS;

    public function __construct() {
        $this->site_name       = get_bloginfo( 'name' );
        $company                = SettingsService::get( 'company', 'company_name', '' );
        $this->from_name       = $company !== '' ? $company : $this->site_name;
        $order_email            = SettingsService::get( 'company', 'email_order_dept', '' );
        $this->from_email      = ( $order_email !== '' && is_email( $order_email ) )
            ? $order_email
            : get_option( 'admin_email' );
        $this->company_address = trim(
            SettingsService::get( 'company', 'company_address', '' ) . ', ' .
            SettingsService::get( 'company', 'company_city', '' ) . ' ' .
            SettingsService::get( 'company', 'company_state', '' ) . ' ' .
            SettingsService::get( 'company', 'company_zip', '' ) . ', ' .
            SettingsService::get( 'company', 'company_country', '' ),
            ', '
        );
        $this->company_phone   = SettingsService::get( 'company', 'company_phone', '' );
        $this->company_phone_2 = SettingsService::get( 'company', 'company_phone_2', '' );
        $this->company_website = SettingsService::get( 'company', 'company_website', '' );
        $this->company_year    = SettingsService::get( 'company', 'company_year', '' );
    }

    // ══════════════════════════════════════════════════════════════════════════
    // PUBLIC SEND METHODS
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Send the new-order notification to the store admin.
     *
     * @param array $order  Full order row (including status_handle, status_label).
     * @param array $items  Order line items with their option snapshots.
     */
    public function send_new_order_admin( array $order, array $items ): void {
        /**
         * Filter whether the new-order admin email should be sent.
         *
         * @param bool  $send   Whether to send. Default true.
         * @param array $order  Order data.
         */
        if ( ! apply_filters( 'titancart_send_new_order_admin_email', true, $order ) ) {
            return;
        }

        $to      = apply_filters( 'titancart_new_order_admin_email', $this->get_admin_email() );
        $subject = apply_filters(
            'titancart_new_order_admin_subject',
            sprintf(
                /* translators: 1: site name, 2: order number */
                __( '[%1$s] New Order: %2$s', 'titancart-lite' ),
                $this->site_name,
                $order['order_number']
            ),
            $order
        );

        $html = $this->render( 'emails/new-order-admin.php', compact( 'order', 'items' ) );
        $this->send( $to, $subject, $html );
    }

    /**
     * Send the order confirmation email to the customer.
     *
     * @param array $order
     * @param array $items
     */
    public function send_order_confirmation( array $order, array $items ): void {
        /**
         * Filter whether the customer confirmation email should be sent.
         *
         * @param bool  $send   Default true.
         * @param array $order
         */
        if ( ! apply_filters( 'titancart_send_order_confirmation_email', true, $order ) ) {
            return;
        }

        // Idempotency guard (KI-05). This method is now reachable from two
        // paths: the sync on_payment_processed path and the async
        // payment-confirmed path (titancart_stripe_payment_intent_succeeded →
        // OrdersModule::on_async_payment_confirmed). The tc_order_confirm_sent
        // transient is set at the end of a successful send below; bail if it is
        // already set so an order can never receive two confirmation emails.
        // Also defends against Stripe's at-least-once webhook redelivery.
        $confirm_guard_id = (int) ( $order['id'] ?? 0 );
        if ( $confirm_guard_id > 0 && get_transient( 'titancart_order_confirm_sent_' . $confirm_guard_id ) ) {
            return;
        }

        $to = sanitize_email( $order['billing_email'] );
        if ( ! is_email( $to ) ) {
            return;
        }

        $subject = apply_filters(
            'titancart_order_confirmation_subject',
            sprintf(
                /* translators: 1: site name, 2: order number */
                __( '[%1$s] Order Confirmed: %2$s', 'titancart-lite' ),
                $this->site_name,
                $order['order_number']
            ),
            $order
        );

        // Phase 4: consume any pending downloads stashed by the keys-issued
        // listener earlier in this same request (sync gateway path). Hydrate
        // into render-ready groups for the template's Downloads block.
        $download_groups = [];
        $order_id        = (int) ( $order['id'] ?? 0 );
        if ( $order_id > 0 && isset( self::$pending_downloads[ $order_id ] ) ) {
            $download_groups = $this->hydrate_download_groups( $order_id, self::$pending_downloads[ $order_id ] );
            unset( self::$pending_downloads[ $order_id ] );
        }

        $template_vars = compact( 'order', 'items', 'download_groups' );
        $html          = $this->render( 'emails/order-confirmation.php', $template_vars );
        $this->send( $to, $subject, $html );

        // Mark this order's confirmation as having been sent. The keys-issued
        // listener uses this transient to decide between "stash for inline
        // embed" (transient absent — sync gateway, in-flight place_order) and
        // "send standalone now" (transient present — async gateway capture
        // arrived after confirmation already went out).
        if ( $order_id > 0 ) {
            set_transient( 'titancart_order_confirm_sent_' . $order_id, 1, self::CONFIRM_SENT_TTL );
        }
    }

    /**
     * Send a status-update email to the customer (only when the new status
     * has notify_customer = 1 in the order_statuses table).
     *
     * @param array  $order          Full order row (status_handle already updated).
     * @param string $old_handle     The previous status handle.
     */
    public function send_status_update( array $order, string $old_handle ): void {
        /**
         * Filter whether the status-update email should be sent.
         *
         * @param bool   $send        Default true.
         * @param array  $order
         * @param string $old_handle
         */
        if ( ! apply_filters( 'titancart_send_status_update_email', true, $order, $old_handle ) ) {
            return;
        }

        $to = sanitize_email( $order['billing_email'] );
        if ( ! is_email( $to ) ) {
            return;
        }

        $new_label = $order['status_label'] ?? $order['status_handle'] ?? '';

        $subject = apply_filters(
            'titancart_status_update_subject',
            sprintf(
                /* translators: 1: site name, 2: order number, 3: new status label */
                __( '[%1$s] Order %2$s — Status: %3$s', 'titancart-lite' ),
                $this->site_name,
                $order['order_number'],
                $new_label
            ),
            $order,
            $old_handle
        );

        $html = $this->render(
            'emails/order-status-update.php',
            compact( 'order', 'old_handle', 'new_label' )
        );
        $this->send( $to, $subject, $html );
    }

    // ══════════════════════════════════════════════════════════════════════════
    // DOWNLOADS — Phase 4
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Hook listener: `titancart_downloadable_keys_issued`.
     *
     * Routes the just-issued raw-token payload to one of two destinations:
     *
     *   1. STASH for inline embed (sync gateway, in-flight place_order).
     *      Detected by: $context === 'order_paid' AND no
     *      `titancart_order_confirm_sent_{$order_id}` transient yet. The customer
     *      confirmation email is about to compose later in this same
     *      request — send_order_confirmation() will pick up the stash and
     *      hydrate the Downloads block inline.
     *
     *   2. STANDALONE send (async gateway capture, or admin resend).
     *      Detected by: $context === 'admin_resend', OR
     *      $context === 'order_paid' AND the confirmation transient exists
     *      from an earlier request. Send the dedicated downloads-ready
     *      email immediately.
     *
     * The raw tokens in $issued_map are the only place raw tokens flow —
     * they're hashed to key_hash before storage and never reproduced
     * anywhere else. We must emit them now or never.
     *
     * @param  int    $order_id
     * @param  array  $issued_map  key_id → { token, key_id, file_id, order_item_id }
     * @param  string $context     'order_paid' | 'admin_resend'
     * @return void
     */
    public function on_keys_issued( int $order_id, array $issued_map, string $context = 'order_paid' ): void {
        if ( $order_id <= 0 || empty( $issued_map ) ) {
            return;
        }

        // Admin resend is unambiguous — always standalone.
        if ( $context === 'admin_resend' ) {
            $this->send_downloads_ready( $order_id, $issued_map, true );
            return;
        }

        // 'order_paid' path. If the confirmation has already been sent in an
        // earlier request (async gateway capture), send standalone now. The
        // transient is set inside send_order_confirmation() AFTER wp_mail —
        // so absence here means "confirmation hasn't run yet in this request"
        // OR "confirmation never ran at all" (admin opted to suppress it).
        if ( get_transient( 'titancart_order_confirm_sent_' . $order_id ) ) {
            $this->send_downloads_ready( $order_id, $issued_map, false );
            return;
        }

        // Sync-gateway path — stash for inline embed by send_order_confirmation.
        self::$pending_downloads[ $order_id ] = $issued_map;
    }

    /**
     * Send the standalone "Downloads Ready" email.
     *
     * Used in two scenarios:
     *   - Async gateway capture arrived after the original confirmation email
     *     had already gone out without download links ($is_resend = false).
     *   - Admin clicked "Resend downloads email" on the order detail page
     *     ($is_resend = true).
     *
     * Returns true on send success, false otherwise (filtered off, no billing
     * email, hydration empty, wp_mail failure). The admin REST handler
     * propagates this to the JS so the toast can reflect the actual outcome.
     *
     * @param  int    $order_id
     * @param  array  $issued_map  key_id → { token, key_id, file_id, order_item_id }
     * @param  bool   $is_resend   true when triggered by admin resend, false for async first delivery.
     * @return bool
     */
    public function send_downloads_ready( int $order_id, array $issued_map, bool $is_resend = false ): bool {
        if ( $order_id <= 0 || empty( $issued_map ) ) {
            return false;
        }

        // Load the full hydrated order.
        $order_manager = new OrderManager();
        $order         = $order_manager->get_order( $order_id );
        if ( ! $order ) {
            titancart_log( sprintf( 'TitanCart Mailer: send_downloads_ready could not load order #%d', $order_id ) );
            return false;
        }

        /**
         * Filter whether the downloads-ready email should be sent.
         *
         * @param bool  $send       Default true.
         * @param array $order
         * @param bool  $is_resend
         */
        if ( ! apply_filters( 'titancart_send_downloads_ready_email', true, $order, $is_resend ) ) {
            return false;
        }

        $to = sanitize_email( $order['billing_email'] ?? '' );
        if ( ! is_email( $to ) ) {
            return false;
        }

        $download_groups = $this->hydrate_download_groups( $order_id, $issued_map );
        if ( empty( $download_groups ) ) {
            // No downloadable line items resolved (admin disabled all files
            // between issuance and email-compose, or some other race). Nothing
            // useful to send.
            return false;
        }

        $subject = apply_filters(
            'titancart_downloads_ready_subject',
            $is_resend
                ? sprintf(
                    /* translators: 1: site name, 2: order number */
                    __( '[%1$s] New download links for order %2$s', 'titancart-lite' ),
                    $this->site_name,
                    $order['order_number']
                )
                : sprintf(
                    /* translators: 1: site name, 2: order number */
                    __( '[%1$s] Your downloads for order %2$s are ready', 'titancart-lite' ),
                    $this->site_name,
                    $order['order_number']
                ),
            $order,
            $is_resend
        );

        $html = $this->render(
            'emails/downloads-ready.php',
            compact( 'order', 'download_groups', 'is_resend' )
        );

        return $this->send( $to, $subject, $html );
    }

    /**
     * Hydrate the issued-keys map into render-ready download groups.
     *
     * The hook payload (`$issued_map`) carries the raw tokens (the only place
     * they flow), but does not carry file metadata, product names, or expiry
     * info — those live in `tc_downloadable_keys` joined to
     * `tc_downloadable_files` and `tc_order_items`. This method bridges the
     * two: queries the DB rows for the keys we just issued, then weaves the
     * raw tokens back in to construct full download URLs.
     *
     * Output shape (matches the contract documented in
     * templates/emails/parts/downloads-block.php):
     *
     *   [
     *     [
     *       'product_name'   => string,
     *       'order_item_id'  => int,
     *       'files'          => [
     *         [
     *           'file_name'     => string,
     *           'file_size'     => int,
     *           'mime'          => string,
     *           'url'           => string,
     *           'expires_at'    => string|null,
     *           'max_downloads' => int|null,
     *           'file_status'   => string,
     *         ],
     *         ...
     *       ],
     *     ],
     *     ...
     *   ]
     *
     * Disabled or expired keys filtered out — the email shouldn't surface
     * dead links. (Active keys that point at a file row whose status is
     * 'disabled' are kept and rendered with a "currently unavailable"
     * notice — see the partial.)
     *
     * @param  int   $order_id
     * @param  array $issued_map  key_id → { token, key_id, file_id, order_item_id }
     * @return array<int, array{product_name:string, order_item_id:int, files:array<int,array>}>
     */
    private function hydrate_download_groups( int $order_id, array $issued_map ): array {
        if ( empty( $issued_map ) ) {
            return [];
        }

        // Pull every key on the order (joined with file + order_item info).
        // We then filter to just the ones in $issued_map.
        $repo = new DownloadableRepository();
        $rows = $repo->get_keys_for_order_admin( $order_id );

        if ( ! $rows ) {
            return [];
        }

        // Index by key_id for O(1) lookup against $issued_map.
        $rows_by_id = [];
        foreach ( $rows as $row ) {
            $rows_by_id[ (int) $row['id'] ] = $row;
        }

        // Group by order_item_id, preserving order_item_id ordering for stable
        // rendering across re-sends.
        $groups = [];
        foreach ( $issued_map as $key_id => $issued_meta ) {
            $key_id = (int) $key_id;
            $row    = $rows_by_id[ $key_id ] ?? null;
            if ( ! $row ) {
                // Issued key vanished from DB between hook fire and now — skip
                // silently. (Only happens if some external process deleted it.)
                continue;
            }

            // Skip non-active keys defensively. Issuance always writes 'active',
            // but a re-issue race could in theory revoke between fire and read.
            if ( ( $row['status'] ?? 'active' ) !== 'active' ) {
                continue;
            }

            $order_item_id = (int) $row['order_item_id'];
            $product_name  = (string) ( $row['product_name'] ?? '' );
            if ( $product_name === '' ) {
                $product_name = sprintf(
                    /* translators: %d: product id */
                    __( 'Product #%d', 'titancart-lite' ),
                    (int) $row['product_id']
                );
            }

            if ( ! isset( $groups[ $order_item_id ] ) ) {
                $groups[ $order_item_id ] = [
                    'product_name'  => $product_name,
                    'order_item_id' => $order_item_id,
                    'files'         => [],
                ];
            }

            $file_name = (string) ( $row['file_original_name'] ?? '' );
            if ( $file_name === '' ) {
                $file_name = sprintf(
                    /* translators: %d: file id */
                    __( 'File #%d', 'titancart-lite' ),
                    (int) $row['file_id']
                );
            }

            $groups[ $order_item_id ]['files'][] = [
                'file_name'     => $file_name,
                'file_size'     => isset( $row['file_size'] ) ? (int) $row['file_size'] : 0,
                'mime'          => (string) ( $row['file_mime'] ?? '' ),
                'url'           => rest_url( 'titancart/v1/downloads/' . rawurlencode( (string) $issued_meta['token'] ) ),
                'expires_at'    => ! empty( $row['expires_at'] ) ? (string) $row['expires_at'] : null,
                'max_downloads' => ( $row['max_downloads'] !== null && $row['max_downloads'] !== '' )
                    ? (int) $row['max_downloads']
                    : null,
                'file_status'   => (string) ( $row['file_status'] ?? 'active' ),
            ];
        }

        // Re-key as a list (templates iterate sequentially).
        return array_values( $groups );
    }

    // ══════════════════════════════════════════════════════════════════════════
    // TEST EMAILS
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Send a test email with sample data for a given template type.
     *
     * Used by admin to verify email transport (php_mail / SMTP / sendmail)
     * and preview template rendering.
     *
     * @param  string $type  One of: order_confirmation, new_order_admin, order_status_update
     * @param  string $to    Recipient email address.
     * @return array{ success:bool, message:string, method:string }
     */
    public function send_test( string $type, string $to ): array {
        $method = \TitanCart\Services\SettingsService::get( 'emails', 'email_method', 'php_mail' );

        // Build sample order data.
        $order = [
            'id'             => 0,
            'order_number'   => 'TC-TEST-0000',
            'status'         => 'paid',
            'status_handle'  => 'paid',
            'status_label'   => 'Paid',
            'status_color'   => '#16A34A',
            'subtotal'       => '149.98',
            'discount'       => '0.00',
            'shipping_cost'  => '9.99',
            'tax_total'      => '12.00',
            'total'          => '171.97',
            'currency'       => 'USD',
            'created_at'     => current_time( 'mysql' ),
            'billing_first_name'  => 'Jane',
            'billing_last_name'   => 'Smith',
            'billing_email'       => $to,
            'billing_phone'       => '(555) 123-4567',
            'billing_address_1'   => '123 Test Street',
            'billing_city'        => 'Demo City',
            'billing_state'       => 'CA',
            'billing_postcode'    => '90210',
            'billing_country'     => 'US',
            'shipping_first_name' => 'Jane',
            'shipping_last_name'  => 'Smith',
            'shipping_address_1'  => '123 Test Street',
            'shipping_city'       => 'Demo City',
            'shipping_state'      => 'CA',
            'shipping_postcode'   => '90210',
            'shipping_country'    => 'US',
            'payment_label'       => 'Stripe (Test)',
            'shipping_label'      => 'Standard Shipping',
            'customer_notes'      => 'This is a test email — no real order was placed.',
        ];

        $items = [
            [
                'name'            => 'Wireless Headphones Pro',
                'product_name'    => 'Wireless Headphones Pro',
                'sku'             => 'WHP-001',
                'price'           => '99.99',
                'quantity'        => 1,
                'total'           => '99.99',
                'line_total'      => '99.99',
                'options'         => [ [ 'option_name' => 'Color', 'variant_name' => 'Midnight Black' ] ],
                'options_display' => 'Color: Midnight Black',
            ],
            [
                'name'            => 'USB-C Hub Adapter',
                'product_name'    => 'USB-C Hub Adapter',
                'sku'             => 'HUB-042',
                'price'           => '49.99',
                'quantity'        => 1,
                'total'           => '49.99',
                'line_total'      => '49.99',
                'options'         => [],
                'options_display' => '',
            ],
        ];

        $subject_prefix = '[TEST] ';

        switch ( $type ) {
            case 'order_confirmation':
                $subject = $subject_prefix . sprintf(
                    /* translators: 1: order number. 2: store name. */
                    __( 'Order %1$s confirmed — %2$s', 'titancart-lite' ),
                    $order['order_number'],
                    $this->from_name
                );
                $html = $this->render( 'emails/order-confirmation.php', compact( 'order', 'items' ) );
                break;

            case 'new_order_admin':
                $subject = $subject_prefix . sprintf(
                    /* translators: 1: order number. 2: store name. */
                    __( 'New order %1$s — %2$s', 'titancart-lite' ),
                    $order['order_number'],
                    $this->from_name
                );
                $html = $this->render( 'emails/new-order-admin.php', compact( 'order', 'items' ) );
                break;

            case 'order_status_update':
                $old_handle = 'processing';
                $new_label  = 'Shipped';
                $order['status_handle'] = 'shipped';
                $order['status_label']  = 'Shipped';
                $subject = $subject_prefix . sprintf(
                    /* translators: 1: order number. 2: store name. */
                    __( 'Order %1$s updated — %2$s', 'titancart-lite' ),
                    $order['order_number'],
                    $this->from_name
                );
                $html = $this->render( 'emails/order-status-update.php', compact( 'order', 'old_handle', 'new_label' ) );
                break;

            default:
                return [
                    'success' => false,
                    'message' => 'Unknown email type: ' . $type,
                    'method'  => $method,
                ];
        }

        // Capture PHPMailer errors for better diagnostics.
        $mail_error = '';
        $error_cb = function( $wp_error ) use ( &$mail_error ) {
            if ( is_wp_error( $wp_error ) ) {
                $mail_error = $wp_error->get_error_message();
                $data = $wp_error->get_error_data();
                if ( ! empty( $data['phpmailer_exception_code'] ) ) {
                    $mail_error .= ' (code ' . $data['phpmailer_exception_code'] . ')';
                }
            }
        };
        add_action( 'wp_mail_failed', $error_cb );

        $sent = $this->send( $to, $subject, $html );

        remove_action( 'wp_mail_failed', $error_cb );

        $fail_detail = $mail_error !== ''
            ? $mail_error
            : 'wp_mail() returned false. Check server mail log for details.';

        return [
            'success' => $sent,
            'message' => $sent
                ? sprintf( 'Test email (%s) sent to %s via %s.', $type, $to, $method )
                : sprintf( 'Failed: %s (transport: %s)', $fail_detail, $method ),
            'method'  => $method,
        ];
    }

    // ══════════════════════════════════════════════════════════════════════════
    // INTERNAL HELPERS
    // ══════════════════════════════════════════════════════════════════════════

    /**
     * Render an email template to an HTML string, wrapped in the base layout.
     *
     * @param  string $template  Relative path under templates/ (e.g. 'emails/order-confirmation.php').
     * @param  array  $vars      Variables available in the template.
     * @return string
     */
    private function render( string $template, array $vars = [] ): string {
        // Add common variables available in every email template.
        $vars = array_merge( $vars, [
            'site_name'       => $this->site_name,
            'site_url'        => home_url(),
            'logo_url'        => apply_filters( 'titancart_email_logo_url', '' ),
            'accent_color'    => apply_filters( 'titancart_email_accent_color', '#1F6AE1' ),
            'company_name'    => $this->from_name,
            'company_email'   => $this->from_email,
            'company_address' => $this->company_address,
            'company_phone'   => $this->company_phone,
            'company_phone_2' => $this->company_phone_2,
            'company_website' => $this->company_website,
            'company_year'    => $this->company_year,
            'mailer'          => $this,
        ] );

        // Render the inner content.
        $inner = TemplateLoader::get_template( $template, $vars, true );

        // Wrap in base layout.
        $vars['email_content'] = $inner;
        return TemplateLoader::get_template( 'emails/base.php', $vars, true );
    }

    /**
     * Dispatch the email via wp_mail().
     *
     * @param  string $to       Recipient email.
     * @param  string $subject  Email subject.
     * @param  string $html     HTML body.
     * @param  array  $headers  Additional wp_mail headers.
     * @return bool
     */
    private function send( string $to, string $subject, string $html, array $headers = [] ): bool {
        $from     = sprintf( 'From: %s <%s>', $this->from_name, $this->from_email );
        $headers  = array_merge( [ $from, 'Content-Type: text/html; charset=UTF-8' ], $headers );

        /**
         * Filter the compiled headers before sending.
         *
         * @param array  $headers
         * @param string $to
         * @param string $subject
         */
        $headers = apply_filters( 'titancart_email_headers', $headers, $to, $subject );

        add_filter( 'wp_mail_content_type', [ $this, 'set_html_content_type' ] );
        $sent = wp_mail( $to, $subject, $html, $headers );
        remove_filter( 'wp_mail_content_type', [ $this, 'set_html_content_type' ] );

        if ( ! $sent ) {
            titancart_log( sprintf( 'TitanCart Mailer: wp_mail() failed for %s — Subject: %s', $to, $subject ) );
        }

        return $sent;
    }

    /**
     * Force wp_mail to use text/html content type during our sends.
     *
     * @return string
     */
    public function set_html_content_type(): string {
        return 'text/html';
    }

    /**
     * Format a money amount for display in emails.
     *
     * @param  string|float $amount
     * @param  string       $currency  3-letter currency code.
     * @return string
     */
    public function format_money( string|float $amount, string $currency = 'USD' ): string {
        return apply_filters(
            'titancart_format_money_email',
            number_format( (float) $amount, 2 ) . ' ' . strtoupper( $currency ),
            $amount,
            $currency
        );
    }

    /**
     * Get the admin email address to send new-order notifications to.
     *
     * @return string
     */
    private function get_admin_email(): string {
        return (string) apply_filters(
            'titancart_admin_order_notification_email',
            get_option( 'admin_email' )
        );
    }
}
