<?php
/**
 * Checkout Field Editor module.
 *
 * @package Kramar
 * @since   1.0.0
 */

declare( strict_types=1 );

namespace Kramar\Modules\Checkout;

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

/**
 * Checkout Field Editor module.
 *
 * Lets shop owners add, remove, reorder, and configure checkout fields in the
 * billing, shipping, and additional information sections. Field definitions are
 * stored in three dedicated options separate from the main settings array.
 * Replaces Checkout Field Editor for WooCommerce (ThemeHigh).
 *
 * @since 1.0.0
 */
final class Field_Editor extends \Kramar\Module {

	/**
	 * Option name for billing field definitions.
	 *
	 * @since 1.0.0
	 */
	private const OPTION_BILLING = 'kramar_checkout_fields_billing';

	/**
	 * Option name for shipping field definitions.
	 *
	 * @since 1.0.0
	 */
	private const OPTION_SHIPPING = 'kramar_checkout_fields_shipping';

	/**
	 * Option name for additional information field definitions.
	 *
	 * @since 1.0.0
	 */
	private const OPTION_ADDITIONAL = 'kramar_checkout_fields_additional';

	/**
	 * POST action for the field management form.
	 *
	 * @since 1.0.0
	 */
	private const FORM_ACTION = 'kramar_manage_checkout_field';

	/**
	 * Nonce action for the field management form.
	 *
	 * @since 1.0.0
	 */
	private const NONCE_ACTION = 'kramar_manage_checkout_field_nonce';

	/**
	 * Nonce action for the export handler.
	 *
	 * @since 1.0.0
	 */
	private const NONCE_EXPORT = 'kramar_export_checkout_fields';

	/**
	 * Nonce action for the import handler.
	 *
	 * @since 1.0.0
	 */
	private const NONCE_IMPORT = 'kramar_import_checkout_fields';

	/**
	 * Screen hook suffix for the visual builder page.
	 *
	 * Set by add_submenu_page() and used to gate asset enqueuing.
	 *
	 * @since 1.0.0
	 *
	 * @var string
	 */
	private string $builder_hook = '';

	/**
	 * Field types that are display-only (not real input fields).
	 *
	 * @since 1.0.0
	 */
	private const DISPLAY_TYPES = [ 'heading', 'paragraph' ];

	/**
	 * All supported field types.
	 *
	 * @since 1.0.0
	 */
	private const FIELD_TYPES = [
		'text'      => 'Text',
		'textarea'  => 'Textarea',
		'select'    => 'Select',
		'radio'     => 'Radio',
		'checkbox'  => 'Checkbox',
		'number'    => 'Number',
		'email'     => 'Email',
		'tel'       => 'Phone',
		'url'       => 'URL',
		'date'      => 'Date',
		'time'      => 'Time',
		'hidden'    => 'Hidden',
		'heading'   => 'Heading (display only)',
		'paragraph' => 'Paragraph (display only)',
	];

	/**
	 * {@inheritDoc}
	 */
	public function get_slug(): string {
		return 'field-editor';
	}

	/**
	 * {@inheritDoc}
	 */
	public function get_category(): string {
		return 'checkout';
	}

	/**
	 * {@inheritDoc}
	 */
	public function get_name(): string {
		return __( 'Checkout Field Editor', 'kramar' );
	}

	/**
	 * {@inheritDoc}
	 */
	public function get_description(): string {
		return __( 'Add, remove, reorder, and configure checkout fields in the billing, shipping, and additional information sections. Replaces Checkout Field Editor for WooCommerce (ThemeHigh).', 'kramar' );
	}

	/**
	 * {@inheritDoc}
	 *
	 * The module toggle is the only setting stored in the main `kramar_settings`
	 * option. Field definitions live in three dedicated options.
	 */
	public function get_default_settings(): array {
		return [
			'enabled' => false,
		];
	}

	/**
	 * {@inheritDoc}
	 */
	public function sanitize_settings( array $input ): array {
		return [
			'enabled' => ! empty( $input['enabled'] ),
		];
	}

	/**
	 * {@inheritDoc}
	 */
	protected function run(): void {
		// Inject fields into Classic checkout (late priority to override WC defaults).
		add_filter( 'woocommerce_billing_fields', [ $this, 'filter_billing_fields' ], 1000 );
		add_filter( 'woocommerce_shipping_fields', [ $this, 'filter_shipping_fields' ], 1000 );
		add_filter( 'woocommerce_checkout_fields', [ $this, 'filter_additional_fields' ], 1000 );

		// Blocks checkout: register custom fields via the WC Blocks API.
		// woocommerce_blocks_loaded has already fired by the time run() is called
		// (WC boots during plugins_loaded; Kramar's run() is called on init priority 0).
		// Call the registration method directly; it guards on function_exists() internally.
		$this->register_blocks_fields();

		// Validation and persistence.
		add_action( 'woocommerce_after_checkout_validation', [ $this, 'validate_custom_fields' ], 10, 2 );
		add_action( 'woocommerce_checkout_update_order_meta', [ $this, 'save_custom_fields' ], 10, 2 );

		// Blocks / Store API checkout: bridge Store API meta (kramar/<key>) → classic
		// meta key (_<key>) so admin display, emails, and order pages stay consistent.
		add_action( 'woocommerce_store_api_checkout_order_processed', [ $this, 'save_blocks_custom_fields' ] );

		// Display custom fields in admin order detail.
		add_filter( 'woocommerce_admin_billing_fields', [ $this, 'admin_billing_fields' ] );
		add_filter( 'woocommerce_admin_shipping_fields', [ $this, 'admin_shipping_fields' ] );
		add_action( 'woocommerce_admin_order_data_after_shipping_address', [ $this, 'admin_additional_fields' ] );

		// Display in order emails.
		add_filter( 'woocommerce_email_order_meta_fields', [ $this, 'email_order_meta_fields' ], 10, 3 );

		// Kramar Email Manager merge fields for each custom field (builder templates
		// bypass the woocommerce_email_order_meta_fields hook above).
		add_filter( 'kramar_email_merge_fields', [ $this, 'register_merge_fields' ], 10, 2 );
		add_filter( 'kramar_email_merge_field_values', [ $this, 'resolve_merge_fields' ], 10, 2 );

		// Orders-list column for additional custom fields (HPOS + legacy).
		add_filter( 'manage_woocommerce_page_wc-orders_columns', [ $this, 'add_orders_column' ] );
		add_action( 'manage_woocommerce_page_wc-orders_custom_column', [ $this, 'render_orders_column' ], 10, 2 );
		add_filter( 'manage_edit-shop_order_columns', [ $this, 'add_orders_column' ] );
		add_action( 'manage_shop_order_posts_custom_column', [ $this, 'render_orders_column_legacy' ], 10, 2 );

		// Display on order received / account order detail pages.
		add_action( 'woocommerce_order_details_after_order_table', [ $this, 'order_received_fields' ] );

		// Admin form handler (PRG) — kept as fallback.
		add_action( 'admin_post_' . self::FORM_ACTION, [ $this, 'handle_field_form' ] );

		// Visual builder sub-page and assets.
		if ( is_admin() ) {
			add_action( 'admin_menu', [ $this, 'add_admin_page' ] );
			add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_builder_assets' ] );
		}

		// AJAX save handler for the visual builder.
		add_action( 'wp_ajax_kramar_save_checkout_fields', [ $this, 'ajax_save_fields' ] );

		// My Account → Edit Address: inject and save custom fields.
		add_filter( 'woocommerce_address_to_edit', [ $this, 'inject_address_edit_fields' ], 10, 2 );
		add_action( 'woocommerce_customer_save_address', [ $this, 'save_address_edit_fields' ], 10, 2 );

		// Import / Export handlers.
		add_action( 'admin_post_kramar_export_checkout_fields', [ $this, 'handle_export' ] );
		add_action( 'admin_post_kramar_import_checkout_fields', [ $this, 'handle_import' ] );
	}

	// -------------------------------------------------------------------------
	// Classic Checkout Field Injection
	// -------------------------------------------------------------------------

	/**
	 * Merge saved billing field config with WooCommerce defaults.
	 *
	 * @since 1.0.0
	 *
	 * @param array<string, array<string, mixed>> $fields WC billing fields.
	 * @return array<string, array<string, mixed>> Modified fields.
	 */
	public function filter_billing_fields( array $fields ): array {
		return $this->apply_field_config( $fields, 'billing' );
	}

	/**
	 * Merge saved shipping field config with WooCommerce defaults.
	 *
	 * @since 1.0.0
	 *
	 * @param array<string, array<string, mixed>> $fields WC shipping fields.
	 * @return array<string, array<string, mixed>> Modified fields.
	 */
	public function filter_shipping_fields( array $fields ): array {
		return $this->apply_field_config( $fields, 'shipping' );
	}

	/**
	 * Merge saved additional field config into the WC checkout fields array.
	 *
	 * The `woocommerce_checkout_fields` filter receives the full array (billing,
	 * shipping, account, order). We only touch the 'order' section here.
	 *
	 * @since 1.0.0
	 *
	 * @param array<string, array<string, array<string, mixed>>> $fields All checkout field groups.
	 * @return array<string, array<string, array<string, mixed>>> Modified fields.
	 */
	public function filter_additional_fields( array $fields ): array {
		$fields['order'] = $this->apply_field_config( $fields['order'] ?? [], 'additional' );
		return $fields;
	}

