<?php
/**
 * Weight-Based / Table-Rate Shipping module.
 *
 * @package Kramar
 * @since   1.0.0
 */

declare( strict_types=1 );

namespace Kramar\Modules\Shipping;

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

/**
 * Weight-Based Shipping module wrapper.
 *
 * Registers the WC_Shipping_Method subclass with WooCommerce when enabled.
 * The actual shipping logic lives in Kramar_Weight_Based_Shipping_Method below.
 *
 * @since 1.0.0
 */
final class Weight_Based_Shipping extends \Kramar\Module {

	/**
	 * {@inheritDoc}
	 */
	public function get_slug(): string {
		return 'weight-based-shipping';
	}

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

	/**
	 * {@inheritDoc}
	 */
	public function get_name(): string {
		return __( 'Weight-Based / Table-Rate Shipping', 'kramar' );
	}

	/**
	 * {@inheritDoc}
	 */
	public function get_description(): string {
		return __( 'Zone-aware shipping method with a rule engine: match by weight, subtotal, item count, and shipping class. Each rule can set a flat + per-kg rate, a free-shipping threshold, and actions (rate / stop / deny).', 'kramar' );
	}

	/**
	 * {@inheritDoc}
	 */
	public function get_default_settings(): array {
		return [
			'enabled' => false,
		];
	}

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

	/**
	 * {@inheritDoc}
	 *
	 * Explain where the actual shipping rules are configured — they live in the
	 * WooCommerce shipping-zone instance UI, not on this Kramar settings page.
	 *
	 * @param string $option_name Settings option name (unused here).
	 */
	public function render_extra_settings( string $option_name ): void {
		$shipping_url = admin_url( 'admin.php?page=wc-settings&tab=shipping' );
		?>
		<div class="kramar-module__extra">
			<div style="padding:12px 14px;background:#eff6ff;border:1px solid #bfdbfe;border-radius:4px;font-size:13px;line-height:1.6;color:#1e3a5f;">
				<strong><?php esc_html_e( 'Where to set up your rates', 'kramar' ); ?></strong>
				<p style="margin:6px 0 0;">
					<?php esc_html_e( 'Rates are configured per shipping zone (so you can charge differently per country/region). After enabling this module:', 'kramar' ); ?>
				</p>
				<ol style="margin:6px 0 0;padding-left:20px;">
					<li>
						<?php
						printf(
							/* translators: %s: opening and closing anchor tags for the WooCommerce shipping settings link. */
							esc_html__( 'Go to %1$sWooCommerce → Settings → Shipping%2$s.', 'kramar' ),
							'<a href="' . esc_url( $shipping_url ) . '">',
							'</a>'
						);
						?>
					</li>
					<li><?php esc_html_e( 'Open the shipping zone you want (or add one), then click “Add shipping method”.', 'kramar' ); ?></li>
					<li><?php esc_html_e( 'Choose “Weight-Based / Table-Rate (Kramar)” and add it.', 'kramar' ); ?></li>
					<li><?php esc_html_e( 'Click the method to open its settings, then build your rules: each rule matches by weight / subtotal / item count / shipping class and sets a flat + per-kg rate, an optional free-shipping threshold, and an action (rate / stop / deny).', 'kramar' ); ?></li>
				</ol>
			</div>
		</div>
		<?php
	}

	/**
	 * {@inheritDoc}
	 *
	 * Register the shipping method class with WooCommerce.
	 */
	protected function run(): void {
		add_filter( 'woocommerce_shipping_methods', [ $this, 'register_method' ] );
		add_action( 'admin_enqueue_scripts', [ $this, 'maybe_enqueue_rules_js' ] );
	}

	/**
	 * Add our method class to the WooCommerce shipping method registry.
	 *
	 * @param array<string, string> $methods Registered WC shipping methods.
	 * @return array<string, string>
	 */
	public function register_method( array $methods ): array {
		$methods['kramar_weight_based'] = Kramar_Weight_Based_Shipping_Method::class;
		return $methods;
	}

