<?php
/**
 * Email Manager module.
 *
 * Central hub for all WooCommerce email management: overview dashboard,
 * global settings (from/reply-to/BCC), per-email overrides, template
 * selection, and custom email creation. The builder UI lives in a
 * separate class (Email_Builder).
 *
 * @package Kramar
 * @since   1.0.0
 */

declare( strict_types=1 );

namespace Kramar\Modules\Email;

// Prevent direct access.
defined( 'ABSPATH' ) || exit;

/**
 * Email Manager module.
 *
 * @since 1.0.0
 */
final class Email_Manager extends \Kramar\Module {

	private const PAGE_SLUG          = 'kramar-emails';
	private const BUILDER_PAGE_SLUG  = 'kramar-email-builder';
	private const OPTION_GLOBAL     = 'kramar_email_settings';
	private const OPTION_OVERRIDES  = 'kramar_email_overrides';
	private const OPTION_CUSTOM     = 'kramar_custom_emails';
	private const OPTION_BUSINESS   = 'kramar_business_details';
	private const NONCE_ACTION      = 'kramar_email_manager';

	private string $overview_hook = '';
	private string $builder_hook  = '';

	/** @var array<string, mixed>|null Cached global settings. */
	private ?array $global_settings = null;

	/** @var array<string, mixed>|null Cached business details. */
	private ?array $business_details = null;

	public function get_slug(): string {
		return 'email-manager';
	}

	public function get_category(): string {
		return 'email';
	}

	public function get_name(): string {
		return __( 'Email Manager', 'kramar' );
	}

	public function get_description(): string {
		return __( 'Complete email management dashboard: customize all WooCommerce emails, create custom emails, drag-and-drop builder, live preview, EU/NL compliant templates.', 'kramar' );
	}

	public function get_default_settings(): array {
		return [
			'enabled'          => false,
			'active_template'  => 'modern',
		];
	}

	public function sanitize_settings( array $input ): array {
		$templates = [ 'modern', 'minimal', 'classic', 'default' ];
		$active    = $input['active_template'] ?? 'modern';

		return [
			'enabled'         => ! empty( $input['enabled'] ),
			'active_template' => in_array( $active, $templates, true ) ? $active : 'modern',
		];
	}

	// ─── Bootstrap ──────────────────────────────────────────────────────

	/**
	 * Register the module.
	 *
	 * The Email Sender + Business Details forms live on the always-visible
	 * Kramar › Settings page and their values feed PDF invoices as well as
	 * emails, so their save handlers must work even when this module is
	 * disabled. Registering them here (rather than in run()) keeps them wired
	 * up regardless of the enabled/available state.
	 *
	 * @since 1.0.0
	 */
	public function register(): void {
		add_action( 'wp_ajax_kramar_email_save_global', [ $this, 'ajax_save_global_settings' ] );
		add_action( 'wp_ajax_kramar_email_save_business', [ $this, 'ajax_save_business_details' ] );

		parent::register();
	}

	public function run(): void {
		// Consolidate any legacy logo into the single global Business Details logo.
		$this->maybe_migrate_logo();

		// Remove orphaned pre-rename "toko_" custom emails / overrides so a store
		// migrated from Toko doesn't double-send withdrawal/marketing emails.
		$this->maybe_cleanup_legacy_toko_emails();

		// Admin pages.
		add_action( 'admin_menu', [ $this, 'register_admin_pages' ] );
		add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_overview_assets' ] );
		add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_builder_assets' ] );
		add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_order_assets' ] );

		// The builder lives under Kramar but has no menu item of its own. Hide its
		// <li> with CSS and point the active highlight at the Emails item.
		add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_builder_hide_style' ] );
		add_filter( 'submenu_file', [ $this, 'highlight_emails_submenu' ], 999 );

		// AJAX handlers. (save_global + save_business are registered in
		// register() so they work even when this module is disabled.)
		add_action( 'wp_ajax_kramar_email_toggle', [ $this, 'ajax_toggle_email' ] );
		add_action( 'wp_ajax_kramar_email_save_blocks', [ $this, 'ajax_save_blocks' ] );
		add_action( 'wp_ajax_kramar_email_render', [ $this, 'ajax_render' ] );
		add_action( 'wp_ajax_kramar_email_preview', [ $this, 'ajax_preview' ] );
		add_action( 'wp_ajax_kramar_email_test_send', [ $this, 'ajax_test_send' ] );
		add_action( 'wp_ajax_kramar_email_create', [ $this, 'ajax_create_email' ] );
		add_action( 'wp_ajax_kramar_email_delete', [ $this, 'ajax_delete_email' ] );

		// Replace WC email body with builder blocks output.
		if ( 'default' !== $this->get_active_template() ) {
			add_filter( 'woocommerce_mail_callback_params', [ $this, 'replace_email_content' ], 10, 2 );
		}

		// Global from/reply-to/BCC overrides.
		add_filter( 'woocommerce_email_from_name', [ $this, 'filter_from_name' ], 10, 2 );
		add_filter( 'woocommerce_email_from_address', [ $this, 'filter_from_address' ], 10, 2 );
		add_filter( 'woocommerce_email_headers', [ $this, 'filter_email_headers' ], 10, 3 );

		// Per-email subject/heading overrides.
		add_filter( 'woocommerce_email_subject_new_order', [ $this, 'filter_subject' ], 10, 3 );
		add_filter( 'woocommerce_email_subject_cancelled_order', [ $this, 'filter_subject' ], 10, 3 );
		add_filter( 'woocommerce_email_subject_failed_order', [ $this, 'filter_subject' ], 10, 3 );
		add_filter( 'woocommerce_email_subject_customer_on_hold_order', [ $this, 'filter_subject' ], 10, 3 );
		add_filter( 'woocommerce_email_subject_customer_processing_order', [ $this, 'filter_subject' ], 10, 3 );
		add_filter( 'woocommerce_email_subject_customer_completed_order', [ $this, 'filter_subject' ], 10, 3 );
		add_filter( 'woocommerce_email_subject_customer_refunded_order', [ $this, 'filter_subject' ], 10, 3 );
		add_filter( 'woocommerce_email_subject_customer_invoice', [ $this, 'filter_subject' ], 10, 3 );
		add_filter( 'woocommerce_email_subject_customer_note', [ $this, 'filter_subject' ], 10, 3 );
		add_filter( 'woocommerce_email_subject_customer_reset_password', [ $this, 'filter_subject' ], 10, 3 );
		add_filter( 'woocommerce_email_subject_customer_new_account', [ $this, 'filter_subject' ], 10, 3 );

		// Emails registered by Kramar's other modules — back-in-stock, abandoned
		// cart, review reminder and the withdrawal flow — add their own WC_Email
		// subclass through woocommerce_email_classes and never pass through
		// register_custom_emails(), so no subject filter was ever hooked for
		// them. The builder let an admin write a subject, saved it, showed it
		// back on reload, and the send used the hardcoded default regardless.
		// The body was applied, which is what made it look like it worked.
		add_filter( 'woocommerce_email_classes', [ $this, 'hook_module_email_subjects' ], 99 );

		// Honor the per-email enable/disable toggle for core WooCommerce emails.
		$this->apply_core_email_toggles();

		// Register custom emails.
		add_filter( 'woocommerce_email_classes', [ $this, 'register_custom_emails' ] );

		// Wire status-change (and future) triggers for custom emails.
		( new Custom_Email_Triggers( $this ) )->register();

		// Action Scheduler callback for delayed custom emails.
		add_action( 'kramar_send_custom_email', [ $this, 'run_scheduled_custom_email' ], 10, 2 );

		// Order edit screen: "Send now" metabox (HPOS + legacy CPT).
		add_action( 'add_meta_boxes', [ $this, 'register_order_send_metabox' ] );
		add_action( 'wp_ajax_kramar_send_custom_email_now', [ $this, 'ajax_send_custom_email_now' ] );
	}

	// ─── Admin Pages ────────────────────────────────────────────────────

	public function register_admin_pages(): void {
		$this->overview_hook = (string) add_submenu_page(
			'kramar',
			__( 'Emails', 'kramar' ),
			__( 'Emails', 'kramar' ),
			'manage_options',
			self::PAGE_SLUG,
			[ $this, 'render_overview_page' ]
		);

		// Registered as a real submenu of Kramar so it stays accessible AND the
		// parent menu highlights naturally (get_admin_page_parent() resolves it
		// to 'kramar'). The menu <li> itself is hidden with CSS (see
		// hide_builder_menu_item) — a null parent would break menu highlighting,
		// and remove_submenu_page() would break access.
		$this->builder_hook = (string) add_submenu_page(
			'kramar',
			__( 'Edit Email', 'kramar' ),
			__( 'Edit Email', 'kramar' ),
			'manage_options',
			self::BUILDER_PAGE_SLUG,
			[ $this, 'render_builder_page' ]
		);
	}

	/**
	 * Keep Kramar › Emails highlighted while the (hidden) builder page is open.
	 *
	 * @since 1.0.0
	 */
	/**
	 * Enqueue an inline style to hide the builder submenu item on all admin pages.
	 * Replaces the former admin_head echo approach for Plugin Check compliance.
	 *
	 * @since 1.0.0
	 *
	 * @param string $hook_suffix Current admin page hook suffix.
	 */
	public function enqueue_builder_hide_style( string $hook_suffix ): void {
		// Load on every admin page (menu is always visible), but use a minimal handle.
		wp_register_style( 'kramar-admin-hide-builder', false, [], KRAMAR_VERSION );
		wp_enqueue_style( 'kramar-admin-hide-builder' );
		wp_add_inline_style(
			'kramar-admin-hide-builder',
			'#adminmenu .wp-submenu li:has(> a[href*="page=' . esc_attr( self::BUILDER_PAGE_SLUG ) . '"]){display:none;}'
		);
	}