	/**
	 * Merge saved field definitions for a section onto the WC fields array.
	 *
	 * - Saved fields with `enabled = false` are removed.
	 * - Saved fields that match existing WC keys override WC defaults.
	 * - Custom fields (not in WC defaults) are added.
	 * - `heading` / `paragraph` types are injected as display-only wrappers.
	 * - Result is sorted by `priority`.
	 *
	 * @since 1.0.0
	 *
	 * @param array<string, array<string, mixed>> $wc_fields WC fields for the section.
	 * @param string                              $section   'billing'|'shipping'|'additional'.
	 * @return array<string, array<string, mixed>> Merged, sorted fields.
	 */
	private function apply_field_config( array $wc_fields, string $section ): array {
		$saved = $this->get_fields( $section );

		if ( empty( $saved ) ) {
			return $wc_fields;
		}

		// Index saved fields by key for O(1) lookups.
		$saved_by_key = [];
		foreach ( $saved as $field_def ) {
			$key = sanitize_key( $field_def['key'] ?? '' );
			if ( '' !== $key ) {
				$saved_by_key[ $key ] = $field_def;
			}
		}

		// Remove or override WC default fields.
		foreach ( array_keys( $wc_fields ) as $field_key ) {
			if ( ! isset( $saved_by_key[ $field_key ] ) ) {
				continue;
			}

			$def = $saved_by_key[ $field_key ];

			if ( empty( $def['enabled'] ) ) {
				// Disabled — remove from output.
				unset( $wc_fields[ $field_key ] );
				continue;
			}

			// Override WC defaults with saved config.
			if ( isset( $def['label'] ) && '' !== $def['label'] ) {
				$wc_fields[ $field_key ]['label'] = sanitize_text_field( $def['label'] );
			}

			if ( isset( $def['placeholder'] ) ) {
				$wc_fields[ $field_key ]['placeholder'] = sanitize_text_field( $def['placeholder'] );
			}

			if ( isset( $def['required'] ) ) {
				$wc_fields[ $field_key ]['required'] = (bool) $def['required'];
			}

			if ( ! empty( $def['class'] ) && is_array( $def['class'] ) ) {
				$wc_fields[ $field_key ]['class'] = array_map( 'sanitize_html_class', $def['class'] );
			}

			if ( isset( $def['default'] ) ) {
				$wc_fields[ $field_key ]['default'] = sanitize_text_field( $def['default'] );
			}

			if ( isset( $def['priority'] ) ) {
				$wc_fields[ $field_key ]['priority'] = (int) $def['priority'];
			}
		}

		// Add custom fields and display-only elements.
		foreach ( $saved_by_key as $key => $def ) {
			if ( empty( $def['enabled'] ) ) {
				continue;
			}

			// Skip WC default fields — already handled above.
			if ( isset( $wc_fields[ $key ] ) ) {
				continue;
			}

			if ( ! empty( $def['custom'] ) ) {
				$type = sanitize_key( $def['type'] ?? 'text' );

				if ( in_array( $type, self::DISPLAY_TYPES, true ) ) {
					$wc_fields[ $key ] = $this->build_display_field( $def );
				} else {
					$wc_fields[ $key ] = $this->build_wc_field( $def );
				}
			}
		}

		// Sort all fields by priority.
		uasort(
			$wc_fields,
			static fn( array $a, array $b ) => ( (int) ( $a['priority'] ?? 10 ) ) <=> ( (int) ( $b['priority'] ?? 10 ) )
		);

		return $wc_fields;
	}

	/**
	 * Build a WC-compatible field definition array from a saved field definition.
	 *
	 * @since 1.0.0
	 *
	 * @param array<string, mixed> $def Saved field definition.
	 * @return array<string, mixed> WC field array.
	 */
	private function build_wc_field( array $def ): array {
		$type    = sanitize_key( $def['type'] ?? 'text' );
		$classes = is_array( $def['class'] ?? '' ) ? array_map( 'sanitize_html_class', $def['class'] ) : [ 'form-row-wide' ];

		$field = [
			'type'        => $type,
			'label'       => sanitize_text_field( $def['label'] ?? '' ),
			'placeholder' => sanitize_text_field( $def['placeholder'] ?? '' ),
			'required'    => (bool) ( $def['required'] ?? false ),
			'class'       => $classes,
			'default'     => sanitize_text_field( $def['default'] ?? '' ),
			'priority'    => (int) ( $def['priority'] ?? 50 ),
		];

		// Add select/radio options.
		if ( in_array( $type, [ 'select', 'radio' ], true ) && ! empty( $def['options'] ) ) {
			$options_raw = (string) $def['options'];
			$options_arr = array_filter( array_map( 'trim', explode( '|', $options_raw ) ) );
			$field['options'] = array_combine( $options_arr, $options_arr ) ?: [];
		}

		return $field;
	}

	/**
	 * Build a display-only (heading/paragraph) woocommerce_form_field wrapper.
	 *
	 * These use WC's `woocommerce_form_field` type hook to render arbitrary HTML.
	 *
	 * @since 1.0.0
	 *
	 * @param array<string, mixed> $def Saved field definition.
	 * @return array<string, mixed> WC-compatible field array with a custom type.
	 */
	private function build_display_field( array $def ): array {
		$type = sanitize_key( $def['type'] ?? 'heading' );

		return [
			'type'     => 'kramar_' . $type,
			'label'    => sanitize_text_field( $def['label'] ?? '' ),
			'class'    => [ 'form-row-wide', 'kramar-display-field' ],
			'priority' => (int) ( $def['priority'] ?? 50 ),
			'required' => false,
		];
	}

	// -------------------------------------------------------------------------
	// Blocks Checkout Field Registration
	// -------------------------------------------------------------------------

	/**
	 * Register custom additional checkout fields via the WC Blocks API (WC 8.9+).
	 *
	 * Only custom fields are registered here (WC handles its own defaults).
	 * Billing custom fields → `location: 'address'`
	 * Additional custom fields → `location: 'order'`
	 * Shipping custom fields are not separately registerable in the Blocks API;
	 * they surface via the shipping address location.
	 *
	 * @since 1.0.0
	 */
	public function register_blocks_fields(): void {
		if ( ! function_exists( 'woocommerce_register_additional_checkout_field' ) ) {
			return;
		}

		$this->register_blocks_section_fields( 'billing', 'address' );
		$this->register_blocks_section_fields( 'additional', 'order' );

		add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_blocks_type_fix' ] );
	}

	/**
	 * Inject JS to patch HTML input types on Blocks checkout.
	 *
	 * The WC Additional Checkout Fields API renders all text-type fields as
	 * type="text". This script upgrades them to their real HTML types so
	 * browsers show date pickers, numeric keyboards, email validation, etc.
	 *
	 * @since 1.0.0
	 */
	public function enqueue_blocks_type_fix(): void {
		if ( ! function_exists( 'is_checkout' ) || ! is_checkout() ) {
			return;
		}
		if ( ! wp_script_is( 'wc-blocks-checkout', 'enqueued' ) && ! wp_script_is( 'wc-blocks-checkout', 'registered' ) ) {
			return;
		}

		$type_map = [];
		foreach ( [ 'billing', 'additional' ] as $section ) {
			foreach ( $this->get_fields( $section ) as $def ) {
				if ( empty( $def['custom'] ) || empty( $def['enabled'] ) ) {
					continue;
				}
				$key  = sanitize_key( $def['key'] ?? '' );
				$type = sanitize_key( $def['type'] ?? 'text' );
				if ( '' === $key || in_array( $type, [ 'text', 'select', 'checkbox', 'hidden' ], true ) || in_array( $type, self::DISPLAY_TYPES, true ) ) {
					continue;
				}
				$entry = [ 'type' => $type ];
				if ( 'radio' === $type && ! empty( $def['options'] ) ) {
					$entry['options'] = array_filter( array_map( 'trim', explode( '|', (string) $def['options'] ) ) );
				}
				$type_map[ $key ] = $entry;
			}
		}

		if ( empty( $type_map ) ) {
			return;
		}

		$js_map = wp_json_encode( $type_map );
		// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_json_encode output passed to wp_add_inline_script, not echoed directly
		wp_add_inline_script(
			'wc-blocks-checkout',
			'try{' .
			'var kramarFM=' . $js_map . ';' .
			'function kramarFix(){' .
			'Object.keys(kramarFM).forEach(function(k){' .
			'var d=kramarFM[k],t=d.type,el=document.querySelector(\'[id*="kramar-\'+k+\'"]\');' .
			'if(!el)return;' .
			'var w=el.closest(\'.wc-block-components-text-input,.wc-block-components-select-input\');' .
			'if(!w)w=el.parentElement;' .
			'if(w.dataset.kramarFixed)return;' .
			'if(t===\'radio\'&&el.tagName===\'SELECT\'&&d.options){' .
			'var name=el.name,val=el.value,lbl=w.querySelector(\'label\');' .
			'var div=document.createElement(\'div\');' .
			'div.className=\'kramar-radio-group\';' .
			'if(lbl){var h=document.createElement(\'div\');h.className=\'kramar-radio-label\';h.textContent=lbl.textContent.replace(\'(optional)\',\'\').trim();div.appendChild(h);}' .
			'd.options.forEach(function(opt){' .
			'var l=document.createElement(\'label\');l.className=\'kramar-radio-option\';' .
			'var inp=document.createElement(\'input\');inp.type=\'radio\';inp.name=name;inp.value=opt;' .
			'if(opt===val)inp.checked=true;' .
			'inp.addEventListener(\'change\',function(){el.value=opt;el.dispatchEvent(new Event(\'change\',{bubbles:true}));});' .
			'l.appendChild(inp);l.appendChild(document.createTextNode(\' \'+opt));' .
			'div.appendChild(l);' .
			'});' .
			'var hidden=document.createElement(\'select\');hidden.name=name;hidden.id=el.id;hidden.style.display=\'none\';' .
			'Array.from(el.options).forEach(function(o){var no=document.createElement(\'option\');no.value=o.value;no.textContent=o.textContent;hidden.appendChild(no);});' .
			'hidden.value=val;' .
			'w.innerHTML=\'\';w.appendChild(div);w.appendChild(hidden);' .
			'w.dataset.kramarFixed=\'1\';' .
			'}else if(t===\'textarea\'&&el.tagName===\'INPUT\'){' .
			'var ta=document.createElement(\'textarea\');' .
			'ta.id=el.id;ta.name=el.name;ta.value=el.value;ta.rows=3;ta.placeholder=el.getAttribute(\'aria-label\')||el.placeholder||\'\';' .
			'ta.style.cssText=\'width:100%;min-height:100px;padding:16px 12px;border:1px solid rgba(43,45,47,.8);border-radius:4px;font-size:16px;font-family:inherit;resize:vertical;box-sizing:border-box;line-height:1.5;color:#2b2d2f;\';' .
			'ta.setAttribute(\'aria-label\',el.getAttribute(\'aria-label\')||\'\');' .
			'var lbl=w.querySelector(\'label\');' .
			'w.innerHTML=\'\';w.style.cssText=\'flex-basis:100%!important;max-width:100%!important;\';' .
			'if(lbl){lbl.style.cssText=\'display:block;margin-bottom:4px;font-size:14px;color:#646970;position:static;transform:none;\';w.appendChild(lbl);}' .
			'w.appendChild(ta);' .
			'ta.addEventListener(\'input\',function(){ta.dispatchEvent(new Event(\'change\',{bubbles:true}));});' .
			'w.dataset.kramarFixed=\'1\';' .
			'}else if(el.tagName===\'INPUT\'&&el.type!==t){' .
			'el.setAttribute(\'type\',t);' .
			'var modes={number:\'numeric\',email:\'email\',tel:\'tel\',url:\'url\'};' .
			'if(modes[t])el.setAttribute(\'inputmode\',modes[t]);' .
			'if(t===\'date\'||t===\'time\'){' .
			'var lbl=w.querySelector(\'label\');' .
			'if(lbl){lbl.style.cssText=\'display:block;margin-bottom:4px;font-size:13px;color:#646970;position:static;transform:none;pointer-events:auto;\';}' .
			'el.style.cssText=\'width:100%;height:50px;padding:16px 12px;border:1px solid rgba(43,45,47,.8);border-radius:4px;font-size:16px;box-sizing:border-box;font-family:inherit;\';' .
			'}' .
			'w.dataset.kramarFixed=\'1\';' .
			'}' .
			'});' .
			'}' .
			'var ss=document.createElement(\'style\');' .
			'ss.textContent=' .
			'\'.kramar-radio-group{padding:12px 0}\'+' .
			'\'.kramar-radio-label{font-size:14px;font-weight:500;color:#2b2d2f;margin-bottom:8px}\'+' .
			'\'.kramar-radio-option{display:flex;align-items:center;gap:8px;padding:6px 0;cursor:pointer;font-size:16px}\'+' .
			'\'.kramar-radio-option input[type=radio]{width:18px;height:18px;margin:0;accent-color:#2271b1}\'+' .
			'\'input[type=date][id*=kramar],input[type=time][id*=kramar]{\'+' .
			'\'  width:100%;height:50px;padding:16px 12px;border:1px solid rgba(43,45,47,.8);border-radius:4px;\'+' .
			'\'  font-size:16px;line-height:25px;box-sizing:border-box;background:#fff;font-family:inherit;color:#2b2d2f}\'+' .
			'\'input[type=date][id*=kramar]:focus,input[type=time][id*=kramar]:focus{\'+' .
			'\'  border-color:#2271b1;box-shadow:0 0 0 1px #2271b1;outline:none}\'+' .
			'\'input[type=number][id*=kramar],input[type=email][id*=kramar],input[type=tel][id*=kramar],input[type=url][id*=kramar]{\'+' .
			'\'  font-family:inherit}\'+' .
			'\'textarea[id*=kramar]{width:100%!important;min-height:100px;padding:16px 12px;border:1px solid rgba(43,45,47,.8);border-radius:4px;font-family:inherit;font-size:16px;color:#2b2d2f;line-height:1.5;box-sizing:border-box;resize:vertical}\'+' .
			'\'textarea[id*=kramar]:focus{border-color:#2271b1;box-shadow:0 0 0 1px #2271b1;outline:none}\'+' .
			'\'.wc-block-components-text-input:has(textarea[id*=kramar]){flex-basis:100%!important;max-width:100%!important}\';' .
			'document.head.appendChild(ss);' .
			'new MutationObserver(kramarFix).observe(document.body,{childList:true,subtree:true});' .
			'setTimeout(kramarFix,500);setTimeout(kramarFix,1500);setTimeout(kramarFix,3000);' .
			'}catch(e){}'
		);
	}

