<?php
/**
 * Cuadro de pedidos: tableros por cliente y lógica Hornitos.
 *
 * @package Logistica
 */

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

/**
 * Gestiona sub-pestañas, inventario físico, entregas y órdenes de compra.
 */
class Logistica_Pedidos_Board {
	const BOARDS_OPTION           = 'logistica_pedidos_boards';
	const MIN_ENTREGA_SLOTS       = 3;
	const MAX_ORDEN_ENTREGA_SLOTS = 50;

	/**
	 * Slots de entrega parcial por línea de OC (mínimo visible).
	 *
	 * @return int
	 */
	public static function max_orden_entrega_slots() {
		return self::MIN_ENTREGA_SLOTS;
	}

	/**
	 * Cantidad de columnas Cant/Remisión según entregas registradas en el tablero.
	 *
	 * @param string $board_slug Tablero.
	 * @return int
	 */
	public static function get_board_entrega_slot_count( $board_slug ) {
		global $wpdb;

		$board_slug = sanitize_title( (string) $board_slug );
		if ( '' === $board_slug ) {
			return self::MIN_ENTREGA_SLOTS;
		}

		$max_slot = -1;

		$parciales_table = Logistica_Database::table( 'pedidos_orden_linea_entregas' );
		$lineas_table    = Logistica_Database::table( 'pedidos_ordenes_lineas' );
		$ordenes_table   = Logistica_Database::table( 'pedidos_ordenes_compra' );
		if ( $parciales_table && $lineas_table && $ordenes_table ) {
			$max_parcial = $wpdb->get_var(
				$wpdb->prepare(
					"SELECT MAX(p.slot_index) FROM {$parciales_table} p
					INNER JOIN {$lineas_table} l ON l.id = p.orden_linea_id
					INNER JOIN {$ordenes_table} o ON o.id = l.orden_id
					WHERE o.board_slug = %s",
					$board_slug
				)
			);
			if ( null !== $max_parcial && '' !== $max_parcial ) {
				$max_slot = max( $max_slot, (int) $max_parcial );
			}
		}

		$entregas_table = Logistica_Database::table( 'pedidos_entregas' );
		$inv_table      = Logistica_Database::table( 'pedidos_inventario' );
		if ( $entregas_table && $inv_table ) {
			$max_entrega = $wpdb->get_var(
				$wpdb->prepare(
					"SELECT MAX(e.slot_index) FROM {$entregas_table} e
					INNER JOIN {$inv_table} i ON i.id = e.inventario_id
					WHERE i.board_slug = %s",
					$board_slug
				)
			);
			if ( null !== $max_entrega && '' !== $max_entrega ) {
				$max_slot = max( $max_slot, (int) $max_entrega );
			}
		}

		return min(
			self::MAX_ORDEN_ENTREGA_SLOTS,
			max( self::MIN_ENTREGA_SLOTS, $max_slot + 2 )
		);
	}

	/**
	 * Sub-pestañas por defecto del cuadro de pedidos.
	 *
	 * @return array<int, array{slug:string,label:string}>
	 */
	public static function default_boards() {
		return array(
			array(
				'slug'  => 'generales',
				'label' => __( 'Generales', 'logistica' ),
			),
			array(
				'slug'  => 'sin-impresion',
				'label' => __( 'Sin impresión', 'logistica' ),
			),
			array(
				'slug'  => 'colsubsidio',
				'label' => __( 'Colsubsidio', 'logistica' ),
			),
			array(
				'slug'  => 'puerto-rico',
				'label' => __( 'Puerto Rico', 'logistica' ),
			),
			array(
				'slug'  => 'hornitos',
				'label' => __( 'Hornitos', 'logistica' ),
			),
		);
	}

	/**
	 * Estados disponibles en órdenes de compra.
	 *
	 * @return array<string, string>
	 */
	public static function orden_estado_options() {
		if ( class_exists( 'Logistica_Select_Catalog', false ) ) {
			$options = Logistica_Select_Catalog::get_options( 'pedidos_orden_estado' );
			if ( is_array( $options ) && ! empty( $options ) ) {
				return $options;
			}
		}

		return array(
			'sin_confirmar' => __( 'Sin confirmar', 'logistica' ),
			'aprobado'      => __( 'Aprobado', 'logistica' ),
			'cancelado'     => __( 'Cancelado', 'logistica' ),
			'programado'    => __( 'Programado', 'logistica' ),
			'conversion'    => __( 'Conversión', 'logistica' ),
			'impresion'     => __( 'Impresión', 'logistica' ),
			'troquelado'    => __( 'Troquelado', 'logistica' ),
			'descartone'    => __( 'Descartone', 'logistica' ),
			'formado'       => __( 'Formado', 'logistica' ),
			'embalaje'      => __( 'Embalaje', 'logistica' ),
			'finalizado'    => __( 'Finalizado', 'logistica' ),
			'despacho'      => __( 'Despacho', 'logistica' ),
			'entregado'     => __( 'Entregado', 'logistica' ),
		);
	}

	/**
	 * @return array<int, array{slug:string,label:string}>
	 */
	public static function get_boards() {
		$saved = get_option( self::BOARDS_OPTION, array() );
		if ( ! is_array( $saved ) || empty( $saved ) ) {
			return self::default_boards();
		}

		$boards = array();
		foreach ( $saved as $board ) {
			if ( ! is_array( $board ) ) {
				continue;
			}
			$slug  = sanitize_title( (string) ( $board['slug'] ?? '' ) );
			$label = sanitize_text_field( (string) ( $board['label'] ?? '' ) );
			if ( '' === $slug || '' === $label ) {
				continue;
			}
			$boards[] = array(
				'slug'  => $slug,
				'label' => $label,
			);
		}

		return ! empty( $boards ) ? $boards : self::default_boards();
	}

	/**
	 * Tableros con cuadro operativo completo (OC + inventario opcional).
	 *
	 * @return list<string>
	 */
	public static function get_implemented_board_slugs() {
		$slugs = array();
		foreach ( self::get_boards() as $board ) {
			$slug = sanitize_title( (string) ( $board['slug'] ?? '' ) );
			if ( '' !== $slug ) {
				$slugs[] = $slug;
			}
		}

		return $slugs;
	}

	/**
	 * @param string $board_slug Slug del tablero.
	 * @return bool
	 */
	public static function is_board_implemented( $board_slug ) {
		return null !== self::get_board_sheet_config( $board_slug );
	}

	/**
	 * Textos y etiquetas por tablero implementado.
	 *
	 * @param string $board_slug Slug del tablero.
	 * @return array<string, string>|null
	 */
	public static function get_board_sheet_config( $board_slug ) {
		$board_slug = sanitize_title( (string) $board_slug );
		if ( '' === $board_slug ) {
			return null;
		}

		$label         = $board_slug;
		$is_registered = false;
		foreach ( self::get_boards() as $board ) {
			if ( (string) ( $board['slug'] ?? '' ) === $board_slug ) {
				$label         = (string) ( $board['label'] ?? $board_slug );
				$is_registered = true;
				break;
			}
		}

		if ( ! $is_registered ) {
			return null;
		}

		$special_configs = array(
			'generales' => array(
				'uses_inventario'   => false,
				'referencia_source' => 'formado_yield',
				'intro'             => __( 'Gestiona órdenes de compra para pedidos de clase general. Registra entregas parciales (cantidad + factura) por línea cuando el estado es Despacho o Entregado. Marca «Entregado» solo cuando la suma de parciales iguale la cantidad pedida.', 'logistica' ),
				'inventario_hint'   => '',
				'empty_inventario'  => '',
				'reporte_label'     => '',
			),
			'hornitos'  => array(
				'uses_inventario'   => true,
				'referencia_source' => 'inventario',
				'intro'             => __( 'Registra entregas parciales (cantidad + factura) por línea de OC cuando el estado es Despacho o Entregado. Puedes usar Cant. 1, Cant. 2, Cant. 3 y más columnas según necesites hasta completar la cantidad pedida. Cada parcial descuenta el inventario reportado a Hornitos (columna Disponible). El stock Latingraf se ajusta solo con los botones + y −. Marca «Entregado» solo cuando la suma de parciales iguale la cantidad pedida.', 'logistica' ),
				'inventario_hint'   => __( 'Las entregas se sincronizan desde las órdenes de compra. El stock Latingraf se ajusta solo con los botones + y −.', 'logistica' ),
				'empty_inventario'  => __( 'No hay filas de inventario. Agrega una orden de producción reportada.', 'logistica' ),
				'reporte_label'     => __( 'Reporte Hornitos', 'logistica' ),
			),
		);

		if ( isset( $special_configs[ $board_slug ] ) ) {
			$config = $special_configs[ $board_slug ];
		} else {
			$config = array(
				'uses_inventario'   => false,
				'referencia_source' => 'formado_yield',
				'intro'             => sprintf(
					/* translators: %s: style label */
					__( 'Gestiona órdenes de compra para pedidos de %s. Registra entregas parciales (cantidad + factura) por línea cuando el estado es Despacho o Entregado. Marca «Entregado» solo cuando la suma de parciales iguale la cantidad pedida.', 'logistica' ),
					$label
				),
				'inventario_hint'   => '',
				'empty_inventario'  => '',
				'reporte_label'     => '',
			);
		}

		return array_merge(
			$config,
			array(
				'slug'  => $board_slug,
				'label' => $label,
			)
		);
	}

	/**
	 * Indica si el tablero sincroniza entregas con inventario físico.
	 *
	 * @param string $board_slug Slug del tablero.
	 * @return bool
	 */
	public static function board_uses_inventario( $board_slug ) {
		$config = self::get_board_sheet_config( $board_slug );
		if ( ! $config ) {
			return false;
		}

		return ! empty( $config['uses_inventario'] );
	}

	/**
	 * Origen de opciones de referencia en líneas de OC.
	 *
	 * @param string $board_slug Slug del tablero.
	 * @return string formado_yield|inventario
	 */
	public static function get_referencia_source( $board_slug ) {
		$config = self::get_board_sheet_config( $board_slug );
		if ( ! $config ) {
			return 'inventario';
		}

		$source = sanitize_key( (string) ( $config['referencia_source'] ?? 'inventario' ) );

		return in_array( $source, array( 'formado_yield', 'inventario' ), true ) ? $source : 'inventario';
	}

	/**
	 * Referencias del catálogo de rendimiento (planeación formado).
	 *
	 * @return array<int, array{value:string,label:string}>
	 */
	public static function get_formado_referencia_options() {
		$options = array();

		foreach ( Logistica_Forming_Yield::get_entries() as $entry ) {
			$label = trim( (string) ( $entry['label'] ?? '' ) );
			if ( '' === $label ) {
				continue;
			}

			$options[] = array(
				'value' => $label,
				'label' => $label,
			);
		}

		usort(
			$options,
			static function ( $a, $b ) {
				return strcasecmp( (string) ( $a['label'] ?? '' ), (string) ( $b['label'] ?? '' ) );
			}
		);

		return $options;
	}

	/**
	 * Opciones de referencia para líneas de OC según el tablero.
	 *
	 * @param string $board_slug Tablero.
	 * @return array<int, array{value:string,label:string}>
	 */
	public static function get_referencia_options( $board_slug ) {
		if ( 'formado_yield' === self::get_referencia_source( $board_slug ) ) {
			return self::get_formado_referencia_options();
		}

		return self::get_inventario_articulo_options( $board_slug );
	}

	/**
	 * @param array<int, array{slug:string,label:string}> $boards Tableros.
	 * @return bool
	 */
	public static function save_boards( $boards ) {
		$clean = array();
		foreach ( (array) $boards as $board ) {
			if ( ! is_array( $board ) ) {
				continue;
			}
			$slug  = sanitize_title( (string) ( $board['slug'] ?? '' ) );
			$label = sanitize_text_field( (string) ( $board['label'] ?? '' ) );
			if ( '' === $slug || '' === $label ) {
				continue;
			}
			$clean[] = array(
				'slug'  => $slug,
				'label' => $label,
			);
		}

		if ( empty( $clean ) ) {
			return false;
		}

		return update_option( self::BOARDS_OPTION, $clean, false );
	}

	/**
	 * @param string $label Etiqueta visible.
	 * @return array{slug:string,label:string}|null
	 */
	public static function add_board( $label ) {
		$label = sanitize_text_field( (string) $label );
		if ( '' === $label ) {
			return null;
		}

		$boards = self::get_boards();
		$base   = sanitize_title( $label );
		$slug   = $base;
		$index  = 2;
		$slugs  = wp_list_pluck( $boards, 'slug' );

		while ( in_array( $slug, $slugs, true ) ) {
			$slug = $base . '-' . $index;
			++$index;
		}

		$board    = array(
			'slug'  => $slug,
			'label' => $label,
		);
		$boards[] = $board;
		self::save_boards( $boards );

		return $board;
	}