	/**
	 * Enqueue assets for the order "Send now" metabox on order-edit screens.
	 *
	 * @since 1.0.0
	 *
	 * @param string $hook_suffix Current admin page hook suffix.
	 */
	public function enqueue_order_assets( string $hook_suffix ): void {
		$order_screens = [ 'shop_order', 'woocommerce_page_wc-orders' ];

		$screen = get_current_screen();
		if ( ! $screen ) {
			return;
		}

		if ( ! in_array( $screen->id, $order_screens, true ) ) {
			return;
		}

		// Resolve order id from the request (both legacy CPT and HPOS).
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only admin page routing
		$order_id = absint( wp_unslash( $_GET['id'] ?? $_GET['post'] ?? 0 ) );

		wp_register_script( 'kramar-order-send', false, [], KRAMAR_VERSION, true );
		wp_enqueue_script( 'kramar-order-send' );
		wp_localize_script(
			'kramar-order-send',
			'kramarOrderSend',
			[
				'ajaxUrl' => admin_url( 'admin-ajax.php' ),
				'nonce'   => wp_create_nonce( 'kramar_send_custom_email_now' ),
				'orderId' => (string) $order_id,
				'i18n'    => [
					'sending'   => __( 'Sending…', 'kramar' ),
					'sent'      => __( 'Email sent!', 'kramar' ),
					'failed'    => __( 'Failed to send.', 'kramar' ),
					'error'     => __( 'Error — could not reach the server.', 'kramar' ),
					'send_now'  => __( 'Send now', 'kramar' ),
				],
			]
		);
		wp_add_inline_script(
			'kramar-order-send',
			'( function () {
	var btn = document.getElementById( \'kramar-send-email-btn\' );
	var sel = document.getElementById( \'kramar-send-email-select\' );
	var msg = document.getElementById( \'kramar-send-email-msg\' );
	if ( ! btn || ! sel || ! msg ) { return; }
	var cfg = kramarOrderSend;
	btn.addEventListener( \'click\', function () {
		btn.disabled = true;
		btn.textContent = cfg.i18n.sending;
		msg.style.display = \'none\';
		var body = new FormData();
		body.append( \'action\', \'kramar_send_custom_email_now\' );
		body.append( \'_ajax_nonce\', cfg.nonce );
		body.append( \'order_id\', cfg.orderId );
		body.append( \'email_id\', sel.value );
		fetch( cfg.ajaxUrl, { method: \'POST\', body: body } )
			.then( function ( r ) { return r.json(); } )
			.then( function ( r ) {
				if ( r && r.success ) {
					msg.style.color = \'#008a20\';
					msg.textContent = cfg.i18n.sent;
				} else {
					msg.style.color = \'#cc1818\';
					msg.textContent = cfg.i18n.failed;
				}
				msg.style.display = \'block\';
				btn.disabled = false;
				btn.textContent = cfg.i18n.send_now;
			} )
			.catch( function () {
				msg.style.color = \'#cc1818\';
				msg.textContent = cfg.i18n.error;
				msg.style.display = \'block\';
				btn.disabled = false;
				btn.textContent = cfg.i18n.send_now;
			} );
	} );
} )();'
		);
	}

	/**
	 * Highlight the Emails submenu item while the builder is open.
	 *
	 * @since 1.0.0
	 */
	public function highlight_emails_submenu( ?string $submenu_file ): ?string {
		return $this->is_builder_page() ? self::PAGE_SLUG : $submenu_file;
	}

	/**
	 * @since 1.0.0
	 */
	private function is_builder_page(): bool {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only admin page routing
		return self::BUILDER_PAGE_SLUG === sanitize_text_field( wp_unslash( $_GET['page'] ?? '' ) );
	}

	public function enqueue_overview_assets( string $hook ): void {
		if ( $hook !== $this->overview_hook ) {
			return;
		}

		wp_enqueue_style(
			'kramar-email-manager',
			KRAMAR_URL . 'assets/css/email-manager.css',
			[],
			(string) filemtime( KRAMAR_DIR . 'assets/css/email-manager.css' )
		);

		wp_enqueue_script(
			'kramar-email-manager',
			KRAMAR_URL . 'assets/js/email-manager.js',
			[],
			(string) filemtime( KRAMAR_DIR . 'assets/js/email-manager.js' ),
			true
		);

		wp_localize_script( 'kramar-email-manager', 'kramarEmailManager', [
			'ajaxUrl' => admin_url( 'admin-ajax.php' ),
			'nonce'   => wp_create_nonce( self::NONCE_ACTION ),
			'i18n'    => [
				'confirmDelete' => __( 'Delete this custom email? This cannot be undone.', 'kramar' ),
				'testSent'      => __( 'Test email sent!', 'kramar' ),
				'testFailed'    => __( 'Failed to send test email.', 'kramar' ),
				'saved'         => __( 'Settings saved.', 'kramar' ),
			],
		] );
	}

	public function enqueue_builder_assets( string $hook ): void {
		if ( $hook !== $this->builder_hook ) {
			return;
		}

		wp_enqueue_media();
		wp_enqueue_style( 'dashicons' );

		wp_enqueue_style(
			'kramar-email-builder',
			KRAMAR_URL . 'assets/css/email-builder.css',
			[],
			KRAMAR_VERSION . '.' . (string) filemtime( KRAMAR_DIR . 'assets/css/email-builder.css' ) . '.' . wp_rand()
		);

		wp_enqueue_script(
			'kramar-email-builder',
			KRAMAR_URL . 'assets/js/email-builder.js',
			[],
			KRAMAR_VERSION . '.' . (string) filemtime( KRAMAR_DIR . 'assets/js/email-builder.js' ) . '.' . wp_rand(),
			true
		);

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$email_id    = sanitize_key( wp_unslash( $_GET['email_id'] ?? '' ) );
		$is_template = '_template' === $email_id;

		wp_localize_script( 'kramar-email-builder', 'kramarEmailBuilder', [
			'ajaxUrl'         => admin_url( 'admin-ajax.php' ),
			'nonce'           => wp_create_nonce( self::NONCE_ACTION ),
			'emailId'         => $email_id,
			'isTemplate'      => $is_template,
			'overviewUrl'     => admin_url( 'admin.php?page=' . self::PAGE_SLUG ),
			'blocks'          => $is_template ? $this->get_template_blocks_for_builder() : $this->get_email_blocks( $email_id ),
			'emailConfig'     => $is_template ? [ 'title' => __( 'Email Template', 'kramar' ) ] : $this->get_email_config( $email_id ),
			'mergeFields'     => $is_template ? [] : $this->get_merge_fields_for_context( $this->get_email_context( $email_id ) ),
			'globalSettings'  => $this->get_global_settings(),
			'brandLogo'       => $this->get_brand_logo(),
			'settingsUrl'     => admin_url( 'admin.php?page=kramar-settings' ),
			'blockTypes'      => $is_template ? $this->get_template_block_types() : $this->get_block_type_definitions(),
			'i18n'            => [
				'saved'            => __( 'Changes saved.', 'kramar' ),
				'unsavedChanges'   => __( 'You have unsaved changes. Leave anyway?', 'kramar' ),
				'clickToEdit'      => __( 'Click to Edit', 'kramar' ),
				'dragToReorder'    => __( 'Drag to Reorder', 'kramar' ),
				'dropHere'         => __( 'Drop block here', 'kramar' ),
				'noBlockSelected'  => __( 'Select a block to edit its settings.', 'kramar' ),
				'insertMergeField' => __( 'Insert merge field', 'kramar' ),
				'duplicate'        => __( 'Duplicate', 'kramar' ),
				'deleteBlock'      => __( 'Delete', 'kramar' ),
				'emailContent'     => __( 'Email content', 'kramar' ),
				'rendering'        => __( 'Rendering…', 'kramar' ),
				'emptyBlock'       => __( 'nothing to show for this order', 'kramar' ),
				'emailPreview'     => __( 'Email Preview', 'kramar' ),
				'loadingPreview'   => __( 'Loading preview…', 'kramar' ),
				'desktop'          => __( 'Desktop', 'kramar' ),
				'mobile'           => __( 'Mobile', 'kramar' ),
				'close'            => __( 'Close', 'kramar' ),
				'sendTestEmail'    => __( 'Send Test Email', 'kramar' ),
				'testRecipientHint' => __( 'Enter recipient address(es), comma-separated.', 'kramar' ),
				'sendTest'         => __( 'Send Test', 'kramar' ),
				'sending'          => __( 'Sending…', 'kramar' ),
				'cancel'           => __( 'Cancel', 'kramar' ),
			],
		] );
	}

	// ─── Overview Page ──────────────────────────────────────────────────

	public function render_overview_page(): void {
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'Unauthorized.', 'kramar' ) );
		}

		$emails = $this->get_all_emails();

		?>
		<div class="wrap kramar-em-wrap">
			<div class="kramar-em-header">
				<h1><?php esc_html_e( 'Email Manager', 'kramar' ); ?></h1>
				<div class="kramar-em-header__actions">
					<a href="<?php echo esc_url( admin_url( 'admin.php?page=kramar-settings' ) ); ?>" class="button kramar-em-header__btn"><span class="dashicons dashicons-admin-generic"></span><?php esc_html_e( 'Settings', 'kramar' ); ?></a>
					<a href="<?php echo esc_url( admin_url( 'admin.php?page=' . self::BUILDER_PAGE_SLUG . '&email_id=_template' ) ); ?>" class="button kramar-em-header__btn"><span class="dashicons dashicons-edit"></span><?php esc_html_e( 'Edit Header & Footer', 'kramar' ); ?></a>
					<a href="#" class="button button-primary kramar-em-add-new" data-action="add-email"><?php esc_html_e( 'Add New Email', 'kramar' ); ?></a>
				</div>
			</div>

			<?php $this->render_emails_tab( $emails ); ?>
		</div>

		<?php $this->render_preview_modal(); ?>
		<?php $this->render_test_send_dialog(); ?>
		<?php $this->render_new_email_dialog(); ?>
		<?php
	}

	// ─── Emails Tab ─────────────────────────────────────────────────────

	/**
	 * @param array<string, array<string, mixed>> $emails
	 */
	private function render_emails_tab( array $emails ): void {
		$groups = [
			'woocommerce' => [ 'label' => __( 'WooCommerce Core', 'kramar' ), 'emails' => [] ],
			'kramar'        => [ 'label' => __( 'Kramar Modules', 'kramar' ), 'emails' => [] ],
			'custom'      => [ 'label' => __( 'Custom Emails', 'kramar' ), 'emails' => [] ],
			'other'       => [ 'label' => __( 'Other Plugins', 'kramar' ), 'emails' => [] ],
		];

		foreach ( $emails as $id => $email ) {
			$source = $email['source'] ?? 'other';
			if ( isset( $groups[ $source ] ) ) {
				$groups[ $source ]['emails'][ $id ] = $email;
			} else {
				$groups['other']['emails'][ $id ] = $email;
			}
		}

		?>
		<div class="kramar-em-emails-tab">
			<?php foreach ( $groups as $group_key => $group ) : ?>
				<?php if ( empty( $group['emails'] ) ) continue; ?>

				<h3 class="kramar-em-group-heading"><?php echo esc_html( $group['label'] ); ?></h3>

				<div class="kramar-em-table-wrap">
				<table class="kramar-em-table widefat striped">
					<thead>
						<tr>
							<th class="kramar-em-col-toggle"><?php esc_html_e( 'Enabled', 'kramar' ); ?></th>
							<th class="kramar-em-col-name"><?php esc_html_e( 'Email', 'kramar' ); ?></th>
							<th class="kramar-em-col-recipient"><?php esc_html_e( 'Recipient', 'kramar' ); ?></th>
							<th class="kramar-em-col-trigger"><?php esc_html_e( 'Trigger', 'kramar' ); ?></th>
							<th class="kramar-em-col-subject"><?php esc_html_e( 'Subject', 'kramar' ); ?></th>
							<th class="kramar-em-col-actions"><?php esc_html_e( 'Actions', 'kramar' ); ?></th>
						</tr>
					</thead>
					<tbody>
						<?php foreach ( $group['emails'] as $id => $email ) : ?>
							<tr data-email-id="<?php echo esc_attr( $id ); ?>">
								<td class="kramar-em-col-toggle">
									<label class="kramar-em-toggle">
										<input type="checkbox"
											   data-email-id="<?php echo esc_attr( $id ); ?>"
											   <?php checked( $email['enabled'] ?? true ); ?> />
										<span class="kramar-em-toggle__slider"></span>
									</label>
								</td>
								<td class="kramar-em-col-name">
									<strong><?php echo esc_html( $email['title'] ?? $id ); ?></strong>
									<span class="kramar-em-email-desc"><?php echo esc_html( $email['description'] ?? '' ); ?></span>
								</td>
								<td class="kramar-em-col-recipient">
									<?php $this->render_recipient_badge( $email ); ?>
								</td>
								<td class="kramar-em-col-trigger">
									<span class="kramar-em-trigger-text"><?php echo esc_html( $email['trigger_description'] ?? '' ); ?></span>
								</td>
								<td class="kramar-em-col-subject">
									<span class="kramar-em-subject-text" title="<?php echo esc_attr( $email['subject'] ?? '' ); ?>">
										<?php echo esc_html( $email['subject'] ?? '' ); ?>
									</span>
								</td>
								<td class="kramar-em-col-actions">
									<a href="<?php echo esc_url( admin_url( 'admin.php?page=' . self::BUILDER_PAGE_SLUG . '&email_id=' . $id ) ); ?>"
									   class="kramar-em-action" title="<?php esc_attr_e( 'Edit', 'kramar' ); ?>">
										<span class="dashicons dashicons-edit"></span>
									</a>
									<button type="button" class="kramar-em-action kramar-em-action--preview"
											data-email-id="<?php echo esc_attr( $id ); ?>"
											title="<?php esc_attr_e( 'Preview', 'kramar' ); ?>">
										<span class="dashicons dashicons-visibility"></span>
									</button>
									<button type="button" class="kramar-em-action kramar-em-action--test"
											data-email-id="<?php echo esc_attr( $id ); ?>"
											title="<?php esc_attr_e( 'Send Test', 'kramar' ); ?>">
										<span class="dashicons dashicons-email"></span>
									</button>
									<?php if ( 'custom' === $group_key ) : ?>
										<button type="button" class="kramar-em-action kramar-em-action--delete"
												data-email-id="<?php echo esc_attr( $id ); ?>"
												title="<?php esc_attr_e( 'Delete', 'kramar' ); ?>">
											<span class="dashicons dashicons-trash"></span>
										</button>
									<?php endif; ?>
								</td>
							</tr>
						<?php endforeach; ?>
					</tbody>
				</table>
				</div>

			<?php endforeach; ?>
		</div>
		<?php
	}

	/**
	 * @param array<string, mixed> $email
	 */
	private function render_recipient_badge( array $email ): void {
		$type = $email['recipient_type'] ?? 'customer';
		$classes = [
			'customer' => 'kramar-em-badge--customer',
			'admin'    => 'kramar-em-badge--admin',
			'custom'   => 'kramar-em-badge--custom',
		];
		$labels = [
			'customer' => __( 'Customer', 'kramar' ),
			'admin'    => __( 'Admin', 'kramar' ),
			'custom'   => $email['recipient'] ?? __( 'Custom', 'kramar' ),
		];

		$class = $classes[ $type ] ?? 'kramar-em-badge--custom';
		$label = $labels[ $type ] ?? $type;

		printf( '<span class="kramar-em-badge %s">%s</span>', esc_attr( $class ), esc_html( $label ) );
	}

	// ─── Global Settings Tab ────────────────────────────────────────────

	/**
	 * @param array<string, mixed> $settings
	 */

	// ─── Business Details (old tab removed, merged into Settings) ──────

	// ─── Modals ─────────────────────────────────────────────────────────

	private function render_preview_modal(): void {
		?>
		<div class="kramar-em-preview-modal" id="kramar-em-preview-modal" style="display:none;">
			<div class="kramar-em-preview-modal__overlay"></div>
			<div class="kramar-em-preview-modal__content">
				<div class="kramar-em-preview-modal__toolbar">
					<span class="kramar-em-preview-modal__title"></span>
					<div class="kramar-em-preview-modal__device-toggle">
						<button type="button" class="active" data-device="desktop">
							<span class="dashicons dashicons-desktop"></span>
						</button>
						<button type="button" data-device="mobile">
							<span class="dashicons dashicons-smartphone"></span>
						</button>
					</div>
					<button type="button" class="kramar-em-preview-modal__close">
						<span class="dashicons dashicons-no-alt"></span>
					</button>
				</div>
				<div class="kramar-em-preview-modal__iframe-wrap">
					<iframe class="kramar-em-preview-modal__iframe" sandbox="allow-same-origin"></iframe>
				</div>
			</div>
		</div>
		<?php
	}

	private function render_test_send_dialog(): void {
		?>
		<div class="kramar-em-test-dialog" id="kramar-em-test-dialog" style="display:none;">
			<div class="kramar-em-test-dialog__overlay"></div>
			<div class="kramar-em-test-dialog__content">
				<h3><?php esc_html_e( 'Send Test Email', 'kramar' ); ?></h3>
				<p><?php esc_html_e( 'Enter one or more email addresses (comma-separated).', 'kramar' ); ?></p>
				<input type="text" class="regular-text kramar-em-test-dialog__input"
					   value="<?php echo esc_attr( get_option( 'admin_email' ) ); ?>" />
				<input type="hidden" class="kramar-em-test-dialog__email-id" value="" />
				<div class="kramar-em-test-dialog__actions">
					<button type="button" class="button button-primary kramar-em-test-dialog__send">
						<?php esc_html_e( 'Send Test', 'kramar' ); ?>
					</button>
					<button type="button" class="button kramar-em-test-dialog__cancel">
						<?php esc_html_e( 'Cancel', 'kramar' ); ?>
					</button>
				</div>
				<div class="kramar-em-test-dialog__result" style="display:none;"></div>
			</div>
		</div>
		<?php
	}

	private function render_new_email_dialog(): void {
		$statuses = wc_get_order_statuses();
		?>
		<div class="kramar-em-new-email" id="kramar-em-new-email" style="display:none;">
			<div class="kramar-em-new-email__overlay"></div>
			<div class="kramar-em-new-email__content">
				<h3><?php esc_html_e( 'Create New Email', 'kramar' ); ?></h3>

				<div class="kramar-em-new-email__step kramar-em-new-email__step--context active">
					<p><?php esc_html_e( 'What type of email is this?', 'kramar' ); ?></p>
					<div class="kramar-em-context-cards">
						<button type="button" class="kramar-em-context-card" data-context="order">
							<span class="dashicons dashicons-cart"></span>
							<strong><?php esc_html_e( 'Order', 'kramar' ); ?></strong>
							<span><?php esc_html_e( 'Triggered by order events. Access to all order, customer, payment, and shipping fields.', 'kramar' ); ?></span>
						</button>
						<button type="button" class="kramar-em-context-card" data-context="account">
							<span class="dashicons dashicons-admin-users"></span>
							<strong><?php esc_html_e( 'Account', 'kramar' ); ?></strong>
							<span><?php esc_html_e( 'Triggered by account events. Access to user name, email, and account fields.', 'kramar' ); ?></span>
						</button>
						<button type="button" class="kramar-em-context-card" data-context="general">
							<span class="dashicons dashicons-store"></span>
							<strong><?php esc_html_e( 'General', 'kramar' ); ?></strong>
							<span><?php esc_html_e( 'Not tied to an order or account. Site and business fields only.', 'kramar' ); ?></span>
						</button>
					</div>
				</div>

				<div class="kramar-em-new-email__step kramar-em-new-email__step--details" style="display:none;">
					<div class="kramar-em-new-email__field">
						<label for="kramar-new-email-name" style="display:block;font-weight:600;font-size:13px;color:#111;margin-bottom:6px;"><?php esc_html_e( 'Email name', 'kramar' ); ?></label>
						<input type="text" id="kramar-new-email-name" name="email_name" required
							   placeholder="<?php esc_attr_e( 'e.g. Order Packed Notification', 'kramar' ); ?>"
							   style="width:100%;padding:10px 12px;border:1px solid #e4e4e7;border-radius:6px;font-size:14px;" />
						<p style="margin:4px 0 0;font-size:12px;color:#a0aec0;"><?php esc_html_e( 'A descriptive name for this email — shown in the overview and WC settings.', 'kramar' ); ?></p>
					</div>

					<div class="kramar-new-email-trigger-row kramar-new-email-trigger-row--order kramar-em-new-email__field" style="display:none;margin-top:20px;">
						<label style="display:block;font-weight:600;font-size:13px;color:#111;margin-bottom:6px;"><?php esc_html_e( 'When should this email be sent?', 'kramar' ); ?></label>
						<div style="display:flex;flex-direction:column;gap:10px;">
							<label style="display:flex;align-items:center;gap:8px;font-size:14px;color:#525252;cursor:pointer;">
								<input type="radio" name="trigger_type" value="status_change" checked />
								<?php esc_html_e( 'When order status changes', 'kramar' ); ?>
							</label>
							<div class="kramar-new-email-trigger-subfields kramar-new-email-trigger-subfields--status_change" style="display:flex;align-items:center;gap:8px;margin-left:24px;">
								<select name="trigger_from" style="padding:8px 10px;border:1px solid #e4e4e7;border-radius:6px;font-size:13px;">
									<option value=""><?php esc_html_e( 'Any status', 'kramar' ); ?></option>
									<?php foreach ( $statuses as $slug => $label ) : ?>
										<option value="<?php echo esc_attr( $slug ); ?>"><?php echo esc_html( $label ); ?></option>
									<?php endforeach; ?>
								</select>
								<span style="color:#a0aec0;">&rarr;</span>
								<select name="trigger_to" style="padding:8px 10px;border:1px solid #e4e4e7;border-radius:6px;font-size:13px;">
									<?php foreach ( $statuses as $slug => $label ) : ?>
										<option value="<?php echo esc_attr( $slug ); ?>"><?php echo esc_html( $label ); ?></option>
									<?php endforeach; ?>
								</select>
							</div>
							<label style="display:flex;align-items:center;gap:8px;font-size:14px;color:#525252;cursor:pointer;">
								<input type="radio" name="trigger_type" value="delay" />
								<?php esc_html_e( 'A set time after an event', 'kramar' ); ?>
							</label>
							<div class="kramar-new-email-trigger-subfields kramar-new-email-trigger-subfields--delay" style="display:none;align-items:center;gap:8px;margin-left:24px;flex-wrap:wrap;">
								<span style="font-size:13px;color:#525252;"><?php esc_html_e( 'Send', 'kramar' ); ?></span>
								<input type="number" name="delay_value" value="1" min="0"
									   style="width:64px;padding:8px 10px;border:1px solid #e4e4e7;border-radius:6px;font-size:13px;" />
								<select name="delay_unit" style="padding:8px 10px;border:1px solid #e4e4e7;border-radius:6px;font-size:13px;">
									<option value="days"><?php esc_html_e( 'days', 'kramar' ); ?></option>
									<option value="hours"><?php esc_html_e( 'hours', 'kramar' ); ?></option>
								</select>
								<span style="font-size:13px;color:#525252;"><?php esc_html_e( 'after', 'kramar' ); ?></span>
								<select name="anchor" style="padding:8px 10px;border:1px solid #e4e4e7;border-radius:6px;font-size:13px;">
									<option value="completed"><?php esc_html_e( 'Order completed', 'kramar' ); ?></option>
									<option value="paid"><?php esc_html_e( 'Order paid', 'kramar' ); ?></option>
									<option value="placed"><?php esc_html_e( 'Order placed', 'kramar' ); ?></option>
									<option value="status"><?php esc_html_e( 'When the order enters a status…', 'kramar' ); ?></option>
								</select>
								<select name="anchor_status" style="display:none;padding:8px 10px;border:1px solid #e4e4e7;border-radius:6px;font-size:13px;">
									<?php foreach ( $statuses as $slug => $label ) : ?>
										<option value="<?php echo esc_attr( $slug ); ?>"><?php echo esc_html( $label ); ?></option>
									<?php endforeach; ?>
								</select>
							</div>
							<label style="display:flex;align-items:center;gap:8px;font-size:14px;color:#525252;cursor:pointer;">
								<input type="radio" name="trigger_type" value="manual" />
								<?php esc_html_e( 'Manual only — send from order admin page', 'kramar' ); ?>
							</label>
						</div>
					</div>

					<div class="kramar-em-new-email__field" style="margin-top:20px;">
						<label style="display:block;font-weight:600;font-size:13px;color:#111;margin-bottom:6px;"><?php esc_html_e( 'Who receives this email?', 'kramar' ); ?></label>
						<div style="display:flex;flex-direction:column;gap:8px;">
							<label style="display:flex;align-items:center;gap:8px;font-size:14px;color:#525252;cursor:pointer;">
								<input type="radio" name="recipient_type" value="customer" checked />
								<?php esc_html_e( 'Customer (billing email from order)', 'kramar' ); ?>
							</label>
							<label style="display:flex;align-items:center;gap:8px;font-size:14px;color:#525252;cursor:pointer;">
								<input type="radio" name="recipient_type" value="admin" />
								<?php esc_html_e( 'Admin (store email)', 'kramar' ); ?>
							</label>
							<label style="display:flex;align-items:center;gap:8px;font-size:14px;color:#525252;cursor:pointer;">
								<input type="radio" name="recipient_type" value="custom" />
								<?php esc_html_e( 'Custom address', 'kramar' ); ?>
							</label>
							<input type="email" name="custom_recipient"
								   placeholder="<?php esc_attr_e( 'warehouse@yourshop.com', 'kramar' ); ?>"
								   style="display:none;width:100%;padding:10px 12px;border:1px solid #e4e4e7;border-radius:6px;font-size:14px;margin-left:24px;max-width:calc(100% - 24px);" />
						</div>
					</div>

					<p class="kramar-em-new-email__error" style="display:none;margin:16px 0 0;color:#b32d2e;font-size:13px;"></p>

					<div style="display:flex;justify-content:space-between;align-items:center;margin-top:24px;padding-top:20px;border-top:1px solid #e4e4e7;">
						<button type="button" class="button kramar-em-new-email__back" style="font-size:14px;">&larr; <?php esc_html_e( 'Back', 'kramar' ); ?></button>
						<button type="button" class="button button-primary kramar-em-new-email__create" style="font-size:14px;padding:8px 20px;"><?php esc_html_e( 'Create Email', 'kramar' ); ?></button>
					</div>
				</div>

				<button type="button" class="kramar-em-new-email__close">
					<span class="dashicons dashicons-no-alt"></span>
				</button>
			</div>
		</div>
		<?php
	}

	// ─── Builder Page ───────────────────────────────────────────────────

	public function render_builder_page(): void {
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'Unauthorized.', 'kramar' ) );
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$email_id = sanitize_key( wp_unslash( $_GET['email_id'] ?? '' ) );
		if ( '' === $email_id ) {
			wp_safe_redirect( admin_url( 'admin.php?page=' . self::PAGE_SLUG ) );
			exit;
		}

		// Template builder mode.
		if ( '_template' === $email_id ) {
			$this->render_template_builder();
			return;
		}

		$config = $this->get_email_config( $email_id );
		$blocks = $this->get_email_blocks( $email_id );

		?>
		<div class="wrap kramar-eb-wrap">
			<div class="kramar-eb-toolbar">
				<a href="<?php echo esc_url( admin_url( 'admin.php?page=' . self::PAGE_SLUG ) ); ?>" class="kramar-eb-toolbar__back" title="<?php esc_attr_e( 'Back to Emails', 'kramar' ); ?>">
					<span class="dashicons dashicons-arrow-left-alt2"></span>
				</a>
				<span class="kramar-eb-toolbar__title"><?php echo esc_html( $config['title'] ?? $email_id ); ?></span>
				<div class="kramar-eb-toolbar__actions">
					<button type="button" class="button kramar-eb-toolbar__preview">
						<span class="dashicons dashicons-visibility"></span>
						<?php esc_html_e( 'Preview', 'kramar' ); ?>
					</button>
					<button type="button" class="button kramar-eb-toolbar__test">
						<span class="dashicons dashicons-email"></span>
						<?php esc_html_e( 'Send Test', 'kramar' ); ?>
					</button>
					<button type="button" class="button button-primary kramar-eb-toolbar__save">
						<span class="dashicons dashicons-saved"></span>
						<?php esc_html_e( 'Save', 'kramar' ); ?>
					</button>
				</div>
			</div>

			<div class="kramar-eb-builder">
				<div class="kramar-eb-palette" id="kramar-eb-palette">
					<div class="kramar-eb-palette__header">
						<h3><?php esc_html_e( 'Add Block', 'kramar' ); ?></h3>
						<input type="search" class="kramar-eb-palette__search"
							   placeholder="<?php esc_attr_e( 'Search blocks...', 'kramar' ); ?>" />
					</div>
					<div class="kramar-eb-palette__body">
						<?php $this->render_block_palette(); ?>
					</div>
				</div>

				<div class="kramar-eb-canvas" id="kramar-eb-canvas">
					<div class="kramar-eb-canvas__subject">
						<label for="kramar-eb-subject"><?php esc_html_e( 'Subject:', 'kramar' ); ?></label>
						<input type="text" id="kramar-eb-subject" class="kramar-eb-canvas__subject-input"
							   value="<?php echo esc_attr( $config['subject'] ?? '' ); ?>"
							   placeholder="<?php esc_attr_e( 'Email subject line...', 'kramar' ); ?>" />
					</div>
					<?php
					/**
					 * Fires after the subject field in the email builder, letting modules
					 * add per-email settings (e.g. PDF Invoices attachment checkboxes).
					 *
					 * @since 1.0.0
					 *
					 * @param string $email_id The email being edited.
					 * @param string $context  The email context ('order', 'account', …).
					 */
					do_action( 'kramar_email_builder_after_subject', $email_id, $this->get_email_context( $email_id ) );
					?>
					<div class="kramar-eb-canvas__email-wrapper">
						<div class="kramar-eb-canvas__email-card" style="background:#fff;border-radius:8px;border:1px solid #e4e4e7;overflow:hidden;">
							<div class="kramar-eb-canvas__email-container" id="kramar-eb-canvas-container" style="padding:32px 40px;">
								<!-- Blocks rendered here by JS -->
							</div>
						</div>
					</div>
					<div class="kramar-eb-canvas__empty" id="kramar-eb-canvas-empty" style="display:none;">
						<span class="dashicons dashicons-email-alt"></span>
						<p><?php esc_html_e( 'Drag blocks here to build your email.', 'kramar' ); ?></p>
					</div>
				</div>

				<div class="kramar-eb-settings" id="kramar-eb-settings">
					<div class="kramar-eb-settings__header">
						<h3 id="kramar-eb-settings-title"><?php esc_html_e( 'Block Settings', 'kramar' ); ?></h3>
					</div>
					<div class="kramar-eb-settings__body" id="kramar-eb-settings-body">
						<div class="kramar-eb-settings__empty">
							<span class="dashicons dashicons-admin-generic"></span>
							<p><?php esc_html_e( 'Select a block to edit its settings.', 'kramar' ); ?></p>
						</div>
					</div>
				</div>
			</div>
		</div>
		<?php
	}

	private function render_block_palette(): void {
		$groups = [
			'content'     => [
				'label'  => __( 'Content', 'kramar' ),
				'blocks' => [
					'heading'    => [ 'dashicons-heading', __( 'Heading', 'kramar' ) ],
					'text'       => [ 'dashicons-editor-paragraph', __( 'Text', 'kramar' ) ],
					'image'      => [ 'dashicons-format-image', __( 'Image', 'kramar' ) ],
					'button'     => [ 'dashicons-button', __( 'Button', 'kramar' ) ],
					'notice'     => [ 'dashicons-info-outline', __( 'Notice', 'kramar' ) ],
					'divider'    => [ 'dashicons-minus', __( 'Divider', 'kramar' ) ],
					'spacer'     => [ 'dashicons-editor-spellcheck', __( 'Spacer', 'kramar' ) ],
					'raw-html'   => [ 'dashicons-editor-code', __( 'Raw HTML', 'kramar' ) ],
				],
			],
			'woocommerce' => [
				'label'  => __( 'WooCommerce', 'kramar' ),
				'blocks' => [
					'order-table' => [ 'dashicons-list-view', __( 'Order Table', 'kramar' ) ],
					'addresses'   => [ 'dashicons-location', __( 'Addresses', 'kramar' ) ],
					'data-field'  => [ 'dashicons-tag', __( 'Data Field', 'kramar' ) ],
				],
			],
			'layout'      => [
				'label'  => __( 'Layout', 'kramar' ),
				'blocks' => [
					'social' => [ 'dashicons-share', __( 'Social Icons', 'kramar' ) ],
				],
			],
		];

		foreach ( $groups as $group_key => $group ) {
			printf( '<div class="kramar-eb-palette-group" data-group="%s">', esc_attr( $group_key ) );
			printf( '<h4 class="kramar-eb-palette-group__label">%s</h4>', esc_html( $group['label'] ) );

			foreach ( $group['blocks'] as $type => $block ) {
				printf(
					'<div class="kramar-eb-palette-item" data-block-type="%s" draggable="true">' .
					'<span class="dashicons %s kramar-eb-palette-icon"></span>' .
					'<span class="kramar-eb-palette-label">%s</span>' .
					'</div>',
					esc_attr( $type ),
					esc_attr( $block[0] ),
					esc_html( $block[1] )
				);
			}

			echo '</div>';
		}
	}

	// ─── Data Retrieval ─────────────────────────────────────────────────

	/**
	 * @return array<string, array<string, mixed>>
	 */
	private function get_all_emails(): array {
		$emails = [];

		$mailer    = \WC()->mailer();
		$wc_emails = $mailer->get_emails();

		$wc_core_ids = [
			'WC_Email_New_Order', 'WC_Email_Cancelled_Order', 'WC_Email_Failed_Order',
			'WC_Email_Customer_On_Hold_Order', 'WC_Email_Customer_Processing_Order',
			'WC_Email_Customer_Completed_Order', 'WC_Email_Customer_Refunded_Order',
			'WC_Email_Customer_Invoice', 'WC_Email_Customer_Note',
			'WC_Email_Customer_Reset_Password', 'WC_Email_Customer_New_Account',
			'WC_Email_Customer_Cancelled_Order', 'WC_Email_Customer_Failed_Order',
			'WC_Email_Admin_Payment_Gateway_Enabled',
			'WC_Email_Customer_POS_Completed_Order', 'WC_Email_Customer_POS_Refunded_Order',
		];

		$kramar_email_ids = [
			'Kramar_Email_Withdrawal_Confirmation', 'Kramar_Email_Withdrawal_Request',
			'Kramar_Email_Withdrawal_Approved', 'Kramar_Email_Withdrawal_Rejected',
		];

		foreach ( $wc_emails as $key => $email_obj ) {
			$is_wc_core = in_array( $key, $wc_core_ids, true );
			$is_kramar    = in_array( $key, $kramar_email_ids, true ) || str_starts_with( $key, 'Kramar_' );

			$source = 'other';
			if ( $is_wc_core ) {
				$source = 'woocommerce';
			} elseif ( $is_kramar ) {
				$source = 'kramar';
			}

			$recipient_type = 'customer';
			if ( $email_obj->is_customer_email() === false ) {
				$recipient_type = 'admin';
			}

			$emails[ $email_obj->id ] = [
				'title'               => $email_obj->get_title(),
				'description'         => $email_obj->get_description(),
				'subject'             => $email_obj->get_default_subject(),
				'enabled'             => $email_obj->is_enabled(),
				'recipient'           => $email_obj->get_recipient(),
				'recipient_type'      => $recipient_type,
				'trigger_description' => $this->get_trigger_description( $email_obj ),
				'source'              => $source,
				'context'             => 'order',
			];
		}

		// Add custom emails.
		$custom = get_option( self::OPTION_CUSTOM, [] );
		if ( is_array( $custom ) ) {
			foreach ( $custom as $id => $config ) {
				if ( empty( $config['source'] ) ) {
					$config['source'] = 'custom';
				}
				// Populate trigger_description from the stored trigger config if not already set.
				if ( empty( $config['trigger_description'] ) || isset( $config['trigger_type'] ) ) {
					$config['trigger_description'] = $this->describe_trigger( $config );
				}
				$emails[ $id ] = $config;
			}
		}

		return $emails;
	}

	private function get_trigger_description( \WC_Email $email ): string {
		$map = [
			'new_order'                 => __( 'New order placed', 'kramar' ),
			'cancelled_order'           => __( 'Order cancelled', 'kramar' ),
			'failed_order'              => __( 'Payment failed', 'kramar' ),
			'customer_on_hold_order'    => __( 'Order on hold', 'kramar' ),
			'customer_processing_order' => __( 'Status → Processing', 'kramar' ),
			'customer_completed_order'  => __( 'Status → Completed', 'kramar' ),
			'customer_refunded_order'   => __( 'Order refunded', 'kramar' ),
			'customer_invoice'          => __( 'Manual invoice / payment link', 'kramar' ),
			'customer_note'             => __( 'Customer note added', 'kramar' ),
			'customer_reset_password'           => __( 'Password reset requested', 'kramar' ),
			'customer_new_account'              => __( 'New account created', 'kramar' ),
			'kramar_withdrawal_request'           => __( 'Withdrawal request submitted', 'kramar' ),
			'kramar_withdrawal_confirmation'      => __( 'Withdrawal request submitted', 'kramar' ),
			'kramar_withdrawal_approved'          => __( 'Withdrawal approved', 'kramar' ),
			'kramar_withdrawal_rejected'          => __( 'Withdrawal rejected', 'kramar' ),
		];

		return $map[ $email->id ] ?? __( 'Custom trigger', 'kramar' );
	}

	/**
	 * Returns a plain-English summary of a custom email's trigger configuration.
	 *
	 * @since 1.0.0
	 *
	 * @param array<string, mixed> $config Custom email config stored in kramar_custom_emails.
	 * @return string Human-readable trigger summary, already translated.
	 */
	private function describe_trigger( array $config ): string {
		$type = $config['trigger_type'] ?? 'manual';

		if ( 'status_change' === $type ) {
			$statuses = wc_get_order_statuses();
			$to       = $statuses[ $config['trigger_to'] ?? '' ] ?? ( $config['trigger_to'] ?? '' );
			/* translators: %s: order status label e.g. "Completed" */
			return sprintf( __( 'When order → %s', 'kramar' ), $to );
		}

		if ( 'delay' === $type ) {
			$units = [
				'days'  => __( 'days', 'kramar' ),
				'hours' => __( 'hours', 'kramar' ),
			];
			$anchors = [
				'completed' => __( 'completed', 'kramar' ),
				'paid'      => __( 'paid', 'kramar' ),
				'placed'    => __( 'placed', 'kramar' ),
			];
			$value        = (int) ( $config['delay_value'] ?? 0 );
			$unit_label   = $units[ $config['delay_unit'] ?? 'days' ] ?? ( $config['delay_unit'] ?? 'days' );
			$anchor_label = $anchors[ $config['anchor'] ?? 'completed' ] ?? ( $config['anchor'] ?? 'completed' );
			if ( 'status' === ( $config['anchor'] ?? '' ) ) {
				$statuses     = wc_get_order_statuses();
				$status_label = $statuses[ $config['anchor_status'] ?? '' ] ?? ( $config['anchor_status'] ?? '' );
				/* translators: %s: order status label e.g. "Completed" */
				$anchor_label = sprintf( __( 'entering %s', 'kramar' ), $status_label );
			}
			/* translators: 1: number of units (integer), 2: unit label e.g. "days" or "hours", 3: anchor event e.g. "completed" */
			return sprintf( __( '%1$d %2$s after %3$s', 'kramar' ), $value, $unit_label, $anchor_label );
		}

		return __( 'Manual / from code', 'kramar' );
	}

	/**
	 * @return array<string, mixed>
	 */
	public function get_global_settings(): array {
		if ( null !== $this->global_settings ) {
			return $this->global_settings;
		}

		$defaults = [
			'from_name'         => '',
			'from_email'        => '',
			'reply_to'          => '',
			'admin_recipients'  => '',
			'global_bcc'        => '',
			'footer_text'       => '',
			'logo_url'          => '',
			'header_bg'         => '#2c3338',
			'header_text_color' => '#ffffff',
			'accent_color'      => '#7f54b3',
			'body_bg'           => '#f7f7f7',
			'content_bg'        => '#ffffff',
			'text_color'        => '#525252',
		];

		$saved = get_option( self::OPTION_GLOBAL, [] );
		$this->global_settings = is_array( $saved ) ? array_merge( $defaults, $saved ) : $defaults;

		return $this->global_settings;
	}

	/**
	 * The address Kramar's own admin notifications go to.
	 *
	 * Reads the option directly rather than through get_global_settings() so it
	 * still answers when the Email module itself is switched off — the setting
	 * lives on the shared Kramar settings screen either way.
	 *
	 * WooCommerce's own admin emails are left alone: each has its own recipient
	 * field in WooCommerce, and silently redirecting those on upgrade would take
	 * new-order mail away from an address the shop deliberately configured.
	 *
	 * @since 1.0.3
	 *
	 * @return string Comma-separated recipient list.
	 */
	public static function admin_recipient_default(): string {
		$saved = get_option( self::OPTION_GLOBAL, [] );
		$value = is_array( $saved ) ? trim( (string) ( $saved['admin_recipients'] ?? '' ) ) : '';

		return '' !== $value ? $value : (string) get_option( 'admin_email' );
	}

	/**
	 * @return array<string, mixed>
	 */
	public function get_business_details(): array {
		if ( null !== $this->business_details ) {
			return $this->business_details;
		}

		$defaults = [
			'logo_url'        => '',
			'company_name'    => '',
			'statutory_name'  => '',
			'company_address' => '',
			'statutory_seat'  => '',
			'kvk_number'      => '',
			'vat_number'      => '',
			'legal_form'      => '',
			'iban'            => '',
			'email'           => '',
			'phone'           => '',
		];

		$saved = get_option( self::OPTION_BUSINESS, [] );
		$this->business_details = is_array( $saved ) ? array_merge( $defaults, $saved ) : $defaults;

		return $this->business_details;
	}

	/**
	 * The single global brand logo — used by email headers AND PDF invoices.
	 *
	 * Configured in one place: Kramar → Settings → Business Details. The legacy
	 * fallbacks keep pre-existing logos working until they're migrated.
	 *
	 * @since 1.0.0
	 */
	public function get_brand_logo(): string {
		$biz = $this->get_business_details();
		if ( ! empty( $biz['logo_url'] ) ) {
			return (string) $biz['logo_url'];
		}

		// Legacy: email "Template Styling" global logo.
		$gs = $this->get_global_settings();
		if ( ! empty( $gs['logo_url'] ) ) {
			return (string) $gs['logo_url'];
		}

		// Legacy: logo set inside the email template's Header block.
		return $this->logo_from_template();
	}

	/**
	 * Find a logo stored inside the email template's blocks (back-compat).
	 *
	 * @since 1.0.0
	 */
	private function logo_from_template(): string {
		$overrides = get_option( self::OPTION_OVERRIDES, [] );
		if ( ! is_array( $overrides ) || empty( $overrides['_template'] ) ) {
			return '';
		}

		$template = $overrides['_template'];
		foreach ( [ $template['header_blocks'] ?? [], $template['footer_blocks'] ?? [], $template['blocks'] ?? [] ] as $blocks ) {
			if ( ! is_array( $blocks ) ) {
				continue;
			}
			foreach ( $blocks as $block ) {
				if ( ( $block['type'] ?? '' ) === 'header' && ! empty( $block['settings']['logo_url'] ) ) {
					return (string) $block['settings']['logo_url'];
				}
			}
		}

		return '';
	}

	/**
	 * One-time migration: promote any legacy logo into the global Business
	 * Details logo so there is a single configurable source going forward.
	 *
	 * @since 1.0.0
	 */
	private function maybe_migrate_logo(): void {
		if ( get_option( 'kramar_logo_migrated' ) ) {
			return;
		}

		$biz = get_option( self::OPTION_BUSINESS, [] );
		$biz = is_array( $biz ) ? $biz : [];

		if ( empty( $biz['logo_url'] ) ) {
			$legacy = $this->get_global_settings()['logo_url'] ?? '';
			if ( empty( $legacy ) ) {
				$legacy = $this->logo_from_template();
			}
			if ( ! empty( $legacy ) ) {
				$biz['logo_url'] = $legacy;
				update_option( self::OPTION_BUSINESS, $biz );
				$this->business_details = null;
			}
		}

		update_option( 'kramar_logo_migrated', 1 );
	}

	/**
	 * One-time cleanup of pre-rename "toko_" custom emails and per-email
	 * overrides. These keys are orphans after the Toko → Kramar rename: the
	 * underlying email ids no longer register, so their overrides never apply,
	 * and a leftover enabled "toko_withdrawal_verify" custom email is a
	 * duplicate of "kramar_withdrawal_verify" (double-send risk). The Kramar
	 * equivalents already exist, so the toko_ copies are safe to drop.
	 *
	 * @since 1.0.0
	 */
	private function maybe_cleanup_legacy_toko_emails(): void {
		if ( get_option( 'kramar_toko_emails_cleaned' ) ) {
			return;
		}

		foreach ( [ self::OPTION_CUSTOM, self::OPTION_OVERRIDES ] as $option ) {
			$data = get_option( $option, [] );
			if ( ! is_array( $data ) ) {
				continue;
			}

			$changed = false;
			foreach ( array_keys( $data ) as $key ) {
				if ( is_string( $key ) && str_starts_with( $key, 'toko_' ) ) {
					unset( $data[ $key ] );
					$changed = true;
				}
			}

			if ( $changed ) {
				update_option( $option, $data );
			}
		}

		update_option( 'kramar_toko_emails_cleaned', 1 );
	}

	private function get_active_template(): string {
		return (string) ( $this->plugin->get_setting( $this->get_category(), $this->get_slug(), 'active_template' ) ?? 'modern' );
	}

	/**
	 * @return array<int, array<string, mixed>>
	 */
	public function get_email_blocks_public( string $email_id ): array {
		return $this->get_email_blocks( $email_id );
	}

	/**
	 * @return string
	 */
	public function render_blocks_public( array $blocks, ?\WC_Order $order ): string {
		return $this->render_blocks_to_html( $blocks, $order );
	}

	/**
	 * @return string
	 */
	public function resolve_merge_fields_public( string $text, \WC_Order $order ): string {
		return $this->resolve_merge_fields( $text, $order );
	}

	private function get_email_blocks( string $email_id ): array {
		$overrides = get_option( self::OPTION_OVERRIDES, [] );
		if ( ! empty( $overrides[ $email_id ]['blocks'] ) ) {
			return $this->inject_template_shell( $overrides[ $email_id ]['blocks'] );
		}

		return $this->get_default_blocks_for_email( $email_id );
	}

	/**
	 * Replace template-origin blocks in saved email data with the current template versions.
	 *
	 * @return array<int, array<string, mixed>>
	 */
	private function inject_template_shell( array $saved_blocks ): array {
		$tpl  = $this->get_template_parts();
		$mark = static fn( array $b ): array => $b + [ '_tpl' => true ];

		$content = [];
		foreach ( $saved_blocks as $block ) {
			if ( ! empty( $block['_tpl'] ) ) {
				continue;
			}
			$type = $block['type'] ?? '';
			if ( 'header' === $type || 'footer' === $type ) {
				continue;
			}
			$content[] = $block;
		}

		return array_merge(
			array_map( $mark, $tpl['header'] ),
			$content,
			array_map( $mark, $tpl['footer'] ),
		);
	}

	/**
	 * @return array<int, array<string, mixed>>
	 */
	private function get_default_blocks_for_email( string $email_id ): array {
		$context   = $this->get_email_context( $email_id );
		$tpl_parts = $this->get_template_parts();
		$mark      = static fn( array $b ): array => $b + [ '_tpl' => true ];

		$blocks = array_map( $mark, $tpl_parts['header'] );

		$blocks[] = [
			'type'     => 'heading',
			'id'       => 'default_heading',
			'settings' => [ 'text' => '{email_heading}', 'level' => 'h2', 'align' => 'left', 'color' => '' ],
		];

		if ( 'order' === $context ) {
			$blocks[] = [
				'type'     => 'text',
				'id'       => 'default_greeting',
				'settings' => [ 'content' => $this->get_default_greeting( $email_id ), 'color' => '', 'bg' => '' ],
			];

			$blocks[] = [
				'type'     => 'divider',
				'id'       => 'default_divider_1',
				'settings' => [ 'color' => '#e5e5e5', 'width' => '100%', 'margin' => '8' ],
			];

			$blocks[] = [
				'type'     => 'order-table',
				'id'       => 'default_order_table',
				'settings' => [ 'show_sku' => '', 'show_image' => '1', 'image_size' => '32', 'show_totals' => '1' ],
			];

			$blocks[] = [
				'type'     => 'divider',
				'id'       => 'default_divider_2',
				'settings' => [ 'color' => '#e5e5e5', 'width' => '100%', 'margin' => '8' ],
			];

			$blocks[] = [
				'type'     => 'addresses',
				'id'       => 'default_addresses',
				'settings' => [ 'show' => 'both' ],
			];

			// Completed orders: add a tracking notice + review button.
			if ( 'customer_completed_order' === $email_id ) {
				$blocks[] = [
					'type'       => 'notice',
					'id'         => 'default_tracking',
					'settings'   => [ 'content' => __( 'Your tracking number: {tracking_number}', 'kramar' ), 'style' => 'info' ],
					'visibility' => [ 'type' => 'show_if', 'field' => '{tracking_number}', 'value' => '' ],
				];
				$blocks[] = [
					'type'     => 'button',
					'id'       => 'default_review_btn',
					'settings' => [ 'text' => __( 'Leave a Review', 'kramar' ), 'url' => '{shop_url}', 'color' => '#ffffff', 'bg' => '#7f54b3', 'align' => 'center', 'radius' => '4' ],
				];
			}

			// On-hold: payment instruction notice.
			if ( 'customer_on_hold_order' === $email_id ) {
				$blocks[] = [
					'type'     => 'notice',
					'id'       => 'default_payment_notice',
					'settings' => [ 'content' => __( 'Please complete your payment to proceed. Payment method: {payment_method}.', 'kramar' ), 'style' => 'warning' ],
				];
				$blocks[] = [
					'type'     => 'button',
					'id'       => 'default_pay_btn',
					'settings' => [ 'text' => __( 'Pay Now', 'kramar' ), 'url' => '{payment_url}', 'color' => '#ffffff', 'bg' => '#7f54b3', 'align' => 'center', 'radius' => '4' ],
				];
			}

			$blocks[] = [
				'type'     => 'divider',
				'id'       => 'default_divider_3',
				'settings' => [ 'color' => '#e5e5e5', 'width' => '100%', 'margin' => '8' ],
			];

			$admin_ids = [ 'new_order', 'cancelled_order', 'failed_order', 'kramar_withdrawal_request' ];
			$closing   = in_array( $email_id, $admin_ids, true )
				? ''
				: __( 'Thanks for shopping with us! If you have any questions, just reply to this email.', 'kramar' );

			$blocks[] = [
				'type'     => 'text',
				'id'       => 'default_closing',
				'settings' => [ 'content' => $closing, 'color' => '#a0aec0', 'bg' => '', 'size' => 'small' ],
			];

		} elseif ( 'account' === $context ) {
			$blocks[] = [
				'type'     => 'text',
				'id'       => 'default_greeting',
				'settings' => [ 'content' => $this->get_default_greeting( $email_id ), 'color' => '', 'bg' => '' ],
			];

			if ( 'customer_new_account' === $email_id ) {
				$blocks[] = [
					'type'     => 'button',
					'id'       => 'default_button',
					'settings' => [ 'text' => __( 'Set Your Password', 'kramar' ), 'url' => '{set_password_url}', 'color' => '#ffffff', 'bg' => '#7f54b3', 'align' => 'center', 'radius' => '4' ],
				];
				$blocks[] = [
					'type'     => 'text',
					'id'       => 'default_account_info',
					'settings' => [ 'content' => __( 'You can manage your orders and account details from your account page: {my_account_url}', 'kramar' ), 'color' => '#787c82', 'bg' => '' ],
				];
			} elseif ( 'customer_reset_password' === $email_id ) {
				$blocks[] = [
					'type'     => 'notice',
					'id'       => 'default_security_notice',
					'settings' => [ 'content' => __( 'If you did not request this, please ignore this email.', 'kramar' ), 'style' => 'warning' ],
				];
				$blocks[] = [
					'type'     => 'button',
					'id'       => 'default_button',
					'settings' => [ 'text' => __( 'Reset Your Password', 'kramar' ), 'url' => '{reset_password_url}', 'color' => '#ffffff', 'bg' => '#7f54b3', 'align' => 'center', 'radius' => '4' ],
				];
			}
		}

		/**
		 * Blocks a module contributes to an email's default template.
		 *
		 * Runs before the footer is appended, so a module can append to the body
		 * without stepping on the template shell. Used by Refund & Return to put
		 * the withdrawal button in the confirmation emails, where the customer
		 * has to be able to see it and the shop has to be able to edit it.
		 *
		 * @since 1.0.3
		 *
		 * @param array<int, array<string, mixed>> $blocks   Body blocks so far.
		 * @param string                           $email_id WC_Email id.
		 * @param string                           $context  'order', 'account', …
		 */
		$blocks = (array) apply_filters( 'kramar_email_default_blocks', $blocks, $email_id, $context );

		$blocks = array_merge( $blocks, array_map( $mark, $tpl_parts['footer'] ) );

		return $blocks;
	}

	private function get_default_greeting( string $email_id ): string {
		$greetings = [
			'new_order'                 => __( 'You\'ve received a new order from {customer_full_name}.', 'kramar' ),
			'cancelled_order'           => __( 'The order #{order_number} from {customer_full_name} has been cancelled.', 'kramar' ),
			'failed_order'              => __( 'Payment for order #{order_number} has failed.', 'kramar' ),
			'customer_on_hold_order'    => __( 'Hi {customer_first_name}, thanks for your order. It\'s on hold until we confirm payment.', 'kramar' ),
			'customer_processing_order' => __( 'Hi {customer_first_name}, thanks for your order! We\'re processing it now.', 'kramar' ),
			'customer_completed_order'  => __( 'Hi {customer_first_name}, your order has been completed. Here are the details:', 'kramar' ),
			'customer_refunded_order'   => __( 'Hi {customer_first_name}, your order #{order_number} has been refunded.', 'kramar' ),
			'customer_invoice'          => __( 'Hi {customer_first_name}, here are the details of your order.', 'kramar' ),
			'customer_note'             => __( 'Hi {customer_first_name}, a note has been added to your order:', 'kramar' ),
			'customer_new_account'      => __( 'Hi {customer_first_name}, welcome! Your account has been created.', 'kramar' ),
			'customer_reset_password'           => __( 'Hi {customer_first_name}, we received a request to reset your password.', 'kramar' ),
			'kramar_withdrawal_request'          => __( 'A withdrawal request has been submitted for order #{order_number} by {customer_full_name}.', 'kramar' ),
			'kramar_withdrawal_confirmation'     => __( 'Hi {customer_first_name}, we have received your withdrawal request for order #{order_number}.', 'kramar' ),
			'kramar_withdrawal_approved'         => __( 'Hi {customer_first_name}, your withdrawal request for order #{order_number} has been approved.', 'kramar' ),
			'kramar_withdrawal_rejected'         => __( 'Hi {customer_first_name}, your withdrawal request for order #{order_number} has been reviewed.', 'kramar' ),
		];

		return $greetings[ $email_id ] ?? __( 'Hi {customer_first_name},', 'kramar' );
	}

	/**
	 * @return array<string, mixed>
	 */
	private function get_email_config( string $email_id ): array {
		// Custom emails: resolve directly from stored config so the builder and
		// send path get a clean, predictable structure without going through the
		// full WC mailer (which isn't always initialised at this point).
		$custom = get_option( self::OPTION_CUSTOM, [] );
		if ( is_array( $custom ) && isset( $custom[ $email_id ] ) ) {
			$overrides = get_option( self::OPTION_OVERRIDES, [] );
			$cfg       = $custom[ $email_id ];
			return [
				'title'     => (string) ( $cfg['title'] ?? $email_id ),
				'subject'   => (string) ( $overrides[ $email_id ]['subject'] ?? $cfg['subject'] ?? '' ),
				'context'   => (string) ( $cfg['context'] ?? 'order' ),
				'recipient' => '',
			];
		}

		$all_emails = $this->get_all_emails();
		$config     = $all_emails[ $email_id ] ?? [];

		$overrides = get_option( self::OPTION_OVERRIDES, [] );
		if ( isset( $overrides[ $email_id ] ) ) {
			$config = array_merge( $config, $overrides[ $email_id ] );
		}

		// Humanize the slug when no title was resolved (unmapped/invalid ids).
		if ( empty( $config['title'] ) && '' !== $email_id ) {
			$config['title'] = ucwords( str_replace( '_', ' ', $email_id ) );
		}

		return $config;
	}

	private function get_email_context( string $email_id ): string {
		$account_ids = [ 'customer_reset_password', 'customer_new_account' ];
		if ( in_array( $email_id, $account_ids, true ) ) {
			return 'account';
		}

		$custom = get_option( self::OPTION_CUSTOM, [] );
		if ( isset( $custom[ $email_id ]['context'] ) ) {
			return $custom[ $email_id ]['context'];
		}

		return 'order';
	}

	/**
	 * @return array<string, array<string, mixed>>
	 */
	private function get_merge_fields_for_context( string $context ): array {
		$fields = [];

		// Site/Business fields — always available.
		$fields['site'] = [
			'label'  => __( 'Site', 'kramar' ),
			'fields' => [
				'{site_title}'  => __( 'Site Title', 'kramar' ),
				'{site_url}'    => __( 'Site URL', 'kramar' ),
				'{admin_email}' => __( 'Admin Email', 'kramar' ),
				'{shop_url}'    => __( 'Shop URL', 'kramar' ),
			],
		];

		$fields['business'] = [
			'label'  => __( 'Business Details', 'kramar' ),
			'fields' => [
				'{company_name}'    => __( 'Company Name', 'kramar' ),
				'{company_address}' => __( 'Company Address', 'kramar' ),
				'{company_kvk}'     => __( 'KVK Number', 'kramar' ),
				'{company_vat}'     => __( 'VAT Number', 'kramar' ),
				'{company_phone}'   => __( 'Company Phone', 'kramar' ),
				'{company_email}'   => __( 'Company Email', 'kramar' ),
				'{company_iban}'    => __( 'IBAN', 'kramar' ),
			],
		];

		if ( 'order' === $context || 'account' === $context ) {
			$fields['customer'] = [
				'label'  => __( 'Customer', 'kramar' ),
				'fields' => [
					'{customer_first_name}' => __( 'First Name', 'kramar' ),
					'{customer_last_name}'  => __( 'Last Name', 'kramar' ),
					'{customer_full_name}'  => __( 'Full Name', 'kramar' ),
					'{customer_email}'      => __( 'Email', 'kramar' ),
					'{customer_phone}'      => __( 'Phone', 'kramar' ),
				],
			];
		}

		if ( 'account' === $context ) {
			$fields['account'] = [
				'label'  => __( 'Account', 'kramar' ),
				'fields' => [
					'{my_account_url}'    => __( 'My Account URL', 'kramar' ),
					'{set_password_url}'  => __( 'Set Password URL', 'kramar' ),
				],
			];
		}

		if ( 'order' === $context ) {
			$fields['order'] = [
				'label'  => __( 'Order', 'kramar' ),
				'fields' => [
					'{order_number}'         => __( 'Order Number', 'kramar' ),
					'{order_date}'           => __( 'Order Date', 'kramar' ),
					'{order_total}'          => __( 'Order Total', 'kramar' ),
					'{order_subtotal}'       => __( 'Subtotal', 'kramar' ),
					'{order_tax}'            => __( 'Tax', 'kramar' ),
					'{order_discount}'       => __( 'Discount', 'kramar' ),
					'{order_shipping_total}' => __( 'Shipping Total', 'kramar' ),
					'{order_status}'         => __( 'Status', 'kramar' ),
					'{order_note}'           => __( 'Customer Note', 'kramar' ),
					'{coupon_codes}'         => __( 'Coupon Codes', 'kramar' ),
					'{item_count}'           => __( 'Item Count', 'kramar' ),
					'{order_url}'            => __( 'Order URL', 'kramar' ),
				],
			];

			$fields['payment'] = [
				'label'  => __( 'Payment', 'kramar' ),
				'fields' => [
					'{payment_method}'       => __( 'Payment Method', 'kramar' ),
					'{payment_method_title}' => __( 'Payment Method Title', 'kramar' ),
					'{payment_url}'          => __( 'Pay Now URL', 'kramar' ),
					'{transaction_id}'       => __( 'Transaction ID', 'kramar' ),
				],
			];

			$fields['billing'] = [
				'label'  => __( 'Billing Address', 'kramar' ),
				'fields' => [
					'{billing_first_name}' => __( 'First Name', 'kramar' ),
					'{billing_last_name}'  => __( 'Last Name', 'kramar' ),
					'{billing_company}'    => __( 'Company', 'kramar' ),
					'{billing_phone}'      => __( 'Phone', 'kramar' ),
					'{billing_email}'      => __( 'Email', 'kramar' ),
					'{billing_state}'      => __( 'State / Province', 'kramar' ),
					'{billing_address}'    => __( 'Full Address', 'kramar' ),
					'{billing_address_1}'  => __( 'Street', 'kramar' ),
					'{billing_postcode}'   => __( 'Postcode', 'kramar' ),
					'{billing_city}'       => __( 'City', 'kramar' ),
					'{billing_country}'    => __( 'Country', 'kramar' ),
				],
			];

			$fields['shipping'] = [
				'label'  => __( 'Shipping', 'kramar' ),
				'fields' => [
					'{shipping_first_name}' => __( 'First Name', 'kramar' ),
					'{shipping_last_name}'  => __( 'Last Name', 'kramar' ),
					'{shipping_company}'    => __( 'Company', 'kramar' ),
					'{shipping_state}'      => __( 'State / Province', 'kramar' ),
					'{shipping_address}'    => __( 'Full Address', 'kramar' ),
					'{shipping_method}'     => __( 'Shipping Method', 'kramar' ),
					'{tracking_number}'     => __( 'Tracking Number', 'kramar' ),
					'{tracking_url}'        => __( 'Tracking URL', 'kramar' ),
				],
			];

			$fields['kramar'] = [
				'label'  => __( 'Kramar', 'kramar' ),
				'fields' => [
					'{vat_id}'              => __( 'Customer VAT ID', 'kramar' ),
					'{withdrawal_link}'     => __( 'Withdrawal Link', 'kramar' ),
					'{verify_url}'          => __( 'Withdrawal Verification Link', 'kramar' ),
					'{withdrawal_deadline}' => __( 'Withdrawal Deadline', 'kramar' ),
					'{invoice_number}'      => __( 'Invoice Number', 'kramar' ),
				],
			];
		}

		return apply_filters( 'kramar_email_merge_fields', $fields, $context );
	}

	/**
	 * @return array<string, array<string, mixed>>
	 */
	private function get_block_type_definitions(): array {
		return [
			'header'      => [ 'label' => __( 'Header', 'kramar' ), 'icon' => 'dashicons-cover-image', 'defaults' => [ 'logo_url' => '', 'store_name' => get_bloginfo( 'name' ), 'align' => 'center' ] ],
			'heading'     => [ 'label' => __( 'Heading', 'kramar' ), 'icon' => 'dashicons-heading', 'defaults' => [ 'text' => 'Heading text', 'level' => 'h2', 'align' => 'left', 'color' => '' ] ],
			'text'        => [ 'label' => __( 'Text', 'kramar' ), 'icon' => 'dashicons-editor-paragraph', 'defaults' => [ 'content' => '', 'color' => '', 'bg' => '', 'size' => 'normal' ] ],
			'image'       => [ 'label' => __( 'Image', 'kramar' ), 'icon' => 'dashicons-format-image', 'defaults' => [ 'url' => '', 'alt' => '', 'width' => '100%', 'align' => 'center', 'link' => '' ] ],
			'button'      => [ 'label' => __( 'Button', 'kramar' ), 'icon' => 'dashicons-button', 'defaults' => [ 'text' => 'Click here', 'url' => '', 'color' => '#ffffff', 'bg' => '', 'align' => 'center', 'radius' => '4' ] ],
			'notice'      => [ 'label' => __( 'Notice', 'kramar' ), 'icon' => 'dashicons-info-outline', 'defaults' => [ 'content' => '', 'style' => 'info' ] ],
			'divider'     => [ 'label' => __( 'Divider', 'kramar' ), 'icon' => 'dashicons-minus', 'defaults' => [ 'color' => '#dcdcde', 'width' => '100%', 'margin' => '16' ] ],
			'spacer'      => [ 'label' => __( 'Spacer', 'kramar' ), 'icon' => 'dashicons-editor-spellcheck', 'defaults' => [ 'height' => '24', 'bg_color' => '' ] ],
			'raw-html'    => [ 'label' => __( 'Raw HTML', 'kramar' ), 'icon' => 'dashicons-editor-code', 'defaults' => [ 'content' => '' ] ],
			'order-table' => [ 'label' => __( 'Order Table', 'kramar' ), 'icon' => 'dashicons-list-view', 'defaults' => [ 'show_sku' => false, 'show_image' => true, 'image_size' => '32', 'show_totals' => true ] ],
			'items-table' => [ 'label' => __( 'Items Table', 'kramar' ), 'icon' => 'dashicons-cart', 'defaults' => [] ],
			'addresses'   => [ 'label' => __( 'Addresses', 'kramar' ), 'icon' => 'dashicons-location', 'defaults' => [ 'show' => 'both' ] ],
			'data-field'  => [ 'label' => __( 'Data Field', 'kramar' ), 'icon' => 'dashicons-tag', 'defaults' => [ 'field' => '{order_number}', 'label' => '' ] ],
			'social'      => [ 'label' => __( 'Social Icons', 'kramar' ), 'icon' => 'dashicons-share', 'defaults' => [ 'facebook' => '', 'instagram' => '', 'linkedin' => '', 'x' => '', 'youtube' => '', 'tiktok' => '' ] ],
			'footer'      => [ 'label' => __( 'Footer', 'kramar' ), 'icon' => 'dashicons-align-center', 'defaults' => [ 'content' => '' ] ],
		];
	}

	private function get_template_block_types(): array {
		$all = $this->get_block_type_definitions();
		unset( $all['order-table'], $all['items-table'], $all['addresses'], $all['data-field'] );
		return $all;
	}

	/**
	 * @return array{header: array<int, array<string, mixed>>, footer: array<int, array<string, mixed>>}
	 */
	private function get_template_parts(): array {
		$overrides = get_option( self::OPTION_OVERRIDES, [] );
		$tpl       = $overrides['_template'] ?? [];

		if ( ! empty( $tpl['header_blocks'] ) || ! empty( $tpl['footer_blocks'] ) ) {
			return [
				'header' => $tpl['header_blocks'] ?? [],
				'footer' => $tpl['footer_blocks'] ?? [],
			];
		}

		// Migrate old flat `blocks` format: everything before first footer → header, rest → footer.
		if ( ! empty( $tpl['blocks'] ) ) {
			$header = [];
			$footer = [];
			$in_footer = false;
			foreach ( $tpl['blocks'] as $block ) {
				if ( ! $in_footer && 'footer' === ( $block['type'] ?? '' ) ) {
					$in_footer = true;
				}
				if ( $in_footer ) {
					$footer[] = $block;
				} else {
					$header[] = $block;
				}
			}
			return [ 'header' => $header, 'footer' => $footer ];
		}

		$gs = $this->get_global_settings();

		return [
			'header' => [
				[
					'type'     => 'header',
					'id'       => 'tpl_header',
					'settings' => [
						'logo_url'   => $gs['logo_url'] ?? '',
						'store_name' => get_bloginfo( 'name' ),
						'align'      => 'center',
					],
				],
				[
					'type'     => 'divider',
					'id'       => 'tpl_divider',
					'settings' => [ 'color' => '#e4e4e7', 'width' => '100%', 'margin' => '0' ],
				],
			],
			'footer' => [
				[
					'type'     => 'footer',
					'id'       => 'tpl_footer',
					// The footer block auto-renders the business/compliance line
					// (company · KVK · BTW · address · phone · email) from the saved
					// Business Details. Leave the extra free-text empty so the same
					// legal footer is not printed twice.
					'settings' => [
						'content' => '',
					],
				],
			],
		];
	}

	/**
	 * @return array<int, array<string, mixed>>
	 */
	private function get_template_blocks_for_builder(): array {
		$tpl = $this->get_template_parts();
		$placeholder = [
			'type'     => 'content-placeholder',
			'id'       => '_content_placeholder',
			'settings' => [],
		];
		return array_merge( $tpl['header'], [ $placeholder ], $tpl['footer'] );
	}

	private function render_template_builder(): void {
		$gs = $this->get_global_settings();

		wp_enqueue_media();

		?>
		<div class="wrap kramar-eb-wrap">
			<div class="kramar-eb-toolbar">
				<a href="<?php echo esc_url( admin_url( 'admin.php?page=' . self::PAGE_SLUG . '&tab=settings' ) ); ?>" class="kramar-eb-toolbar__back" title="<?php esc_attr_e( 'Back to Settings', 'kramar' ); ?>">
					<span class="dashicons dashicons-arrow-left-alt2"></span>
				</a>
				<span class="kramar-eb-toolbar__title"><?php esc_html_e( 'Email Template', 'kramar' ); ?></span>
				<div class="kramar-eb-toolbar__actions">
					<button type="button" class="button kramar-eb-toolbar__preview">
						<span class="dashicons dashicons-visibility"></span>
						<?php esc_html_e( 'Preview', 'kramar' ); ?>
					</button>
					<button type="button" class="button button-primary kramar-eb-toolbar__save">
						<span class="dashicons dashicons-saved"></span>
						<?php esc_html_e( 'Save', 'kramar' ); ?>
					</button>
				</div>
			</div>

			<div class="kramar-eb-builder">
				<div class="kramar-eb-palette" id="kramar-eb-palette">
					<div class="kramar-eb-palette__header">
						<h3><?php esc_html_e( 'Add Block', 'kramar' ); ?></h3>
						<input type="search" class="kramar-eb-palette__search"
							   placeholder="<?php esc_attr_e( 'Search blocks...', 'kramar' ); ?>" />
					</div>
					<div class="kramar-eb-palette__body">
						<?php $this->render_template_palette(); ?>
					</div>

					<div class="kramar-eb-palette__section" style="padding:12px;border-top:1px solid #e4e4e7;">
						<h4 style="font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#a0aec0;margin:0 0 10px;"><?php esc_html_e( 'Global Colors', 'kramar' ); ?></h4>
						<div style="display:flex;flex-direction:column;gap:10px;">
								<?php
								$global_colors = [
									'accent_color' => [ __( 'Accent', 'kramar' ), '#7f54b3' ],
									'text_color'   => [ __( 'Text color', 'kramar' ), '#525252' ],
									'body_bg'      => [ __( 'Page BG', 'kramar' ), '#f6f6f6' ],
									'content_bg'   => [ __( 'Card BG', 'kramar' ), '#ffffff' ],
								];
								foreach ( $global_colors as $gc_key => $gc_meta ) :
									$gc_label = $gc_meta[0];
									$gc_value = $gs[ $gc_key ] ?? $gc_meta[1];
									?>
									<div class="kramar-eb-gcolor" data-color-name="<?php echo esc_attr( $gc_key ); ?>">
										<label class="kramar-eb-gcolor__label" for="kramar-gc-<?php echo esc_attr( $gc_key ); ?>"><?php echo esc_html( $gc_label ); ?></label>
										<div class="kramar-eb-gcolor__control kramar-eb-hex">
											<input type="color" class="kramar-eb-hex__swatch" value="<?php echo esc_attr( $gc_value ); ?>" tabindex="-1" aria-hidden="true" />
											<input type="text" class="kramar-eb-hex__input" id="kramar-gc-<?php echo esc_attr( $gc_key ); ?>" name="<?php echo esc_attr( $gc_key ); ?>" value="<?php echo esc_attr( $gc_value ); ?>" spellcheck="false" autocomplete="off" maxlength="7" aria-label="<?php echo esc_attr( sprintf( /* translators: %s: colour name, e.g. Accent. */ __( '%s colour, hex value', 'kramar' ), $gc_label ) ); ?>" />
										</div>
									</div>
								<?php endforeach; ?>
						</div>
					</div>
				</div>

				<div class="kramar-eb-canvas" id="kramar-eb-canvas">
					<div class="kramar-eb-canvas__email-wrapper">
						<div class="kramar-eb-canvas__email-card" style="background:#fff;border-radius:8px;border:1px solid #e4e4e7;overflow:hidden;">
							<div class="kramar-eb-canvas__email-container" id="kramar-eb-canvas-container" style="padding:32px 40px;">
								<!-- Template blocks rendered here by JS -->
							</div>
						</div>
					</div>
					<div class="kramar-eb-canvas__empty" id="kramar-eb-canvas-empty" style="display:none;">
						<span class="dashicons dashicons-email-alt"></span>
						<p><?php esc_html_e( 'Drag blocks here to build your template.', 'kramar' ); ?></p>
					</div>
				</div>

				<div class="kramar-eb-settings" id="kramar-eb-settings">
					<div class="kramar-eb-settings__header">
						<h3 id="kramar-eb-settings-title"><?php esc_html_e( 'Block Settings', 'kramar' ); ?></h3>
					</div>
					<div class="kramar-eb-settings__body" id="kramar-eb-settings-body">
						<div class="kramar-eb-settings__empty">
							<span class="dashicons dashicons-admin-generic"></span>
							<p><?php esc_html_e( 'Select a block to edit its settings.', 'kramar' ); ?></p>
						</div>
					</div>
				</div>
			</div>
		</div>
		<?php
	}

	private function render_template_palette(): void {
		$types = $this->get_template_block_types();
		$groups = [
			'template' => [
				'label'  => __( 'Template', 'kramar' ),
				'types'  => [ 'header', 'footer' ],
			],
			'content'  => [
				'label'  => __( 'Content', 'kramar' ),
				'types'  => [ 'heading', 'text', 'image', 'button', 'notice', 'divider' ],
			],
			'layout'   => [
				'label'  => __( 'Layout', 'kramar' ),
				'types'  => [ 'social' ],
			],
		];

		foreach ( $groups as $group ) {
			printf( '<div class="kramar-eb-palette-group">' );
			printf( '<h4 class="kramar-eb-palette-group__label">%s</h4>', esc_html( $group['label'] ) );
			foreach ( $group['types'] as $type ) {
				if ( ! isset( $types[ $type ] ) ) continue;
				$block = $types[ $type ];
				printf(
					'<div class="kramar-eb-palette-item" data-block-type="%s" draggable="true">' .
					'<span class="dashicons %s kramar-eb-palette-icon"></span>' .
					'<span class="kramar-eb-palette-label">%s</span>' .
					'</div>',
					esc_attr( $type ),
					esc_attr( $block['icon'] ),
					esc_html( $block['label'] )
				);
			}
			echo '</div>';
		}
	}

	// ─── WC Email Hooks ─────────────────────────────────────────────────

	/**
	 * Replace the WC email body with builder blocks output.
	 *
	 * @param array     $params [ $to, $subject, $message, $headers, $attachments ]
	 * @param \WC_Email $email
	 * @return array
	 */
	public function replace_email_content( array $params, $email ): array {
		if ( ! $email instanceof \WC_Email ) {
			return $params;
		}

		$email_id = $email->id;
		$blocks   = $this->get_email_blocks( $email_id );
		if ( empty( $blocks ) ) {
			return $params;
		}

		$context = null;
		if ( $email->object instanceof \WC_Order || $email->object instanceof \WP_User ) {
			$context = $email->object;
		} elseif ( method_exists( $email, 'kramar_block_context' ) ) {
			// Non-order/non-user emails (Back-in-Stock, Abandoned Cart) supply a
			// data context: [ 'merge' => [ tag => value ], 'items' => [...] ].
			$ctx = $email->kramar_block_context();
			if ( is_array( $ctx ) ) {
				$context = $ctx;
			}
		}

		$email_heading = $email->get_heading();

		foreach ( $blocks as &$block ) {
			if ( 'heading' === ( $block['type'] ?? '' ) ) {
				$text = $block['settings']['text'] ?? '';
				$block['settings']['text'] = str_replace( '{email_heading}', $email_heading, $text );
			}
		}
		unset( $block );

		// A template that places {withdrawal_link} itself owns the link: the
		// Refund & Return module stands down for this render rather than adding
		// a second one. That lets a shop move, restyle or reword the link in the
		// builder without losing it, and without a setting to remember.
		$owns_withdrawal_link = self::blocks_contain( $blocks, '{withdrawal_link}' );
		if ( $owns_withdrawal_link ) {
			add_filter( 'kramar_suppress_withdrawal_link', '__return_true' );
		}

		$body_html = $this->render_blocks_to_html( $blocks, $context, $email );

		if ( $owns_withdrawal_link ) {
			remove_filter( 'kramar_suppress_withdrawal_link', '__return_true' );
		}

		$params[2] = $this->wrap_email_document( $body_html );

		return $params;
	}

	/**
	 * Whether any block setting contains a given merge tag.
	 *
	 * @since 1.0.3
	 *
	 * @param array<int, array<string, mixed>> $blocks Blocks about to render.
	 * @param string                           $tag    Merge tag, e.g. '{withdrawal_link}'.
	 */
	private static function blocks_contain( array $blocks, string $tag ): bool {
		foreach ( $blocks as $block ) {
			foreach ( (array) ( $block['settings'] ?? [] ) as $value ) {
				if ( is_string( $value ) && str_contains( $value, $tag ) ) {
					return true;
				}
			}
		}

		return false;
	}

	/**
	 * Whether this email's own template fires WooCommerce's order-table hooks.
	 *
	 * Block templates replace the body, so re-firing those hooks is how gateway
	 * instructions and third-party additions survive. Firing them for *every*
	 * email that happens to carry an order was too broad: WooCommerce's COD
	 * gateway guards only on the payment method, not on status or email, because
	 * in stock WooCommerce the hook only reaches emails where payment
	 * instructions make sense. The result was "Pay with cash upon delivery" on
	 * delivery-delay notices, refunds and withdrawal emails.
	 *
	 * The rule is parity: fire only where WooCommerce itself would.
	 *
	 * @since 1.0.3
	 */
	private static function email_fires_order_table_hooks( \WC_Email $email ): bool {
		// Kramar's own emails are not WooCommerce order emails. Several borrow a
		// WooCommerce template for its layout, which would otherwise drag
		// payment instructions onto a withdrawal notice.
		if ( str_starts_with( (string) $email->id, 'kramar_' ) ) {
			return false;
		}

		// Builder-created custom emails and the marketing emails carry no
		// WooCommerce template at all — nothing would have fired.
		$template = (string) $email->template_html;
		if ( '' === $template ) {
			return false;
		}

		static $cache = [];
		if ( isset( $cache[ $template ] ) ) {
			return $cache[ $template ];
		}

		// Read the template that would actually have rendered — wc_locate_template()
		// honours theme overrides, so a theme that strips the hook is respected too.
		$path = wc_locate_template( $template );

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading a local PHP template to detect a hook call; WP_Filesystem is not initialised during a mail send.
		$source = is_readable( $path ) ? (string) file_get_contents( $path ) : '';

		$cache[ $template ] = str_contains( $source, 'woocommerce_email_order_details' );

		return $cache[ $template ];
	}

	/**
	 * Capture the output of one of WooCommerce's order-email hooks.
	 *
	 * Block templates replace the whole email body, so the hooks WooCommerce's
	 * own templates fire never run. That silently drops anything a gateway,
	 * theme or plugin adds to order emails — including BACS/cheque/COD payment
	 * instructions, which WooCommerce core prints on
	 * `woocommerce_email_before_order_table`. Running them here keeps block
	 * templates compatible with the ecosystem.
	 *
	 * @since 1.0.3
	 *
	 * @param string     $hook  Hook name.
	 * @param \WC_Order  $order Order being emailed.
	 * @param \WC_Email  $email Email being sent.
	 * @return string Captured HTML.
	 */
	private function capture_order_email_hook( string $hook, \WC_Order $order, \WC_Email $email ): string {
		ob_start();
		// $plain_text is always false: block templates render HTML.
		// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- these are WooCommerce's own hooks, re-fired; a Kramar prefix would defeat the point.
		do_action( $hook, $order, ! $email->is_customer_email(), false, $email );
		return (string) ob_get_clean();
	}

	/**
	 * The global body-text colour, escaped for use in an inline style.
	 *
	 * Every block renderer that draws body copy resolves its colour through
	 * here, so a single setting drives the whole email. Blocks that carry their
	 * own explicit colour override still win.
	 *
	 * @since 1.0.3
	 */
	private function text_color(): string {
		$settings = $this->get_global_settings();

		return esc_attr( $settings['text_color'] ?? '#525252' );
	}

	/**
	 * Wrap rendered block HTML in the full email document (doctype, responsive
	 * styles, body/card backgrounds). Single source used by the send path AND
	 * the preview, so the preview always reflects the real colour settings.
	 *
	 * @since 1.0.0
	 */
	private function wrap_email_document( string $body_html ): string {
		$settings = $this->get_global_settings();
		$accent   = esc_attr( $settings['accent_color'] ?? '#7f54b3' );
		$body_bg  = esc_attr( $settings['body_bg'] ?? '#f6f6f6' );
		$card_bg  = esc_attr( $settings['content_bg'] ?? '#ffffff' );
		$text     = $this->text_color();

		$html  = '<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">';
		// Inline <style> is required: this is an HTML email body sent via wp_mail(),
		// not page output — wp_enqueue_style() does not apply to email.
		$html .= '<style>';
		$html .= 'body{margin:0;padding:0;background:' . $body_bg . ';}';
		$html .= 'img{max-width:100%;height:auto;}';
		$html .= 'a{color:' . $accent . ';}';
		$html .= '@media (max-width:480px){';
		$html .= '.kramar-email-wrapper{padding:20px 16px!important;}';
		$html .= '.kramar-email-content{padding:20px 16px!important;}';
		$html .= '.kramar-addresses-outer{padding:16px!important;}';
		$html .= '.kramar-addresses-row{flex-direction:column!important;}';
		$html .= '.kramar-address-col{width:100%!important;flex:none!important;}';
		$html .= '.kramar-btn-full{display:block!important;text-align:center!important;}';
		$html .= '}';
		$html .= '</style>';
		$html .= '</head>';
		$html .= '<body style="margin:0;padding:0;background:' . $body_bg . ';font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,\'Helvetica Neue\',Arial,sans-serif;font-size:16px;color:' . $text . ';">';
		$html .= '<div class="kramar-email-wrapper" style="max-width:600px;margin:0 auto;padding:40px 20px;">';
		$html .= '<div class="kramar-email-content" style="padding:32px 40px;background:' . $card_bg . ';border-radius:8px;">';
		$html .= $body_html;
		$html .= '</div>';
		$html .= '</div>';
		$html .= '</body></html>';

		return $html;
	}

	/**
	 * @param \WC_Email $email
	 */
	public function filter_from_name( string $name, $email ): string {
		$settings = $this->get_global_settings();

		// Check per-email override first.
		$overrides = get_option( self::OPTION_OVERRIDES, [] );
		if ( is_object( $email ) && isset( $overrides[ $email->id ]['from_name'] ) && '' !== $overrides[ $email->id ]['from_name'] ) {
			return $overrides[ $email->id ]['from_name'];
		}

		if ( '' !== $settings['from_name'] ) {
			return $settings['from_name'];
		}

		return $name;
	}

	/**
	 * @param \WC_Email $email
	 */
	public function filter_from_address( string $address, $email ): string {
		$settings = $this->get_global_settings();

		$overrides = get_option( self::OPTION_OVERRIDES, [] );
		if ( is_object( $email ) && isset( $overrides[ $email->id ]['from_email'] ) && '' !== $overrides[ $email->id ]['from_email'] ) {
			return $overrides[ $email->id ]['from_email'];
		}

		if ( '' !== $settings['from_email'] ) {
			return $settings['from_email'];
		}

		return $address;
	}

	/**
	 * @param \WC_Email $email
	 */
	public function filter_email_headers( string $headers, string $email_id, $email ): string {
		$settings  = $this->get_global_settings();
		$overrides = get_option( self::OPTION_OVERRIDES, [] );

		// Reply-To.
		$reply_to = $overrides[ $email_id ]['reply_to'] ?? $settings['reply_to'] ?? '';
		if ( '' !== $reply_to ) {
			// WC_Email::get_headers() already emitted its own "Reply-to:" line
			// whenever a from-name/from-address is set. Appending ours left two
			// conflicting Reply-To headers on the message, and mail clients
			// disagree about which one wins — so the configured address was not
			// reliably honoured. Replace rather than add.
			$headers  = (string) preg_replace( '/^reply-to:[^\r\n]*(?:\r\n|\r|\n)?/im', '', $headers );
			$headers .= 'Reply-To: ' . sanitize_email( $reply_to ) . "\r\n";
		}

		// Per-email CC.
		$cc = $overrides[ $email_id ]['cc'] ?? '';
		if ( '' !== $cc ) {
			foreach ( explode( ',', $cc ) as $addr ) {
				$addr = trim( $addr );
				if ( is_email( $addr ) ) {
					$headers .= 'Cc: ' . sanitize_email( $addr ) . "\r\n";
				}
			}
		}

		// Per-email BCC + global BCC.
		$bcc_list = [];
		$per_email_bcc = $overrides[ $email_id ]['bcc'] ?? '';
		if ( '' !== $per_email_bcc ) {
			$bcc_list = array_merge( $bcc_list, array_map( 'trim', explode( ',', $per_email_bcc ) ) );
		}
		if ( '' !== $settings['global_bcc'] ) {
			$bcc_list = array_merge( $bcc_list, array_map( 'trim', explode( ',', $settings['global_bcc'] ) ) );
		}
		foreach ( array_unique( $bcc_list ) as $addr ) {
			if ( is_email( $addr ) ) {
				$headers .= 'Bcc: ' . sanitize_email( $addr ) . "\r\n";
			}
		}

		return $headers;
	}

	/**
	 * @param \WC_Order $order
	 */
	public function filter_subject( string $subject, $order, $email = null ): string {
		$filter    = current_filter();
		$email_id  = str_replace( 'woocommerce_email_subject_', '', $filter );
		$overrides = get_option( self::OPTION_OVERRIDES, [] );

		$custom = '';
		if ( isset( $overrides[ $email_id ]['subject'] ) && '' !== $overrides[ $email_id ]['subject'] ) {
			$custom = (string) $overrides[ $email_id ]['subject'];
		} else {
			// Custom emails store their subject in kramar_custom_emails, not the
			// per-email overrides. Fall back to it so custom-email subjects run
			// through the same merge-field resolution as built-ins.
			$custom_emails = get_option( self::OPTION_CUSTOM, [] );
			if ( is_array( $custom_emails ) && isset( $custom_emails[ $email_id ]['subject'] ) && '' !== $custom_emails[ $email_id ]['subject'] ) {
				$custom = (string) $custom_emails[ $email_id ]['subject'];
			}
		}

		if ( '' === $custom ) {
			return $subject;
		}

		// Any context, not only orders. WooCommerce passes a WP_User for the
		// account emails (password reset, new account), and this only handled
		// WC_Order — so a subject written with the merge fields the builder's own
		// picker offers for those emails arrived in the inbox as the literal text
		// "Hi {customer_first_name}". The body resolved them correctly all along.
		// Emails that are about neither an order nor a user carry their own
		// tag map. Back-in-stock and abandoned-cart set $this->object to a
		// WC_Product or leave it null, so resolving against the object alone
		// matched none of the handled types and every merge field but the one
		// each class hardcodes shipped as literal "{customer_first_name}" text.
		if (
			! $order instanceof \WC_Order
			&& ! $order instanceof \WP_User
			&& ! is_array( $order )
			&& $email instanceof \WC_Email
			&& method_exists( $email, 'kramar_block_context' )
		) {
			$order = (array) $email->kramar_block_context();
		}

		$custom = $this->resolve_merge_fields_context( $custom, $order );

		// A subject line is plain text. The money merge fields resolve through
		// wc_price()/get_formatted_order_total(), which return markup — dropped
		// into a subject unchanged that reaches the inbox as literal
		// "<span class=...>&euro;&nbsp;36,05</span>" instead of a price.
		return self::to_plain_text( $custom );
	}

	/**
	 * Flatten resolved merge-field output for a plain-text context.
	 *
	 * Several merge fields legitimately resolve to HTML because they are built
	 * for the email body. Anywhere that output is used as text — a subject line,
	 * an escaped Data Field — it has to be reduced first, entities included, or
	 * the customer sees the markup.
	 *
	 * @since 1.0.2
	 */
	private static function to_plain_text( string $value ): string {
		return trim( html_entity_decode( wp_strip_all_tags( $value ), ENT_QUOTES, 'UTF-8' ) );
	}

	/**
	 * @param array<string, \WC_Email> $emails
	 * @return array<string, \WC_Email>
	 */
	public function register_custom_emails( array $emails ): array {
		$custom = get_option( self::OPTION_CUSTOM, [] );
		if ( ! is_array( $custom ) ) {
			return $emails;
		}

		// WC_Email is loaded by WC_Emails::init() just before this filter fires.
		// We defer our class file's require to this point so that `extends \WC_Email`
		// resolves correctly regardless of plugin load order.
		require_once KRAMAR_DIR . 'modules/email/class-kramar-custom-email.php';

		foreach ( $custom as $id => $config ) {
			if ( empty( $config['enabled'] ) || ! is_array( $config ) ) {
				continue;
			}
			$id = sanitize_key( (string) $id );
			if ( '' === $id || isset( $emails[ $id ] ) ) {
				continue;
			}
			$emails[ $id ] = new Kramar_Custom_Email( $id, $config );

			// Resolve subject merge fields the same way built-in emails do:
			// WC fires woocommerce_email_subject_{id} → filter_subject() →
			// resolve_merge_fields() (which honours all Kramar merge fields and the
			// kramar_email_merge_field_values filter).
			add_filter( 'woocommerce_email_subject_' . $id, [ $this, 'filter_subject' ], 10, 3 );
		}

		return $emails;
	}

	/**
	 * Hook the subject filter for every Kramar email WooCommerce knows about.
	 *
	 * Hooked late on woocommerce_email_classes — the point where every module's
	 * subclass is present, and which always runs. woocommerce_email_init is not
	 * usable here: the mailer can be built before this module registers, and the
	 * action would already have fired. Idempotent via has_filter().
	 *
	 * @since 1.0.2
	 *
	 * @param array $emails Registered WC_Email instances.
	 * @return array The same array, unmodified.
	 */
	public function hook_module_email_subjects( $emails = [] ) {
		foreach ( (array) $emails as $email ) {
			if ( ! $email instanceof \WC_Email ) {
				continue;
			}

			$id = (string) $email->id;
			if ( '' === $id || ! str_starts_with( $id, 'kramar_' ) ) {
				continue;
			}

			$hook = 'woocommerce_email_subject_' . $id;
			if ( ! has_filter( $hook, [ $this, 'filter_subject' ] ) ) {
				add_filter( $hook, [ $this, 'filter_subject' ], 10, 3 );
			}
		}

		return $emails;
	}

	// ─── Merge Field Resolution ─────────────────────────────────────────

	public function resolve_merge_fields( string $text, \WC_Order $order ): string {
		$business = $this->get_business_details();

		$replacements = [
			'{order_number}'         => $order->get_order_number(),
			'{order_date}'           => wc_format_datetime( $order->get_date_created() ),
			'{order_total}'          => $order->get_formatted_order_total(),
			'{order_subtotal}'       => wc_price( (float) $order->get_subtotal() ),
			'{order_tax}'            => wc_price( (float) $order->get_total_tax() ),
			'{order_discount}'       => wc_price( (float) $order->get_discount_total() ),
			'{order_shipping_total}' => wc_price( (float) $order->get_shipping_total() ),
			'{order_status}'         => wc_get_order_status_name( $order->get_status() ),
			'{order_note}'           => $order->get_customer_note(),
			'{item_count}'           => (string) $order->get_item_count(),
			'{order_url}'            => $order->get_view_order_url(),
			'{payment_method}'       => $order->get_payment_method_title(),
			'{payment_method_title}' => $order->get_payment_method_title(),
			'{payment_url}'          => $order->get_checkout_payment_url(),
			'{transaction_id}'       => $order->get_transaction_id(),
			'{customer_first_name}'  => $order->get_billing_first_name(),
			'{customer_last_name}'   => $order->get_billing_last_name(),
			'{customer_full_name}'   => $order->get_billing_first_name() . ' ' . $order->get_billing_last_name(),
			'{customer_email}'       => $order->get_billing_email(),
			'{customer_phone}'       => $order->get_billing_phone(),
			'{billing_first_name}'   => $order->get_billing_first_name(),
			'{billing_last_name}'    => $order->get_billing_last_name(),
			'{billing_company}'      => $order->get_billing_company(),
			'{billing_phone}'        => $order->get_billing_phone(),
			'{billing_email}'        => $order->get_billing_email(),
			'{billing_state}'        => $order->get_billing_state(),
			'{billing_address}'      => $order->get_formatted_billing_address(),
			'{billing_address_1}'    => $order->get_billing_address_1(),
			'{billing_postcode}'     => $order->get_billing_postcode(),
			'{billing_city}'         => $order->get_billing_city(),
			'{billing_country}'      => WC()->countries->countries[ $order->get_billing_country() ] ?? $order->get_billing_country(),
			'{shipping_first_name}'  => $order->get_shipping_first_name(),
			'{shipping_last_name}'   => $order->get_shipping_last_name(),
			'{shipping_company}'     => $order->get_shipping_company(),
			'{shipping_state}'       => $order->get_shipping_state(),
			'{shipping_address}'     => $order->get_formatted_shipping_address(),
			'{shipping_method}'      => $order->get_shipping_method(),
			'{tracking_number}'      => $order->get_meta( '_tracking_number' ),
			'{tracking_url}'         => $order->get_meta( '_tracking_url' ),
			'{coupon_codes}'         => implode( ', ', $order->get_coupon_codes() ),
			'{vat_id}'               => $order->get_meta( '_billing_vat_id' ),
			'{site_title}'           => get_bloginfo( 'name' ),
			'{site_url}'             => home_url(),
			'{admin_email}'          => get_option( 'admin_email' ),
			'{shop_url}'             => wc_get_page_permalink( 'shop' ),
			'{my_account_url}'       => wc_get_page_permalink( 'myaccount' ),
			'{company_name}'         => $business['company_name'],
			'{company_address}'      => $business['company_address'],
			'{company_kvk}'          => $business['kvk_number'],
			'{company_vat}'          => $business['vat_number'],
			'{company_phone}'        => $business['phone'],
			'{company_email}'        => $business['email'],
			'{company_iban}'         => $business['iban'],
		];

		$replacements = apply_filters( 'kramar_email_merge_field_values', $replacements, $order );

		return str_replace( array_keys( $replacements ), array_values( $replacements ), $text );
	}

	/**
	 * Resolve merge fields against whatever context the email carries — a
	 * WC_Order (order emails) or a WP_User (account emails).
	 *
	 * @since 1.0.0
	 *
	 * @param \WC_Order|\WP_User|null $context Resolution context.
	 */
	private function resolve_merge_fields_context( string $text, $context ): string {
		if ( $context instanceof \WC_Order ) {
			return $this->resolve_merge_fields( $text, $context );
		}
		if ( $context instanceof \WP_User ) {
			return $this->resolve_merge_fields_user( $text, $context );
		}
		if ( is_array( $context ) ) {
			return $this->resolve_merge_fields_data( $text, $context );
		}
		return $text;
	}

	/**
	 * Resolve merge fields against a generic data context supplied by a
	 * non-order / non-user email — e.g. Back-in-Stock (product) or Abandoned
	 * Cart (cart). The email provides its own tag→value map via
	 * `kramar_block_context()`; the common site/company fields are added here so
	 * every block type behaves the same as it does for order emails.
	 *
	 * @since 1.0.0
	 *
	 * @param string               $text    Text containing merge tags.
	 * @param array<string, mixed> $context Data context: [ 'merge' => [ tag => value ], 'items' => [...] ].
	 * @return string Resolved text.
	 */
	private function resolve_merge_fields_data( string $text, array $context ): string {
		$business = $this->get_business_details();

		$replacements = [
			'{site_title}'      => get_bloginfo( 'name' ),
			'{site_url}'        => home_url(),
			'{admin_email}'     => get_option( 'admin_email' ),
			'{shop_url}'        => wc_get_page_permalink( 'shop' ),
			'{my_account_url}'  => wc_get_page_permalink( 'myaccount' ),
			'{company_name}'    => $business['company_name'],
			'{company_address}' => $business['company_address'],
			'{company_kvk}'     => $business['kvk_number'],
			'{company_vat}'     => $business['vat_number'],
			'{company_phone}'   => $business['phone'],
			'{company_email}'   => $business['email'],
			'{company_iban}'    => $business['iban'],
		];

		$merge = isset( $context['merge'] ) && is_array( $context['merge'] ) ? $context['merge'] : [];
		$replacements = array_merge( $replacements, $merge );

		/**
		 * Filter data-context merge values (Back-in-Stock, Abandoned Cart, etc).
		 *
		 * @param array<string, string> $replacements Tag → value map.
		 * @param array<string, mixed>  $context      Full data context.
		 */
		$replacements = apply_filters( 'kramar_email_merge_field_values_data', $replacements, $context );

		return str_replace( array_keys( $replacements ), array_values( $replacements ), $text );
	}

	/**
	 * Resolve account-context merge fields (new-account / password-reset emails).
	 *
	 * Without this, tokens such as {set_password_url} were sent verbatim,
	 * producing broken password links.
	 *
	 * @since 1.0.0
	 */
	public function resolve_merge_fields_user( string $text, \WP_User $user ): string {
		$business = $this->get_business_details();
		$set_url  = $this->build_set_password_url( $user );

		$replacements = [
			'{set_password_url}'    => $set_url,
			'{reset_password_url}'  => $set_url,
			'{my_account_url}'      => wc_get_page_permalink( 'myaccount' ),
			'{shop_url}'            => wc_get_page_permalink( 'shop' ),
			'{customer_first_name}' => $user->first_name,
			'{customer_last_name}'  => $user->last_name,
			'{customer_full_name}'  => trim( $user->first_name . ' ' . $user->last_name ) ?: $user->display_name,
			'{customer_email}'      => $user->user_email,
			'{user_login}'          => $user->user_login,
			'{site_title}'          => get_bloginfo( 'name' ),
			'{site_url}'            => home_url(),
			'{admin_email}'         => get_option( 'admin_email' ),
			'{company_name}'        => $business['company_name'],
			'{company_address}'     => $business['company_address'],
			'{company_kvk}'         => $business['kvk_number'],
			'{company_vat}'         => $business['vat_number'],
			'{company_phone}'       => $business['phone'],
			'{company_email}'       => $business['email'],
			'{company_iban}'        => $business['iban'],
		];

		$replacements = apply_filters( 'kramar_email_merge_field_values_user', $replacements, $user );

		return str_replace( array_keys( $replacements ), array_values( $replacements ), $text );
	}

	/**
	 * Build a WooCommerce-compatible set/reset-password URL for a user.
	 *
	 * @since 1.0.0
	 */
	private function build_set_password_url( \WP_User $user ): string {
		$account_url = wc_get_page_permalink( 'myaccount' );
		$key         = get_password_reset_key( $user );

		if ( is_wp_error( $key ) ) {
			return $account_url;
		}

		$base = function_exists( 'wc_get_endpoint_url' )
			? wc_get_endpoint_url( 'lost-password', '', $account_url )
			: $account_url;

		return add_query_arg(
			[
				'key' => $key,
				'id'  => $user->ID,
			],
			$base
		);
	}

	/**
	 * Enforce the per-email enable/disable toggle for core WooCommerce emails.
	 *
	 * The overview UI writes `kramar_email_overrides[$id]['enabled']` for core WC
	 * emails, but WooCommerce only stops sending an email when its own
	 * `woocommerce_email_enabled_{$id}` filter returns false. This bridges the two,
	 * so a core email switched off in Kramar actually stops sending. Custom emails
	 * manage their own enabled flag via their WC_Email subclass and are skipped.
	 *
	 * @since 1.0.0
	 */
	private function apply_core_email_toggles(): void {
		$overrides = get_option( self::OPTION_OVERRIDES, [] );
		if ( ! is_array( $overrides ) ) {
			return;
		}

		foreach ( $overrides as $email_id => $config ) {
			if ( ! is_array( $config ) || ! array_key_exists( 'enabled', $config ) ) {
				continue;
			}
			if ( str_starts_with( (string) $email_id, 'kramar_custom_' ) ) {
				continue;
			}
			if ( false === $config['enabled'] ) {
				add_filter( 'woocommerce_email_enabled_' . $email_id, '__return_false', 99 );
			}
		}
	}

	// ─── AJAX Handlers ──────────────────────────────────────────────────

	public function ajax_toggle_email(): void {
		check_ajax_referer( self::NONCE_ACTION );

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( 'Unauthorized' );
		}

		$email_id = sanitize_key( wp_unslash( $_POST['email_id'] ?? '' ) );
		$enabled  = ! empty( $_POST['enabled'] );

		// Anything with an entry in kramar_custom_emails keeps its enabled state
		// there, because that is the flag every send path checks. Keyed on the
		// entry rather than a "kramar_custom_" name prefix: status notifications
		// (kramar_status_*) and the withdrawal verification email live in the
		// same option under different prefixes, and writing their toggle to the
		// overrides instead left it with no effect on whether they sent.
		$custom = get_option( self::OPTION_CUSTOM, [] );
		if ( ! is_array( $custom ) ) {
			$custom = [];
		}

		if ( isset( $custom[ $email_id ] ) ) {
			$custom[ $email_id ]['enabled'] = $enabled;
			update_option( self::OPTION_CUSTOM, $custom );

			// Drop any state a previous version wrote to the overrides so the
			// two copies cannot disagree.
			$overrides = get_option( self::OPTION_OVERRIDES, [] );
			if ( is_array( $overrides ) && isset( $overrides[ $email_id ]['enabled'] ) ) {
				unset( $overrides[ $email_id ]['enabled'] );
				update_option( self::OPTION_OVERRIDES, $overrides );
			}

			wp_send_json_success();
		}

		if ( str_starts_with( $email_id, 'kramar_custom_' ) ) {
			wp_send_json_error( 'Unknown custom email' );
		}

		$overrides = get_option( self::OPTION_OVERRIDES, [] );
		if ( ! is_array( $overrides ) ) {
			$overrides = [];
		}

		if ( ! isset( $overrides[ $email_id ] ) ) {
			$overrides[ $email_id ] = [];
		}
		$overrides[ $email_id ]['enabled'] = $enabled;
		update_option( self::OPTION_OVERRIDES, $overrides );

		wp_send_json_success();
	}