	/**
	 * Register Blocks API fields for one section.
	 *
	 * @since 1.0.0
	 *
	 * @param string $section  'billing'|'additional'.
	 * @param string $location WC Blocks location string: 'address'|'order'.
	 */
	private function register_blocks_section_fields( string $section, string $location ): void {
		foreach ( $this->get_fields( $section ) as $def ) {
			if ( empty( $def['custom'] ) || empty( $def['enabled'] ) ) {
				continue;
			}

			$key  = sanitize_key( $def['key'] ?? '' );
			$type = sanitize_key( $def['type'] ?? 'text' );

			if ( '' === $key ) {
				continue;
			}

			// Skip display-only types — no input to register.
			if ( in_array( $type, self::DISPLAY_TYPES, true ) ) {
				continue;
			}

			// Blocks API supports text, select, checkbox. Map closest equivalents.
			$blocks_type = match ( $type ) {
				'select', 'radio' => 'select',
				'checkbox'        => 'checkbox',
				default           => 'text',
			};

			$args = [
				'id'       => 'kramar/' . $key,
				'label'    => sanitize_text_field( $def['label'] ?? $key ),
				'location' => $location,
				'type'     => $blocks_type,
				'required' => (bool) ( $def['required'] ?? false ),
			];

			if ( 'select' === $blocks_type && ! empty( $def['options'] ) && in_array( $type, [ 'select', 'radio' ], true ) ) {
				$options_raw = (string) $def['options'];
				$options_arr = array_filter( array_map( 'trim', explode( '|', $options_raw ) ) );
				$args['options'] = array_map(
					static fn( string $opt ) => [ 'value' => $opt, 'label' => $opt ],
					array_values( $options_arr )
				);
			}

			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			try {
				woocommerce_register_additional_checkout_field( $args );
			} catch ( \Throwable $e ) {
				// Silently skip if registration fails (e.g. duplicate ID).
			}
		}
	}

	// -------------------------------------------------------------------------
	// Validation
	// -------------------------------------------------------------------------

	/**
	 * Validate required custom fields on Classic checkout submission.
	 *
	 * @since 1.0.0
	 *
	 * @param array<string, mixed> $data   Posted checkout data (unused).
	 * @param \WP_Error            $errors Error collector.
	 */
	public function validate_custom_fields( array $data, \WP_Error $errors ): void {
		// WooCommerce already enforces `required` on every field present in its own
		// checkout fields (billing / shipping / order), which is exactly where this
		// module injects its custom fields. Re-validating them here produced a
		// duplicate "<field> is a required field." notice next to WC's own
		// ("Billing <field> is a required field."). Build the set of keys WC already
		// knows about so we only validate a field WC is NOT already validating.
		$wc_field_keys = [];
		if ( function_exists( 'WC' ) && WC()->checkout ) {
			foreach ( WC()->checkout->get_checkout_fields() as $group ) {
				if ( is_array( $group ) ) {
					foreach ( array_keys( $group ) as $wc_key ) {
						$wc_field_keys[ $wc_key ] = true;
					}
				}
			}
		}

		// phpcs:disable WordPress.Security.NonceVerification.Missing
		foreach ( [ 'billing', 'shipping', 'additional' ] as $section ) {
			foreach ( $this->get_fields( $section ) as $def ) {
				if ( empty( $def['custom'] ) || empty( $def['enabled'] ) || empty( $def['required'] ) ) {
					continue;
				}

				$key  = sanitize_key( $def['key'] ?? '' );
				$type = sanitize_key( $def['type'] ?? 'text' );

				if ( '' === $key || in_array( $type, self::DISPLAY_TYPES, true ) ) {
					continue;
				}

				// Skip fields WooCommerce already validates (avoids the duplicate error).
				if ( isset( $wc_field_keys[ $key ] ) ) {
					continue;
				}

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

				if ( '' === $value ) {
					$label = sanitize_text_field( $def['label'] ?? $key );
					$errors->add(
						'kramar_field_' . $key,
						/* translators: %s: checkout field label */
						sprintf( __( '%s is a required field.', 'kramar' ), '<strong>' . esc_html( $label ) . '</strong>' )
					);
				}
			}
		}
		// phpcs:enable WordPress.Security.NonceVerification.Missing
	}

	// -------------------------------------------------------------------------
	// Save to Order Meta
	// -------------------------------------------------------------------------

	/**
	 * Save custom field values to order meta on Classic checkout.
	 *
	 * Custom field values are stored with an underscore prefix so they appear
	 * in the hidden meta section of the admin order detail.
	 *
	 * @since 1.0.0
	 *
	 * @param int                  $order_id Order ID.
	 * @param array<string, mixed> $posted   Posted checkout data (unused).
	 */
	public function save_custom_fields( int $order_id, array $posted ): void {
		// phpcs:disable WordPress.Security.NonceVerification.Missing
		$order = wc_get_order( $order_id );
		if ( ! $order ) {
			return;
		}

		$saved_any = false;

		foreach ( [ 'billing', 'shipping', 'additional' ] as $section ) {
			foreach ( $this->get_fields( $section ) as $def ) {
				if ( empty( $def['custom'] ) || empty( $def['enabled'] ) ) {
					continue;
				}

				$key  = sanitize_key( $def['key'] ?? '' );
				$type = sanitize_key( $def['type'] ?? 'text' );

				if ( '' === $key || in_array( $type, self::DISPLAY_TYPES, true ) ) {
					continue;
				}

				if ( ! isset( $_POST[ $key ] ) ) {
					continue;
				}

				if ( 'checkbox' === $type ) {
					$value = ! empty( $_POST[ $key ] ) ? '1' : '0';
				} else {
					// sanitize_text_field() collapses newlines to a single space, so
					// a multi-line textarea answer was silently flattened. Textareas
					// use the textarea-aware sanitiser, which strips tags but keeps
					// the line breaks the customer typed.
					$value = 'textarea' === $type
						? sanitize_textarea_field( wp_unslash( $_POST[ $key ] ) )
						: sanitize_text_field( wp_unslash( $_POST[ $key ] ) );
				}

				$order->update_meta_data( '_' . $key, $value );
				$saved_any = true;
			}
		}

		if ( $saved_any ) {
			$order->save();
		}
		// phpcs:enable WordPress.Security.NonceVerification.Missing
	}

	// -------------------------------------------------------------------------
	// Blocks / Store API Save Bridge
	// -------------------------------------------------------------------------

