<?php
/**
 * Order closing configuration and calculations.
 *
 * @package Logistica
 */

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

if ( ! class_exists( 'Logistica_Warehouse_Inventory', false ) && defined( 'LOGISTICA_PLUGIN_DIR' ) ) {
	require_once LOGISTICA_PLUGIN_DIR . 'includes/class-logistica-warehouse-inventory.php';
}

/**
 * Stores the configurable order-closing model.
 */
class Logistica_Order_Closing {
	const OPTION = 'logistica_order_closing_config';

	/**
	 * Bump when reference block matrices and permanent bases are migrated.
	 */
	const BLOCKS_SCHEMA_VERSION = 4;

	/**
	 * Standard variant labels (NAC, EXP, BIO).
	 *
	 * @return string[]
	 */
	public static function standard_variant_options() {
		if ( class_exists( 'Logistica_Select_Catalog', false ) ) {
			$options = Logistica_Select_Catalog::get_options( 'closing_variants' );
			if ( is_array( $options ) && ! empty( $options ) ) {
				return array_values( $options );
			}
		}

		return array( 'Nacional', 'Exportación', 'Bionatura', 'Estándar' );
	}

	/**
	 * Standard product type labels for the closing catalog.
	 *
	 * @return string[]
	 */
	public static function standard_product_type_options() {
		if ( class_exists( 'Logistica_Select_Catalog', false ) ) {
			$options = Logistica_Select_Catalog::get_options( 'closing_product_types' );
			if ( is_array( $options ) && ! empty( $options ) ) {
				return array_values( $options );
			}
		}

		return array( 'Vaso', 'Tazon/Bowl', 'Tapa', 'Collarin', 'Acetato', 'Otro' );
	}

	/**
	 * Nombres de referencia del catálogo (Configuraciones).
	 *
	 * @return string[]
	 */
	public static function standard_reference_name_options() {
		if ( class_exists( 'Logistica_Select_Catalog', false ) ) {
			$options = Logistica_Select_Catalog::get_options( 'closing_references' );
			if ( is_array( $options ) && ! empty( $options ) ) {
				return array_values( $options );
			}
		}

		return self::catalog_reference_name_defaults();
	}

	/**
	 * Nombres por defecto de referencias (seed del catálogo de productos).
	 *
	 * @return string[]
	 */
	public static function catalog_reference_name_defaults() {
		$names = array();

		foreach ( self::catalog_product_definitions() as $product ) {
			$nombre = trim( (string) ( $product['nombre'] ?? '' ) );
			if ( '' !== $nombre ) {
				$names[ strtoupper( $nombre ) ] = $nombre;
			}
		}

		return array_values( $names );
	}

	/**
	 * Propaga un rename del catálogo de selects al config de cierre (Operaciones).
	 *
	 * @param string $catalog_id closing_references|closing_variants|closing_product_types.
	 * @param string $old_label  Valor anterior.
	 * @param string $new_label  Valor nuevo.
	 * @return int Cantidad de campos actualizados.
	 */
	public static function propagate_catalog_rename( $catalog_id, $old_label, $new_label ) {
		$catalog_id = sanitize_key( (string) $catalog_id );
		$old_label  = trim( (string) $old_label );
		$new_label  = trim( (string) $new_label );

		if ( '' === $old_label || '' === $new_label || strtolower( $old_label ) === strtolower( $new_label ) ) {
			return 0;
		}

		$config = get_option( self::OPTION, array() );
		if ( ! is_array( $config ) || empty( $config ) ) {
			return 0;
		}

		$changed = 0;
		$old_l   = strtolower( $old_label );

		if ( 'closing_references' === $catalog_id ) {
			foreach ( $config['referencias'] as $index => $reference ) {
				if ( ! is_array( $reference ) ) {
					continue;
				}
				if ( strtolower( trim( (string) ( $reference['nombre'] ?? '' ) ) ) === $old_l ) {
					$config['referencias'][ $index ]['nombre'] = $new_label;
					++$changed;
				}
				if ( strtolower( trim( (string) ( $reference['formula_template'] ?? '' ) ) ) === $old_l ) {
					$config['referencias'][ $index ]['formula_template'] = $new_label;
					++$changed;
				}
			}
			foreach ( $config['bases'] as $index => $base ) {
				if ( ! is_array( $base ) ) {
					continue;
				}
				if ( strtolower( trim( (string) ( $base['referencia'] ?? '' ) ) ) === $old_l ) {
					$config['bases'][ $index ]['referencia'] = $new_label;
					++$changed;
				}
			}
		} elseif ( 'closing_variants' === $catalog_id ) {
			foreach ( $config['referencias'] as $index => $reference ) {
				if ( ! is_array( $reference ) ) {
					continue;
				}
				if ( strtolower( trim( (string) ( $reference['variante'] ?? '' ) ) ) === $old_l ) {
					$config['referencias'][ $index ]['variante'] = $new_label;
					++$changed;
				}
			}
			foreach ( $config['bases'] as $index => $base ) {
				if ( ! is_array( $base ) ) {
					continue;
				}
				if ( strtolower( trim( (string) ( $base['variante'] ?? '' ) ) ) === $old_l ) {
					$config['bases'][ $index ]['variante'] = $new_label;
					++$changed;
				}
			}
		} elseif ( 'closing_product_types' === $catalog_id ) {
			foreach ( $config['referencias'] as $index => $reference ) {
				if ( ! is_array( $reference ) ) {
					continue;
				}
				if ( strtolower( trim( (string) ( $reference['tipo_producto'] ?? '' ) ) ) === $old_l ) {
					$config['referencias'][ $index ]['tipo_producto'] = $new_label;
					++$changed;
				}
			}
		} else {
			return 0;
		}

		if ( $changed > 0 ) {
			update_option( self::OPTION, $config, false );
		}

		return $changed;
	}

	/**
	 * Remapea nombres de referencia huérfanos cuando el catálogo ya tiene un rename
	 * que extiende el nombre anterior (ej. ACETATO → ACETATO 6.5 X 69 CM).
	 *
	 * @param array $config Config de cierre.
	 * @return array{config: array, changed: int}
	 */
	public static function remap_orphan_reference_names( $config ) {
		if ( ! is_array( $config ) ) {
			return array(
				'config'  => array(),
				'changed' => 0,
			);
		}

		$catalog = self::standard_reference_name_options();
		if ( empty( $catalog ) ) {
			return array(
				'config'  => $config,
				'changed' => 0,
			);
		}

		$catalog_lower = array();
		foreach ( $catalog as $option ) {
			$catalog_lower[ strtolower( trim( (string) $option ) ) ] = (string) $option;
		}

		$changed = 0;
		$renames = array();

		foreach ( $config['referencias'] ?? array() as $index => $reference ) {
			if ( ! is_array( $reference ) ) {
				continue;
			}

			$nombre = trim( (string) ( $reference['nombre'] ?? '' ) );
			if ( '' === $nombre ) {
				continue;
			}

			$nombre_l = strtolower( $nombre );
			if ( isset( $catalog_lower[ $nombre_l ] ) ) {
				continue;
			}

			$candidates = array();
			foreach ( $catalog as $option ) {
				$option = trim( (string) $option );
				if ( 0 === stripos( $option, $nombre ) && strlen( $option ) > strlen( $nombre ) ) {
					$next = substr( $option, strlen( $nombre ), 1 );
					if ( '' === $next || ctype_space( $next ) || '-' === $next || '/' === $next ) {
						$candidates[] = $option;
					}
				}
			}

			$candidates = array_values( array_unique( $candidates ) );
			if ( 1 !== count( $candidates ) ) {
				continue;
			}

			$new_name                        = $candidates[0];
			$renames[ $nombre_l ]            = $new_name;
			$config['referencias'][ $index ]['nombre'] = $new_name;
			++$changed;

			if ( strtolower( trim( (string) ( $reference['formula_template'] ?? '' ) ) ) === $nombre_l ) {
				$config['referencias'][ $index ]['formula_template'] = $new_name;
				++$changed;
			}
		}

		if ( ! empty( $renames ) ) {
			foreach ( $config['bases'] ?? array() as $index => $base ) {
				if ( ! is_array( $base ) ) {
					continue;
				}
				$ref_l = strtolower( trim( (string) ( $base['referencia'] ?? '' ) ) );
				if ( isset( $renames[ $ref_l ] ) ) {
					$config['bases'][ $index ]['referencia'] = $renames[ $ref_l ];
					++$changed;
				}
			}
		}

		return array(
			'config'  => $config,
			'changed' => $changed,
		);
	}

	/**
	 * Whether a product name belongs to the active variant scope (Tazon=Nacional, Bowl=Exportación).
	 *
	 * @param string $nombre        Product name.
	 * @param string $variant_filter Active variant filter or empty.
	 * @return bool
	 */
	public static function reference_matches_variant_scope( $nombre, $variant_filter = '' ) {
		$nombre  = strtoupper( trim( (string) $nombre ) );
		$variant = strtolower( trim( (string) $variant_filter ) );

		if ( '' === $variant ) {
			return true;
		}

		if ( 'exportacion' === $variant && 0 === strpos( $nombre, 'TAZ' ) ) {
			return false;
		}

		if ( 'nacional' === $variant && 0 === strpos( $nombre, 'BOWL' ) ) {
			return false;
		}

		return true;
	}

	/**
	 * Reference filter options grouped by product type (unique product names).
	 *
	 * @param array  $config Closing config.
	 * @param string $variant_filter Optional sidebar variant filter.
	 * @return array<string, string[]>
	 */
	public static function reference_filter_groups( $config, $variant_filter = '' ) {
		$groups = array();

		foreach ( $config['referencias'] ?? array() as $reference ) {
			$nombre = trim( (string) ( $reference['nombre'] ?? '' ) );
			$tipo   = trim( (string) ( $reference['tipo_producto'] ?? 'Otro' ) );

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

			if ( ! self::reference_matches_variant_scope( $nombre, $variant_filter ) ) {
				continue;
			}

			if ( in_array( $tipo, array( 'Tazon', 'Bowl' ), true ) ) {
				$tipo = 'Tazon/Bowl';
			}

			if ( '' === $tipo ) {
				$tipo = 'Otro';
			}

			if ( ! isset( $groups[ $tipo ] ) ) {
				$groups[ $tipo ] = array();
			}

			if ( ! in_array( $nombre, $groups[ $tipo ], true ) ) {
				$groups[ $tipo ][] = $nombre;
			}
		}

		ksort( $groups, SORT_NATURAL | SORT_FLAG_CASE );

		foreach ( $groups as &$names ) {
			sort( $names, SORT_NATURAL | SORT_FLAG_CASE );
		}
		unset( $names );

		return $groups;
	}

