<?php

declare(strict_types=1);

namespace Australcode\Rewards\Admin;

use Australcode\Rewards\Domain\Settings\Settings;
use Australcode\Rewards\Domain\Settings\SettingsRepository;
use Australcode\Rewards\Infrastructure\Settings\OptionsSettingsRepository;
use Australcode\Rewards\Service\Licensing\LicenseGate;

defined( 'ABSPATH' ) || exit;

/**
 * Server-rendered settings form (hybrid-UI decision: PHP for config surfaces).
 *
 * The five Pro-only earning settings are shown but disabled (with an upsell
 * badge) when the license isn't Pro. Crucially, saving in Free does NOT wipe a
 * previously configured Pro value: merge_settings() starts from the raw stored
 * option and only overwrites Pro fields when Pro is active — so the FeatureMap
 * "save the real config, gate it on read" contract holds, and re-activating a
 * license restores the admin's Pro settings without reconfiguring.
 *
 * Save goes through admin-post (nonce + capability), then Settings::from_array()
 * does the final clamping/validation as a second line of defense.
 */
final class SettingsPage {

	use RendersPageHeader;

	public const ACTION = 'acrwd_save_settings';
	public const NONCE  = 'acrwd_settings';

	private const CAPABILITY = 'manage_woocommerce';
	private const PAGE_SLUG  = 'acrwd-settings';

	/** Sample amounts used by the live earning/redemption preview (A-103). */
	private const CALC_SAMPLE_SPEND  = 10000;
	private const CALC_SAMPLE_POINTS = 1000;

	/** Pro-gated settings (forced to 0 in Free by the FeatureMap). */
	private const PRO_FIELDS = array(
		'signup_points',
		'daily_login_points',
		'daily_login_streak_bonus',
		'first_order_points',
		'review_points',
		'points_expiration_months',
		'birthday_points',
		'referrer_points',
		'referee_points',
		'profile_points',
		'orders_milestone_count',
		'orders_milestone_points',
		'cart_threshold_points',
		'lifetime_spend_points',
		'expiry_reminder_days',
	);

	/**
	 * Pro float settings (currency thresholds). Merged like PRO_FIELDS but kept as
	 * floats; only written on a Pro build so a tampered Free submit can't set them.
	 */
	private const PRO_FLOAT_FIELDS = array(
		'cart_threshold_amount',
		'lifetime_spend_amount',
	);

	public function __construct(
		private readonly SettingsRepository $settings,
		private readonly LicenseGate $gate,
	) {
	}

	public function register(): void {
		add_action( 'admin_post_' . self::ACTION, array( $this, 'handle_save' ) );
	}