	/**
	 * Elimina lógicamente un estilo de pedido (tablero).
	 *
	 * @param string $slug Slug del tablero.
	 * @return true|WP_Error
	 */
	public static function delete_board( $slug ) {
		$slug   = sanitize_title( (string) $slug );
		$boards = self::get_boards();

		if ( '' === $slug ) {
			return new WP_Error( 'invalid_slug', __( 'Estilo de pedido no válido.', 'logistica' ) );
		}

		if ( count( $boards ) <= 1 ) {
			return new WP_Error( 'last_board', __( 'Debe existir al menos un estilo de pedido.', 'logistica' ) );
		}

		$target = null;
		foreach ( $boards as $board ) {
			if ( (string) ( $board['slug'] ?? '' ) === $slug ) {
				$target = $board;
				break;
			}
		}

		if ( ! $target ) {
			return new WP_Error( 'not_found', __( 'Estilo de pedido no encontrado.', 'logistica' ) );
		}

		return Logistica_Option_Lifecycle::delete(
			'pedidos_board',
			'board',
			$slug,
			(string) ( $target['label'] ?? $slug ),
			array(
				'board' => $target,
			),
			static function () use ( $slug, $boards ) {
				$filtered = array_values(
					array_filter(
						$boards,
						static function ( $board ) use ( $slug ) {
							return (string) ( $board['slug'] ?? '' ) !== $slug;
						}
					)
				);
				self::save_boards( $filtered );
			}
		);
	}

