<?php
/**
 * Plugin Name: Junk Removal Cost Calculator
 * Description: Adds item-based, truck-volume, and optional AI photo junk removal estimates with editable pricing, branding, and booking request handling.
 * Version: 1.0.2
 * Requires at least: 5.8
 * Tested up to: 7.0
 * Requires PHP: 7.2
 * Author: Andrew
 * License: GPLv2 or later
 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain: junk-removal-cost-calculator
 *
 * @package JunkRemovalCalculator
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

final class Junkreco_Calculator_Plugin {
	private const VERSION = '1.0.2';
	private const FRONTEND_HANDLE = 'junkreco-calculator';
	private const ADMIN_HANDLE = 'junkreco-calculator-admin';
	private const RECOMMENDED_PHOTO_UPLOAD_BYTES = 5242880;
	private const AI_ESTIMATOR_PHOTO_UPLOAD_BYTES = 20971520;
	private const JUNKPATH_API_BASE_URL = 'https://zgyycocireuxobtbsyax.supabase.co/functions/v1/api-v1';
	private const BOOKING_URL_OPTION = 'junkreco_booking_url';
	private const BOOKING_FORM_OPTION = 'junkreco_booking_form_options';
	private const AI_ESTIMATOR_OPTION = 'junkreco_ai_estimator_options';
	private const PRICING_METHODS_OPTION = 'junkreco_pricing_methods';
	private const DESIGN_OPTION = 'junkreco_design_options';
	private const CATALOG_ITEMS_OPTION = 'junkreco_catalog_items';
	private const VOLUME_LOADS_OPTION = 'junkreco_volume_loads';

	/**
	 * Cached normalized catalog data.
	 *
	 * @var array<string, mixed>|null
	 */
	private $catalog = null;

	/**
	 * Cached bundled default catalog data.
	 *
	 * @var array<string, mixed>|null
	 */
	private $default_catalog = null;

	/**
	 * Settings page hook suffix.
	 *
	 * @var string
	 */
	private $settings_page_hook = '';

	public function __construct() {
		add_action( 'wp_enqueue_scripts', array( $this, 'register_assets' ) );
		add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_assets' ) );
		add_shortcode( 'junkreco_calculator', array( $this, 'render_shortcode' ) );
		add_action( 'admin_menu', array( $this, 'add_settings_page' ) );
		add_action( 'admin_init', array( $this, 'register_settings' ) );
		add_action( 'elementor/elements/categories_registered', array( $this, 'register_elementor_category' ) );
		add_action( 'elementor/widgets/register', array( $this, 'register_elementor_widget' ) );
		add_action( 'wp_ajax_junkreco_submit_booking', array( $this, 'handle_booking_submission' ) );
		add_action( 'wp_ajax_nopriv_junkreco_submit_booking', array( $this, 'handle_booking_submission' ) );
		add_action( 'wp_ajax_junkreco_create_ai_estimate', array( $this, 'handle_ai_estimate_request' ) );
		add_action( 'wp_ajax_nopriv_junkreco_create_ai_estimate', array( $this, 'handle_ai_estimate_request' ) );
		add_action( 'wp_ajax_junkreco_junkpath_admin', array( $this, 'handle_junkpath_admin_request' ) );
	}

	public function register_assets(): void {
		$base_url = plugin_dir_url( __FILE__ );

		wp_register_style(
			self::FRONTEND_HANDLE,
			$base_url . 'assets/css/junkreco-calculator.css',
			array(),
			$this->get_asset_version( 'assets/css/junkreco-calculator.css' )
		);

		wp_register_script(
			self::FRONTEND_HANDLE,
			$base_url . 'assets/js/junkreco-calculator.js',
			array(),
			$this->get_asset_version( 'assets/js/junkreco-calculator.js' ),
			true
		);
	}

	private function get_asset_version( string $relative_path ): string {
		$path     = plugin_dir_path( __FILE__ ) . ltrim( $relative_path, '/' );
		$modified = is_readable( $path ) ? filemtime( $path ) : false;

		return $modified ? self::VERSION . '.' . (string) $modified : self::VERSION;
	}

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

		$base_url = plugin_dir_url( __FILE__ );

		if ( ! wp_style_is( self::FRONTEND_HANDLE, 'registered' ) ) {
			$this->register_assets();
		}

		wp_enqueue_media();
		$this->enqueue_calculator_assets();
		wp_enqueue_style(
			self::ADMIN_HANDLE,
			$base_url . 'assets/css/junkreco-admin.css',
			array( self::FRONTEND_HANDLE ),
			$this->get_asset_version( 'assets/css/junkreco-admin.css' )
		);
		wp_enqueue_script(
			self::ADMIN_HANDLE,
			$base_url . 'assets/js/junkreco-admin.js',
			array( self::FRONTEND_HANDLE ),
			$this->get_asset_version( 'assets/js/junkreco-admin.js' ),
			true
		);
		wp_add_inline_script(
			self::ADMIN_HANDLE,
			'window.JunkrecoAdminData = ' . wp_json_encode(
				array(
					'junkpath' => array(
						'ajaxUrl'   => admin_url( 'admin-ajax.php' ),
						'nonce'     => wp_create_nonce( 'junkreco_junkpath_admin' ),
						'hasApiKey' => '' !== $this->get_junkpath_api_key(),
						'strings'   => array(
							'loading'      => __( 'Working with Junkpath...', 'junk-removal-cost-calculator' ),
							'accountDone'  => __( 'Junkpath account settings loaded.', 'junk-removal-cost-calculator' ),
							'settingsDone' => __( 'Junkpath settings loaded.', 'junk-removal-cost-calculator' ),
							'settingsSaved' => __( 'Junkpath settings saved.', 'junk-removal-cost-calculator' ),
							'feesDone'     => __( 'Extra fees loaded.', 'junk-removal-cost-calculator' ),
							'feeSaved'     => __( 'Extra fee saved.', 'junk-removal-cost-calculator' ),
							'feeDeleted'   => __( 'Extra fee deleted.', 'junk-removal-cost-calculator' ),
							'missingKey'   => __( 'Save a Junkpath API key before using live account settings.', 'junk-removal-cost-calculator' ),
							'error'        => __( 'Junkpath request failed. Please try again.', 'junk-removal-cost-calculator' ),
						),
					),
				),
				JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
			) . ';',
			'before'
		);
	}

	public function register_settings(): void {
		register_setting(
			'junkreco_booking_form_settings',
			self::BOOKING_URL_OPTION,
			array(
				'type'              => 'string',
				'sanitize_callback' => array( $this, 'sanitize_booking_url' ),
				'default'           => home_url( '/contact/' ),
			)
		);

		register_setting(
			'junkreco_items_settings',
			self::CATALOG_ITEMS_OPTION,
			array(
				'type'              => 'array',
				'sanitize_callback' => array( $this, 'sanitize_catalog_items' ),
			)
		);

		register_setting(
			'junkreco_general_settings',
			self::PRICING_METHODS_OPTION,
			array(
				'type'              => 'array',
				'sanitize_callback' => array( $this, 'sanitize_pricing_methods' ),
				'default'           => $this->get_default_pricing_methods(),
			)
		);

		register_setting(
			'junkreco_design_settings',
			self::DESIGN_OPTION,
			array(
				'type'              => 'array',
				'sanitize_callback' => array( $this, 'sanitize_design_options' ),
				'default'           => $this->get_default_design_options(),
			)
		);

		register_setting(
			'junkreco_booking_form_settings',
			self::BOOKING_FORM_OPTION,
			array(
				'type'              => 'array',
				'sanitize_callback' => array( $this, 'sanitize_booking_form_options' ),
				'default'           => $this->get_default_booking_form_options(),
			)
		);

		register_setting(
			'junkreco_ai_estimator_settings',
			self::AI_ESTIMATOR_OPTION,
			array(
				'type'              => 'array',
				'sanitize_callback' => array( $this, 'sanitize_ai_estimator_options' ),
				'default'           => $this->get_default_ai_estimator_options(),
			)
		);

		register_setting(
			'junkreco_volume_settings',
			self::VOLUME_LOADS_OPTION,
			array(
				'type'              => 'array',
				'sanitize_callback' => array( $this, 'sanitize_volume_loads' ),
			)
		);

		add_settings_section(
			'junkreco_settings_main',
			__( 'Calculator Settings', 'junk-removal-cost-calculator' ),
			static function (): void {
				echo '<p>' . esc_html__( 'Control which pricing methods visitors can use.', 'junk-removal-cost-calculator' ) . '</p>';
			},
			'junkreco_general_settings'
		);

		add_settings_field(
			self::PRICING_METHODS_OPTION,
			__( 'Pricing Methods', 'junk-removal-cost-calculator' ),
			array( $this, 'render_pricing_methods_field' ),
			'junkreco_general_settings',
			'junkreco_settings_main'
		);
	}

	public function add_settings_page(): void {
		$this->settings_page_hook = add_options_page(
			__( 'Junk Removal Calculator', 'junk-removal-cost-calculator' ),
			__( 'Junk Calculator', 'junk-removal-cost-calculator' ),
			'manage_options',
			'junkreco-settings',
			array( $this, 'render_settings_page' )
		);
	}

	/**
	 * Add a small Elementor category when Elementor is active.
	 *
	 * @param mixed $elements_manager Elementor elements manager.
	 */
	public function register_elementor_category( $elements_manager ): void {
		if ( ! is_object( $elements_manager ) || ! method_exists( $elements_manager, 'add_category' ) ) {
			return;
		}

		$elements_manager->add_category(
			'junkreco-calculators',
			array(
				'title' => __( 'Junk Calculator', 'junk-removal-cost-calculator' ),
				'icon'  => 'fa fa-calculator',
			)
		);
	}

	/**
	 * Register an optional native Elementor widget.
	 *
	 * @param mixed $widgets_manager Elementor widgets manager.
	 */
	public function register_elementor_widget( $widgets_manager ): void {
		if ( ! class_exists( '\Elementor\Widget_Base' ) || ! is_object( $widgets_manager ) || ! method_exists( $widgets_manager, 'register' ) ) {
			return;
		}

		require_once plugin_dir_path( __FILE__ ) . 'includes/elementor-widget.php';

		if ( class_exists( 'Junkreco_Elementor_Calculator_Widget' ) ) {
			$widgets_manager->register( new Junkreco_Elementor_Calculator_Widget() );
		}
	}

	public function sanitize_booking_url( string $value ): string {
		$value = trim( $value );

		if ( '' === $value ) {
			return '';
		}

		return esc_url_raw( $value );
	}

	/**
	 * Sanitize enabled pricing methods.
	 *
	 * @param mixed $value Raw option value.
	 * @return array<int, string>
	 */
	public function sanitize_pricing_methods( $value ): array {
		return $this->normalize_pricing_methods( $value, true );
	}

	/**
	 * Sanitize design options.
	 *
	 * @param mixed $value Raw option value.
	 * @return array<string, mixed>
	 */
	public function sanitize_design_options( $value ): array {
		$defaults = $this->get_default_design_options();

		if ( ! is_array( $value ) ) {
			return $defaults;
		}

		$colors = array(
			'primary_color',
			'primary_dark_color',
			'text_color',
			'muted_color',
			'surface_color',
			'panel_color',
			'option_bg_color',
			'option_text_color',
			'option_active_bg_color',
			'option_active_text_color',
			'border_color',
			'danger_color',
			'cta_text_color',
		);

		$sanitized = $defaults;
		foreach ( $colors as $key ) {
			$color = isset( $value[ $key ] ) ? sanitize_hex_color( (string) $value[ $key ] ) : '';
			if ( $color ) {
				$sanitized[ $key ] = $color;
			}
		}

		$font_family = isset( $value['font_family'] ) ? sanitize_key( (string) $value['font_family'] ) : $defaults['font_family'];
		if ( in_array( $font_family, array( 'inherit', 'system', 'sans', 'serif', 'mono', 'custom' ), true ) ) {
			$sanitized['font_family'] = $font_family;
		}

		$custom_font = isset( $value['custom_font_family'] ) ? sanitize_text_field( (string) $value['custom_font_family'] ) : '';
		$sanitized['custom_font_family'] = $this->sanitize_font_stack( $custom_font );

		$sanitized['heading_size']   = $this->bounded_int( $value['heading_size'] ?? $defaults['heading_size'], 24, 56, (int) $defaults['heading_size'] );
		$sanitized['body_size']      = $this->bounded_int( $value['body_size'] ?? $defaults['body_size'], 13, 20, (int) $defaults['body_size'] );
		$sanitized['max_width']      = $this->bounded_int( $value['max_width'] ?? $defaults['max_width'], 640, 1400, (int) $defaults['max_width'] );
		$sanitized['radius']         = $this->bounded_int( $value['radius'] ?? $defaults['radius'], 0, 24, (int) $defaults['radius'] );
		$sanitized['control_radius'] = $this->bounded_int( $value['control_radius'] ?? $defaults['control_radius'], 0, 20, (int) $defaults['control_radius'] );

		$shadow = isset( $value['shadow'] ) ? sanitize_key( (string) $value['shadow'] ) : $defaults['shadow'];
		if ( in_array( $shadow, array( 'none', 'subtle', 'soft', 'strong' ), true ) ) {
			$sanitized['shadow'] = $shadow;
		}

		return $sanitized;
	}

	/**
	 * Sanitize built-in booking form options.
	 *
	 * @param mixed $value Raw option value.
	 * @return array<string, mixed>
	 */
	public function sanitize_booking_form_options( $value ): array {
		$defaults = $this->get_default_booking_form_options();

		if ( ! is_array( $value ) ) {
			return $defaults;
		}

		$sanitized = $defaults;

		$mode = isset( $value['mode'] ) ? sanitize_key( (string) $value['mode'] ) : $defaults['mode'];
		$sanitized['mode'] = in_array( $mode, array( 'redirect', 'form' ), true ) ? $mode : $defaults['mode'];

		$email = isset( $value['recipient_email'] ) ? sanitize_email( (string) $value['recipient_email'] ) : '';
		$sanitized['recipient_email'] = is_email( $email ) ? $email : (string) get_option( 'admin_email' );

		$text_fields = array(
			'email_subject',
			'form_title',
			'submit_label',
			'success_message',
			'success_redirect_url',
		);
		foreach ( $text_fields as $field ) {
			$raw = isset( $value[ $field ] ) ? (string) $value[ $field ] : (string) $defaults[ $field ];
			if ( 'success_redirect_url' === $field ) {
				$sanitized[ $field ] = esc_url_raw( trim( $raw ) );
				continue;
			}
			$sanitized[ $field ] = sanitize_text_field( $raw );
		}

		$textarea_fields = array( 'form_intro', 'consent_text' );
		foreach ( $textarea_fields as $field ) {
			$sanitized[ $field ] = isset( $value[ $field ] ) ? sanitize_textarea_field( (string) $value[ $field ] ) : (string) $defaults[ $field ];
		}

		$sanitized['allowed_zip_codes'] = implode( ', ', $this->normalize_zip_codes( $value['allowed_zip_codes'] ?? $defaults['allowed_zip_codes'] ) );

		$checkboxes = array(
			'enable_zip_eligibility',
			'enable_photos',
			'require_address',
			'require_preferred_date',
			'show_pickup_options',
			'show_stairs',
			'show_disassembly',
			'show_donation',
			'show_special_note',
			'require_text_consent',
		);
		foreach ( $checkboxes as $field ) {
			$sanitized[ $field ] = ! empty( $value[ $field ] );
		}

		$sanitized['max_photos'] = $this->bounded_int( $value['max_photos'] ?? $defaults['max_photos'], 1, 9, (int) $defaults['max_photos'] );

		return $sanitized;
	}

	/**
	 * Normalize a ZIP code list from comma-, space-, or newline-separated input.
	 *
	 * @param mixed $value Raw ZIP code list.
	 * @return array<int, string>
	 */
	private function normalize_zip_codes( $value ): array {
		if ( is_array( $value ) ) {
			$value = implode( ',', $value );
		}

		$parts = preg_split( '/[\s,;|]+/', (string) $value );
		if ( ! is_array( $parts ) ) {
			return array();
		}

		$zip_codes = array();
		foreach ( $parts as $part ) {
			$digits = preg_replace( '/\D+/', '', $part );
			if ( ! is_string( $digits ) || strlen( $digits ) < 5 ) {
				continue;
			}

			$zip = substr( $digits, 0, 5 );
			if ( preg_match( '/^\d{5}$/', $zip ) && ! in_array( $zip, $zip_codes, true ) ) {
				$zip_codes[] = $zip;
			}
		}

		return $zip_codes;
	}

	/**
	 * Sanitize item rows submitted from the settings editor.
	 *
	 * @param mixed $value Raw option value.
	 * @return array<int, array<string, mixed>>
	 */
	public function sanitize_catalog_items( $value ): array {
		if ( is_string( $value ) ) {
			$decoded = json_decode( $value, true );
			if ( ! is_array( $decoded ) ) {
				$decoded = json_decode( wp_unslash( $value ), true );
			}
			$value   = is_array( $decoded ) ? $decoded : array();
		}

		if ( ! is_array( $value ) ) {
			return array();
		}

		$items = array();
		foreach ( $value as $row ) {
			if ( ! is_array( $row ) || ! empty( $row['_delete'] ) ) {
				continue;
			}

			$category = isset( $row['category'] ) ? sanitize_text_field( (string) $row['category'] ) : '';
			$name     = isset( $row['name'] ) ? sanitize_text_field( (string) $row['name'] ) : '';

			if ( '' === $category && '' === $name ) {
				continue;
			}

			if ( '' === $category || '' === $name ) {
				add_settings_error(
					self::CATALOG_ITEMS_OPTION,
					'junkreco_item_missing_fields',
					__( 'Each saved item needs both a category and an item name. Incomplete item rows were skipped.', 'junk-removal-cost-calculator' ),
					'warning'
				);
				continue;
			}

			$id = isset( $row['id'] ) ? sanitize_key( (string) $row['id'] ) : '';
			if ( '' === $id ) {
				$id = 'junkreco_item_' . str_replace( '-', '', wp_generate_uuid4() );
			}

			$images = $this->normalize_catalog_images(
				$category,
				isset( $row['category_image'] ) ? (string) $row['category_image'] : '',
				isset( $row['image'] ) ? (string) $row['image'] : ''
			);

			$items[] = array(
				'id'             => $id,
				'category'       => $category,
				'category_image' => $images['category_image'],
				'name'           => $name,
				'image'          => $images['image'],
				'price'          => $this->normalize_price( $row['price'] ?? 0 ),
			);
		}

		return array_values( $items );
	}

	/**
	 * Sanitize truck volume rows submitted from the settings editor.
	 *
	 * @param mixed $value Raw option value.
	 * @return array<int, array<string, mixed>>
	 */
	public function sanitize_volume_loads( $value ): array {
		if ( is_string( $value ) ) {
			$decoded = json_decode( $value, true );
			if ( ! is_array( $decoded ) ) {
				$decoded = json_decode( wp_unslash( $value ), true );
			}
			$value   = is_array( $decoded ) ? $decoded : array();
		}

		if ( ! is_array( $value ) ) {
			return array();
		}

		$loads = array();
		foreach ( $value as $row ) {
			if ( ! is_array( $row ) || ! empty( $row['_delete'] ) ) {
				continue;
			}

			$title = isset( $row['title'] ) ? sanitize_text_field( (string) $row['title'] ) : '';
			if ( '' === $title ) {
				continue;
			}

			$id = isset( $row['id'] ) ? sanitize_key( (string) $row['id'] ) : '';
			if ( '' === $id ) {
				$id = 'junkreco_load_' . str_replace( '-', '', wp_generate_uuid4() );
			}

			$loads[] = array(
				'id'          => $id,
				'title'       => $title,
				'items'       => isset( $row['items'] ) ? sanitize_text_field( (string) $row['items'] ) : '',
				'description' => isset( $row['description'] ) ? sanitize_text_field( (string) $row['description'] ) : '',
				'image'       => isset( $row['image'] ) && '' !== trim( (string) $row['image'] ) ? $this->resolve_catalog_image_url( (string) $row['image'] ) : $this->get_default_volume_image_url(),
				'price'       => $this->normalize_price( $row['price'] ?? 0 ),
			);
		}

		return array_values( $loads );
	}

	/**
	 * Sanitize Junkpath AI Photo Estimator options.
	 *
	 * @param mixed $value Raw option value.
	 * @return array<string, mixed>
	 */
	public function sanitize_ai_estimator_options( $value ): array {
		$defaults = $this->get_default_ai_estimator_options();
		$current  = get_option( self::AI_ESTIMATOR_OPTION, array() );
		$current  = is_array( $current ) ? wp_parse_args( $current, $defaults ) : $defaults;

		if ( ! is_array( $value ) ) {
			return $current;
		}

		$api_key = isset( $value['api_key'] ) ? $this->sanitize_api_key( (string) $value['api_key'] ) : '';
		if ( empty( $value['clear_api_key'] ) && '' === $api_key ) {
			$api_key = (string) $current['api_key'];
		}

		$sanitized = $defaults;
		$sanitized['api_key']      = $api_key;
		$sanitized['max_photos']   = $this->bounded_int( $value['max_photos'] ?? $defaults['max_photos'], 1, 10, (int) $defaults['max_photos'] );
		$sanitized['enable_notes'] = ! empty( $value['enable_notes'] );

		foreach ( array( 'intro_text', 'submit_label', 'disclaimer' ) as $field ) {
			$raw = isset( $value[ $field ] ) ? (string) $value[ $field ] : (string) $defaults[ $field ];
			if ( 'intro_text' === $field || 'disclaimer' === $field ) {
				$sanitized[ $field ] = sanitize_textarea_field( $raw );
				continue;
			}
			$sanitized[ $field ] = sanitize_text_field( $raw );
		}

		return $sanitized;
	}

	public function render_booking_url_field(): void {
		$value = get_option( self::BOOKING_URL_OPTION, home_url( '/contact/' ) );
		?>
		<label class="junkreco-admin-field">
			<span><?php echo esc_html__( 'Booking URL', 'junk-removal-cost-calculator' ); ?></span>
			<input type="url" class="regular-text" name="<?php echo esc_attr( self::BOOKING_URL_OPTION ); ?>" value="<?php echo esc_attr( (string) $value ); ?>" placeholder="<?php echo esc_attr( home_url( '/contact/' ) ); ?>" />
		</label>
		<p class="description"><?php echo esc_html__( 'Use a full URL or a site-relative path. You can also override this per shortcode.', 'junk-removal-cost-calculator' ); ?></p>
		<?php
	}

	public function render_pricing_methods_field(): void {
		$selected = $this->get_pricing_methods();
		?>
		<fieldset class="junkreco-pricing-methods-field">
			<input type="hidden" name="<?php echo esc_attr( self::PRICING_METHODS_OPTION ); ?>[]" value="" />
			<?php foreach ( $this->get_pricing_method_choices() as $key => $label ) : ?>
				<label>
					<input
						type="checkbox"
						name="<?php echo esc_attr( self::PRICING_METHODS_OPTION ); ?>[]"
						value="<?php echo esc_attr( $key ); ?>"
						<?php checked( in_array( $key, $selected, true ) ); ?>
					/>
					<span><?php echo esc_html( $label ); ?></span>
				</label>
			<?php endforeach; ?>
			<p class="description"><?php echo esc_html__( 'Choose which pricing paths appear on the frontend. When only one method is enabled, the calculator skips the method chooser and opens that method directly.', 'junk-removal-cost-calculator' ); ?></p>
		</fieldset>
		<?php
	}

	public function render_settings_page(): void {
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}

		$current_tab = $this->get_current_admin_tab();
		?>
		<div class="wrap junkreco-admin">
			<h1><?php echo esc_html__( 'Junk Removal Calculator', 'junk-removal-cost-calculator' ); ?></h1>
			<?php $this->render_admin_tabs( $current_tab ); ?>
			<form method="post" action="options.php" class="junkreco-admin-tab-content">
				<?php $this->render_admin_tab_content( $current_tab ); ?>
			</form>
		</div>
		<?php
	}

	/**
	 * @return array<string, array<string, string>>
	 */
	private function get_admin_tabs(): array {
		return array(
			'general' => array(
				'label'  => __( 'General', 'junk-removal-cost-calculator' ),
				'group'  => 'junkreco_general_settings',
				'submit' => __( 'Save General Settings', 'junk-removal-cost-calculator' ),
			),
			'design'  => array(
				'label'  => __( 'Design & Branding', 'junk-removal-cost-calculator' ),
				'group'  => 'junkreco_design_settings',
				'submit' => __( 'Save Design Settings', 'junk-removal-cost-calculator' ),
			),
			'items'   => array(
				'label'  => __( 'Specific Item Pricing', 'junk-removal-cost-calculator' ),
				'group'  => 'junkreco_items_settings',
				'submit' => __( 'Save Item Pricing', 'junk-removal-cost-calculator' ),
			),
			'booking' => array(
				'label'  => __( 'Booking Form', 'junk-removal-cost-calculator' ),
				'group'  => 'junkreco_booking_form_settings',
				'submit' => __( 'Save Booking Form Settings', 'junk-removal-cost-calculator' ),
			),
			'volume'  => array(
				'label'  => __( 'Truck Volume Pricing', 'junk-removal-cost-calculator' ),
				'group'  => 'junkreco_volume_settings',
				'submit' => __( 'Save Truck Volume Pricing', 'junk-removal-cost-calculator' ),
			),
			'ai'      => array(
				'label'  => __( 'AI Photo Estimator', 'junk-removal-cost-calculator' ),
				'group'  => 'junkreco_ai_estimator_settings',
				'submit' => __( 'Save AI Photo Estimator', 'junk-removal-cost-calculator' ),
			),
		);
	}

	private function get_current_admin_tab(): string {
		$tabs = $this->get_admin_tabs();
		$tab  = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( (string) $_GET['tab'] ) ) : 'general'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended

		return isset( $tabs[ $tab ] ) ? $tab : 'general';
	}

	private function render_admin_tabs( string $current_tab ): void {
		$tabs = $this->get_admin_tabs();
		?>
		<nav class="nav-tab-wrapper junkreco-admin-tabs" aria-label="<?php echo esc_attr__( 'Junk calculator settings sections', 'junk-removal-cost-calculator' ); ?>">
			<?php foreach ( $tabs as $tab => $tab_data ) : ?>
				<a
					class="<?php echo esc_attr( 'nav-tab' . ( $current_tab === $tab ? ' nav-tab-active' : '' ) ); ?>"
					href="<?php echo esc_url( add_query_arg( array( 'page' => 'junkreco-settings', 'tab' => $tab ), admin_url( 'options-general.php' ) ) ); ?>"
				>
					<?php echo esc_html( $tab_data['label'] ); ?>
				</a>
			<?php endforeach; ?>
		</nav>
		<?php
	}

	private function render_admin_tab_content( string $current_tab ): void {
		$tabs = $this->get_admin_tabs();
		$tab  = $tabs[ $current_tab ];

		settings_fields( $tab['group'] );

		if ( 'general' === $current_tab ) {
			do_settings_sections( 'junkreco_general_settings' );
			$this->render_shortcode_reference();
		} elseif ( 'design' === $current_tab ) {
			$this->render_design_editor();
		} elseif ( 'items' === $current_tab ) {
			$this->render_item_editor();
		} elseif ( 'booking' === $current_tab ) {
			$this->render_booking_form_editor();
		} elseif ( 'volume' === $current_tab ) {
			$this->render_volume_editor();
		} elseif ( 'ai' === $current_tab ) {
			$this->render_ai_estimator_editor();
		}

		submit_button( $tab['submit'] );
	}

	private function render_shortcode_reference(): void {
		?>
		<section class="junkreco-admin-section junkreco-shortcode-reference">
			<h2><?php echo esc_html__( 'Shortcode', 'junk-removal-cost-calculator' ); ?></h2>
			<p><?php echo esc_html__( 'Use the shortcode anywhere WordPress accepts shortcodes, including Elementor Shortcode widgets.', 'junk-removal-cost-calculator' ); ?></p>
			<p><code>[junkreco_calculator]</code></p>
			<p><code>[junkreco_calculator booking_url="/book-now/" button_label="Continue to Booking"]</code></p>
			<p><code>[junkreco_calculator booking_mode="form"]</code></p>
			<p><code>[junkreco_calculator booking_mode="redirect"]</code></p>
			<p><code>[junkreco_calculator pricing_methods="specific"]</code></p>
			<p><code>[junkreco_calculator pricing_methods="volume"]</code></p>
			<p><code>[junkreco_calculator pricing_methods="ai"]</code></p>
			<p><code>[junkreco_calculator pricing_methods="specific,volume,ai"]</code></p>
		</section>
		<?php
	}

	private function render_design_editor(): void {
		$design = $this->get_design_options();
		?>
		<section class="junkreco-admin-section">
			<div class="junkreco-admin-section-head">
				<div>
					<h2><?php echo esc_html__( 'Design & Branding', 'junk-removal-cost-calculator' ); ?></h2>
					<p><?php echo esc_html__( 'Tune the calculator to match the site brand. These settings apply anywhere the shortcode or Elementor widget is used.', 'junk-removal-cost-calculator' ); ?></p>
				</div>
			</div>
			<div class="junkreco-design-workspace">
				<div class="junkreco-design-controls">
					<div class="junkreco-design-grid">
						<fieldset class="junkreco-design-card">
							<legend><?php echo esc_html__( 'Colors', 'junk-removal-cost-calculator' ); ?></legend>
							<?php
							$this->render_color_control( 'primary_color', __( 'Primary accent', 'junk-removal-cost-calculator' ), $design['primary_color'] );
							$this->render_color_control( 'primary_dark_color', __( 'Primary hover', 'junk-removal-cost-calculator' ), $design['primary_dark_color'] );
							$this->render_color_control( 'cta_text_color', __( 'Selected button text', 'junk-removal-cost-calculator' ), $design['cta_text_color'] );
							$this->render_color_control( 'danger_color', __( 'Remove/error accent', 'junk-removal-cost-calculator' ), $design['danger_color'] );
							?>
						</fieldset>
						<fieldset class="junkreco-design-card">
							<legend><?php echo esc_html__( 'Buttons & Options', 'junk-removal-cost-calculator' ); ?></legend>
							<?php
							$this->render_color_control( 'option_bg_color', __( 'Option background', 'junk-removal-cost-calculator' ), $design['option_bg_color'] );
							$this->render_color_control( 'option_text_color', __( 'Option text', 'junk-removal-cost-calculator' ), $design['option_text_color'] );
							$this->render_color_control( 'option_active_bg_color', __( 'Selected option background', 'junk-removal-cost-calculator' ), $design['option_active_bg_color'] );
							$this->render_color_control( 'option_active_text_color', __( 'Selected option text', 'junk-removal-cost-calculator' ), $design['option_active_text_color'] );
							?>
						</fieldset>
						<fieldset class="junkreco-design-card">
							<legend><?php echo esc_html__( 'Text & Surfaces', 'junk-removal-cost-calculator' ); ?></legend>
							<?php
							$this->render_color_control( 'text_color', __( 'Main text', 'junk-removal-cost-calculator' ), $design['text_color'] );
							$this->render_color_control( 'muted_color', __( 'Muted text', 'junk-removal-cost-calculator' ), $design['muted_color'] );
							$this->render_color_control( 'surface_color', __( 'Outer surface', 'junk-removal-cost-calculator' ), $design['surface_color'] );
							$this->render_color_control( 'panel_color', __( 'Inner panels', 'junk-removal-cost-calculator' ), $design['panel_color'] );
							$this->render_color_control( 'border_color', __( 'Borders', 'junk-removal-cost-calculator' ), $design['border_color'] );
							?>
						</fieldset>
						<fieldset class="junkreco-design-card">
							<legend><?php echo esc_html__( 'Typography', 'junk-removal-cost-calculator' ); ?></legend>
							<label>
								<span><?php echo esc_html__( 'Font family', 'junk-removal-cost-calculator' ); ?></span>
								<select name="<?php echo esc_attr( self::DESIGN_OPTION ); ?>[font_family]">
									<?php foreach ( $this->get_font_family_choices() as $key => $label ) : ?>
										<option value="<?php echo esc_attr( $key ); ?>" <?php selected( $design['font_family'], $key ); ?>><?php echo esc_html( $label ); ?></option>
									<?php endforeach; ?>
								</select>
							</label>
							<label>
								<span><?php echo esc_html__( 'Custom font stack', 'junk-removal-cost-calculator' ); ?></span>
								<input type="text" class="regular-text" name="<?php echo esc_attr( self::DESIGN_OPTION ); ?>[custom_font_family]" value="<?php echo esc_attr( (string) $design['custom_font_family'] ); ?>" placeholder="<?php echo esc_attr__( 'Inter, Arial, sans-serif', 'junk-removal-cost-calculator' ); ?>" />
							</label>
							<?php
							$this->render_number_control( 'heading_size', __( 'Heading size', 'junk-removal-cost-calculator' ), $design['heading_size'], 24, 56, 'px' );
							$this->render_number_control( 'body_size', __( 'Body size', 'junk-removal-cost-calculator' ), $design['body_size'], 13, 20, 'px' );
							?>
						</fieldset>
						<fieldset class="junkreco-design-card">
							<legend><?php echo esc_html__( 'Layout & Shape', 'junk-removal-cost-calculator' ); ?></legend>
							<?php
							$this->render_number_control( 'max_width', __( 'Max width', 'junk-removal-cost-calculator' ), $design['max_width'], 640, 1400, 'px' );
							$this->render_number_control( 'radius', __( 'Card radius', 'junk-removal-cost-calculator' ), $design['radius'], 0, 24, 'px' );
							$this->render_number_control( 'control_radius', __( 'Control radius', 'junk-removal-cost-calculator' ), $design['control_radius'], 0, 20, 'px' );
							?>
							<label>
								<span><?php echo esc_html__( 'Shadow depth', 'junk-removal-cost-calculator' ); ?></span>
								<select name="<?php echo esc_attr( self::DESIGN_OPTION ); ?>[shadow]">
									<?php foreach ( $this->get_shadow_choices() as $key => $label ) : ?>
										<option value="<?php echo esc_attr( $key ); ?>" <?php selected( $design['shadow'], $key ); ?>><?php echo esc_html( $label ); ?></option>
									<?php endforeach; ?>
								</select>
							</label>
						</fieldset>
					</div>
				</div>
				<?php $this->render_design_mobile_preview(); ?>
			</div>
		</section>
		<?php
	}

	private function render_design_mobile_preview(): void {
		?>
		<aside class="junkreco-design-preview" aria-label="<?php echo esc_attr__( 'Mobile calculator preview', 'junk-removal-cost-calculator' ); ?>">
			<div class="junkreco-design-preview-head">
				<h3><?php echo esc_html__( 'Mobile Preview', 'junk-removal-cost-calculator' ); ?></h3>
				<span><?php echo esc_html__( 'Interactive', 'junk-removal-cost-calculator' ); ?></span>
			</div>
			<div class="junkreco-phone-frame">
				<div class="junkreco-phone-top" aria-hidden="true"></div>
				<div class="junkreco-phone-screen">
					<div
						class="junkreco-calculator junkreco-preview-calculator"
						style="<?php echo esc_attr( $this->build_design_style_attr() ); ?>"
						data-junkreco-design-preview
						data-booking-url="#"
						data-booking-mode="form"
						data-button-label="<?php echo esc_attr__( 'Book Your Pickup', 'junk-removal-cost-calculator' ); ?>"
						data-pricing-methods="<?php echo esc_attr( implode( ',', $this->get_pricing_methods() ) ); ?>"
						data-title="<?php echo esc_attr__( 'Calculate Your Junk Removal Cost', 'junk-removal-cost-calculator' ); ?>"
						data-intro="<?php echo esc_attr__( 'Choose item-based pricing, truck volume, or AI photo estimating, then continue with the booking link when the price looks right.', 'junk-removal-cost-calculator' ); ?>"
					>
						<noscript><?php echo esc_html__( 'Please enable JavaScript to use the mobile preview.', 'junk-removal-cost-calculator' ); ?></noscript>
					</div>
				</div>
			</div>
		</aside>
		<?php
	}

	private function render_color_control( string $key, string $label, string $value ): void {
		?>
		<label class="junkreco-color-control">
			<span><?php echo esc_html( $label ); ?></span>
			<input type="color" value="<?php echo esc_attr( $value ); ?>" data-junkreco-color-picker />
			<input type="text" name="<?php echo esc_attr( self::DESIGN_OPTION . '[' . $key . ']' ); ?>" value="<?php echo esc_attr( $value ); ?>" pattern="#[0-9a-fA-F]{6}" data-junkreco-color-text />
		</label>
		<?php
	}

	/**
	 * @param int|float|string $value Current value.
	 */
	private function render_number_control( string $key, string $label, $value, int $min, int $max, string $suffix ): void {
		?>
		<label class="junkreco-number-control">
			<span><?php echo esc_html( $label ); ?></span>
			<input type="number" min="<?php echo esc_attr( (string) $min ); ?>" max="<?php echo esc_attr( (string) $max ); ?>" step="1" name="<?php echo esc_attr( self::DESIGN_OPTION . '[' . $key . ']' ); ?>" value="<?php echo esc_attr( (string) $value ); ?>" />
			<em><?php echo esc_html( $suffix ); ?></em>
		</label>
		<?php
	}

	private function render_booking_form_editor(): void {
		$options = $this->get_booking_form_options();
		?>
		<section class="junkreco-admin-section">
			<div class="junkreco-admin-section-head">
				<div>
					<h2><?php echo esc_html__( 'Booking Form', 'junk-removal-cost-calculator' ); ?></h2>
					<p><?php echo esc_html__( 'Choose whether calculator buttons redirect to a URL or open the built-in booking request form. Built-in submissions are sent with WordPress email.', 'junk-removal-cost-calculator' ); ?></p>
				</div>
			</div>
			<div class="junkreco-booking-grid">
				<fieldset class="junkreco-design-card junkreco-booking-action-card">
					<legend><?php echo esc_html__( 'Booking Action', 'junk-removal-cost-calculator' ); ?></legend>
					<label class="junkreco-radio-control">
						<input type="radio" name="<?php echo esc_attr( self::BOOKING_FORM_OPTION ); ?>[mode]" value="redirect" data-junkreco-booking-mode <?php checked( $options['mode'], 'redirect' ); ?> />
						<span>
							<strong><?php echo esc_html__( 'Redirect to booking URL', 'junk-removal-cost-calculator' ); ?></strong>
							<small><?php echo esc_html__( 'Sends visitors to the Booking URL below or the shortcode override.', 'junk-removal-cost-calculator' ); ?></small>
						</span>
					</label>
					<div class="junkreco-redirect-url-field" data-junkreco-booking-url-field <?php if ( 'redirect' !== $options['mode'] ) : ?>hidden<?php endif; ?>>
						<?php $this->render_booking_url_field(); ?>
					</div>
					<label class="junkreco-radio-control">
						<input type="radio" name="<?php echo esc_attr( self::BOOKING_FORM_OPTION ); ?>[mode]" value="form" data-junkreco-booking-mode <?php checked( $options['mode'], 'form' ); ?> />
						<span>
							<strong><?php echo esc_html__( 'Use built-in booking form', 'junk-removal-cost-calculator' ); ?></strong>
							<small><?php echo esc_html__( 'Opens a request form after the visitor reviews item or truck-volume pricing.', 'junk-removal-cost-calculator' ); ?></small>
						</span>
					</label>
				</fieldset>

				<fieldset class="junkreco-design-card">
					<legend><?php echo esc_html__( 'Email Delivery', 'junk-removal-cost-calculator' ); ?></legend>
					<?php
					$this->render_booking_text_control( 'recipient_email', __( 'Recipient email', 'junk-removal-cost-calculator' ), (string) $options['recipient_email'], 'email', (string) get_option( 'admin_email' ) );
					$this->render_booking_text_control( 'email_subject', __( 'Email subject', 'junk-removal-cost-calculator' ), (string) $options['email_subject'] );
					?>
				</fieldset>

				<fieldset class="junkreco-design-card">
					<legend><?php echo esc_html__( 'Form Copy', 'junk-removal-cost-calculator' ); ?></legend>
					<?php
					$this->render_booking_text_control( 'form_title', __( 'Form title', 'junk-removal-cost-calculator' ), (string) $options['form_title'] );
					$this->render_booking_textarea_control( 'form_intro', __( 'Intro text', 'junk-removal-cost-calculator' ), (string) $options['form_intro'] );
					$this->render_booking_text_control( 'submit_label', __( 'Submit button label', 'junk-removal-cost-calculator' ), (string) $options['submit_label'] );
					$this->render_booking_text_control( 'success_message', __( 'Success message', 'junk-removal-cost-calculator' ), (string) $options['success_message'] );
					$this->render_booking_text_control( 'success_redirect_url', __( 'Optional success redirect URL', 'junk-removal-cost-calculator' ), (string) $options['success_redirect_url'], 'url', home_url( '/thank-you/' ) );
					?>
				</fieldset>

				<fieldset class="junkreco-design-card">
					<legend><?php echo esc_html__( 'Fields & Sections', 'junk-removal-cost-calculator' ); ?></legend>
					<?php
					$this->render_booking_checkbox_control( 'require_address', __( 'Require booking location fields', 'junk-removal-cost-calculator' ), (bool) $options['require_address'] );
					$this->render_booking_checkbox_control( 'require_preferred_date', __( 'Require pickup date', 'junk-removal-cost-calculator' ), (bool) $options['require_preferred_date'] );
					$this->render_booking_checkbox_control( 'show_pickup_options', __( 'Show in-home/outdoor pickup options', 'junk-removal-cost-calculator' ), (bool) $options['show_pickup_options'] );
					$this->render_booking_checkbox_control( 'show_stairs', __( 'Show stairs question', 'junk-removal-cost-calculator' ), (bool) $options['show_stairs'] );
					$this->render_booking_checkbox_control( 'show_disassembly', __( 'Show disassembly question', 'junk-removal-cost-calculator' ), (bool) $options['show_disassembly'] );
					$this->render_booking_checkbox_control( 'show_donation', __( 'Show donation question', 'junk-removal-cost-calculator' ), (bool) $options['show_donation'] );
					$this->render_booking_checkbox_control( 'show_special_note', __( 'Show special note field', 'junk-removal-cost-calculator' ), (bool) $options['show_special_note'] );
					$this->render_booking_checkbox_control( 'require_text_consent', __( 'Require SMS/contact consent checkbox', 'junk-removal-cost-calculator' ), (bool) $options['require_text_consent'] );
					$this->render_booking_textarea_control( 'consent_text', __( 'Consent text', 'junk-removal-cost-calculator' ), (string) $options['consent_text'] );
					?>
				</fieldset>

				<fieldset class="junkreco-design-card">
					<legend><?php echo esc_html__( 'ZIP Code Eligibility', 'junk-removal-cost-calculator' ); ?></legend>
					<?php
					$this->render_booking_checkbox_control( 'enable_zip_eligibility', __( 'Ask for ZIP code before showing the booking form', 'junk-removal-cost-calculator' ), (bool) $options['enable_zip_eligibility'] );
					$this->render_booking_textarea_control( 'allowed_zip_codes', __( 'Allowed ZIP codes', 'junk-removal-cost-calculator' ), (string) $options['allowed_zip_codes'] );
					?>
					<p class="description"><?php echo esc_html__( 'Enter 5-digit ZIP codes separated by commas. City lookup can be added later with a ZIP-code API.', 'junk-removal-cost-calculator' ); ?></p>
				</fieldset>

				<fieldset class="junkreco-design-card">
					<legend><?php echo esc_html__( 'Photo Estimate Uploads', 'junk-removal-cost-calculator' ); ?></legend>
					<?php
					$this->render_booking_checkbox_control( 'enable_photos', __( 'Allow visitors to upload photos', 'junk-removal-cost-calculator' ), (bool) $options['enable_photos'] );
					?>
					<label class="junkreco-number-control">
						<span><?php echo esc_html__( 'Maximum photos', 'junk-removal-cost-calculator' ); ?></span>
						<input type="number" min="1" max="9" step="1" name="<?php echo esc_attr( self::BOOKING_FORM_OPTION ); ?>[max_photos]" value="<?php echo esc_attr( (string) $options['max_photos'] ); ?>" />
						<em><?php echo esc_html__( 'files', 'junk-removal-cost-calculator' ); ?></em>
					</label>
					<?php $this->render_booking_photo_upload_limit_notice(); ?>
					<p class="description"><?php echo esc_html__( 'Photos are attached to the notification email and removed from temporary storage after the email attempt.', 'junk-removal-cost-calculator' ); ?></p>
				</fieldset>
			</div>
		</section>
		<?php
	}

	private function render_booking_photo_upload_limit_notice(): void {
		$wp_limit      = (int) wp_max_upload_size();
		$effective     = $this->get_booking_max_photo_size();
		$recommended   = self::RECOMMENDED_PHOTO_UPLOAD_BYTES;
		$is_low_limit  = $wp_limit > 0 && $wp_limit < $recommended;
		$upload_max    = ini_get( 'upload_max_filesize' );
		$post_max      = ini_get( 'post_max_size' );
		$memory_limit  = ini_get( 'memory_limit' );
		$site_health   = admin_url( 'site-health.php?tab=debug' );
		$notice_class  = 'junkreco-upload-limit-notice' . ( $is_low_limit ? ' is-warning' : ' is-ok' );
		$upload_max    = false === $upload_max ? __( 'Unknown', 'junk-removal-cost-calculator' ) : (string) $upload_max;
		$post_max      = false === $post_max ? __( 'Unknown', 'junk-removal-cost-calculator' ) : (string) $post_max;
		$memory_limit  = false === $memory_limit ? __( 'Unknown', 'junk-removal-cost-calculator' ) : (string) $memory_limit;
		?>
		<div class="<?php echo esc_attr( $notice_class ); ?>">
			<strong>
				<?php
				echo esc_html(
					$is_low_limit
						? __( 'Server upload limit is low for phone photos', 'junk-removal-cost-calculator' )
						: __( 'Photo upload limit detected', 'junk-removal-cost-calculator' )
				);
				?>
			</strong>
			<p>
				<?php
				printf(
					/* translators: 1: WordPress max upload size, 2: effective calculator photo limit, 3: recommended size. */
					esc_html__( 'WordPress currently allows uploads up to %1$s. The calculator will show visitors a photo limit of %2$s per photo. Recommended minimum for modern phone photos: %3$s or higher.', 'junk-removal-cost-calculator' ),
					esc_html( size_format( $wp_limit ) ),
					esc_html( size_format( $effective ) ),
					esc_html( size_format( $recommended ) )
				);
				?>
			</p>
			<dl>
				<div>
					<dt><?php echo esc_html__( 'upload_max_filesize', 'junk-removal-cost-calculator' ); ?></dt>
					<dd><?php echo esc_html( $upload_max ); ?></dd>
				</div>
				<div>
					<dt><?php echo esc_html__( 'post_max_size', 'junk-removal-cost-calculator' ); ?></dt>
					<dd><?php echo esc_html( $post_max ); ?></dd>
				</div>
				<div>
					<dt><?php echo esc_html__( 'memory_limit', 'junk-removal-cost-calculator' ); ?></dt>
					<dd><?php echo esc_html( $memory_limit ); ?></dd>
				</div>
			</dl>
			<?php if ( $is_low_limit ) : ?>
				<p>
					<?php
					echo esc_html__( 'To raise this, update the PHP upload limits in your hosting control panel, php.ini, .user.ini, or ask your host. A practical starting point is upload_max_filesize = 16M and post_max_size = 32M.', 'junk-removal-cost-calculator' );
					?>
				</p>
			<?php else : ?>
				<p>
					<?php echo esc_html__( 'This looks healthy for estimate photos. The calculator still keeps photo uploads bounded so notification emails do not become oversized.', 'junk-removal-cost-calculator' ); ?>
				</p>
			<?php endif; ?>
			<p>
				<a href="<?php echo esc_url( $site_health ); ?>">
					<?php echo esc_html__( 'View WordPress Site Health upload details', 'junk-removal-cost-calculator' ); ?>
				</a>
			</p>
		</div>
		<?php
	}

	private function render_booking_text_control( string $key, string $label, string $value, string $type = 'text', string $placeholder = '' ): void {
		?>
		<label class="junkreco-admin-field">
			<span><?php echo esc_html( $label ); ?></span>
			<input type="<?php echo esc_attr( $type ); ?>" class="regular-text" name="<?php echo esc_attr( self::BOOKING_FORM_OPTION . '[' . $key . ']' ); ?>" value="<?php echo esc_attr( $value ); ?>" placeholder="<?php echo esc_attr( $placeholder ); ?>" />
		</label>
		<?php
	}

	private function render_booking_textarea_control( string $key, string $label, string $value ): void {
		?>
		<label class="junkreco-admin-field">
			<span><?php echo esc_html( $label ); ?></span>
			<textarea rows="3" name="<?php echo esc_attr( self::BOOKING_FORM_OPTION . '[' . $key . ']' ); ?>"><?php echo esc_textarea( $value ); ?></textarea>
		</label>
		<?php
	}

	private function render_booking_checkbox_control( string $key, string $label, bool $checked ): void {
		?>
		<label class="junkreco-checkbox-control">
			<input type="checkbox" name="<?php echo esc_attr( self::BOOKING_FORM_OPTION . '[' . $key . ']' ); ?>" value="1" <?php checked( $checked ); ?> />
			<span><?php echo esc_html( $label ); ?></span>
		</label>
		<?php
	}

	private function render_ai_estimator_editor(): void {
		$options      = $this->get_ai_estimator_options();
		$has_api_key  = '' !== $this->get_junkpath_api_key( $options );
		$signup_url   = 'https://junkpath.com/';
		$api_docs_url = 'https://junkpath.com/api-docs';
		?>
		<section class="junkreco-admin-section">
			<div class="junkreco-admin-section-head">
				<div>
					<h2><?php echo esc_html__( 'AI Photo Estimator', 'junk-removal-cost-calculator' ); ?></h2>
					<p><?php echo esc_html__( 'Let visitors upload job photos and receive a Junkpath-powered estimate before booking. The API key is stored in WordPress and is never printed into frontend JavaScript.', 'junk-removal-cost-calculator' ); ?></p>
				</div>
			</div>
			<div class="junkreco-booking-grid">
				<fieldset class="junkreco-design-card">
					<legend><?php echo esc_html__( 'Junkpath Connection', 'junk-removal-cost-calculator' ); ?></legend>
					<p>
						<?php
						printf(
							/* translators: 1: opening signup link, 2: closing signup link, 3: opening docs link, 4: closing docs link. */
							esc_html__( 'Sign up at %1$sjunkpath.com%2$s, create an API key, then paste it below. You can review the API documentation at %3$sjunkpath.com/api-docs%4$s.', 'junk-removal-cost-calculator' ),
							'<a href="' . esc_url( $signup_url ) . '" target="_blank" rel="noopener noreferrer">',
							'</a>',
							'<a href="' . esc_url( $api_docs_url ) . '" target="_blank" rel="noopener noreferrer">',
							'</a>'
						);
						?>
					</p>
					<label class="junkreco-admin-field">
						<span><?php echo esc_html__( 'API key', 'junk-removal-cost-calculator' ); ?></span>
						<input
							type="password"
							class="regular-text"
							name="<?php echo esc_attr( self::AI_ESTIMATOR_OPTION ); ?>[api_key]"
							value=""
							autocomplete="new-password"
							placeholder="<?php echo esc_attr( $has_api_key ? __( 'API key saved - leave blank to keep it', 'junk-removal-cost-calculator' ) : __( 'jp_live_...', 'junk-removal-cost-calculator' ) ); ?>"
						/>
					</label>
					<?php if ( $has_api_key ) : ?>
						<label class="junkreco-checkbox-control">
							<input type="checkbox" name="<?php echo esc_attr( self::AI_ESTIMATOR_OPTION ); ?>[clear_api_key]" value="1" />
							<span><?php echo esc_html__( 'Clear the saved API key', 'junk-removal-cost-calculator' ); ?></span>
						</label>
					<?php endif; ?>
					<p class="description">
						<?php
						echo esc_html(
							$has_api_key
								? __( 'Connection ready. Enable "AI Photo Estimator" in General > Pricing Methods to show it on the frontend.', 'junk-removal-cost-calculator' )
								: __( 'The frontend AI pricing method will stay hidden until an API key is saved.', 'junk-removal-cost-calculator' )
						);
						?>
					</p>
				</fieldset>

				<fieldset class="junkreco-design-card">
					<legend><?php echo esc_html__( 'Frontend Experience', 'junk-removal-cost-calculator' ); ?></legend>
					<?php
					$this->render_ai_textarea_control( 'intro_text', __( 'Intro text', 'junk-removal-cost-calculator' ), (string) $options['intro_text'] );
					$this->render_ai_text_control( 'submit_label', __( 'Analyze button label', 'junk-removal-cost-calculator' ), (string) $options['submit_label'] );
					$this->render_ai_textarea_control( 'disclaimer', __( 'Estimate disclaimer', 'junk-removal-cost-calculator' ), (string) $options['disclaimer'] );
					?>
					<label class="junkreco-number-control">
						<span><?php echo esc_html__( 'Maximum photos', 'junk-removal-cost-calculator' ); ?></span>
						<input type="number" min="1" max="10" step="1" name="<?php echo esc_attr( self::AI_ESTIMATOR_OPTION ); ?>[max_photos]" value="<?php echo esc_attr( (string) $options['max_photos'] ); ?>" />
						<em><?php echo esc_html__( 'files', 'junk-removal-cost-calculator' ); ?></em>
					</label>
					<label class="junkreco-checkbox-control">
						<input type="checkbox" name="<?php echo esc_attr( self::AI_ESTIMATOR_OPTION ); ?>[enable_notes]" value="1" <?php checked( ! empty( $options['enable_notes'] ) ); ?> />
						<span><?php echo esc_html__( 'Let visitors add context notes with their photos', 'junk-removal-cost-calculator' ); ?></span>
					</label>
				</fieldset>

				<fieldset class="junkreco-design-card">
					<legend><?php echo esc_html__( 'Photo Limits', 'junk-removal-cost-calculator' ); ?></legend>
					<div class="junkreco-upload-limit-notice">
						<strong><?php echo esc_html__( 'Detected upload capacity', 'junk-removal-cost-calculator' ); ?></strong>
						<p>
							<?php
							printf(
								/* translators: 1: per-photo size, 2: total upload size. */
								esc_html__( 'AI estimator photos are limited to %1$s per photo and %2$s total for each estimate request, based on Junkpath and WordPress upload limits.', 'junk-removal-cost-calculator' ),
								esc_html( size_format( $this->get_ai_estimator_max_photo_size() ) ),
								esc_html( size_format( $this->get_ai_estimator_max_photo_total_size() ) )
							);
							?>
						</p>
						<p><?php echo esc_html__( 'Junkpath supports up to 10 photos per estimate. If your host has a smaller upload limit, WordPress may require lower limits here.', 'junk-removal-cost-calculator' ); ?></p>
					</div>
				</fieldset>
			</div>
			<?php $this->render_junkpath_account_tools( $has_api_key ); ?>
		</section>
		<?php
	}

	private function render_junkpath_account_tools( bool $has_api_key ): void {
		?>
		<section class="junkreco-junkpath-tools" data-junkpath-admin data-junkpath-autoload="<?php echo esc_attr( $has_api_key ? '1' : '0' ); ?>" data-junkpath-has-key="<?php echo esc_attr( $has_api_key ? '1' : '0' ); ?>">
			<div class="junkreco-admin-section-head">
				<div>
					<h2><?php echo esc_html__( 'Junkpath Account Settings', 'junk-removal-cost-calculator' ); ?></h2>
					<p><?php echo esc_html__( 'Pull the pricing settings Junkpath uses for AI estimates, update truck/load pricing, and manage extra fees without exposing the API key in the browser.', 'junk-removal-cost-calculator' ); ?></p>
				</div>
				<button type="button" class="button button-secondary" data-junkpath-action="load-account" <?php disabled( ! $has_api_key ); ?>><?php echo esc_html__( 'Load All from Junkpath', 'junk-removal-cost-calculator' ); ?></button>
			</div>
			<div class="junkreco-junkpath-status" data-junkpath-status aria-live="polite" hidden></div>
			<div class="junkreco-junkpath-grid">
				<fieldset class="junkreco-design-card junkreco-junkpath-settings">
					<legend><?php echo esc_html__( 'Pricing Settings', 'junk-removal-cost-calculator' ); ?></legend>
					<div class="junkreco-junkpath-actions">
						<button type="button" class="button button-secondary" data-junkpath-action="load-settings" <?php disabled( ! $has_api_key ); ?>><?php echo esc_html__( 'Load from Junkpath', 'junk-removal-cost-calculator' ); ?></button>
						<button type="button" class="button button-primary" data-junkpath-action="save-settings" <?php disabled( ! $has_api_key ); ?>><?php echo esc_html__( 'Save to Junkpath', 'junk-removal-cost-calculator' ); ?></button>
					</div>
					<div class="junkreco-junkpath-form">
						<label>
							<span><?php echo esc_html__( 'Landfill price', 'junk-removal-cost-calculator' ); ?></span>
							<input type="number" min="0" step="0.01" data-junkpath-setting="landfill_price" placeholder="72" />
						</label>
						<label>
							<span><?php echo esc_html__( 'Landfill minimum tons', 'junk-removal-cost-calculator' ); ?></span>
							<input type="number" min="0" step="0.01" data-junkpath-setting="landfill_minimum_tons" placeholder="0.5" />
						</label>
						<label class="junkreco-junkpath-check">
							<input type="checkbox" data-junkpath-setting="landfill_has_minimum" />
							<span><?php echo esc_html__( 'Landfill has a minimum', 'junk-removal-cost-calculator' ); ?></span>
						</label>
						<label>
							<span><?php echo esc_html__( 'Threshold percent', 'junk-removal-cost-calculator' ); ?></span>
							<input type="number" min="0" max="100" step="0.01" data-junkpath-setting="threshold_percent" placeholder="50" />
						</label>
					</div>
					<h3><?php echo esc_html__( 'Truck', 'junk-removal-cost-calculator' ); ?></h3>
					<div class="junkreco-junkpath-form is-four">
						<label>
							<span><?php echo esc_html__( 'Length ft', 'junk-removal-cost-calculator' ); ?></span>
							<input type="number" min="0" step="0.01" data-junkpath-setting="truck_length" />
						</label>
						<label>
							<span><?php echo esc_html__( 'Width ft', 'junk-removal-cost-calculator' ); ?></span>
							<input type="number" min="0" step="0.01" data-junkpath-setting="truck_width" />
						</label>
						<label>
							<span><?php echo esc_html__( 'Height ft', 'junk-removal-cost-calculator' ); ?></span>
							<input type="number" min="0" step="0.01" data-junkpath-setting="truck_height" />
						</label>
						<label>
							<span><?php echo esc_html__( 'Cubic yards', 'junk-removal-cost-calculator' ); ?></span>
							<input type="number" min="0" step="0.01" data-junkpath-setting="truck_cubic_yards" />
						</label>
					</div>
					<h3><?php echo esc_html__( 'Load Tiers', 'junk-removal-cost-calculator' ); ?></h3>
					<div class="junkreco-junkpath-tiers">
						<?php foreach ( $this->get_junkpath_pricing_tiers() as $key => $label ) : ?>
							<div class="junkreco-junkpath-tier">
								<label>
									<span><?php echo esc_html( $label ); ?></span>
									<input type="number" min="0" step="0.01" data-junkpath-setting="<?php echo esc_attr( 'pricing_' . $key ); ?>" />
								</label>
								<label class="junkreco-junkpath-check">
									<input type="checkbox" data-junkpath-setting="<?php echo esc_attr( 'enabled_' . $key ); ?>" />
									<span><?php echo esc_html__( 'Enabled', 'junk-removal-cost-calculator' ); ?></span>
								</label>
							</div>
						<?php endforeach; ?>
					</div>
				</fieldset>

				<fieldset class="junkreco-design-card junkreco-junkpath-fees">
					<legend><?php echo esc_html__( 'Extra Fees', 'junk-removal-cost-calculator' ); ?></legend>
					<p><?php echo esc_html__( 'Extra fees are Junkpath rules for special-handling items such as mattresses, refrigerators, paint, or other local add-ons. Include the amount in the instruction, for example "$25 per tire you see in the photos."', 'junk-removal-cost-calculator' ); ?></p>
					<div class="junkreco-junkpath-actions">
						<button type="button" class="button button-secondary" data-junkpath-action="load-fees" <?php disabled( ! $has_api_key ); ?>><?php echo esc_html__( 'Load Extra Fees', 'junk-removal-cost-calculator' ); ?></button>
						<button type="button" class="button button-secondary" data-junkpath-action="add-fee" <?php disabled( ! $has_api_key ); ?>><?php echo esc_html__( 'Add Fee Row', 'junk-removal-cost-calculator' ); ?></button>
					</div>
					<div class="junkreco-junkpath-fee-list" data-junkpath-fees-body>
						<p class="description"><?php echo esc_html__( 'Load your Junkpath extra fees or add a new row to begin.', 'junk-removal-cost-calculator' ); ?></p>
					</div>
				</fieldset>
			</div>
		</section>
		<?php
	}

	/**
	 * @return array<string, string>
	 */
	private function get_junkpath_pricing_tiers(): array {
		return array(
			'minimum'              => __( 'Minimum', 'junk-removal-cost-calculator' ),
			'eighth_load'          => __( '1/8 Load', 'junk-removal-cost-calculator' ),
			'sixth_load'           => __( '1/6 Load', 'junk-removal-cost-calculator' ),
			'quarter_load'         => __( '1/4 Load', 'junk-removal-cost-calculator' ),
			'third_load'           => __( '1/3 Load', 'junk-removal-cost-calculator' ),
			'three_eighths_load'   => __( '3/8 Load', 'junk-removal-cost-calculator' ),
			'half_load'            => __( '1/2 Load', 'junk-removal-cost-calculator' ),
			'five_eighths_load'    => __( '5/8 Load', 'junk-removal-cost-calculator' ),
			'two_thirds_load'      => __( '2/3 Load', 'junk-removal-cost-calculator' ),
			'three_quarters_load'  => __( '3/4 Load', 'junk-removal-cost-calculator' ),
			'seven_eighths_load'   => __( '7/8 Load', 'junk-removal-cost-calculator' ),
			'full_load'            => __( 'Full Load', 'junk-removal-cost-calculator' ),
		);
	}

	private function render_ai_text_control( string $key, string $label, string $value ): void {
		?>
		<label class="junkreco-admin-field">
			<span><?php echo esc_html( $label ); ?></span>
			<input type="text" class="regular-text" name="<?php echo esc_attr( self::AI_ESTIMATOR_OPTION . '[' . $key . ']' ); ?>" value="<?php echo esc_attr( $value ); ?>" />
		</label>
		<?php
	}

	private function render_ai_textarea_control( string $key, string $label, string $value ): void {
		?>
		<label class="junkreco-admin-field">
			<span><?php echo esc_html( $label ); ?></span>
			<textarea rows="3" name="<?php echo esc_attr( self::AI_ESTIMATOR_OPTION . '[' . $key . ']' ); ?>"><?php echo esc_textarea( $value ); ?></textarea>
		</label>
		<?php
	}

	private function render_item_editor(): void {
		$items      = $this->get_catalog_item_rows();
		$categories = $this->group_catalog_item_rows( $items );
		/* translators: %d: number of pricing categories. */
		$category_count_label = sprintf( _n( '%d category', '%d categories', count( $categories ), 'junk-removal-cost-calculator' ), count( $categories ) );
		/* translators: %d: number of pricing sub-items. */
		$item_count_label = sprintf( _n( '%d sub-item', '%d sub-items', count( $items ), 'junk-removal-cost-calculator' ), count( $items ) );
		?>
		<section class="junkreco-admin-section">
			<input type="hidden" name="<?php echo esc_attr( self::CATALOG_ITEMS_OPTION ); ?>" data-junkreco-json-target="items" value="" />
			<div class="junkreco-admin-section-head">
				<div>
						<h2><?php echo esc_html__( 'Specific Item Pricing', 'junk-removal-cost-calculator' ); ?></h2>
						<p><?php echo esc_html__( "Edit the top-level category tiles visitors click first, then manage each category's selectable sub-items, photos, and prices.", 'junk-removal-cost-calculator' ); ?></p>
						<div class="junkreco-admin-stats">
							<span><?php echo esc_html( $category_count_label ); ?></span>
							<span><?php echo esc_html( $item_count_label ); ?></span>
						</div>
				</div>
				<button type="button" class="button button-secondary" data-junkreco-add-category><?php echo esc_html__( 'Add Category', 'junk-removal-cost-calculator' ); ?></button>
			</div>
			<label class="junkreco-admin-filter">
				<span><?php echo esc_html__( 'Search settings items', 'junk-removal-cost-calculator' ); ?></span>
				<input type="search" class="regular-text" data-junkreco-filter="items" placeholder="<?php echo esc_attr__( 'Filter by category or sub-item name...', 'junk-removal-cost-calculator' ); ?>" />
			</label>
			<div id="junkreco-category-groups" class="junkreco-category-groups" data-junkreco-rows="items">
				<?php
				foreach ( $categories as $index => $category ) {
					$this->render_item_category_group( $category, (string) $index, $index < 4 );
				}
				?>
			</div>
			<template id="junkreco-item-row-template">
				<?php $this->render_sub_item_row( array(), '__index__' ); ?>
			</template>
			<template id="junkreco-category-group-template">
				<?php
				$this->render_item_category_group(
					array(
						'category'       => '',
						'category_image' => '',
						'items'          => array( array() ),
					),
					'__category__',
					true
				);
				?>
			</template>
		</section>
		<?php
	}

	/**
	 * Group flattened item rows into category editors.
	 *
	 * @param array<int, array<string, mixed>> $items Flattened item rows.
	 * @return array<int, array<string, mixed>>
	 */
	private function group_catalog_item_rows( array $items ): array {
		$groups = array();

		foreach ( $items as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}

			$category = isset( $item['category'] ) ? (string) $item['category'] : '';
			if ( '' === $category ) {
				$category = __( 'Uncategorized', 'junk-removal-cost-calculator' );
			}

			$key = sanitize_title( $category );
			if ( '' === $key ) {
				$key = md5( $category );
			}

			if ( ! isset( $groups[ $key ] ) ) {
				$groups[ $key ] = array(
					'category'       => $category,
					'category_image' => isset( $item['category_image'] ) ? (string) $item['category_image'] : '',
					'items'          => array(),
				);
			}

			if ( empty( $groups[ $key ]['category_image'] ) && ! empty( $item['category_image'] ) ) {
				$groups[ $key ]['category_image'] = (string) $item['category_image'];
			}

			$groups[ $key ]['items'][] = $item;
		}

		return array_values( $groups );
	}

	/**
	 * Render one category card with nested editable sub-items.
	 *
	 * @param array<string, mixed> $category Category group data.
	 * @param string              $index Category index.
	 * @param bool                $open Whether details starts expanded.
	 */
	private function render_item_category_group( array $category, string $index, bool $open = false ): void {
		$category_name  = isset( $category['category'] ) ? (string) $category['category'] : '';
		$category_image = isset( $category['category_image'] ) ? (string) $category['category_image'] : '';
		$items          = isset( $category['items'] ) && is_array( $category['items'] ) ? $category['items'] : array();
		if ( empty( $items ) ) {
			$items = array( array() );
		}
		$names       = array_map(
			static function ( $item ): string {
				return is_array( $item ) && isset( $item['name'] ) ? (string) $item['name'] : '';
			},
			$items
		);
		$search_text = strtolower( $category_name . ' ' . implode( ' ', $names ) );
		/* translators: %d: number of sub-items in this category. */
		$subitem_count_label = sprintf( _n( '%d sub-item', '%d sub-items', count( $items ), 'junk-removal-cost-calculator' ), count( $items ) );
		?>
		<details class="junkreco-category-group" data-junkreco-category-group data-junkreco-search="<?php echo esc_attr( $search_text ); ?>" <?php if ( $open ) : ?>open<?php endif; ?>>
			<summary class="junkreco-category-summary">
				<span class="junkreco-category-title" data-junkreco-category-title><?php echo esc_html( $category_name ? $category_name : __( 'New Category', 'junk-removal-cost-calculator' ) ); ?></span>
				<span class="junkreco-category-count" data-junkreco-sub-count><?php echo esc_html( $subitem_count_label ); ?></span>
			</summary>
			<div class="junkreco-category-body">
				<div class="junkreco-category-fields">
					<label class="junkreco-admin-field">
						<span><?php echo esc_html__( 'Category tile name', 'junk-removal-cost-calculator' ); ?></span>
						<input type="text" class="regular-text" data-junkreco-category-field="category" value="<?php echo esc_attr( $category_name ); ?>" placeholder="<?php echo esc_attr__( 'Couches', 'junk-removal-cost-calculator' ); ?>" />
					</label>
					<div class="junkreco-admin-field">
						<span><?php echo esc_html__( 'Category tile photo', 'junk-removal-cost-calculator' ); ?></span>
						<?php $this->render_photo_field( 'category_image', $category_image, true ); ?>
					</div>
				</div>
				<div class="junkreco-subitem-head">
					<h3><?php echo esc_html__( 'Sub-items visitors can add after opening this category', 'junk-removal-cost-calculator' ); ?></h3>
					<div class="junkreco-subitem-actions">
						<button type="button" class="button button-secondary" data-junkreco-add-sub-item><?php echo esc_html__( 'Add Sub-item', 'junk-removal-cost-calculator' ); ?></button>
						<button type="button" class="button-link-delete" data-junkreco-delete-category><?php echo esc_html__( 'Delete Category', 'junk-removal-cost-calculator' ); ?></button>
					</div>
				</div>
				<div class="junkreco-subitem-list" data-junkreco-subitems-body>
					<?php
					foreach ( $items as $item_index => $item ) {
						$row_index = '__category__' === $index ? '__index__' : $index . '-' . (string) $item_index;
						$this->render_sub_item_row( is_array( $item ) ? $item : array(), $row_index );
					}
					?>
				</div>
			</div>
		</details>
		<?php
	}

	/**
	 * Render a single editable sub-item row.
	 *
	 * @param array<string, mixed> $item Item row.
	 * @param string              $index Input array index.
	 */
	private function render_sub_item_row( array $item, string $index ): void {
		$name = isset( $item['name'] ) ? (string) $item['name'] : '';
		?>
		<div class="junkreco-subitem-row" data-junkreco-row="items" data-junkreco-search="<?php echo esc_attr( strtolower( $name ) ); ?>">
			<input type="hidden" data-junkreco-field="id" value="<?php echo esc_attr( isset( $item['id'] ) ? (string) $item['id'] : '' ); ?>" />
			<label class="junkreco-admin-field">
				<span><?php echo esc_html__( 'Sub-item name', 'junk-removal-cost-calculator' ); ?></span>
				<input type="text" class="regular-text" data-junkreco-field="name" value="<?php echo esc_attr( $name ); ?>" placeholder="<?php echo esc_attr__( 'Couch / Loveseat', 'junk-removal-cost-calculator' ); ?>" />
			</label>
			<div class="junkreco-admin-field">
				<span><?php echo esc_html__( 'Sub-item photo', 'junk-removal-cost-calculator' ); ?></span>
				<?php $this->render_photo_field( 'image', isset( $item['image'] ) ? (string) $item['image'] : '' ); ?>
			</div>
			<label class="junkreco-admin-field">
				<span><?php echo esc_html__( 'Price', 'junk-removal-cost-calculator' ); ?></span>
				<span class="junkreco-money-input">
					<span aria-hidden="true">$</span>
					<input type="number" min="0" step="0.01" data-junkreco-field="price" value="<?php echo esc_attr( isset( $item['price'] ) ? (string) $item['price'] : '0' ); ?>" />
				</span>
			</label>
			<div class="junkreco-subitem-delete">
				<input type="hidden" data-junkreco-delete-input data-junkreco-field="_delete" value="0" />
				<button type="button" class="button-link-delete" data-junkreco-delete-row><?php echo esc_html__( 'Delete', 'junk-removal-cost-calculator' ); ?></button>
			</div>
		</div>
		<?php
	}

	private function render_volume_editor(): void {
		$loads = $this->get_volume_load_rows();
		?>
		<section class="junkreco-admin-section">
			<input type="hidden" name="<?php echo esc_attr( self::VOLUME_LOADS_OPTION ); ?>" data-junkreco-json-target="volume" value="" />
			<div class="junkreco-admin-section-head">
				<div>
					<h2><?php echo esc_html__( 'Truck Volume Pricing', 'junk-removal-cost-calculator' ); ?></h2>
					<p><?php echo esc_html__( 'Add, edit, delete, and reorder the truck-volume pricing cards shown in the volume-based calculator.', 'junk-removal-cost-calculator' ); ?></p>
				</div>
				<button type="button" class="button button-secondary" data-junkreco-add-row="volume"><?php echo esc_html__( 'Add Truck Volume', 'junk-removal-cost-calculator' ); ?></button>
			</div>
			<div class="junkreco-admin-table-wrap">
				<table class="widefat striped junkreco-admin-table">
					<thead>
						<tr>
							<th><?php echo esc_html__( 'Title', 'junk-removal-cost-calculator' ); ?></th>
							<th><?php echo esc_html__( 'Item Range', 'junk-removal-cost-calculator' ); ?></th>
							<th><?php echo esc_html__( 'Description', 'junk-removal-cost-calculator' ); ?></th>
							<th><?php echo esc_html__( 'Truck Photo', 'junk-removal-cost-calculator' ); ?></th>
							<th><?php echo esc_html__( 'Price', 'junk-removal-cost-calculator' ); ?></th>
							<th><?php echo esc_html__( 'Delete', 'junk-removal-cost-calculator' ); ?></th>
						</tr>
					</thead>
					<tbody id="junkreco-volumes-body" data-junkreco-rows="volume">
						<?php
						foreach ( $loads as $index => $load ) {
							$this->render_volume_row( $load, (string) $index );
						}
						?>
					</tbody>
				</table>
			</div>
			<template id="junkreco-volume-row-template">
				<?php $this->render_volume_row( array(), '__index__' ); ?>
			</template>
		</section>
		<?php
	}

	/**
	 * Render a single editable volume row.
	 *
	 * @param array<string, mixed> $load Volume row.
	 * @param string              $index Input array index.
	 */
	private function render_volume_row( array $load, string $index ): void {
		?>
		<tr data-junkreco-row="volume">
			<td>
				<input type="hidden" data-junkreco-field="id" value="<?php echo esc_attr( isset( $load['id'] ) ? (string) $load['id'] : '' ); ?>" />
				<input type="text" class="regular-text" data-junkreco-field="title" value="<?php echo esc_attr( isset( $load['title'] ) ? (string) $load['title'] : '' ); ?>" placeholder="<?php echo esc_attr__( 'Half Load', 'junk-removal-cost-calculator' ); ?>" />
			</td>
			<td>
				<input type="text" class="regular-text" data-junkreco-field="items" value="<?php echo esc_attr( isset( $load['items'] ) ? (string) $load['items'] : '' ); ?>" placeholder="<?php echo esc_attr__( '(6-10 items)', 'junk-removal-cost-calculator' ); ?>" />
			</td>
			<td>
				<textarea data-junkreco-field="description" rows="2" placeholder="<?php echo esc_attr__( 'Short explanation shown on the pricing card.', 'junk-removal-cost-calculator' ); ?>"><?php echo esc_textarea( isset( $load['description'] ) ? (string) $load['description'] : '' ); ?></textarea>
			</td>
			<td>
				<?php $this->render_photo_field( 'image', isset( $load['image'] ) ? (string) $load['image'] : $this->get_default_volume_image_url() ); ?>
			</td>
			<td>
				<label class="junkreco-money-input">
					<span aria-hidden="true">$</span>
					<input type="number" min="0" step="0.01" data-junkreco-field="price" value="<?php echo esc_attr( isset( $load['price'] ) ? (string) $load['price'] : '0' ); ?>" />
				</label>
			</td>
			<td>
				<input type="hidden" data-junkreco-delete-input data-junkreco-field="_delete" value="0" />
				<button type="button" class="button-link-delete" data-junkreco-delete-row><?php echo esc_html__( 'Delete', 'junk-removal-cost-calculator' ); ?></button>
			</td>
		</tr>
		<?php
	}

	private function render_photo_field( string $field, string $value, bool $category_field = false ): void {
		$field_attr = $category_field ? 'data-junkreco-category-field' : 'data-junkreco-field';
		?>
		<div class="junkreco-photo-field">
			<input type="url" <?php echo esc_attr( $field_attr ); ?>="<?php echo esc_attr( $field ); ?>" value="<?php echo esc_attr( $value ); ?>" placeholder="https://..." data-junkreco-image-input />
			<button type="button" class="button" data-junkreco-media-button><?php echo esc_html__( 'Choose', 'junk-removal-cost-calculator' ); ?></button>
			<span class="junkreco-image-preview" data-junkreco-image-preview>
				<?php if ( $value ) : ?>
					<img src="<?php echo esc_url( $value ); ?>" alt="" />
				<?php endif; ?>
			</span>
		</div>
		<?php
	}

	public function handle_ai_estimate_request(): void {
		$content_length = isset( $_SERVER['CONTENT_LENGTH'] )
			? absint( sanitize_text_field( wp_unslash( (string) $_SERVER['CONTENT_LENGTH'] ) ) )
			: 0;
		if ( $content_length > 0 && empty( $_POST ) && empty( $_FILES ) && $content_length > $this->get_ai_estimator_max_photo_total_size() ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
			wp_send_json_error(
				array( 'message' => __( 'The selected photos are too large. Please upload fewer or smaller photos and try again.', 'junk-removal-cost-calculator' ) ),
				413
			);
		}

		if ( ! check_ajax_referer( 'junkreco_ai_estimator', 'nonce', false ) ) {
			wp_send_json_error(
				array( 'message' => __( 'AI estimator security check failed. Please refresh the page and try again.', 'junk-removal-cost-calculator' ) ),
				403
			);
		}

		$options = $this->get_ai_estimator_options();
		$api_key = $this->get_junkpath_api_key( $options );
		if ( '' === $api_key ) {
			wp_send_json_error(
				array( 'message' => __( 'The AI Photo Estimator is not configured yet. Please add a Junkpath API key in the plugin settings.', 'junk-removal-cost-calculator' ) ),
				503
			);
		}

		$images = $this->collect_ai_estimator_images( (int) $options['max_photos'] );
		if ( ! empty( $images['errors'] ) ) {
			wp_send_json_error(
				array( 'message' => implode( ' ', array_unique( $images['errors'] ) ) ),
				400
			);
		}

		if ( empty( $images['images'] ) ) {
			wp_send_json_error(
				array( 'message' => __( 'Please add at least one photo before requesting an AI estimate.', 'junk-removal-cost-calculator' ) ),
				400
			);
		}

		$payload = array(
			'images_base64' => $images['images'],
		);

		if ( ! empty( $options['enable_notes'] ) && isset( $_POST['notes'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
			$notes = sanitize_textarea_field( wp_unslash( (string) $_POST['notes'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
			$notes = substr( $notes, 0, 2000 );
			if ( '' !== $notes ) {
				$payload['notes'] = $notes;
			}
		}

		$response = wp_remote_post(
			trailingslashit( $this->get_junkpath_api_base_url() ) . 'estimates',
			array(
				'timeout' => 90,
				'headers' => array(
					'Authorization' => 'Bearer ' . $api_key,
					'Content-Type'  => 'application/json',
					'Accept'        => 'application/json',
				),
				'body'    => wp_json_encode( $payload ),
			)
		);

		if ( is_wp_error( $response ) ) {
			wp_send_json_error(
				array( 'message' => $response->get_error_message() ? $response->get_error_message() : __( 'Junkpath could not be reached. Please try again.', 'junk-removal-cost-calculator' ) ),
				502
			);
		}

		$status = (int) wp_remote_retrieve_response_code( $response );
		$body   = (string) wp_remote_retrieve_body( $response );
		$data   = json_decode( $body, true );

		if ( $status < 200 || $status >= 300 ) {
			wp_send_json_error(
				array( 'message' => $this->get_junkpath_error_message( is_array( $data ) ? $data : array(), $status ) ),
				$status > 0 ? $status : 502
			);
		}

		if ( ! is_array( $data ) ) {
			wp_send_json_error(
				array( 'message' => __( 'Junkpath returned an unreadable estimate response.', 'junk-removal-cost-calculator' ) ),
				502
			);
		}

		wp_send_json_success(
			array(
				'estimate' => $this->sanitize_junkpath_estimate_response( $data ),
			)
		);
	}

	public function handle_junkpath_admin_request(): void {
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error(
				array( 'message' => __( 'You do not have permission to manage Junkpath settings.', 'junk-removal-cost-calculator' ) ),
				403
			);
		}

		if ( ! check_ajax_referer( 'junkreco_junkpath_admin', 'nonce', false ) ) {
			wp_send_json_error(
				array( 'message' => __( 'Junkpath security check failed. Please refresh the page and try again.', 'junk-removal-cost-calculator' ) ),
				403
			);
		}

		$operation = isset( $_POST['operation'] ) ? sanitize_key( wp_unslash( (string) $_POST['operation'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing

		if ( '' === $this->get_junkpath_api_key() ) {
			wp_send_json_error(
				array( 'message' => __( 'Save a Junkpath API key before using live account settings.', 'junk-removal-cost-calculator' ) ),
				503
			);
		}

		switch ( $operation ) {
			case 'get_account':
				$this->send_junkpath_account_response();
				break;

			case 'get_settings':
				$response = $this->request_junkpath_api( 'GET', 'settings' );
				$this->send_junkpath_admin_response(
					$response,
					static function ( array $data ): array {
						return array( 'settings' => Junkreco_Calculator_Plugin::sanitize_junkpath_settings_response_static( $data ) );
					}
				);
				break;

			case 'update_settings':
				$payload  = $this->get_posted_json_payload();
				$response = $this->request_junkpath_api( 'PATCH', 'settings', $this->sanitize_junkpath_settings_update( $payload ) );
				$this->send_junkpath_admin_response(
					$response,
					static function ( array $data ): array {
						return array( 'settings' => Junkreco_Calculator_Plugin::sanitize_junkpath_settings_response_static( $data ) );
					}
				);
				break;

			case 'list_fees':
				$response = $this->request_junkpath_api( 'GET', 'extra-fees' );
				$this->send_junkpath_admin_response(
					$response,
					function ( array $data ): array {
						return array( 'fees' => $this->sanitize_junkpath_extra_fee_list( $data ) );
					}
				);
				break;

			case 'create_fee':
				$payload  = $this->sanitize_junkpath_extra_fee_input( $this->get_posted_json_payload(), true );
				$response = $this->request_junkpath_api( 'POST', 'extra-fees', $payload );
				$this->send_junkpath_admin_response(
					$response,
					function ( array $data ): array {
						return array( 'fee' => $this->sanitize_junkpath_extra_fee_response( $data ) );
					},
					201
				);
				break;

			case 'update_fee':
				$id       = $this->get_posted_extra_fee_id();
				$payload  = $this->sanitize_junkpath_extra_fee_input( $this->get_posted_json_payload(), true );
				$response = $this->request_junkpath_api( 'PATCH', 'extra-fees/' . rawurlencode( $id ), $payload );
				$this->send_junkpath_admin_response(
					$response,
					function ( array $data ): array {
						return array( 'fee' => $this->sanitize_junkpath_extra_fee_response( $data ) );
					}
				);
				break;

			case 'delete_fee':
				$id       = $this->get_posted_extra_fee_id();
				$response = $this->request_junkpath_api( 'DELETE', 'extra-fees/' . rawurlencode( $id ) );
				$this->send_junkpath_admin_response(
					$response,
					static function ( array $data ): array {
						return array(
							'deleted' => ! empty( $data['deleted'] ),
							'id'      => isset( $data['id'] ) ? sanitize_text_field( (string) $data['id'] ) : '',
						);
					}
				);
				break;

			default:
				wp_send_json_error(
					array( 'message' => __( 'Unknown Junkpath operation.', 'junk-removal-cost-calculator' ) ),
					400
				);
		}
	}

	public function handle_booking_submission(): void {
		$content_length = isset( $_SERVER['CONTENT_LENGTH'] )
			? absint( sanitize_text_field( wp_unslash( (string) $_SERVER['CONTENT_LENGTH'] ) ) )
			: 0;
		if ( $content_length > 0 && empty( $_POST ) && empty( $_FILES ) && $content_length > $this->get_booking_max_photo_total_size() ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
			wp_send_json_error(
				array( 'message' => __( 'The selected photos are too large. Please upload fewer or smaller photos and try again.', 'junk-removal-cost-calculator' ) ),
				413
			);
		}

		if ( ! check_ajax_referer( 'junkreco_booking_form', 'nonce', false ) ) {
			wp_send_json_error(
				array( 'message' => __( 'Booking form security check failed. Please refresh the page and try again.', 'junk-removal-cost-calculator' ) ),
				403
			);
		}

		$raw_booking = isset( $_POST['booking'] ) ? sanitize_textarea_field( wp_unslash( (string) $_POST['booking'] ) ) : '';
		$decoded     = json_decode( $raw_booking, true );
		if ( ! is_array( $decoded ) ) {
			wp_send_json_error(
				array( 'message' => __( 'The booking request could not be read. Please try again.', 'junk-removal-cost-calculator' ) ),
				400
			);
		}

		$options = $this->get_booking_form_options();
		$booking = $this->sanitize_booking_submission_data( $decoded );
		$errors  = $this->validate_booking_submission( $booking, $options );

		if ( ! empty( $errors ) ) {
			wp_send_json_error(
				array(
					'message' => sprintf(
						/* translators: %s: comma-separated list of missing fields. */
						__( 'Please complete: %s.', 'junk-removal-cost-calculator' ),
						implode( ', ', $errors )
					),
				),
				400
			);
		}

		$uploads = $this->handle_booking_photo_uploads( (bool) $options['enable_photos'], (int) $options['max_photos'] );
		if ( ! empty( $uploads['errors'] ) ) {
			$this->cleanup_booking_attachments( $uploads['files'] );
			wp_send_json_error(
				array( 'message' => implode( ' ', $uploads['errors'] ) ),
				400
			);
		}

		$recipient = is_email( (string) $options['recipient_email'] ) ? (string) $options['recipient_email'] : (string) get_option( 'admin_email' );
		$subject   = (string) $options['email_subject'];
		$message   = $this->build_booking_email_message( $booking, $uploads['names'] );
		$headers   = array( 'Content-Type: text/plain; charset=UTF-8' );

		if ( is_email( (string) $booking['email'] ) ) {
			$reply_name = trim( (string) $booking['first_name'] . ' ' . (string) $booking['last_name'] );
			$headers[]  = 'Reply-To: ' . ( $reply_name ? $reply_name . ' ' : '' ) . '<' . (string) $booking['email'] . '>';
		}

		$sent = wp_mail( $recipient, $subject, $message, $headers, $uploads['files'] );
		$this->cleanup_booking_attachments( $uploads['files'] );

		if ( ! $sent ) {
			wp_send_json_error(
				array( 'message' => __( 'The request was captured, but WordPress could not send the email. Please check the site mail settings.', 'junk-removal-cost-calculator' ) ),
				500
			);
		}

		wp_send_json_success(
			array(
				'message'     => (string) $options['success_message'],
				'redirectUrl' => (string) $options['success_redirect_url'],
			)
		);
	}

	/**
	 * @param array<string, mixed> $data Raw decoded booking JSON.
	 * @return array<string, mixed>
	 */
	private function sanitize_booking_submission_data( array $data ): array {
		$estimate = isset( $data['estimate'] ) && is_array( $data['estimate'] ) ? $data['estimate'] : array();
		$items    = array();
		$ai_details = array();

		if ( isset( $estimate['items'] ) && is_array( $estimate['items'] ) ) {
			foreach ( $estimate['items'] as $item ) {
				if ( ! is_array( $item ) ) {
					continue;
				}

				$name = isset( $item['name'] ) ? sanitize_text_field( (string) $item['name'] ) : '';
				if ( '' === $name ) {
					continue;
				}

				$items[] = array(
					'name'     => $name,
					'quantity' => max( 1, (int) ( $item['quantity'] ?? 1 ) ),
					'price'    => $this->normalize_price( $item['price'] ?? 0 ),
					'total'    => $this->normalize_price( $item['total'] ?? 0 ),
				);
			}
		}

		if ( isset( $estimate['aiDetails'] ) && is_array( $estimate['aiDetails'] ) ) {
			$ai_details = $this->sanitize_junkpath_estimate_response( $estimate['aiDetails'] );
		}

		$pickup_time = isset( $data['pickup_time'] ) ? sanitize_key( (string) $data['pickup_time'] ) : '';
		if ( ! in_array( $pickup_time, array( 'morning', 'afternoon', 'evening', 'all-day' ), true ) ) {
			$pickup_time = '';
		}

		$pickup_type = isset( $data['pickup_type'] ) ? sanitize_key( (string) $data['pickup_type'] ) : '';
		if ( ! in_array( $pickup_type, array( 'in-home', 'outdoor' ), true ) ) {
			$pickup_type = '';
		}

		return array(
			'first_name'            => isset( $data['first_name'] ) ? sanitize_text_field( (string) $data['first_name'] ) : '',
			'last_name'             => isset( $data['last_name'] ) ? sanitize_text_field( (string) $data['last_name'] ) : '',
			'email'                 => isset( $data['email'] ) ? sanitize_email( (string) $data['email'] ) : '',
			'phone'                 => isset( $data['phone'] ) ? sanitize_text_field( (string) $data['phone'] ) : '',
			'street'                => isset( $data['street'] ) ? sanitize_text_field( (string) $data['street'] ) : '',
			'city'                  => isset( $data['city'] ) ? sanitize_text_field( (string) $data['city'] ) : '',
			'state'                 => isset( $data['state'] ) ? strtoupper( substr( sanitize_text_field( (string) $data['state'] ), 0, 2 ) ) : '',
			'postal_code'           => isset( $data['postal_code'] ) ? sanitize_text_field( (string) $data['postal_code'] ) : '',
			'pickup_date'           => isset( $data['pickup_date'] ) ? sanitize_text_field( (string) $data['pickup_date'] ) : '',
			'pickup_time'           => $pickup_time,
			'pickup_type'           => $pickup_type,
			'has_stairs'            => ! empty( $data['has_stairs'] ),
			'stairs_flights'        => max( 1, min( 20, (int) ( $data['stairs_flights'] ?? 1 ) ) ),
			'needs_disassembly'     => ! empty( $data['needs_disassembly'] ),
			'donate_items'          => ! empty( $data['donate_items'] ),
			'donation_organization' => isset( $data['donation_organization'] ) ? sanitize_text_field( (string) $data['donation_organization'] ) : '',
			'special_note'          => isset( $data['special_note'] ) ? sanitize_textarea_field( (string) $data['special_note'] ) : '',
			'text_consent'          => ! empty( $data['text_consent'] ),
			'page_url'              => isset( $data['page_url'] ) ? esc_url_raw( (string) $data['page_url'] ) : '',
			'estimate'              => array(
				'method'       => isset( $estimate['method'] ) ? sanitize_key( (string) $estimate['method'] ) : '',
				'method_label' => isset( $estimate['methodLabel'] ) ? sanitize_text_field( (string) $estimate['methodLabel'] ) : '',
				'title'        => isset( $estimate['title'] ) ? sanitize_text_field( (string) $estimate['title'] ) : '',
				'items_label'  => isset( $estimate['itemsLabel'] ) ? sanitize_text_field( (string) $estimate['itemsLabel'] ) : '',
				'total'        => $this->normalize_price( $estimate['total'] ?? 0 ),
				'area_fee'     => $this->normalize_price( $estimate['areaFee'] ?? 0 ),
				'items'        => $items,
				'ai_details'   => $ai_details,
			),
		);
	}

	/**
	 * @param array<string, mixed> $booking Sanitized booking data.
	 * @param array<string, mixed> $options Sanitized booking form options.
	 * @return array<int, string>
	 */
	private function validate_booking_submission( array $booking, array $options ): array {
		$missing = array();

		if ( '' === (string) $booking['first_name'] ) {
			$missing[] = __( 'First Name', 'junk-removal-cost-calculator' );
		}
		if ( '' === (string) $booking['last_name'] ) {
			$missing[] = __( 'Last Name', 'junk-removal-cost-calculator' );
		}
		if ( ! is_email( (string) $booking['email'] ) ) {
			$missing[] = __( 'Email', 'junk-removal-cost-calculator' );
		}
		if ( '' === (string) $booking['phone'] ) {
			$missing[] = __( 'Phone Number', 'junk-removal-cost-calculator' );
		}

		if ( ! empty( $options['require_address'] ) ) {
			foreach ( array( 'street' => __( 'Street Address', 'junk-removal-cost-calculator' ), 'city' => __( 'City', 'junk-removal-cost-calculator' ), 'state' => __( 'State', 'junk-removal-cost-calculator' ), 'postal_code' => __( 'Postal Code', 'junk-removal-cost-calculator' ) ) as $key => $label ) {
				if ( '' === (string) $booking[ $key ] ) {
					$missing[] = $label;
				}
			}
		}

		if ( ! empty( $options['require_preferred_date'] ) && '' === (string) $booking['pickup_date'] ) {
			$missing[] = __( 'Pickup Date', 'junk-removal-cost-calculator' );
		}

		if ( ! empty( $options['require_text_consent'] ) && empty( $booking['text_consent'] ) ) {
			$missing[] = __( 'SMS/contact consent', 'junk-removal-cost-calculator' );
		}

		return $missing;
	}

	private function get_booking_max_photo_size(): int {
		$wp_limit     = (int) wp_max_upload_size();
		$plugin_limit = self::RECOMMENDED_PHOTO_UPLOAD_BYTES;

		return $wp_limit > 0 ? max( 1, min( $plugin_limit, $wp_limit ) ) : $plugin_limit;
	}

	private function get_booking_max_photo_total_size(): int {
		$wp_limit = (int) wp_max_upload_size();

		return $wp_limit > 0 ? max( 1, $wp_limit ) : $this->get_booking_max_photo_size();
	}

	private function get_ai_estimator_max_photo_size(): int {
		$wp_limit     = (int) wp_max_upload_size();
		$plugin_limit = self::AI_ESTIMATOR_PHOTO_UPLOAD_BYTES;

		return $wp_limit > 0 ? max( 1, min( $plugin_limit, $wp_limit ) ) : $plugin_limit;
	}

	private function get_ai_estimator_max_photo_total_size(): int {
		$wp_limit = (int) wp_max_upload_size();

		return $wp_limit > 0 ? max( 1, $wp_limit ) : $this->get_ai_estimator_max_photo_size();
	}

	/**
	 * @return array<string, string>
	 */
	private function get_booking_photo_mimes(): array {
		return array(
			'jpg|jpeg|jpe' => 'image/jpeg',
			'png'          => 'image/png',
			'gif'          => 'image/gif',
			'webp'         => 'image/webp',
			'heic'         => 'image/heic',
			'heif'         => 'image/heif',
		);
	}

	/**
	 * @return array{images: array<int, string>, names: array<int, string>, errors: array<int, string>}
	 */
	private function collect_ai_estimator_images( int $max_photos ): array {
		$result = array(
			'images' => array(),
			'names'  => array(),
			'errors' => array(),
		);

		if ( empty( $_FILES['photos'] ) || ! is_array( $_FILES['photos'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
			return $result;
		}

		$files          = $_FILES['photos']; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Missing
		$names          = isset( $files['name'] ) && is_array( $files['name'] ) ? $files['name'] : array( $files['name'] ?? '' );
		$count          = min( count( $names ), $max_photos );
		$max_file_size  = $this->get_ai_estimator_max_photo_size();
		$max_total_size = $this->get_ai_estimator_max_photo_total_size();
		$mimes          = $this->get_booking_photo_mimes();
		$total_size     = 0;

		if ( count( $names ) > $max_photos ) {
			$result['errors'][] = sprintf(
				/* translators: %d: maximum number of photos. */
				__( 'You can upload up to %d photos.', 'junk-removal-cost-calculator' ),
				$max_photos
			);
		}

		for ( $index = 0; $index < $count; $index++ ) {
			$error = is_array( $files['error'] ) ? (int) $files['error'][ $index ] : (int) $files['error'];
			if ( UPLOAD_ERR_NO_FILE === $error ) {
				continue;
			}
			if ( UPLOAD_ERR_OK !== $error ) {
				$result['errors'][] = __( 'One photo could not be uploaded.', 'junk-removal-cost-calculator' );
				continue;
			}

			$size = is_array( $files['size'] ) ? (int) $files['size'][ $index ] : (int) $files['size'];
			if ( $size > $max_file_size ) {
				$result['errors'][] = sprintf(
					/* translators: %s: maximum file size. */
					__( 'Each photo must be %s or smaller.', 'junk-removal-cost-calculator' ),
					size_format( $max_file_size )
				);
				continue;
			}

			$total_size += $size;
			if ( $total_size > $max_total_size ) {
				$result['errors'][] = sprintf(
					/* translators: %s: maximum total upload size. */
					__( 'Selected photos must total %s or smaller.', 'junk-removal-cost-calculator' ),
					size_format( $max_total_size )
				);
				continue;
			}

			$name     = sanitize_file_name( is_array( $files['name'] ) ? (string) $files['name'][ $index ] : (string) $files['name'] );
			$tmp_name = is_array( $files['tmp_name'] ) ? (string) $files['tmp_name'][ $index ] : (string) $files['tmp_name'];
			if ( '' === $tmp_name || ! is_uploaded_file( $tmp_name ) ) {
				$result['errors'][] = __( 'One photo could not be verified.', 'junk-removal-cost-calculator' );
				continue;
			}

			$filetype = wp_check_filetype_and_ext( $tmp_name, $name, $mimes );
			$mime     = isset( $filetype['type'] ) ? (string) $filetype['type'] : '';
			if ( '' === $mime ) {
				$mime = is_array( $files['type'] ) ? (string) $files['type'][ $index ] : (string) $files['type'];
			}
			if ( ! in_array( $mime, array_values( $mimes ), true ) ) {
				$result['errors'][] = __( 'Please upload JPG, PNG, GIF, WebP, HEIC, or HEIF photos.', 'junk-removal-cost-calculator' );
				continue;
			}

			$raw = file_get_contents( $tmp_name ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
			if ( ! is_string( $raw ) || '' === $raw ) {
				$result['errors'][] = __( 'One photo could not be read.', 'junk-removal-cost-calculator' );
				continue;
			}

			$result['images'][] = 'data:' . $mime . ';base64,' . base64_encode( $raw ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
			$result['names'][]  = $name;
		}

		return $result;
	}

	/**
	 * @return array{files: array<int, string>, names: array<int, string>, errors: array<int, string>}
	 */
	private function handle_booking_photo_uploads( bool $enabled, int $max_photos ): array {
		$result = array(
			'files'  => array(),
			'names'  => array(),
			'errors' => array(),
		);

		if ( ! $enabled || empty( $_FILES['photos'] ) || ! is_array( $_FILES['photos'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
			return $result;
		}

		require_once ABSPATH . 'wp-admin/includes/file.php';

		$files         = $_FILES['photos']; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Missing
		$names         = isset( $files['name'] ) && is_array( $files['name'] ) ? $files['name'] : array( $files['name'] ?? '' );
		$count         = min( count( $names ), $max_photos );
		$max_file_size = $this->get_booking_max_photo_size();
		$mimes         = $this->get_booking_photo_mimes();

		if ( count( $names ) > $max_photos ) {
			$result['errors'][] = sprintf(
				/* translators: %d: maximum number of photos. */
				__( 'You can upload up to %d photos.', 'junk-removal-cost-calculator' ),
				$max_photos
			);
		}

		for ( $index = 0; $index < $count; $index++ ) {
			$error = is_array( $files['error'] ) ? (int) $files['error'][ $index ] : (int) $files['error'];
			if ( UPLOAD_ERR_NO_FILE === $error ) {
				continue;
			}
			if ( UPLOAD_ERR_OK !== $error ) {
				$result['errors'][] = __( 'One photo could not be uploaded.', 'junk-removal-cost-calculator' );
				continue;
			}

			$size = is_array( $files['size'] ) ? (int) $files['size'][ $index ] : (int) $files['size'];
			if ( $size > $max_file_size ) {
				$result['errors'][] = sprintf(
					/* translators: %s: maximum file size. */
					__( 'Each photo must be %s or smaller.', 'junk-removal-cost-calculator' ),
					size_format( $max_file_size )
				);
				continue;
			}

			$file = array(
				'name'     => sanitize_file_name( is_array( $files['name'] ) ? (string) $files['name'][ $index ] : (string) $files['name'] ),
				'type'     => is_array( $files['type'] ) ? (string) $files['type'][ $index ] : (string) $files['type'],
				'tmp_name' => is_array( $files['tmp_name'] ) ? (string) $files['tmp_name'][ $index ] : (string) $files['tmp_name'],
				'error'    => $error,
				'size'     => $size,
			);

			$uploaded = wp_handle_upload(
				$file,
				array(
					'test_form' => false,
					'mimes'     => $mimes,
				)
			);

			if ( isset( $uploaded['error'] ) ) {
				$result['errors'][] = sanitize_text_field( (string) $uploaded['error'] );
				continue;
			}

			if ( ! empty( $uploaded['file'] ) ) {
				$result['files'][] = (string) $uploaded['file'];
				$result['names'][] = $file['name'];
			}
		}

		return $result;
	}

	/**
	 * @param array<int, string> $files Attachment file paths.
	 */
	private function cleanup_booking_attachments( array $files ): void {
		foreach ( $files as $file ) {
			if ( is_string( $file ) && '' !== $file && file_exists( $file ) ) {
				wp_delete_file( $file );
			}
		}
	}

	/**
	 * @param array<string, mixed> $booking Sanitized booking data.
	 * @param array<int, string>   $photo_names Uploaded photo display names.
	 */
	private function build_booking_email_message( array $booking, array $photo_names ): string {
		$estimate = isset( $booking['estimate'] ) && is_array( $booking['estimate'] ) ? $booking['estimate'] : array();
		$lines    = array(
			__( 'New junk removal booking request', 'junk-removal-cost-calculator' ),
			'',
			__( 'Estimate', 'junk-removal-cost-calculator' ),
			'------------------------------',
			__( 'Pricing method:', 'junk-removal-cost-calculator' ) . ' ' . ( (string) ( $estimate['method_label'] ?? '' ) ?: (string) ( $estimate['method'] ?? '' ) ),
		);

		if ( ! empty( $estimate['title'] ) ) {
			$lines[] = __( 'Selected option:', 'junk-removal-cost-calculator' ) . ' ' . (string) $estimate['title'];
		}
		if ( ! empty( $estimate['items_label'] ) ) {
			$lines[] = __( 'Item range:', 'junk-removal-cost-calculator' ) . ' ' . (string) $estimate['items_label'];
		}
		if ( ! empty( $estimate['items'] ) && is_array( $estimate['items'] ) ) {
			$lines[] = __( 'Items:', 'junk-removal-cost-calculator' );
			foreach ( $estimate['items'] as $item ) {
				if ( ! is_array( $item ) ) {
					continue;
				}
				$lines[] = sprintf(
					'- %1$s x %2$d (%3$s each, %4$s total)',
					(string) $item['name'],
					(int) $item['quantity'],
					'$' . number_format_i18n( (float) $item['price'], 2 ),
					'$' . number_format_i18n( (float) $item['total'], 2 )
				);
			}
		}
		if ( ! empty( $estimate['ai_details'] ) && is_array( $estimate['ai_details'] ) ) {
			$details = $estimate['ai_details'];
			$lines[] = __( 'AI estimate details:', 'junk-removal-cost-calculator' );
			if ( ! empty( $details['recommended_load_fraction'] ) ) {
				$lines[] = '- ' . __( 'Recommended load:', 'junk-removal-cost-calculator' ) . ' ' . (string) $details['recommended_load_fraction'];
			}
			$lines[] = '- ' . __( 'Cubic yards:', 'junk-removal-cost-calculator' ) . ' ' . number_format_i18n( (float) ( $details['cubic_yards'] ?? 0 ), 2 );
			$lines[] = '- ' . __( 'Estimated weight:', 'junk-removal-cost-calculator' ) . ' ' . number_format_i18n( (float) ( $details['estimated_weight_lbs'] ?? 0 ), 0 ) . ' lbs';
			$lines[] = '- ' . __( 'Truck usage:', 'junk-removal-cost-calculator' ) . ' ' . (int) ( $details['truck_percentage'] ?? 0 ) . '%';
			$lines[] = '- ' . __( 'Confidence:', 'junk-removal-cost-calculator' ) . ' ' . (int) ( $details['confidence'] ?? 0 ) . '%';
			if ( ! empty( $details['landfill_cost'] ) ) {
				$lines[] = '- ' . __( 'Estimated landfill cost:', 'junk-removal-cost-calculator' ) . ' $' . number_format_i18n( (float) $details['landfill_cost'], 2 );
			}
			if ( ! empty( $details['item_breakdown'] ) && is_array( $details['item_breakdown'] ) ) {
				$lines[] = __( 'Detected items:', 'junk-removal-cost-calculator' );
				foreach ( $details['item_breakdown'] as $item ) {
					if ( ! is_array( $item ) ) {
						continue;
					}
					$lines[] = sprintf(
						'- %1$s x %2$s (%3$s cu yd, %4$s lbs)',
						(string) ( $item['item'] ?? '' ),
						$this->format_estimate_quantity( $item['quantity'] ?? 0 ),
						number_format_i18n( (float) ( $item['cubicYards'] ?? 0 ), 2 ),
						number_format_i18n( (float) ( $item['weight'] ?? 0 ), 0 )
					);
				}
			}
			if ( ! empty( $details['extra_fees_total'] ) ) {
				$lines[] = '- ' . __( 'Extra fees total:', 'junk-removal-cost-calculator' ) . ' $' . number_format_i18n( (float) $details['extra_fees_total'], 2 );
			}
			if ( ! empty( $details['detected_extra_fees'] ) && is_array( $details['detected_extra_fees'] ) ) {
				$lines[] = __( 'Detected extra fees:', 'junk-removal-cost-calculator' );
				foreach ( $details['detected_extra_fees'] as $fee ) {
					if ( ! is_array( $fee ) ) {
						continue;
					}
					$lines[] = sprintf(
						'- %1$s x %2$s (%3$s)',
						(string) ( $fee['name'] ?? __( 'Extra fee', 'junk-removal-cost-calculator' ) ),
						$this->format_estimate_quantity( $fee['quantity'] ?? 1 ),
						'$' . number_format_i18n( (float) ( $fee['total'] ?? 0 ), 2 )
					);
					if ( ! empty( $fee['reason'] ) ) {
						$lines[] = '  ' . (string) $fee['reason'];
					}
				}
			}
			if ( ! empty( $details['analysis_notes'] ) ) {
				$lines[] = __( 'Analysis notes:', 'junk-removal-cost-calculator' ) . ' ' . (string) $details['analysis_notes'];
			}
		}
		$lines[] = __( 'Estimated total:', 'junk-removal-cost-calculator' ) . ' $' . number_format_i18n( (float) ( $estimate['total'] ?? 0 ), 2 );

		$lines = array_merge(
			$lines,
			array(
				'',
				__( 'Contact information', 'junk-removal-cost-calculator' ),
				'------------------------------',
				__( 'Name:', 'junk-removal-cost-calculator' ) . ' ' . trim( (string) $booking['first_name'] . ' ' . (string) $booking['last_name'] ),
				__( 'Email:', 'junk-removal-cost-calculator' ) . ' ' . (string) $booking['email'],
				__( 'Phone:', 'junk-removal-cost-calculator' ) . ' ' . (string) $booking['phone'],
				'',
				__( 'Booking location', 'junk-removal-cost-calculator' ),
				'------------------------------',
				__( 'Street Address:', 'junk-removal-cost-calculator' ) . ' ' . (string) $booking['street'],
				__( 'City:', 'junk-removal-cost-calculator' ) . ' ' . (string) $booking['city'],
				__( 'State:', 'junk-removal-cost-calculator' ) . ' ' . (string) $booking['state'],
				__( 'Postal Code:', 'junk-removal-cost-calculator' ) . ' ' . (string) $booking['postal_code'],
				'',
				__( 'Order information', 'junk-removal-cost-calculator' ),
				'------------------------------',
				__( 'Pickup Date:', 'junk-removal-cost-calculator' ) . ' ' . (string) $booking['pickup_date'],
				__( 'Pickup Time:', 'junk-removal-cost-calculator' ) . ' ' . $this->label_booking_choice( (string) $booking['pickup_time'] ),
				__( 'Pickup Type:', 'junk-removal-cost-calculator' ) . ' ' . $this->label_booking_choice( (string) $booking['pickup_type'] ),
				__( 'Stairs:', 'junk-removal-cost-calculator' ) . ' ' . $this->yes_no( ! empty( $booking['has_stairs'] ) ) . ( ! empty( $booking['has_stairs'] ) ? ' (' . (int) $booking['stairs_flights'] . ' ' . __( 'flight(s)', 'junk-removal-cost-calculator' ) . ')' : '' ),
				__( 'Needs disassembly:', 'junk-removal-cost-calculator' ) . ' ' . $this->yes_no( ! empty( $booking['needs_disassembly'] ) ),
				__( 'Donate usable items:', 'junk-removal-cost-calculator' ) . ' ' . $this->yes_no( ! empty( $booking['donate_items'] ) ),
				__( 'Donation organization:', 'junk-removal-cost-calculator' ) . ' ' . (string) $booking['donation_organization'],
				__( 'Special Note:', 'junk-removal-cost-calculator' ) . ' ' . (string) $booking['special_note'],
				__( 'Text consent:', 'junk-removal-cost-calculator' ) . ' ' . $this->yes_no( ! empty( $booking['text_consent'] ) ),
			)
		);

		if ( ! empty( $photo_names ) ) {
			$lines[] = '';
			$lines[] = __( 'Attached photos', 'junk-removal-cost-calculator' );
			$lines[] = '------------------------------';
			foreach ( $photo_names as $name ) {
				$lines[] = '- ' . $name;
			}
		}

		if ( ! empty( $booking['page_url'] ) ) {
			$lines[] = '';
			$lines[] = __( 'Submitted from:', 'junk-removal-cost-calculator' ) . ' ' . (string) $booking['page_url'];
		}

		return implode( "\n", $lines );
	}

	private function label_booking_choice( string $value ): string {
		$labels = array(
			'morning' => __( 'Morning (8AM - 12PM)', 'junk-removal-cost-calculator' ),
			'afternoon' => __( 'Afternoon (12PM - 4PM)', 'junk-removal-cost-calculator' ),
			'evening' => __( 'Evening (4PM - 8PM)', 'junk-removal-cost-calculator' ),
			'all-day' => __( 'All Day (8AM - 8PM)', 'junk-removal-cost-calculator' ),
			'in-home' => __( 'In-home pickup', 'junk-removal-cost-calculator' ),
			'outdoor' => __( 'Outdoor pickup', 'junk-removal-cost-calculator' ),
		);

		return $labels[ $value ] ?? $value;
	}

	private function yes_no( bool $value ): string {
		return $value ? __( 'Yes', 'junk-removal-cost-calculator' ) : __( 'No', 'junk-removal-cost-calculator' );
	}

	/**
	 * Render the calculator host element.
	 *
	 * @param array<string, string> $atts Shortcode attributes.
	 */
	public function render_shortcode( $atts = array() ): string {
		if ( ! is_array( $atts ) ) {
			$atts = array();
		}

		$atts = shortcode_atts(
			array(
				'booking_url'     => '',
				'booking_mode'    => '',
				'button_label'    => __( 'Book Your Pickup', 'junk-removal-cost-calculator' ),
				'pricing_methods' => '',
				'title'           => __( 'Calculate Your Junk Removal Cost', 'junk-removal-cost-calculator' ),
				'intro'           => __( 'Choose item-based pricing, truck volume, or AI photo estimating, then continue with the booking link when the price looks right.', 'junk-removal-cost-calculator' ),
			),
			$atts,
			'junkreco_calculator'
		);

		$this->enqueue_calculator_assets();

		$booking_url = $atts['booking_url'] ? (string) $atts['booking_url'] : (string) get_option( self::BOOKING_URL_OPTION, home_url( '/contact/' ) );
		$booking_url = $this->sanitize_public_url( $booking_url, '#' );
		$pricing_methods = '' !== trim( (string) $atts['pricing_methods'] )
			? $this->parse_pricing_methods_attribute( (string) $atts['pricing_methods'] )
			: $this->get_pricing_methods();
		$booking_mode = '' !== trim( (string) $atts['booking_mode'] )
			? $this->parse_booking_mode_attribute( (string) $atts['booking_mode'] )
			: (string) $this->get_booking_form_options()['mode'];

		ob_start();
		?>
		<div
			class="junkreco-calculator"
			style="<?php echo esc_attr( $this->build_design_style_attr() ); ?>"
			data-booking-url="<?php echo esc_url( (string) $booking_url ); ?>"
			data-booking-mode="<?php echo esc_attr( $booking_mode ); ?>"
			data-button-label="<?php echo esc_attr( (string) $atts['button_label'] ); ?>"
			data-pricing-methods="<?php echo esc_attr( implode( ',', $pricing_methods ) ); ?>"
			data-title="<?php echo esc_attr( (string) $atts['title'] ); ?>"
			data-intro="<?php echo esc_attr( (string) $atts['intro'] ); ?>"
		>
			<noscript><?php echo esc_html__( 'Please enable JavaScript to use the junk removal cost calculator.', 'junk-removal-cost-calculator' ); ?></noscript>
		</div>
		<?php
		return (string) ob_get_clean();
	}

	private function enqueue_calculator_assets(): void {
		// Block themes render post content before wp_enqueue_scripts fires, so the
		// handles may not be registered yet and wp_add_inline_script() would be dropped.
		if ( ! wp_script_is( self::FRONTEND_HANDLE, 'registered' ) ) {
			$this->register_assets();
		}

		wp_enqueue_style( self::FRONTEND_HANDLE );

		static $data_added = false;
		if ( ! $data_added ) {
			wp_add_inline_script(
				self::FRONTEND_HANDLE,
				'window.JunkrecoCalculatorData = ' . wp_json_encode( $this->get_calculator_data(), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT ) . ';',
				'before'
			);
			$data_added = true;
		}

		wp_enqueue_script( self::FRONTEND_HANDLE );
	}

	/**
	 * Build data passed to the frontend calculator.
	 *
	 * @return array<string, mixed>
	 */
	private function get_calculator_data(): array {
		return array(
			'catalog'       => $this->load_catalog(),
			'currency'      => '$',
			'areaFee'       => 0,
			'popularCount'  => 6,
			'morePageSize'  => 6,
			'pricingMethods' => $this->get_pricing_methods(),
			'bookingForm'   => $this->get_public_booking_form_options(),
			'aiEstimator'   => $this->get_public_ai_estimator_options(),
			'estimateNote'  => __( 'Prices shown are estimates and may vary slightly upon physical inspection.', 'junk-removal-cost-calculator' ),
			'emptyTitle'    => __( 'No Items Selected', 'junk-removal-cost-calculator' ),
			'emptyMessage'  => __( 'Browse popular items below, or search for what you need.', 'junk-removal-cost-calculator' ),
			'strings'       => array(
				'specificTitle'     => __( 'Specific Item Based', 'junk-removal-cost-calculator' ),
				'specificHelp'      => __( 'Best if you know what items you want to remove', 'junk-removal-cost-calculator' ),
				'volumeTitle'       => __( 'Truck Volume Based', 'junk-removal-cost-calculator' ),
				'volumeHelp'        => __( "Best if you're not sure about item count", 'junk-removal-cost-calculator' ),
				'aiTitle'           => __( 'AI Photo Estimator', 'junk-removal-cost-calculator' ),
				'aiHelp'            => __( 'Best if you can upload photos of the job', 'junk-removal-cost-calculator' ),
				'aiResultTitle'     => __( 'AI photo estimate', 'junk-removal-cost-calculator' ),
				'methodLabel'       => __( 'Select a method to calculate your junk', 'junk-removal-cost-calculator' ),
				'selectItems'       => __( 'Please select your items', 'junk-removal-cost-calculator' ),
				'searchPlaceholder' => __( 'Search your items here...', 'junk-removal-cost-calculator' ),
				'searchResults'     => __( 'Search Results', 'junk-removal-cost-calculator' ),
				'popularItems'      => __( 'Popular Items', 'junk-removal-cost-calculator' ),
				'moreItems'         => __( 'All Items', 'junk-removal-cost-calculator' ),
				'selectedItems'     => __( 'Selected items', 'junk-removal-cost-calculator' ),
				'subtotal'          => __( 'SUB Total Price -', 'junk-removal-cost-calculator' ),
				'areaFees'          => __( 'Area Fees -', 'junk-removal-cost-calculator' ),
				'total'             => __( 'Total Price -', 'junk-removal-cost-calculator' ),
				'startingAt'        => __( 'Starting at', 'junk-removal-cost-calculator' ),
				'volumeCtaPrefix'   => __( 'If you want to book, select your option and then', 'junk-removal-cost-calculator' ),
				'noResults'         => __( 'No items found matching', 'junk-removal-cost-calculator' ),
				'bookingBack'       => __( 'Back to estimate', 'junk-removal-cost-calculator' ),
			),
		);
	}

	/**
	 * Load normalized calculator catalog data.
	 *
	 * @return array<string, mixed>
	 */
	private function load_catalog(): array {
		if ( null !== $this->catalog ) {
			return $this->catalog;
		}

		$categories = array();
		$seen_items = array();

		foreach ( $this->get_catalog_item_rows() as $index => $item ) {
			$category_name = isset( $item['category'] ) ? (string) $item['category'] : '';
			$item_name     = isset( $item['name'] ) ? (string) $item['name'] : '';

			if ( '' === $category_name || '' === $item_name ) {
				continue;
			}

			$category_image = isset( $item['category_image'] ) ? (string) $item['category_image'] : '';
			if ( $this->is_placeholder_catalog_image( $category_image ) ) {
				$category_image = '';
			}

			if ( '' === $category_image ) {
				$category_image = $this->get_bundled_category_image_url( $category_name );
			}

			$item_image = isset( $item['image'] ) ? (string) $item['image'] : '';
			if ( $this->is_placeholder_catalog_image( $item_image ) ) {
				$item_image = '';
			}

			$category_key = sanitize_title( $category_name );
			if ( '' === $category_key ) {
				$category_key = 'category-' . md5( $category_name );
			}

			if ( ! isset( $categories[ $category_key ] ) ) {
				$categories[ $category_key ] = array(
					'id'               => $category_key,
					'name'             => $category_name,
					'image'            => $category_image ? $category_image : $item_image,
					'popularity_count' => 0,
					'sub_items'        => array(),
				);
			}

			if ( empty( $categories[ $category_key ]['image'] ) ) {
				if ( '' !== $category_image ) {
					$categories[ $category_key ]['image'] = $category_image;
				} elseif ( '' !== $item_image ) {
					$categories[ $category_key ]['image'] = $item_image;
				}
			}

			$item_id = isset( $item['id'] ) ? sanitize_key( (string) $item['id'] ) : '';
			if ( '' === $item_id ) {
				$item_id = 'junkreco_item_' . md5( $category_key . $item_name . $index );
			}
			if ( isset( $seen_items[ $item_id ] ) ) {
				$item_id .= '-' . (string) $index;
			}
			$seen_items[ $item_id ] = true;

			$categories[ $category_key ]['popularity_count']++;
			$categories[ $category_key ]['sub_items'][] = array(
				'id'    => $item_id,
				'name'  => $item_name,
				'image' => $item_image ? $item_image : ( $category_image ? $category_image : (string) $categories[ $category_key ]['image'] ),
				'price' => $this->normalize_price( $item['price'] ?? 0 ),
			);
		}

		$this->catalog = array(
			'categories'   => array_values( $categories ),
			'volume_loads' => $this->get_volume_load_rows(),
		);

		return $this->catalog;
	}

	/**
	 * Get editable item rows from saved options or bundled defaults.
	 *
	 * @return array<int, array<string, mixed>>
	 */
	private function get_catalog_item_rows(): array {
		$saved = get_option( self::CATALOG_ITEMS_OPTION, false );
		if ( false !== $saved && is_array( $saved ) ) {
			return $this->normalize_catalog_item_rows( $saved );
		}

		return $this->flatten_default_catalog_items();
	}

	/**
	 * Get editable truck volume rows from saved options or bundled defaults.
	 *
	 * @return array<int, array<string, mixed>>
	 */
	private function get_volume_load_rows(): array {
		$saved = get_option( self::VOLUME_LOADS_OPTION, false );
		if ( false !== $saved && is_array( $saved ) ) {
			return $this->normalize_volume_rows( $saved );
		}

		$default = $this->load_default_catalog();
		return isset( $default['volume_loads'] ) && is_array( $default['volume_loads'] )
			? $this->normalize_volume_rows( $default['volume_loads'] )
			: array();
	}

	/**
	 * @return array<int, string>
	 */
	private function get_pricing_methods(): array {
		$saved = get_option( self::PRICING_METHODS_OPTION, $this->get_default_pricing_methods() );
		return $this->normalize_pricing_methods( $saved, false );
	}

	/**
	 * @return array<int, string>
	 */
	private function get_default_pricing_methods(): array {
		return array( 'specific', 'volume' );
	}

	/**
	 * @return array<string, string>
	 */
	private function get_pricing_method_choices(): array {
		return array(
			'specific' => __( 'Specific item-based pricing', 'junk-removal-cost-calculator' ),
			'volume'   => __( 'Truck volume-based pricing', 'junk-removal-cost-calculator' ),
			'ai'       => __( 'AI Photo Estimator', 'junk-removal-cost-calculator' ),
		);
	}

	/**
	 * @param mixed $value Raw pricing methods.
	 * @return array<int, string>
	 */
	private function normalize_pricing_methods( $value, bool $add_error ): array {
		if ( is_string( $value ) ) {
			$value = preg_split( '/[\s,|]+/', $value );
		}

		if ( ! is_array( $value ) ) {
			$value = array();
		}

		$allowed = array_keys( $this->get_pricing_method_choices() );
		$methods = array();

		foreach ( $value as $method ) {
			$method = sanitize_key( (string) $method );
			if ( in_array( $method, $allowed, true ) && ! in_array( $method, $methods, true ) ) {
				$methods[] = $method;
			}
		}

		if ( empty( $methods ) ) {
			if ( $add_error ) {
				add_settings_error(
					self::PRICING_METHODS_OPTION,
					'junkreco_pricing_methods_required',
					__( 'At least one pricing method must stay enabled. The default pricing methods were restored.', 'junk-removal-cost-calculator' ),
					'warning'
				);
			}

			return $this->get_default_pricing_methods();
		}

		return array_values( $methods );
	}

	/**
	 * @return array<int, string>
	 */
	private function parse_pricing_methods_attribute( string $value ): array {
		$value = trim( strtolower( $value ) );

		if ( '' === $value || 'both' === $value ) {
			return $this->get_default_pricing_methods();
		}

		if ( 'all' === $value ) {
			return array_keys( $this->get_pricing_method_choices() );
		}

		return $this->normalize_pricing_methods( $value, false );
	}

	private function parse_booking_mode_attribute( string $value ): string {
		$value = sanitize_key( strtolower( trim( $value ) ) );

		if ( in_array( $value, array( 'form', 'built-in', 'builtin', 'built_in' ), true ) ) {
			return 'form';
		}

		if ( in_array( $value, array( 'redirect', 'url', 'link' ), true ) ) {
			return 'redirect';
		}

		return (string) $this->get_booking_form_options()['mode'];
	}

	/**
	 * @return array<string, mixed>
	 */
	private function get_booking_form_options(): array {
		$saved = get_option( self::BOOKING_FORM_OPTION, array() );
		return wp_parse_args( is_array( $saved ) ? $saved : array(), $this->get_default_booking_form_options() );
	}

	/**
	 * @return array<string, mixed>
	 */
	private function get_ai_estimator_options(): array {
		$saved = get_option( self::AI_ESTIMATOR_OPTION, array() );
		return wp_parse_args( is_array( $saved ) ? $saved : array(), $this->get_default_ai_estimator_options() );
	}

	/**
	 * @return array<string, mixed>
	 */
	private function get_public_booking_form_options(): array {
		$options = $this->get_booking_form_options();

		return array(
			'mode'                  => (string) $options['mode'],
			'ajaxUrl'               => admin_url( 'admin-ajax.php' ),
			'nonce'                 => wp_create_nonce( 'junkreco_booking_form' ),
			'formTitle'             => (string) $options['form_title'],
			'formIntro'             => (string) $options['form_intro'],
			'submitLabel'           => (string) $options['submit_label'],
			'successMessage'        => (string) $options['success_message'],
			'successRedirectUrl'    => (string) $options['success_redirect_url'],
			'consentText'           => (string) $options['consent_text'],
			'enableZipEligibility'  => (bool) $options['enable_zip_eligibility'],
			'allowedZipCodes'       => $this->normalize_zip_codes( $options['allowed_zip_codes'] ),
			'enablePhotos'          => (bool) $options['enable_photos'],
			'maxPhotos'             => (int) $options['max_photos'],
			'maxPhotoSize'          => $this->get_booking_max_photo_size(),
			'maxPhotoTotalSize'     => $this->get_booking_max_photo_total_size(),
			'requireAddress'        => (bool) $options['require_address'],
			'requirePreferredDate'  => (bool) $options['require_preferred_date'],
			'showPickupOptions'     => (bool) $options['show_pickup_options'],
			'showStairs'            => (bool) $options['show_stairs'],
			'showDisassembly'       => (bool) $options['show_disassembly'],
			'showDonation'          => (bool) $options['show_donation'],
			'showSpecialNote'       => (bool) $options['show_special_note'],
			'requireTextConsent'    => (bool) $options['require_text_consent'],
			'submittingText'        => __( 'Submitting...', 'junk-removal-cost-calculator' ),
			'errorText'             => __( 'Something went wrong. Please try again.', 'junk-removal-cost-calculator' ),
			'photoHelpText'         => __( 'Add photos for a more accurate estimate.', 'junk-removal-cost-calculator' ),
			/* translators: %d: maximum number of photos allowed. */
			'photoLimitText'        => __( 'You can upload up to %d photos.', 'junk-removal-cost-calculator' ),
			/* translators: %s: maximum size for each photo. */
			'photoSizeErrorText'    => __( 'Each photo must be %s or smaller.', 'junk-removal-cost-calculator' ),
			/* translators: %s: maximum total size for all selected photos. */
			'photoTotalSizeErrorText' => __( 'Selected photos must total %s or smaller.', 'junk-removal-cost-calculator' ),
			'photoTypeErrorText'    => __( 'Please upload JPG, PNG, GIF, WebP, HEIC, or HEIF photos.', 'junk-removal-cost-calculator' ),
			'zipTitle'              => __( 'Check service availability', 'junk-removal-cost-calculator' ),
			'zipIntro'              => __( 'Enter your ZIP code to see if we can be available for you.', 'junk-removal-cost-calculator' ),
			'zipLabel'              => __( 'ZIP Code', 'junk-removal-cost-calculator' ),
			'zipPlaceholder'        => __( 'Enter ZIP code', 'junk-removal-cost-calculator' ),
			'zipSubmitLabel'        => __( 'Check Availability', 'junk-removal-cost-calculator' ),
			'zipEligibleMessage'    => __( 'Great news, we service your area.', 'junk-removal-cost-calculator' ),
			'zipIneligibleMessage'  => __( 'Sorry, we do not currently service that ZIP code.', 'junk-removal-cost-calculator' ),
			'zipInvalidMessage'     => __( 'Please enter a valid 5-digit ZIP code.', 'junk-removal-cost-calculator' ),
		);
	}

	/**
	 * @return array<string, mixed>
	 */
	private function get_public_ai_estimator_options(): array {
		$options = $this->get_ai_estimator_options();

		return array(
			'enabled'                 => '' !== $this->get_junkpath_api_key( $options ),
			'ajaxUrl'                 => admin_url( 'admin-ajax.php' ),
			'nonce'                   => wp_create_nonce( 'junkreco_ai_estimator' ),
			'maxPhotos'               => (int) $options['max_photos'],
			'maxPhotoSize'            => $this->get_ai_estimator_max_photo_size(),
			'maxPhotoTotalSize'       => $this->get_ai_estimator_max_photo_total_size(),
			'compressPhotos'          => true,
			'compressionMaxDimension' => 2048,
			'compressionQuality'      => 0.82,
			'compressionTriggerSize'  => 1572864,
			'enableNotes'             => (bool) $options['enable_notes'],
			'introText'               => (string) $options['intro_text'],
			'submitLabel'             => (string) $options['submit_label'],
			'disclaimer'              => (string) $options['disclaimer'],
			'submittingText'          => __( 'Analyzing photos...', 'junk-removal-cost-calculator' ),
			'compressionText'         => __( 'Optimizing photos...', 'junk-removal-cost-calculator' ),
			/* translators: %d: number of optimized photos. */
			'compressionDoneText'     => __( 'Optimized %d photo(s) for upload.', 'junk-removal-cost-calculator' ),
			'successText'             => __( 'Estimate ready.', 'junk-removal-cost-calculator' ),
			'resetLabel'              => __( 'Start a new estimate', 'junk-removal-cost-calculator' ),
			'errorText'               => __( 'The AI estimate could not be created. Please try again.', 'junk-removal-cost-calculator' ),
			'photoHelpText'           => __( 'Add clear photos from a few angles for the most useful estimate.', 'junk-removal-cost-calculator' ),
			'resultPhotosLabel'       => __( 'Uploaded photos', 'junk-removal-cost-calculator' ),
			/* translators: %d: maximum number of photos allowed. */
			'photoLimitText'          => __( 'You can upload up to %d photos.', 'junk-removal-cost-calculator' ),
			/* translators: %s: maximum size for each photo. */
			'photoSizeErrorText'      => __( 'Each photo must be %s or smaller.', 'junk-removal-cost-calculator' ),
			/* translators: %s: maximum total size for all selected photos. */
			'photoTotalSizeErrorText' => __( 'Selected photos must total %s or smaller.', 'junk-removal-cost-calculator' ),
			'photoTypeErrorText'      => __( 'Please upload JPG, PNG, GIF, WebP, HEIC, or HEIF photos.', 'junk-removal-cost-calculator' ),
			'notesLabel'              => __( 'Job notes', 'junk-removal-cost-calculator' ),
			'notesPlaceholder'        => __( 'Anything not obvious in the photos? For example: items under a tarp, stairs, or heavy materials.', 'junk-removal-cost-calculator' ),
			'noPhotosText'            => __( 'Add at least one photo before requesting an AI estimate.', 'junk-removal-cost-calculator' ),
			'confidenceLabel'         => __( 'Confidence', 'junk-removal-cost-calculator' ),
			'cubicYardsLabel'         => __( 'Cubic yards', 'junk-removal-cost-calculator' ),
			'weightLabel'             => __( 'Estimated weight', 'junk-removal-cost-calculator' ),
			'truckPercentLabel'       => __( 'Truck usage', 'junk-removal-cost-calculator' ),
			'loadFractionLabel'       => __( 'Recommended load', 'junk-removal-cost-calculator' ),
			'landfillCostLabel'       => __( 'Estimated landfill cost', 'junk-removal-cost-calculator' ),
			'extraFeesLabel'          => __( 'Detected extra fees', 'junk-removal-cost-calculator' ),
			'extraFeesTotalLabel'     => __( 'Extra fees total', 'junk-removal-cost-calculator' ),
			'analysisNotesLabel'      => __( 'Analysis notes', 'junk-removal-cost-calculator' ),
			'itemBreakdownLabel'      => __( 'Detected items', 'junk-removal-cost-calculator' ),
		);
	}

	/**
	 * @return array<string, mixed>
	 */
	private function get_default_booking_form_options(): array {
		return array(
			'mode'                  => 'redirect',
			'recipient_email'       => (string) get_option( 'admin_email' ),
			'email_subject'         => __( 'New junk removal booking request', 'junk-removal-cost-calculator' ),
			'form_title'            => __( 'Book your junk removal', 'junk-removal-cost-calculator' ),
			'form_intro'            => __( 'Send your contact details, pickup location, and preferred schedule. We will follow up to confirm the request.', 'junk-removal-cost-calculator' ),
			'submit_label'          => __( 'Submit Request', 'junk-removal-cost-calculator' ),
			'success_message'       => __( 'Your request has been sent successfully.', 'junk-removal-cost-calculator' ),
			'success_redirect_url'  => '',
			'consent_text'          => __( 'By checking this box, you agree to receive SMS messages about this booking request.', 'junk-removal-cost-calculator' ),
			'enable_zip_eligibility' => false,
			'allowed_zip_codes'     => '',
			'enable_photos'         => true,
			'max_photos'            => 6,
			'require_address'       => true,
			'require_preferred_date' => true,
			'show_pickup_options'   => true,
			'show_stairs'           => true,
			'show_disassembly'      => true,
			'show_donation'         => true,
			'show_special_note'     => true,
			'require_text_consent'  => true,
		);
	}

	/**
	 * @return array<string, mixed>
	 */
	private function get_default_ai_estimator_options(): array {
		return array(
			'api_key'      => '',
			'max_photos'   => 6,
			'enable_notes' => true,
			'intro_text'   => __( 'Upload photos of the junk you need removed. The AI estimator will analyze the load and return a recommended price.', 'junk-removal-cost-calculator' ),
			'submit_label' => __( 'Get AI Estimate', 'junk-removal-cost-calculator' ),
			'disclaimer'   => __( 'AI estimates are approximate and may change after an in-person review.', 'junk-removal-cost-calculator' ),
		);
	}

	/**
	 * @return array<int, array<string, mixed>>
	 */
	private function flatten_default_catalog_items(): array {
		$default = $this->load_default_catalog();
		$rows    = array();

		if ( empty( $default['categories'] ) || ! is_array( $default['categories'] ) ) {
			return array();
		}

		foreach ( $default['categories'] as $category ) {
			if ( ! is_array( $category ) || empty( $category['sub_items'] ) || ! is_array( $category['sub_items'] ) ) {
				continue;
			}

			foreach ( $category['sub_items'] as $sub_item ) {
				if ( ! is_array( $sub_item ) ) {
					continue;
				}

				$category_name = isset( $category['name'] ) ? sanitize_text_field( (string) $category['name'] ) : '';
				$images        = $this->normalize_catalog_images(
					$category_name,
					isset( $category['image'] ) ? (string) $category['image'] : '',
					isset( $sub_item['image'] ) ? (string) $sub_item['image'] : ''
				);

				$rows[] = array(
					'id'             => isset( $sub_item['id'] ) ? sanitize_key( (string) $sub_item['id'] ) : '',
					'category'       => $category_name,
					'category_image' => $images['category_image'],
					'name'           => isset( $sub_item['name'] ) ? sanitize_text_field( (string) $sub_item['name'] ) : '',
					'image'          => $images['image'],
					'price'          => $this->normalize_price( $sub_item['price'] ?? 0 ),
				);
			}
		}

		return $rows;
	}

	/**
	 * @param array<int|string, mixed> $rows Raw rows.
	 * @return array<int, array<string, mixed>>
	 */
	private function normalize_catalog_item_rows( array $rows ): array {
		$normalized = array();
		foreach ( $rows as $row ) {
			if ( ! is_array( $row ) ) {
				continue;
			}

			$category = isset( $row['category'] ) ? sanitize_text_field( (string) $row['category'] ) : '';
			$images   = $this->normalize_catalog_images(
				$category,
				isset( $row['category_image'] ) ? (string) $row['category_image'] : '',
				isset( $row['image'] ) ? (string) $row['image'] : ''
			);

			$normalized[] = array(
				'id'             => isset( $row['id'] ) ? sanitize_key( (string) $row['id'] ) : '',
				'category'       => $category,
				'category_image' => $images['category_image'],
				'name'           => isset( $row['name'] ) ? sanitize_text_field( (string) $row['name'] ) : '',
				'image'          => $images['image'],
				'price'          => $this->normalize_price( $row['price'] ?? 0 ),
			);
		}

		return $normalized;
	}

	/**
	 * @param array<int|string, mixed> $rows Raw rows.
	 * @return array<int, array<string, mixed>>
	 */
	private function normalize_volume_rows( array $rows ): array {
		$normalized = array();
		foreach ( $rows as $row ) {
			if ( ! is_array( $row ) ) {
				continue;
			}

			$normalized[] = array(
				'id'          => isset( $row['id'] ) ? sanitize_key( (string) $row['id'] ) : '',
				'title'       => isset( $row['title'] ) ? sanitize_text_field( (string) $row['title'] ) : '',
				'items'       => isset( $row['items'] ) ? sanitize_text_field( (string) $row['items'] ) : '',
				'description' => isset( $row['description'] ) ? sanitize_text_field( (string) $row['description'] ) : '',
				'image'       => isset( $row['image'] ) && '' !== trim( (string) $row['image'] ) ? $this->resolve_catalog_image_url( (string) $row['image'] ) : $this->get_default_volume_image_url(),
				'price'       => $this->normalize_price( $row['price'] ?? 0 ),
			);
		}

		return $normalized;
	}

	/**
	 * Load bundled default calculator catalog data.
	 *
	 * @return array<string, mixed>
	 */
	private function load_default_catalog(): array {
		if ( null !== $this->default_catalog ) {
			return $this->default_catalog;
		}

		$path = plugin_dir_path( __FILE__ ) . 'data/catalog.json';
		if ( ! is_readable( $path ) ) {
			$this->default_catalog = array(
				'categories'   => array(),
				'volume_loads' => array(),
			);
			return $this->default_catalog;
		}

		$raw  = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
		$data = is_string( $raw ) ? json_decode( $raw, true ) : null;

		$this->default_catalog = is_array( $data ) ? $data : array(
			'categories'   => array(),
			'volume_loads' => array(),
		);

		return $this->default_catalog;
	}

	private function resolve_catalog_image_url( string $value ): string {
		$value = trim( $value );

		if ( '' === $value ) {
			return '';
		}

		if ( preg_match( '#^https?://#i', $value ) ) {
			return esc_url_raw( $value );
		}

		if ( 0 === strpos( $value, 'assets/' ) ) {
			return esc_url_raw( plugin_dir_url( __FILE__ ) . $value );
		}

		return esc_url_raw( $value );
	}

	private function sanitize_api_key( string $value ): string {
		$value = sanitize_text_field( wp_unslash( $value ) );
		$value = preg_replace( '/\s+/', '', $value );
		$value = is_string( $value ) ? $value : '';

		return substr( $value, 0, 200 );
	}

	/**
	 * @param array<string, mixed>|null $options AI estimator options.
	 */
	private function get_junkpath_api_key( ?array $options = null ): string {
		if ( defined( 'JUNKRECO_JUNKPATH_API_KEY' ) && is_string( JUNKRECO_JUNKPATH_API_KEY ) ) {
			return $this->sanitize_api_key( JUNKRECO_JUNKPATH_API_KEY );
		}

		if ( null === $options ) {
			$options = $this->get_ai_estimator_options();
		}

		return isset( $options['api_key'] ) ? $this->sanitize_api_key( (string) $options['api_key'] ) : '';
	}

	/**
	 * @return array<string, mixed>
	 */
	private function request_junkpath_api( string $method, string $path, ?array $payload = null ): array {
		$args = array(
			'method'  => strtoupper( $method ),
			'timeout' => 90,
			'headers' => array(
				'Authorization' => 'Bearer ' . $this->get_junkpath_api_key(),
				'Accept'        => 'application/json',
			),
		);

		if ( null !== $payload ) {
			$args['headers']['Content-Type'] = 'application/json';
			$args['body']                    = wp_json_encode( $payload );
		}

		$response = wp_remote_request(
			trailingslashit( $this->get_junkpath_api_base_url() ) . ltrim( $path, '/' ),
			$args
		);

		if ( is_wp_error( $response ) ) {
			return array(
				'ok'      => false,
				'status'  => 502,
				'message' => $response->get_error_message() ? $response->get_error_message() : __( 'Junkpath could not be reached. Please try again.', 'junk-removal-cost-calculator' ),
				'data'    => array(),
			);
		}

		$status = (int) wp_remote_retrieve_response_code( $response );
		$body   = (string) wp_remote_retrieve_body( $response );
		$data   = json_decode( $body, true );
		$data   = is_array( $data ) ? $data : array();

		return array(
			'ok'      => $status >= 200 && $status < 300,
			'status'  => $status > 0 ? $status : 502,
			'message' => $status >= 200 && $status < 300 ? '' : $this->get_junkpath_error_message( $data, $status ),
			'data'    => $data,
		);
	}

	/**
	 * @param array<string, mixed> $response Response returned by request_junkpath_api().
	 * @param callable             $transform Success payload transformer.
	 */
	private function send_junkpath_admin_response( array $response, callable $transform, int $success_status = 200 ): void {
		if ( empty( $response['ok'] ) ) {
			wp_send_json_error(
				array( 'message' => $this->format_junkpath_admin_error( $response ) ),
				isset( $response['status'] ) ? (int) $response['status'] : 502
			);
		}

		$data = isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : array();
		wp_send_json_success( $transform( $data ), $success_status );
	}

	private function send_junkpath_account_response(): void {
		$settings_response = $this->request_junkpath_api( 'GET', 'settings' );
		$fees_response     = $this->request_junkpath_api( 'GET', 'extra-fees' );

		$payload = array();
		$errors  = array();
		$status  = 502;

		if ( ! empty( $settings_response['ok'] ) ) {
			$settings_data       = isset( $settings_response['data'] ) && is_array( $settings_response['data'] ) ? $settings_response['data'] : array();
			$payload['settings'] = self::sanitize_junkpath_settings_response_static( $settings_data );
		} else {
			$errors[] = $this->format_junkpath_admin_error( $settings_response );
			$status   = isset( $settings_response['status'] ) ? (int) $settings_response['status'] : $status;
		}

		if ( ! empty( $fees_response['ok'] ) ) {
			$fees_data       = isset( $fees_response['data'] ) && is_array( $fees_response['data'] ) ? $fees_response['data'] : array();
			$payload['fees'] = $this->sanitize_junkpath_extra_fee_list( $fees_data );
		} else {
			$errors[] = $this->format_junkpath_admin_error( $fees_response );
			if ( empty( $payload ) ) {
				$status = isset( $fees_response['status'] ) ? (int) $fees_response['status'] : $status;
			}
		}

		if ( empty( $payload ) ) {
			wp_send_json_error(
				array( 'message' => implode( ' ', array_unique( $errors ) ) ),
				$status
			);
		}

		if ( ! empty( $errors ) ) {
			$payload['warning'] = implode( ' ', array_unique( $errors ) );
		}

		wp_send_json_success( $payload );
	}

	/**
	 * @param array<string, mixed> $response Response returned by request_junkpath_api().
	 */
	private function format_junkpath_admin_error( array $response ): string {
		$message = isset( $response['message'] ) && '' !== (string) $response['message']
			? (string) $response['message']
			: __( 'Junkpath request failed. Please try again.', 'junk-removal-cost-calculator' );
		$status  = isset( $response['status'] ) ? (int) $response['status'] : 0;

		if ( $status >= 400 ) {
			return sprintf(
				/* translators: 1: HTTP status code, 2: Junkpath error message. */
				__( 'Junkpath API returned HTTP %1$d: %2$s', 'junk-removal-cost-calculator' ),
				$status,
				$message
			);
		}

		return $message;
	}

	/**
	 * @return array<string, mixed>
	 */
	private function get_posted_json_payload(): array {
		$raw     = isset( $_POST['payload'] ) ? wp_unslash( (string) $_POST['payload'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing
		$decoded = '' !== $raw ? json_decode( $raw, true ) : array();

		return is_array( $decoded ) ? $decoded : array();
	}

	private function get_posted_extra_fee_id(): string {
		$id = isset( $_POST['id'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['id'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing
		if ( '' === $id ) {
			wp_send_json_error(
				array( 'message' => __( 'Missing extra fee ID.', 'junk-removal-cost-calculator' ) ),
				400
			);
		}

		return $id;
	}

	private function get_junkpath_api_base_url(): string {
		/**
		 * Filters the Junkpath API base URL used by the AI Photo Estimator.
		 *
		 * @param string $url Base URL without a trailing endpoint path.
		 */
		$url = (string) apply_filters( 'junkreco_junkpath_api_base_url', self::JUNKPATH_API_BASE_URL );
		$url = esc_url_raw( $url );

		return $url ? untrailingslashit( $url ) : self::JUNKPATH_API_BASE_URL;
	}

	/**
	 * @param array<string, mixed> $data Raw settings response.
	 * @return array<string, mixed>
	 */
	private static function sanitize_junkpath_settings_response_static( array $data ): array {
		$truck  = isset( $data['truck'] ) && is_array( $data['truck'] ) ? $data['truck'] : array();
		$prices = isset( $data['pricing'] ) && is_array( $data['pricing'] ) ? $data['pricing'] : array();
		$enabled = isset( $data['enabled'] ) && is_array( $data['enabled'] ) ? $data['enabled'] : array();

		$pricing_keys = array(
			'minimum',
			'eighth_load',
			'sixth_load',
			'quarter_load',
			'third_load',
			'three_eighths_load',
			'half_load',
			'five_eighths_load',
			'two_thirds_load',
			'three_quarters_load',
			'seven_eighths_load',
			'full_load',
		);

		$pricing = array();
		foreach ( $pricing_keys as $key ) {
			if ( array_key_exists( $key, $prices ) ) {
				$pricing[ $key ] = max( 0, round( (float) $prices[ $key ], 2 ) );
			}
		}

		$enabled_out = array();
		foreach ( $pricing_keys as $key ) {
			if ( array_key_exists( $key, $enabled ) ) {
				$enabled_out[ $key ] = ! empty( $enabled[ $key ] );
			}
		}

		return array(
			'landfill_price'        => max( 0, round( (float) ( $data['landfill_price'] ?? 0 ), 2 ) ),
			'landfill_has_minimum'  => ! empty( $data['landfill_has_minimum'] ),
			'landfill_minimum_tons' => max( 0, round( (float) ( $data['landfill_minimum_tons'] ?? 0 ), 2 ) ),
			'threshold_percent'     => max( 0, round( (float) ( $data['threshold_percent'] ?? ( $data['threshold'] ?? 0 ) ), 2 ) ),
			'truck'                 => array(
				'length_ft'   => max( 0, round( (float) ( $truck['length_ft'] ?? 0 ), 2 ) ),
				'width_ft'    => max( 0, round( (float) ( $truck['width_ft'] ?? 0 ), 2 ) ),
				'height_ft'   => max( 0, round( (float) ( $truck['height_ft'] ?? 0 ), 2 ) ),
				'cubic_yards' => max( 0, round( (float) ( $truck['cubic_yards'] ?? 0 ), 2 ) ),
			),
			'pricing'               => $pricing,
			'enabled'               => $enabled_out,
		);
	}

	/**
	 * @param array<string, mixed> $data Raw settings update payload.
	 * @return array<string, mixed>
	 */
	private function sanitize_junkpath_settings_update( array $data ): array {
		$payload = array();

		$numeric_keys = array(
			'landfill_price',
			'landfill_minimum_tons',
			'truck_length',
			'truck_width',
			'truck_height',
			'truck_cubic_yards',
			'threshold_percent',
			'threshold',
		);

		foreach ( $this->get_junkpath_pricing_tiers() as $key => $label ) {
			$numeric_keys[] = 'pricing_' . $key;
		}

		foreach ( $numeric_keys as $key ) {
			if ( array_key_exists( $key, $data ) && '' !== (string) $data[ $key ] ) {
				$payload[ $key ] = max( 0, round( (float) $data[ $key ], 2 ) );
			}
		}

		foreach ( array( 'landfill_has_minimum' ) as $key ) {
			if ( array_key_exists( $key, $data ) ) {
				$payload[ $key ] = ! empty( $data[ $key ] );
			}
		}

		foreach ( $this->get_junkpath_pricing_tiers() as $key => $label ) {
			$enabled_key = 'enabled_' . $key;
			if ( array_key_exists( $enabled_key, $data ) ) {
				$payload[ $enabled_key ] = ! empty( $data[ $enabled_key ] );
			}
		}

		if ( empty( $payload ) ) {
			wp_send_json_error(
				array( 'message' => __( 'No Junkpath settings were provided to update.', 'junk-removal-cost-calculator' ) ),
				400
			);
		}

		return $payload;
	}

	/**
	 * @param array<string, mixed> $data Raw extra-fee response wrapper.
	 * @return array<int, array<string, mixed>>
	 */
	private function sanitize_junkpath_extra_fee_list( array $data ): array {
		$rows = isset( $data['data'] ) && is_array( $data['data'] ) ? $data['data'] : $data;
		$fees = array();

		foreach ( $rows as $row ) {
			if ( is_array( $row ) ) {
				$fees[] = $this->sanitize_junkpath_extra_fee_response( $row );
			}
		}

		return $fees;
	}

	/**
	 * @param array<string, mixed> $data Raw extra-fee response.
	 * @return array<string, mixed>
	 */
	private function sanitize_junkpath_extra_fee_response( array $data ): array {
		return array(
			'id'                   => isset( $data['id'] ) ? sanitize_text_field( (string) $data['id'] ) : '',
			'name'                 => isset( $data['name'] ) ? sanitize_text_field( (string) $data['name'] ) : '',
			'instruction'          => isset( $data['instruction'] ) ? sanitize_textarea_field( (string) $data['instruction'] ) : '',
			'per_unit_price'       => $this->normalize_price( $data['per_unit_price'] ?? 0 ),
			'constrain_to_brushed' => ! empty( $data['constrain_to_brushed'] ),
			'created_at'           => isset( $data['created_at'] ) ? sanitize_text_field( (string) $data['created_at'] ) : '',
		);
	}

	/**
	 * @param array<string, mixed> $data Raw extra-fee input.
	 * @return array<string, mixed>
	 */
	private function sanitize_junkpath_extra_fee_input( array $data, bool $require_all ): array {
		$payload = array(
			'name'                 => isset( $data['name'] ) ? sanitize_text_field( (string) $data['name'] ) : '',
			'instruction'          => isset( $data['instruction'] ) ? sanitize_textarea_field( (string) $data['instruction'] ) : '',
			'constrain_to_brushed' => ! empty( $data['constrain_to_brushed'] ),
		);

		if ( $require_all && ( '' === $payload['name'] || '' === $payload['instruction'] ) ) {
			wp_send_json_error(
				array( 'message' => __( 'Extra fees need a name and an instruction.', 'junk-removal-cost-calculator' ) ),
				400
			);
		}

		$payload['name']        = substr( (string) $payload['name'], 0, 120 );
		$payload['instruction'] = substr( (string) $payload['instruction'], 0, 2000 );
		$unit_price             = $this->normalize_price( $data['per_unit_price'] ?? 0 );
		if ( 0.0 === $unit_price ) {
			$unit_price = $this->extract_junkpath_extra_fee_unit_price( (string) $payload['instruction'] );
		}

		if ( $require_all && 0.0 === $unit_price ) {
			wp_send_json_error(
				array( 'message' => __( 'Extra fees need a recognizable dollar amount in the instruction, such as "$25 per tire".', 'junk-removal-cost-calculator' ) ),
				400
			);
		}

		// The current Junkpath OpenAPI still requires per_unit_price, so derive it from the instruction instead of showing a duplicate admin field.
		$payload['per_unit_price'] = $unit_price;

		return $payload;
	}

	private function extract_junkpath_extra_fee_unit_price( string $instruction ): float {
		$patterns = array(
			'/(?:\\$|USD\\s*)\\s*([0-9][0-9,]*(?:\\.[0-9]{1,2})?)/i',
			'/\\b([0-9][0-9,]*(?:\\.[0-9]{1,2})?)\\s*(?:dollars?|usd)\\b/i',
			'/\\b(?:add|charge|fee|cost|price)\\D{0,30}([0-9][0-9,]*(?:\\.[0-9]{1,2})?)\\D{0,30}\\bper\\b/i',
			'/\\b([0-9][0-9,]*(?:\\.[0-9]{1,2})?)\\s*(?:\\/|per)\\s+[a-z]/i',
		);

		foreach ( $patterns as $pattern ) {
			if ( preg_match( $pattern, $instruction, $matches ) ) {
				return $this->normalize_price( str_replace( ',', '', $matches[1] ) );
			}
		}

		return 0.0;
	}

	/**
	 * @param array<string, mixed> $data Junkpath error response.
	 */
	private function get_junkpath_error_message( array $data, int $status ): string {
		$message = '';
		if ( ! empty( $data['detail'] ) ) {
			$message = sanitize_text_field( (string) $data['detail'] );
		} elseif ( ! empty( $data['error'] ) ) {
			$message = sanitize_text_field( (string) $data['error'] );
		}

		if ( '' !== $message ) {
			return $message;
		}

		if ( 401 === $status || 403 === $status ) {
			return __( 'The Junkpath API key was rejected. Please check the saved key and try again.', 'junk-removal-cost-calculator' );
		}

		if ( 413 === $status ) {
			return __( 'Junkpath rejected one or more photos as too large.', 'junk-removal-cost-calculator' );
		}

		return __( 'Junkpath could not create an estimate right now. Please try again.', 'junk-removal-cost-calculator' );
	}

	/**
	 * @param array<string, mixed> $data Raw Junkpath estimate response.
	 * @return array<string, mixed>
	 */
	private function sanitize_junkpath_estimate_response( array $data ): array {
		$items = array();
		if ( ! empty( $data['item_breakdown'] ) && is_array( $data['item_breakdown'] ) ) {
			foreach ( $data['item_breakdown'] as $item ) {
				if ( ! is_array( $item ) ) {
					continue;
				}

				$name = isset( $item['item'] ) ? sanitize_text_field( (string) $item['item'] ) : '';
				if ( '' === $name ) {
					continue;
				}

				$items[] = array(
					'item'        => $name,
					'quantity'    => $this->normalize_metric_value( $item['quantity'] ?? 0 ),
					'cubicYards'  => $this->normalize_metric_value( $item['cubicYards'] ?? ( $item['cubic_yards'] ?? 0 ) ),
					'weight'      => $this->normalize_metric_value( $item['weight'] ?? 0 ),
				);
			}
		}

		$raw_extra_fees = array();
		if ( ! empty( $data['extra_fees'] ) && is_array( $data['extra_fees'] ) ) {
			$raw_extra_fees = $data['extra_fees'];
		} elseif ( ! empty( $data['detected_extra_fees'] ) && is_array( $data['detected_extra_fees'] ) ) {
			$raw_extra_fees = $data['detected_extra_fees'];
		}

		$extra_fees = array();
		if ( ! empty( $raw_extra_fees ) ) {
			foreach ( $raw_extra_fees as $fee ) {
				if ( ! is_array( $fee ) ) {
					continue;
				}

				$name = isset( $fee['name'] ) ? sanitize_text_field( (string) $fee['name'] ) : '';
				if ( '' === $name && isset( $fee['fee_name'] ) ) {
					$name = sanitize_text_field( (string) $fee['fee_name'] );
				}
				if ( '' === $name && isset( $fee['item'] ) ) {
					$name = sanitize_text_field( (string) $fee['item'] );
				}
				if ( '' === $name && isset( $fee['label'] ) ) {
					$name = sanitize_text_field( (string) $fee['label'] );
				}

				$quantity       = $this->normalize_metric_value( $fee['quantity'] ?? ( $fee['count'] ?? ( $fee['units'] ?? 1 ) ) );
				$per_unit_price = $this->normalize_price( $fee['per_unit_price'] ?? ( $fee['unit_price'] ?? ( $fee['price'] ?? 0 ) ) );
				$total          = $this->normalize_price( $fee['subtotal'] ?? ( $fee['total'] ?? ( $fee['amount'] ?? ( $fee['fee'] ?? ( $fee['line_total'] ?? 0 ) ) ) ) );
				if ( 0.0 === $total && $per_unit_price > 0 ) {
					$total = $this->normalize_price( $per_unit_price * max( 1, $quantity ) );
				}

				if ( '' === $name && 0.0 === $total ) {
					continue;
				}

				$extra_fees[] = array(
					'id'             => isset( $fee['id'] ) ? sanitize_text_field( (string) $fee['id'] ) : '',
					'name'           => '' !== $name ? $name : __( 'Extra fee', 'junk-removal-cost-calculator' ),
					'quantity'       => $quantity,
					'per_unit_price' => $per_unit_price,
					'total'          => $total,
					'reason'         => isset( $fee['reason'] ) ? sanitize_textarea_field( (string) $fee['reason'] ) : '',
					'instruction'    => isset( $fee['instruction'] ) ? sanitize_textarea_field( (string) $fee['instruction'] ) : '',
				);
			}
		}

		$extra_fees_total = $this->normalize_price( $data['extra_fees_total'] ?? ( $data['detected_extra_fees_total'] ?? 0 ) );
		if ( 0.0 === $extra_fees_total && ! empty( $extra_fees ) ) {
			$extra_fees_total = $this->normalize_price(
				array_reduce(
					$extra_fees,
					static function ( float $total, array $fee ): float {
						return $total + (float) ( $fee['total'] ?? 0 );
					},
					0.0
				)
			);
		}

		$recommended_price           = $this->normalize_price( $data['recommended_price'] ?? 0 );
		$recommended_price_with_fees = $this->normalize_price( $data['recommended_price_with_fees'] ?? 0 );
		if ( 0.0 === $recommended_price_with_fees && $extra_fees_total > 0 ) {
			$recommended_price_with_fees = $this->normalize_price( $recommended_price + $extra_fees_total );
		}

		return array(
			'estimate_id'               => isset( $data['estimate_id'] ) ? sanitize_text_field( (string) $data['estimate_id'] ) : ( isset( $data['id'] ) ? sanitize_text_field( (string) $data['id'] ) : '' ),
			'cubic_yards'               => $this->normalize_metric_value( $data['cubic_yards'] ?? 0 ),
			'estimated_weight_lbs'      => $this->normalize_metric_value( $data['estimated_weight_lbs'] ?? ( $data['estimated_weight'] ?? 0 ) ),
			'confidence'                => max( 0, min( 100, (int) ( $data['confidence'] ?? 0 ) ) ),
			'landfill_cost'             => $this->normalize_price( $data['landfill_cost'] ?? 0 ),
			'recommended_price'         => $recommended_price,
			'recommended_price_with_fees' => $recommended_price_with_fees,
			'total_cost'                => $this->normalize_price( $data['total_cost'] ?? 0 ),
			'recommended_load_fraction' => isset( $data['recommended_load_fraction'] ) ? sanitize_text_field( (string) $data['recommended_load_fraction'] ) : '',
			'truck_percentage'          => max( 0, min( 100, (int) ( $data['truck_percentage'] ?? 0 ) ) ),
			'item_breakdown'            => $items,
			'detected_extra_fees'       => $extra_fees,
			'extra_fees_total'          => $extra_fees_total,
			'analysis_notes'            => isset( $data['analysis_notes'] ) ? sanitize_textarea_field( (string) $data['analysis_notes'] ) : '',
			'created_at'                => isset( $data['created_at'] ) ? sanitize_text_field( (string) $data['created_at'] ) : '',
		);
	}

	/**
	 * @param mixed $value Raw numeric metric.
	 */
	private function normalize_metric_value( $value ): float {
		if ( is_string( $value ) ) {
			$value = str_replace( ',', '', $value );
		}

		return max( 0, round( (float) $value, 2 ) );
	}

	/**
	 * @param mixed $value Raw quantity.
	 */
	private function format_estimate_quantity( $value ): string {
		$quantity = $this->normalize_metric_value( $value );

		return floor( $quantity ) === $quantity
			? number_format_i18n( $quantity, 0 )
			: number_format_i18n( $quantity, 1 );
	}

	/**
	 * Normalize catalog row image URLs for both admin editing and frontend output.
	 *
	 * @return array{category_image: string, image: string}
	 */
	private function normalize_catalog_images( string $category_name, string $category_image, string $item_image ): array {
		$category_image = $this->resolve_catalog_image_url( $category_image );
		$item_image     = $this->resolve_catalog_image_url( $item_image );

		if ( $this->is_placeholder_catalog_image( $category_image ) ) {
			$category_image = '';
		}

		if ( '' === $category_image ) {
			$category_image = $this->get_bundled_category_image_url( $category_name );
		}

		if ( $this->is_placeholder_catalog_image( $item_image ) ) {
			$item_image = '';
		}

		if ( '' === $item_image ) {
			$item_image = $category_image;
		}

		return array(
			'category_image' => $category_image,
			'image'          => $item_image,
		);
	}

	private function is_placeholder_catalog_image( string $value ): bool {
		if ( '' === trim( $value ) ) {
			return false;
		}

		$path = wp_parse_url( $value, PHP_URL_PATH );
		if ( ! is_string( $path ) || '' === $path ) {
			$path = $value;
		}

		return in_array( basename( $path ), array( 'default-category.svg', 'default-item.svg' ), true );
	}

	private function get_bundled_category_image_url( string $category_name ): string {
		$map  = $this->get_bundled_category_image_map();
		$slug = sanitize_title( $category_name );

		if ( ! isset( $map[ $slug ] ) ) {
			return '';
		}

		$relative = 'assets/images/catalog/' . $map[ $slug ];
		if ( ! is_readable( plugin_dir_path( __FILE__ ) . $relative ) ) {
			return '';
		}

		return esc_url_raw( plugin_dir_url( __FILE__ ) . $relative );
	}

	/**
	 * Map default catalog category slugs to bundled WebP assets.
	 *
	 * @return array<string, string>
	 */
	private function get_bundled_category_image_map(): array {
		static $map = null;

		if ( null !== $map ) {
			return $map;
		}

		$map = array(
			'above-ground-pool'          => 'category-68da9958b41538c99aea8e4f.webp',
			'air-conditioner'            => 'category-67fd75f44d530cfaba27d17b.webp',
			'aquarium'                   => 'category-67d3228405b85c1a4e6c351d.webp',
			'area-rug'                   => 'category-686a3fd691e9b5eeb24b81fc.webp',
			'automotive-rim-tire'        => 'category-67fc190e4d530cfaba27c807.webp',
			'baby-s-stuff'               => 'category-67fd7c8c4d530cfaba27d2fc.webp',
			'bag-of-junk'                => 'category-67d32fbe05b85c1a4e6c3a28.webp',
			'basi-systems-reformer'      => 'category-68daa07cb41538c99aea9076.webp',
			'bathtub'                    => 'category-68223e018891708b950d360e.webp',
			'beanbag'                    => 'category-6822408d8891708b950d36e3.webp',
			'bed-base-foundation'        => 'category-693063a60f4ce5f267ade37a.webp',
			'bed-frame'                  => 'category-67d32bd505b85c1a4e6c38d1.webp',
			'bed-side-rails'             => 'category-692ff3dc19b5265e79ca7ec9.webp',
			'bench'                      => 'category-69305c7f0f4ce5f267ade176.webp',
			'bicycle'                    => 'category-686a3f9f91e9b5eeb24b817a.webp',
			'bookshelf'                  => 'category-686a3f7891e9b5eeb24b8136.webp',
			'box-spring'                 => 'category-67d334b005b85c1a4e6c3c7f.webp',
			'bundle-of-flat-moving-boxes' => 'category-6935c3090f4ce5f267ae1e1c.webp',
			'bunkbed'                    => 'category-686a412f91e9b5eeb24b851b.webp',
			'bunky-boards'               => 'category-6a08862a9d4acad7eeac3d10.webp',
			'cabinet'                    => 'category-6928a69d4514d2559cb428ac.webp',
			'cardboard-box'              => 'category-67d32e4a05b85c1a4e6c39d3.webp',
			'carpet'                     => 'category-686a3fd691e9b5eeb24b81fc.webp',
			'cat-tree'                   => 'category-686a429c91e9b5eeb24b85cb.webp',
			'ceiling-fan'                => 'category-686a3fb491e9b5eeb24b81bc.webp',
			'chair'                      => 'category-6801647da90ddb95fb64e05c.webp',
			'chaise-lounge'              => 'category-692ff40319b5265e79ca7f02.webp',
			'chest'                      => 'category-68ee9aa6496f68cf6f3f521c.webp',
			'clothing-steamer'           => 'category-6926175d4514d2559cb414c9.webp',
			'couches'                    => 'category-67d3314a05b85c1a4e6c3ae0.webp',
			'crib'                       => 'category-692ff43b19b5265e79ca7f3b.webp',
			'daybed'                     => 'category-6a0884689d4acad7eeac3b91.webp',
			'desk'                       => 'category-67d3305905b85c1a4e6c3a65.webp',
			'dishwasher'                 => 'category-68da9981b41538c99aea8e8a.webp',
			'dresser'                    => 'category-67d3332f05b85c1a4e6c3b81.webp',
			'dryer'                      => 'category-69289fe74514d2559cb4268f.webp',
			'electric-fireplace'         => 'category-68ee9181496f68cf6f3f4a9b.webp',
			'elliptical'                 => 'category-68ee8051496f68cf6f3f4329.webp',
			'end-table'                  => 'category-6921cc004514d2559cb3e6de.webp',
			'entertainment-center'       => 'category-68ee972d496f68cf6f3f4bc5.webp',
			'exercise-bike'              => 'category-6a0f308252664be8ab984444.webp',
			'file-cabinet'               => 'category-69b6df43881ca54988d5946f.webp',
			'footboard'                  => 'category-6921cdb14514d2559cb3e72d.webp',
			'freezer'                    => 'category-691ed0034514d2559cb3ca73.webp',
			'generator'                  => 'category-68da97e6b41538c99aea8bf7.webp',
			'golf-clubs'                 => 'category-68da98d9b41538c99aea8da3.webp',
			'grill'                      => 'category-68daa052b41538c99aea9028.webp',
			'headboard'                  => 'category-6921c8f54514d2559cb3e652.webp',
			'hot-tub'                    => 'category-68e2c00118f1ed17703a8036.webp',
			'hutch'                      => 'category-6928a69d4514d2559cb428ac.webp',
			'ice-machine'                => 'category-68e29d6918f1ed17703a792c.webp',
			'ice-maker'                  => 'category-68e29f3818f1ed17703a79f1.webp',
			'jackhammer'                 => 'category-693878210f4ce5f267ae39e3.webp',
			'kitchen-hood'               => 'category-699b399c1aab85eb5b552996.webp',
			'kitchen-range'              => 'category-67d3273605b85c1a4e6c36fe.webp',
			'lamp'                       => 'category-692610354514d2559cb412ee.webp',
			'large-wall-decor'           => 'category-6a0f305b52664be8ab984349.webp',
			'lawnmower-push'             => 'category-6935cbd10f4ce5f267ae1f20.webp',
			'lawnmower-riding'           => 'category-6935cbaf0f4ce5f267ae1ee9.webp',
			'linens'                     => 'category-699ae0a61aab85eb5b5524e2.webp',
			'marble-concrete-table'      => 'category-69c94d371fbb5b55d522bc74.webp',
			'mattress'                   => 'category-67d3353e05b85c1a4e6c3d31.webp',
			'microwave'                  => 'category-6935bfb50f4ce5f267ae1dc7.webp',
			'mirror'                     => 'category-6928a1344514d2559cb42766.webp',
			'ottoman'                    => 'category-69b6e32e881ca54988d598f4.webp',
			'outdoor-furniture'          => 'category-6928b2234514d2559cb42935.webp',
			'paint-1-gallon-paint-can'   => 'category-6935be370f4ce5f267ae1ccc.webp',
			'paint-5-gallon-bucket'      => 'category-6935bdf50f4ce5f267ae1c95.webp',
			'pillow'                     => 'category-699adfb21aab85eb5b5523d8.webp',
			'printer'                    => 'category-69260fa74514d2559cb41219.webp',
			'propane-tank'               => 'category-69386cd70f4ce5f267ae3845.webp',
			'recliner'                   => 'category-67d330c005b85c1a4e6c3a9f.webp',
			'refrigerator'               => 'category-67d3287b05b85c1a4e6c371f.webp',
			'sandbox-wooden'             => 'category-6938753c0f4ce5f267ae38cb.webp',
			'scooter'                    => 'category-67d3265c05b85c1a4e6c36a7.webp',
			'sewing-machine'             => 'category-69260e344514d2559cb41175.webp',
			'sleeper-sofa'               => 'category-6a0884689d4acad7eeac3b91.webp',
			'smoker-grill'               => 'category-69261a344514d2559cb41562.webp',
			'speakers'                   => 'category-69260eae4514d2559cb411ac.webp',
			'stereo-system'              => 'category-69260ed54514d2559cb411e3.webp',
			'stool'                      => 'category-692610754514d2559cb413b3.webp',
			'stroller'                   => 'category-699ae1c11aab85eb5b552536.webp',
			'swing-s'                    => 'category-6926137a4514d2559cb4147c.webp',
			'table'                      => 'category-6801336c5fb426ab5e6d4358.webp',
			'television'                 => 'category-67d32b3a05b85c1a4e6c3899.webp',
			'toilet'                     => 'category-6a08865e9d4acad7eeac3d39.webp',
			'treadmill'                  => 'category-67d32d8105b85c1a4e6c398b.webp',
			'tv-stand'                   => 'category-67d32aa605b85c1a4e6c382d.webp',
			'vacuum'                     => 'category-68e29c0c18f1ed17703a7858.webp',
			'vanity'                     => 'category-68e29b2318f1ed17703a775d.webp',
			'vending-machine'            => 'category-69c94ba61fbb5b55d522ba90.webp',
			'washer'                     => 'category-6802a78aa90ddb95fb64e833.webp',
			'water-heater'               => 'category-68daa4b9b41538c99aea92c1.webp',
			'waterbed'                   => 'category-6921cad64514d2559cb3e694.webp',
			'weed-eater-trimmer'         => 'category-68daa54fb41538c99aea9300.webp',
			'weight-bench'               => 'category-68dbf5b9b41538c99aeaa3e8.webp',
			'weight-bench-set'           => 'category-68dbf5f9b41538c99aeaa423.webp',
			'weight-machine'             => 'category-68dbf619b41538c99aeaa45d.webp',
			'wheelbarrow'                => 'category-68dbf859b41538c99aeaa511.webp',
			'wheelchair'                 => 'category-68dbf8b3b41538c99aeaa54b.webp',
			'wine-cooler-large'          => 'category-68e2995618f1ed17703a76be.webp',
			'wood-pallet'                => 'category-68e2997e18f1ed17703a76f8.webp',
		);

		return $map;
	}

	private function sanitize_public_url( string $value, string $fallback = '' ): string {
		$value = trim( $value );

		if ( '' === $value ) {
			return $fallback;
		}

		$sanitized = esc_url_raw( $value );

		return '' !== $sanitized ? $sanitized : $fallback;
	}

	private function get_default_volume_image_url(): string {
		return esc_url_raw( plugin_dir_url( __FILE__ ) . 'assets/images/volume/truck-icon.svg' );
	}

	/**
	 * Get saved design options merged with defaults.
	 *
	 * @return array<string, mixed>
	 */
	private function get_design_options(): array {
		$saved  = get_option( self::DESIGN_OPTION, array() );
		$saved  = is_array( $saved ) ? $saved : array();
		$design = wp_parse_args( $saved, $this->get_default_design_options() );

		if ( ! array_key_exists( 'option_text_color', $saved ) ) {
			$design['option_text_color'] = $design['text_color'];
		}

		if ( ! array_key_exists( 'option_active_bg_color', $saved ) ) {
			$design['option_active_bg_color'] = $design['primary_color'];
		}

		if ( ! array_key_exists( 'option_active_text_color', $saved ) ) {
			$design['option_active_text_color'] = $design['cta_text_color'];
		}

		return $design;
	}

	/**
	 * @return array<string, mixed>
	 */
	private function get_default_design_options(): array {
		return array(
			'primary_color'      => '#1bad3c',
			'primary_dark_color' => '#0e8629',
			'text_color'         => '#232323',
			'muted_color'        => '#505050',
			'surface_color'      => '#fafafa',
			'panel_color'        => '#ffffff',
			'option_bg_color'    => '#ffffff',
			'option_text_color'  => '#232323',
			'option_active_bg_color' => '#1bad3c',
			'option_active_text_color' => '#ffffff',
			'border_color'       => '#e5e5e5',
			'danger_color'       => '#d93434',
			'cta_text_color'     => '#ffffff',
			'font_family'        => 'inherit',
			'custom_font_family' => '',
			'heading_size'       => 44,
			'body_size'          => 16,
			'max_width'          => 860,
			'radius'             => 12,
			'control_radius'     => 8,
			'shadow'             => 'subtle',
		);
	}

	/**
	 * @return array<string, string>
	 */
	private function get_font_family_choices(): array {
		return array(
			'inherit' => __( 'Inherit theme font', 'junk-removal-cost-calculator' ),
			'system'  => __( 'System UI', 'junk-removal-cost-calculator' ),
			'sans'    => __( 'Clean sans-serif', 'junk-removal-cost-calculator' ),
			'serif'   => __( 'Editorial serif', 'junk-removal-cost-calculator' ),
			'mono'    => __( 'Monospace', 'junk-removal-cost-calculator' ),
			'custom'  => __( 'Custom font stack', 'junk-removal-cost-calculator' ),
		);
	}

	/**
	 * @return array<string, string>
	 */
	private function get_shadow_choices(): array {
		return array(
			'none'   => __( 'None', 'junk-removal-cost-calculator' ),
			'subtle' => __( 'Subtle', 'junk-removal-cost-calculator' ),
			'soft'   => __( 'Soft', 'junk-removal-cost-calculator' ),
			'strong' => __( 'Strong', 'junk-removal-cost-calculator' ),
		);
	}

	private function build_design_style_attr(): string {
		$design = $this->get_design_options();

		$vars = array(
			'--junkreco-primary'      => (string) $design['primary_color'],
			'--junkreco-primary-dark' => (string) $design['primary_dark_color'],
			'--junkreco-text'         => (string) $design['text_color'],
			'--junkreco-muted-text'   => (string) $design['muted_color'],
			'--junkreco-surface'      => (string) $design['surface_color'],
			'--junkreco-panel'        => (string) $design['panel_color'],
			'--junkreco-option-bg'     => (string) $design['option_bg_color'],
			'--junkreco-option-text'   => (string) $design['option_text_color'],
			'--junkreco-option-active-bg' => (string) $design['option_active_bg_color'],
			'--junkreco-option-active-text' => (string) $design['option_active_text_color'],
			'--junkreco-border'       => (string) $design['border_color'],
			'--junkreco-danger'       => (string) $design['danger_color'],
			'--junkreco-cta-text'     => (string) $design['cta_text_color'],
			'--junkreco-font-family'  => $this->resolve_font_family( (string) $design['font_family'], (string) $design['custom_font_family'] ),
			'--junkreco-heading-size' => (int) $design['heading_size'] . 'px',
			'--junkreco-body-size'    => (int) $design['body_size'] . 'px',
			'--junkreco-max-width'    => (int) $design['max_width'] . 'px',
			'--junkreco-radius'       => (int) $design['radius'] . 'px',
			'--junkreco-control-radius' => (int) $design['control_radius'] . 'px',
			'--junkreco-shadow'       => $this->resolve_shadow( (string) $design['shadow'] ),
		);

		$declarations = array();
		foreach ( $vars as $property => $value ) {
			$declarations[] = $property . ': ' . $value;
		}

		return implode( '; ', $declarations ) . ';';
	}

	private function resolve_font_family( string $choice, string $custom ): string {
		if ( 'custom' === $choice && '' !== $custom ) {
			return $this->sanitize_font_stack( $custom );
		}

		$families = array(
			'inherit' => 'inherit',
			'system'  => '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
			'sans'    => 'Arial, Helvetica, sans-serif',
			'serif'   => 'Georgia, "Times New Roman", serif',
			'mono'    => '"SFMono-Regular", Consolas, "Liberation Mono", monospace',
		);

		return $families[ $choice ] ?? $families['inherit'];
	}

	private function sanitize_font_stack( string $value ): string {
		$value = str_replace( array( ';', '{', '}', '<', '>', '\\' ), '', $value );
		$value = preg_replace( '/[^A-Za-z0-9\s,"\'._-]/', '', $value );
		$value = is_string( $value ) ? trim( preg_replace( '/\s+/', ' ', $value ) ) : '';

		return substr( $value, 0, 160 );
	}

	private function resolve_shadow( string $choice ): string {
		$shadows = array(
			'none'   => 'none',
			'subtle' => '0 18px 20px -18px rgba(0, 0, 0, 0.16)',
			'soft'   => '0 16px 32px -24px rgba(0, 0, 0, 0.24)',
			'strong' => '0 24px 48px -28px rgba(0, 0, 0, 0.34)',
		);

		return $shadows[ $choice ] ?? $shadows['subtle'];
	}

	/**
	 * @param mixed $value Raw number value.
	 */
	private function bounded_int( $value, int $min, int $max, int $fallback ): int {
		$value = (int) $value;
		if ( $value < $min || $value > $max ) {
			return $fallback;
		}

		return $value;
	}

	/**
	 * Normalize a price to a non-negative float.
	 *
	 * @param mixed $value Raw price value.
	 */
	private function normalize_price( $value ): float {
		if ( is_string( $value ) ) {
			$value = str_replace( ',', '', $value );
		}

		return max( 0, round( (float) $value, 2 ) );
	}
}

new Junkreco_Calculator_Plugin();