	public function handle_save(): void {
		if ( ! current_user_can( self::CAPABILITY ) ) {
			wp_die( esc_html__( 'You are not allowed to manage Australcode Rewards settings.', 'australcode-points-rewards' ) );
		}
		check_admin_referer( self::NONCE );

		// Nonce + capability verified above. The payload is sanitized as text up
		// front; merge_settings() casts each field to its real type and
		// Settings::from_array() performs the final validation.
		$input = map_deep( wp_unslash( $_POST ), 'sanitize_text_field' );

		// Message templates may carry basic HTML + line breaks, which the blanket
		// sanitize_text_field above strips — re-read them raw and apply post-grade
		// kses instead (nonce already verified by check_admin_referer above).
		foreach ( array( 'msg_product', 'msg_cart', 'msg_redeem', 'msg_email' ) as $msg_key ) {
			if ( isset( $_POST[ $msg_key ] ) ) {
				$input[ $msg_key ] = wp_kses_post( wp_unslash( $_POST[ $msg_key ] ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- wp_kses_post sanitizes.
			}
		}

		$raw = get_option( OptionsSettingsRepository::OPTION_NAME, array() );
		$raw = is_array( $raw ) ? $raw : array();

		$merged = $this->merge_settings( $raw, $input, $this->gate->is_pro_active() );
		$this->settings->save( Settings::from_array( $merged ) );

		wp_safe_redirect(
			add_query_arg(
				'acrwd_updated',
				'1',
				admin_url( 'admin.php?page=' . self::PAGE_SLUG )
			)
		);
		exit;
	}

	/**
	 * Pure merge: the persisted result starts from the raw stored values, then
	 * every non-Pro field is overwritten from the request (checkboxes resolved by
	 * presence). Pro fields are overwritten ONLY when Pro is active; in Free they
	 * keep their stored value, so a downgrade never erases a paid configuration.
	 *
	 * @param array<string,mixed> $stored Raw stored option (NOT FeatureMap-overlaid).
	 * @param array<string,mixed> $input  Sanitized request payload.
	 * @return array<string,mixed>
	 */
	public function merge_settings( array $stored, array $input, bool $is_pro ): array {
		$result = $stored;

		$result['earning_enabled']          = ! empty( $input['earning_enabled'] );
		$result['redemption_enabled']       = ! empty( $input['redemption_enabled'] );
		$result['earning_include_tax']      = ! empty( $input['earning_include_tax'] );
		$result['dark_mode_default']        = ! empty( $input['dark_mode_default'] );
		$result['show_floating_widget']     = ! empty( $input['show_floating_widget'] );
		$result['review_require_verified']  = ! empty( $input['review_require_verified'] );
		$result['delete_data_on_uninstall'] = ! empty( $input['delete_data_on_uninstall'] );
		$result['show_product_message']     = ! empty( $input['show_product_message'] );
		$result['show_cart_notice']         = ! empty( $input['show_cart_notice'] );
		$result['show_account_tab']         = ! empty( $input['show_account_tab'] );
		$result['show_order_email']         = ! empty( $input['show_order_email'] );
		$result['show_points_value']        = ! empty( $input['show_points_value'] );

		$result['floating_widget_position'] = sanitize_text_field( (string) ( $input['floating_widget_position'] ?? 'bottom-right' ) );
		$result['product_message_position'] = sanitize_text_field( (string) ( $input['product_message_position'] ?? 'summary' ) );

		$result['msg_product'] = (string) ( $input['msg_product'] ?? '' );
		$result['msg_cart']    = (string) ( $input['msg_cart'] ?? '' );
		$result['msg_redeem']  = (string) ( $input['msg_redeem'] ?? '' );
		$result['msg_email']   = (string) ( $input['msg_email'] ?? '' );

		// Earning rate as a 2-field ratio ("for every <per>, give <points>"): the
		// merchant never types a decimal to decode (0.03). We store the canonical
		// float points_per_currency_unit = points / per (guard per >= 1).
		$earn_per                           = max( 1, (int) ( $input['acrwd_earn_per'] ?? 1 ) );
		$earn_points                        = max( 0.0, (float) ( $input['acrwd_earn_points'] ?? 0 ) );
		$result['points_per_currency_unit'] = $earn_points / $earn_per;
		$result['redemption_rate']          = (int) ( $input['redemption_rate'] ?? 0 );
		$result['min_points_to_redeem']     = (int) ( $input['min_points_to_redeem'] ?? 0 );
		$result['max_redemption_percent']   = (int) ( $input['max_redemption_percent'] ?? 0 );

		$result['earning_trigger_statuses']  = $this->collect_trigger_statuses( $input );
		$result['earning_rounding']          = sanitize_text_field( (string) ( $input['earning_rounding'] ?? 'floor' ) );
		$result['earning_base']              = sanitize_text_field( (string) ( $input['earning_base'] ?? 'after_discounts' ) );
		$result['earning_exclude_sale']      = ! empty( $input['earning_exclude_sale'] );
		$result['earning_exclude_redeemed']  = ! empty( $input['earning_exclude_redeemed'] );
		$result['earning_link_guest_orders'] = ! empty( $input['earning_link_guest_orders'] );
		$result['accent_color']              = sanitize_text_field( (string) ( $input['accent_color'] ?? '' ) );

		$singular = sanitize_text_field( (string) ( $input['points_label_singular'] ?? '' ) );
		$plural   = sanitize_text_field( (string) ( $input['points_label_plural'] ?? '' ) );

		$result['points_label_singular'] = '' !== $singular ? $singular : 'point';
		$result['points_label_plural']   = '' !== $plural ? $plural : 'points';

		$result['role_earning_multipliers'] = $this->collect_role_multipliers( $input );

		if ( $is_pro ) {
			foreach ( self::PRO_FIELDS as $field ) {
				$result[ $field ] = max( 0, (int) ( $input[ $field ] ?? 0 ) );
			}
			foreach ( self::PRO_FLOAT_FIELDS as $field ) {
				$result[ $field ] = max( 0.0, (float) ( $input[ $field ] ?? 0 ) );
			}
			// Pro bool — written only on a Pro build so a Free save never wipes it
			// (the checkbox is stripped in Free; absence must not force it to false).
			$result['notify_points_updated'] = ! empty( $input['notify_points_updated'] );
		}

		return $result;
	}

	/**
	 * The order statuses that award purchase points, from the checkbox group. The
	 * Settings VO re-sanitises and guarantees at least one status, so an all-unchecked
	 * submit falls back to "completed" rather than silently disabling earning.
	 *
	 * @param array<string,mixed> $input Sanitized request payload.
	 * @return array<int,string>
	 */
	private function collect_trigger_statuses( array $input ): array {
		$raw = ( isset( $input['earning_trigger_statuses'] ) && is_array( $input['earning_trigger_statuses'] ) )
			? $input['earning_trigger_statuses']
			: array();

		$statuses = array();
		foreach ( $raw as $status ) {
			$statuses[] = sanitize_text_field( (string) $status );
		}

		return $statuses;
	}

	/**
	 * Parses the role→multiplier inputs (FREE). Keeps only role slugs with a
	 * positive multiplier; the rest fall back to the default 1.0 at resolve time.
	 *
	 * @param array<string,mixed> $input Sanitized request payload.
	 * @return array<string,float>
	 */
	private function collect_role_multipliers( array $input ): array {
		$raw = ( isset( $input['role_multiplier'] ) && is_array( $input['role_multiplier'] ) ) ? $input['role_multiplier'] : array();

		$map = array();
		foreach ( $raw as $slug => $value ) {
			$slug   = sanitize_key( (string) $slug );
			$factor = (float) $value;
			if ( '' !== $slug && $factor > 0 ) {
				$map[ $slug ] = $factor;
			}
		}

		return $map;
	}

	/**
	 * Decomposes the stored earning rate into a clean "<points> per <per>" ratio for
	 * the 2-field input: the smallest power-of-ten base that makes points a whole
	 * number (0.03 → 3 per 100; 1.0 → 1 per 1). Pure + public for unit testing.
	 *
	 * @return array{per:int,points:float}
	 */
	public static function ratio_for_rate( float $rate ): array {
		if ( $rate <= 0 ) {
			return array(
				'per'    => 1,
				'points' => 0.0,
			);
		}
		foreach ( array( 1, 10, 100, 1000, 10000 ) as $per ) {
			$points = $rate * $per;
			if ( abs( $points - round( $points ) ) < 0.000001 ) {
				return array(
					'per'    => $per,
					'points' => round( $points ),
				);
			}
		}
		return array(
			'per'    => 1,
			'points' => $rate,
		);
	}

	public function render(): void {
		$settings = $this->settings->get();
		$is_pro   = $this->gate->is_pro_active();

		echo '<div class="wrap acds-root acrwd-page"><div class="acds-wrap">';
		$this->render_page_header(
			__( 'Settings', 'australcode-points-rewards' ),
			__( 'Configure how customers earn and redeem points, and how the program looks on your store.', 'australcode-points-rewards' ),
			$this->dashboard_back_action()
		);

		if ( isset( $_GET['acrwd_updated'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only confirmation flag, no state change.
			echo '<div class="notice notice-success is-dismissible"><p>'
				. esc_html__( 'Settings saved.', 'australcode-points-rewards' )
				. '</p></div>';
		}

		echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '">';
		echo '<input type="hidden" name="action" value="' . esc_attr( self::ACTION ) . '" />';
		wp_nonce_field( self::NONCE );

		// Tabs are show/hide (settings-tabs.js): every panel's fields stay in the DOM,
		// so a single Save submits the whole form. Without JS the panels just stack
		// (the .acrwd-tabs-js class that hides inactive panels is added by the script).
		$tabs = array(
			'earning'    => __( 'Earning', 'australcode-points-rewards' ),
			'redemption' => __( 'Redemption', 'australcode-points-rewards' ),
			'display'    => __( 'Display & Widgets', 'australcode-points-rewards' ),
			'messages'   => __( 'Messages', 'australcode-points-rewards' ),
			'advanced'   => __( 'Advanced', 'australcode-points-rewards' ),
		);
		echo '<div class="acrwd-settings-tabs">';
		echo '<nav class="nav-tab-wrapper acrwd-tab-nav">';
		$active = true;
		foreach ( $tabs as $id => $label ) {
			echo '<a href="#acrwd-tab-' . esc_attr( $id ) . '" class="nav-tab' . ( $active ? ' nav-tab-active' : '' )
				. '" data-acrwd-tab="' . esc_attr( $id ) . '">' . esc_html( $label ) . '</a>';
			$active = false;
		}
		echo '</nav>';

		echo '<div class="acrwd-tab-panel is-active" id="acrwd-tab-earning" data-acrwd-panel="earning">';
		$this->render_earning_section( $settings, $is_pro );
		echo '</div>';
		echo '<div class="acrwd-tab-panel" id="acrwd-tab-redemption" data-acrwd-panel="redemption">';
		$this->render_redemption_section( $settings );
		echo '</div>';
		echo '<div class="acrwd-tab-panel" id="acrwd-tab-display" data-acrwd-panel="display">';
		$this->render_display_section( $settings );
		echo '</div>';
		echo '<div class="acrwd-tab-panel" id="acrwd-tab-messages" data-acrwd-panel="messages">';
		$this->render_messages_section( $settings );
		echo '</div>';
		echo '<div class="acrwd-tab-panel" id="acrwd-tab-advanced" data-acrwd-panel="advanced">';
		$this->render_advanced_section( $settings );
		echo '</div>';
		echo '</div>';

		submit_button( __( 'Save changes', 'australcode-points-rewards' ) );
		echo '</form>';
		echo '</div></div>';
	}

	private function render_earning_section( Settings $settings, bool $is_pro ): void {
		echo '<div class="acds-surface"><div class="acds-surface__head"><h2 class="acds-surface__title">'
			. esc_html__( 'Earning', 'australcode-points-rewards' ) . '</h2></div><div class="acds-surface__body">';
		echo '<table class="form-table" role="presentation"><tbody>';

		$this->checkbox_row( 'earning_enabled', __( 'Enable earning', 'australcode-points-rewards' ), $settings->earning_enabled );
		$this->render_earn_rate_row( $settings );
		$this->render_earning_preview();
		$this->checkboxes_row(
			'earning_trigger_statuses',
			__( 'Award points when the order is', 'australcode-points-rewards' ),
			$settings->earning_trigger_statuses,
			array(
				'processing' => __( 'Processing (paid, not yet fulfilled)', 'australcode-points-rewards' ),
				'completed'  => __( 'Completed (fully paid and fulfilled)', 'australcode-points-rewards' ),
			),
			__( '<strong>Recommended: Completed only</strong> — points are awarded after the order is fully paid and fulfilled, avoiding clawbacks on cancellations. Tick Processing as well to also award as soon as payment clears (accepting the refund risk). At least one is required.', 'australcode-points-rewards' )
		);
		$this->select_row(
			'earning_rounding',
			__( 'Rounding of earned points', 'australcode-points-rewards' ),
			$settings->earning_rounding,
			array(
				'floor' => __( 'Round down (recommended)', 'australcode-points-rewards' ),
				'round' => __( 'Round to nearest', 'australcode-points-rewards' ),
				'ceil'  => __( 'Round up', 'australcode-points-rewards' ),
			),
			__( '<strong>Recommended: Round down</strong> — an order of $19 at 1 point per $10 gives 1 point, never more than the rate implies. Round up is the most generous to customers.', 'australcode-points-rewards' )
		);
		$this->select_row(
			'earning_base',
			__( 'Earn points on', 'australcode-points-rewards' ),
			$settings->earning_base,
			array(
				'after_discounts'  => __( 'Price after coupons (recommended)', 'australcode-points-rewards' ),
				'before_discounts' => __( 'Full price before coupons', 'australcode-points-rewards' ),
			),
			__( '<strong>Recommended: after coupons</strong> — customers earn on what they actually pay. Choose full price to keep rewarding the original amount even when a coupon is applied.', 'australcode-points-rewards' )
		);
		$this->checkbox_row(
			'earning_exclude_sale',
			__( 'Do not earn points on items already on sale', 'australcode-points-rewards' ),
			$settings->earning_exclude_sale,
			__( 'When on, products with an active sale price are skipped when calculating points. Off by default — on-sale items still earn.', 'australcode-points-rewards' )
		);
		$this->checkbox_row(
			'earning_exclude_redeemed',
			__( 'Do not earn points on amounts paid with points', 'australcode-points-rewards' ),
			$settings->earning_exclude_redeemed,
			__( 'Recommended on — stops customers earning new points on the part of an order they paid for by redeeming points ("points on points"). The redeemed amount is removed from the earning base.', 'australcode-points-rewards' )
		);
		$this->checkbox_row(
			'earning_link_guest_orders',
			__( 'Credit past guest orders when a customer registers', 'australcode-points-rewards' ),
			$settings->earning_link_guest_orders,
			__( 'When a new account is created, match earlier guest orders by email and award the points they would have earned. Off by default; looks back at the most recent guest orders.', 'australcode-points-rewards' )
		);
		$this->checkbox_row( 'earning_include_tax', __( 'Include tax in the earning amount', 'australcode-points-rewards' ), $settings->earning_include_tax );

		// Pro earning bonuses + expiration. The renderer is @fs_premium_only, so these
		// locked settings are stripped from the free WP.org build (Guideline 5,
		// Trialware) instead of shipping disabled. class_exists keeps the free build
		// from referencing the missing class.
		if ( class_exists( ProEarningSettings::class ) ) {
			( new ProEarningSettings() )->render_rows( $settings, $is_pro );
		}

		$this->render_role_multiplier_rows( $settings );

		echo '</tbody></table></div></div>';
	}

	/**
	 * Per-role earning multipliers (P5, FREE). Compact repeater: only the configured
	 * overrides are shown as rows (role <select> + multiplier + remove); "Add" clones
	 * the row template. The highest of a customer's role multipliers applies on top of
	 * the base rate. The number input keeps the name role_multiplier[<slug>] (set by
	 * role-multipliers.js from the chosen role), so the POST shape and
	 * collect_role_multipliers() are unchanged.
	 */
	private function render_role_multiplier_rows( Settings $settings ): void {
		if ( ! function_exists( 'wp_roles' ) ) {
			return;
		}
		$roles = wp_roles()->get_names();
		if ( array() === $roles ) {
			return;
		}

		$role_names = array();
		foreach ( $roles as $slug => $name ) {
			$role_names[ (string) $slug ] = translate_user_role( (string) $name );
		}

		// Only roles that actually have a configured override become rows; the rest
		// stay at the default 1.0 and are offered by the "Add" <select>.
		$configured = array();
		foreach ( $settings->role_earning_multipliers as $slug => $factor ) {
			$slug = (string) $slug;
			if ( isset( $role_names[ $slug ] ) ) {
				$configured[ $slug ] = (float) $factor;
			}
		}

		echo '<tr><th scope="row">' . esc_html__( 'Earning by user role', 'australcode-points-rewards' ) . '</th><td>';
		echo '<p class="description">' . esc_html__( 'Multiply points earned based on the customer’s role. 1 means no change. Add a row only for the roles you want to change; if a customer has several roles, the highest multiplier applies.', 'australcode-points-rewards' ) . '</p>';

		echo '<div class="acrwd-role-repeater' . ( array() === $configured ? ' is-empty' : '' ) . '" data-empty="'
			. esc_attr__( 'No role multipliers yet — customers earn at the base rate.', 'australcode-points-rewards' ) . '">';
		echo '<div class="acrwd-role-rows">';
		foreach ( $configured as $slug => $factor ) {
			$this->render_role_row( $role_names, $slug, $factor );
		}
		echo '</div>';
		echo '<p><button type="button" class="button acrwd-role-add">'
			. esc_html__( '+ Add role multiplier', 'australcode-points-rewards' ) . '</button></p>';

		// Template cloned by the JS repeater. role-multipliers.js assigns the input
		// name from the selected role on add/change.
		echo '<template class="acrwd-role-row-template">';
		$this->render_role_row( $role_names, '', null );
		echo '</template>';

		echo '</div></td></tr>';
	}

	/**
	 * One repeater row: role <select> + multiplier input + remove button. With an
	 * empty $slug (the template) the input has no name yet — the script sets it.
	 *
	 * @param array<string,string> $role_names Role slug => translated display name.
	 */
	private function render_role_row( array $role_names, string $slug, ?float $factor ): void {
		echo '<div class="acrwd-role-row">';

		echo '<select class="acrwd-role-select">';
		foreach ( $role_names as $role_slug => $role_name ) {
			echo '<option value="' . esc_attr( $role_slug ) . '" ' . selected( $slug, $role_slug, false ) . '>'
				. esc_html( $role_name ) . '</option>';
		}
		echo '</select>';

		echo '<input type="number" step="0.01" min="0" class="acrwd-role-value" placeholder="1"';
		if ( '' !== $slug ) {
			echo ' name="role_multiplier[' . esc_attr( $slug ) . ']"';
		}
		if ( null !== $factor ) {
			echo ' value="' . esc_attr( (string) $factor ) . '"';
		}
		echo ' />';

		echo '<button type="button" class="button-link acrwd-role-remove" aria-label="'
			. esc_attr__( 'Remove this role multiplier', 'australcode-points-rewards' ) . '">'
			. esc_html__( 'Remove', 'australcode-points-rewards' ) . '</button>';

		echo '</div>';
	}

	private function render_redemption_section( Settings $settings ): void {
		echo '<div class="acds-surface"><div class="acds-surface__head"><h2 class="acds-surface__title">'
			. esc_html__( 'Redemption', 'australcode-points-rewards' ) . '</h2></div><div class="acds-surface__body">';
		echo '<table class="form-table" role="presentation"><tbody>';

		$this->checkbox_row( 'redemption_enabled', __( 'Enable redemption', 'australcode-points-rewards' ), $settings->redemption_enabled );
		$this->number_row(
			'redemption_rate',
			__( 'Points per 1 unit of discount', 'australcode-points-rewards' ),
			$settings->redemption_rate,
			array( 'min' => '1' ),
			__( 'How many points equal one unit of discount at checkout. Example: 100 means 100 points give a 1.00 discount.', 'australcode-points-rewards' )
		);
		$this->render_redemption_preview();
		$this->number_row(
			'min_points_to_redeem',
			__( 'Minimum points to redeem', 'australcode-points-rewards' ),
			$settings->min_points_to_redeem,
			array( 'min' => '0' ),
			__( 'Customers must hold at least this many points before they can redeem. 0 means no minimum.', 'australcode-points-rewards' )
		);
		$this->number_row(
			'max_redemption_percent',
			__( 'Max % of cart payable with points', 'australcode-points-rewards' ),
			$settings->max_redemption_percent,
			array(
				'min' => '1',
				'max' => '100',
			),
			__( 'Caps how much of a cart can be paid with points. 50–100% is a healthy range depending on your margins.', 'australcode-points-rewards' )
		);

		echo '</tbody></table></div></div>';
	}

	/**
	 * Earning rate as a friendly 2-field ratio ("For every {symbol} {per} spent,
	 * give {points} {label}") instead of a single decimal the merchant must decode
	 * (the 0.03 = 3% pain). The inputs feed admin-settings-calc.js for the live
	 * preview; merge_settings() computes the stored points_per_currency_unit from them.
	 */
	private function render_earn_rate_row( Settings $settings ): void {
		$ratio  = self::ratio_for_rate( $settings->points_per_currency_unit );
		$symbol = get_woocommerce_currency_symbol();

		echo '<tr><th scope="row">' . esc_html__( 'Earning rate', 'australcode-points-rewards' ) . '</th><td>';
		echo '<span class="acrwd-rate-ratio">';
		/* translators: %s: store currency symbol. */
		printf( esc_html__( 'For every %s', 'australcode-points-rewards' ), esc_html( $symbol ) );
		echo ' <input type="number" id="acrwd_earn_per" name="acrwd_earn_per" min="1" step="1" value="'
			. esc_attr( (string) $ratio['per'] ) . '" class="small-text" /> ';
		echo esc_html__( 'spent, give', 'australcode-points-rewards' );
		echo ' <input type="number" id="acrwd_earn_points" name="acrwd_earn_points" min="0" step="0.01" value="'
			. esc_attr( $this->trim_float( $ratio['points'] ) ) . '" class="small-text" /> ';
		echo esc_html( $settings->points_label_plural ) . '.';
		echo '</span>';
		echo '<p class="description">' . esc_html__( 'Set the reward as a simple ratio — no decimals to figure out. Example: “for every 100 spent, give 3 points”. With 100 points worth 1.00 of discount (see the Redemption tab), that is a 3% reward. Points are calculated and floored per product line.', 'australcode-points-rewards' ) . '</p>';
		echo '</td></tr>';
	}

	/** Formats a float without trailing zeros for a number input value ("3.00" → "3"). */
	private function trim_float( float $value ): string {
		return rtrim( rtrim( number_format( $value, 2, '.', '' ), '0' ), '.' );
	}

	/**
	 * Live "what a customer earns" preview (A-103). The wording is translatable;
	 * admin-settings-calc.js only updates the numbers as the rate changes.
	 */
	private function render_earning_preview(): void {
		$symbol = get_woocommerce_currency_symbol();
		$sample = $symbol . number_format_i18n( self::CALC_SAMPLE_SPEND );

		echo '<tr><th scope="row">' . esc_html__( 'Preview', 'australcode-points-rewards' ) . '</th><td>';
		echo '<p class="acds-muted" id="acrwd-earning-calc" data-sample="' . esc_attr( (string) self::CALC_SAMPLE_SPEND ) . '">';
		echo wp_kses_post(
			sprintf(
				/* translators: 1: a sample order amount (e.g. $10,000), 2: the computed points. */
				__( 'A %1$s order earns %2$s points.', 'australcode-points-rewards' ),
				'<strong>' . esc_html( $sample ) . '</strong>',
				'<strong id="acrwd-earn-preview">—</strong>'
			)
		);
		echo '</p></td></tr>';
	}

	/**
	 * Live "what points are worth" preview (A-103).
	 */
	private function render_redemption_preview(): void {
		$symbol = get_woocommerce_currency_symbol();

		echo '<tr><th scope="row">' . esc_html__( 'Preview', 'australcode-points-rewards' ) . '</th><td>';
		echo '<p class="acds-muted" id="acrwd-redeem-calc" data-currency="' . esc_attr( $symbol ) . '" data-points="' . esc_attr( (string) self::CALC_SAMPLE_POINTS ) . '">';
		echo wp_kses_post(
			sprintf(
				/* translators: 1: a sample points amount, 2: the discount it is worth (e.g. $10). */
				__( '%1$s points are worth %2$s off.', 'australcode-points-rewards' ),
				'<strong>' . esc_html( number_format_i18n( self::CALC_SAMPLE_POINTS ) ) . '</strong>',
				'<strong id="acrwd-redeem-preview">—</strong>'
			)
		);
		echo '</p></td></tr>';
	}

	private function render_display_section( Settings $settings ): void {
		echo '<div class="acds-surface"><div class="acds-surface__head"><h2 class="acds-surface__title">'
			. esc_html__( 'Display & Widgets', 'australcode-points-rewards' ) . '</h2></div><div class="acds-surface__body">';
		echo '<table class="form-table" role="presentation"><tbody>';

		$this->text_row( 'points_label_singular', __( 'Points label (singular)', 'australcode-points-rewards' ), $settings->points_label_singular );
		$this->text_row( 'points_label_plural', __( 'Points label (plural)', 'australcode-points-rewards' ), $settings->points_label_plural );
		$this->color_row( 'accent_color', __( 'Accent color', 'australcode-points-rewards' ), $settings->accent_color );
		$this->checkbox_row(
			'dark_mode_default',
			__( 'Dark mode by default', 'australcode-points-rewards' ),
			$settings->dark_mode_default,
			__( 'Renders the customer-facing rewards surfaces (My Account tab, widget, cart message) on dark backgrounds. Pick this when your storefront theme is dark.', 'australcode-points-rewards' )
		);

		// Per-surface visibility (FREE). Each toggle turns one storefront surface on
		// or off; redemption still works from the My Account Rewards page even when
		// the cart and product surfaces are hidden.
		echo '<tr><th scope="row">' . esc_html__( 'Storefront surfaces', 'australcode-points-rewards' ) . '</th><td>';
		echo '<p class="description">' . esc_html__( 'Choose where the rewards program appears on your storefront. Turning a surface off only hides it — it does not change how points are earned or redeemed.', 'australcode-points-rewards' ) . '</p></td></tr>';

		$this->checkbox_row(
			'show_account_tab',
			__( 'My Account “Rewards” tab', 'australcode-points-rewards' ),
			$settings->show_account_tab,
			__( 'The Rewards tab in My Account with the balance, redeem form and points history. This is the main customer surface — leave it on unless you place the shortcodes elsewhere.', 'australcode-points-rewards' )
		);
		$this->checkbox_row(
			'show_points_value',
			__( 'Show what points are worth', 'australcode-points-rewards' ),
			$settings->show_points_value,
			__( 'Shows the money value of a customer’s balance (e.g. “worth about $10.00”) on the My Account Rewards tab, using your redemption rate.', 'australcode-points-rewards' )
		);
		$this->checkbox_row(
			'show_product_message',
			__( 'Product page earning message', 'australcode-points-rewards' ),
			$settings->show_product_message,
			__( 'The “Earn X points with this purchase” line on single product pages.', 'australcode-points-rewards' )
		);
		$this->select_row(
			'product_message_position',
			__( 'Product message position', 'australcode-points-rewards' ),
			$settings->product_message_position,
			array(
				'summary'     => __( 'In the product summary', 'australcode-points-rewards' ),
				'before_cart' => __( 'Before the Add to cart button', 'australcode-points-rewards' ),
				'after_cart'  => __( 'After the Add to cart button', 'australcode-points-rewards' ),
				'after_meta'  => __( 'After the product meta (SKU, categories)', 'australcode-points-rewards' ),
			),
			__( 'Where the earning message appears on the product page.', 'australcode-points-rewards' )
		);
		$this->checkbox_row(
			'show_cart_notice',
			__( 'Cart points message & redeem form', 'australcode-points-rewards' ),
			$settings->show_cart_notice,
			__( 'The balance message and “use your points” form shown on the cart.', 'australcode-points-rewards' )
		);
		$this->checkbox_row(
			'show_order_email',
			__( 'Order email points line', 'australcode-points-rewards' ),
			$settings->show_order_email,
			__( 'Appends the updated points balance to the customer’s WooCommerce order emails.', 'australcode-points-rewards' )
		);
		$this->checkbox_row(
			'show_floating_widget',
			__( 'Floating points widget', 'australcode-points-rewards' ),
			$settings->show_floating_widget,
			__( 'A small floating badge with the points balance at a corner of every storefront page. Only logged-in customers see it; it links to their Rewards page.', 'australcode-points-rewards' )
		);
		$this->select_row(
			'floating_widget_position',
			__( 'Floating widget position', 'australcode-points-rewards' ),
			$settings->floating_widget_position,
			array(
				'bottom-right' => __( 'Bottom right', 'australcode-points-rewards' ),
				'bottom-left'  => __( 'Bottom left', 'australcode-points-rewards' ),
			),
			__( 'Which corner the floating widget sits in.', 'australcode-points-rewards' )
		);

		echo '</tbody></table></div></div>';
	}

	private function render_advanced_section( Settings $settings ): void {
		echo '<div class="acds-surface"><div class="acds-surface__head"><h2 class="acds-surface__title">'
			. esc_html__( 'Advanced', 'australcode-points-rewards' ) . '</h2></div><div class="acds-surface__body">';
		echo '<table class="form-table" role="presentation"><tbody>';

		$this->checkbox_row(
			'delete_data_on_uninstall',
			__( 'Delete all plugin data on uninstall', 'australcode-points-rewards' ),
			$settings->delete_data_on_uninstall,
			__( 'When enabled, deleting this plugin permanently removes all points balances, transaction history, tiers, campaigns, referrals and settings. Off by default, so your data survives a reinstall. Deactivating the plugin never deletes data.', 'australcode-points-rewards' )
		);

		echo '</tbody></table></div></div>';
	}

	/**
	 * Merchant-editable storefront copy. Each field overrides the built-in default
	 * for its surface (blank = default); the help lists the placeholders the surface
	 * fills in. Writing the copy here also lets a store phrase it in its own language.
	 */
	private function render_messages_section( Settings $settings ): void {
		echo '<div class="acds-surface"><div class="acds-surface__head"><h2 class="acds-surface__title">'
			. esc_html__( 'Messages', 'australcode-points-rewards' ) . '</h2></div><div class="acds-surface__body">';
		echo '<p class="acds-muted">' . esc_html__( 'Customise the wording customers see — in your own language. Leave a field blank to use the built-in default.', 'australcode-points-rewards' ) . '</p>';
		echo '<table class="form-table" role="presentation"><tbody>';

		$this->message_row(
			'msg_product',
			__( 'Product page message', 'australcode-points-rewards' ),
			$settings->msg_product,
			__( 'Earn <strong>{points} {points_label}</strong> with this purchase.', 'australcode-points-rewards' ),
			array( '{points}', '{points_label}' ),
			__( 'Shown on single product pages.', 'australcode-points-rewards' )
		);
		$this->message_row(
			'msg_cart',
			__( 'Cart message', 'australcode-points-rewards' ),
			$settings->msg_cart,
			__( 'You have <strong>{points} {points_label}</strong> — redeem up to {max_discount} off this order.', 'australcode-points-rewards' ),
			array( '{points}', '{points_label}', '{max_discount}' ),
			__( 'Shown above the cart totals.', 'australcode-points-rewards' )
		);
		$this->message_row(
			'msg_redeem',
			__( 'Redeem form message', 'australcode-points-rewards' ),
			$settings->msg_redeem,
			__( 'Redeem up to <strong>{points}</strong> points on your current cart — worth {max_discount} off.', 'australcode-points-rewards' ),
			array( '{points}', '{max_discount}' ),
			__( 'Shown in the My Account Rewards redeem form.', 'australcode-points-rewards' )
		);
		$this->message_row(
			'msg_email',
			__( 'Order email message', 'australcode-points-rewards' ),
			$settings->msg_email,
			__( 'You earned rewards with this order. Your points balance is now {balance} {points_label}.', 'australcode-points-rewards' ),
			array( '{balance}', '{points_label}' ),
			__( 'Appended to the customer order email (plain text).', 'australcode-points-rewards' )
		);

		echo '</tbody></table></div></div>';
	}

	/**
	 * One message-template textarea: the merchant override, the default shown as the
	 * placeholder, a help line, and the allowed placeholder tokens as <code> chips.
	 *
	 * @param string[] $placeholders Tokens the surface fills in (e.g. '{points}').
	 */
	private function message_row( string $name, string $label, string $value, string $default_text, array $placeholders, string $help ): void {
		echo '<tr><th scope="row"><label for="' . esc_attr( $name ) . '">' . esc_html( $label ) . '</label></th><td>';
		echo '<textarea id="' . esc_attr( $name ) . '" name="' . esc_attr( $name ) . '" rows="2" class="large-text code" placeholder="'
			. esc_attr( $default_text ) . '">' . esc_textarea( $value ) . '</textarea>';
		if ( '' !== $help ) {
			echo '<p class="description">' . esc_html( $help ) . '</p>';
		}
		$chips = array();
		foreach ( $placeholders as $placeholder ) {
			$chips[] = '<code>' . esc_html( $placeholder ) . '</code>';
		}
		echo '<p class="description">' . esc_html__( 'Placeholders:', 'australcode-points-rewards' ) . ' '
			. wp_kses_post( implode( ' ', $chips ) ) . '. '
			. esc_html__( 'Leave blank to use the default.', 'australcode-points-rewards' ) . '</p>';
		echo '</td></tr>';
	}

	private function checkbox_row( string $name, string $label, bool $checked, string $help = '' ): void {
		echo '<tr><th scope="row">' . esc_html( $label ) . '</th><td>';
		echo '<label><input type="checkbox" name="' . esc_attr( $name ) . '" value="1" ' . checked( $checked, true, false ) . ' /> ';
		echo esc_html__( 'Enabled', 'australcode-points-rewards' ) . '</label>';
		if ( '' !== $help ) {
			echo '<p class="description">' . esc_html( $help ) . '</p>';
		}
		echo '</td></tr>';
	}

	/**
	 * @param array<string,string> $attrs
	 */
	private function number_row( string $name, string $label, int|float $value, array $attrs = array(), string $help = '' ): void {
		echo '<tr><th scope="row"><label for="' . esc_attr( $name ) . '">' . esc_html( $label ) . '</label></th><td>';
		echo '<input type="number" id="' . esc_attr( $name ) . '" name="' . esc_attr( $name ) . '" value="' . esc_attr( (string) $value ) . '"';
		foreach ( $attrs as $key => $val ) {
			echo ' ' . esc_attr( $key ) . '="' . esc_attr( $val ) . '"';
		}
		echo ' class="small-text" />';
		if ( '' !== $help ) {
			echo '<p class="description">' . esc_html( $help ) . '</p>';
		}
		echo '</td></tr>';
	}

	private function text_row( string $name, string $label, string $value ): void {
		echo '<tr><th scope="row"><label for="' . esc_attr( $name ) . '">' . esc_html( $label ) . '</label></th><td>';
		echo '<input type="text" id="' . esc_attr( $name ) . '" name="' . esc_attr( $name ) . '" value="' . esc_attr( $value ) . '" class="regular-text" /></td></tr>';
	}

	private function color_row( string $name, string $label, string $value ): void {
		echo '<tr><th scope="row"><label for="' . esc_attr( $name ) . '">' . esc_html( $label ) . '</label></th><td>';
		echo '<input type="color" id="' . esc_attr( $name ) . '" name="' . esc_attr( $name ) . '" value="' . esc_attr( $value ) . '" /></td></tr>';
	}

	/**
	 * A multi-select rendered as a checkbox group (name="<name>[]"). $selected lists
	 * the currently checked option values; the help may contain <strong>.
	 *
	 * @param array<int,string>    $selected Currently checked option values.
	 * @param array<string,string> $options  Option value => label.
	 */
	private function checkboxes_row( string $name, string $label, array $selected, array $options, string $help = '' ): void {
		echo '<tr><th scope="row">' . esc_html( $label ) . '</th><td><fieldset>';
		foreach ( $options as $value => $option_label ) {
			echo '<label><input type="checkbox" name="' . esc_attr( $name ) . '[]" value="' . esc_attr( (string) $value ) . '" '
				. checked( in_array( (string) $value, $selected, true ), true, false ) . ' /> '
				. esc_html( $option_label ) . '</label><br />';
		}
		echo '</fieldset>';
		if ( '' !== $help ) {
			echo '<p class="description">' . wp_kses( $help, array( 'strong' => array() ) ) . '</p>';
		}
		echo '</td></tr>';
	}

	/**
	 * @param array<string,string> $options Option value => label.
	 */
	private function select_row( string $name, string $label, string $value, array $options, string $help = '' ): void {
		echo '<tr><th scope="row"><label for="' . esc_attr( $name ) . '">' . esc_html( $label ) . '</label></th><td>';
		echo '<select id="' . esc_attr( $name ) . '" name="' . esc_attr( $name ) . '">';
		foreach ( $options as $option_value => $option_label ) {
			echo '<option value="' . esc_attr( $option_value ) . '" ' . selected( $value, $option_value, false ) . '>'
				. esc_html( $option_label ) . '</option>';
		}
		echo '</select>';
		if ( '' !== $help ) {
			echo '<p class="description">' . wp_kses( $help, array( 'strong' => array() ) ) . '</p>';
		}
		echo '</td></tr>';
	}
}