	/**
	 * Enqueue the rules-table JS only on WC shipping-settings screens.
	 *
	 * WooCommerce shipping zone / method settings pages all have the query
	 * string ?page=wc-settings&tab=shipping.  We detect that to keep the asset
	 * scoped tightly.  The instance-settings modal is loaded on the same page.
	 *
	 * @param string $hook_suffix Current admin page hook suffix.
	 */
	public function maybe_enqueue_rules_js( string $hook_suffix ): void {
		// Only on WC → Settings admin page.
		if ( 'woocommerce_page_wc-settings' !== $hook_suffix ) {
			return;
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : '';
		if ( 'shipping' !== $tab ) {
			return;
		}

		wp_enqueue_script(
			'kramar-shipping-rules-admin',
			KRAMAR_URL . 'assets/js/shipping-rules-admin.js',
			[],
			KRAMAR_VERSION,
			true
		);

		// Pass WC shipping-class terms so the PHP-rendered select can be
		// re-created dynamically in new rows.
		$terms = get_terms(
			[
				'taxonomy'   => 'product_shipping_class',
				'hide_empty' => false,
			]
		);

		$classes = [];
		if ( is_array( $terms ) ) {
			foreach ( $terms as $term ) {
				if ( $term instanceof \WP_Term ) {
					$classes[] = [
						'id'   => (string) $term->term_id,
						'name' => $term->name,
					];
				}
			}
		}

		wp_localize_script(
			'kramar-shipping-rules-admin',
			'kramarShippingRules',
			[
				'shippingClasses' => $classes,
				'i18n'            => [
					'addRule'    => __( 'Add Rule', 'kramar' ),
					'deleteRule' => __( 'Delete', 'kramar' ),
					'allClasses' => __( '— All Classes —', 'kramar' ),
				],
			]
		);
	}
}

/**
 * WooCommerce shipping method: Weight-Based / Table-Rate.
 *
 * Rule struct (stored as JSON in WC instance option 'kramar_rules'):
 * [
 *   {
 *     "enabled":        true,
 *     "label":          "",        // optional checkout label override
 *     "weight_min":     "",        // kg, '' = no minimum
 *     "weight_max":     "",        // kg, '' = no maximum (0 = ∞ alias)
 *     "subtotal_min":   "",        // cart subtotal, '' = no minimum
 *     "subtotal_max":   "",        // cart subtotal, '' = no maximum (0 = ∞ alias)
 *     "items_min":      "",        // item quantity, '' = no minimum
 *     "items_max":      "",        // item quantity, '' = no maximum (0 = ∞ alias)
 *     "shipping_class": "",        // '' = all; term_id string = class must match
 *     "base_cost":      "0",       // flat base rate
 *     "per_kg_cost":    "0",       // additional cost per kg
 *     "step_kg":        "0",       // weight rounding step; 0 = continuous
 *     "max_cost":       "0",       // cost cap; 0 = no cap
 *     "free_above":     "0",       // subtotal ≥ this → cost = 0; 0 = off
 *     "action":         "rate"     // "rate" | "stop" | "deny"
 *   },
 *   ...
 * ]
 *
 * Rule matching (shipping_class):
 *   - '' → matches any cart contents.
 *   - term_id → matches if AT LEAST ONE item in the cart belongs to that class.
 *     (Suitable for "apply this rate when heavy items are present".)
 *
 * @since 1.0.0
 */
final class Kramar_Weight_Based_Shipping_Method extends \WC_Shipping_Method {

	/**
	 * Constructor.
	 *
	 * @param int $instance_id WC shipping zone instance ID.
	 */
	public function __construct( int $instance_id = 0 ) {
		$this->id                 = 'kramar_weight_based';
		$this->instance_id        = absint( $instance_id );
		$this->method_title       = __( 'Weight-Based / Table-Rate (Kramar)', 'kramar' );
		$this->method_description = __( 'Flexible shipping rates based on weight, subtotal, item count, and shipping class — with per-rule stop/deny actions.', 'kramar' );
		// NOTE: 'instance-settings-modal' is intentionally NOT declared. WooCommerce's
		// zone-method React modal only renders the fields in init_instance_form_fields()
		// (Method Title + a hidden rules JSON field) and never calls admin_options(), so
		// the interactive rules table would be unreachable through the standard "Add
		// method" flow. Without modal support, WC routes the Edit link to the full
		// instance settings page, which DOES call admin_options() and renders the table.
		$this->supports           = [
			'shipping-zones',
			'instance-settings',
		];

		$this->init();
	}