	/**
	 * @param string $board_slug Tablero.
	 * @return array<int, array<string, mixed>>
	 */
	public static function get_inventario_rows( $board_slug ) {
		global $wpdb;

		$board_slug = sanitize_title( (string) $board_slug );
		$table      = Logistica_Database::table( 'pedidos_inventario' );
		if ( ! $table || '' === $board_slug ) {
			return array();
		}

		$not_deleted = Logistica_Soft_Delete::sql_not_deleted( 'pedidos_inventario' );
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT * FROM {$table} WHERE board_slug = %s AND {$not_deleted} ORDER BY referencia ASC, fecha_op ASC, id ASC",
				$board_slug
			),
			ARRAY_A
		);

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

		return array_map( array( __CLASS__, 'hydrate_inventario_row' ), $rows );
	}

	/**
	 * @param array<string, mixed> $row Fila cruda.
	 * @return array<string, mixed>
	 */
	public static function hydrate_inventario_row( $row ) {
		$row_id          = (int) ( $row['id'] ?? 0 );
		$entregas        = self::get_entregas_for_inventario( $row_id );
		$entregas_total  = self::sum_entregas( $entregas );
		$reporte_hornitos = (float) ( $row['reporte_hornitos'] ?? 0 );
		$stock_latingraf  = (float) ( $row['stock_latingraf'] ?? 0 );

		$row['entregas']             = $entregas;
		$row['entregas_total']       = $entregas_total;
		$row['reporte_disponible']   = $reporte_hornitos;
		$row['stock_disponible']     = $stock_latingraf;
		$row['en_produccion']        = ! empty( $row['en_produccion'] ) ? 'si' : 'no';

		return $row;
	}

	/**
	 * @param int $inventario_id ID inventario.
	 * @return array<int, array<string, mixed>>
	 */
	public static function get_entregas_for_inventario( $inventario_id ) {
		global $wpdb;

		$table = Logistica_Database::table( 'pedidos_entregas' );
		if ( ! $table ) {
			return array();
		}

		$not_deleted = Logistica_Soft_Delete::sql_not_deleted( 'pedidos_entregas' );
		$rows        = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT * FROM {$table} WHERE inventario_id = %d AND {$not_deleted} ORDER BY slot_index ASC, id ASC",
				(int) $inventario_id
			),
			ARRAY_A
		);

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

	/**
	 * @param array<int, array<string, mixed>> $entregas Entregas.
	 * @return float
	 */
	public static function sum_entregas( $entregas ) {
		$total = 0.0;
		foreach ( (array) $entregas as $entrega ) {
			$total += (float) ( $entrega['cantidad'] ?? 0 );
		}
		return $total;
	}

	/**
	 * @param string $board_slug Tablero.
	 * @param string $referencia Referencia.
	 * @return float
	 */
	public static function get_referencia_disponible_total( $board_slug, $referencia ) {
		$rows  = self::get_inventario_rows( $board_slug );
		$total = 0.0;
		$ref   = self::normalize_referencia( $referencia );

		foreach ( $rows as $row ) {
			if ( self::normalize_referencia( $row['referencia'] ?? '' ) !== $ref ) {
				continue;
			}
			$total += (float) ( $row['reporte_disponible'] ?? 0 );
		}

		return $total;
	}

	/**
	 * @param string $value Texto.
	 * @return string
	 */
	public static function normalize_referencia( $value ) {
		return strtolower( trim( (string) $value ) );
	}

	/**
	 * Opciones de artículo para órdenes de compra (desde inventario físico).
	 *
	 * @param string $board_slug Tablero.
	 * @return array<int, array{value:string,label:string}>
	 */
	public static function get_inventario_articulo_options( $board_slug ) {
		$rows    = self::get_inventario_rows( $board_slug );
		$options = array();
		$seen    = array();

		foreach ( $rows as $row ) {
			$referencia = trim( (string) ( $row['referencia'] ?? '' ) );
			if ( '' === $referencia ) {
				continue;
			}

			$key = self::normalize_referencia( $referencia );
			if ( isset( $seen[ $key ] ) ) {
				continue;
			}

			$seen[ $key ] = true;
			$item         = trim( (string) ( $row['item'] ?? '' ) );
			$label        = ( '' !== $item && $item !== $referencia ) ? $item . ' — ' . $referencia : $referencia;

			$options[] = array(
				'value' => $referencia,
				'label' => $label,
			);
		}

		usort(
			$options,
			static function ( $a, $b ) {
				return strcasecmp( (string) ( $a['label'] ?? '' ), (string) ( $b['label'] ?? '' ) );
			}
		);

		return $options;
	}

	/**
	 * Incluye un artículo legacy que ya no está en inventario.
	 *
	 * @param array<int, array{value:string,label:string}> $options  Opciones.
	 * @param string                                       $articulo Artículo seleccionado.
	 * @return array<int, array{value:string,label:string}>
	 */
	public static function merge_articulo_option( $options, $articulo ) {
		$articulo = trim( (string) $articulo );
		if ( '' === $articulo ) {
			return $options;
		}

		foreach ( (array) $options as $option ) {
			if ( self::normalize_referencia( $option['value'] ?? '' ) === self::normalize_referencia( $articulo ) ) {
				return $options;
			}
		}

		array_unshift(
			$options,
			array(
				'value' => $articulo,
				'label' => $articulo,
			)
		);

		return $options;
	}

	/**
	 * @param array<string, mixed> $payload Datos.
	 * @return array<string, mixed>|WP_Error
	 */
	public static function save_inventario_row( $payload ) {
		global $wpdb;

		$table = Logistica_Database::table( 'pedidos_inventario' );
		if ( ! $table ) {
			return new WP_Error( 'missing_table', __( 'Tabla de inventario no disponible.', 'logistica' ) );
		}

		$id         = (int) ( $payload['id'] ?? 0 );
		$board_slug = sanitize_title( (string) ( $payload['board_slug'] ?? '' ) );
		$item       = sanitize_text_field( (string) ( $payload['item'] ?? '' ) );
		$referencia = sanitize_text_field( (string) ( $payload['referencia'] ?? '' ) );

		if ( '' === $board_slug || '' === $referencia ) {
			return new WP_Error( 'invalid_data', __( 'Indica tipo de producto y tablero.', 'logistica' ) );
		}

		$cantidad_op      = self::parse_quantity( $payload['cantidad_op'] ?? 0 );
		$reporte_hornitos = self::parse_quantity( $payload['reporte_hornitos'] ?? 0 );
		$valor_und        = self::parse_money( $payload['valor_und'] ?? 0 );
		$fecha_op         = self::parse_date( $payload['fecha_op'] ?? '' );
		$en_produccion    = self::parse_bool( $payload['en_produccion'] ?? false ) ? 1 : 0;

		if ( $reporte_hornitos > $cantidad_op && $cantidad_op > 0 ) {
			return new WP_Error( 'invalid_reporte', __( 'El reporte a Hornitos no puede superar la cantidad de la orden de producción.', 'logistica' ) );
		}

		$data = array(
			'board_slug'       => $board_slug,
			'item'             => $item,
			'referencia'       => $referencia,
			'cantidad_op'      => $cantidad_op,
			'fecha_op'         => $fecha_op,
			'reporte_hornitos' => $reporte_hornitos,
			'valor_und'        => $valor_und,
			'stock_latingraf'  => 0,
			'en_produccion'    => $en_produccion,
			'updated_at'       => current_time( 'mysql' ),
		);

		if ( $id > 0 ) {
			$existing = self::get_inventario_row_by_id( $id );
			$before_raw = $existing ? Logistica_Soft_Delete::get_row( 'pedidos_inventario', $id, true ) : null;
			if ( ! $existing || (string) $existing['board_slug'] !== $board_slug ) {
				return new WP_Error( 'not_found', __( 'Registro de inventario no encontrado.', 'logistica' ) );
			}

			$data['stock_latingraf'] = (float) ( $existing['stock_latingraf'] ?? 0 );

			$updated = $wpdb->update( $table, $data, array( 'id' => $id ), self::inventario_formats(), array( '%d' ) );
			if ( false === $updated ) {
				return new WP_Error( 'db_error', __( 'No se pudo actualizar el inventario.', 'logistica' ) );
			}

			$after_raw = Logistica_Soft_Delete::get_row( 'pedidos_inventario', $id, true );
			if ( $before_raw && $after_raw ) {
				Logistica_Entity_Lifecycle::record_update(
					'pedidos_board',
					'inventario',
					(string) $id,
					$before_raw,
					$after_raw,
					sprintf(
						/* translators: %s: inventory reference */
						__( 'Inventario pedidos %s actualizado', 'logistica' ),
						$referencia
					)
				);
			}

			return self::hydrate_inventario_row( self::get_inventario_row_raw( $id ) );
		}

		$data['created_at'] = current_time( 'mysql' );
		$inserted           = $wpdb->insert( $table, $data, self::inventario_formats( true ) );
		if ( ! $inserted ) {
			return new WP_Error( 'db_error', __( 'No se pudo crear el registro de inventario.', 'logistica' ) );
		}

		$new_id    = (int) $wpdb->insert_id;
		$after_raw = Logistica_Soft_Delete::get_row( 'pedidos_inventario', $new_id, true );
		if ( $after_raw ) {
			Logistica_Entity_Lifecycle::record_create(
				'pedidos_board',
				'inventario',
				(string) $new_id,
				$after_raw,
				sprintf(
					/* translators: %s: inventory reference */
					__( 'Inventario pedidos %s creado', 'logistica' ),
					$referencia
				)
			);
		}

		return self::hydrate_inventario_row( self::get_inventario_row_raw( $new_id ) );
	}

	/**
	 * @param int $id ID.
	 * @return array<string, mixed>|null
	 */
	public static function get_inventario_row_by_id( $id ) {
		$row = self::get_inventario_row_raw( (int) $id );
		return $row ? self::hydrate_inventario_row( $row ) : null;
	}

	/**
	 * @param int $id ID.
	 * @return array<string, mixed>|null
	 */
	private static function get_inventario_row_raw( $id ) {
		global $wpdb;

		$table = Logistica_Database::table( 'pedidos_inventario' );
		if ( ! $table || $id <= 0 ) {
			return null;
		}

		$not_deleted = Logistica_Soft_Delete::sql_not_deleted( 'pedidos_inventario' );
		$row = $wpdb->get_row(
			$wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d AND {$not_deleted}", $id ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			ARRAY_A
		);

		return is_array( $row ) ? $row : null;
	}

	/**
	 * @param int $id ID inventario.
	 * @return bool|WP_Error
	 */
	public static function delete_inventario_row( $id ) {
		$id = (int) $id;
		if ( $id <= 0 ) {
			return new WP_Error( 'invalid_id', __( 'Registro no válido.', 'logistica' ) );
		}

		$row = Logistica_Soft_Delete::get_row( 'pedidos_inventario', $id, true );
		if ( ! $row || Logistica_Soft_Delete::row_is_deleted( $row ) ) {
			return new WP_Error( 'not_found', __( 'Registro no encontrado.', 'logistica' ) );
		}

		return Logistica_Soft_Delete::soft_delete( 'pedidos_inventario', $id, $row );
	}

	/**
	 * @param int   $inventario_id ID inventario.
	 * @param float $cantidad Cantidad a sumar.
	 * @return array<string, mixed>|WP_Error
	 */
	public static function add_stock_latingraf( $inventario_id, $cantidad ) {
		$qty = self::parse_quantity( $cantidad );
		if ( $qty <= 0 ) {
			return new WP_Error( 'invalid_qty', __( 'Indica una cantidad válida para sumar.', 'logistica' ) );
		}

		return self::adjust_stock_latingraf( (int) $inventario_id, $qty );
	}

	/**
	 * @param int   $inventario_id ID inventario.
	 * @param float $cantidad Cantidad a restar.
	 * @return array<string, mixed>|WP_Error
	 */
	public static function subtract_stock_latingraf( $inventario_id, $cantidad ) {
		$qty = self::parse_quantity( $cantidad );
		if ( $qty <= 0 ) {
			return new WP_Error( 'invalid_qty', __( 'Indica una cantidad válida para restar.', 'logistica' ) );
		}

		return self::adjust_stock_latingraf( (int) $inventario_id, -$qty );
	}

	/**
	 * @param int   $inventario_id ID inventario.
	 * @param float $delta Cantidad a sumar o restar.
	 * @return array<string, mixed>|WP_Error
	 */
	private static function adjust_stock_latingraf( $inventario_id, $delta ) {
		global $wpdb;

		$row = self::get_inventario_row_by_id( (int) $inventario_id );
		if ( ! $row ) {
			return new WP_Error( 'not_found', __( 'Registro de inventario no encontrado.', 'logistica' ) );
		}

		$table = Logistica_Database::table( 'pedidos_inventario' );
		if ( ! $table ) {
			return new WP_Error( 'missing_table', __( 'Tabla de inventario no disponible.', 'logistica' ) );
		}

		$new_stock = (float) ( $row['stock_latingraf'] ?? 0 ) + (float) $delta;

		if ( $new_stock < 0 ) {
			return new WP_Error( 'invalid_stock', __( 'El stock Latingraf no puede ser negativo.', 'logistica' ) );
		}

		$updated = $wpdb->update(
			$table,
			array(
				'stock_latingraf' => $new_stock,
				'updated_at'      => current_time( 'mysql' ),
			),
			array( 'id' => (int) $inventario_id ),
			array( '%f', '%s' ),
			array( '%d' )
		);

		if ( false === $updated ) {
			return new WP_Error( 'db_error', __( 'No se pudo actualizar el stock Latingraf.', 'logistica' ) );
		}

		return self::hydrate_inventario_row( self::get_inventario_row_raw( (int) $inventario_id ) );
	}

	/**
	 * Ajusta solo el reporte Hornitos (inventario físico entregable vía OC).
	 *
	 * @param int   $inventario_id ID inventario.
	 * @param float $delta         Cantidad a sumar (negativa para descontar).
	 * @return true|WP_Error
	 */
	private static function adjust_inventario_reporte( $inventario_id, $delta ) {
		global $wpdb;

		$inventario_id = (int) $inventario_id;
		$delta         = (float) $delta;

		if ( $inventario_id <= 0 || abs( $delta ) < 0.000001 ) {
			return true;
		}

		$table = Logistica_Database::table( 'pedidos_inventario' );
		$row   = self::get_inventario_row_raw( $inventario_id );
		if ( ! $table || ! $row ) {
			return new WP_Error( 'not_found', __( 'Registro de inventario no encontrado.', 'logistica' ) );
		}

		$new_reporte = (float) ( $row['reporte_hornitos'] ?? 0 ) + $delta;
		if ( $new_reporte < -0.000001 ) {
			return new WP_Error(
				'negative_inventory',
				__( 'La entrega supera el inventario reportado a Hornitos.', 'logistica' )
			);
		}

		$updated = $wpdb->update(
			$table,
			array(
				'reporte_hornitos' => max( 0, $new_reporte ),
				'updated_at'       => current_time( 'mysql' ),
			),
			array( 'id' => $inventario_id ),
			array( '%f', '%s' ),
			array( '%d' )
		);

		if ( false === $updated ) {
			return new WP_Error( 'db_error', __( 'No se pudo actualizar el inventario.', 'logistica' ) );
		}

		return true;
	}

	/**
	 * Ajusta inventario según el origen de la entrega.
	 *
	 * @param int    $inventario_id ID inventario.
	 * @param float  $delta         Cantidad a sumar (negativa para descontar).
	 * @param string $source        Origen entrega.
	 * @return true|WP_Error
	 */
	private static function adjust_entrega_inventory( $inventario_id, $delta, $source = 'orden_compra' ) {
		if ( 'orden_compra' === sanitize_key( (string) $source ) ) {
			return self::adjust_inventario_reporte( $inventario_id, $delta );
		}

		return self::adjust_inventario_stock_and_reporte( $inventario_id, $delta );
	}

	/**
	 * Ajusta reporte Hornitos y stock Latingraf en la misma magnitud.
	 *
	 * @param int   $inventario_id ID inventario.
	 * @param float $delta         Cantidad a sumar (negativa para descontar).
	 * @return true|WP_Error
	 */
	private static function adjust_inventario_stock_and_reporte( $inventario_id, $delta ) {
		global $wpdb;

		$inventario_id = (int) $inventario_id;
		$delta           = (float) $delta;

		if ( $inventario_id <= 0 || abs( $delta ) < 0.000001 ) {
			return true;
		}

		$table = Logistica_Database::table( 'pedidos_inventario' );
		$row   = self::get_inventario_row_raw( $inventario_id );
		if ( ! $table || ! $row ) {
			return new WP_Error( 'not_found', __( 'Registro de inventario no encontrado.', 'logistica' ) );
		}

		$new_reporte = (float) ( $row['reporte_hornitos'] ?? 0 ) + $delta;
		$new_stock   = (float) ( $row['stock_latingraf'] ?? 0 ) + $delta;

		if ( $new_reporte < -0.000001 || $new_stock < -0.000001 ) {
			return new WP_Error(
				'negative_inventory',
				__( 'La entrega supera el reporte Hornitos o el stock Latingraf disponible.', 'logistica' )
			);
		}

		$updated = $wpdb->update(
			$table,
			array(
				'reporte_hornitos' => max( 0, $new_reporte ),
				'stock_latingraf'  => max( 0, $new_stock ),
				'updated_at'       => current_time( 'mysql' ),
			),
			array( 'id' => $inventario_id ),
			array( '%f', '%f', '%s' ),
			array( '%d' )
		);

		if ( false === $updated ) {
			return new WP_Error( 'db_error', __( 'No se pudo actualizar el inventario.', 'logistica' ) );
		}

		return true;
	}

	/**
	 * Devuelve al inventario las cantidades de entregas antes de eliminarlas.
	 *
	 * @param array<int, array<string, mixed>> $entregas Entregas a restaurar.
	 * @return void
	 */
	private static function restore_entregas_inventory( $entregas ) {
		foreach ( (array) $entregas as $entrega ) {
			$qty = (float) ( $entrega['cantidad'] ?? 0 );
			if ( $qty <= 0 ) {
				continue;
			}

			$inventario_id = (int) ( $entrega['inventario_id'] ?? 0 );
			if ( $inventario_id <= 0 ) {
				continue;
			}

			self::adjust_entrega_inventory(
				$inventario_id,
				$qty,
				(string) ( $entrega['source'] ?? 'orden_compra' )
			);
		}
	}

	/**
	 * Marca entregas de inventario como eliminadas (sincronización masiva, sin papelera individual).
	 *
	 * @param array<int, array<string, mixed>> $entregas Filas de entrega.
	 * @return void
	 */
	private static function mark_entregas_deleted( $entregas ) {
		$ids = array();
		foreach ( (array) $entregas as $entrega ) {
			$id = (int) ( $entrega['id'] ?? 0 );
			if ( $id > 0 ) {
				$ids[] = $id;
			}
		}
		if ( ! empty( $ids ) ) {
			Logistica_Soft_Delete::mark_deleted_batch( 'pedidos_entregas', $ids );
		}
	}

	/**
	 * @param array<string, mixed> $payload Datos entrega.
	 * @return array<string, mixed>|WP_Error
	 */
	public static function save_entrega( $payload ) {
		global $wpdb;

		$source = sanitize_key( (string) ( $payload['source'] ?? 'manual' ) );
		if ( 'orden_compra' !== $source ) {
			return new WP_Error(
				'manual_disabled',
				__( 'Las entregas del inventario se sincronizan desde las órdenes de compra. No se pueden editar manualmente.', 'logistica' )
			);
		}

		$table         = Logistica_Database::table( 'pedidos_entregas' );
		$inventario_id = (int) ( $payload['inventario_id'] ?? 0 );
		$row           = self::get_inventario_row_by_id( $inventario_id );

		if ( ! $table || ! $row ) {
			return new WP_Error( 'not_found', __( 'Registro de inventario no encontrado.', 'logistica' ) );
		}

		$cantidad = self::parse_quantity( $payload['cantidad'] ?? 0 );
		$remision = sanitize_text_field( (string) ( $payload['remision'] ?? '' ) );
		$slot     = max( 0, (int) ( $payload['slot_index'] ?? 0 ) );
		$id       = (int) ( $payload['id'] ?? 0 );
		$old_cantidad = 0.0;
		$existing_entrega = null;

		if ( $id > 0 ) {
			$existing_entrega = Logistica_Soft_Delete::get_row( 'pedidos_entregas', $id, false );
			if ( is_array( $existing_entrega ) ) {
				$old_cantidad = (float) ( $existing_entrega['cantidad'] ?? 0 );
			}
		}

		if ( $cantidad <= 0 ) {
			if ( $id > 0 ) {
				if ( $old_cantidad > 0 ) {
					$restored = self::adjust_entrega_inventory( $inventario_id, $old_cantidad, $source );
					if ( is_wp_error( $restored ) ) {
						return $restored;
					}
				}
				$row_for_delete = is_array( $existing_entrega ?? null )
					? $existing_entrega
					: Logistica_Soft_Delete::get_row( 'pedidos_entregas', $id, true );
				Logistica_Soft_Delete::soft_delete( 'pedidos_entregas', $id, $row_for_delete );
				return array(
					'deleted'       => true,
					'inventario_id' => $inventario_id,
					'inventario'    => self::get_inventario_row_by_id( $inventario_id ),
				);
			}
			return new WP_Error( 'invalid_qty', __( 'Indica una cantidad válida.', 'logistica' ) );
		}

		$inventory_delta = $old_cantidad - $cantidad;
		if ( $inventory_delta < -0.000001 ) {
			$deduct  = -$inventory_delta;
			$row_raw = self::get_inventario_row_raw( $inventario_id );
			if ( ! $row_raw ) {
				return new WP_Error( 'not_found', __( 'Registro de inventario no encontrado.', 'logistica' ) );
			}
			if ( $deduct > (float) ( $row_raw['reporte_hornitos'] ?? 0 ) ) {
				return new WP_Error( 'exceeds_reporte', __( 'La entrega supera el inventario reportado a Hornitos.', 'logistica' ) );
			}
		}

		$data = array(
			'inventario_id'    => $inventario_id,
			'slot_index'       => $slot,
			'cantidad'         => $cantidad,
			'remision'         => $remision,
			'source'           => sanitize_key( (string) ( $payload['source'] ?? 'manual' ) ),
			'orden_linea_id'   => max( 0, (int) ( $payload['orden_linea_id'] ?? 0 ) ),
			'orden_slot_index' => max( 0, (int) ( $payload['orden_slot_index'] ?? 0 ) ),
			'updated_at'       => current_time( 'mysql' ),
		);

		if ( $id > 0 ) {
			$updated = $wpdb->update( $table, $data, array( 'id' => $id ), self::entrega_formats(), array( '%d' ) );
			if ( false === $updated ) {
				return new WP_Error( 'db_error', __( 'No se pudo actualizar la entrega.', 'logistica' ) );
			}
			$entrega_id = $id;
		} else {
			$data['created_at'] = current_time( 'mysql' );
			$inserted           = $wpdb->insert( $table, $data, self::entrega_formats( true ) );
			if ( ! $inserted ) {
				return new WP_Error( 'db_error', __( 'No se pudo registrar la entrega.', 'logistica' ) );
			}
			$entrega_id = (int) $wpdb->insert_id;
		}

		if ( abs( $inventory_delta ) > 0.000001 ) {
			$adjusted = self::adjust_entrega_inventory( $inventario_id, $inventory_delta, $source );
			if ( is_wp_error( $adjusted ) ) {
				if ( $id <= 0 ) {
					Logistica_Soft_Delete::mark_deleted_batch( 'pedidos_entregas', array( $entrega_id ) );
				} else {
					$wpdb->update(
						$table,
						array(
							'cantidad'   => $old_cantidad,
							'updated_at' => current_time( 'mysql' ),
						),
						array( 'id' => $id ),
						array( '%f', '%s' ),
						array( '%d' )
					);
				}
				return $adjusted;
			}
		}

		$inventario = self::get_inventario_row_by_id( $inventario_id );

		return array(
			'entrega'          => $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $entrega_id ), ARRAY_A ),
			'inventario'       => $inventario,
			'referencia_total' => self::get_referencia_disponible_total( (string) ( $inventario['board_slug'] ?? '' ), (string) ( $inventario['referencia'] ?? '' ) ),
		);
	}

	/**
	 * @param string $board_slug Tablero.
	 * @return array<int, array<string, mixed>>
	 */
	public static function get_ordenes_compra( $board_slug ) {
		global $wpdb;

		$board_slug = sanitize_title( (string) $board_slug );
		$table      = Logistica_Database::table( 'pedidos_ordenes_compra' );
		if ( ! $table || '' === $board_slug ) {
			return array();
		}

		$ordenes_not_deleted = Logistica_Soft_Delete::sql_not_deleted( 'pedidos_ordenes_compra' );
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT * FROM {$table} WHERE board_slug = %s AND {$ordenes_not_deleted} ORDER BY fecha_oc DESC, oc_numero DESC, id DESC",
				$board_slug
			),
			ARRAY_A
		);

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

		$lineas_table = Logistica_Database::table( 'pedidos_ordenes_lineas' );
		$output       = array();

		foreach ( $rows as $row ) {
			$lineas = array();
			if ( $lineas_table ) {
				$lineas_not_deleted = Logistica_Soft_Delete::sql_not_deleted( 'pedidos_ordenes_lineas' );
				$lineas = $wpdb->get_results(
					$wpdb->prepare(
						"SELECT * FROM {$lineas_table} WHERE orden_id = %d AND {$lineas_not_deleted} ORDER BY id ASC",
						(int) ( $row['id'] ?? 0 )
					),
					ARRAY_A
				);
				$lineas = is_array( $lineas ) ? $lineas : array();
			}
			$row['lineas'] = array_map( array( __CLASS__, 'hydrate_orden_linea' ), $lineas );
			if ( empty( $row['lineas'] ) ) {
				continue;
			}
			$output[] = $row;
		}

		self::prune_ordenes_sin_lineas( $board_slug );

		return $output;
	}

	/**
	 * Órdenes con líneas entregadas para historial / trazabilidad.
	 *
	 * @param string $board_slug Tablero.
	 * @param string $date_from  Fecha desde (Y-m-d), opcional.
	 * @param string $date_to    Fecha hasta (Y-m-d), opcional.
	 * @return array<int, array<string, mixed>>
	 */
	public static function get_ordenes_compra_entregadas_historial( $board_slug, $date_from = '', $date_to = '' ) {
		$board_slug = sanitize_title( (string) $board_slug );
		$date_from  = self::parse_date( $date_from );
		$date_to    = self::parse_date( $date_to );
		$output     = array();

		foreach ( self::get_ordenes_compra( $board_slug ) as $orden ) {
			$lineas = array();
			foreach ( (array) ( $orden['lineas'] ?? array() ) as $linea ) {
				if ( ! is_array( $linea ) || 'entregado' !== sanitize_key( (string) ( $linea['estado'] ?? '' ) ) ) {
					continue;
				}
				$entregado_at = self::format_entregado_at_iso( $linea );
				if ( $date_from && ( '' === $entregado_at || $entregado_at < $date_from ) ) {
					continue;
				}
				if ( $date_to && ( '' === $entregado_at || $entregado_at > $date_to ) ) {
					continue;
				}
				$lineas[] = $linea;
			}
			if ( empty( $lineas ) ) {
				continue;
			}
			$orden['lineas'] = $lineas;
			$output[]        = $orden;
		}

		return $output;
	}

	/**
	 * Fecha ISO (Y-m-d) en que la línea quedó en estado entregado.
	 *
	 * @param array<string, mixed>|null $linea Línea.
	 * @return string
	 */
	public static function format_entregado_at_iso( $linea ) {
		if ( ! is_array( $linea ) || 'entregado' !== sanitize_key( (string) ( $linea['estado'] ?? '' ) ) ) {
			return '';
		}
		$updated = trim( (string) ( $linea['updated_at'] ?? '' ) );
		if ( preg_match( '/^(\d{4}-\d{2}-\d{2})/', $updated, $matches ) ) {
			return $matches[1];
		}
		return self::format_date_input( $updated );
	}

	/**
	 * Elimina órdenes de compra huérfanas (sin líneas) del tablero.
	 *
	 * @param string $board_slug Slug del tablero.
	 * @return void
	 */
	private static function prune_ordenes_sin_lineas( $board_slug ) {
		global $wpdb;

		$board_slug   = sanitize_title( (string) $board_slug );
		$ordenes_tbl  = Logistica_Database::table( 'pedidos_ordenes_compra' );
		$lineas_table = Logistica_Database::table( 'pedidos_ordenes_lineas' );
		if ( ! $ordenes_tbl || ! $lineas_table || '' === $board_slug ) {
			return;
		}

		$lineas_not_deleted = Logistica_Soft_Delete::sql_not_deleted( 'pedidos_ordenes_lineas', 'l' );

		$orphan_ids = $wpdb->get_col(
			$wpdb->prepare(
				"SELECT o.id FROM {$ordenes_tbl} o
				LEFT JOIN {$lineas_table} l ON l.orden_id = o.id AND {$lineas_not_deleted}
				WHERE o.board_slug = %s AND " . Logistica_Soft_Delete::sql_not_deleted( 'pedidos_ordenes_compra', 'o' ) . ' AND l.id IS NULL',
				$board_slug
			)
		);
		if ( ! is_array( $orphan_ids ) || empty( $orphan_ids ) ) {
			return;
		}

		foreach ( array_map( 'intval', $orphan_ids ) as $orphan_id ) {
			if ( $orphan_id <= 0 ) {
				continue;
			}
			$row = Logistica_Soft_Delete::get_row( 'pedidos_ordenes_compra', $orphan_id, true );
			if ( is_array( $row ) && ! Logistica_Soft_Delete::row_is_deleted( $row ) ) {
				Logistica_Soft_Delete::soft_delete( 'pedidos_ordenes_compra', $orphan_id, $row );
			}
		}
	}

	/**
	 * Agrupa órdenes con el mismo número de OC en una sola entrada visual.
	 *
	 * @param array<int, array<string, mixed>> $ordenes Órdenes con líneas.
	 * @return array<int, array<string, mixed>>
	 */
	private static function consolidate_ordenes_compra( array $ordenes ) {
		$grouped = array();

		foreach ( $ordenes as $orden ) {
			$lineas = isset( $orden['lineas'] ) && is_array( $orden['lineas'] ) ? $orden['lineas'] : array();
			if ( empty( $lineas ) ) {
				continue;
			}

			$oc = trim( (string) ( $orden['oc_numero'] ?? '' ) );
			$key = '' !== $oc ? 'oc:' . $oc : 'id:' . (int) ( $orden['id'] ?? 0 );

			if ( ! isset( $grouped[ $key ] ) ) {
				$grouped[ $key ] = $orden;
				continue;
			}

			$existing = $grouped[ $key ];
			if ( (int) ( $orden['id'] ?? 0 ) < (int) ( $existing['id'] ?? 0 ) ) {
				$orden['lineas'] = array_merge( $lineas, $existing['lineas'] ?? array() );
				$grouped[ $key ] = $orden;
			} else {
				$existing['lineas'] = array_merge( $existing['lineas'] ?? array(), $lineas );
				$grouped[ $key ]    = $existing;
			}
		}

		return array_values( $grouped );
	}

	/**
	 * @param array<string, mixed> $linea Línea.
	 * @return array<string, mixed>
	 */
	public static function hydrate_orden_linea( $linea ) {
		$linea_id = (int) ( $linea['id'] ?? 0 );
		$linea['estado']             = sanitize_key( (string) ( $linea['estado'] ?? 'sin_confirmar' ) );
		$linea['entregas_parciales'] = self::get_orden_linea_entregas( $linea_id );
		$linea['cantidad_despachada'] = self::sum_orden_linea_entregas( $linea['entregas_parciales'] );
		$linea['cantidad_entregada']  = (float) ( $linea['cantidad_entregada'] ?? 0 );

		return $linea;
	}

	/**
	 * @param int $linea_id ID línea OC.
	 * @return array<int, array<string, mixed>>
	 */
	public static function get_orden_linea_entregas( $linea_id ) {
		global $wpdb;

		$table = Logistica_Database::table( 'pedidos_orden_linea_entregas' );
		if ( ! $table || $linea_id <= 0 ) {
			return array();
		}

		$not_deleted = Logistica_Soft_Delete::sql_not_deleted( 'pedidos_orden_linea_entregas' );
		$rows        = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT * FROM {$table} WHERE orden_linea_id = %d AND {$not_deleted} ORDER BY slot_index ASC, id ASC",
				(int) $linea_id
			),
			ARRAY_A
		);

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

	/**
	 * @param array<int, array<string, mixed>> $entregas Parciales.
	 * @return float
	 */
	public static function sum_orden_linea_entregas( $entregas ) {
		$total = 0.0;
		foreach ( (array) $entregas as $entrega ) {
			$total += (float) ( $entrega['cantidad'] ?? 0 );
		}
		return $total;
	}

	/**
	 * @param int $linea_id ID línea.
	 * @return float
	 */
	public static function get_orden_linea_entregada_total( $linea_id ) {
		return self::sum_orden_linea_entregas( self::get_orden_linea_entregas( $linea_id ) );
	}

	/**
	 * Actualiza totales denormalizados en la línea de OC.
	 *
	 * @param int $linea_id ID línea.
	 * @return void
	 */
	private static function refresh_orden_linea_entregada_totals( $linea_id ) {
		global $wpdb;

		$table = Logistica_Database::table( 'pedidos_ordenes_lineas' );
		if ( ! $table || $linea_id <= 0 ) {
			return;
		}

		$linea_row = $wpdb->get_row(
			$wpdb->prepare( "SELECT estado FROM {$table} WHERE id = %d", $linea_id ),
			ARRAY_A
		);
		$estado = is_array( $linea_row ) ? sanitize_key( (string) ( $linea_row['estado'] ?? '' ) ) : '';

		$parciales = self::get_orden_linea_entregas( $linea_id );
		$factura   = '';
		foreach ( $parciales as $parcial ) {
			$text = trim( (string) ( $parcial['factura'] ?? '' ) );
			if ( '' !== $text ) {
				$factura = $text;
			}
		}

		if ( 'finalizado' !== $estado ) {
			$data    = array(
				'factura'    => $factura,
				'updated_at' => current_time( 'mysql' ),
			);
			$formats = array( '%s', '%s' );
		} else {
			$data    = array(
				'factura'    => $factura,
				'updated_at' => current_time( 'mysql' ),
			);
			$formats = array( '%s', '%s' );
		}

		$wpdb->update(
			$table,
			$data,
			array( 'id' => $linea_id ),
			$formats,
			array( '%d' )
		);
	}

	/**
	 * @param array<string, mixed> $linea Línea hidratada.
	 * @return array<string, mixed>
	 */
	public static function format_orden_linea_client( $linea ) {
		if ( ! is_array( $linea ) ) {
			return array();
		}

		$parciales = array();
		foreach ( (array) ( $linea['entregas_parciales'] ?? array() ) as $parcial ) {
			$parciales[] = array(
				'id'           => (int) ( $parcial['id'] ?? 0 ),
				'slot_index'   => (int) ( $parcial['slot_index'] ?? 0 ),
				'cantidad'     => (float) ( $parcial['cantidad'] ?? 0 ),
				'cantidad_fmt' => self::format_quantity( $parcial['cantidad'] ?? 0 ),
				'factura'      => (string) ( $parcial['factura'] ?? '' ),
			);
		}

		$cantidad_pedida      = (float) ( $linea['cantidad'] ?? 0 );
		$cantidad_finalizada  = (float) ( $linea['cantidad_entregada'] ?? 0 );
		$cantidad_despachada  = (float) ( $linea['cantidad_despachada'] ?? self::sum_orden_linea_entregas( $parciales ) );
		$cantidad_pendiente   = self::compute_orden_linea_pendiente( $linea );
		$finalizada_accuracy  = self::format_finalizada_accuracy_badge(
			$cantidad_pedida,
			$cantidad_finalizada,
			(float) ( $linea['cantidad_maxima_recibida'] ?? 0 )
		);

		return array(
			'id'                           => (int) ( $linea['id'] ?? 0 ),
			'orden_id'                     => (int) ( $linea['orden_id'] ?? 0 ),
			'articulo'                     => (string) ( $linea['articulo'] ?? '' ),
			'descripcion'                  => (string) ( $linea['descripcion'] ?? '' ),
			'cantidad'                     => $cantidad_pedida,
			'cantidad_fmt'                 => self::format_quantity( $cantidad_pedida ),
			'cantidad_maxima_recibida'     => (float) ( $linea['cantidad_maxima_recibida'] ?? 0 ),
			'cantidad_maxima_recibida_fmt' => self::format_tolerancia_pct( $linea['cantidad_maxima_recibida'] ?? 0 ),
			'precio_unit'                  => (float) ( $linea['precio_unit'] ?? 0 ),
			'precio_unit_fmt'              => self::format_money( $linea['precio_unit'] ?? 0 ),
			'fecha_entrega'                => self::format_date_input( $linea['fecha_entrega'] ?? '' ),
			'fecha_prorroga'               => self::format_date_input( $linea['fecha_prorroga'] ?? '' ),
			'observaciones'                => (string) ( $linea['observaciones'] ?? '' ),
			'op'                           => (string) ( $linea['op'] ?? '' ),
			'fecha_aprobacion'             => self::format_date_input( $linea['fecha_aprobacion'] ?? '' ),
			'estado'                       => sanitize_key( (string) ( $linea['estado'] ?? 'sin_confirmar' ) ),
			'cantidad_entregada'           => $cantidad_finalizada,
			'cantidad_entregada_fmt'       => self::format_quantity( $cantidad_finalizada ),
			'cantidad_despachada'          => $cantidad_despachada,
			'cantidad_despachada_fmt'      => self::format_quantity( $cantidad_despachada ),
			'cantidad_pendiente'           => $cantidad_pendiente,
			'cantidad_pendiente_fmt'       => self::format_quantity( $cantidad_pendiente ),
			'entregas_parciales'           => $parciales,
			'entrega_completa'             => $cantidad_pedida > 0 && $cantidad_despachada >= $cantidad_pedida,
			'finalizada_accuracy'          => $finalizada_accuracy,
			'entregado_at'                 => self::format_entregado_at_iso( $linea ),
		);
	}

	/**
	 * @param array<string, mixed>|null $orden Orden de compra.
	 * @return array<string, mixed>
	 */
	public static function format_orden_compra_client( $orden ) {
		if ( ! is_array( $orden ) ) {
			return array();
		}

		return array(
			'id'         => (int) ( $orden['id'] ?? 0 ),
			'board_slug' => sanitize_title( (string) ( $orden['board_slug'] ?? '' ) ),
			'fecha_oc'   => self::format_date_input( $orden['fecha_oc'] ?? '' ),
			'oc_numero'  => (string) ( $orden['oc_numero'] ?? '' ),
			'cliente'    => (string) ( $orden['cliente'] ?? '' ),
		);
	}

	/**
	 * Pendiente por entregar en una línea de OC.
	 *
	 * @param array<string, mixed>|null $linea Línea.
	 * @return float
	 */
	public static function compute_orden_linea_pendiente( $linea ) {
		if ( ! is_array( $linea ) ) {
			return 0.0;
		}

		$cantidad   = (float) ( $linea['cantidad'] ?? 0 );
		$despachada = isset( $linea['cantidad_despachada'] )
			? (float) $linea['cantidad_despachada']
			: self::sum_orden_linea_entregas( $linea['entregas_parciales'] ?? array() );

		return max( 0.0, $cantidad - $despachada );
	}

	/**
	 * Tolerancia efectiva (%) para comparar despacho vs cantidad pedida.
	 *
	 * @param float $tolerancia_pct Valor configurado en la línea.
	 * @return float
	 */
	public static function effective_despacho_tolerancia_pct( $tolerancia_pct ) {
		$tolerancia_pct = (float) $tolerancia_pct;
		return $tolerancia_pct > 0 ? $tolerancia_pct : 10.0;
	}

	/**
	 * Diferencia porcentual pedida vs cantidad finalizada.
	 *
	 * @param float $cantidad_pedida Cantidad solicitada.
	 * @param float $finalizada      Cantidad finalizada comercial.
	 * @return float|null
	 */
	public static function compute_finalizada_diff_pct( $cantidad_pedida, $finalizada ) {
		$cantidad_pedida = (float) $cantidad_pedida;
		$finalizada      = (float) $finalizada;
		if ( $cantidad_pedida <= 0 || $finalizada <= 0 ) {
			return null;
		}
		return ( ( $finalizada - $cantidad_pedida ) / $cantidad_pedida ) * 100;
	}

	/**
	 * Etiqueta de variación pedida vs finalizada respecto a tolerancia ±.
	 *
	 * @param float $cantidad_pedida Cantidad solicitada.
	 * @param float $finalizada      Cantidad finalizada.
	 * @param float $tolerancia_pct  Tolerancia ±.
	 * @return array{label:string,class:string,diff_pct:float|null,within_tolerance:bool}
	 */
	public static function format_finalizada_accuracy_badge( $cantidad_pedida, $finalizada, $tolerancia_pct ) {
		$diff = self::compute_finalizada_diff_pct( $cantidad_pedida, $finalizada );
		if ( null === $diff ) {
			return array(
				'label'              => '',
				'class'              => 'is-empty',
				'diff_pct'           => null,
				'within_tolerance'   => true,
			);
		}

		$tolerancia = self::effective_despacho_tolerancia_pct( $tolerancia_pct );
		$within     = abs( $diff ) <= $tolerancia;
		$abs_fmt    = number_format( abs( $diff ), 1, '.', '' );
		$tol_fmt    = (string) (int) round( $tolerancia );

		if ( abs( $diff ) < 0.05 ) {
			$label = sprintf(
				/* translators: %s: tolerance percent */
				__( '0%% (±%s%%)', 'logistica' ),
				$tol_fmt
			);
		} elseif ( $within ) {
			$sign  = $diff > 0 ? '+' : '−';
			$label = sprintf(
				/* translators: 1: signed percent, 2: tolerance percent */
				__( '%1$s%2$s%% (dentro ±%3$s%%)', 'logistica' ),
				$sign,
				$abs_fmt,
				$tol_fmt
			);
		} else {
			$sign  = $diff > 0 ? '+' : '−';
			$verb  = $diff > 0 ? __( 'supera', 'logistica' ) : __( 'falta', 'logistica' );
			$label = sprintf(
				/* translators: 1: sign, 2: percent, 3: supera/falta, 4: tolerance */
				__( '%1$s%2$s%% (%3$s ±%4$s%%)', 'logistica' ),
				$sign,
				$abs_fmt,
				$verb,
				$tol_fmt
			);
		}

		return array(
			'label'              => $label,
			'class'              => $within ? 'is-within-tolerance' : 'is-over-tolerance',
			'diff_pct'           => $diff,
			'within_tolerance'   => $within,
		);
	}

	/**
	 * Valida que el despacho acumulado no supere la cantidad finalizada.
	 *
	 * @param array<string, mixed> $linea_row      Fila de línea.
	 * @param float                $total_despacho Suma de parciales.
	 * @param string               $estado         Estado actual.
	 * @return true|WP_Error
	 */
	public static function validate_despacho_against_finalizada( $linea_row, $total_despacho, $estado ) {
		$estado = sanitize_key( (string) $estado );
		if ( ! in_array( $estado, array( 'despacho', 'entregado' ), true ) ) {
			return true;
		}

		$cantidad_finalizada = (float) ( $linea_row['cantidad_entregada'] ?? 0 );
		$total_despacho      = (float) $total_despacho;
		if ( $cantidad_finalizada <= 0 || $total_despacho <= 0 ) {
			return true;
		}

		if ( $total_despacho > $cantidad_finalizada + 0.0001 ) {
			return new WP_Error(
				'exceeds_finalizada',
				sprintf(
					/* translators: 1: dispatched total, 2: finalized quantity */
					__( 'La suma despachada (%1$s) supera la cantidad finalizada (%2$s).', 'logistica' ),
					self::format_quantity( $total_despacho ),
					self::format_quantity( $cantidad_finalizada )
				)
			);
		}

		return true;
	}

	/**
	 * @param array<string, mixed> $payload Datos.
	 * @return array<string, mixed>|WP_Error
	 */
	public static function save_orden_compra( $payload ) {
		global $wpdb;

		$table = Logistica_Database::table( 'pedidos_ordenes_compra' );
		if ( ! $table ) {
			return new WP_Error( 'missing_table', __( 'Tabla de órdenes no disponible.', 'logistica' ) );
		}

		$id         = (int) ( $payload['id'] ?? 0 );
		$board_slug = sanitize_title( (string) ( $payload['board_slug'] ?? '' ) );
		$fecha_oc   = self::parse_date( $payload['fecha_oc'] ?? '' );
		$oc_numero  = sanitize_text_field( (string) ( $payload['oc_numero'] ?? '' ) );
		$cliente    = sanitize_text_field( (string) ( $payload['cliente'] ?? '' ) );

		if ( '' === $board_slug || '' === $oc_numero ) {
			return new WP_Error( 'invalid_data', __( 'Indica número de OC y tablero.', 'logistica' ) );
		}

		if ( $id <= 0 ) {
			$existing_id = (int) $wpdb->get_var(
				$wpdb->prepare(
					"SELECT id FROM {$table} WHERE board_slug = %s AND oc_numero = %s LIMIT 1",
					$board_slug,
					$oc_numero
				)
			);
			if ( $existing_id > 0 ) {
				$id = $existing_id;
			}
		}

		$data = array(
			'board_slug' => $board_slug,
			'fecha_oc'   => $fecha_oc,
			'oc_numero'  => $oc_numero,
			'cliente'    => $cliente,
			'updated_at' => current_time( 'mysql' ),
		);

		if ( $id > 0 ) {
			$wpdb->update( $table, $data, array( 'id' => $id ), array( '%s', '%s', '%s', '%s', '%s' ), array( '%d' ) );
			$orden_id = $id;
		} else {
			$data['created_at'] = current_time( 'mysql' );
			$wpdb->insert( $table, $data, array( '%s', '%s', '%s', '%s', '%s', '%s' ) );
			$orden_id = (int) $wpdb->insert_id;
		}

		if ( '' !== $cliente && ! self::get_cliente_by_nombre( $cliente ) ) {
			self::save_cliente(
				array(
					'nombre' => $cliente,
				)
			);
		}

		return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $orden_id ), ARRAY_A );
	}

	/**
	 * Indica si una línea de OC puede registrar cantidad entregada y factura.
	 *
	 * @param array<string, mixed>|null $orden  Orden de compra.
	 * @param array<string, mixed>|null $linea  Línea.
	 * @param string                    $estado Estado actual.
	 * @return bool
	 */
	public static function orden_linea_allows_entrega_fields( $orden, $linea, $estado ) {
		if ( ! is_array( $orden ) || ! is_array( $linea ) ) {
			return false;
		}

		$oc_numero = trim( (string) ( $orden['oc_numero'] ?? '' ) );
		$articulo  = trim( (string) ( $linea['articulo'] ?? '' ) );
		$estado    = sanitize_key( (string) $estado );

		if ( '' === $oc_numero || '' === $articulo ) {
			return false;
		}

		return in_array( $estado, array( 'entregado', 'despacho' ), true );
	}

	/**
	 * Estados a partir de los cuales se puede registrar la OP.
	 *
	 * @return array<int, string>
	 */
	public static function orden_linea_op_estados() {
		return array(
			'conversion',
			'impresion',
			'troquelado',
			'descartone',
			'formado',
			'embalaje',
			'despacho',
			'entregado',
			'finalizado',
		);
	}

	/**
	 * @param string $estado Estado de línea.
	 * @return bool
	 */
	public static function orden_linea_allows_op_field( $estado ) {
		return in_array( sanitize_key( (string) $estado ), self::orden_linea_op_estados(), true );
	}

	/**
	 * @param string $estado Estado de línea.
	 * @return bool
	 */
	public static function orden_linea_allows_fecha_aprobacion_field( $estado ) {
		return 'aprobado' === sanitize_key( (string) $estado );
	}

	/**
	 * @param array<string, mixed> $payload Datos línea.
	 * @return array<string, mixed>|WP_Error
	 */
	public static function save_orden_linea( $payload ) {
		global $wpdb;

		$table    = Logistica_Database::table( 'pedidos_ordenes_lineas' );
		$orden_id = (int) ( $payload['orden_id'] ?? 0 );
		if ( ! $table || $orden_id <= 0 ) {
			return new WP_Error( 'invalid_data', __( 'Orden de compra no válida.', 'logistica' ) );
		}

		$id                 = (int) ( $payload['id'] ?? 0 );
		$estado_anterior    = '';
		$existing           = null;
		$articulo                  = sanitize_text_field( (string) ( $payload['articulo'] ?? '' ) );
		$descripcion               = sanitize_textarea_field( (string) ( $payload['descripcion'] ?? '' ) );
		$cantidad                  = self::parse_quantity( $payload['cantidad'] ?? 0 );
		$cantidad_maxima_recibida  = self::parse_tolerancia_pct( $payload['cantidad_maxima_recibida'] ?? 0 );
		$precio_unit               = self::parse_money( $payload['precio_unit'] ?? 0 );
		$fecha_entrega      = self::parse_date( $payload['fecha_entrega'] ?? '' );
		$fecha_prorroga     = self::parse_date( $payload['fecha_prorroga'] ?? '' );
		$observaciones      = sanitize_textarea_field( (string) ( $payload['observaciones'] ?? '' ) );
		$op                 = sanitize_text_field( (string) ( $payload['op'] ?? '' ) );
		$fecha_aprobacion   = self::parse_date( $payload['fecha_aprobacion'] ?? '' );
		$estado             = sanitize_key( (string) ( $payload['estado'] ?? 'sin_confirmar' ) );
		$manual_entregada   = null;
		if ( 'finalizado' === $estado && array_key_exists( 'cantidad_entregada', $payload ) ) {
			$manual_entregada = self::parse_quantity( $payload['cantidad_entregada'] );
		}

		if ( '' === $articulo ) {
			return new WP_Error( 'invalid_data', __( 'Indica el tipo de producto.', 'logistica' ) );
		}

		$orden = $wpdb->get_row(
			$wpdb->prepare(
				'SELECT * FROM ' . Logistica_Database::table( 'pedidos_ordenes_compra' ) . ' WHERE id = %d',
				$orden_id
			),
			ARRAY_A
		);

		if ( ! is_array( $orden ) ) {
			return new WP_Error( 'invalid_data', __( 'Orden de compra no encontrada.', 'logistica' ) );
		}

		$payload_oc = sanitize_text_field( (string) ( $payload['oc_numero'] ?? '' ) );
		if ( '' !== $payload_oc && 0 !== strcasecmp( trim( (string) ( $orden['oc_numero'] ?? '' ) ), $payload_oc ) ) {
			return new WP_Error(
				'oc_mismatch',
				__( 'No se pueden mezclar líneas con números de OC distintos.', 'logistica' )
			);
		}

		$uses_inventario = self::board_uses_inventario( sanitize_title( (string) ( $orden['board_slug'] ?? '' ) ) );

		if ( $id > 0 ) {
			$existing = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ), ARRAY_A );
			if ( ! is_array( $existing ) ) {
				return new WP_Error( 'invalid_data', __( 'Línea de OC no encontrada.', 'logistica' ) );
			}
			if ( (int) ( $existing['orden_id'] ?? 0 ) !== $orden_id ) {
				return new WP_Error( 'invalid_data', __( 'La línea no pertenece a esta orden de compra.', 'logistica' ) );
			}
			$estado_anterior = sanitize_key( (string) ( $existing['estado'] ?? '' ) );
		}

		if ( $id <= 0 && '' !== $articulo ) {
			$existing_linea_id = (int) $wpdb->get_var(
				$wpdb->prepare(
					"SELECT id FROM {$table} WHERE orden_id = %d AND articulo = %s LIMIT 1",
					$orden_id,
					$articulo
				)
			);
			if ( $existing_linea_id > 0 ) {
				$id = $existing_linea_id;
				$existing = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ), ARRAY_A );
				if ( is_array( $existing ) ) {
					$estado_anterior = sanitize_key( (string) ( $existing['estado'] ?? '' ) );
				}
			}
		}

		$linea_preview  = array(
			'articulo' => $articulo,
		);
		$allows_entrega = self::orden_linea_allows_entrega_fields( $orden, $linea_preview, $estado );
		$total_entregado = $id > 0 ? self::get_orden_linea_entregada_total( $id ) : 0.0;

		if ( ! $allows_entrega ) {
			if ( $uses_inventario && $id > 0 && 'entregado' === $estado_anterior ) {
				self::remove_entregas_for_orden_linea( $id );
			}
			if ( 'entregado' === $estado ) {
				return new WP_Error(
					'invalid_entrega',
					__( 'Indica el número de OC y el tipo de producto antes de marcar como entregado.', 'logistica' )
				);
			}
		} elseif ( 'entregado' === $estado && 'entregado' !== $estado_anterior ) {
			if ( $total_entregado <= 0 ) {
				return new WP_Error(
					'invalid_entrega',
					__( 'Registra al menos una entrega parcial antes de marcar como entregado.', 'logistica' )
				);
			}
			if ( $cantidad > 0 && $total_entregado < $cantidad ) {
				return new WP_Error(
					'invalid_entrega',
					__( 'La suma de entregas parciales debe completar la cantidad pedida antes de marcar como entregado.', 'logistica' )
				);
			}
		}

		$existing_entregada = is_array( $existing ) ? (float) ( $existing['cantidad_entregada'] ?? 0 ) : 0.0;
		$existing_factura   = is_array( $existing ) ? (string) ( $existing['factura'] ?? '' ) : '';

		if ( 'finalizado' === $estado && null !== $manual_entregada ) {
			$cantidad_entregada_save = $manual_entregada;
		} else {
			$cantidad_entregada_save = $existing_entregada;
		}

		if ( ! self::orden_linea_allows_op_field( $estado ) ) {
			$op = is_array( $existing ) ? (string) ( $existing['op'] ?? '' ) : '';
		}

		if ( ! self::orden_linea_allows_fecha_aprobacion_field( $estado ) ) {
			$fecha_aprobacion = is_array( $existing ) ? self::format_date_input( $existing['fecha_aprobacion'] ?? '' ) : '';
		} elseif ( '' === $fecha_aprobacion && is_array( $existing ) ) {
			$fecha_aprobacion = self::format_date_input( $existing['fecha_aprobacion'] ?? '' );
		}

		if ( self::orden_linea_allows_fecha_aprobacion_field( $estado ) && '' === $fecha_aprobacion ) {
			return new WP_Error(
				'invalid_fecha_aprobacion',
				__( 'Indica la fecha de aprobación.', 'logistica' )
			);
		}

		$data = array(
			'orden_id'                 => $orden_id,
			'articulo'                 => $articulo,
			'descripcion'              => $descripcion,
			'cantidad'                 => $cantidad,
			'cantidad_maxima_recibida' => $cantidad_maxima_recibida,
			'precio_unit'              => $precio_unit,
			'fecha_entrega'            => $fecha_entrega,
			'fecha_prorroga'           => $fecha_prorroga,
			'observaciones'            => $observaciones,
			'op'                       => $op,
			'fecha_aprobacion'         => $fecha_aprobacion,
			'estado'                   => array_key_exists( $estado, self::orden_estado_options() ) ? $estado : 'sin_confirmar',
			'cantidad_entregada'       => $cantidad_entregada_save,
			'factura'                  => $existing_factura,
			'updated_at'               => current_time( 'mysql' ),
		);

		if ( $id > 0 ) {
			$wpdb->update( $table, $data, array( 'id' => $id ), self::orden_linea_formats(), array( '%d' ) );
			$linea_id = $id;
		} else {
			$data['created_at'] = current_time( 'mysql' );
			$wpdb->insert( $table, $data, self::orden_linea_formats( true ) );
			$linea_id = (int) $wpdb->insert_id;
		}

		$linea = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $linea_id ), ARRAY_A );
		if ( ! is_array( $linea ) ) {
			return new WP_Error( 'db_error', __( 'No se pudo guardar la línea.', 'logistica' ) );
		}

		$linea = self::hydrate_orden_linea( $linea );

		if ( $uses_inventario && $id > 0 && 'entregado' === $estado_anterior && 'entregado' !== $data['estado'] ) {
			self::remove_entregas_for_orden_linea( $linea_id );
		}

		$entered_entrega_estado = in_array( $data['estado'], array( 'despacho', 'entregado' ), true )
			&& ! in_array( $estado_anterior, array( 'despacho', 'entregado' ), true );
		$should_resync          = $uses_inventario && $id > 0 && (
			$entered_entrega_estado
			|| ( 'entregado' === $data['estado'] && 'entregado' !== $estado_anterior )
		);

		if ( $should_resync ) {
			$resync = self::resync_all_parciales_to_inventario( $orden, $linea );
			if ( is_wp_error( $resync ) ) {
				return array(
					'linea'        => self::format_orden_linea_client( $linea ),
					'entrega_sync' => $resync,
				);
			}
		}

		if ( $id > 0 && $estado_anterior !== $data['estado'] && class_exists( 'Logistica_Pedidos_Planeacion_Bridge', false ) ) {
			Logistica_Pedidos_Planeacion_Bridge::sync_linea_estado_to_planeacion_tasks(
				(int) $linea_id,
				(string) $data['estado']
			);
		}

		return array(
			'linea' => self::format_orden_linea_client( $linea ),
		);
	}

	/**
	 * Guarda una entrega parcial de línea de OC.
	 *
	 * @param array<string, mixed> $payload Datos.
	 * @return array<string, mixed>|WP_Error
	 */
	public static function save_orden_linea_entrega( $payload ) {
		global $wpdb;

		$table    = Logistica_Database::table( 'pedidos_orden_linea_entregas' );
		global $wpdb;

		$table    = Logistica_Database::table( 'pedidos_orden_linea_entregas' );
		$linea_id = (int) ( $payload['orden_linea_id'] ?? 0 );
		$slot     = max( 0, (int) ( $payload['slot_index'] ?? 0 ) );

		if ( ! $table || $linea_id <= 0 ) {
			return new WP_Error( 'invalid_data', __( 'Línea de OC no válida.', 'logistica' ) );
		}
		if ( $slot >= self::MAX_ORDEN_ENTREGA_SLOTS ) {
			return new WP_Error( 'invalid_slot', __( 'No hay más slots de entrega parcial.', 'logistica' ) );
		}

		$linea_row = $wpdb->get_row(
			$wpdb->prepare(
				'SELECT * FROM ' . Logistica_Database::table( 'pedidos_ordenes_lineas' ) . ' WHERE id = %d',
				$linea_id
			),
			ARRAY_A
		);
		if ( ! is_array( $linea_row ) ) {
			return new WP_Error( 'not_found', __( 'Línea de OC no encontrada.', 'logistica' ) );
		}

		$orden = $wpdb->get_row(
			$wpdb->prepare(
				'SELECT * FROM ' . Logistica_Database::table( 'pedidos_ordenes_compra' ) . ' WHERE id = %d',
				(int) ( $linea_row['orden_id'] ?? 0 )
			),
			ARRAY_A
		);
		if ( ! is_array( $orden ) ) {
			return new WP_Error( 'invalid_data', __( 'Orden de compra no encontrada.', 'logistica' ) );
		}

		$uses_inventario = self::board_uses_inventario( sanitize_title( (string) ( $orden['board_slug'] ?? '' ) ) );

		$estado    = sanitize_key( (string) ( $linea_row['estado'] ?? 'sin_confirmar' ) );
		$estado_ui = sanitize_key( (string) ( $payload['estado'] ?? '' ) );
		if ( in_array( $estado_ui, array( 'despacho', 'entregado' ), true )
			&& self::orden_linea_allows_entrega_fields( $orden, $linea_row, $estado_ui ) ) {
			$estado_persist = 'entregado' === $estado_ui ? 'despacho' : $estado_ui;
			if ( ! in_array( $estado, array( 'despacho', 'entregado' ), true ) ) {
				$wpdb->update(
					Logistica_Database::table( 'pedidos_ordenes_lineas' ),
					array(
						'estado'     => $estado_persist,
						'updated_at' => current_time( 'mysql' ),
					),
					array( 'id' => $linea_id ),
					array( '%s', '%s' ),
					array( '%d' )
				);
				$linea_row['estado'] = $estado_persist;
				$estado              = $estado_persist;
			}
		}

		if ( ! self::orden_linea_allows_entrega_fields( $orden, $linea_row, $estado ) ) {
			return new WP_Error(
				'invalid_entrega',
				__( 'Las entregas parciales requieren OC, tipo de producto y estado Despacho o Entregado.', 'logistica' )
			);
		}

		$cantidad = self::parse_quantity( $payload['cantidad'] ?? 0 );
		$factura  = sanitize_text_field( (string) ( $payload['factura'] ?? '' ) );
		$cantidad_pedida = (float) ( $linea_row['cantidad'] ?? 0 );

		$parciales    = self::get_orden_linea_entregas( $linea_id );
		$other_total  = 0.0;
		$existing_id  = 0;
		foreach ( $parciales as $parcial ) {
			if ( (int) ( $parcial['slot_index'] ?? -1 ) === $slot ) {
				$existing_id = (int) ( $parcial['id'] ?? 0 );
				continue;
			}
			$other_total += (float) ( $parcial['cantidad'] ?? 0 );
		}

		$total_despacho = $other_total + $cantidad;

		$estado_validacion = in_array( $estado_ui, array( 'despacho', 'entregado' ), true ) ? $estado_ui : $estado;
		$finalizada_check  = self::validate_despacho_against_finalizada( $linea_row, $total_despacho, $estado_validacion );
		if ( is_wp_error( $finalizada_check ) ) {
			return $finalizada_check;
		}

		if ( $cantidad <= 0 ) {
			if ( $existing_id > 0 ) {
				$row_for_delete = null;
				foreach ( $parciales as $parcial ) {
					if ( (int) ( $parcial['id'] ?? 0 ) === $existing_id ) {
						$row_for_delete = $parcial;
						break;
					}
				}
				Logistica_Soft_Delete::soft_delete( 'pedidos_orden_linea_entregas', $existing_id, $row_for_delete );
			}
			if ( $uses_inventario ) {
				self::remove_entregas_for_orden_linea_slot( $linea_id, $slot );
			}
		} else {
			$data = array(
				'orden_linea_id' => $linea_id,
				'slot_index'     => $slot,
				'cantidad'       => $cantidad,
				'factura'        => $factura,
				'updated_at'     => current_time( 'mysql' ),
			);
			if ( $existing_id > 0 ) {
				$updated = $wpdb->update( $table, $data, array( 'id' => $existing_id ), array( '%d', '%d', '%f', '%s', '%s' ), array( '%d' ) );
				if ( false === $updated ) {
					return new WP_Error( 'db_error', __( 'No se pudo actualizar la entrega parcial.', 'logistica' ) );
				}
			} else {
				$data['created_at'] = current_time( 'mysql' );
				$inserted           = $wpdb->insert( $table, $data, array( '%d', '%d', '%f', '%s', '%s', '%s' ) );
				if ( ! $inserted ) {
					return new WP_Error( 'db_error', __( 'No se pudo registrar la entrega parcial.', 'logistica' ) );
				}
			}
		}

		self::refresh_orden_linea_entregada_totals( $linea_id );

		$linea = $wpdb->get_row(
			$wpdb->prepare(
				'SELECT * FROM ' . Logistica_Database::table( 'pedidos_ordenes_lineas' ) . ' WHERE id = %d',
				$linea_id
			),
			ARRAY_A
		);
		if ( ! is_array( $linea ) ) {
			return new WP_Error( 'db_error', __( 'No se pudo actualizar la entrega parcial.', 'logistica' ) );
		}

		$linea       = self::hydrate_orden_linea( $linea );
		$entrega_sync = null;
		if ( $uses_inventario && $cantidad > 0 ) {
			$entrega_sync = self::sync_entrega_parcial_from_orden( $orden, $linea, $slot, $cantidad, $factura );
		} elseif ( 'entregado' === $estado ) {
			$total = self::get_orden_linea_entregada_total( $linea_id );
			if ( $total <= 0 ) {
				$wpdb->update(
					Logistica_Database::table( 'pedidos_ordenes_lineas' ),
					array(
						'estado'     => 'despacho',
						'updated_at' => current_time( 'mysql' ),
					),
					array( 'id' => $linea_id ),
					array( '%s', '%s' ),
					array( '%d' )
				);
				$linea['estado'] = 'despacho';
			}
		}

		if ( 'entregado' === $estado_ui && $cantidad_pedida > 0 ) {
			$total_entregado = self::get_orden_linea_entregada_total( $linea_id );
			if ( $total_entregado >= $cantidad_pedida ) {
				$wpdb->update(
					Logistica_Database::table( 'pedidos_ordenes_lineas' ),
					array(
						'estado'     => 'entregado',
						'updated_at' => current_time( 'mysql' ),
					),
					array( 'id' => $linea_id ),
					array( '%s', '%s' ),
					array( '%d' )
				);
				$linea_refreshed = $wpdb->get_row(
					$wpdb->prepare(
						'SELECT * FROM ' . Logistica_Database::table( 'pedidos_ordenes_lineas' ) . ' WHERE id = %d',
						$linea_id
					),
					ARRAY_A
				);
				if ( is_array( $linea_refreshed ) ) {
					$linea = self::hydrate_orden_linea( $linea_refreshed );
				}
			}
		}

		return array(
			'linea'        => self::format_orden_linea_client( $linea ),
			'entrega_sync' => $entrega_sync,
		);
	}

	/**
	 * Registra en inventario una entrega parcial de OC.
	 *
	 * @param array<string, mixed> $orden       Orden.
	 * @param array<string, mixed> $linea       Línea.
	 * @param int                  $slot_index  Slot parcial OC.
	 * @param float                $cantidad    Cantidad del parcial.
	 * @param string               $factura     Factura del parcial.
	 * @return array<string, mixed>|WP_Error
	 */
	public static function sync_entrega_parcial_from_orden( $orden, $linea, $slot_index, $cantidad, $factura ) {
		$board_slug = sanitize_title( (string) ( $orden['board_slug'] ?? '' ) );
		if ( ! self::board_uses_inventario( $board_slug ) ) {
			return null;
		}

		$referencia = sanitize_text_field( (string) ( $linea['articulo'] ?? '' ) );
		$linea_id   = (int) ( $linea['id'] ?? 0 );
		$cantidad   = (float) $cantidad;
		$factura    = sanitize_text_field( (string) $factura );
		$slot_index = max( 0, (int) $slot_index );

		if ( '' === $board_slug || '' === $referencia || $linea_id <= 0 || $cantidad <= 0 ) {
			return new WP_Error( 'invalid_sync', __( 'Datos insuficientes para registrar la entrega.', 'logistica' ) );
		}

		self::remove_entregas_for_orden_linea_slot( $linea_id, $slot_index );

		$remaining  = $cantidad;
		$rows       = self::get_inventario_rows( $board_slug );
		$created    = array();
		$slot_fixed = false;

		foreach ( $rows as $row ) {
			if ( $remaining <= 0 ) {
				break;
			}
			if ( self::normalize_referencia( $row['referencia'] ?? '' ) !== self::normalize_referencia( $referencia ) ) {
				continue;
			}

			$disponible_reporte = (float) ( $row['reporte_disponible'] ?? 0 );
			$alloc              = min( $remaining, max( 0, $disponible_reporte ) );
			if ( $alloc <= 0 ) {
				continue;
			}

			$inventario_id = (int) ( $row['id'] ?? 0 );
			$entrega_slot  = ! $slot_fixed ? $slot_index : self::next_entrega_slot( $inventario_id );

			$result = self::save_entrega(
				array(
					'inventario_id'    => $inventario_id,
					'cantidad'         => $alloc,
					'remision'         => $factura,
					'source'           => 'orden_compra',
					'orden_linea_id'   => $linea_id,
					'orden_slot_index' => $slot_index,
					'slot_index'       => $entrega_slot,
				)
			);

			if ( is_wp_error( $result ) ) {
				return $result;
			}

			$created[] = $result;
			$remaining -= $alloc;
			$slot_fixed = true;
		}

		if ( $remaining > 0 ) {
			if ( empty( $created ) ) {
				return new WP_Error(
					'no_inventario',
					sprintf(
						/* translators: %s: tipo de producto */
						__( 'No hay inventario físico con el tipo de producto «%s». Verifica que el artículo de la OC coincida con el inventario.', 'logistica' ),
						$referencia
					)
				);
			}

			return new WP_Error(
				'partial_sync',
				sprintf(
					/* translators: 1: cantidad registrada, 2: cantidad solicitada */
					__( 'Solo se pudieron registrar %1$s de %2$s unidades en inventario.', 'logistica' ),
					number_format_i18n( $cantidad - $remaining, 0 ),
					number_format_i18n( $cantidad, 0 )
				),
				array(
					'created'   => $created,
					'remaining' => $remaining,
				)
			);
		}

		return array(
			'created'          => $created,
			'referencia_total' => self::get_referencia_disponible_total( $board_slug, $referencia ),
		);
	}

	/**
	 * Re-sincroniza todos los parciales de una línea al inventario.
	 *
	 * @param array<string, mixed> $orden    Orden.
	 * @param array<string, mixed> $linea    Línea hidratada.
	 * @return array<string, mixed>|WP_Error|null
	 */
	public static function resync_all_parciales_to_inventario( $orden, $linea ) {
		$board_slug = sanitize_title( (string) ( $orden['board_slug'] ?? '' ) );
		if ( ! self::board_uses_inventario( $board_slug ) ) {
			return null;
		}

		$parciales = (array) ( $linea['entregas_parciales'] ?? array() );
		if ( empty( $parciales ) ) {
			return null;
		}

		$last_sync = null;
		foreach ( $parciales as $parcial ) {
			$cantidad = (float) ( $parcial['cantidad'] ?? 0 );
			if ( $cantidad <= 0 ) {
				continue;
			}
			$last_sync = self::sync_entrega_parcial_from_orden(
				$orden,
				$linea,
				(int) ( $parcial['slot_index'] ?? 0 ),
				$cantidad,
				(string) ( $parcial['factura'] ?? '' )
			);
			if ( is_wp_error( $last_sync ) ) {
				return $last_sync;
			}
		}

		return $last_sync;
	}

	/**
	 * @param int $linea_id ID línea OC.
	 * @return void
	 */
	private static function remove_entregas_for_orden_linea( $linea_id ) {
		global $wpdb;

		$table = Logistica_Database::table( 'pedidos_entregas' );
		if ( ! $table || $linea_id <= 0 ) {
			return;
		}

		$not_deleted = Logistica_Soft_Delete::sql_not_deleted( 'pedidos_entregas' );
		$entregas    = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT * FROM {$table} WHERE orden_linea_id = %d AND source = %s AND {$not_deleted}",
				(int) $linea_id,
				'orden_compra'
			),
			ARRAY_A
		);
		if ( is_array( $entregas ) && ! empty( $entregas ) ) {
			self::restore_entregas_inventory( $entregas );
			self::mark_entregas_deleted( $entregas );
		}
	}

	/**
	 * Elimina entregas de inventario ligadas a un parcial de OC.
	 *
	 * @param int $linea_id   ID línea OC.
	 * @param int $slot_index Slot parcial OC.
	 * @return void
	 */
	private static function remove_entregas_for_orden_linea_slot( $linea_id, $slot_index ) {
		global $wpdb;

		$table = Logistica_Database::table( 'pedidos_entregas' );
		if ( ! $table || $linea_id <= 0 ) {
			return;
		}

		$slot_index = max( 0, (int) $slot_index );
		$not_deleted = Logistica_Soft_Delete::sql_not_deleted( 'pedidos_entregas' );
		$entregas   = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT * FROM {$table} WHERE orden_linea_id = %d AND orden_slot_index = %d AND source = %s AND {$not_deleted}",
				(int) $linea_id,
				$slot_index,
				'orden_compra'
			),
			ARRAY_A
		);
		if ( is_array( $entregas ) && ! empty( $entregas ) ) {
			self::restore_entregas_inventory( $entregas );
			self::mark_entregas_deleted( $entregas );
		}
	}

	/**
	 * @param int $inventario_id ID inventario.
	 * @return int
	 */
	private static function next_entrega_slot( $inventario_id ) {
		$entregas = self::get_entregas_for_inventario( $inventario_id );
		$max      = -1;
		foreach ( $entregas as $entrega ) {
			$max = max( $max, (int) ( $entrega['slot_index'] ?? 0 ) );
		}
		return $max + 1;
	}

	/**
	 * @param int $linea_id ID línea.
	 * @return bool|WP_Error
	 */
	public static function delete_orden_linea( $linea_id ) {
		global $wpdb;

		$linea_id = (int) $linea_id;
		$table    = Logistica_Database::table( 'pedidos_ordenes_lineas' );
		if ( ! $table || $linea_id <= 0 ) {
			return new WP_Error( 'invalid_id', __( 'Línea no válida.', 'logistica' ) );
		}

		$row = Logistica_Soft_Delete::get_row( 'pedidos_ordenes_lineas', $linea_id, true );
		if ( ! $row || Logistica_Soft_Delete::row_is_deleted( $row ) ) {
			return new WP_Error( 'not_found', __( 'Línea no encontrada.', 'logistica' ) );
		}

		$linea_row = $wpdb->get_row(
			$wpdb->prepare( "SELECT orden_id FROM {$table} WHERE id = %d", $linea_id ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			ARRAY_A
		);
		if ( is_array( $linea_row ) ) {
			$orden = $wpdb->get_row(
				$wpdb->prepare(
					'SELECT board_slug FROM ' . Logistica_Database::table( 'pedidos_ordenes_compra' ) . ' WHERE id = %d',
					(int) ( $linea_row['orden_id'] ?? 0 )
				),
				ARRAY_A
			);
			if ( is_array( $orden ) && self::board_uses_inventario( sanitize_title( (string) ( $orden['board_slug'] ?? '' ) ) ) ) {
				self::remove_entregas_for_orden_linea( $linea_id );
			}
		}

		return Logistica_Soft_Delete::soft_delete( 'pedidos_ordenes_lineas', $linea_id, $row );
	}

	/**
	 * @param mixed $value Valor.
	 * @return float
	 */
	public static function parse_quantity( $value ) {
		if ( is_int( $value ) || is_float( $value ) ) {
			return (float) $value;
		}

		if ( is_numeric( $value ) && false === strpos( (string) $value, ',' ) && false === strpos( (string) $value, '.' ) ) {
			return (float) $value;
		}

		$text = trim( (string) $value );
		if ( '' === $text ) {
			return 0.0;
		}

		$text = preg_replace( '/[^\d,.\-\s]/', '', $text );
		$text = str_replace( ' ', '', $text );

		if ( '' === $text || '-' === $text ) {
			return 0.0;
		}

		$has_comma = false !== strpos( $text, ',' );
		$has_dot   = false !== strpos( $text, '.' );

		if ( $has_comma && $has_dot ) {
			$last_comma = strrpos( $text, ',' );
			$last_dot   = strrpos( $text, '.' );

			if ( $last_comma > $last_dot ) {
				$text = str_replace( '.', '', $text );
				$text = str_replace( ',', '.', $text );
			} else {
				$text = str_replace( ',', '', $text );
			}
		} elseif ( $has_comma ) {
			if ( preg_match( '/^\d{1,3}(,\d{3})+$/', $text ) ) {
				$text = str_replace( ',', '', $text );
			} else {
				$text = str_replace( ',', '.', $text );
			}
		} elseif ( $has_dot && ! $has_comma ) {
			$dot_parts = explode( '.', $text );
			$is_thousands = count( $dot_parts ) > 1;
			if ( $is_thousands ) {
				foreach ( $dot_parts as $index => $part ) {
					if ( ( 0 === $index && ! preg_match( '/^\d+$/', $part ) ) || ( $index > 0 && ! preg_match( '/^\d{3}$/', $part ) ) ) {
						$is_thousands = false;
						break;
					}
				}
			}
			if ( $is_thousands ) {
				$text = implode( '', $dot_parts );
			} elseif ( preg_match( '/^\d{1,3}(\.\d{3})+$/', $text ) ) {
				$text = str_replace( '.', '', $text );
			}
		}

		return is_numeric( $text ) ? (float) $text : 0.0;
	}

	/**
	 * @param mixed $value Valor.
	 * @return float
	 */
	public static function parse_money( $value ) {
		if ( is_int( $value ) || is_float( $value ) ) {
			return (float) $value;
		}

		$text = trim( (string) $value );
		if ( '' === $text ) {
			return 0.0;
		}

		$text = preg_replace( '/^\$/u', '', $text );
		$text = preg_replace( '/\s*(COP|cop)\s*$/u', '', $text );
		$text = trim( $text );

		return self::parse_quantity( $text );
	}

	/**
	 * @param float $value Valor monetario.
	 * @return string
	 */
	public static function format_money( $value ) {
		$number = (float) $value;
		if ( abs( $number ) < 0.000001 ) {
			return '';
		}

		return '$' . self::format_quantity( $value );
	}

	/**
	 * @param mixed $value Fecha.
	 * @return string|null
	 */
	public static function parse_date( $value ) {
		$text = trim( (string) $value );
		if ( '' === $text ) {
			return null;
		}

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

		if ( preg_match( '/^(\d{1,2})[\/\.](\d{1,2})[\/\.](\d{2,4})$/', $text, $matches ) ) {
			$day  = (int) $matches[1];
			$mon  = (int) $matches[2];
			$year = (int) $matches[3];
			if ( $year < 100 ) {
				$year += 2000;
			}
			return sprintf( '%04d-%02d-%02d', $year, $mon, $day );
		}

		return null;
	}

	/**
	 * Días entre fecha de aprobación y fecha de entrega efectiva.
	 *
	 * @param string $fecha_entrega    Fecha entrega Y-m-d (efectiva si hay prórroga).
	 * @param string $fecha_aprobacion Fecha aprobación Y-m-d.
	 * @return int|null
	 */
	public static function compute_dias_entrega_desde_aprobacion( $fecha_entrega, $fecha_aprobacion ) {
		$entrega = self::parse_date( $fecha_entrega );
		$aprob   = self::parse_date( $fecha_aprobacion );
		if ( null === $entrega || null === $aprob ) {
			return null;
		}

		$t_entrega = strtotime( $entrega . ' 00:00:00' );
		$t_aprob   = strtotime( $aprob . ' 00:00:00' );
		if ( false === $t_entrega || false === $t_aprob ) {
			return null;
		}

		return (int) round( ( $t_entrega - $t_aprob ) / DAY_IN_SECONDS );
	}

	/**
	 * @param mixed $value Valor.
	 * @return bool
	 */
	public static function parse_bool( $value ) {
		if ( is_bool( $value ) ) {
			return $value;
		}
		$text = strtolower( trim( (string) $value ) );
		return in_array( $text, array( '1', 'true', 'yes', 'si', 'sí', 'on' ), true );
	}

	/**
	 * @param bool $with_created Incluir created_at.
	 * @return array<int, string>
	 */
	private static function inventario_formats( $with_created = false ) {
		$formats = array( '%s', '%s', '%s', '%f', '%s', '%f', '%f', '%f', '%d', '%s' );
		if ( $with_created ) {
			$formats[] = '%s';
		}
		return $formats;
	}

	/**
	 * @param bool $with_created Incluir created_at.
	 * @return array<int, string>
	 */
	private static function entrega_formats( $with_created = false ) {
		$formats = array( '%d', '%d', '%f', '%s', '%s', '%d', '%d', '%s' );
		if ( $with_created ) {
			$formats[] = '%s';
		}
		return $formats;
	}

	/**
	 * @param bool $with_created Incluir created_at.
	 * @return array<int, string>
	 */
	private static function orden_linea_formats( $with_created = false ) {
		$formats = array( '%d', '%s', '%s', '%f', '%f', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%f', '%s', '%s' );
		if ( $with_created ) {
			$formats[] = '%s';
		}
		return $formats;
	}

	/**
	 * @param float $value Cantidad.
	 * @return string
	 */
	public static function format_quantity( $value ) {
		$number = (float) $value;
		if ( abs( $number - round( $number ) ) < 0.00001 ) {
			return number_format_i18n( (int) round( $number ), 0 );
		}
		return number_format_i18n( $number, 2 );
	}

	/**
	 * Formato de tolerancia ± % (solo número; el símbolo se muestra en la UI).
	 *
	 * @param mixed $value Porcentaje.
	 * @return string
	 */
	public static function format_tolerancia_pct( $value ) {
		$number = max( 0.0, (float) $value );
		if ( abs( $number - round( $number ) ) < 0.00001 ) {
			return number_format_i18n( (int) round( $number ), 0 );
		}

		return number_format_i18n( $number, 1 );
	}

	/**
	 * @param mixed $value Valor con o sin %.
	 * @return float
	 */
	public static function parse_tolerancia_pct( $value ) {
		if ( is_int( $value ) || is_float( $value ) ) {
			return max( 0.0, (float) $value );
		}

		$text = trim( (string) $value );
		if ( '' === $text ) {
			return 0.0;
		}

		$text = str_replace( array( '±', '+', '-', '%' ), '', $text );
		$text = trim( $text );

		return max( 0.0, self::parse_quantity( $text ) );
	}

	/**
	 * Formato de fila de inventario para respuestas AJAX.
	 *
	 * @param array<string, mixed> $row Fila hidratada.
	 * @return array<string, mixed>
	 */
	public static function format_inventario_client( $row ) {
		if ( ! is_array( $row ) ) {
			return array();
		}

		$entregas = array();
		foreach ( (array) ( $row['entregas'] ?? array() ) as $entrega ) {
			$entregas[] = array(
				'id'          => (int) ( $entrega['id'] ?? 0 ),
				'slot_index'  => (int) ( $entrega['slot_index'] ?? 0 ),
				'cantidad'    => (float) ( $entrega['cantidad'] ?? 0 ),
				'cantidad_fmt'=> self::format_quantity( $entrega['cantidad'] ?? 0 ),
				'remision'    => (string) ( $entrega['remision'] ?? '' ),
			);
		}

		return array(
			'id'                     => (int) ( $row['id'] ?? 0 ),
			'board_slug'             => (string) ( $row['board_slug'] ?? '' ),
			'item'                   => (string) ( $row['item'] ?? '' ),
			'referencia'             => (string) ( $row['referencia'] ?? '' ),
			'cantidad_op'            => (float) ( $row['cantidad_op'] ?? 0 ),
			'cantidad_op_fmt'        => self::format_quantity( $row['cantidad_op'] ?? 0 ),
			'fecha_op'               => (string) ( $row['fecha_op'] ?? '' ),
			'reporte_hornitos'       => (float) ( $row['reporte_hornitos'] ?? 0 ),
			'reporte_hornitos_fmt'   => self::format_quantity( $row['reporte_hornitos'] ?? 0 ),
			'reporte_disponible'     => (float) ( $row['reporte_disponible'] ?? 0 ),
			'reporte_disponible_fmt' => self::format_quantity( $row['reporte_disponible'] ?? 0 ),
			'stock_latingraf'        => (float) ( $row['stock_latingraf'] ?? 0 ),
			'stock_latingraf_fmt'    => self::format_quantity( $row['stock_latingraf'] ?? 0 ),
			'stock_disponible'       => (float) ( $row['stock_disponible'] ?? 0 ),
			'stock_disponible_fmt'   => self::format_quantity( $row['stock_disponible'] ?? 0 ),
			'valor_und'              => (float) ( $row['valor_und'] ?? 0 ),
			'valor_und_fmt'          => self::format_money( $row['valor_und'] ?? 0 ),
			'en_produccion'          => (string) ( $row['en_produccion'] ?? 'no' ),
			'entregas'               => $entregas,
		);
	}

	/**
	 * @param string|null $value Fecha ISO.
	 * @return string
	 */
	public static function format_date_display( $value ) {
		if ( empty( $value ) ) {
			return '';
		}
		$time = strtotime( (string) $value );
		return $time ? gmdate( 'd/m/Y', $time ) : '';
	}

	/**
	 * Formato ISO para inputs type="date".
	 *
	 * @param string|null $value Fecha ISO.
	 * @return string
	 */
	public static function format_date_input( $value ) {
		if ( empty( $value ) ) {
			return '';
		}
		$time = strtotime( (string) $value );
		return $time ? gmdate( 'Y-m-d', $time ) : '';
	}

	/**
	 * Lista de clientes del cuadro de pedidos.
	 *
	 * @return array<int, array<string, mixed>>
	 */
	public static function get_clientes() {
		global $wpdb;

		$table = Logistica_Database::table( 'pedidos_clientes' );
		if ( ! $table ) {
			return array();
		}

		$rows = $wpdb->get_results( "SELECT * FROM {$table} ORDER BY nombre ASC", ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		if ( ! is_array( $rows ) ) {
			return array();
		}

		return array_map( array( self::class, 'format_cliente_client' ), $rows );
	}

	/**
	 * @param int $id ID cliente.
	 * @return array<string, mixed>|null
	 */
	public static function get_cliente_by_id( $id ) {
		global $wpdb;

		$id    = (int) $id;
		$table = Logistica_Database::table( 'pedidos_clientes' );
		if ( $id <= 0 || ! $table ) {
			return null;
		}

		$row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ), ARRAY_A );
		return is_array( $row ) ? self::format_cliente_client( $row ) : null;
	}

	/**
	 * @param string $nombre Nombre del cliente.
	 * @return array<string, mixed>|null
	 */
	public static function get_cliente_by_nombre( $nombre ) {
		global $wpdb;

		$nombre = trim( sanitize_text_field( (string) $nombre ) );
		$table  = Logistica_Database::table( 'pedidos_clientes' );
		if ( '' === $nombre || ! $table ) {
			return null;
		}

		$row = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT * FROM {$table} WHERE nombre = %s LIMIT 1",
				$nombre
			),
			ARRAY_A
		);

		return is_array( $row ) ? self::format_cliente_client( $row ) : null;
	}

	/**
	 * Importa clientes únicos desde órdenes de compra existentes.
	 *
	 * @return void
	 */
	public static function migrate_pedidos_clientes_from_ordenes() {
		if ( get_option( 'logistica_pedidos_clientes_migrated_v1' ) ) {
			return;
		}

		global $wpdb;

		$clientes_table = Logistica_Database::table( 'pedidos_clientes' );
		$ordenes_table  = Logistica_Database::table( 'pedidos_ordenes_compra' );
		if ( ! $clientes_table || ! $ordenes_table ) {
			return;
		}

		$clientes = $wpdb->get_col( "SELECT DISTINCT cliente FROM {$ordenes_table} WHERE cliente <> '' ORDER BY cliente ASC" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		if ( is_array( $clientes ) ) {
			foreach ( $clientes as $cliente ) {
				$nombre = trim( sanitize_text_field( (string) $cliente ) );
				if ( '' === $nombre || self::get_cliente_by_nombre( $nombre ) ) {
					continue;
				}
				$wpdb->insert(
					$clientes_table,
					array(
						'nombre'     => $nombre,
						'created_at' => current_time( 'mysql' ),
						'updated_at' => current_time( 'mysql' ),
					),
					array( '%s', '%s', '%s' )
				);
			}
		}

		update_option( 'logistica_pedidos_clientes_migrated_v1', '1', false );
	}

	/**
	 * @param array<string, mixed> $payload Datos del cliente.
	 * @return array<string, mixed>|WP_Error
	 */
	public static function save_cliente( $payload ) {
		global $wpdb;

		$table = Logistica_Database::table( 'pedidos_clientes' );
		if ( ! $table ) {
			return new WP_Error( 'missing_table', __( 'Tabla de clientes no disponible.', 'logistica' ) );
		}

		$id       = (int) ( $payload['id'] ?? 0 );
		$nombre   = sanitize_text_field( (string) ( $payload['nombre'] ?? '' ) );
		$codigo   = sanitize_text_field( (string) ( $payload['codigo'] ?? '' ) );
		$contacto = sanitize_text_field( (string) ( $payload['contacto'] ?? '' ) );
		$telefono = sanitize_text_field( (string) ( $payload['telefono'] ?? '' ) );
		$email    = sanitize_email( (string) ( $payload['email'] ?? '' ) );
		$notas    = sanitize_textarea_field( (string) ( $payload['notas'] ?? '' ) );

		if ( '' === $nombre ) {
			return new WP_Error( 'invalid_data', __( 'Indica el nombre del cliente.', 'logistica' ) );
		}

		$old_nombre = '';
		if ( $id > 0 ) {
			$existing = self::get_cliente_by_id( $id );
			if ( ! $existing ) {
				return new WP_Error( 'invalid_data', __( 'Cliente no encontrado.', 'logistica' ) );
			}
			$old_nombre = (string) ( $existing['nombre'] ?? '' );
		}

		$duplicate = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT id FROM {$table} WHERE nombre = %s AND id <> %d LIMIT 1",
				$nombre,
				$id
			)
		);
		if ( $duplicate ) {
			return new WP_Error( 'duplicate', __( 'Ya existe un cliente con ese nombre.', 'logistica' ) );
		}

		$data = array(
			'nombre'     => $nombre,
			'codigo'     => $codigo,
			'contacto'   => $contacto,
			'telefono'   => $telefono,
			'email'      => $email,
			'notas'      => $notas,
			'updated_at' => current_time( 'mysql' ),
		);

		if ( $id > 0 ) {
			$wpdb->update( $table, $data, array( 'id' => $id ), array( '%s', '%s', '%s', '%s', '%s', '%s', '%s' ), array( '%d' ) );
			$cliente_id = $id;
		} else {
			$data['created_at'] = current_time( 'mysql' );
			$wpdb->insert( $table, $data, array( '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ) );
			$cliente_id = (int) $wpdb->insert_id;
		}

		if ( '' !== $old_nombre && $old_nombre !== $nombre ) {
			self::rename_cliente_in_ordenes( $old_nombre, $nombre );
		}

		$cliente = self::get_cliente_by_id( $cliente_id );
		return $cliente ? $cliente : new WP_Error( 'save_failed', __( 'No se pudo guardar el cliente.', 'logistica' ) );
	}

	/**
	 * @param int $id ID cliente.
	 * @return true|WP_Error
	 */
	public static function delete_cliente( $id ) {
		global $wpdb;

		$id    = (int) $id;
		$table = Logistica_Database::table( 'pedidos_clientes' );
		if ( $id <= 0 || ! $table ) {
			return new WP_Error( 'invalid_data', __( 'Cliente no válido.', 'logistica' ) );
		}

		$deleted = $wpdb->delete( $table, array( 'id' => $id ), array( '%d' ) );
		if ( ! $deleted ) {
			return new WP_Error( 'delete_failed', __( 'No se pudo eliminar el cliente.', 'logistica' ) );
		}

		return true;
	}

	/**
	 * Actualiza el nombre del cliente en órdenes de compra existentes.
	 *
	 * @param string $old_nombre Nombre anterior.
	 * @param string $new_nombre Nombre nuevo.
	 * @return void
	 */
	public static function rename_cliente_in_ordenes( $old_nombre, $new_nombre ) {
		global $wpdb;

		$old_nombre = trim( sanitize_text_field( (string) $old_nombre ) );
		$new_nombre = trim( sanitize_text_field( (string) $new_nombre ) );
		$table      = Logistica_Database::table( 'pedidos_ordenes_compra' );
		if ( '' === $old_nombre || '' === $new_nombre || $old_nombre === $new_nombre || ! $table ) {
			return;
		}

		$wpdb->update(
			$table,
			array(
				'cliente'    => $new_nombre,
				'updated_at' => current_time( 'mysql' ),
			),
			array( 'cliente' => $old_nombre ),
			array( '%s', '%s' ),
			array( '%s' )
		);
	}

	/**
	 * @param array<string, mixed> $row Fila de cliente.
	 * @return array<string, mixed>
	 */
	public static function format_cliente_client( $row ) {
		if ( ! is_array( $row ) ) {
			return array();
		}

		return array(
			'id'       => (int) ( $row['id'] ?? 0 ),
			'nombre'   => (string) ( $row['nombre'] ?? '' ),
			'codigo'   => (string) ( $row['codigo'] ?? '' ),
			'contacto' => (string) ( $row['contacto'] ?? '' ),
			'telefono' => (string) ( $row['telefono'] ?? '' ),
			'email'    => (string) ( $row['email'] ?? '' ),
			'notas'    => (string) ( $row['notas'] ?? '' ),
		);
	}
}