	/**
	 * Normalizes legacy names that included NAC/EXP/BIO in the title.
	 *
	 * @param string $nombre Reference name.
	 * @return array{nombre:string,variante:string}
	 */
	private static function parse_legacy_reference_name( $nombre ) {
		$working  = trim( (string) $nombre );
		$variante = 'Estándar';

		if ( preg_match( '/\s+BIO\s+EXP$/iu', $working ) ) {
			$working  = preg_replace( '/\s+BIO\s+EXP$/iu', ' BIO', $working );
			$variante = 'Bionatura';
		} elseif ( preg_match( '/\s+NAC$/iu', $working ) ) {
			$working  = preg_replace( '/\s+NAC$/iu', '', $working );
			$variante = 'Nacional';
		} elseif ( preg_match( '/\s+EXP$/iu', $working ) ) {
			$working  = preg_replace( '/\s+EXP$/iu', '', $working );
			$variante = 'Exportación';
		} elseif ( preg_match( '/\bBIO\b/i', $working ) ) {
			$variante = 'Bionatura';
		}

		$working = self::normalize_tapa_nombre( $working );
		$working = self::normalize_vaso_14oz_nombre( $working );

		return array(
			'nombre'   => trim( $working ),
			'variante' => $variante,
		);
	}

	/**
	 * Vaso 14 OZ family names with export size label in parentheses.
	 *
	 * @return array<string, string>
	 */
	private static function vaso_14oz_exp_suffix_names() {
		return array(
			'VASO 14 OZ'     => 'VASO 14 OZ (12 OZ EXP)',
			'VASO 14 OZ BIO' => 'VASO 14 OZ BIO (12 OZ EXP)',
			'VASO 14 OZ DP'  => 'VASO 14 OZ DP (12 OZ EXP)',
		);
	}

	/**
	 * Adds (12 OZ EXP) label to 14 OZ vaso references when missing.
	 *
	 * @param string $nombre Product name.
	 * @return string
	 */
	private static function normalize_vaso_14oz_nombre( $nombre ) {
		$trimmed = trim( (string) $nombre );

		foreach ( self::vaso_14oz_exp_suffix_names() as $legacy => $standard ) {
			if ( strtoupper( $trimmed ) === strtoupper( $legacy ) ) {
				return $standard;
			}
		}

		return $trimmed;
	}

	/**
	 * Millimeter lid size to compatible cup size in ounces.
	 *
	 * @return array<string, string>
	 */
	private static function tapa_oz_by_mm() {
		return array(
			'62' => '4 OZ',
			'73' => '7 OZ',
			'80' => '8-9-12 OZ',
			'90' => '10-12-14-16 OZ EXP',
		);
	}

	/**
	 * Builds the standard tapa label: TAPA {mm} MM ({oz}).
	 *
	 * @param string $mm Lid diameter in mm.
	 * @return string
	 */
	private static function tapa_standard_name( $mm ) {
		$oz = self::tapa_oz_by_mm()[ $mm ] ?? '';

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

		return 'TAPA ' . $mm . ' MM (' . $oz . ')';
	}

	/**
	 * Standardized tapa names including mm and oz.
	 *
	 * @return string[]
	 */
	private static function tapa_catalog_names() {
		$names = array();

		foreach ( array_keys( self::tapa_oz_by_mm() ) as $mm ) {
			$names[] = self::tapa_standard_name( $mm );
		}

		return $names;
	}

	/**
	 * Renames legacy tapa references to TAPA {mm} MM ({oz}).
	 *
	 * @param string $nombre Product name.
	 * @return string
	 */
	private static function normalize_tapa_nombre( $nombre ) {
		$trimmed = trim( (string) $nombre );

		if ( ! preg_match( '/^TAPA\s+/iu', $trimmed ) ) {
			return $trimmed;
		}

		foreach ( array_keys( self::tapa_oz_by_mm() ) as $mm ) {
			if ( preg_match( '/^TAPA\s+' . preg_quote( $mm, '/' ) . '\s+MM\b/iu', $trimmed ) ) {
				return self::tapa_standard_name( $mm );
			}
		}

		return $trimmed;
	}

	/**
	 * Extracts pack/unit template from product name (without variant suffix).
	 *
	 * @param string $nombre Reference name.
	 * @return string
	 */
	private static function template_from_nombre( $nombre ) {
		$working = trim( (string) $nombre );
		$working = preg_replace( '/^(VASO|TAZ[ÓO]N|BOWL|TAPA|COLLAR[ÍI]N)\s+/iu', '', $working );

		return trim( $working );
	}

	/**
	 * Catalog key for nombre + variante.
	 *
	 * @param string $nombre   Product name.
	 * @param string $variante Variant label.
	 * @return string
	 */
	private static function reference_catalog_key( $nombre, $variante ) {
		return self::normalize_match_key( $nombre ) . '|' . self::normalize_match_key( self::canonicalize_base_variante( $variante ) );
	}

	/**
	 * Builds one reference row (product + variant).
	 *
	 * @param string $nombre        Product name without NAC/EXP suffix.
	 * @param string $tipo_producto Product type.
	 * @param string $variante      Nacional, Exportación, Bionatura or Estándar.
	 * @return array
	 */
	private static function build_reference_entry( $nombre, $tipo_producto, $variante ) {
		$template = self::template_from_nombre( $nombre );
		$lineas   = self::default_lines( '', '', '', '', '', '' );

		if ( 'Vaso' === $tipo_producto ) {
			if ( self::is_vaso_doble_pared_name( $nombre ) ) {
				$lineas = self::standard_vaso_doble_pared_block_lines();
			} else {
				$lineas = self::standard_vaso_block_lines();
			}
		} elseif ( self::is_tazon_bowl_product_type( $tipo_producto ) ) {
			$lineas = self::standard_vaso_block_lines();
		}

		return array(
			'id'               => sanitize_title( $nombre . '-' . $variante ),
			'nombre'           => $nombre,
			'variante'         => $variante,
			'tipo_producto'    => $tipo_producto,
			'unidad_empaque'   => $template,
			'unidades'         => '',
			'formula_template' => $template,
			'lineas'           => $lineas,
		);
	}

	/**
	 * Whether the product name is a double-wall (DP) vaso.
	 *
	 * @param string $nombre Reference name.
	 * @return bool
	 */
	private static function is_vaso_doble_pared_name( $nombre ) {
		return (bool) preg_match( '/\bDP\b/i', strtoupper( trim( (string) $nombre ) ) );
	}

	/**
	 * Whether a reference is a vaso row (standard or double-wall).
	 *
	 * @param array $reference Reference row.
	 * @return bool
	 */
	private static function is_vaso_reference( $reference ) {
		$tipo   = trim( (string) ( $reference['tipo_producto'] ?? '' ) );
		$nombre = strtoupper( trim( (string) ( $reference['nombre'] ?? '' ) ) );

		return 'Vaso' === $tipo && 0 === strpos( $nombre, 'VASO' );
	}

	/**
	 * Whether a reference should use the four standard vaso calculation blocks.
	 *
	 * @param array $reference Reference row.
	 * @return bool
	 */
	private static function applies_standard_vaso_blocks( $reference ) {
		return self::is_vaso_reference( $reference ) && ! self::is_vaso_doble_pared_name( $reference['nombre'] ?? '' );
	}

	/**
	 * Whether a double-wall vaso should use caja, saldo and bolsa only (no rollo fondo).
	 *
	 * @param array $reference Reference row.
	 * @return bool
	 */
	private static function applies_vaso_doble_pared_blocks( $reference ) {
		return self::is_vaso_reference( $reference ) && self::is_vaso_doble_pared_name( $reference['nombre'] ?? '' );
	}

	/**
	 * @param string $tipo_producto Product type label.
	 * @return bool
	 */
	private static function is_tazon_bowl_product_type( $tipo_producto ) {
		return in_array( trim( (string) $tipo_producto ), array( 'Tazon', 'Bowl', 'Tazon/Bowl' ), true );
	}

	/**
	 * Whether a reference is a tazon/bowl row with the standard packaging blocks.
	 *
	 * @param array $reference Reference row.
	 * @return bool
	 */
	private static function applies_tazon_bowl_blocks( $reference ) {
		if ( self::is_tazon_bowl_product_type( $reference['tipo_producto'] ?? '' ) ) {
			return true;
		}

		$nombre = strtoupper( trim( (string) ( $reference['nombre'] ?? '' ) ) );

		return 0 === strpos( $nombre, 'TAZ' ) || 0 === strpos( $nombre, 'BOWL' );
	}

	/**
	 * Applies the correct block matrix for a catalog reference.
	 *
	 * @param array $reference Reference row.
	 * @return array<int, array<string, string>>
	 */
	private static function normalize_reference_block_lines( $reference ) {
		if ( self::applies_vaso_doble_pared_blocks( $reference ) ) {
			return self::normalize_vaso_reference_lines( $reference, false );
		}

		if ( self::applies_standard_vaso_blocks( $reference ) || self::applies_tazon_bowl_blocks( $reference ) ) {
			return self::normalize_vaso_reference_lines( $reference, true );
		}

		return self::normalize_generic_reference_lines( $reference );
	}

	/**
	 * Sorts lines and drops deprecated rows for tapas, collarines, etc.
	 *
	 * @param array $reference Reference row.
	 * @return array<int, array<string, string>>
	 */
	private static function normalize_generic_reference_lines( $reference ) {
		$lineas = array();

		foreach ( $reference['lineas'] ?? array() as $line ) {
			if ( ! is_array( $line ) ) {
				continue;
			}

			$tipo      = sanitize_key( $line['tipo'] ?? '' );
			$base_tipo = sanitize_key( $line['base_tipo'] ?? '' );

			if ( 'caja_mediana' === $tipo ) {
				continue;
			}

			$line['formula']        = self::default_formula_for_line( $tipo, $base_tipo );
			$line['estado_formula'] = '' !== $line['formula'] ? 'formulada' : 'pendiente';
			$lineas[]               = $line;
		}

		return self::sort_reference_lines( $lineas );
	}

	/**
	 * Canonical vaso block types.
	 *
	 * @param bool $include_rollos Include rollo fondo block.
	 * @return array<string, array{label:string, base_tipo:string}>
	 */
	private static function canonical_vaso_line_definitions( $include_rollos = true ) {
		$definitions = array(
			'cajas'       => array(
				'label'     => 'Cajas',
				'base_tipo' => 'cajas',
			),
			'caja_saldos' => array(
				'label'     => 'Cajas saldo',
				'base_tipo' => 'cajas_saldo',
			),
			'bolsas'      => array(
				'label'     => 'Bolsas',
				'base_tipo' => 'bolsas',
			),
		);

		if ( $include_rollos ) {
			$definitions['rollos'] = array(
				'label'     => 'Rollo fondo',
				'base_tipo' => 'rollos',
			);
		}

		return $definitions;
	}