	/**
	 * Initialise settings.
	 */
	private function init(): void {
		// Form fields + saved settings MUST load before reading any instance option,
		// otherwise get_instance_option() warns on a missing form-field default.
		$this->init_instance_form_fields();
		$this->init_settings();
		$this->title = $this->get_instance_option( 'title', __( 'Standard Shipping', 'kramar' ) );
	}

	/**
	 * Declare the instance form fields WC needs to save/restore.
	 *
	 * `kramar_rules` is stored as a JSON string.  We register it here so WC
	 * includes it in `process_admin_options()`.  The visible UI comes from
	 * `admin_options()` rather than from these form-fields definitions.
	 */
	public function init_instance_form_fields(): void {
		$this->instance_form_fields = [
			'title'      => [
				'title'       => __( 'Method Title', 'kramar' ),
				'type'        => 'text',
				'description' => __( 'Displayed to the customer at checkout.', 'kramar' ),
				'default'     => __( 'Standard Shipping', 'kramar' ),
				'desc_tip'    => true,
			],
			'kramar_rules' => [
				'title'   => __( 'Rules', 'kramar' ),
				'type'    => 'textarea',
				'default' => '[]',
				'css'     => 'display:none;',
			],
		];
	}

	/**
	 * Calculate the shipping rates for a package.
	 *
	 * Iterates rules in order.  For each enabled, matching rule:
	 *  - action "rate"  → add rate, continue to next rule.
	 *  - action "stop"  → add rate, stop processing.
	 *  - action "deny"  → add no rate, remove method entirely, stop.
	 *
	 * @param array<string, mixed> $package WooCommerce shipping package.
	 */
	public function calculate_shipping( $package = [] ): void {
		$rules_json = $this->get_instance_option( 'kramar_rules', '[]' );
		$rules      = json_decode( $rules_json, true );

		if ( ! is_array( $rules ) || [] === $rules ) {
			return;
		}

		$weight   = $this->get_package_weight( $package );
		$subtotal = (float) ( $package['cart_subtotal'] ?? 0.0 );

		foreach ( $rules as $rule_index => $rule ) {
			if ( empty( $rule['enabled'] ) ) {
				continue;
			}

			if ( ! $this->rule_matches( $rule, $weight, $subtotal, $package ) ) {
				continue;
			}

			$action = isset( $rule['action'] ) ? (string) $rule['action'] : 'rate';

			if ( 'deny' === $action ) {
				// Rule matched — remove this method from the results entirely.
				return;
			}

			$cost  = $this->compute_cost( $rule, $weight, $subtotal );
			$label = ( isset( $rule['label'] ) && '' !== trim( $rule['label'] ) )
				? sanitize_text_field( $rule['label'] )
				: $this->title;

			// Each rate needs its own id. WC_Shipping_Method::add_rate() stores
			// rates in an array keyed by id, so calling it repeatedly with the
			// bare method id overwrote the previous rate every time — the whole
			// point of the "rate" action is to add a rate and carry on, and
			// stacking two rules (a base rate plus a surcharge) silently yielded
			// only the last one. Two adjacent bands meeting at an inclusive
			// boundary hit the same thing.
			$this->add_rate(
				[
					'id'    => $this->get_rate_id( (string) $rule_index ),
					'label' => $label,
					'cost'  => $cost,
					'taxes' => false, // WC applies tax classes automatically.
				]
			);

			if ( 'stop' === $action ) {
				return;
			}
			// action === 'rate': continue to next rule.
		}
	}