	/**
	 * Bridge Store API field values to the classic order meta keys.
	 *
	 * When the WooCommerce Blocks checkout processes an order via the Store API,
	 * additional fields registered with `woocommerce_register_additional_checkout_field()`
	 * are stored by WooCommerce under namespaced meta keys:
	 *   - billing location:   `_wc_billing/kramar/<id>`
	 *   - shipping location:  `_wc_shipping/kramar/<id>` (not registered here — billing covers it)
	 *   - order location:     `_wc_other/kramar/<id>`
	 *
	 * The admin display, email, and order-received callbacks all read from `_<field-key>`
	 * (the key the Classic checkout path writes). This method copies the Store API
	 * values to the classic meta keys so all display surfaces keep working without
	 * any changes.
	 *
	 * Only fires on Blocks/Store API orders (`woocommerce_store_api_checkout_order_processed`).
	 * Classic orders are handled by `save_custom_fields()` via POST data; there is
	 * no overlap because the Classic hook (`woocommerce_checkout_update_order_meta`)
	 * does not fire on Store API orders and vice versa.
	 *
	 * @since 1.0.0
	 *
	 * @param \WC_Order $order The order object, already populated by the Store API.
	 */
	public function save_blocks_custom_fields( \WC_Order $order ): void {
		$saved_any = false;

		// Map section → WC Store API meta key prefix.
		$section_prefix = [
			'billing'    => '_wc_billing/kramar/',
			'additional' => '_wc_other/kramar/',
		];

		foreach ( $section_prefix as $section => $meta_prefix ) {
			foreach ( $this->get_fields( $section ) as $def ) {
				if ( empty( $def['custom'] ) || empty( $def['enabled'] ) ) {
					continue;
				}

				$key  = sanitize_key( $def['key'] ?? '' );
				$type = sanitize_key( $def['type'] ?? 'text' );

				if ( '' === $key || in_array( $type, self::DISPLAY_TYPES, true ) ) {
					continue;
				}

				// Skip types not registered with the Blocks API.
				if ( 'hidden' === $type ) {
					continue;
				}

				// Read from the Store API meta key (e.g. _wc_billing/kramar/billing_foo).
				$blocks_meta_key = $meta_prefix . $key;
				$raw_value       = $order->get_meta( $blocks_meta_key, true );

				// If the Store API didn't write a value (field was optional and left
				// blank, or the type was not registered), skip — don't overwrite any
				// existing classic meta with an empty string.
				if ( '' === (string) $raw_value && ! $order->meta_exists( $blocks_meta_key ) ) {
					continue;
				}

				if ( 'checkbox' === $type ) {
					$value = ! empty( $raw_value ) ? '1' : '0';
				} else {
					// sanitize_text_field() collapses newlines to a single space, so
					// a multi-line textarea answer was silently flattened. Textareas
					// use the textarea-aware sanitiser, which strips tags but keeps
					// the line breaks the customer typed.
					$value = 'textarea' === $type
						? sanitize_textarea_field( (string) $raw_value )
						: sanitize_text_field( (string) $raw_value );
				}

				// Write to the classic meta key (_<field-key>) used by admin/email/order display.
				$order->update_meta_data( '_' . $key, $value );
				$saved_any = true;
			}
		}

		if ( $saved_any ) {
			$order->save();
		}
	}

	// -------------------------------------------------------------------------
	// Admin Order Detail Display
	// -------------------------------------------------------------------------

	/**
	 * Add custom billing fields to the admin order billing section.
	 *
	 * @since 1.0.0
	 *
	 * @param array<string, array<string, mixed>> $fields Existing admin billing fields.
	 * @return array<string, array<string, mixed>> Extended fields.
	 */
	public function admin_billing_fields( array $fields ): array {
		return $this->append_admin_fields( $fields, 'billing' );
	}

	/**
	 * Add custom shipping fields to the admin order shipping section.
	 *
	 * @since 1.0.0
	 *
	 * @param array<string, array<string, mixed>> $fields Existing admin shipping fields.
	 * @return array<string, array<string, mixed>> Extended fields.
	 */
	public function admin_shipping_fields( array $fields ): array {
		return $this->append_admin_fields( $fields, 'shipping' );
	}

	/**
	 * Append custom fields for a section to the admin order field array.
	 *
	 * @since 1.0.0
	 *
	 * @param array<string, array<string, mixed>> $fields  Existing admin fields.
	 * @param string                              $section 'billing'|'shipping'.
	 * @return array<string, array<string, mixed>> Extended fields.
	 */
	private function append_admin_fields( array $fields, string $section ): array {
		foreach ( $this->get_fields( $section ) as $def ) {
			if ( empty( $def['custom'] ) || empty( $def['enabled'] ) ) {
				continue;
			}

			$key  = sanitize_key( $def['key'] ?? '' );
			$type = sanitize_key( $def['type'] ?? 'text' );

			if ( '' === $key || in_array( $type, self::DISPLAY_TYPES, true ) || 'hidden' === $type ) {
				continue;
			}

			// WC's order billing/shipping metabox prepends `_billing_`/`_shipping_`
			// to the array key when reading and writing the value (see
			// WC_Meta_Box_Order_Data: `$field['id'] = '_billing_' . $key` /
			// `$order->get_meta( '_billing_' . $key )`). Our field keys are already
			// section-prefixed (e.g. `billing_custom_1`), and the customer value is
			// stored at `_billing_custom_1`. Register the field under the un-prefixed
			// key so WC's prepend resolves to the correct meta key instead of a
			// doubled `_billing_billing_custom_1` that never matches.
			$field_key = $key;
			$prefix    = $section . '_';
			if ( str_starts_with( $field_key, $prefix ) ) {
				$field_key = substr( $field_key, strlen( $prefix ) );
			}

			$fields[ $field_key ] = [
				'label'   => sanitize_text_field( $def['label'] ?? $key ),
				'wrapper' => '<div class="address-field">',
				'show'    => false,
			];
		}

		return $fields;
	}

	/**
	 * Output additional section custom fields below the shipping address block.
	 *
	 * @since 1.0.0
	 *
	 * @param \WC_Order $order The order object.
	 */
	public function admin_additional_fields( \WC_Order $order ): void {
		$fields = $this->get_fields( 'additional' );
		$output = '';

		foreach ( $fields as $def ) {
			if ( empty( $def['custom'] ) || empty( $def['enabled'] ) ) {
				continue;
			}

			$key  = sanitize_key( $def['key'] ?? '' );
			$type = sanitize_key( $def['type'] ?? 'text' );

			if ( '' === $key || in_array( $type, self::DISPLAY_TYPES, true ) || 'hidden' === $type ) {
				continue;
			}

			$value = $order->get_meta( '_' . $key );
			if ( '' === (string) $value ) {
				continue;
			}

			$label = esc_html( sanitize_text_field( $def['label'] ?? $key ) );
			$output .= '<p><strong>' . $label . ':</strong> ' . esc_html( (string) $value ) . '</p>';
		}

		if ( '' !== $output ) {
			// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $output is assembled exclusively from esc_html() calls above
			echo '<div class="kramar-additional-fields"><h3>' . esc_html__( 'Additional Information', 'kramar' ) . '</h3>' . $output . '</div>';
		}
	}

	// -------------------------------------------------------------------------
	// Email Display
	// -------------------------------------------------------------------------

	/**
	 * Add custom field values to order confirmation emails.
	 *
	 * @since 1.0.0
	 *
	 * @param array<string, array<string, mixed>> $fields  Existing email meta fields.
	 * @param bool                                $sent_to_admin True when email is to admin.
	 * @param \WC_Order                           $order   Order object.
	 * @return array<string, array<string, mixed>> Extended email fields.
	 */
	public function email_order_meta_fields( array $fields, bool $sent_to_admin, \WC_Order $order ): array {
		foreach ( [ 'billing', 'shipping', 'additional' ] as $section ) {
			foreach ( $this->get_fields( $section ) as $def ) {
				if ( empty( $def['custom'] ) || empty( $def['enabled'] ) ) {
					continue;
				}

				$key  = sanitize_key( $def['key'] ?? '' );
				$type = sanitize_key( $def['type'] ?? 'text' );

				if ( '' === $key || in_array( $type, self::DISPLAY_TYPES, true ) || 'hidden' === $type ) {
					continue;
				}

				$value = $order->get_meta( '_' . $key );
				if ( '' === (string) $value ) {
					continue;
				}

				$fields[ $key ] = [
					'label' => sanitize_text_field( $def['label'] ?? $key ),
					'value' => $value,
				];
			}
		}

		return $fields;
	}

	/**
	 * Build a [tag => {key,label}] map of merge-able custom checkout fields.
	 *
	 * @since 1.0.0
	 *
	 * @return array<string, array{key: string, label: string}>
	 */
	private function merge_field_map(): array {
		$map = [];
		foreach ( [ 'billing', 'shipping', 'additional' ] as $section ) {
			foreach ( $this->get_fields( $section ) as $def ) {
				if ( empty( $def['custom'] ) || empty( $def['enabled'] ) ) {
					continue;
				}
				$key  = sanitize_key( $def['key'] ?? '' );
				$type = sanitize_key( $def['type'] ?? 'text' );
				if ( '' === $key || in_array( $type, self::DISPLAY_TYPES, true ) || 'hidden' === $type ) {
					continue;
				}
				$map[ '{field_' . $key . '}' ] = [
					'key'   => $key,
					'label' => sanitize_text_field( $def['label'] ?? $key ),
				];
			}
		}
		return $map;
	}

	/**
	 * Register a merge field per enabled custom checkout field.
	 *
	 * @since 1.0.0
	 *
	 * @param array<string, array<string, mixed>> $fields  Merge field groups.
	 * @param string                              $context Email context.
	 * @return array<string, array<string, mixed>>
	 */
	public function register_merge_fields( array $fields, string $context ): array {
		if ( 'order' !== $context ) {
			return $fields;
		}
		$map = $this->merge_field_map();
		if ( empty( $map ) ) {
			return $fields;
		}
		$group = [];
		foreach ( $map as $tag => $info ) {
			$group[ $tag ] = $info['label'];
		}
		$fields['checkout_fields'] = [
			'label'  => __( 'Checkout Fields', 'kramar' ),
			'fields' => $group,
		];
		return $fields;
	}