	/**
	 * Builds default line rows from canonical vaso definitions.
	 *
	 * @param bool $include_rollos Include rollo fondo block.
	 * @return array<int, array<string, string>>
	 */
	private static function build_standard_vaso_block_lines( $include_rollos = true ) {
		$lineas = array();

		foreach ( self::canonical_vaso_line_definitions( $include_rollos ) as $tipo => $defaults ) {
			$line = array(
				'tipo'             => $tipo,
				'label'            => $defaults['label'],
				'cantidad'         => '',
				'paquetes'         => 'cajas' === $tipo ? '1' : '',
				'unidades_paquete' => '',
				'base_tipo'        => $defaults['base_tipo'],
				'base_link_id'     => '',
				'formula'          => self::default_formula_for_line( $tipo, $defaults['base_tipo'] ),
				'estado_formula'   => '',
			);

			$line['estado_formula'] = '' !== $line['formula'] ? 'formulada' : 'pendiente';
			$lineas[]               = $line;
		}

		return self::sort_reference_lines( $lineas );
	}

	/**
	 * Default four-block matrix for standard vasos (not double-wall).
	 *
	 * @return array<int, array<string, string>>
	 */
	public static function standard_vaso_block_lines() {
		return self::build_standard_vaso_block_lines( true );
	}

	/**
	 * Default blocks for double-wall vasos (sin rollo fondo).
	 *
	 * @return array<int, array<string, string>>
	 */
	public static function standard_vaso_doble_pared_block_lines() {
		return self::build_standard_vaso_block_lines( false );
	}

	/**
	 * Ensures a vaso reference has the expected standard blocks, keeping saved values.
	 *
	 * @param array $reference      Reference row.
	 * @param bool  $include_rollos Include rollo fondo block.
	 * @return array<int, array<string, string>>
	 */
	private static function normalize_vaso_reference_lines( $reference, $include_rollos = true ) {
		$existing_by_tipo = array();

		foreach ( $reference['lineas'] ?? array() as $line ) {
			if ( ! is_array( $line ) ) {
				continue;
			}

			$tipo = sanitize_key( $line['tipo'] ?? '' );

			if ( $tipo && ! isset( $existing_by_tipo[ $tipo ] ) ) {
				$existing_by_tipo[ $tipo ] = $line;
			}
		}

		$lineas = array();

		foreach ( self::canonical_vaso_line_definitions( $include_rollos ) as $tipo => $defaults ) {
			$line = isset( $existing_by_tipo[ $tipo ] ) ? $existing_by_tipo[ $tipo ] : array();

			$line['tipo']           = $tipo;
			$line['label']          = $defaults['label'];
			$line['base_tipo']      = $defaults['base_tipo'];
			$line['formula']        = self::default_formula_for_line( $tipo, $defaults['base_tipo'] );
			$line['estado_formula'] = '' !== $line['formula'] ? 'formulada' : 'pendiente';

			if ( 'cajas' === $tipo && '' === trim( (string) ( $line['paquetes'] ?? '' ) ) ) {
				$line['paquetes'] = '1';
			}

			$lineas[] = $line;
		}

		return self::sort_reference_lines( $lineas );
	}

	/**
	 * Product catalog: one name per product, multiple variants where applicable.
	 *
	 * NAC = Nacional, EXP = Exportación (variant selector, not part of nombre).
	 *
	 * @return array<int, array{nombre:string,tipo:string,variants:string[]}>
	 */
	private static function catalog_product_definitions() {
		$products   = array();
		$nac_exp    = array( 'Nacional', 'Exportación' );
		$nac_only   = array( 'Nacional' );
		$exp_only   = array( 'Exportación' );
		$bio_only   = array( 'Bionatura' );
		$estandar   = array( 'Estándar' );
		$add_many   = function ( array $nombres, $tipo, array $variants ) use ( &$products ) {
			foreach ( $nombres as $nombre ) {
				$products[] = array(
					'nombre'   => $nombre,
					'tipo'     => $tipo,
					'variants' => $variants,
				);
			}
		};

		$add_many(
				array(
				'VASO 4 OZ',
				'VASO 6 OZ',
				'VASO 7 OZ',
				'VASO 8 OZ',
				'VASO 12 OZ',
				'VASO 12 OZ BOCA 80 MM',
				'VASO 14 OZ (12 OZ EXP)',
				'VASO 16 OZ',
				'VASO 22 OZ',
			),
			'Vaso',
			$nac_exp
		);

		$add_many( array( 'VASO 9 OZ' ), 'Vaso', $nac_exp );

		$add_many(
				array(
				'VASO 7 OZ DP',
				'VASO 9 OZ DP',
				'VASO 10 OZ DP',
				'VASO 12 OZ DP',
				'VASO 14 OZ DP (12 OZ EXP)',
				'VASO 16 OZ DP',
			),
			'Vaso',
			$nac_exp
		);

		$add_many( array( 'VASO 14 OZ BIO (12 OZ EXP)' ), 'Vaso', $bio_only );

		// Tazones: variante Nacional. Bowls: variante Exportación (tipo Tazon/Bowl).
		$add_many(
			array(
				'TAZÓN 5-6 OZ',
				'TAZÓN 8 OZ',
				'TAZÓN 12 OZ',
				'TAZÓN 16 OZ',
			),
			'Tazon/Bowl',
			$nac_only
		);

		$add_many(
			array(
				'BOWL 5-6 OZ',
				'BOWL 8 OZ',
				'BOWL 12 OZ',
				'BOWL 16 OZ',
			),
			'Tazon/Bowl',
			$exp_only
		);

		$add_many( self::tapa_catalog_names(), 'Tapa', $estandar );

		$add_many(
			array(
				'COLLARÍN AJUSTABLE',
				'COLLARÍN CON PEGUE',
			),
			'Collarin',
			$estandar
		);

		$add_many(
			array(
				'ACETATO',
				'CHAROL (BANDEJA)',
				'CAJA PORTACOMIDA INDIVIDUAL',
				'CAJA PORTACOMIDA GRANDE',
				'ANTIGRASO 30 X 30 CM',
				'ANTIGRASO 50 X 35 CM',
			),
			'Otro',
			$estandar
		);

		return $products;
	}

	/**
	 * Full reference catalog: one product name, one row per variant.
	 *
	 * @return array<int, array>
	 */
	public static function reference_catalog_entries() {
		$entries = array();

		foreach ( self::catalog_product_definitions() as $product ) {
			foreach ( $product['variants'] as $variante ) {
				$entries[] = self::build_reference_entry( $product['nombre'], $product['tipo'], $variante );
			}
		}

		return $entries;
	}