	/**
	 * Check whether a single rule matches the current package.
	 *
	 * @param array<string, mixed> $rule    Rule definition.
	 * @param float                $weight   Cart total weight in kg.
	 * @param float                $subtotal Cart subtotal.
	 * @param array<string, mixed> $package  WC shipping package.
	 * @return bool True if all conditions pass.
	 */
	public function rule_matches( array $rule, float $weight, float $subtotal, array $package ): bool {
		// Weight conditions.
		$w_min = isset( $rule['weight_min'] ) ? (string) $rule['weight_min'] : '';
		$w_max = isset( $rule['weight_max'] ) ? (string) $rule['weight_max'] : '';

		if ( '' !== $w_min && $weight < (float) $w_min ) {
			return false;
		}
		// 0 in weight_max is treated as ∞ (no maximum).
		if ( '' !== $w_max && (float) $w_max > 0 && $weight > (float) $w_max ) {
			return false;
		}

		// Subtotal conditions.
		$s_min = isset( $rule['subtotal_min'] ) ? (string) $rule['subtotal_min'] : '';
		$s_max = isset( $rule['subtotal_max'] ) ? (string) $rule['subtotal_max'] : '';

		if ( '' !== $s_min && $subtotal < (float) $s_min ) {
			return false;
		}
		// 0 in subtotal_max is treated as ∞.
		if ( '' !== $s_max && (float) $s_max > 0 && $subtotal > (float) $s_max ) {
			return false;
		}

		// Item-count conditions.
		$i_min = isset( $rule['items_min'] ) ? (string) $rule['items_min'] : '';
		$i_max = isset( $rule['items_max'] ) ? (string) $rule['items_max'] : '';

		if ( '' !== $i_min || '' !== $i_max ) {
			$qty = 0;
			if ( isset( $package['contents'] ) && is_array( $package['contents'] ) ) {
				foreach ( $package['contents'] as $item ) {
					$qty += (int) ( $item['quantity'] ?? 0 );
				}
			}

			if ( '' !== $i_min && $qty < (int) $i_min ) {
				return false;
			}
			// 0 in items_max is treated as ∞.
			if ( '' !== $i_max && (int) $i_max > 0 && $qty > (int) $i_max ) {
				return false;
			}
		}

		// Shipping-class condition.
		// '' = all classes pass; term_id string = at least one cart item must belong to that class.
		$class = isset( $rule['shipping_class'] ) ? (string) $rule['shipping_class'] : '';
		if ( '' !== $class && ! $this->package_has_shipping_class( $package, $class ) ) {
			return false;
		}

		return true;
	}

	/**
	 * Compute the shipping cost for a matched rule.
	 *
	 * Formula:
	 *   - If subtotal >= free_above (and free_above > 0) → cost = 0.
	 *   - Otherwise: cost = base_cost + per_kg_cost * billable_weight.
	 *     - billable_weight = weight rounded UP to next step_kg boundary (if step_kg > 0).
	 *   - If max_cost > 0: cost = min(cost, max_cost).
	 *
	 * @param array<string, mixed> $rule    Rule definition.
	 * @param float                $weight   Cart total weight in kg.
	 * @param float                $subtotal Cart subtotal.
	 * @return float Computed shipping cost (never negative).
	 */
	public function compute_cost( array $rule, float $weight, float $subtotal ): float {
		$free_above = (float) ( $rule['free_above'] ?? 0 );
		if ( $free_above > 0.0 && $subtotal >= $free_above ) {
			return 0.0;
		}

		$cost        = (float) ( $rule['base_cost'] ?? 0 );
		$per_kg_cost = (float) ( $rule['per_kg_cost'] ?? 0 );

		if ( $per_kg_cost > 0.0 ) {
			$step_kg = (float) ( $rule['step_kg'] ?? 0 );

			if ( $step_kg > 0.0 ) {
				// Round weight UP to the nearest step boundary.
				$billable = ceil( $weight / $step_kg ) * $step_kg;
			} else {
				// Continuous: use exact weight.
				$billable = $weight;
			}

			$cost += $per_kg_cost * $billable;
		}

		$max_cost = (float) ( $rule['max_cost'] ?? 0 );
		if ( $max_cost > 0.0 && $cost > $max_cost ) {
			$cost = $max_cost;
		}

		return max( 0.0, $cost );
	}