	/**
	 * Resolve the custom checkout field merge tags for an order.
	 *
	 * @since 1.0.0
	 *
	 * @param array<string, string> $replacements Tag → value map.
	 * @param \WC_Order             $order        Order.
	 * @return array<string, string>
	 */
	public function resolve_merge_fields( array $replacements, \WC_Order $order ): array {
		foreach ( $this->merge_field_map() as $tag => $info ) {
			$replacements[ $tag ] = (string) $order->get_meta( '_' . $info['key'] );
		}
		return $replacements;
	}

	/**
	 * Add a "Checkout Info" orders-list column when there are additional custom
	 * fields to surface.
	 *
	 * @since 1.0.0
	 *
	 * @param array<string, string> $columns Existing columns.
	 * @return array<string, string>
	 */
	public function add_orders_column( array $columns ): array {
		$has = false;
		foreach ( $this->get_fields( 'additional' ) as $def ) {
			if ( ! empty( $def['custom'] ) && ! empty( $def['enabled'] ) ) {
				$has = true;
				break;
			}
		}
		if ( ! $has ) {
			return $columns;
		}

		$new = [];
		foreach ( $columns as $key => $label ) {
			$new[ $key ] = $label;
			if ( 'shipping_address' === $key ) {
				$new['kramar_checkout_info'] = __( 'Checkout Info', 'kramar' );
			}
		}
		if ( ! isset( $new['kramar_checkout_info'] ) ) {
			$new['kramar_checkout_info'] = __( 'Checkout Info', 'kramar' );
		}
		return $new;
	}

	/**
	 * Render the Checkout Info column (HPOS).
	 *
	 * @since 1.0.0
	 *
	 * @param string    $column Column key.
	 * @param \WC_Order $order  Order.
	 */
	public function render_orders_column( string $column, \WC_Order $order ): void {
		if ( 'kramar_checkout_info' === $column ) {
			echo wp_kses_post( $this->render_checkout_info( $order ) );
		}
	}

	/**
	 * Render the Checkout Info column (legacy CPT).
	 *
	 * @since 1.0.0
	 *
	 * @param string $column  Column key.
	 * @param int    $post_id Order post ID.
	 */
	public function render_orders_column_legacy( string $column, int $post_id ): void {
		if ( 'kramar_checkout_info' !== $column ) {
			return;
		}
		$order = wc_get_order( $post_id );
		if ( $order ) {
			echo wp_kses_post( $this->render_checkout_info( $order ) );
		}
	}

	/**
	 * Build the compact additional-fields cell for the orders list.
	 *
	 * @since 1.0.0
	 *
	 * @param \WC_Order $order Order.
	 * @return string HTML.
	 */
	private function render_checkout_info( \WC_Order $order ): string {
		$out = [];
		foreach ( $this->get_fields( 'additional' ) as $def ) {
			if ( empty( $def['custom'] ) || empty( $def['enabled'] ) ) {
				continue;
			}
			$key  = sanitize_key( $def['key'] ?? '' );
			$type = sanitize_key( $def['type'] ?? 'text' );
			if ( '' === $key || in_array( $type, self::DISPLAY_TYPES, true ) || 'hidden' === $type ) {
				continue;
			}
			$val = (string) $order->get_meta( '_' . $key );
			if ( '' === $val ) {
				continue;
			}
			$out[] = '<span style="display:block;font-size:11px;line-height:1.5;"><strong>'
				. esc_html( sanitize_text_field( $def['label'] ?? $key ) ) . ':</strong> '
				. esc_html( $val ) . '</span>';
		}
		return $out ? implode( '', $out ) : '<span style="color:#a7aaad;">&mdash;</span>';
	}

	// -------------------------------------------------------------------------
	// Order Received / Account Order Detail Display
	// -------------------------------------------------------------------------

	/**
	 * Display custom field values on the order received / thank you page.
	 *
	 * @since 1.0.0
	 *
	 * @param \WC_Order $order Order object.
	 */
	public function order_received_fields( \WC_Order $order ): void {
		$rows = [];

		foreach ( [ 'billing', 'shipping', 'additional' ] as $section ) {
			foreach ( $this->get_fields( $section ) as $def ) {
				if ( empty( $def['custom'] ) || empty( $def['enabled'] ) ) {
					continue;
				}

				$key  = sanitize_key( $def['key'] ?? '' );
				$type = sanitize_key( $def['type'] ?? 'text' );

				if ( '' === $key || in_array( $type, self::DISPLAY_TYPES, true ) || 'hidden' === $type ) {
					continue;
				}

				$value = $order->get_meta( '_' . $key );
				if ( '' === (string) $value ) {
					continue;
				}

				$rows[] = [
					'label' => sanitize_text_field( $def['label'] ?? $key ),
					'value' => (string) $value,
				];
			}
		}

		if ( empty( $rows ) ) {
			return;
		}

		echo '<section class="kramar-order-custom-fields woocommerce-order-custom-fields">';
		echo '<h2>' . esc_html__( 'Additional Information', 'kramar' ) . '</h2>';
		echo '<table class="woocommerce-table">';
		echo '<tbody>';
		foreach ( $rows as $row ) {
			printf(
				'<tr><th>%s</th><td>%s</td></tr>',
				esc_html( $row['label'] ),
				esc_html( $row['value'] )
			);
		}
		echo '</tbody>';
		echo '</table>';
		echo '</section>';
	}

	// -------------------------------------------------------------------------
	// My Account → Edit Address
	// -------------------------------------------------------------------------

	/**
	 * Inject custom address fields into the My Account Edit Address form.
	 *
	 * Hooked on `woocommerce_address_to_edit` (priority 10).
	 *
	 * @since 1.0.0
	 *
	 * @param array<string, array<string, mixed>> $address      WC address fields for the form.
	 * @param string                              $load_address Address type: 'billing' or 'shipping'.
	 * @return array<string, array<string, mixed>> Fields with custom entries appended.
	 */
	public function inject_address_edit_fields( array $address, string $load_address ): array {
		if ( ! in_array( $load_address, [ 'billing', 'shipping' ], true ) ) {
			return $address;
		}

		$user_id = get_current_user_id();

		foreach ( $this->get_fields( $load_address ) as $def ) {
			if ( empty( $def['enabled'] ) || empty( $def['custom'] ) ) {
				continue;
			}

			$key  = sanitize_key( $def['key'] ?? '' );
			$type = sanitize_key( $def['type'] ?? 'text' );

			if ( '' === $key || in_array( $type, self::DISPLAY_TYPES, true ) ) {
				continue;
			}

			$field            = $this->build_wc_field( $def );
			$field['default'] = (string) get_user_meta( $user_id, $key, true );

			$address[ $key ] = $field;
		}

		return $address;
	}

	/**
	 * Persist custom address field values when the user saves their address.
	 *
	 * Hooked on `woocommerce_customer_save_address` (priority 10).
	 * WooCommerce already verifies the `woocommerce-edit_address` nonce before
	 * this action fires, so we only add a capability/existence check here.
	 *
	 * @since 1.0.0
	 *
	 * @param int    $user_id      ID of the user whose address is being saved.
	 * @param string $load_address Address type: 'billing' or 'shipping'.
	 */
	public function save_address_edit_fields( int $user_id, string $load_address ): void {
		if ( ! in_array( $load_address, [ 'billing', 'shipping' ], true ) ) {
			return;
		}

		if ( ! get_userdata( $user_id ) ) {
			return;
		}

		// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce is verified by WooCommerce via check_admin_referer('woocommerce-edit_address') before this action fires.
		foreach ( $this->get_fields( $load_address ) as $def ) {
			if ( empty( $def['enabled'] ) || empty( $def['custom'] ) ) {
				continue;
			}

			$key  = sanitize_key( $def['key'] ?? '' );
			$type = sanitize_key( $def['type'] ?? 'text' );

			if ( '' === $key || in_array( $type, self::DISPLAY_TYPES, true ) ) {
				continue;
			}

			if ( 'checkbox' === $type ) {
				$value = ! empty( $_POST[ $key ] ) ? '1' : '0';
			} else {
				$value = sanitize_text_field( wp_unslash( $_POST[ $key ] ?? '' ) );
			}

			update_user_meta( $user_id, $key, $value );
		}
		// phpcs:enable WordPress.Security.NonceVerification.Missing
	}

	// -------------------------------------------------------------------------
	// Admin Form Handler (PRG pattern)
	// -------------------------------------------------------------------------