	/**
	 * True si $nombre quedó reemplazado por un rename más largo en la lista.
	 * Ej.: ACETATO queda supersedido por ACETATO 6.5 X 69 CM.
	 *
	 * @param string   $nombre Nombre candidato.
	 * @param string[] $others Otros nombres (catálogo o referencias).
	 * @return bool
	 */
	private static function is_reference_name_superseded( $nombre, array $others ) {
		$nombre = trim( (string) $nombre );
		if ( '' === $nombre ) {
			return false;
		}

		$nombre_len = strlen( $nombre );

		foreach ( $others as $other ) {
			$other = trim( (string) $other );
			if ( '' === $other || strtolower( $other ) === strtolower( $nombre ) ) {
				continue;
			}

			if ( 0 !== stripos( $other, $nombre ) || strlen( $other ) <= $nombre_len ) {
				continue;
			}

			$next = substr( $other, $nombre_len, 1 );
			if ( '' === $next || ctype_space( $next ) || '-' === $next || '/' === $next ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Adds catalog references missing from a saved config.
	 *
	 * @param array $config Current config.
	 * @return array
	 */
	private static function merge_reference_catalog( $config ) {
		if ( ! is_array( $config ) ) {
			$config = array();
		}

		if ( empty( $config['bases'] ) || ! is_array( $config['bases'] ) ) {
			$config['bases'] = array();
		}

		if ( empty( $config['referencias'] ) || ! is_array( $config['referencias'] ) ) {
			$config['referencias'] = array();
		}

		$select_names = self::standard_reference_name_options();
		$select_lower = array();
		$use_select   = ! empty( $select_names );
		foreach ( $select_names as $select_name ) {
			$select_lower[ strtolower( trim( (string) $select_name ) ) ] = trim( (string) $select_name );
		}

		$normalized = array();
		$existing   = array();

		foreach ( $config['referencias'] as $reference ) {
			$parsed   = self::parse_legacy_reference_name( $reference['nombre'] ?? '' );
			$nombre   = $parsed['nombre'];
			$variante = self::canonicalize_base_variante( trim( (string) ( $reference['variante'] ?? '' ) ) );

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

			if ( '' === $variante || 'Estándar' === $variante ) {
				$variante = self::canonicalize_base_variante( $parsed['variante'] );
			}

			// Nombre viejo tras rename en Configuraciones: no conservar el duplicado.
			if ( $use_select && ! isset( $select_lower[ strtolower( $nombre ) ] )
				&& self::is_reference_name_superseded( $nombre, $select_names ) ) {
				continue;
			}

			$template = self::template_from_nombre( $nombre );
			$key      = self::reference_catalog_key( $nombre, $variante );

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

			$tipo_producto = trim( (string) ( $reference['tipo_producto'] ?? '' ) );

			if ( in_array( $tipo_producto, array( 'Tazon', 'Bowl' ), true ) ) {
				$tipo_producto = 'Tazon/Bowl';
			}

			$nombre_upper = strtoupper( $nombre );

			if ( 'Vaso' === $tipo_producto && ( 0 === strpos( $nombre_upper, 'TAZ' ) || 0 === strpos( $nombre_upper, 'BOWL' ) ) ) {
				$tipo_producto = 'Tazon/Bowl';
			}

			$reference['nombre']           = $nombre;
			$reference['variante']         = $variante;
			$reference['tipo_producto']    = $tipo_producto;
			$reference['unidad_empaque']   = $template;
			$reference['formula_template'] = $template;

			$reference['lineas'] = self::normalize_reference_block_lines( $reference );

			$normalized[]     = $reference;
			$existing[ $key ] = true;
		}

		$config['referencias'] = $normalized;

		$config = self::migrate_separate_caja_saldo_bases( $config );

		foreach ( self::reference_catalog_entries() as $entry ) {
			$key = self::reference_catalog_key( $entry['nombre'], $entry['variante'] );

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

			// Configuraciones manda: no reinyectar nombres renombrados/eliminados (ej. ACETATO).
			if ( $use_select && ! isset( $select_lower[ strtolower( (string) ( $entry['nombre'] ?? '' ) ) ] ) ) {
				continue;
			}

			$config['referencias'][] = $entry;
			$existing[ $key ]        = true;
		}

		$config['blocks_schema_version'] = self::BLOCKS_SCHEMA_VERSION;

		return self::merge_default_bases( $config );
	}

	/**
	 * Stable key for a permanent base row.
	 *
	 * @param array $base Base row.
	 * @return string
	 */
	private static function base_catalog_key( $base ) {
		$variante   = self::canonicalize_base_variante( $base['variante'] ?? '' );
		$tipo       = sanitize_key( (string) ( $base['tipo'] ?? '' ) );
		$referencia = self::canonicalize_base_referencia( $base['referencia'] ?? '' );

		return self::normalize_match_key( $variante ) . '|' . $tipo . '|' . self::normalize_match_key( $referencia );
	}

	/**
	 * Accent-insensitive lowercase key for catalog matching.
	 *
	 * @param string $value Raw label.
	 * @return string
	 */
	private static function normalize_match_key( $value ) {
		$value = strtolower( trim( (string) $value ) );

		if ( function_exists( 'remove_accents' ) ) {
			$value = remove_accents( $value );
		}

		return $value;
	}

	/**
	 * Maps variant labels to the canonical catalog spelling (Estándar, Exportación, …).
	 *
	 * @param string $variante Raw variant.
	 * @return string
	 */
	private static function canonicalize_base_variante( $variante ) {
		$raw = trim( (string) $variante );

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

		$normalized = self::normalize_match_key( $raw );

		foreach ( self::standard_variant_options() as $standard ) {
			if ( self::normalize_match_key( $standard ) === $normalized ) {
				return $standard;
			}
		}

		return $raw;
	}

	/**
	 * Empty permanent base field template.
	 *
	 * @return array<string, string>
	 */
	private static function blank_base_template() {
		return array(
			'cod'                 => '',
			'lote'                => '',
			'gramaje'             => '',
			'ancho'               => '',
			'calibre'             => '',
			'rendimiento'         => '',
			'ajuste'              => '0',
			'cod_saldo'           => '',
			'lote_saldo'          => '',
			'largo'               => '',
			'alto'                => '',
			'clave'               => '',
			'largo_saldo'         => '',
			'ancho_saldo'         => '',
			'alto_saldo'          => '',
			'clave_saldo'         => '',
			'und_molde'           => '',
			'peso_tamano'         => '',
			'sustitucion_activa'  => '0',
			'cod_habitual'        => '',
			'sustitucion_desde'   => '',
			'sustitucion_hasta'   => '',
		);
	}

	/**
	 * Sanitizes a Y-m-d date stored on permanent bases.
	 *
	 * @param mixed $value Raw value.
	 * @return string
	 */
	private static function sanitize_base_ymd( $value ) {
		$value = sanitize_text_field( (string) $value );

		if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $value ) ) {
			return '';
		}

		$parts = array_map( 'intval', explode( '-', $value ) );
		if ( 3 !== count( $parts ) || ! checkdate( $parts[1], $parts[2], $parts[0] ) ) {
			return '';
		}

		return $value;
	}

	/**
	 * Whether a cajas base row still has legacy saldo fields to migrate.
	 *
	 * @param array $base Base row.
	 * @return bool
	 */
	private static function base_has_embedded_saldo( $base ) {
		foreach ( array( 'cod_saldo', 'lote_saldo', 'largo_saldo', 'ancho_saldo', 'alto_saldo', 'clave_saldo' ) as $field ) {
			if ( '' !== trim( (string) ( $base[ $field ] ?? '' ) ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Splits legacy saldo fields on cajas bases into dedicated cajas_saldo bases.
	 *
	 * @param array $config Current config.
	 * @return array
	 */
	private static function migrate_separate_caja_saldo_bases( $config ) {
		$bases_by_key = array();

		foreach ( $config['bases'] ?? array() as $base ) {
			if ( ! is_array( $base ) ) {
				continue;
			}

			$merged = array_merge(
				array(
					'id'         => '',
					'variante'   => '',
					'tipo'       => '',
					'referencia' => '',
				),
				self::blank_base_template(),
				$base
			);
			$key = self::base_catalog_key( $merged );

			$bases_by_key[ $key ] = $merged;
		}

		foreach ( $bases_by_key as $key => $base ) {
			if ( 'cajas' !== sanitize_key( $base['tipo'] ?? '' ) || ! self::base_has_embedded_saldo( $base ) ) {
				continue;
			}

			$saldo_probe = array(
				'variante'   => $base['variante'] ?? '',
				'tipo'       => 'cajas_saldo',
				'referencia' => $base['referencia'] ?? '',
			);
			$saldo_key   = self::base_catalog_key( $saldo_probe );

			if ( ! isset( $bases_by_key[ $saldo_key ] ) ) {
				$bases_by_key[ $saldo_key ] = self::normalize_base_row(
					array_merge(
						self::blank_base_template(),
						array(
							'id'         => 'base-' . sanitize_title( 'cajas-saldo-' . ( $base['referencia'] ?? 'ref' ) . '-' . ( $base['variante'] ?? 'var' ) ),
							'variante'   => $base['variante'] ?? '',
							'tipo'       => 'cajas_saldo',
							'referencia' => $base['referencia'] ?? '',
							'cod'        => $base['cod_saldo'] ?? '',
							'lote'       => $base['lote_saldo'] ?? '',
							'largo'      => $base['largo_saldo'] ?? '',
							'ancho'      => $base['ancho_saldo'] ?? '',
							'alto'       => $base['alto_saldo'] ?? '',
							'clave'      => $base['clave_saldo'] ?? '',
							'ajuste'     => $base['ajuste'] ?? '0',
						)
					)
				);
			} else {
				$saldo_base = $bases_by_key[ $saldo_key ];

				if ( '' === trim( (string) ( $saldo_base['cod'] ?? '' ) ) ) {
					$saldo_base['cod'] = $base['cod_saldo'] ?? '';
				}

				if ( '' === trim( (string) ( $saldo_base['lote'] ?? '' ) ) ) {
					$saldo_base['lote'] = $base['lote_saldo'] ?? '';
				}

				foreach ( array( 'largo' => 'largo_saldo', 'ancho' => 'ancho_saldo', 'alto' => 'alto_saldo', 'clave' => 'clave_saldo' ) as $target => $source ) {
					if ( '' === trim( (string) ( $saldo_base[ $target ] ?? '' ) ) ) {
						$saldo_base[ $target ] = $base[ $source ] ?? '';
					}
				}

				$bases_by_key[ $saldo_key ] = self::normalize_base_row( $saldo_base );
			}

			$base['cod_saldo']   = '';
			$base['lote_saldo']  = '';
			$base['largo_saldo'] = '';
			$base['ancho_saldo'] = '';
			$base['alto_saldo']  = '';
			$base['clave_saldo'] = '';

			$bases_by_key[ $key ] = self::normalize_base_row( $base );
		}

		foreach ( $config['referencias'] ?? array() as $ref_index => $reference ) {
			if ( ! is_array( $reference ) ) {
				continue;
			}

			foreach ( $reference['lineas'] ?? array() as $line_index => $line ) {
				if ( ! is_array( $line ) ) {
					continue;
				}

				$tipo = sanitize_key( $line['tipo'] ?? '' );

				if ( 'caja_saldos' !== $tipo ) {
					continue;
				}

				if ( 'cajas' === sanitize_key( $line['base_tipo'] ?? '' ) || '' === trim( (string) ( $line['base_tipo'] ?? '' ) ) ) {
					$config['referencias'][ $ref_index ]['lineas'][ $line_index ]['base_tipo'] = 'cajas_saldo';
				}

				$config['referencias'][ $ref_index ]['lineas'][ $line_index ]['base_link_id'] = '';
			}
		}

		$config['bases'] = array();

		foreach ( $bases_by_key as $base ) {
			$config['bases'][] = self::normalize_base_row( $base );
		}

		return $config;
	}

	/**
	 * Sort rank for permanent base cards in the sidebar.
	 *
	 * @param string $tipo Base type slug.
	 * @return int
	 */
	private static function base_type_sort_rank( $tipo ) {
		$ranks = array(
			'cajas'          => 10,
			'cajas_saldo'    => 15,
			'bolsas'         => 20,
			'rollos'         => 30,
			'plastico'       => 40,
			'acetato'        => 45,
			'papel_hojas'    => 50,
			'papel_bobinas'  => 55,
			'microcorrugado' => 60,
		);

		return $ranks[ sanitize_key( $tipo ) ] ?? 900;
	}

	/**
	 * Normalizes one permanent base row (saldo fields, id, defaults).
	 *
	 * @param array $base Base row.
	 * @return array<string, string>
	 */
	private static function normalize_base_row( $base ) {
		$base = array_merge(
			array(
				'id'         => '',
				'variante'   => '',
				'tipo'       => '',
				'referencia' => '',
			),
			self::blank_base_template(),
			$base
		);

		$base['cod_saldo']   = '';
		$base['lote_saldo']  = '';
		$base['largo_saldo'] = '';
		$base['ancho_saldo'] = '';
		$base['alto_saldo']  = '';
		$base['clave_saldo'] = '';
		$base['variante']    = self::canonicalize_base_variante( $base['variante'] ?? '' );
		$base['referencia']  = self::canonicalize_base_referencia( $base['referencia'] ?? '' );

		$activa_raw                   = $base['sustitucion_activa'] ?? '0';
		$base['sustitucion_activa']   = ( '1' === (string) $activa_raw || 1 === $activa_raw || true === $activa_raw ) ? '1' : '0';
		$base['cod_habitual']         = strtoupper( trim( (string) ( $base['cod_habitual'] ?? '' ) ) );
		$base['sustitucion_desde']    = self::sanitize_base_ymd( $base['sustitucion_desde'] ?? '' );
		$base['sustitucion_hasta']    = self::sanitize_base_ymd( $base['sustitucion_hasta'] ?? '' );

		if ( '' === trim( (string) ( $base['id'] ?? '' ) ) ) {
			$base['id'] = 'base-' . sanitize_title(
				( $base['tipo'] ?? 'base' ) . '-' . ( $base['referencia'] ?? 'ref' ) . '-' . ( $base['variante'] ?? 'var' )
			);
		}

		if ( '' === trim( (string) ( $base['ajuste'] ?? '' ) ) ) {
			$base['ajuste'] = '0';
		}

		return $base;
	}

	/**
	 * Maps product-name referencias (VASO 4 OZ) to catalog keys (4 OZ).
	 *
	 * @param string $referencia Raw base referencia.
	 * @return string
	 */
	private static function canonicalize_base_referencia( $referencia ) {
		$ref = trim( (string) $referencia );

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

		// Product-name keys → pack/size template used by permanent bases.
		if ( preg_match( '/^(VASO|TAZ[ÓO]N|BOWL|TAPA)\s+/iu', $ref ) ) {
			return self::template_from_nombre( $ref );
		}

		return $ref;
	}

	/**
	 * Prefers the base row that still carries user-entered codes/dims.
	 *
	 * @param array $current  Existing row for a catalog key.
	 * @param array $incoming Candidate row for the same key.
	 * @return array
	 */
	private static function prefer_richer_base_row( $current, $incoming ) {
		$score = static function ( $row ) {
			$points = 0;
			foreach ( array( 'cod', 'lote', 'largo', 'ancho', 'alto', 'clave', 'gramaje', 'calibre', 'ajuste', 'cod_habitual', 'sustitucion_desde' ) as $field ) {
				if ( '' !== trim( (string) ( $row[ $field ] ?? '' ) ) ) {
					++$points;
				}
			}
			if ( '1' === (string) ( $row['sustitucion_activa'] ?? '' ) ) {
				$points += 3;
			}
			return $points;
		};

		$winner = $score( $incoming ) >= $score( $current ) ? $incoming : $current;
		$other  = $winner === $incoming ? $current : $incoming;

		// Keep an active temporary-substitution notice even if the other row is richer in dims.
		if ( '1' === (string) ( $other['sustitucion_activa'] ?? '' )
			&& '1' !== (string) ( $winner['sustitucion_activa'] ?? '' ) ) {
			$winner['sustitucion_activa'] = '1';
			$winner['cod_habitual']       = (string) ( $other['cod_habitual'] ?? $winner['cod_habitual'] ?? '' );
			$winner['sustitucion_desde']  = (string) ( $other['sustitucion_desde'] ?? $winner['sustitucion_desde'] ?? '' );
			$winner['sustitucion_hasta']  = (string) ( $other['sustitucion_hasta'] ?? $winner['sustitucion_hasta'] ?? '' );
		}

		return $winner;
	}

	/**
	 * Template key used in permanent bases for a reference row.
	 *
	 * @param array $reference Reference row.
	 * @return string
	 */
	private static function base_referencia_for_reference( $reference ) {
		$template = trim( (string) ( $reference['formula_template'] ?? '' ) );

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

		$template = trim( (string) ( $reference['unidad_empaque'] ?? '' ) );

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

		$tipo = trim( (string) ( $reference['tipo_producto'] ?? '' ) );

		if ( in_array( $tipo, array( 'Tapa', 'Collarin', 'Acetato', 'Otro' ), true ) ) {
			return trim( (string) ( $reference['nombre'] ?? '' ) );
		}

		return self::template_from_nombre( $reference['nombre'] ?? '' );
	}

	/**
	 * Base types required for a reference according to its calculation blocks.
	 *
	 * @param array $reference Reference row.
	 * @return string[]
	 */
	private static function required_base_types_for_reference( $reference ) {
		$tipos         = array();
		$tipo_producto = trim( (string) ( $reference['tipo_producto'] ?? '' ) );

		foreach ( $reference['lineas'] ?? array() as $line ) {
			if ( ! is_array( $line ) ) {
				continue;
			}

			$base_tipo = sanitize_key( $line['base_tipo'] ?? '' );

			if ( $base_tipo ) {
				$tipos[ $base_tipo ] = true;
			}
		}

		if ( ! empty( $tipos ) ) {
			return array_keys( $tipos );
		}

		if ( 'Tapa' === $tipo_producto ) {
			return array( 'plastico' );
		}

		if ( 'Collarin' === $tipo_producto ) {
			return array( 'microcorrugado' );
		}

		if ( 'Acetato' === $tipo_producto ) {
			return array( 'acetato' );
		}

		return array();
	}

	/**
	 * Links calculation rows to permanent bases using variant + base type + template key.
	 *
	 * @param array $config Current config.
	 * @return array
	 */
	private static function link_reference_lines_to_bases( $config ) {
		$base_id_by_key = array();

		foreach ( $config['bases'] ?? array() as $base ) {
			if ( ! is_array( $base ) ) {
				continue;
			}

			$normalized = self::normalize_base_row( $base );
			$key        = self::base_catalog_key( $normalized );
			$id         = trim( (string) ( $normalized['id'] ?? '' ) );

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

			$base_id_by_key[ $key ] = $id;
		}

		foreach ( $config['referencias'] ?? array() as $ref_index => $reference ) {
			if ( ! is_array( $reference ) ) {
				continue;
			}

			$variante   = trim( (string) ( $reference['variante'] ?? '' ) );
			$referencia = self::base_referencia_for_reference( $reference );

			if ( '' === $variante || '' === $referencia ) {
				continue;
			}

			foreach ( $reference['lineas'] ?? array() as $line_index => $line ) {
				if ( ! is_array( $line ) ) {
					continue;
				}

				$base_tipo = sanitize_key( $line['base_tipo'] ?? '' );

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

				$key = self::base_catalog_key(
					array(
						'variante'   => $variante,
						'tipo'       => $base_tipo,
						'referencia' => $referencia,
					)
				);

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

				$config['referencias'][ $ref_index ]['lineas'][ $line_index ]['base_link_id'] = $base_id_by_key[ $key ];
			}
		}

		return $config;
	}

	/**
	 * Creates or updates permanent bases for every catalog reference and block.
	 *
	 * @param array $config Current config.
	 * @return array
	 */
	private static function sync_catalog_bases( $config ) {
		$expected_keys = array();
		$bases_by_key  = array();

		if ( ! is_array( $config['bases'] ?? null ) ) {
			$config['bases'] = array();
		}

		foreach ( $config['bases'] as $base ) {
			if ( ! is_array( $base ) ) {
				continue;
			}

			$normalized = self::normalize_base_row( $base );
			$key        = self::base_catalog_key( $normalized );

			if ( isset( $bases_by_key[ $key ] ) ) {
				$bases_by_key[ $key ] = self::prefer_richer_base_row( $bases_by_key[ $key ], $normalized );
			} else {
				$bases_by_key[ $key ] = $normalized;
			}
		}

		foreach ( self::default_catalog_bases() as $starter ) {
			$starter = self::normalize_base_row( $starter );
			$key     = self::base_catalog_key( $starter );

			$expected_keys[ $key ] = true;

			if ( ! isset( $bases_by_key[ $key ] ) ) {
				$bases_by_key[ $key ] = $starter;
			}
		}

		foreach ( $config['referencias'] ?? array() as $reference ) {
			$variante   = self::canonicalize_base_variante( trim( (string) ( $reference['variante'] ?? '' ) ) );
			$referencia = self::base_referencia_for_reference( $reference );

			if ( '' === $variante || '' === $referencia ) {
				continue;
			}

			foreach ( self::required_base_types_for_reference( $reference ) as $tipo ) {
				$probe = array(
					'variante'   => $variante,
					'tipo'       => $tipo,
					'referencia' => $referencia,
				);
				$key   = self::base_catalog_key( $probe );
				$expected_keys[ $key ] = true;

				if ( isset( $bases_by_key[ $key ] ) ) {
					$bases_by_key[ $key ]['variante']   = $variante;
					$bases_by_key[ $key ]['tipo']       = $tipo;
					$bases_by_key[ $key ]['referencia'] = $referencia;
					continue;
				}

				$bases_by_key[ $key ] = self::normalize_base_row(
					array_merge(
						self::blank_base_template(),
						array(
							'id'         => 'base-' . sanitize_title( $tipo . '-' . $referencia . '-' . $variante ),
							'variante'   => $variante,
							'tipo'       => $tipo,
							'referencia' => $referencia,
						)
					)
				);
			}
		}

		$synced = array();

		foreach ( $bases_by_key as $key => $base ) {
			if ( ! isset( $expected_keys[ $key ] ) ) {
				continue;
			}

			$synced[] = self::normalize_base_row( $base );
		}

		usort(
			$synced,
			function ( $a, $b ) {
				$variant_cmp = strnatcasecmp( (string) ( $a['variante'] ?? '' ), (string) ( $b['variante'] ?? '' ) );

				if ( 0 !== $variant_cmp ) {
					return $variant_cmp;
				}

				$ref_cmp = strnatcasecmp( (string) ( $a['referencia'] ?? '' ), (string) ( $b['referencia'] ?? '' ) );

				if ( 0 !== $ref_cmp ) {
					return $ref_cmp;
				}

				return self::base_type_sort_rank( $a['tipo'] ?? '' ) - self::base_type_sort_rank( $b['tipo'] ?? '' );
			}
		);

		$config['bases'] = $synced;

		return $config;
	}

	/**
	 * Syncs permanent bases with the reference catalog.
	 *
	 * @param array $config Current config.
	 * @return array
	 */
	private static function merge_default_bases( $config ) {
		return self::sync_catalog_bases( $config );
	}

	/**
	 * Starter permanent bases for catalog products not covered by 4 OZ NAL/EXP samples.
	 *
	 * @return array<int, array<string, string>>
	 */
	private static function default_catalog_bases() {
		$blank = self::blank_base_template();

		$rows = array();

		foreach ( array( 'COLLARÍN AJUSTABLE', 'COLLARÍN CON PEGUE' ) as $nombre ) {
			$rows[] = array_merge(
				$blank,
				array(
					'id'         => 'base-micro-' . sanitize_title( $nombre ) . '-est',
					'variante'   => 'Estándar',
					'tipo'       => 'microcorrugado',
					'referencia' => $nombre,
				)
			);
		}

		foreach ( self::tapa_catalog_names() as $nombre ) {
			$rows[] = array_merge(
				$blank,
				array(
					'id'         => 'base-plast-' . sanitize_title( $nombre ) . '-est',
					'variante'   => 'Estándar',
					'tipo'       => 'plastico',
					'referencia' => $nombre,
				)
			);
		}

		return $rows;
	}

	/**
	 * Returns the default model with the full reference catalog.
	 *
	 * @return array
	 */
	public static function default_config() {
		return self::merge_reference_catalog(
			array(
			'bases'      => array(
				array( 'id' => 'base-rollo-4oz-nal', 'variante' => 'Nacional', 'tipo' => 'rollos', 'referencia' => '4 OZ', 'cod' => '4731', 'lote' => '22122024', 'gramaje' => '210', 'ancho' => '6.8', 'calibre' => '', 'rendimiento' => '1347', 'ajuste' => '1' ),
				array( 'id' => 'base-bolsa-4oz-nal', 'variante' => 'Nacional', 'tipo' => 'bolsas', 'referencia' => '4 OZ', 'cod' => '15', 'lote' => '-', 'gramaje' => '', 'ancho' => '', 'calibre' => '0.65', 'rendimiento' => '', 'ajuste' => '5' ),
				array( 'id' => 'base-caja-4oz-nal', 'variante' => 'Nacional', 'tipo' => 'cajas', 'referencia' => '4 OZ', 'cod' => '9', 'lote' => '503511', 'gramaje' => '', 'ancho' => '', 'calibre' => '', 'rendimiento' => '', 'ajuste' => '0' ),
				array( 'id' => 'base-caja-saldo-4oz-nal', 'variante' => 'Nacional', 'tipo' => 'cajas_saldo', 'referencia' => '4 OZ', 'cod' => '4420', 'lote' => '501938', 'gramaje' => '', 'ancho' => '', 'calibre' => '', 'rendimiento' => '', 'ajuste' => '0' ),
				array( 'id' => 'base-rollo-4oz-exp', 'variante' => 'Exportación', 'tipo' => 'rollos', 'referencia' => '4 OZ', 'cod' => '4731', 'lote' => '22122024', 'gramaje' => '210', 'ancho' => '6.8', 'calibre' => '', 'rendimiento' => '1347', 'ajuste' => '1' ),
				array( 'id' => 'base-bolsa-4oz-exp', 'variante' => 'Exportación', 'tipo' => 'bolsas', 'referencia' => '4 OZ', 'cod' => '3044', 'lote' => '-', 'gramaje' => '', 'ancho' => '', 'calibre' => '0.65', 'rendimiento' => '', 'ajuste' => '5' ),
				array( 'id' => 'base-caja-4oz-exp', 'variante' => 'Exportación', 'tipo' => 'cajas', 'referencia' => '4 OZ', 'cod' => '4682', 'lote' => '141428', 'gramaje' => '', 'ancho' => '', 'calibre' => '', 'rendimiento' => '', 'ajuste' => '0' ),
				array( 'id' => 'base-caja-saldo-4oz-exp', 'variante' => 'Exportación', 'tipo' => 'cajas_saldo', 'referencia' => '4 OZ', 'cod' => '4420', 'lote' => '501938', 'gramaje' => '', 'ancho' => '', 'calibre' => '', 'rendimiento' => '', 'ajuste' => '0' ),
			),
				'referencias' => array(),
			)
		);
	}

	/**
	 * Default consumption rows.
	 *
	 * @param string $cajas Boxes.
	 * @param string $saldos Balance boxes.
	 * @param string $mediana Medium boxes.
	 * @param string $bolsas Bags.
	 * @param string $rollos Rolls.
	 * @param string $plastico Plastic kg.
	 * @return array
	 */
	private static function default_lines( $cajas, $saldos, $mediana, $bolsas, $rollos, $plastico ) {
		return array(
			array( 'tipo' => 'cajas', 'label' => 'Cajas', 'cantidad' => $cajas, 'paquetes' => '1', 'unidades_paquete' => $cajas, 'base_tipo' => 'cajas', 'formula' => 'paquetes * unidades_paquete', 'estado_formula' => 'formulada' ),
			array( 'tipo' => 'caja_saldos', 'label' => 'Cajas saldo', 'cantidad' => $saldos, 'base_tipo' => 'cajas_saldo', 'formula' => 'saldos / cajas', 'estado_formula' => 'formulada' ),
			array( 'tipo' => 'bolsas', 'label' => 'Bolsas', 'cantidad' => $bolsas, 'base_tipo' => 'bolsas', 'formula' => 'unidades / cantidad', 'estado_formula' => 'formulada' ),
			array( 'tipo' => 'rollos', 'label' => 'Rollo fondo', 'cantidad' => $rollos, 'base_tipo' => 'rollos', 'formula' => 'unidades * consumo unidad', 'estado_formula' => 'formulada' ),
			array( 'tipo' => 'plastico_kg', 'label' => 'Plástico en kg', 'cantidad' => $plastico, 'base_tipo' => 'plastico', 'formula' => '', 'estado_formula' => 'pendiente' ),
		);
	}

	/**
	 * Sort order for calculation block lines (matches default_lines sequence).
	 *
	 * @return array<string, int>
	 */
	public static function reference_line_sort_ranks() {
		return array(
			'cajas'           => 10,
			'caja_saldos'     => 20,
			'caja_mediana'    => 25,
			'bolsas'          => 30,
			'rollos'          => 40,
			'plastico_kg'     => 50,
			'acetato'         => 55,
			'nuevo_bloque'    => 500,
		);
	}

	/**
	 * Rank for a single line when sorting reference blocks.
	 *
	 * @param array<string, string> $line Line row.
	 * @return int
	 */
	public static function reference_line_sort_rank( array $line ) {
		$ranks    = self::reference_line_sort_ranks();
		$tipo     = sanitize_key( $line['tipo'] ?? '' );
		$base     = sanitize_key( $line['base_tipo'] ?? '' );
		$by_tipo  = $ranks[ $tipo ] ?? null;
		$by_base  = array(
			'cajas'          => 10,
			'bolsas'         => 30,
			'rollos'         => 40,
			'plastico'       => 50,
			'acetato'        => 55,
			'papel_hojas'    => 60,
			'papel_bobinas'  => 65,
			'microcorrugado' => 70,
		);

		if ( null !== $by_tipo ) {
			return (int) $by_tipo;
		}

		if ( isset( $by_base[ $base ] ) ) {
			return (int) $by_base[ $base ];
		}

		return 900;
	}

	/**
	 * Sort calculation lines for display and persistence.
	 *
	 * @param array<int, array<string, string>> $lines Reference lines.
	 * @return array<int, array<string, string>>
	 */
	public static function sort_reference_lines( array $lines ) {
		$sorted = array_values( $lines );

		usort(
			$sorted,
			function ( $a, $b ) {
				$rank_diff = self::reference_line_sort_rank( $a ) - self::reference_line_sort_rank( $b );

				if ( 0 !== $rank_diff ) {
					return $rank_diff;
				}

				return strnatcasecmp( (string) ( $a['label'] ?? '' ), (string) ( $b['label'] ?? '' ) );
			}
		);

		return $sorted;
	}

	/**
	 * Option key for per-type base field visibility.
	 */
	const OPTION_BASE_TYPE_FIELDS = 'logistica_closing_base_type_fields';

	/**
	 * Base types for permanent bases and consumption matrix.
	 *
	 * @return array<string, string> Value => label.
	 */
	public static function base_type_options() {
		$defaults = array(
			'cajas'          => __( 'Cajas', 'logistica' ),
			'cajas_saldo'    => __( 'Cajas saldo', 'logistica' ),
			'bolsas'         => __( 'Bolsas', 'logistica' ),
			'rollos'         => __( 'Rollo fondo', 'logistica' ),
			'plastico'       => __( 'Plástico', 'logistica' ),
			'acetato'        => __( 'Acetato (láminas)', 'logistica' ),
			'papel_hojas'    => __( 'Papel en hojas', 'logistica' ),
			'papel_bobinas'  => __( 'Papel en bobinas', 'logistica' ),
			'microcorrugado' => __( 'Microcorrugado', 'logistica' ),
		);

		if ( class_exists( 'Logistica_Select_Catalog', false ) ) {
			$options = Logistica_Select_Catalog::get_options( 'closing_base_types' );
			if ( is_array( $options ) && ! empty( $options ) ) {
				foreach ( $defaults as $key => $label ) {
					if ( ! isset( $options[ $key ] ) ) {
						$options[ $key ] = $label;
					}
				}

				return $options;
			}
		}

		return $defaults;
	}

	/**
	 * Campos configurables por tipo de base (código, lote y ajuste siempre visibles).
	 *
	 * @return array<string, array{label: string, group: string}>
	 */
	public static function base_field_definitions() {
		return array(
			'largo'            => array(
				'label' => __( 'Largo', 'logistica' ),
				'group' => 'dims',
			),
			'ancho'            => array(
				'label' => __( 'Ancho', 'logistica' ),
				'group' => 'dims',
			),
			'alto'             => array(
				'label' => __( 'Alto', 'logistica' ),
				'group' => 'dims',
			),
			'clave'            => array(
				'label' => __( 'Clave', 'logistica' ),
				'group' => 'box',
			),
			'gramaje'          => array(
				'label' => __( 'Gramaje (gr/m²)', 'logistica' ),
				'group' => 'material',
			),
			'calibre'          => array(
				'label' => __( 'Calibre (mm / milésimas)', 'logistica' ),
				'group' => 'material',
			),
			'consumo_cajas'    => array(
				'label' => __( 'Consumo unidades (cajas)', 'logistica' ),
				'group' => 'calc',
			),
			'consumo_bolsas'   => array(
				'label' => __( 'Consumo unidad (bolsas)', 'logistica' ),
				'group' => 'calc',
			),
			'consumo_rollos'   => array(
				'label' => __( 'Consumo unidad (rollos)', 'logistica' ),
				'group' => 'calc',
			),
			'und_molde'        => array(
				'label' => __( 'Und x molde / cortes por lámina', 'logistica' ),
				'group' => 'plastic',
			),
			'peso_tamano'      => array(
				'label' => __( 'Peso tamaño (gr)', 'logistica' ),
				'group' => 'plastic',
			),
			'consumo_plastico' => array(
				'label' => __( 'Consumo unidad (kg)', 'logistica' ),
				'group' => 'plastic',
			),
			'consumo_acetato'  => array(
				'label' => __( 'Consumo (láminas)', 'logistica' ),
				'group' => 'acetato',
			),
		);
	}

	/**
	 * Defaults de campos visibles por tipo (sin código/lote/ajuste).
	 *
	 * @return array<string, string[]>
	 */
	public static function default_base_type_fields() {
		return array(
			'cajas'          => array( 'largo', 'ancho', 'alto', 'clave', 'consumo_cajas' ),
			'cajas_saldo'    => array( 'largo', 'ancho', 'alto', 'clave' ),
			'bolsas'         => array( 'largo', 'ancho', 'calibre', 'consumo_bolsas' ),
			'rollos'         => array( 'ancho', 'gramaje', 'consumo_rollos' ),
			'papel_bobinas'  => array( 'ancho', 'gramaje', 'consumo_rollos' ),
			'plastico'       => array( 'largo', 'ancho', 'und_molde', 'peso_tamano', 'consumo_plastico' ),
			'acetato'        => array( 'largo', 'ancho', 'und_molde', 'consumo_acetato' ),
			'papel_hojas'    => array( 'ancho', 'gramaje', 'calibre' ),
			'microcorrugado' => array( 'ancho', 'gramaje', 'calibre' ),
		);
	}

	/**
	 * Campos por defecto para un tipo nuevo o desconocido.
	 *
	 * @return string[]
	 */
	public static function default_fields_for_unknown_base_type() {
		return array( 'largo', 'ancho' );
	}

	/**
	 * Mapa completo tipo => campos visibles (defaults + overrides guardados).
	 *
	 * @return array<string, string[]>
	 */
	public static function get_base_type_fields_map() {
		$defs     = self::base_field_definitions();
		$allowed  = array_keys( $defs );
		$defaults = self::default_base_type_fields();
		$saved    = get_option( self::OPTION_BASE_TYPE_FIELDS, array() );
		if ( ! is_array( $saved ) ) {
			$saved = array();
		}

		$map = array();
		foreach ( self::base_type_options() as $tipo => $_label ) {
			$tipo = sanitize_key( (string) $tipo );
			if ( '' === $tipo ) {
				continue;
			}

			if ( isset( $saved[ $tipo ] ) && is_array( $saved[ $tipo ] ) ) {
				$fields = $saved[ $tipo ];
			} elseif ( isset( $defaults[ $tipo ] ) ) {
				$fields = $defaults[ $tipo ];
			} else {
				$fields = self::default_fields_for_unknown_base_type();
			}

			$clean = array();
			foreach ( (array) $fields as $field ) {
				$field = sanitize_key( (string) $field );
				if ( in_array( $field, $allowed, true ) ) {
					$clean[] = $field;
				}
			}

			// Acetato siempre necesita despiece (und_molde) aunque la opción guardada sea antigua.
			if ( 'acetato' === $tipo ) {
				foreach ( array( 'largo', 'ancho', 'und_molde', 'consumo_acetato' ) as $required_field ) {
					if ( ! in_array( $required_field, $clean, true ) ) {
						$clean[] = $required_field;
					}
				}
			}

			$map[ $tipo ] = array_values( array_unique( $clean ) );
		}

		return $map;
	}

	/**
	 * Campos visibles para un tipo de base.
	 *
	 * @param string $tipo Clave del tipo.
	 * @return string[]
	 */
	public static function fields_for_base_type( $tipo ) {
		$tipo = sanitize_key( (string) $tipo );
		$map  = self::get_base_type_fields_map();

		if ( isset( $map[ $tipo ] ) ) {
			return $map[ $tipo ];
		}

		$defaults = self::default_base_type_fields();
		if ( isset( $defaults[ $tipo ] ) ) {
			return $defaults[ $tipo ];
		}

		return self::default_fields_for_unknown_base_type();
	}

	/**
	 * ¿El tipo muestra este campo configurable?
	 *
	 * @param string $tipo  Clave del tipo.
	 * @param string $field Clave del campo.
	 * @return bool
	 */
	public static function base_type_shows_field( $tipo, $field ) {
		$field = sanitize_key( (string) $field );
		return in_array( $field, self::fields_for_base_type( $tipo ), true );
	}

	/**
	 * Guarda los campos visibles de un tipo de base.
	 *
	 * @param string   $tipo   Clave del tipo.
	 * @param string[] $fields Campos seleccionados.
	 * @return array<string, string[]>|\WP_Error Mapa actualizado.
	 */
	public static function save_base_type_fields( $tipo, $fields ) {
		$tipo = sanitize_key( (string) $tipo );
		if ( '' === $tipo ) {
			return new WP_Error( 'invalid_tipo', __( 'Tipo de base no válido.', 'logistica' ) );
		}

		$allowed = array_keys( self::base_field_definitions() );
		$clean   = array();
		foreach ( (array) $fields as $field ) {
			$field = sanitize_key( (string) $field );
			if ( in_array( $field, $allowed, true ) ) {
				$clean[] = $field;
			}
		}
		$clean = array_values( array_unique( $clean ) );

		$saved = get_option( self::OPTION_BASE_TYPE_FIELDS, array() );
		if ( ! is_array( $saved ) ) {
			$saved = array();
		}
		$saved[ $tipo ] = $clean;
		update_option( self::OPTION_BASE_TYPE_FIELDS, $saved, false );

		return self::get_base_type_fields_map();
	}

	/**
	 * Elimina la config de campos de un tipo (al borrar del catálogo).
	 *
	 * @param string $tipo Clave del tipo.
	 * @return void
	 */
	public static function delete_base_type_fields( $tipo ) {
		$tipo = sanitize_key( (string) $tipo );
		if ( '' === $tipo ) {
			return;
		}

		$saved = get_option( self::OPTION_BASE_TYPE_FIELDS, array() );
		if ( ! is_array( $saved ) || ! isset( $saved[ $tipo ] ) ) {
			return;
		}

		unset( $saved[ $tipo ] );
		update_option( self::OPTION_BASE_TYPE_FIELDS, $saved, false );
	}

	/**
	 * Base types that use roll/bobina consumption fields.
	 *
	 * @return string[]
	 */
	public static function base_types_roll_like() {
		$map   = self::get_base_type_fields_map();
		$types = array();
		foreach ( $map as $tipo => $fields ) {
			if ( in_array( 'consumo_rollos', $fields, true ) ) {
				$types[] = $tipo;
			}
		}

		if ( empty( $types ) ) {
			return array( 'rollos', 'papel_bobinas' );
		}

		return $types;
	}

	/**
	 * Base types that use sheet/plastic consumption fields (gramaje, calibre).
	 *
	 * @return string[]
	 */
	public static function base_types_sheet_like() {
		$map   = self::get_base_type_fields_map();
		$types = array();
		foreach ( $map as $tipo => $fields ) {
			if ( in_array( 'gramaje', $fields, true ) || in_array( 'calibre', $fields, true ) || in_array( 'consumo_plastico', $fields, true ) ) {
				$types[] = $tipo;
			}
		}

		if ( empty( $types ) ) {
			return array( 'plastico', 'papel_hojas', 'microcorrugado' );
		}

		return $types;
	}

	/**
	 * Default formula value for a matrix line from line type and/or base type.
	 *
	 * @param string $tipo      Line type (cajas, caja_saldos, bolsas, rollos, etc.).
	 * @param string $base_tipo Base type select value.
	 * @return string
	 */
	public static function default_formula_for_line( $tipo, $base_tipo = '' ) {
		if ( 'caja_saldos' === $tipo ) {
			return 'saldos / cajas';
		}

		$by_tipo = array(
			'cajas'         => 'paquetes * unidades_paquete',
			'bolsas'        => 'unidades / cantidad',
			'rollos'        => 'unidades * consumo unidad',
			'plastico_kg'   => 'plastico_peso_molde',
			'acetato'       => 'acetato_laminas',
		);

		if ( isset( $by_tipo[ $tipo ] ) ) {
			return $by_tipo[ $tipo ];
		}

		$by_base = array(
			'cajas'           => 'paquetes * unidades_paquete',
			'cajas_saldo'     => 'saldos / cajas',
			'bolsas'          => 'unidades / cantidad',
			'rollos'          => 'unidades * consumo unidad',
			'papel_bobinas'   => 'unidades * consumo unidad',
			'plastico'        => 'plastico_peso_molde',
			'acetato'         => 'acetato_laminas',
			'papel_hojas'     => '',
			'microcorrugado'  => '',
		);

		return $by_base[ $base_tipo ] ?? '';
	}

	/**
	 * Human-readable label for a stored formula value.
	 *
	 * @param string $value Fórmula key.
	 * @return string
	 */
	public static function formula_label( $value ) {
		$options = self::formula_options();

		if ( isset( $options[ $value ] ) ) {
			return $options[ $value ];
		}

		return '' === $value ? $options[''] : $value;
	}

	/**
	 * Returns formula choices for the closing matrix dropdown.
	 *
	 * @return array<string, string> Value => label.
	 */
	public static function formula_options() {
		return array(
			''                              => __( 'Sin fórmula', 'logistica' ),
			'paquetes * unidades_paquete'   => __( 'Paquetes x und por paquete', 'logistica' ),
			'saldos / cajas'                => __( 'Saldos / cajas', 'logistica' ),
			'unidades / cantidad'           => __( 'Unidades / cantidad', 'logistica' ),
			'unidades * consumo unidad'     => __( 'Unidades x consumo unidad', 'logistica' ),
			'plastico_peso_molde'           => __( 'Unidades x peso plástico (molde)', 'logistica' ),
			'acetato_laminas'               => __( 'Unidades / und por lámina (láminas)', 'logistica' ),
		);
	}

	/**
	 * Builds formula options including a custom saved value when needed.
	 *
	 * @param string $current Current formula value.
	 * @return array<string, string>
	 */
	public static function formula_options_for_select( $current = '' ) {
		$options = self::formula_options();
		$current = trim( (string) $current );

		if ( 'unidades / cant' === $current ) {
			$current = 'unidades / cantidad';
		}

		if ( '' !== $current && ! isset( $options[ $current ] ) ) {
			$options[ $current ] = $current;
		}

		return $options;
	}

	/**
	 * Decodes the JSON payload sent by the closing workspace (autosave / manual save).
	 *
	 * @param string $json Raw JSON string.
	 * @return array|\WP_Error
	 */
	public static function parse_cierre_json( $json ) {
		if ( ! is_string( $json ) || '' === trim( $json ) ) {
			return new WP_Error(
				'logistica_cierre_empty',
				__( 'Sin datos para guardar. Recarga la página e intenta de nuevo.', 'logistica' )
			);
		}

		$decoded = json_decode( $json, true );

		if ( ! is_array( $decoded ) ) {
			return new WP_Error(
				'logistica_cierre_invalid_json',
				__( 'Formato de datos inválido. Recarga la página e intenta de nuevo.', 'logistica' )
			);
		}

		return $decoded;
	}

	/**
	 * Gets current config.
	 *
	 * @return array
	 */
	public static function get_config() {
		$config = get_option( self::OPTION, array() );

		if ( ! is_array( $config ) || empty( $config ) ) {
			return self::default_config();
		}

		$remap = self::remap_orphan_reference_names( $config );
		$config = $remap['config'];
		if ( ! empty( $remap['changed'] ) ) {
			update_option( self::OPTION, $config, false );
		}

		return self::merge_and_persist_config( $config );
	}

	/**
	 * Rebuilds references/bases from the catalog and saves when the stored model is behind.
	 *
	 * @return array{referencias:int,bases:int,saldo_bases:int}
	 */
	public static function refresh_config_from_catalog() {
		$config = get_option( self::OPTION, array() );

		if ( ! is_array( $config ) || empty( $config ) ) {
			$config = self::default_config();
			update_option( self::OPTION, $config );

			return self::refresh_config_stats( $config );
		}

		$config = self::merge_and_persist_config( $config, true );

		return self::refresh_config_stats( $config );
	}

	/**
	 * Summary counts after a catalog refresh.
	 *
	 * @param array $config Config row.
	 * @return array{referencias:int,bases:int,saldo_bases:int}
	 */
	private static function refresh_config_stats( $config ) {
		$saldo_bases = 0;

		foreach ( $config['bases'] ?? array() as $base ) {
			if ( is_array( $base ) && 'cajas_saldo' === sanitize_key( $base['tipo'] ?? '' ) ) {
				++$saldo_bases;
			}
		}

		return array(
			'referencias' => count( $config['referencias'] ?? array() ),
			'bases'       => count( $config['bases'] ?? array() ),
			'saldo_bases' => $saldo_bases,
		);
	}

	/**
	 * Catalog keys for permanent bases.
	 *
	 * @param array $config Config row.
	 * @return string[]
	 */
	private static function catalog_base_keys( $config ) {
		$keys = array();

		foreach ( $config['bases'] ?? array() as $base ) {
			if ( ! is_array( $base ) ) {
				continue;
			}

			$keys[] = self::base_catalog_key( $base );
		}

		sort( $keys );

		return $keys;
	}

	/**
	 * Whether stored config should be replaced by a merged catalog snapshot.
	 *
	 * @param array $stored Config from the database.
	 * @param array $merged Merged catalog config.
	 * @return bool
	 */
	private static function should_persist_merged_config( $stored, $merged ) {
		$schema_version = (int) ( $stored['blocks_schema_version'] ?? 0 );

		if ( $schema_version < self::BLOCKS_SCHEMA_VERSION ) {
			return true;
		}

		if ( self::catalog_base_keys( $stored ) !== self::catalog_base_keys( $merged ) ) {
			return true;
		}

		if ( self::reference_identity_keys( $stored ) !== self::reference_identity_keys( $merged ) ) {
			return true;
		}

		if ( self::stored_config_has_legacy_saldo_bases( $stored ) ) {
			return true;
		}

		foreach ( $stored['referencias'] ?? array() as $reference ) {
			if ( ! is_array( $reference ) ) {
				continue;
			}

			foreach ( $reference['lineas'] ?? array() as $line ) {
				if ( ! is_array( $line ) ) {
					continue;
				}

				if ( 'caja_saldos' === sanitize_key( $line['tipo'] ?? '' )
					&& 'cajas' === sanitize_key( $line['base_tipo'] ?? '' ) ) {
					return true;
				}
			}
		}

		return false;
	}

	/**
	 * Keys nombre|variante de referencias (para detectar renames/duplicados).
	 *
	 * @param array $config Config row.
	 * @return string[]
	 */
	private static function reference_identity_keys( $config ) {
		$keys = array();

		foreach ( $config['referencias'] ?? array() as $reference ) {
			if ( ! is_array( $reference ) ) {
				continue;
			}

			$keys[] = self::reference_catalog_key(
				(string) ( $reference['nombre'] ?? '' ),
				(string) ( $reference['variante'] ?? '' )
			);
		}

		sort( $keys );

		return $keys;
	}

	/**
	 * Detects cajas bases that still embed saldo fields (pre schema v4).
	 *
	 * @param array $config Config row.
	 * @return bool
	 */
	private static function stored_config_has_legacy_saldo_bases( $config ) {
		foreach ( $config['bases'] ?? array() as $base ) {
			if ( ! is_array( $base ) ) {
				continue;
			}

			if ( 'cajas' !== sanitize_key( $base['tipo'] ?? '' ) ) {
				continue;
			}

			if ( self::base_has_embedded_saldo( $base ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Merges catalog data and persists when the stored snapshot is outdated.
	 *
	 * @param array $config   Stored config.
	 * @param bool  $force    Always write to the database.
	 * @return array
	 */
	private static function merge_and_persist_config( $config, $force = false ) {
		$merged = self::merge_reference_catalog( $config );
		$merged = self::link_reference_lines_to_bases( $merged );
		$merged['blocks_schema_version'] = self::BLOCKS_SCHEMA_VERSION;

		$lotes_overlay_changed = false;
		if ( class_exists( 'Logistica_Warehouse_Inventory', false ) ) {
			$before_overlay = self::base_lotes_snapshot( $merged );
			$merged         = self::overlay_warehouse_lotes_on_config( $merged );
			$lotes_overlay_changed = self::base_lotes_snapshot( $merged ) !== $before_overlay;
		}

		if ( class_exists( 'Logistica_Order_Closing_Characteristics', false ) ) {
			$merged = Logistica_Order_Closing_Characteristics::apply_to_config_bases( $merged );
		}

		if ( $force || self::should_persist_merged_config( $config, $merged ) || $lotes_overlay_changed ) {
			update_option( self::OPTION, $merged );
		}

		return $merged;
	}

	/**
	 * Aplica lotes maestros del inventario bodega al config de cierre.
	 *
	 * @param array<string, mixed> $config Config row.
	 * @return array<string, mixed>
	 */
	private static function overlay_warehouse_lotes_on_config( $config ) {
		if ( ! class_exists( 'Logistica_Warehouse_Inventory', false ) ) {
			return $config;
		}

		if ( ! is_array( $config['bases'] ?? null ) ) {
			return $config;
		}

		foreach ( Logistica_Warehouse_Inventory::lote_map() as $cod => $lote_master ) {
			foreach ( $config['bases'] as $index => $base ) {
				if ( ! is_array( $base ) ) {
					continue;
				}

				$base_cod  = strtoupper( trim( (string) ( $base['cod'] ?? '' ) ) );
				$saldo_cod = strtoupper( trim( (string) ( $base['cod_saldo'] ?? '' ) ) );

				if ( $base_cod === $cod && (string) ( $base['lote'] ?? '' ) !== $lote_master ) {
					$config['bases'][ $index ]['lote'] = $lote_master;
				}

				if ( $saldo_cod === $cod && (string) ( $base['lote_saldo'] ?? '' ) !== $lote_master ) {
					$config['bases'][ $index ]['lote_saldo'] = $lote_master;
				}
			}
		}

		return $config;
	}

	/**
	 * Snapshot estable de cod/lote en bases permanentes.
	 *
	 * @param array $config Config row.
	 * @return string
	 */
	private static function base_lotes_snapshot( $config ) {
		$parts = array();

		foreach ( $config['bases'] ?? array() as $base ) {
			if ( ! is_array( $base ) ) {
				continue;
			}

			$parts[] = implode(
				'|',
				array(
					(string) ( $base['id'] ?? '' ),
					(string) ( $base['cod'] ?? '' ),
					(string) ( $base['lote'] ?? '' ),
					(string) ( $base['cod_saldo'] ?? '' ),
					(string) ( $base['lote_saldo'] ?? '' ),
				)
			);
		}

		sort( $parts );

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

	/**
	 * Saves config and stores a version snapshot in conversiones.
	 *
	 * @param array $raw Raw request data.
	 * @param bool  $track_lifecycle Si registrar auditoría e historial (false en autosave).
	 * @return void
	 */
	public static function save_config( $raw, $track_lifecycle = true ) {
		global $wpdb;

		$before_config = self::get_config();

		$config = array(
			'bases'       => array(),
			'referencias' => array(),
		);

		foreach ( $raw['referencias'] ?? array() as $ref ) {
			$clean = array(
				'id'               => sanitize_title( wp_unslash( $ref['id'] ?? uniqid( 'ref-' ) ) ),
				'nombre'           => sanitize_text_field( wp_unslash( $ref['nombre'] ?? '' ) ),
				'variante'         => sanitize_text_field( wp_unslash( $ref['variante'] ?? '' ) ),
				'tipo_producto'    => sanitize_text_field( wp_unslash( $ref['tipo_producto'] ?? '' ) ),
				'unidad_empaque'   => sanitize_text_field( wp_unslash( $ref['unidad_empaque'] ?? '' ) ),
				'unidades'         => sanitize_text_field( wp_unslash( $ref['unidades'] ?? '0' ) ),
				'formula_template' => sanitize_text_field( wp_unslash( $ref['formula_template'] ?? '' ) ),
				'lineas'           => array(),
			);

			foreach ( $ref['lineas'] ?? array() as $line ) {
				$clean_line = array_map( 'sanitize_text_field', wp_unslash( $line ) );
				$tipo       = $clean_line['tipo'] ?? '';
				$base_tipo  = $clean_line['base_tipo'] ?? '';

				$clean_line['formula']        = self::default_formula_for_line( $tipo, $base_tipo );
				$clean_line['estado_formula'] = '' !== $clean_line['formula'] ? 'formulada' : 'pendiente';

				$clean['lineas'][] = $clean_line;
			}

			$clean['lineas'] = self::normalize_reference_block_lines( $clean );

			$config['referencias'][] = $clean;
		}

		foreach ( $raw['bases'] ?? array() as $base ) {
			$config['bases'][] = self::normalize_base_row( array_map( 'sanitize_text_field', wp_unslash( $base ) ) );
		}

		$config = self::migrate_separate_caja_saldo_bases( $config );
		$config = self::sync_catalog_bases( $config );
		$config = self::link_reference_lines_to_bases( $config );
		$config['blocks_schema_version'] = self::BLOCKS_SCHEMA_VERSION;

		if ( class_exists( 'Logistica_Warehouse_Inventory', false ) ) {
			$config = Logistica_Warehouse_Inventory::apply_master_lotes_to_cierre_config( $config );
		}

		if ( class_exists( 'Logistica_Order_Closing_Characteristics', false ) ) {
			$config = Logistica_Order_Closing_Characteristics::apply_to_config_bases( $config );
		}

		update_option( self::OPTION, $config );

		if ( $track_lifecycle ) {
			if ( is_array( $before_config ) && ! empty( $before_config ) ) {
				Logistica_Entity_Lifecycle::record_update(
					'cierre_ordenes',
					'config',
					'global',
					$before_config,
					$config,
					__( 'Configuración de cierre de órdenes actualizada', 'logistica' )
				);
			} else {
				Logistica_Entity_Lifecycle::record_create(
					'cierre_ordenes',
					'config',
					'global',
					$config,
					__( 'Configuración de cierre de órdenes creada', 'logistica' )
				);
			}
		}

		$inserted = $wpdb->insert(
			Logistica_Database::table( 'conversiones' ),
			array(
				'tipo_conversion' => 'cierre_ordenes_config',
				'valor_entrada'   => 0,
				'parametros'      => wp_json_encode( $config ),
				'resultado'       => count( $config['referencias'] ),
				'created_by'      => get_current_user_id(),
			)
		);

		if ( false === $inserted && defined( 'WP_DEBUG' ) && WP_DEBUG ) {
			// El historial es auxiliar; la configuracion activa ya esta en wp_options.
			error_log( 'logistica cierre_ordenes snapshot: ' . $wpdb->last_error ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
		}
	}

	/**
	 * Downloads an Excel-compatible HTML file.
	 *
	 * @return void
	 */
	public static function download_excel() {
		Logistica_Order_Closing_Bulk_Excel::download_template();
	}

	/**
	 * Actualiza el lote en todas las bases permanentes que comparten codigo.
	 *
	 * @param string $codigo Código insumo.
	 * @param string $lote Lote activo.
	 * @return int Cantidad de bases actualizadas.
	 */
	public static function sync_lote_by_codigo( $codigo, $lote ) {
		$codigo  = strtoupper( trim( sanitize_text_field( (string) $codigo ) ) );
		$lote    = sanitize_text_field( (string) $lote );
		$changed = 0;

		if ( '' === $codigo ) {
			return 0;
		}

		$config = get_option( self::OPTION, array() );
		if ( empty( $config['bases'] ) || ! is_array( $config['bases'] ) ) {
			return 0;
		}

		foreach ( $config['bases'] as $index => $base ) {
			$base_cod  = strtoupper( trim( (string) ( $base['cod'] ?? '' ) ) );
			$saldo_cod = strtoupper( trim( (string) ( $base['cod_saldo'] ?? '' ) ) );

			if ( $base_cod === $codigo && (string) ( $base['lote'] ?? '' ) !== $lote ) {
				$config['bases'][ $index ]['lote'] = $lote;
				++$changed;
			}

			if ( $saldo_cod === $codigo && (string) ( $base['lote_saldo'] ?? '' ) !== $lote ) {
				$config['bases'][ $index ]['lote_saldo'] = $lote;
				++$changed;
			}
		}

		if ( $changed > 0 ) {
			update_option( self::OPTION, $config );
		}

		return $changed;
	}
}