	public function ajax_save_global_settings(): void {
		check_ajax_referer( self::NONCE_ACTION );

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( 'Unauthorized' );
		}

		// Merge into the stored option — only overwrite keys actually present in
		// the request. Two different forms post to this action: the email builder
		// sends all keys, while the Settings page's "Email Sender" form sends only
		// the five sender fields. Rebuilding the whole array from POST would reset
		// the absent builder keys (colors/footer/logo) to defaults on every
		// sender-form save. See audit finding G6.
		$settings = get_option( self::OPTION_GLOBAL, [] );
		if ( ! is_array( $settings ) ) {
			$settings = [];
		}

		if ( isset( $_POST['from_name'] ) ) {
			$settings['from_name'] = sanitize_text_field( wp_unslash( $_POST['from_name'] ) );
		}
		if ( isset( $_POST['from_email'] ) ) {
			$settings['from_email'] = sanitize_email( wp_unslash( $_POST['from_email'] ) );
		}
		if ( isset( $_POST['reply_to'] ) ) {
			$settings['reply_to'] = sanitize_email( wp_unslash( $_POST['reply_to'] ) );
		}
		if ( isset( $_POST['admin_recipients'] ) ) {
			$settings['admin_recipients'] = sanitize_text_field( wp_unslash( $_POST['admin_recipients'] ) );
		}
		if ( isset( $_POST['global_bcc'] ) ) {
			$settings['global_bcc'] = sanitize_text_field( wp_unslash( $_POST['global_bcc'] ) );
		}
		if ( isset( $_POST['footer_text'] ) ) {
			$settings['footer_text'] = sanitize_textarea_field( wp_unslash( $_POST['footer_text'] ) );
		}
		if ( isset( $_POST['logo_url'] ) ) {
			$settings['logo_url'] = esc_url_raw( wp_unslash( $_POST['logo_url'] ) );
		}
		if ( isset( $_POST['header_bg'] ) ) {
			$settings['header_bg'] = sanitize_hex_color( wp_unslash( $_POST['header_bg'] ) ) ?? '#2c3338';
		}
		if ( isset( $_POST['header_text_color'] ) ) {
			$settings['header_text_color'] = sanitize_hex_color( wp_unslash( $_POST['header_text_color'] ) ) ?? '#ffffff';
		}
		if ( isset( $_POST['accent_color'] ) ) {
			$settings['accent_color'] = sanitize_hex_color( wp_unslash( $_POST['accent_color'] ) ) ?? '#7f54b3';
		}
		if ( isset( $_POST['body_bg'] ) ) {
			$settings['body_bg'] = sanitize_hex_color( wp_unslash( $_POST['body_bg'] ) ) ?? '#f7f7f7';
		}
		if ( isset( $_POST['content_bg'] ) ) {
			$settings['content_bg'] = sanitize_hex_color( wp_unslash( $_POST['content_bg'] ) ) ?? '#ffffff';
		}
		if ( isset( $_POST['text_color'] ) ) {
			$settings['text_color'] = sanitize_hex_color( wp_unslash( $_POST['text_color'] ) ) ?? '#525252';
		}