	/**
	 * Handle field add/delete/toggle form submissions.
	 *
	 * Hooked on `admin_post_kramar_manage_checkout_field`. Verifies nonce and
	 * capability, dispatches to the appropriate handler, then redirects (PRG).
	 *
	 * @since 1.0.0
	 */
	public function handle_field_form(): void {
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'You do not have permission to manage checkout fields.', 'kramar' ) );
		}

		check_admin_referer( self::NONCE_ACTION );

		$action = sanitize_key( $_POST['kramar_field_action'] ?? '' );

		match ( $action ) {
			'add'    => $this->handle_add_field(),
			'delete' => $this->handle_delete_field(),
			'toggle' => $this->handle_toggle_field(),
			default  => null,
		};

		$redirect = add_query_arg(
			[ 'page' => 'kramar', 'tab' => 'checkout', 'field-updated' => '1' ],
			admin_url( 'admin.php' )
		);

		wp_safe_redirect( $redirect );
		exit;
	}

	/**
	 * Add or update a field from POST data.
	 *
	 * @since 1.0.0
	 */
	private function handle_add_field(): void {
		// Nonce verified in handle_field_form() via check_admin_referer() before this method is called.
		// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified in handle_field_form() before dispatch.
		$section = sanitize_key( $_POST['kramar_field_section'] ?? 'billing' );
		if ( ! in_array( $section, [ 'billing', 'shipping', 'additional' ], true ) ) {
			$section = 'billing';
		}

		$raw_key = sanitize_key( $_POST['kramar_field_key'] ?? '' );
		if ( '' === $raw_key ) {
			return;
		}

		// Ensure the key is properly prefixed for billing/shipping sections.
		$key = $raw_key;
		if ( 'billing' === $section && ! str_starts_with( $key, 'billing_' ) ) {
			$key = 'billing_' . $key;
		} elseif ( 'shipping' === $section && ! str_starts_with( $key, 'shipping_' ) ) {
			$key = 'shipping_' . $key;
		}

		$type  = sanitize_key( $_POST['kramar_field_type'] ?? 'text' );
		if ( ! isset( self::FIELD_TYPES[ $type ] ) ) {
			$type = 'text';
		}

		$raw_class = sanitize_text_field( wp_unslash( $_POST['kramar_field_class'] ?? 'form-row-wide' ) );
		$classes   = array_values(
			array_filter(
				array_map( 'sanitize_html_class', array_map( 'trim', explode( ',', $raw_class ) ) )
			)
		);
		if ( empty( $classes ) ) {
			$classes = [ 'form-row-wide' ];
		}

		// Determine if this is a WC default field or a new custom one.
		$existing_fields = $this->get_fields( $section );
		$is_custom       = true;

		foreach ( $existing_fields as $def ) {
			if ( sanitize_key( $def['key'] ?? '' ) === $key && empty( $def['custom'] ) ) {
				$is_custom = false;
				break;
			}
		}

		$new_field = [
			'key'         => $key,
			'type'        => $type,
			'label'       => sanitize_text_field( wp_unslash( $_POST['kramar_field_label'] ?? '' ) ),
			'placeholder' => sanitize_text_field( wp_unslash( $_POST['kramar_field_placeholder'] ?? '' ) ),
			'required'    => ! empty( $_POST['kramar_field_required'] ),
			'class'       => $classes,
			'default'     => sanitize_text_field( wp_unslash( $_POST['kramar_field_default'] ?? '' ) ),
			'options'     => sanitize_textarea_field( wp_unslash( $_POST['kramar_field_options'] ?? '' ) ),
			'enabled'     => true,
			'priority'    => absint( $_POST['kramar_field_priority'] ?? 50 ),
			'custom'      => $is_custom,
		];
		// phpcs:enable WordPress.Security.NonceVerification.Missing

		$fields = $this->get_fields( $section );
		$found  = false;

		foreach ( $fields as $i => $existing ) {
			if ( sanitize_key( $existing['key'] ?? '' ) === $key ) {
				// Preserve the `custom` flag from the existing entry (WC defaults stay non-custom).
				$new_field['custom'] = $existing['custom'] ?? $is_custom;
				$fields[ $i ]        = $new_field;
				$found               = true;
				break;
			}
		}

		if ( ! $found ) {
			$fields[] = $new_field;
		}

		$this->save_fields( $section, $fields );
	}

	/**
	 * Delete a custom field from POST data. WC default fields cannot be deleted.
	 *
	 * @since 1.0.0
	 */
	private function handle_delete_field(): void {
		// Nonce verified in handle_field_form() via check_admin_referer() before this method is called.
		// phpcs:disable WordPress.Security.NonceVerification.Missing
		$section = sanitize_key( $_POST['kramar_field_section'] ?? '' );
		$key     = sanitize_key( $_POST['kramar_field_key'] ?? '' );
		// phpcs:enable WordPress.Security.NonceVerification.Missing

		if ( ! in_array( $section, [ 'billing', 'shipping', 'additional' ], true ) || '' === $key ) {
			return;
		}

		$fields = $this->get_fields( $section );
		$fields = array_values(
			array_filter(
				$fields,
				static fn( array $f ) => ! ( sanitize_key( $f['key'] ?? '' ) === $key && ! empty( $f['custom'] ) )
			)
		);

		$this->save_fields( $section, $fields );
	}

	/**
	 * Toggle the `enabled` flag of a field from POST data.
	 *
	 * @since 1.0.0
	 */
	private function handle_toggle_field(): void {
		// Nonce verified in handle_field_form() via check_admin_referer() before this method is called.
		// phpcs:disable WordPress.Security.NonceVerification.Missing
		$section = sanitize_key( $_POST['kramar_field_section'] ?? '' );
		$key     = sanitize_key( $_POST['kramar_field_key'] ?? '' );
		// phpcs:enable WordPress.Security.NonceVerification.Missing

		if ( ! in_array( $section, [ 'billing', 'shipping', 'additional' ], true ) || '' === $key ) {
			return;
		}

		$fields = $this->get_fields( $section );

		foreach ( $fields as $i => $def ) {
			if ( sanitize_key( $def['key'] ?? '' ) === $key ) {
				$fields[ $i ]['enabled'] = empty( $def['enabled'] );
				break;
			}
		}

		$this->save_fields( $section, $fields );
	}

	// -------------------------------------------------------------------------
	// Import / Export
	// -------------------------------------------------------------------------

	/**
	 * Handle the export request: stream all field definitions as a JSON file.
	 *
	 * Hooked on `admin_post_kramar_export_checkout_fields`.
	 *
	 * @since 1.0.0
	 */
	public function handle_export(): void {
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'You do not have permission to export checkout fields.', 'kramar' ) );
		}

		check_admin_referer( self::NONCE_EXPORT );

		$data = [
			'billing'    => get_option( self::OPTION_BILLING, [] ),
			'shipping'   => get_option( self::OPTION_SHIPPING, [] ),
			'additional' => get_option( self::OPTION_ADDITIONAL, [] ),
		];

		$json = wp_json_encode( $data, JSON_PRETTY_PRINT );

		nocache_headers();
		header( 'Content-Type: application/json; charset=utf-8' );
		header( 'Content-Disposition: attachment; filename="kramar-checkout-fields-' . gmdate( 'Y-m-d' ) . '.json"' );
		header( 'X-Content-Type-Options: nosniff' );

		// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON encoded by wp_json_encode and streamed as a file download, not rendered as HTML.
		echo $json;
		exit;
	}

	/**
	 * Handle the import request: read an uploaded JSON file and save field definitions.
	 *
	 * Hooked on `admin_post_kramar_import_checkout_fields`.
	 *
	 * @since 1.0.0
	 */
	public function handle_import(): void {
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'You do not have permission to import checkout fields.', 'kramar' ) );
		}

		check_admin_referer( self::NONCE_IMPORT );

		$error_redirect = static function ( string $message ): never {
			wp_safe_redirect(
				add_query_arg(
					[
						'page'             => 'kramar-checkout-fields',
						'kramar-import'      => 'error',
						'kramar-import-msg'  => rawurlencode( $message ),
					],
					admin_url( 'admin.php' )
				)
			);
			exit;
		};

		// Validate upload (nonce + capability already checked above; $_FILES is server-populated).
		if ( ! isset(
			$_FILES['kramar_import_file']['error'],
			$_FILES['kramar_import_file']['tmp_name'],
			$_FILES['kramar_import_file']['size'],
			$_FILES['kramar_import_file']['type']
		) || UPLOAD_ERR_OK !== (int) $_FILES['kramar_import_file']['error'] ) {
			$error_redirect( __( 'No valid file uploaded.', 'kramar' ) );
		}

		$tmp  = sanitize_text_field( wp_unslash( $_FILES['kramar_import_file']['tmp_name'] ) );
		$size = (int) $_FILES['kramar_import_file']['size'];
		$mime = sanitize_text_field( wp_unslash( $_FILES['kramar_import_file']['type'] ) );

		if ( ! is_uploaded_file( $tmp ) ) {
			$error_redirect( __( 'Invalid upload.', 'kramar' ) );
		}

		if ( $size > 524288 ) { // 512 KB
			$error_redirect( __( 'Import file exceeds the 512 KB size limit.', 'kramar' ) );
		}

		// Loose MIME check — browser-reported type varies; content check below is authoritative.
		if ( ! str_starts_with( $mime, 'application/json' ) && ! str_starts_with( $mime, 'text/' ) ) {
			$error_redirect( __( 'Invalid file type. Please upload a JSON file.', 'kramar' ) );
		}

		$raw_content = file_get_contents( $tmp );
		if ( false === $raw_content ) {
			$error_redirect( __( 'Could not read the uploaded file.', 'kramar' ) );
		}

		$decoded = json_decode( $raw_content, true );
		if ( ! is_array( $decoded ) ) {
			$error_redirect( __( 'The uploaded file does not contain valid JSON.', 'kramar' ) );
		}

		$skipped_empty = [];
		$dropped_dupes = 0;
		$dropped_bad   = 0;
		$imported      = 0;

		foreach ( [ 'billing', 'shipping', 'additional' ] as $section ) {
			if ( ! isset( $decoded[ $section ] ) || ! is_array( $decoded[ $section ] ) ) {
				continue;
			}

			// An explicitly empty section used to pass straight through to
			// save_fields() and wipe every field configured for it — labels,
			// required flags and custom fields alike — with no warning. A partial
			// export, or a hand-edited file with a section left empty, silently
			// destroyed that section. Treat "empty" as "nothing to import".
			if ( empty( $decoded[ $section ] ) ) {
				$skipped_empty[] = $section;
				continue;
			}

			$sanitized = [];
			$seen_keys = [];

			foreach ( $decoded[ $section ] as $raw_def ) {
				$clean = $this->sanitize_field_def( $raw_def );
				if ( null === $clean ) {
					++$dropped_bad;
					continue;
				}

				// Keys have to be unique. A duplicate key produced two cards in
				// the builder that both resolved to the FIRST entry, so editing
				// or deleting the second silently acted on the first, and
				// WooCommerce logged "field is already registered" on every
				// request. Last definition wins, matching the builder's own
				// rename behaviour.
				// A definition with no key cannot be saved or edited. It used to be
				// dropped in silence while the import still reported success.
				$key = (string) ( $clean['key'] ?? '' );
				if ( '' === $key ) {
					++$dropped_bad;
					continue;
				}

				if ( isset( $seen_keys[ $key ] ) ) {
					$sanitized[ $seen_keys[ $key ] ] = $clean;
					++$dropped_dupes;
					continue;
				}

				$seen_keys[ $key ] = count( $sanitized );
				$sanitized[]       = $clean;
			}

			$imported += count( $sanitized );

			$this->save_fields( $section, array_values( $sanitized ) );
		}

		// "Imported successfully" on a file that produced no fields is a
		// misleading thing to tell an admin who is about to rely on it. Valid
		// JSON of the wrong shape got exactly that message while importing
		// nothing at all.
		$notice_args = [
			'page'          => 'kramar-checkout-fields',
			'kramar-import' => $imported > 0 ? 'success' : 'empty',
		];
		if ( $dropped_bad > 0 ) {
			$notice_args['kramar-import-bad'] = $dropped_bad;
		}
		if ( $skipped_empty ) {
			$notice_args['kramar-import-skipped'] = implode( ',', $skipped_empty );
		}
		if ( $dropped_dupes > 0 ) {
			$notice_args['kramar-import-dupes'] = $dropped_dupes;
		}

		wp_safe_redirect( add_query_arg( $notice_args, admin_url( 'admin.php' ) ) );
		exit;
	}

	// -------------------------------------------------------------------------
	// Admin UI — Visual Builder
	// -------------------------------------------------------------------------

	/**
	 * Register the Checkout Fields sub-page under the Kramar menu.
	 *
	 * @since 1.0.0
	 */
	public function add_admin_page(): void {
		$this->builder_hook = (string) add_submenu_page(
			'kramar',
			__( 'Checkout Fields', 'kramar' ),
			__( 'Checkout Fields', 'kramar' ),
			'manage_options',
			'kramar-checkout-fields',
			[ $this, 'render_builder_page' ]
		);
	}

	/**
	 * Enqueue builder CSS and JS only on the builder sub-page.
	 *
	 * @since 1.0.0
	 *
	 * @param string $hook Current admin page hook suffix.
	 */
	public function enqueue_builder_assets( string $hook ): void {
		if ( $hook !== $this->builder_hook ) {
			return;
		}

		$this->maybe_seed_defaults();

		wp_enqueue_style(
			'kramar-field-builder',
			KRAMAR_URL . 'assets/css/field-builder.css',
			[],
			KRAMAR_VERSION
		);

		wp_enqueue_script(
			'kramar-field-builder',
			KRAMAR_URL . 'assets/js/field-builder.js',
			[],
			KRAMAR_VERSION,
			true
		);

		wp_localize_script(
			'kramar-field-builder',
			'kramarFieldBuilder',
			[
				'ajaxUrl'    => admin_url( 'admin-ajax.php' ),
				'nonce'      => wp_create_nonce( 'kramar_field_builder' ),
				'sections'   => [
					'billing'    => $this->get_fields( 'billing' ),
					'shipping'   => $this->get_fields( 'shipping' ),
					'additional' => $this->get_fields( 'additional' ),
				],
				'fieldTypes' => self::FIELD_TYPES,
				'i18n'       => [
					'billing'       => __( 'Billing Details', 'kramar' ),
					'shipping'      => __( 'Shipping Address', 'kramar' ),
					'additional'    => __( 'Additional Information', 'kramar' ),
					'addField'      => __( 'Add Field', 'kramar' ),
					'selectField'   => __( 'Select a field to edit', 'kramar' ),
					'fieldSettings' => __( 'Field Settings', 'kramar' ),
					'label'         => __( 'Label', 'kramar' ),
					'placeholder'   => __( 'Placeholder', 'kramar' ),
					'type'          => __( 'Type', 'kramar' ),
					'required'      => __( 'Required', 'kramar' ),
					'enabled'       => __( 'Enabled', 'kramar' ),
					'priority'      => __( 'Priority', 'kramar' ),
					'cssClass'      => __( 'CSS Classes', 'kramar' ),
					'defaultValue'  => __( 'Default Value', 'kramar' ),
					'options'       => __( 'Options (one per line)', 'kramar' ),
					'delete'        => __( 'Delete Field', 'kramar' ),
					'confirmDelete' => __( 'Delete this field?', 'kramar' ),
					'cannotDelete'  => __( 'Default WooCommerce fields cannot be deleted.', 'kramar' ),
					'default'       => __( 'Default', 'kramar' ),
					'custom'        => __( 'Custom', 'kramar' ),
					'saving'        => __( 'Saving…', 'kramar' ),
					'saved'         => __( 'Saved', 'kramar' ),
					'save'          => __( 'Save Changes', 'kramar' ),
					'fieldKey'      => __( 'Field Key', 'kramar' ),
					'newFieldKey'   => __( 'new_field', 'kramar' ),
					'width'         => __( 'Width', 'kramar' ),
				],
			]
		);
	}

	/**
	 * AJAX handler: save all checkout field definitions sent from the builder.
	 *
	 * Expects POST fields:
	 *   _wpnonce — nonce for 'kramar_field_builder'
	 *   fields   — JSON-encoded { billing: [...], shipping: [...], additional: [...] }
	 *
	 * @since 1.0.0
	 */
	public function ajax_save_fields(): void {
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( __( 'Permission denied.', 'kramar' ), 403 );
		}

		if ( ! check_ajax_referer( 'kramar_field_builder', '_wpnonce', false ) ) {
			wp_send_json_error( __( 'Security check failed.', 'kramar' ), 403 );
		}

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

		if ( '' === $raw ) {
			wp_send_json_error( __( 'No field data received.', 'kramar' ) );
		}

		$decoded = json_decode( $raw, true );

		if ( ! is_array( $decoded ) ) {
			wp_send_json_error( __( 'Invalid field data.', 'kramar' ) );
		}

		foreach ( [ 'billing', 'shipping', 'additional' ] as $section ) {
			if ( ! isset( $decoded[ $section ] ) || ! is_array( $decoded[ $section ] ) ) {
				continue;
			}

			$sanitized = [];

			foreach ( $decoded[ $section ] as $raw_def ) {
				$clean = $this->sanitize_field_def( $raw_def );
				if ( null !== $clean ) {
					$sanitized[] = $clean;
				}
			}

			$this->save_fields( $section, $sanitized );
		}

		wp_send_json_success();
	}

	/**
	 * Sanitize a single raw field definition array into our canonical shape.
	 *
	 * Returns null when the definition has no usable key (should be skipped).
	 * Used by both ajax_save_fields() and handle_import().
	 *
	 * @since 1.0.0
	 *
	 * @param mixed $raw_def Raw field definition (must be an array).
	 * @return array<string, mixed>|null Sanitized field definition, or null to skip.
	 */
	/**
	 * Whether a key belongs to a WooCommerce address/order field.
	 *
	 * @since 1.0.2
	 *
	 * @param string $key Field key.
	 */
	private function is_woocommerce_field_key( string $key ): bool {
		static $known = null;

		if ( null === $known ) {
			$known = [];

			// get_address_fields() runs through woocommerce_billing_fields and
			// woocommerce_shipping_fields, and Kramar's own VAT ID module hooks
			// exactly those to inject billing_vat_id — so asking WooCommerce what it
			// owns returned fields this plugin had just added. An imported
			// billing_vat_id was then classified as WooCommerce's, marked not-custom,
			// and silently ignored by every render, save and delete loop. Unhook our
			// own filters for the length of the question.
			if ( function_exists( 'WC' ) && WC()->countries ) {
				$ours = [];

				foreach ( [ 'woocommerce_billing_fields', 'woocommerce_shipping_fields', 'woocommerce_default_address_fields' ] as $hook ) {
					$callbacks = $GLOBALS['wp_filter'][ $hook ] ?? null;
					if ( ! $callbacks instanceof \WP_Hook ) {
						continue;
					}

					foreach ( $callbacks->callbacks as $priority => $entries ) {
						foreach ( $entries as $entry ) {
							$fn = $entry['function'] ?? null;
							if ( ! is_array( $fn ) || ! is_object( $fn[0] ?? null ) ) {
								continue;
							}
							if ( ! str_starts_with( get_class( $fn[0] ), 'Kramar\\' ) ) {
								continue;
							}

							$ours[] = [ $hook, $fn, $priority, $entry['accepted_args'] ?? 1 ];
							remove_filter( $hook, $fn, $priority );
						}
					}
				}

				foreach ( [ 'billing', 'shipping' ] as $section ) {
					foreach ( array_keys( (array) WC()->countries->get_address_fields( '', $section . '_' ) ) as $field_key ) {
						$known[ (string) $field_key ] = true;
					}
				}

				foreach ( $ours as [ $hook, $fn, $priority, $args ] ) {
					add_filter( $hook, $fn, $priority, $args );
				}
			}

			// The additional section's two core fields have no address-field source.
			$known['order_comments'] = true;
			$known['account_password'] = true;
		}

		return isset( $known[ $key ] );
	}

	private function sanitize_field_def( mixed $raw_def ): ?array {
		if ( ! is_array( $raw_def ) ) {
			return null;
		}

		$key = sanitize_key( $raw_def['key'] ?? '' );
		if ( '' === $key ) {
			return null;
		}

		// A key WooCommerce does not own has to be a custom field, whatever the
		// file says. "custom" is an internal flag, and an imported definition
		// that omits it defaults to false — which meant a hand-added field was
		// never rendered (only WooCommerce keys and custom fields are) and never
		// deletable (the builder only removes custom ones), so it sat in the
		// option forever while the import reported success. Importing by hand is
		// a documented way to use this screen.
		$custom = ! empty( $raw_def['custom'] ) || ! $this->is_woocommerce_field_key( $key );

		$type = sanitize_key( $raw_def['type'] ?? 'text' );
		if ( ! isset( self::FIELD_TYPES[ $type ] ) ) {
			$type = 'text';
		}

		$raw_class = $raw_def['class'] ?? [ 'form-row-wide' ];
		$classes   = is_array( $raw_class )
			? array_values( array_filter( array_map( 'sanitize_html_class', $raw_class ) ) )
			: [ 'form-row-wide' ];
		if ( empty( $classes ) ) {
			$classes = [ 'form-row-wide' ];
		}

		return [
			'key'         => $key,
			'type'        => $type,
			'label'       => sanitize_text_field( $raw_def['label'] ?? '' ),
			'placeholder' => sanitize_text_field( $raw_def['placeholder'] ?? '' ),
			'required'    => (bool) ( $raw_def['required'] ?? false ),
			'class'       => $classes,
			'default'     => sanitize_text_field( $raw_def['default'] ?? '' ),
			'options'     => sanitize_textarea_field( $raw_def['options'] ?? '' ),
			'enabled'     => (bool) ( $raw_def['enabled'] ?? true ),
			'priority'    => absint( $raw_def['priority'] ?? 10 ),
			'custom'      => $custom,
		];
	}

	/**
	 * Render the visual checkout field builder page.
	 *
	 * The 3-panel layout (palette / canvas / settings) is driven by
	 * field-builder.js; this method outputs the skeleton HTML.
	 *
	 * @since 1.0.0
	 */
	public function render_builder_page(): void {
		// phpcs:disable WordPress.Security.NonceVerification.Recommended -- display-only notice from our own redirect; no state change made here.
		if ( isset( $_GET['kramar-import'] ) ) {
			$status = sanitize_key( $_GET['kramar-import'] );
			if ( 'empty' === $status ) {
				echo '<div class="notice notice-warning is-dismissible"><p>' . esc_html__( 'The file was read without error, but it contained no field definitions this screen could use — nothing was imported and nothing was changed.', 'kramar' ) . '</p></div>';
			} elseif ( 'success' === $status ) {
				echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( 'Field configuration imported successfully.', 'kramar' ) . '</p></div>';

				// Say what the import deliberately did NOT do, rather than
				// reporting a bare success for a file that was partly ignored.
				if ( isset( $_GET['kramar-import-skipped'] ) ) {
					$skipped = array_filter( array_map( 'sanitize_key', explode( ',', (string) wp_unslash( $_GET['kramar-import-skipped'] ) ) ) );
					if ( $skipped ) {
						echo '<div class="notice notice-warning is-dismissible"><p>' . esc_html(
							sprintf(
								/* translators: %s: comma-separated section names, e.g. "billing, shipping" */
								__( 'These sections were empty in the file and were left untouched rather than cleared: %s. Nothing you had configured for them was removed.', 'kramar' ),
								implode( ', ', $skipped )
							)
						) . '</p></div>';
					}
				}

				$bad = isset( $_GET['kramar-import-bad'] ) ? absint( $_GET['kramar-import-bad'] ) : 0;
				if ( $bad > 0 ) {
					echo '<div class="notice notice-warning is-dismissible"><p>' . esc_html(
						sprintf(
							/* translators: %d: number of unusable field definitions */
							_n(
								'%d entry in the file had no usable key and was skipped.',
								'%d entries in the file had no usable key and were skipped.',
								$bad,
								'kramar'
							),
							$bad
						)
					) . '</p></div>';
				}

				$dupes = isset( $_GET['kramar-import-dupes'] ) ? absint( $_GET['kramar-import-dupes'] ) : 0;
				if ( $dupes > 0 ) {
					echo '<div class="notice notice-warning is-dismissible"><p>' . esc_html(
						sprintf(
							/* translators: %d: number of duplicate field keys */
							_n(
								'%d field in the file reused a key that already appeared in the same section. Only the last definition was kept — two fields cannot share a key.',
								'%d fields in the file reused a key that already appeared in the same section. Only the last definition of each was kept — two fields cannot share a key.',
								$dupes,
								'kramar'
							),
							$dupes
						)
					) . '</p></div>';
				}
			} else {
				$msg = isset( $_GET['kramar-import-msg'] )
					? sanitize_text_field( wp_unslash( $_GET['kramar-import-msg'] ) )
					: __( 'Import failed.', 'kramar' );
				echo '<div class="notice notice-error is-dismissible"><p>' . esc_html( $msg ) . '</p></div>';
			}
		}
		// phpcs:enable WordPress.Security.NonceVerification.Recommended
		?>
		<div class="wrap kramar-field-builder-wrap">

			<div class="kramar-fb-toolbar">
				<a href="<?php echo esc_url( admin_url( 'admin.php?page=kramar' ) ); ?>" class="kramar-fb-toolbar__back">&larr;</a>
				<h1 class="kramar-fb-toolbar__title"><?php esc_html_e( 'Checkout Fields', 'kramar' ); ?></h1>
				<span id="kramar-fb-field-count" class="kramar-fb-field-count"></span>
				<div class="kramar-fb-toolbar__actions">
					<span id="kramar-fb-save-status" class="kramar-fb-save-status"></span>
					<button type="button" class="kramar-fb-btn kramar-fb-btn--primary" id="kramar-fb-save">
						<?php esc_html_e( 'Save Changes', 'kramar' ); ?>
					</button>
				</div>
				<div class="kramar-fb-toolbar__io">
					<a href="<?php echo esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=kramar_export_checkout_fields' ), self::NONCE_EXPORT ) ); ?>" class="kramar-fb-btn kramar-fb-btn--secondary">
						<?php esc_html_e( 'Export', 'kramar' ); ?>
					</a>
					<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" enctype="multipart/form-data" class="kramar-fb-import-form" id="kramar-fb-import-form">
						<?php wp_nonce_field( self::NONCE_IMPORT, '_wpnonce', true ); ?>
						<input type="hidden" name="action" value="kramar_import_checkout_fields">
						<label for="kramar-fb-import-file" class="kramar-fb-btn kramar-fb-btn--secondary" style="cursor:pointer;">
							<?php esc_html_e( 'Import', 'kramar' ); ?>
						</label>
						<input type="file" id="kramar-fb-import-file" name="kramar_import_file" accept=".json,application/json" style="display:none;" onchange="this.form.submit()">
					</form>
				</div>
			</div>

			<div class="kramar-fb-builder">
				<div class="kramar-fb-palette" id="kramar-fb-palette"></div>
				<div class="kramar-fb-canvas" id="kramar-fb-canvas"></div>
				<div class="kramar-fb-settings" id="kramar-fb-settings"></div>
			</div>

		</div>
		<?php
	}

	/**
	 * {@inheritDoc}
	 *
	 * Replaces the old table UI with a link to the visual builder.
	 */
	public function render_extra_settings( string $option_name ): void {
		?>
		<div class="kramar-module__extra">
			<p>
				<a href="<?php echo esc_url( admin_url( 'admin.php?page=kramar-checkout-fields' ) ); ?>" class="button">
					<?php esc_html_e( 'Open Checkout Field Builder', 'kramar' ); ?> &rarr;
				</a>
			</p>
		</div>
		<?php
	}

	// -------------------------------------------------------------------------
	// Helpers
	// -------------------------------------------------------------------------

	/**
	 * Return saved field definitions for a section.
	 *
	 * @since 1.0.0
	 *
	 * @param string $section 'billing'|'shipping'|'additional'.
	 * @return array<int, array<string, mixed>> List of field definition arrays.
	 */
	private function get_fields( string $section ): array {
		$option = match ( $section ) {
			'billing'    => self::OPTION_BILLING,
			'shipping'   => self::OPTION_SHIPPING,
			'additional' => self::OPTION_ADDITIONAL,
			default      => '',
		};

		if ( '' === $option ) {
			return [];
		}

		$fields = get_option( $option, null );
		return is_array( $fields ) ? $fields : [];
	}

	/**
	 * Persist field definitions for a section.
	 *
	 * @since 1.0.0
	 *
	 * @param string                          $section 'billing'|'shipping'|'additional'.
	 * @param array<int, array<string, mixed>> $fields  Field definition arrays.
	 */
	private function save_fields( string $section, array $fields ): void {
		$option = match ( $section ) {
			'billing'    => self::OPTION_BILLING,
			'shipping'   => self::OPTION_SHIPPING,
			'additional' => self::OPTION_ADDITIONAL,
			default      => '',
		};

		if ( '' !== $option ) {
			update_option( $option, array_values( $fields ), false );
		}
	}

	/**
	 * Seed the field options from WooCommerce defaults on first load.
	 *
	 * Only runs when all three options are absent (i.e. never been saved yet).
	 * Does nothing if WooCommerce is not active.
	 *
	 * @since 1.0.0
	 */
	private function maybe_seed_defaults(): void {
		// Check if already seeded (any option exists means seeding was done).
		if (
			null !== get_option( self::OPTION_BILLING, null ) ||
			null !== get_option( self::OPTION_SHIPPING, null ) ||
			null !== get_option( self::OPTION_ADDITIONAL, null )
		) {
			return;
		}

		if ( ! function_exists( 'WC' ) || ! WC()->countries ) {
			return;
		}

		// Billing fields.
		$billing_wc = WC()->countries->get_address_fields( '', 'billing_' );
		$billing    = $this->wc_fields_to_definitions( $billing_wc, 'billing', false );
		$this->save_fields( 'billing', $billing );

		// Shipping fields.
		$shipping_wc = WC()->countries->get_address_fields( '', 'shipping_' );
		$shipping    = $this->wc_fields_to_definitions( $shipping_wc, 'shipping', false );
		$this->save_fields( 'shipping', $shipping );

		// Additional fields.
		if ( WC()->checkout ) {
			$checkout_fields = WC()->checkout->get_checkout_fields( 'order' );
			$additional      = $this->wc_fields_to_definitions( $checkout_fields, 'additional', false );
		} else {
			$additional = [];
		}
		$this->save_fields( 'additional', $additional );
	}

	/**
	 * Convert a WooCommerce field array to our internal definition format.
	 *
	 * @since 1.0.0
	 *
	 * @param array<string, array<string, mixed>> $wc_fields WC field definitions (key => config).
	 * @param string                              $section   'billing'|'shipping'|'additional'.
	 * @param bool                                $custom    Whether to mark fields as custom.
	 * @return array<int, array<string, mixed>> Internal field definition list.
	 */
	private function wc_fields_to_definitions( array $wc_fields, string $section, bool $custom ): array {
		$definitions = [];

		foreach ( $wc_fields as $key => $config ) {
			$key  = sanitize_key( $key );
			$type = sanitize_key( $config['type'] ?? 'text' );

			// Map WC's 'country' / 'state' types to 'select' for our UI.
			if ( in_array( $type, [ 'country', 'state' ], true ) ) {
				$type = 'select';
			}

			// Only include types we support.
			if ( ! isset( self::FIELD_TYPES[ $type ] ) ) {
				$type = 'text';
			}

			$raw_class = $config['class'] ?? [];
			$classes   = is_array( $raw_class ) ? array_map( 'sanitize_html_class', $raw_class ) : [ 'form-row-wide' ];

			$definitions[] = [
				'key'         => $key,
				'type'        => $type,
				'label'       => sanitize_text_field( $config['label'] ?? '' ),
				'placeholder' => sanitize_text_field( $config['placeholder'] ?? '' ),
				'required'    => (bool) ( $config['required'] ?? false ),
				'class'       => $classes,
				'default'     => sanitize_text_field( (string) ( $config['default'] ?? '' ) ),
				'options'     => '',
				'enabled'     => true,
				'priority'    => (int) ( $config['priority'] ?? 10 ),
				'custom'      => $custom,
			];
		}

		return $definitions;
	}
}