	/**
	 * Return the total weight of the package in store weight units (kg assumed).
	 *
	 * @param array<string, mixed> $package WC shipping package.
	 * @return float Total weight.
	 */
	private function get_package_weight( array $package ): float {
		$weight = 0.0;

		if ( ! isset( $package['contents'] ) || ! is_array( $package['contents'] ) ) {
			return $weight;
		}

		foreach ( $package['contents'] as $item ) {
			if ( ! isset( $item['data'] ) || ! ( $item['data'] instanceof \WC_Product ) ) {
				continue;
			}
			$item_weight = (float) $item['data']->get_weight();
			$qty         = (int) ( $item['quantity'] ?? 1 );
			$weight     += $item_weight * $qty;
		}

		return $weight;
	}

	/**
	 * Check whether at least one cart item in the package belongs to the given shipping class.
	 *
	 * @param array<string, mixed> $package   WC shipping package.
	 * @param string               $class_id  Shipping class term_id to match.
	 * @return bool True if any item carries that class.
	 */
	private function package_has_shipping_class( array $package, string $class_id ): bool {
		if ( ! isset( $package['contents'] ) || ! is_array( $package['contents'] ) ) {
			return false;
		}

		foreach ( $package['contents'] as $item ) {
			if ( ! isset( $item['data'] ) || ! ( $item['data'] instanceof \WC_Product ) ) {
				continue;
			}

			$product_class_id = (string) $item['data']->get_shipping_class_id();
			if ( $product_class_id === $class_id ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Render the admin settings UI (method title + rules table).
	 *
	 * Overrides WC_Shipping_Method::admin_options() to output the interactive
	 * rules table.  The table rows are serialised into a hidden JSON field by
	 * shipping-rules-admin.js on form submit.
	 */
	public function admin_options(): void {
		$rules_json = $this->get_instance_option( 'kramar_rules', '[]' );
		$rules      = json_decode( $rules_json, true );
		if ( ! is_array( $rules ) ) {
			$rules = [];
		}

		$terms = get_terms(
			[
				'taxonomy'   => 'product_shipping_class',
				'hide_empty' => false,
			]
		);
		$shipping_classes = is_array( $terms ) ? $terms : [];

		?>
		<h2><?php echo esc_html( $this->method_title ); ?></h2>
		<p><?php echo esc_html( $this->method_description ); ?></p>

		<table class="form-table" id="kramar-wbs-method-title-table">
			<tr>
				<th scope="row">
					<label for="woocommerce_kramar_weight_based_title">
						<?php esc_html_e( 'Method Title', 'kramar' ); ?>
					</label>
				</th>
				<td>
					<input
						type="text"
						name="woocommerce_kramar_weight_based_title"
						id="woocommerce_kramar_weight_based_title"
						value="<?php echo esc_attr( $this->title ); ?>"
						class="regular-text"
					/>
					<p class="description"><?php esc_html_e( 'Displayed to the customer at checkout.', 'kramar' ); ?></p>
				</td>
			</tr>
		</table>

		<hr />
		<h3><?php esc_html_e( 'Shipping Rules', 'kramar' ); ?></h3>
		<p class="description">
			<?php esc_html_e( 'Rules are evaluated top to bottom. Each matching rule applies its action: "Rate" adds a rate and continues; "Stop" adds the rate and stops; "Deny" removes this method entirely.', 'kramar' ); ?>
		</p>

		<?php if ( [] === $rules ) : ?>
			<div class="notice notice-warning inline" style="margin:12px 0;">
				<p>
					<strong><?php esc_html_e( 'No rules configured.', 'kramar' ); ?></strong>
					<?php esc_html_e( 'This method will not offer any shipping rate at checkout until you add at least one rule below. Add a catch-all rule (leave all conditions blank) to always offer a rate.', 'kramar' ); ?>
				</p>
			</div>
		<?php endif; ?>

		<input
			type="hidden"
			name="woocommerce_kramar_weight_based_kramar_rules"
			id="kramar-wbs-rules-json"
			value="<?php echo esc_attr( $rules_json ); ?>"
		/>

		<div id="kramar-wbs-rules-wrap">
			<div class="kramar-wbs-rules-scroll" style="overflow-x:auto;-webkit-overflow-scrolling:touch;max-width:100%;">
			<table class="widefat kramar-wbs-rules-table" id="kramar-wbs-rules-table">
				<thead>
					<tr>
						<th><?php esc_html_e( 'On', 'kramar' ); ?></th>
						<th><?php esc_html_e( 'Label', 'kramar' ); ?></th>
						<th><?php esc_html_e( 'Weight min (kg)', 'kramar' ); ?></th>
						<th><?php esc_html_e( 'Weight max (kg)', 'kramar' ); ?></th>
						<th><?php esc_html_e( 'Subtotal min', 'kramar' ); ?></th>
						<th><?php esc_html_e( 'Subtotal max', 'kramar' ); ?></th>
						<th><?php esc_html_e( 'Items min', 'kramar' ); ?></th>
						<th><?php esc_html_e( 'Items max', 'kramar' ); ?></th>
						<th><?php esc_html_e( 'Shipping Class', 'kramar' ); ?></th>
						<th><?php esc_html_e( 'Base cost', 'kramar' ); ?></th>
						<th><?php esc_html_e( 'Per kg', 'kramar' ); ?></th>
						<th><?php esc_html_e( 'Step kg', 'kramar' ); ?></th>
						<th><?php esc_html_e( 'Max cost', 'kramar' ); ?></th>
						<th><?php esc_html_e( 'Free above', 'kramar' ); ?></th>
						<th><?php esc_html_e( 'Action', 'kramar' ); ?></th>
						<th></th>
					</tr>
				</thead>
				<tbody id="kramar-wbs-rules-body">
					<?php foreach ( $rules as $rule ) : ?>
						<?php $this->render_rule_row( $rule, $shipping_classes ); ?>
					<?php endforeach; ?>
				</tbody>
			</table>
			</div>

			<p>
				<button type="button" id="kramar-wbs-add-rule" class="button">
					<?php esc_html_e( 'Add Rule', 'kramar' ); ?>
				</button>
			</p>
		</div>

		<?php
		// Hidden template row — cloned by JS when "Add Rule" is clicked.
		$empty_rule = [
			'enabled'        => true,
			'label'          => '',
			'weight_min'     => '',
			'weight_max'     => '',
			'subtotal_min'   => '',
			'subtotal_max'   => '',
			'items_min'      => '',
			'items_max'      => '',
			'shipping_class' => '',
			'base_cost'      => '0',
			'per_kg_cost'    => '0',
			'step_kg'        => '0',
			'max_cost'       => '0',
			'free_above'     => '0',
			'action'         => 'rate',
		];
		?>
		<table id="kramar-wbs-row-template" style="display:none;">
			<tbody>
				<?php $this->render_rule_row( $empty_rule, $shipping_classes ); ?>
			</tbody>
		</table>
		<?php
	}

	/**
	 * Render a single rule row for the admin table.
	 *
	 * @param array<string, mixed> $rule             Rule data.
	 * @param \WP_Term[]           $shipping_classes Available shipping class terms.
	 */
	private function render_rule_row( array $rule, array $shipping_classes ): void {
		$enabled   = ! empty( $rule['enabled'] );
		$sel_class = (string) ( $rule['shipping_class'] ?? '' );
		$sel_act   = (string) ( $rule['action'] ?? 'rate' );
		?>
		<tr class="kramar-wbs-rule-row">
			<td>
				<input
					type="checkbox"
					class="kramar-wbs-field"
					data-field="enabled"
					value="1"
					<?php checked( $enabled ); ?>
				/>
			</td>
			<td>
				<input
					type="text"
					class="kramar-wbs-field"
					data-field="label"
					value="<?php echo esc_attr( (string) ( $rule['label'] ?? '' ) ); ?>"
					placeholder="<?php esc_attr_e( 'Same as method title', 'kramar' ); ?>"
					style="width:90px;"
				/>
			</td>
			<td>
				<input
					type="number"
					class="kramar-wbs-field"
					data-field="weight_min"
					value="<?php echo esc_attr( (string) ( $rule['weight_min'] ?? '' ) ); ?>"
					min="0"
					step="0.001"
					style="width:70px;"
					placeholder="—"
				/>
			</td>
			<td>
				<input
					type="number"
					class="kramar-wbs-field"
					data-field="weight_max"
					value="<?php echo esc_attr( (string) ( $rule['weight_max'] ?? '' ) ); ?>"
					min="0"
					step="0.001"
					style="width:70px;"
					placeholder="∞"
				/>
			</td>
			<td>
				<input
					type="number"
					class="kramar-wbs-field"
					data-field="subtotal_min"
					value="<?php echo esc_attr( (string) ( $rule['subtotal_min'] ?? '' ) ); ?>"
					min="0"
					step="0.01"
					style="width:70px;"
					placeholder="—"
				/>
			</td>
			<td>
				<input
					type="number"
					class="kramar-wbs-field"
					data-field="subtotal_max"
					value="<?php echo esc_attr( (string) ( $rule['subtotal_max'] ?? '' ) ); ?>"
					min="0"
					step="0.01"
					style="width:70px;"
					placeholder="∞"
				/>
			</td>
			<td>
				<input
					type="number"
					class="kramar-wbs-field"
					data-field="items_min"
					value="<?php echo esc_attr( (string) ( $rule['items_min'] ?? '' ) ); ?>"
					min="0"
					step="1"
					style="width:60px;"
					placeholder="—"
				/>
			</td>
			<td>
				<input
					type="number"
					class="kramar-wbs-field"
					data-field="items_max"
					value="<?php echo esc_attr( (string) ( $rule['items_max'] ?? '' ) ); ?>"
					min="0"
					step="1"
					style="width:60px;"
					placeholder="∞"
				/>
			</td>
			<td>
				<select class="kramar-wbs-field" data-field="shipping_class">
					<option value="" <?php selected( $sel_class, '' ); ?>>
						<?php esc_html_e( '— All Classes —', 'kramar' ); ?>
					</option>
					<?php foreach ( $shipping_classes as $term ) : ?>
						<?php if ( $term instanceof \WP_Term ) : ?>
							<option
								value="<?php echo esc_attr( (string) $term->term_id ); ?>"
								<?php selected( $sel_class, (string) $term->term_id ); ?>
							>
								<?php echo esc_html( $term->name ); ?>
							</option>
						<?php endif; ?>
					<?php endforeach; ?>
				</select>
			</td>
			<td>
				<input
					type="number"
					class="kramar-wbs-field"
					data-field="base_cost"
					value="<?php echo esc_attr( (string) ( $rule['base_cost'] ?? '0' ) ); ?>"
					min="0"
					step="0.01"
					style="width:70px;"
				/>
			</td>
			<td>
				<input
					type="number"
					class="kramar-wbs-field"
					data-field="per_kg_cost"
					value="<?php echo esc_attr( (string) ( $rule['per_kg_cost'] ?? '0' ) ); ?>"
					min="0"
					step="0.01"
					style="width:70px;"
				/>
			</td>
			<td>
				<input
					type="number"
					class="kramar-wbs-field"
					data-field="step_kg"
					value="<?php echo esc_attr( (string) ( $rule['step_kg'] ?? '0' ) ); ?>"
					min="0"
					step="0.001"
					style="width:60px;"
					placeholder="0"
				/>
			</td>
			<td>
				<input
					type="number"
					class="kramar-wbs-field"
					data-field="max_cost"
					value="<?php echo esc_attr( (string) ( $rule['max_cost'] ?? '0' ) ); ?>"
					min="0"
					step="0.01"
					style="width:70px;"
					placeholder="0"
				/>
			</td>
			<td>
				<input
					type="number"
					class="kramar-wbs-field"
					data-field="free_above"
					value="<?php echo esc_attr( (string) ( $rule['free_above'] ?? '0' ) ); ?>"
					min="0"
					step="0.01"
					style="width:70px;"
					placeholder="0"
				/>
			</td>
			<td>
				<select class="kramar-wbs-field" data-field="action">
					<option value="rate" <?php selected( $sel_act, 'rate' ); ?>>
						<?php esc_html_e( 'Rate', 'kramar' ); ?>
					</option>
					<option value="stop" <?php selected( $sel_act, 'stop' ); ?>>
						<?php esc_html_e( 'Stop', 'kramar' ); ?>
					</option>
					<option value="deny" <?php selected( $sel_act, 'deny' ); ?>>
						<?php esc_html_e( 'Deny', 'kramar' ); ?>
					</option>
				</select>
			</td>
			<td>
				<button type="button" class="button-link kramar-wbs-delete-row" style="color:#b32d2e;">
					<?php esc_html_e( 'Delete', 'kramar' ); ?>
				</button>
			</td>
		</tr>
		<?php
	}

	/**
	 * Save and sanitize instance settings.
	 *
	 * Intercepts the `kramar_rules` JSON field from $_POST, sanitizes every
	 * field of every rule, then lets WC's parent save routine store them.
	 *
	 * @return bool
	 */
	public function process_admin_options(): bool {
		// Belt-and-suspenders: WC's settings save flow already verifies its nonce and
		// this screen is gated by the manage_woocommerce capability, but assert the
		// capability explicitly here rather than relying solely on the caller.
		if ( ! current_user_can( 'manage_woocommerce' ) ) {
			return false;
		}

		// Sanitize the rules JSON posted from the hidden input.
		// Nonce is verified by WC's settings save flow before process_admin_options() is called.
		// Each rule field is individually sanitized in the loop below.
		$raw_json = isset( $_POST['woocommerce_kramar_weight_based_kramar_rules'] ) // phpcs:ignore WordPress.Security.NonceVerification.Missing
			? (string) wp_unslash( $_POST['woocommerce_kramar_weight_based_kramar_rules'] ) // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
			: '[]';

		if ( ! is_string( $raw_json ) ) {
			$raw_json = '[]';
		}

		$rules = json_decode( $raw_json, true );
		if ( ! is_array( $rules ) ) {
			$rules = [];
		}

		$sanitized_rules = [];
		foreach ( $rules as $rule ) {
			if ( ! is_array( $rule ) ) {
				continue;
			}

			$sanitized_rules[] = [
				'enabled'        => ! empty( $rule['enabled'] ),
				'label'          => sanitize_text_field( (string) ( $rule['label'] ?? '' ) ),
				'weight_min'     => '' === (string) ( $rule['weight_min'] ?? '' ) ? '' : (string) abs( (float) $rule['weight_min'] ),
				'weight_max'     => '' === (string) ( $rule['weight_max'] ?? '' ) ? '' : (string) abs( (float) $rule['weight_max'] ),
				'subtotal_min'   => '' === (string) ( $rule['subtotal_min'] ?? '' ) ? '' : (string) abs( (float) $rule['subtotal_min'] ),
				'subtotal_max'   => '' === (string) ( $rule['subtotal_max'] ?? '' ) ? '' : (string) abs( (float) $rule['subtotal_max'] ),
				'items_min'      => '' === (string) ( $rule['items_min'] ?? '' ) ? '' : (string) absint( $rule['items_min'] ),
				'items_max'      => '' === (string) ( $rule['items_max'] ?? '' ) ? '' : (string) absint( $rule['items_max'] ),
				'shipping_class' => sanitize_key( (string) ( $rule['shipping_class'] ?? '' ) ),
				'base_cost'      => (string) abs( (float) ( $rule['base_cost'] ?? 0 ) ),
				'per_kg_cost'    => (string) abs( (float) ( $rule['per_kg_cost'] ?? 0 ) ),
				'step_kg'        => (string) abs( (float) ( $rule['step_kg'] ?? 0 ) ),
				'max_cost'       => (string) abs( (float) ( $rule['max_cost'] ?? 0 ) ),
				'free_above'     => (string) abs( (float) ( $rule['free_above'] ?? 0 ) ),
				'action'         => in_array( $rule['action'] ?? '', [ 'rate', 'stop', 'deny' ], true )
					? (string) $rule['action']
					: 'rate',
			];
		}

		// Inject sanitized rules back into $_POST so WC's parent save picks them up.
		// phpcs:ignore WordPress.Security.NonceVerification.Missing
		$_POST['woocommerce_kramar_weight_based_kramar_rules'] = wp_json_encode( $sanitized_rules );

		// Purge shipping transient cache so rates recalculate immediately.
		\WC_Cache_Helper::get_transient_version( 'shipping', true );

		return parent::process_admin_options();
	}
}