		update_option( self::OPTION_GLOBAL, $settings );
		$this->global_settings = null;

		wp_send_json_success();
	}

	public function ajax_save_business_details(): void {
		check_ajax_referer( self::NONCE_ACTION );

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( 'Unauthorized' );
		}

		$keys = [
			'company_name', 'statutory_name', 'company_address', 'statutory_seat',
			'kvk_number', 'vat_number', 'legal_form', 'iban', 'email', 'phone',
		];

		$details = [];
		foreach ( $keys as $key ) {
			// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized via sanitize_text_field / sanitize_textarea_field on the next line
			$raw = wp_unslash( $_POST[ $key ] ?? '' );
			$details[ $key ] = 'company_address' === $key
				? sanitize_textarea_field( $raw )
				: sanitize_text_field( $raw );
		}

		// Global brand logo (used by PDF invoices and email header).
		$details['logo_url'] = esc_url_raw( wp_unslash( $_POST['logo_url'] ?? '' ) );

		update_option( self::OPTION_BUSINESS, $details );
		$this->business_details = null;

		wp_send_json_success();
	}

	public function ajax_save_blocks(): void {
		check_ajax_referer( self::NONCE_ACTION );

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( 'Unauthorized' );
		}

		$email_id = sanitize_key( wp_unslash( $_POST['email_id'] ?? '' ) );
		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- raw JSON string; nonce + capability are verified at the top of this handler and the decoded structure is sanitized via sanitize_blocks() below.
		$raw_blocks = wp_unslash( $_POST['blocks'] ?? '[]' );
		$blocks     = json_decode( $raw_blocks, true );

		if ( ! is_array( $blocks ) ) {
			wp_send_json_error( 'Invalid blocks data' );
		}

		$subject = sanitize_text_field( wp_unslash( $_POST['subject'] ?? '' ) );

		$overrides = get_option( self::OPTION_OVERRIDES, [] );
		if ( ! is_array( $overrides ) ) {
			$overrides = [];
		}

		if ( ! isset( $overrides[ $email_id ] ) ) {
			$overrides[ $email_id ] = [];
		}

		if ( '_template' === $email_id ) {
			$header_blocks = [];
			$footer_blocks = [];
			$in_footer     = false;
			foreach ( $blocks as $block ) {
				if ( 'content-placeholder' === ( $block['type'] ?? '' ) ) {
					$in_footer = true;
					continue;
				}
				if ( $in_footer ) {
					$footer_blocks[] = $block;
				} else {
					$header_blocks[] = $block;
				}
			}
			$overrides[ $email_id ]['header_blocks'] = $this->sanitize_blocks( $header_blocks );
			$overrides[ $email_id ]['footer_blocks'] = $this->sanitize_blocks( $footer_blocks );
			unset( $overrides[ $email_id ]['blocks'] );
		} else {
			$overrides[ $email_id ]['blocks'] = $this->sanitize_blocks( $blocks );
		}

		$overrides[ $email_id ]['subject'] = $subject;

		update_option( self::OPTION_OVERRIDES, $overrides );

		wp_send_json_success();
	}

	public function ajax_preview(): void {
		check_ajax_referer( self::NONCE_ACTION );

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( 'Unauthorized' );
		}

		$email_id = sanitize_key( wp_unslash( $_POST['email_id'] ?? '' ) );
		$blocks   = $this->get_email_blocks( $email_id );
		$config   = $this->get_email_config( $email_id );
		$settings = $this->get_global_settings();
		$business = $this->get_business_details();

		$orders = wc_get_orders( [ 'limit' => 1, 'type' => 'shop_order', 'status' => 'completed', 'orderby' => 'date', 'order' => 'DESC' ] );
		if ( empty( $orders ) ) {
			$orders = wc_get_orders( [ 'limit' => 1, 'type' => 'shop_order', 'orderby' => 'date', 'order' => 'DESC' ] );
		}
		$order = ! empty( $orders ) ? $orders[0] : null;

		$mailer    = \WC()->mailer();
		$wc_emails = $mailer->get_emails();
		$email_heading = $config['title'] ?? $email_id;
		foreach ( $wc_emails as $obj ) {
			if ( $obj->id === $email_id ) {
				if ( $order ) {
					$obj->object = $order;
					$obj->placeholders['{order_date}']   = wc_format_datetime( $order->get_date_created() );
					$obj->placeholders['{order_number}'] = $order->get_order_number();
				}
				$email_heading = $obj->get_heading();
				break;
			}
		}

		foreach ( $blocks as &$block ) {
			if ( isset( $block['settings'] ) && is_array( $block['settings'] ) ) {
				foreach ( $block['settings'] as $key => &$val ) {
					if ( is_string( $val ) ) {
						$val = str_replace( '{email_heading}', $email_heading, $val );
					}
				}
				unset( $val );
			}
		}
		unset( $block );

		$body_html = $this->render_blocks_to_html( $blocks, $order );

		wp_send_json_success( [ 'html' => $this->wrap_email_document( $body_html ) ] );
	}

	/**
	 * Render posted builder blocks to per-block HTML for the canvas — the
	 * builder's single source of truth (canvas === sent email).
	 *
	 * @since 1.0.0
	 */
	public function ajax_render(): void {
		check_ajax_referer( self::NONCE_ACTION );

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( 'Unauthorized' );
		}

		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- raw JSON string; nonce + capability are verified at the top of this handler and the decoded structure is sanitized via sanitize_blocks() below.
		$raw_blocks = wp_unslash( $_POST['blocks'] ?? '[]' );
		$blocks     = json_decode( $raw_blocks, true );
		if ( ! is_array( $blocks ) ) {
			wp_send_json_error( 'Invalid blocks data' );
		}

		$orders = wc_get_orders( [ 'limit' => 1, 'type' => 'shop_order', 'status' => 'completed', 'orderby' => 'date', 'order' => 'DESC' ] );
		if ( empty( $orders ) ) {
			$orders = wc_get_orders( [ 'limit' => 1, 'type' => 'shop_order', 'orderby' => 'date', 'order' => 'DESC' ] );
		}
		$order = ! empty( $orders ) ? $orders[0] : null;

		// Resolve {email_heading} exactly as ajax_preview() and the real send do.
		// This handler is what the canvas calls on load, edit and reorder, and it
		// had no such step — so every default Heading block showed the literal
		// "{email_heading}" while Preview showed the real one, contradicting this
		// builder's own promise that the canvas is what gets sent.
		$email_id      = isset( $_POST['email_id'] ) ? sanitize_key( wp_unslash( $_POST['email_id'] ) ) : '';
		$email_heading = '';
		if ( '' !== $email_id && function_exists( 'WC' ) && WC()->mailer() ) {
			foreach ( WC()->mailer()->get_emails() as $obj ) {
				if ( $obj instanceof \WC_Email && $obj->id === $email_id ) {
					if ( $order ) {
						$obj->object = $order;
					}
					$email_heading = (string) $obj->get_heading();
					break;
				}
			}

			if ( '' === $email_heading ) {
				$custom_emails = get_option( self::OPTION_CUSTOM, [] );
				$email_heading = (string) ( $custom_emails[ $email_id ]['title'] ?? '' );
			}
		}

		$rendered = [];
		foreach ( $blocks as $block ) {
			$id   = isset( $block['id'] ) ? sanitize_text_field( (string) $block['id'] ) : '';
			$norm = $this->sanitize_blocks( [ $block ] );
			$one  = $norm[0] ?? [];

			if ( '' !== $email_heading && isset( $one['settings'] ) && is_array( $one['settings'] ) ) {
				foreach ( $one['settings'] as $key => $val ) {
					if ( is_string( $val ) ) {
						$one['settings'][ $key ] = str_replace( '{email_heading}', $email_heading, $val );
					}
				}
			}

			$rendered[] = [
				'id'   => $id,
				'html' => $this->render_email_block_public( $one, $order ),
			];
		}

		wp_send_json_success( [ 'blocks' => $rendered ] );
	}

	/**
	 * @param array<int, array<string, mixed>> $blocks
	 * @param \WC_Order|\WP_User|array|null    $context Resolution context.
	 * @param \WC_Email|null                   $email   Email being sent, when this
	 *                                                  is a real send rather than a
	 *                                                  builder preview. Only then do
	 *                                                  WooCommerce's order-email hooks
	 *                                                  fire.
	 */
	private function render_blocks_to_html( array $blocks, $context = null, ?\WC_Email $email = null ): string {
		$accent = esc_attr( $this->get_global_settings()['accent_color'] ?? '#7f54b3' );
		$order  = $context instanceof \WC_Order ? $context : null;
		$hooked = null !== $order && null !== $email && self::email_fires_order_table_hooks( $email );

		$parts       = [];
		$footer_at   = null;
		$table_at    = null;
		$table_done  = false;
		$meta_done   = false;

		foreach ( $blocks as $block ) {
			$vis = $block['visibility'] ?? [];
			if ( ! empty( $vis['type'] ) && 'always' !== $vis['type'] && null !== $context ) {
				$field_val = $this->resolve_merge_fields_context( $vis['field'] ?? '', $context );
				if ( 'show_if' === $vis['type'] && '' === trim( $field_val ) ) {
					continue;
				}
				if ( 'hide_if' === $vis['type'] && '' !== trim( $field_val ) ) {
					continue;
				}
			}

			$type = $block['type'] ?? '';
			$html = $this->render_email_block( $block, $context, $accent );

			// The order table is where WooCommerce's own templates fire these,
			// so third-party output lands in the position it was written for.
			if ( $hooked && 'order-table' === $type ) {
				$html = $this->capture_order_email_hook( 'woocommerce_email_before_order_table', $order, $email )
					. $html
					. $this->capture_order_email_hook( 'woocommerce_email_after_order_table', $order, $email );
				$table_done = true;
				$table_at   = count( $parts );
			}

			// Order meta sits between the order details and the customer details
			// in WooCommerce's templates, and the addresses block is this
			// template's customer details. Firing it here rather than straight
			// after the table keeps the shop's own blocks — the withdrawal
			// button among them — next to the totals they were placed beside.
			if ( $hooked && ! $meta_done && 'addresses' === $type ) {
				$html      = $this->capture_order_email_hook( 'woocommerce_email_order_meta', $order, $email ) . $html;
				$meta_done = true;
			}

			if ( null === $footer_at && 'footer' === $type ) {
				$footer_at = count( $parts );
			}

			$parts[] = $html;
		}

		// A template built without an order table still has to carry gateway
		// instructions and anything else hooked on: place it above the footer.
		if ( $hooked && ! $table_done ) {
			$extra = $this->capture_order_email_hook( 'woocommerce_email_before_order_table', $order, $email )
				. $this->capture_order_email_hook( 'woocommerce_email_after_order_table', $order, $email );
			if ( '' !== trim( $extra ) ) {
				$at = $footer_at ?? count( $parts );
				array_splice( $parts, $at, 0, [ $extra ] );
				if ( null !== $footer_at ) {
					++$footer_at;
				}
			}
		}

		// No addresses block to anchor the order meta to: fall back to just
		// after the order table, or above the footer.
		if ( $hooked && ! $meta_done ) {
			$meta = $this->capture_order_email_hook( 'woocommerce_email_order_meta', $order, $email );
			if ( '' !== trim( $meta ) ) {
				array_splice( $parts, null !== $table_at ? $table_at + 1 : ( $footer_at ?? count( $parts ) ), 0, [ $meta ] );
			}
		}

		return implode( '', $parts );
	}

	/**
	 * Render a single block to email HTML. Single source of truth shared by the
	 * send path, the preview, and the builder canvas (via AJAX).
	 *
	 * @since 1.0.0
	 *
	 * @param array<string, mixed>      $block   Block data.
	 * @param \WC_Order|\WP_User|null   $context Resolution context.
	 * @param string                    $accent  Accent colour.
	 */
	private function render_email_block( array $block, $context, string $accent ): string {
		$type = $block['type'] ?? '';
		$s    = $block['settings'] ?? [];

		$content = '';
		switch ( $type ) {
				case 'header':
					$logo   = esc_url( $this->get_brand_logo() );
					$sname  = esc_html( $s['store_name'] ?? get_bloginfo( 'name' ) );
					$halign = esc_attr( $s['align'] ?? 'center' );
					if ( '' !== $logo ) {
						$content = '<div style="text-align:' . $halign . ';padding:16px 0;"><img src="' . $logo . '" alt="' . $sname . '" style="max-width:140px;height:auto;" /></div>';
					} else {
						$content = '<div style="text-align:' . $halign . ';padding:16px 0;"><span style="font-size:18px;font-weight:700;color:#111;">' . $sname . '</span></div>';
					}
					break;

				case 'heading':
					$text  = esc_html( $s['text'] ?? 'Heading' );
					if ( null !== $context ) {
						// Flattened before escaping, the same as the Data Field block:
						// a money field resolves through wc_price() and an address field
						// carries <br/>, and escaping either shows the customer the tags.
						$text = esc_html( self::to_plain_text( $this->resolve_merge_fields_context( $s['text'] ?? 'Heading', $context ) ) );
					}
					$level     = $s['level'] ?? 'h2';
					if ( ! in_array( $level, [ 'h2', 'h3', 'h4' ], true ) ) {
						$level = 'h2';
					}
					$h_align   = esc_attr( $s['align'] ?? 'left' );
					$h_color   = ! empty( $s['color'] ) ? esc_attr( $s['color'] ) : '#1a1a1a';
					$h_sizes   = [ 'h2' => '22px', 'h3' => '18px', 'h4' => '16px' ];
					$h_size    = $h_sizes[ $level ];
					$content = '<' . $level . ' style="font-size:' . $h_size . ';font-weight:700;color:' . $h_color . ';text-align:' . $h_align . ';margin:0 0 8px;line-height:1.3;">' . $text . '</' . $level . '>';
					break;

				case 'text':
					$text = wp_kses_post( $s['content'] ?? '' );
					if ( null !== $context ) {
						$text = wp_kses_post( $this->resolve_merge_fields_context( $s['content'] ?? '', $context ) );
					}
					$txt_clr  = ! empty( $s['color'] ) ? esc_attr( $s['color'] ) : $this->text_color();
					$txt_bg   = ! empty( $s['bg'] ) ? 'background:' . esc_attr( $s['bg'] ) . ';padding:14px 16px;border-radius:6px;' : '';
					$size_map = [ 'small' => '12px', 'large' => '16px' ];
					$txt_size = $size_map[ $s['size'] ?? '' ] ?? '14px';
					$content = '<p style="font-size:' . $txt_size . ';line-height:1.5;color:' . $txt_clr . ';margin:0 0 16px;' . $txt_bg . '">' . nl2br( $text ) . '</p>';
					break;

				case 'button':
					$label = esc_html( $s['text'] ?? 'Click' );
					$url   = esc_url( $s['url'] ?? '#' );
					$bg_c  = ! empty( $s['bg'] ) ? esc_attr( $s['bg'] ) : $accent;
					$txt_c = ! empty( $s['color'] ) ? esc_attr( $s['color'] ) : '#ffffff';
					$rad   = absint( $s['radius'] ?? 6 );
					if ( null !== $context ) {
						$url = esc_url( $this->resolve_merge_fields_context( $s['url'] ?? '#', $context ) );

						// A merge field that resolves to nothing leaves a button
						// that looks clickable and goes nowhere — worse than no
						// button. Only on a real send: the builder still shows
						// the block so it can be edited.
						if ( '' === $url ) {
							break;
						}
					}
					$btn_align = esc_attr( $s['align'] ?? 'center' );
					$content = '<div style="text-align:' . $btn_align . ';margin:24px 0;">';
					$content .= '<a href="' . $url . '" class="kramar-btn-full" style="display:inline-block;padding:14px 28px;background:' . $bg_c . ';color:' . $txt_c . ';text-decoration:none;border-radius:' . $rad . 'px;font-size:15px;font-weight:600;">' . $label . '</a>';
					$content .= '</div>';
					break;

				case 'notice':
					$text  = esc_html( $s['content'] ?? '' );
					$style = $s['style'] ?? 'info';
					$colors = [
						'info'    => [ '#eff6ff', '#3b82f6', '#1e40af' ],
						'warning' => [ '#fefce8', '#eab308', '#854d0e' ],
						'success' => [ '#f0fdf4', '#22c55e', '#166534' ],
						'error'   => [ '#fef2f2', '#ef4444', '#991b1b' ],
					];
					$c = $colors[ $style ] ?? $colors['info'];
					if ( null !== $context ) {
						// See the Heading block above — same escaping trap.
						$text = esc_html( self::to_plain_text( $this->resolve_merge_fields_context( $s['content'] ?? '', $context ) ) );
					}
					$content = '<div style="background:' . $c[0] . ';border-left:4px solid ' . $c[1] . ';padding:14px 16px;border-radius:4px;margin:16px 0;font-size:14px;line-height:22px;color:' . $c[2] . ';">' . $text . '</div>';
					break;

				case 'divider':
					$div_clr = ! empty( $s['color'] ) ? esc_attr( $s['color'] ) : '#e4e4e7';
					$div_mar = absint( $s['margin'] ?? 24 );
					$content = '<hr style="border:none;border-top:1px solid ' . $div_clr . ';margin:' . $div_mar . 'px 0;">';
					break;

				case 'spacer':
					$sp_h  = absint( $s['height'] ?? 24 );
					$sp_bg = ! empty( $s['bg_color'] ) ? 'background:' . esc_attr( $s['bg_color'] ) . ';' : '';
					// Table-row approach for Outlook compatibility.
					$content = '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">'
						. '<tr><td height="' . $sp_h . '" style="height:' . $sp_h . 'px;line-height:' . $sp_h . 'px;font-size:' . $sp_h . 'px;' . $sp_bg . '">&nbsp;</td></tr>'
						. '</table>';
					break;

				case 'raw-html':
					// Stored by admins but always sanitised on output — never trust raw storage.
					$raw_html = wp_kses_post( $s['content'] ?? '' );
					$content  = $raw_html;
					break;

				case 'image':
					$url = esc_url( $s['url'] ?? '' );
					$alt = esc_attr( $s['alt'] ?? '' );
					if ( '' !== $url ) {
						$img_width = esc_attr( $s['width'] ?? '100%' );
						$img_align = $s['align'] ?? 'center';
						$img_margin = 'center' === $img_align ? '16px auto' : ( 'right' === $img_align ? '16px 0 16px auto' : '16px auto 16px 0' );
						$link_url  = ! empty( $s['link'] ) ? esc_url( $s['link'] ) : '';
						$img_tag   = '<img src="' . $url . '" alt="' . $alt . '" style="max-width:' . $img_width . ';height:auto;border-radius:6px;display:block;margin:' . $img_margin . ';">';
						$content = $link_url ? '<a href="' . $link_url . '">' . $img_tag . '</a>' : $img_tag;
					}
					break;

				case 'order-table':
					if ( $context instanceof \WC_Order ) {
						$content = $this->render_order_table_html( $context, $s );
					} else {
						$content = '<p style="color:#a0aec0;font-style:italic;">' . esc_html__( '[Order table — preview requires an order]', 'kramar' ) . '</p>';
					}
					break;

				case 'items-table':
					if ( is_array( $context ) && ! empty( $context['items'] ) && is_array( $context['items'] ) ) {
						$content = $this->render_items_table_html( $context['items'], (string) ( $context['items_total'] ?? '' ) );
					} else {
						$content = '<p style="color:#a0aec0;font-style:italic;">' . esc_html__( '[Items table — preview shows live cart/product data when sent]', 'kramar' ) . '</p>';
					}
					break;

				case 'addresses':
					if ( $context instanceof \WC_Order ) {
						$content = $this->render_addresses_html( $context, $s );
					} else {
						$content = '<p style="color:#a0aec0;font-style:italic;">' . esc_html__( '[Addresses — preview requires an order]', 'kramar' ) . '</p>';
					}
					break;

				case 'data-field':
					$label_text = esc_html( $s['label'] ?? '' );
					$field      = $s['field'] ?? '{order_number}';
					// Reduced to text before escaping: a money field resolves to
					// markup, and escaping that shows the tags to the customer.
					$value      = null !== $context ? esc_html( self::to_plain_text( $this->resolve_merge_fields_context( $field, $context ) ) ) : esc_html( $field );
					$content = '<div style="font-size:14px;color:' . $this->text_color() . ';margin:8px 0;"><span style="color:#a0aec0;">' . $label_text . '</span> ' . $value . '</div>';
					break;

				case 'social':
					$icons = [
						'facebook'  => [ 'color' => '#1877F2', 'svg' => '<path fill="#fff" d="M18 2h-3a5 5 0 00-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 011-1h3z"/>' ],
						'instagram' => [ 'color' => '#E4405F', 'svg' => '<rect x="2" y="2" width="20" height="20" rx="5" fill="none" stroke="#fff" stroke-width="2"/><circle cx="12" cy="12" r="5" fill="none" stroke="#fff" stroke-width="2"/><circle cx="17.5" cy="6.5" r="1.5" fill="#fff"/>' ],
						'linkedin'  => [ 'color' => '#0A66C2', 'svg' => '<path fill="#fff" d="M16 8a6 6 0 016 6v7h-4v-7a2 2 0 00-4 0v7h-4v-7a6 6 0 016-6zM2 9h4v12H2zM4 6a2 2 0 100-4 2 2 0 000 4z"/>' ],
						'x'         => [ 'color' => '#000000', 'svg' => '<path fill="#fff" d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>' ],
						'youtube'   => [ 'color' => '#FF0000', 'svg' => '<path fill="#fff" d="M22.54 6.42a2.78 2.78 0 00-1.94-2C18.88 4 12 4 12 4s-6.88 0-8.6.46a2.78 2.78 0 00-1.94 2A29 29 0 001 12a29 29 0 00.46 5.58 2.78 2.78 0 001.94 2C5.12 20 12 20 12 20s6.88 0 8.6-.46a2.78 2.78 0 001.94-2A29 29 0 0023 12a29 29 0 00-.46-5.58zM9.75 15.02V8.98L15.5 12z"/>' ],
						'tiktok'    => [ 'color' => '#000000', 'svg' => '<path fill="#fff" d="M19.59 6.69a4.83 4.83 0 01-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 01-2.88 2.5 2.89 2.89 0 01-2.89-2.89 2.89 2.89 0 012.89-2.89c.28 0 .54.04.79.1v-3.5a6.37 6.37 0 00-.79-.05A6.34 6.34 0 003.15 15a6.34 6.34 0 0010.86-4.43V7.1a8.16 8.16 0 004.77 1.52V5.19a4.85 4.85 0 01-.81-.12 4.8 4.8 0 01-.38.62z"/>' ],
					];
					$links = '';
					foreach ( $icons as $platform => $meta ) {
						$url = trim( $s[ $platform ] ?? '' );
						if ( '' === $url ) {
							continue;
						}
						$links .= '<a href="' . esc_url( $url ) . '" style="display:inline-block;width:32px;height:32px;border-radius:50%;background:' . $meta['color'] . ';text-decoration:none;margin:0 4px;vertical-align:middle;" title="' . esc_attr( $platform ) . '">'
							. '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" style="display:block;margin:7px auto;">' . $meta['svg'] . '</svg>'
							. '</a>';
					}
					if ( '' !== $links ) {
						$content = '<div style="text-align:center;margin:16px 0;">' . $links . '</div>';
					}
					break;

				case 'footer':
					$biz     = $this->get_business_details();
					$parts   = [];
					$company = $biz['company_name'] ?? '';
					if ( '' === $company ) {
						$company = get_bloginfo( 'name', 'display' );
					}
					$parts[] = esc_html( $company );
					if ( ! empty( $biz['kvk_number'] ) ) {
						$parts[] = 'KVK: ' . esc_html( $biz['kvk_number'] );
					}
					if ( ! empty( $biz['vat_number'] ) ) {
						$parts[] = 'BTW: ' . esc_html( $biz['vat_number'] );
					}
					$content = '<div style="text-align:center;font-size:13px;color:#a0aec0;padding-top:24px;border-top:1px solid #e4e4e7;margin-top:24px;line-height:20px;">';
					$content .= implode( ' &middot; ', $parts );
					if ( ! empty( $biz['company_address'] ) ) {
						$content .= '<br>' . esc_html( $biz['company_address'] );
					}
					$contact = [];
					if ( ! empty( $biz['phone'] ) ) {
						$contact[] = esc_html( $biz['phone'] );
					}
					$biz_email = $biz['email'] ?? '';
					if ( '' === $biz_email ) {
						$biz_email = get_option( 'admin_email' );
					}
					$contact[] = esc_html( $biz_email );
					$content .= '<br>' . implode( ' &middot; ', $contact );
					$custom_text = $s['content'] ?? '';
					if ( '' !== $custom_text ) {
						$content .= '<br>' . esc_html( $custom_text );
					}
					$content .= '</div>';
					break;
		}

		return $content;
	}

	/**
	 * Render a single block for the builder canvas (single source of truth).
	 *
	 * @since 1.0.0
	 *
	 * @param array<string, mixed>    $block   Block data.
	 * @param \WC_Order|\WP_User|null $context Resolution context.
	 */
	public function render_email_block_public( array $block, $context = null ): string {
		$accent = esc_attr( $this->get_global_settings()['accent_color'] ?? '#7f54b3' );
		return $this->render_email_block( $block, $context, $accent );
	}

	/**
	 * Render a generic line-items table from a data context (Abandoned Cart,
	 * Back-in-Stock multi-item, etc.). Each item is [ 'name', 'qty', 'total' ].
	 *
	 * @since 1.0.0
	 *
	 * @param array<int, array<string, mixed>> $items Line items.
	 * @param string                           $total Pre-formatted grand total (optional).
	 * @return string Table HTML.
	 */
	private function render_items_table_html( array $items, string $total = '' ): string {
		$accent = esc_attr( $this->get_global_settings()['accent_color'] ?? '#7f54b3' );

		$html  = '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;margin:8px 0 16px;font-size:14px;color:' . $this->text_color() . ';">';
		$html .= '<thead><tr>'
			. '<th align="left" style="padding:8px 0;border-bottom:2px solid #e4e4e7;">' . esc_html__( 'Product', 'kramar' ) . '</th>'
			. '<th align="center" style="padding:8px 0;border-bottom:2px solid #e4e4e7;">' . esc_html__( 'Qty', 'kramar' ) . '</th>'
			. '<th align="right" style="padding:8px 0;border-bottom:2px solid #e4e4e7;">' . esc_html__( 'Total', 'kramar' ) . '</th>'
			. '</tr></thead><tbody>';

		foreach ( $items as $item ) {
			$name = esc_html( (string) ( $item['name'] ?? '' ) );
			$qty  = esc_html( (string) ( $item['qty'] ?? '' ) );
			$line = wp_kses_post( (string) ( $item['total'] ?? '' ) );
			$html .= '<tr>'
				. '<td align="left" style="padding:8px 0;border-bottom:1px solid #f0f0f0;">' . $name . '</td>'
				. '<td align="center" style="padding:8px 0;border-bottom:1px solid #f0f0f0;">' . $qty . '</td>'
				. '<td align="right" style="padding:8px 0;border-bottom:1px solid #f0f0f0;">' . $line . '</td>'
				. '</tr>';
		}

		$html .= '</tbody>';

		if ( '' !== $total ) {
			$html .= '<tfoot><tr>'
				. '<td colspan="2" align="left" style="padding:12px 0 0;font-weight:700;">' . esc_html__( 'Total', 'kramar' ) . '</td>'
				. '<td align="right" style="padding:12px 0 0;font-weight:700;color:' . $accent . ';">' . wp_kses_post( $total ) . '</td>'
				. '</tr></tfoot>';
		}

		$html .= '</table>';

		return $html;
	}

	/**
	 * @param array<string, mixed> $s Block settings.
	 */
	private function render_order_table_html( \WC_Order $order, array $s ): string {
		$show_image = ! empty( $s['show_image'] );

		$html = '<div style="font-size:11px;text-transform:uppercase;letter-spacing:1px;color:#a0aec0;font-weight:600;margin-bottom:16px;">' . esc_html__( 'Order Summary', 'kramar' ) . '</div>';
		$html .= '<div style="border-top:1px solid #e4e4e7;">';

		foreach ( $order->get_items() as $item ) {
			$product = $item->get_product();
			$html .= '<div style="display:flex;align-items:center;padding:16px 0;border-bottom:1px solid #f0f0f0;">';

			if ( $show_image ) {
				$img = $product ? wp_get_attachment_image_url( $product->get_image_id(), 'thumbnail' ) : '';
				$html .= '<div style="width:48px;height:48px;border-radius:6px;overflow:hidden;flex-shrink:0;margin-right:14px;background:#f6f6f6;">';
				if ( $img ) {
					$html .= '<img src="' . esc_url( $img ) . '" width="48" height="48" style="display:block;object-fit:cover;border-radius:6px;">';
				}
				$html .= '</div>';
			}

			$html .= '<div style="flex:1;min-width:0;">';
			$html .= '<div style="font-size:15px;font-weight:500;color:#111;">' . esc_html( $item->get_name() ) . '</div>';
			$html .= '<div style="font-size:13px;color:#a0aec0;margin-top:2px;">' . esc_html__( 'Qty:', 'kramar' ) . ' ' . esc_html( (string) $item->get_quantity() ) . '</div>';
			$html .= '</div>';

			$html .= '<div style="font-size:15px;font-weight:600;color:#111;text-align:right;flex-shrink:0;">' . wp_kses_post( $order->get_formatted_line_subtotal( $item ) ) . '</div>';
			$html .= '</div>';
		}

		$html .= '</div>';

		if ( ! empty( $s['show_totals'] ) ) {
			$totals = $order->get_order_item_totals();

			$html .= '<div style="margin-top:16px;padding-top:16px;border-top:1px solid #e4e4e7;">';

			foreach ( $totals as $key => $total ) {
				$label = wp_strip_all_tags( $total['label'] );
				$value = wp_kses_post( $total['value'] );

				if ( 'order_total' === $key ) {
					$html .= '<div style="display:flex;justify-content:space-between;padding:12px 0 0;margin-top:8px;border-top:1px solid #e4e4e7;font-size:18px;font-weight:700;color:#111;">';
					$html .= '<span>' . esc_html( $label ) . '</span>';
					$html .= '<span>' . $value . '</span>';
					$html .= '</div>';
				} elseif ( 'payment_method' === $key ) {
					$html .= '<div style="display:flex;justify-content:space-between;padding:8px 0;margin-top:8px;font-size:13px;color:#a0aec0;">';
					$html .= '<span>' . esc_html( $label ) . '</span>';
					$html .= '<span style="color:' . $this->text_color() . ';font-weight:400;">' . wp_strip_all_tags( $value ) . '</span>';
					$html .= '</div>';
				} else {
					$html .= '<div style="display:flex;justify-content:space-between;padding:4px 0;font-size:14px;color:' . $this->text_color() . ';">';
					$html .= '<span>' . esc_html( $label ) . '</span>';
					$html .= '<span>' . $value . '</span>';
					$html .= '</div>';
				}
			}

			$html .= '</div>';
		}

		return $html;
	}

	/**
	 * @param array<string, mixed> $s Block settings.
	 */
	private function render_addresses_html( \WC_Order $order, array $s ): string {
		$show     = $s['show'] ?? 'both';
		$billing  = $order->get_formatted_billing_address();
		$shipping = $order->get_formatted_shipping_address();

		if ( '' === $shipping ) {
			$shipping = $billing;
		}

		$html = '<div style="margin:24px 0;">';
		$html .= '<div style="font-size:11px;text-transform:uppercase;letter-spacing:1px;color:#a0aec0;font-weight:600;margin-bottom:12px;">' . esc_html__( 'Delivery Details', 'kramar' ) . '</div>';
		$html .= '<div class="kramar-addresses-outer" style="display:flex;gap:20px;background:#f9fafb;border-radius:8px;padding:20px;">';

		if ( 'both' === $show || 'shipping' === $show ) {
			$html .= '<div class="kramar-address-col" style="flex:1;">';
			$html .= '<div style="font-size:11px;text-transform:uppercase;letter-spacing:1px;color:#a0aec0;font-weight:600;margin-bottom:6px;">' . esc_html__( 'Ship to', 'kramar' ) . '</div>';
			$html .= '<div style="font-size:14px;line-height:22px;color:' . $this->text_color() . ';">' . wp_kses_post( $shipping ) . '</div>';
			$html .= '</div>';
		}

		if ( 'both' === $show || 'billing' === $show ) {
			$html .= '<div class="kramar-address-col" style="flex:1;">';
			$html .= '<div style="font-size:11px;text-transform:uppercase;letter-spacing:1px;color:#a0aec0;font-weight:600;margin-bottom:6px;">' . esc_html__( 'Bill to', 'kramar' ) . '</div>';
			$html .= '<div style="font-size:14px;line-height:22px;color:' . $this->text_color() . ';">' . wp_kses_post( $billing ) . '</div>';
			$html .= '</div>';
		}

		$html .= '</div>';
		$html .= '</div>';
		return $html;
	}

	public function ajax_test_send(): void {
		check_ajax_referer( self::NONCE_ACTION );

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( 'Unauthorized' );
		}

		$email_id   = sanitize_key( wp_unslash( $_POST['email_id'] ?? '' ) );
		$recipients = sanitize_text_field( wp_unslash( $_POST['recipients'] ?? '' ) );

		if ( '' === $recipients ) {
			$recipients = self::admin_recipient_default();
		}

		$orders = wc_get_orders( [ 'limit' => 1, 'type' => 'shop_order', 'status' => 'completed', 'orderby' => 'date', 'order' => 'DESC' ] );
		$order  = ! empty( $orders ) ? $orders[0] : null;

		$mailer    = \WC()->mailer();
		$wc_emails = $mailer->get_emails();

		$email_obj = null;
		foreach ( $wc_emails as $obj ) {
			if ( $obj->id === $email_id ) {
				$email_obj = $obj;
				break;
			}
		}

		if ( ! $email_obj || ! $order ) {
			wp_send_json_error( 'Email or order not found' );
		}

		$email_obj->recipient = $recipients;
		$email_obj->object    = $order;
		$result               = $email_obj->send(
			$recipients,
			$email_obj->get_subject(),
			$email_obj->get_content(),
			$email_obj->get_headers(),
			$email_obj->get_attachments()
		);

		if ( $result ) {
			wp_send_json_success();
		} else {
			wp_send_json_error( 'Failed to send' );
		}
	}

	public function ajax_create_email(): void {
		check_ajax_referer( self::NONCE_ACTION );

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( 'Unauthorized' );
		}

		$name             = sanitize_text_field( wp_unslash( $_POST['email_name'] ?? '' ) );
		$context          = sanitize_key( wp_unslash( $_POST['context'] ?? 'order' ) );
		$trigger_type     = sanitize_key( wp_unslash( $_POST['trigger_type'] ?? 'manual' ) );
		$trigger_from     = sanitize_key( wp_unslash( $_POST['trigger_from'] ?? '' ) );
		$trigger_to       = sanitize_key( wp_unslash( $_POST['trigger_to'] ?? '' ) );
		$anchor           = sanitize_key( wp_unslash( $_POST['anchor'] ?? 'completed' ) );
		$anchor_status    = sanitize_key( wp_unslash( $_POST['anchor_status'] ?? '' ) );
		$delay_value      = max( 0, absint( wp_unslash( $_POST['delay_value'] ?? 0 ) ) );
		$delay_unit       = 'hours' === sanitize_key( wp_unslash( $_POST['delay_unit'] ?? 'days' ) ) ? 'hours' : 'days';
		$recipient_type   = sanitize_key( wp_unslash( $_POST['recipient_type'] ?? 'customer' ) );
		$custom_recipient = sanitize_email( wp_unslash( $_POST['custom_recipient'] ?? '' ) );

		if ( '' === $name ) {
			wp_send_json_error( 'Name is required' );
		}

		$id = 'kramar_custom_' . sanitize_key( str_replace( ' ', '_', strtolower( $name ) ) );

		$custom = get_option( self::OPTION_CUSTOM, [] );
		if ( ! is_array( $custom ) ) {
			$custom = [];
		}

		// Names that differ only in case or punctuation collapse to the same id.
		// Without this guard the second one overwrites the first, while the
		// builder blocks and subject (stored in a different option, keyed by id)
		// survive — so the old template silently ends up on a different email.
		if ( isset( $custom[ $id ] ) ) {
			wp_send_json_error(
				sprintf(
					/* translators: %s: title of the existing email */
					__( 'An email called "%s" already exists — its internal id would be the same. Pick a different name.', 'kramar' ),
					(string) ( $custom[ $id ]['title'] ?? $name )
				)
			);
		}

		if ( 'delay' === $trigger_type ) {
			/* translators: 1: number of days/hours, 2: unit (days/hours), 3: anchor event label */
			$trigger_description = sprintf( __( '%1$d %2$s after %3$s', 'kramar' ), $delay_value, $delay_unit, $anchor );
		} elseif ( 'status_change' === $trigger_type ) {
			$trigger_description = sprintf( '%s → %s', $trigger_from ?: __( 'Any', 'kramar' ), $trigger_to );
		} else {
			$trigger_description = __( 'Manual', 'kramar' );
		}

		$custom[ $id ] = [
			'title'               => $name,
			'description'         => '',
			'context'             => $context,
			'trigger_type'        => $trigger_type,
			'trigger_from'        => $trigger_from,
			'trigger_to'          => $trigger_to,
			'anchor'              => $anchor,
			'anchor_status'       => $anchor_status,
			'delay_value'         => $delay_value,
			'delay_unit'          => $delay_unit,
			'recipient_type'      => $recipient_type,
			'recipient'           => 'custom' === $recipient_type ? $custom_recipient : '',
			'subject'             => $name,
			'enabled'             => true,
			'trigger_description' => $trigger_description,
		];

		update_option( self::OPTION_CUSTOM, $custom );

		wp_send_json_success( [ 'id' => $id, 'redirect' => admin_url( 'admin.php?page=' . self::BUILDER_PAGE_SLUG . '&email_id=' . $id ) ] );
	}

	public function ajax_delete_email(): void {
		check_ajax_referer( self::NONCE_ACTION );

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( 'Unauthorized' );
		}

		$email_id = sanitize_key( wp_unslash( $_POST['email_id'] ?? '' ) );
		if ( ! str_starts_with( $email_id, 'kramar_custom_' ) ) {
			wp_send_json_error( 'Can only delete custom emails' );
		}

		$custom = get_option( self::OPTION_CUSTOM, [] );
		if ( is_array( $custom ) ) {
			unset( $custom[ $email_id ] );
			update_option( self::OPTION_CUSTOM, $custom );
		}

		$overrides = get_option( self::OPTION_OVERRIDES, [] );
		if ( is_array( $overrides ) ) {
			unset( $overrides[ $email_id ] );
			update_option( self::OPTION_OVERRIDES, $overrides );
		}

		// Cancel any pending scheduled sends for this custom email.
		// as_unschedule_all_actions() requires the full args array to match the serialised
		// payload — the scheduled args include order_id too, so a partial-args call misses
		// everything.  Fetch pending actions for the hook + group, then cancel only those
		// whose email_id matches the deleted id.
		if ( function_exists( 'as_get_scheduled_actions' ) && function_exists( 'as_unschedule_action' ) ) {
			$pending = as_get_scheduled_actions(
				[
					'hook'     => 'kramar_send_custom_email',
					'group'    => 'kramar',
					'status'   => \ActionScheduler_Store::STATUS_PENDING,
					'per_page' => -1,
				],
				'ids'
			);
			foreach ( $pending as $action_id ) {
				$action = \ActionScheduler::store()->fetch_action( (int) $action_id );
				$args   = ( $action && method_exists( $action, 'get_args' ) ) ? $action->get_args() : [];
				if ( isset( $args['email_id'] ) && $args['email_id'] === $email_id ) {
					as_unschedule_action( 'kramar_send_custom_email', $args, 'kramar' );
				}
			}
		}

		wp_send_json_success();
	}

	// ─── Public Send API ────────────────────────────────────────────────

	/**
	 * Send a custom email now for a given context.
	 *
	 * Resolves the WC email instance registered via {@see register_custom_emails()}
	 * and calls its trigger() method. Calling WC()->mailer()->get_emails() both
	 * loads the Kramar_Custom_Email class (deferred until the woocommerce_email_classes
	 * filter fires) and returns all registered instances.
	 *
	 * @since 1.0.0
	 *
	 * @param string                           $id      Custom email id (e.g. kramar_custom_xxx).
	 * @param \WC_Order|\WP_User|int|null      $context Order object, order id, user, or null.
	 * @return bool True if the email was dispatched; false if the id is unknown or disabled.
	 */
	public function send_custom_email( string $id, mixed $context = null ): bool {
		$custom = get_option( self::OPTION_CUSTOM, [] );
		if ( ! is_array( $custom ) || empty( $custom[ $id ]['enabled'] ) ) {
			return false;
		}

		// Older versions of the Emails screen wrote the toggle for status and
		// withdrawal emails into the overrides instead. Keep honouring it so an
		// email switched off before that was fixed stays off.
		$overrides = get_option( self::OPTION_OVERRIDES, [] );
		if ( is_array( $overrides ) && isset( $overrides[ $id ]['enabled'] ) && false === $overrides[ $id ]['enabled'] ) {
			return false;
		}

		$order = null;
		if ( $context instanceof \WC_Order ) {
			$order = $context;
		} elseif ( is_int( $context ) ) {
			$order = wc_get_order( $context );
		} elseif ( is_string( $context ) && ctype_digit( $context ) ) {
			$order = wc_get_order( (int) $context );
		}

		// A context that was supplied but didn't resolve to an order is an error,
		// not "send it anyway". WC()->mailer() hands back singleton WC_Email
		// objects that persist for the request, and WC_Email::trigger() only
		// replaces $this->object when given a truthy order — so a second call
		// with an unresolvable context silently reused the PREVIOUS call's
		// order, sending one customer's details under another call's intent.
		if ( null !== $context && ! $order instanceof \WC_Order ) {
			return false;
		}

		$emails = WC()->mailer()->get_emails();
		$email  = $emails[ $id ] ?? null;
		if ( ! $email instanceof Kramar_Custom_Email ) {
			return false;
		}

		// Report what actually happened. The docblock promises "true if the
		// email was dispatched", but this returned true unconditionally — so a
		// caller could not tell a real send from a silent no-op caused by the
		// email being disabled, having no recipient, or a
		// kramar_email_should_send filter vetoing it.
		$dispatched = false;
		$watch      = static function ( $short, $atts ) use ( &$dispatched ) {
			$dispatched = true;
			return $short;
		};

		add_filter( 'pre_wp_mail', $watch, PHP_INT_MAX, 2 );
		try {
			$email->trigger( $order instanceof \WC_Order ? $order->get_id() : 0, $order );
		} finally {
			remove_filter( 'pre_wp_mail', $watch, PHP_INT_MAX );
		}

		return $dispatched;
	}

	/**
	 * Schedule a custom email send via Action Scheduler.
	 *
	 * @since 1.0.0
	 *
	 * @param \WC_Order|\WP_User|int|null $context Send context.
	 */
	public function schedule_custom_email( string $id, mixed $context, int $timestamp ): bool {
		if ( ! function_exists( 'as_schedule_single_action' ) ) {
			return false;
		}
		$order_id = $context instanceof \WC_Order ? $context->get_id() : (int) ( is_numeric( $context ) ? $context : 0 );
		$args     = [
			'email_id' => $id,
			'order_id' => $order_id,
		];
		if ( as_next_scheduled_action( 'kramar_send_custom_email', $args, 'kramar' ) ) {
			return true;
		}
		as_schedule_single_action( $timestamp, 'kramar_send_custom_email', $args, 'kramar' );
		return true;
	}

	/**
	 * Action Scheduler callback: deliver a scheduled custom email.
	 *
	 * @since 1.0.0
	 */
	public function run_scheduled_custom_email( string $email_id, int $order_id ): void {
		$order = $order_id ? wc_get_order( $order_id ) : null;
		if ( $order instanceof \WC_Order && '1' === (string) $order->get_meta( '_kramar_sent_' . $email_id ) ) {
			return; // already sent
		}
		if ( $this->send_custom_email( $email_id, $order ?: null ) && $order instanceof \WC_Order ) {
			$order->update_meta_data( '_kramar_sent_' . $email_id, '1' );
			$order->save_meta_data();
		}
	}

	// ─── Order "Send now" Metabox ────────────────────────────────────────

	/**
	 * Register the "Kramar Emails" metabox on the order edit screen for both
	 * the HPOS orders screen and the legacy shop_order CPT screen.
	 *
	 * @since 1.0.0
	 */
	public function register_order_send_metabox(): void {
		$screens = [ 'shop_order', 'woocommerce_page_wc-orders' ];
		foreach ( $screens as $screen ) {
			add_meta_box(
				'kramar-send-custom-email',
				__( 'Kramar Emails', 'kramar' ),
				[ $this, 'render_order_send_metabox' ],
				$screen,
				'side',
				'default'
			);
		}
	}

	/**
	 * Render the "Send now" metabox on the order edit screen.
	 *
	 * Shows a select of enabled custom order emails and a Send button.
	 * Uses vanilla JS (no jQuery) to POST via AJAX and display the result.
	 *
	 * @since 1.0.0
	 *
	 * @param \WP_Post|\WC_Order|int $post_or_order
	 */
	public function render_order_send_metabox( $post_or_order ): void {
		if ( $post_or_order instanceof \WP_Post ) {
			$order_id = $post_or_order->ID;
		} elseif ( $post_or_order instanceof \WC_Order ) {
			$order_id = $post_or_order->get_id();
		} else {
			$order_id = (int) $post_or_order;
		}
		$order = wc_get_order( $order_id );
		if ( ! $order ) {
			return;
		}

		// Collect enabled custom emails with context 'order'.
		$custom        = get_option( self::OPTION_CUSTOM, [] );
		$enabled_emails = [];
		if ( is_array( $custom ) ) {
			foreach ( $custom as $id => $config ) {
				if ( ! empty( $config['enabled'] ) && ( ( $config['context'] ?? 'order' ) === 'order' ) ) {
					$enabled_emails[ $id ] = $config['title'] ?? $id;
				}
			}
		}

		if ( empty( $enabled_emails ) ) {
			echo '<p style="margin:0;color:#646970;font-size:13px;">'
				. esc_html__( 'No custom emails yet. Create one under Kramar → Emails.', 'kramar' )
				. '</p>';
			return;
		}

		?>
		<div id="kramar-send-email-wrap">
			<p style="margin:0 0 8px;">
				<label for="kramar-send-email-select" style="font-weight:600;font-size:13px;display:block;margin-bottom:4px;">
					<?php esc_html_e( 'Select email', 'kramar' ); ?>
				</label>
				<select id="kramar-send-email-select" style="width:100%;">
					<?php foreach ( $enabled_emails as $id => $title ) : ?>
						<option value="<?php echo esc_attr( $id ); ?>"><?php echo esc_html( $title ); ?></option>
					<?php endforeach; ?>
				</select>
			</p>
			<button type="button" id="kramar-send-email-btn" class="button button-primary" style="width:100%;">
				<?php esc_html_e( 'Send now', 'kramar' ); ?>
			</button>
			<p id="kramar-send-email-msg" style="margin:6px 0 0;font-size:12px;display:none;"></p>
		</div>
		<?php
	}

	/**
	 * AJAX handler: send a custom email for a specific order.
	 *
	 * Requires a valid nonce and the `manage_woocommerce` capability.
	 *
	 * @since 1.0.0
	 */
	public function ajax_send_custom_email_now(): void {
		check_ajax_referer( 'kramar_send_custom_email_now' );

		if ( ! current_user_can( 'manage_woocommerce' ) ) {
			wp_send_json_error( [ 'reason' => 'unauthorized' ] );
		}

		$order_id = absint( wp_unslash( $_POST['order_id'] ?? 0 ) );
		$email_id = sanitize_key( wp_unslash( $_POST['email_id'] ?? '' ) );

		if ( ! $order_id || '' === $email_id ) {
			wp_send_json_error( [ 'reason' => 'missing_params' ] );
		}

		if ( ! wc_get_order( $order_id ) ) {
			wp_send_json_error( [ 'reason' => 'order_not_found' ] );
		}

		$sent = $this->send_custom_email( $email_id, $order_id );

		if ( $sent ) {
			wp_send_json_success( [ 'email_id' => $email_id, 'order_id' => $order_id ] );
		} else {
			wp_send_json_error( [ 'reason' => 'send_failed' ] );
		}
	}

	// ─── Helpers ────────────────────────────────────────────────────────

	/**
	 * @param array<int, array<string, mixed>> $blocks
	 * @return array<int, array<string, mixed>>
	 */
	private function sanitize_blocks( array $blocks ): array {
		$clean = [];
		$valid_types = array_keys( $this->get_block_type_definitions() );

		foreach ( $blocks as $block ) {
			if ( ! is_array( $block ) || ! isset( $block['type'] ) ) {
				continue;
			}

			$type = sanitize_key( $block['type'] );
			if ( ! in_array( $type, $valid_types, true ) ) {
				continue;
			}

			$clean_block = [
				'type'     => $type,
				'id'       => sanitize_key( $block['id'] ?? wp_generate_uuid4() ),
				'settings' => [],
			];

			if ( isset( $block['settings'] ) && is_array( $block['settings'] ) ) {
				// Blocks whose content is rendered as HTML (wp_kses_post) rather
				// than escaped. sanitize_text_field() strips every tag, so running
				// it over these emptied the Raw HTML block completely and silently
				// dropped any link or bold a merchant typed into a Text block —
				// the save destroyed the markup long before the renderer's own
				// wp_kses_post() ever saw it.
				$html_keys = [
					'text'     => [ 'content' ],
					'raw-html' => [ 'content' ],
				];
				$allows_html = $html_keys[ $type ] ?? [];

				foreach ( $block['settings'] as $key => $value ) {
					$key = sanitize_key( $key );
					if ( is_bool( $value ) ) {
						// Preserve booleans — sanitize_text_field() would turn
						// true into '1' and false into '', breaking toggles.
						$clean_block['settings'][ $key ] = $value;
					} elseif ( is_array( $value ) ) {
						$clean_block['settings'][ $key ] = array_map( 'sanitize_text_field', $value );
					} elseif ( in_array( $key, $allows_html, true ) ) {
						// Same allow-list WordPress uses for post content, and the
						// same one the renderer applies on output: scripts, iframes
						// and event handlers are still removed.
						$clean_block['settings'][ $key ] = wp_kses_post( (string) $value );
					} else {
						$clean_block['settings'][ $key ] = sanitize_text_field( (string) $value );
					}
				}
			}

			if ( isset( $block['visibility'] ) && is_array( $block['visibility'] ) ) {
				$clean_block['visibility'] = [
					'type'  => sanitize_key( $block['visibility']['type'] ?? 'always' ),
					'field' => sanitize_text_field( $block['visibility']['field'] ?? '' ),
					'value' => sanitize_text_field( $block['visibility']['value'] ?? '' ),
				];
			}

			if ( ! empty( $block['_tpl'] ) ) {
				$clean_block['_tpl'] = true;
			}

			$clean[] = $clean_block;
		}

		return $clean;
	}
}
