<?php
/**
 * Planeación de impresión por máquina y semana.
 *
 * @package Logistica
 */

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

/**
 * CRUD y utilidades para la planeación de impresión.
 */
class Logistica_Print_Planning {

	/** @var list<string> */
	/** Máquinas predefinidas (programación independiente por slug en BD). */
	const MACHINES = array(
		'sm74'   => 'SPEED MASTER 74',
		'sormz'  => 'SORMZ',
		'sm102'  => 'SPEED MASTER 102',
	);

	const CUSTOM_MACHINES_OPTION           = 'logistica_planeacion_custom_machines';
	const MACHINE_LABEL_OVERRIDES_OPTION   = 'logistica_planeacion_machine_label_overrides';
	const HIDDEN_DEFAULT_MACHINES_OPTION   = 'logistica_planeacion_hidden_default_machines';
	const FIELD_OPTIONS_OPTION             = 'logistica_planeacion_field_options';

	/** @var list<string> */
	const CHECKLIST_FIELDS = array( 'montaje', 'prep_tinta', 'planchas', 'meta' );

	/** @var list<string> */
	const DAYS = array(
		0 => 'Lunes',
		1 => 'Martes',
		2 => 'Miércoles',
		3 => 'Jueves',
		4 => 'Viernes',
		5 => 'Sábado',
		6 => 'Domingo',
	);

	/** @var list<string> */
	const STATUSES = array(
		'pending'     => 'Pendiente',
		'in_progress' => 'En proceso',
		'done'        => 'Terminado',
		'delayed'     => 'Retrasado',
	);

	/** @var list<string> */
	const CHECKLIST_VALUES = array( 'OK', 'BUSCAR', 'PLANCHAS PEDIDAS', 'N/A', '' );

	/** Tiraje de referencia para calcular duración (1000 tamaños ≈ 60 min). */
	const TAMANOS_BASE = 1000;

	const MINUTES_BASE = 60;

	/** @var array<string, float> */
	const COMPLEXITY_FACTORS = array(
		'normal'    => 1.0,
		'media'     => 1.25,
		'alta'      => 1.5,
		'muy_alta'  => 2.0,
	);

	/** @var array<string, string> */
	const COMPLEXITY_LABELS = array(
		'normal'   => 'Normal',
		'media'    => 'Media',
		'alta'     => 'Alta',
		'muy_alta' => 'Muy alta',
	);

	/** @var array<string, string> */
	const LABOR_TYPES = array(
		'impresion'              => 'Impresión',
		'prep_tinta'             => 'Preparación de tinta',
		'limpieza_mantenimiento' => 'Limpieza y mantenimiento',
		'prep_sig_trabajo'       => 'Preparación siguiente trabajo',
		'lavado_maquina'         => 'Lavado de máquina',
		'reunion'                => 'Reunión impresión y diseño',
		'otro'                   => 'Otra actividad',
	);

	/** Duración sugerida en minutos (impresión usa tiraje). */
	const LABOR_DEFAULT_MINUTES = array(
		'impresion'              => 60,
		'prep_tinta'             => 60,
		'limpieza_mantenimiento' => 90,
		'prep_sig_trabajo'       => 60,
		'lavado_maquina'         => 60,
		'reunion'                => 60,
		'otro'                   => 60,
	);

	/**
	 * Campos opcionales visibles según tipo de labor.
	 *
	 * @return array<string, array{fields:list<string>,auto_duration:bool,hint:string}>
	 */
	public static function labor_field_rules() {
		return array(
			'impresion'              => array(
				'fields'        => array( 'tamanos', 'complexity', 'tintas', 'papel', 'montaje', 'prep_tinta', 'planchas', 'meta' ),
				'auto_duration' => true,
				'hint'          => __( 'Tiraje, tintas (tiro/retiro), colores y papel. La duración se calcula: 1000 tamaños ≈ 60 min según complejidad.', 'logistica' ),
			),
			'prep_tinta'             => array(
				'fields'        => array( 'tintas', 'meta' ),
				'auto_duration' => false,
				'hint'          => __( 'Indique tintas, colores y pantones a preparar. Duración editable manualmente.', 'logistica' ),
			),
			'prep_sig_trabajo'       => array(
				'fields'        => array( 'meta' ),
				'auto_duration' => false,
				'hint'          => __( 'Indique la duración y el cumplimiento de metas para el siguiente trabajo.', 'logistica' ),
			),
			'limpieza_mantenimiento' => array(
				'fields'        => array( 'meta' ),
				'auto_duration' => false,
				'hint'          => __( 'Duración sugerida según el tipo de labor. Ajuste si es necesario.', 'logistica' ),
			),
			'lavado_maquina'         => array(
				'fields'        => array( 'meta' ),
				'auto_duration' => false,
				'hint'          => __( 'Duración sugerida para lavado de máquina. Ajuste si es necesario.', 'logistica' ),
			),
			'reunion'                => array(
				'fields'        => array( 'meta' ),
				'auto_duration' => false,
				'hint'          => __( 'Duración de la reunión. Ajuste si es necesario.', 'logistica' ),
			),
			'otro'                   => array(
				'fields'        => array( 'meta' ),
				'auto_duration' => false,
				'hint'          => __( 'Describe la actividad y ajusta la duración.', 'logistica' ),
			),
		);
	}

	/**
	 * @param string $labor_type Tipo de labor.
	 * @return array{fields:list<string>,auto_duration:bool,hint:string}
	 */
	public static function get_labor_field_rules( $labor_type ) {
		$labor_type = self::sanitize_labor_type( $labor_type );
		$rules      = self::labor_field_rules();

		return $rules[ $labor_type ] ?? $rules['otro'];
	}

	/**
	 * @param string $labor_type Tipo de labor.
	 * @param string $field      Clave de campo.
	 * @return bool
	 */
	public static function labor_field_visible( $labor_type, $field ) {
		$rules = self::get_labor_field_rules( $labor_type );

		return in_array( $field, $rules['fields'], true );
	}

	/** @var list<string> */
	const TINTAS_COLOR_KEYS = array( 'cyan', 'magenta', 'amarillo', 'negro', 'otro' );

	/**
	 * Configuración por defecto del panel de tintas.
	 *
	 * @return array<string, mixed>
	 */
	public static function default_tintas_config() {
		return array(
			'count_tiro'     => '',
			'count_retiro'   => '',
			'tiros_x_color'  => '',
			'pasadas'        => '',
			'total_tiraje'   => '',
			'num_planchas'   => '',
			'tiro'           => self::default_tintas_side_config( false ),
			'retiro'         => self::default_tintas_side_config( false ),
		);
	}

	/**
	 * @param bool $active Activo por defecto.
	 * @return array<string, mixed>
	 */
	public static function default_tintas_side_config( $active = false ) {
		return array(
			'active'    => (bool) $active,
			'cyan'      => false,
			'magenta'   => false,
			'amarillo'  => false,
			'negro'     => false,
			'otro'      => false,
			'pantones'  => array( '', '', '', '', '' ),
			'mezcla'    => '',
		);
	}

	/**
	 * @param mixed $task Tarea cruda.
	 * @return array<string, mixed>
	 */
	public static function ensure_tintas_config( $task ) {
		$config = isset( $task['tintas_config'] ) && is_array( $task['tintas_config'] )
			? $task['tintas_config']
			: array();

		if ( empty( $config ) ) {
			$config = self::default_tintas_config();
			$legacy_tintas  = trim( (string) ( $task['tintas'] ?? '' ) );
			$legacy_colores = trim( (string) ( $task['colores'] ?? '' ) );
			if ( '' !== $legacy_tintas ) {
				$config['count_tiro'] = $legacy_tintas;
			}
			if ( '' !== $legacy_colores ) {
				$config['tiros_x_color'] = $legacy_colores;
			}
		}

		return self::sanitize_tintas_config( $config );
	}

	/**
	 * @param mixed $config Configuración cruda.
	 * @return array<string, mixed>
	 */
	public static function sanitize_tintas_config( $config ) {
		if ( ! is_array( $config ) ) {
			return self::default_tintas_config();
		}

		$clean = self::default_tintas_config();
		$clean['count_tiro']    = sanitize_text_field( (string) ( $config['count_tiro'] ?? '' ) );
		$clean['count_retiro']  = sanitize_text_field( (string) ( $config['count_retiro'] ?? '' ) );
		$clean['tiros_x_color'] = sanitize_text_field( (string) ( $config['tiros_x_color'] ?? '' ) );
		$clean['pasadas']       = sanitize_text_field( (string) ( $config['pasadas'] ?? '' ) );
		$clean['total_tiraje']  = sanitize_text_field( (string) ( $config['total_tiraje'] ?? '' ) );
		$clean['num_planchas']  = sanitize_text_field( (string) ( $config['num_planchas'] ?? '' ) );

		foreach ( array( 'tiro', 'retiro' ) as $side ) {
			$clean[ $side ] = self::sanitize_tintas_side_config( $config[ $side ] ?? array(), false );
		}

		return self::sync_tintas_active_from_counts( $clean );
	}

	/**
	 * Activa tiro/retiro según los cuadros de tintas (tiro × retiro).
	 *
	 * @param array<string, mixed> $config Configuración de tintas.
	 * @return array<string, mixed>
	 */
	public static function sync_tintas_active_from_counts( $config ) {
		$config['tiro']['active']   = self::parse_tintas_count( $config['count_tiro'] ?? '' ) > 0;
		$config['retiro']['active'] = self::parse_tintas_count( $config['count_retiro'] ?? '' ) > 0;

		return $config;
	}

	/**
	 * @param mixed $value Valor numérico de tintas.
	 * @return float
	 */
	public static function parse_tintas_count( $value ) {
		$raw = trim( str_replace( ',', '.', (string) $value ) );
		if ( '' === $raw || ! is_numeric( $raw ) ) {
			return 0.0;
		}

		$num = (float) $raw;
		return $num > 0 ? $num : 0.0;
	}

	/**
	 * @param mixed $side   Lado tiro/retiro.
	 * @param bool  $active_default Valor active por defecto.
	 * @return array<string, mixed>
	 */
	public static function sanitize_tintas_side_config( $side, $active_default = false ) {
		$clean = self::default_tintas_side_config( $active_default );
		if ( ! is_array( $side ) ) {
			return $clean;
		}

		$clean['active'] = ! empty( $side['active'] );
		foreach ( self::TINTAS_COLOR_KEYS as $color_key ) {
			$clean[ $color_key ] = ! empty( $side[ $color_key ] );
		}

		$pantones = isset( $side['pantones'] ) && is_array( $side['pantones'] ) ? $side['pantones'] : array();
		if ( ! empty( $clean['otro'] ) ) {
			for ( $i = 0; $i < 5; $i++ ) {
				$clean['pantones'][ $i ] = sanitize_text_field( (string) ( $pantones[ $i ] ?? '' ) );
			}
		}

		$clean['mezcla'] = sanitize_text_field( (string) ( $side['mezcla'] ?? '' ) );

		return $clean;
	}

	/**
	 * Resumen legible para tarjetas y Excel.
	 *
	 * @param array<string, mixed> $config Configuración de tintas.
	 * @return array{tintas:string,colores:string}
	 */
	public static function format_tintas_summaries( $config ) {
		$config = self::sanitize_tintas_config( $config );

		$count_tiro   = trim( (string) $config['count_tiro'] );
		$count_retiro = trim( (string) $config['count_retiro'] );
		$tintas_parts = array();

		if ( '' !== $count_tiro || '' !== $count_retiro ) {
			$tintas_parts[] = ( '' !== $count_tiro ? $count_tiro : '0' ) . ' × ' . ( '' !== $count_retiro ? $count_retiro : '0' );
		}

		$extra = array();
		foreach ( array(
			'tiros_x_color' => __( 'Tiros×Color', 'logistica' ),
			'pasadas'       => __( 'Pasadas', 'logistica' ),
			'total_tiraje'  => __( 'Tiraje', 'logistica' ),
			'num_planchas'  => __( 'Planchas', 'logistica' ),
		) as $key => $label ) {
			$value = trim( (string) ( $config[ $key ] ?? '' ) );
			if ( '' !== $value ) {
				$extra[] = $label . ': ' . $value;
			}
		}

		if ( ! empty( $extra ) ) {
			$tintas_parts[] = implode( ' · ', $extra );
		}

		$color_labels = array(
			'cyan'     => 'C',
			'magenta'  => 'M',
			'amarillo' => 'A',
			'negro'    => 'N',
			'otro'     => __( 'Otro', 'logistica' ),
		);

		$colores_parts = array();
		foreach ( array( 'tiro' => __( 'Tiro', 'logistica' ), 'retiro' => __( 'Retiro', 'logistica' ) ) as $side_key => $side_label ) {
			$side = is_array( $config[ $side_key ] ?? null ) ? $config[ $side_key ] : array();
			if ( empty( $side['active'] ) ) {
				continue;
			}

			$selected = array();
			foreach ( self::TINTAS_COLOR_KEYS as $color_key ) {
				if ( ! empty( $side[ $color_key ] ) ) {
					$selected[] = $color_labels[ $color_key ];
				}
			}

			$pantones = array();
			if ( ! empty( $side['otro'] ) ) {
				foreach ( (array) ( $side['pantones'] ?? array() ) as $pantone ) {
					$pantone = trim( (string) $pantone );
					if ( '' !== $pantone ) {
						$pantones[] = $pantone;
					}
				}
			}

			$side_bits = array();
			if ( ! empty( $selected ) ) {
				$side_bits[] = implode( '+', $selected );
			}
			if ( ! empty( $pantones ) ) {
				$side_bits[] = 'Pantone: ' . implode( ', ', $pantones );
			}
			$mezcla = trim( (string) ( $side['mezcla'] ?? '' ) );
			if ( '' !== $mezcla ) {
				$side_bits[] = 'Mezcla: ' . $mezcla;
			}

			if ( ! empty( $side_bits ) ) {
				$colores_parts[] = $side_label . ' ' . implode( ' · ', $side_bits );
			} else {
				$colores_parts[] = $side_label;
			}
		}

		return array(
			'tintas'  => implode( ' | ', array_filter( $tintas_parts ) ),
			'colores' => implode( ' | ', $colores_parts ),
		);
	}

	/**
	 * Elimina campos que no aplican al tipo de labor.
	 *
	 * @param array<string, mixed> $task       Tarea.
	 * @param string               $labor_type Tipo de labor.
	 * @return array<string, mixed>
	 */
	public static function strip_task_labor_fields( $task, $labor_type ) {
		$labor_type = self::sanitize_labor_type( $labor_type );

		if ( ! self::labor_field_visible( $labor_type, 'tamanos' ) ) {
			$task['tamanos']    = '';
			$task['complexity'] = 'normal';
		}

		if ( ! self::labor_field_visible( $labor_type, 'tintas' ) ) {
			$task['tintas']        = '';
			$task['colores']       = '';
			$task['tintas_config'] = self::default_tintas_config();
		}

		if ( ! self::labor_field_visible( $labor_type, 'papel' ) ) {
			$task['papel']               = '';
			$task['papel_ancho']         = '';
			$task['papel_largo']         = '';
			$task['papel_gramaje']       = '';
			$task['papel_material']      = '';
			$task['papel_material_otro'] = '';
		}

		foreach ( array( 'montaje', 'prep_tinta', 'planchas', 'meta' ) as $check_field ) {
			if ( ! self::labor_field_visible( $labor_type, $check_field ) ) {
				$task[ $check_field ] = '';
			}
		}

		return $task;
	}

	/** @var array<string, string> */
	const PAPEL_MATERIALS = array(
		'P1'   => 'P1',
		'P2'   => 'P2',
		'GCU'  => 'GCU',
		'otro' => 'Otro',
	);

	/**
	 * @return array<string, string>
	 */
	public static function day_types() {
		if ( class_exists( 'Logistica_User_Panel', false ) ) {
			return Logistica_User_Panel::get_day_types();
		}

		return array(
			''           => 'Laborable',
			'festivo'    => 'Festivo',
			'libre'      => 'Día libre',
			'vacaciones' => 'Vacaciones',
			'permisos'   => 'Permisos',
		);
	}

	/**
	 * Estados de tarea configurables.
	 *
	 * @return array<string, string>
	 */
	public static function get_statuses() {
		if ( class_exists( 'Logistica_Select_Catalog', false ) ) {
			$options = Logistica_Select_Catalog::get_options( 'planeacion_statuses' );
			if ( is_array( $options ) && ! empty( $options ) ) {
				return $options;
			}
		}

		return self::STATUSES;
	}

	/**
	 * Tipos de labor configurables.
	 *
	 * @return array<string, string>
	 */
	public static function get_labor_types() {
		if ( class_exists( 'Logistica_Select_Catalog', false ) ) {
			$options = Logistica_Select_Catalog::get_options( 'planeacion_labor_types' );
			if ( is_array( $options ) && ! empty( $options ) ) {
				return $options;
			}
		}

		return self::LABOR_TYPES;
	}

	/**
	 * Etiquetas de complejidad configurables.
	 *
	 * @return array<string, string>
	 */
	public static function get_complexity_labels() {
		if ( class_exists( 'Logistica_Select_Catalog', false ) ) {
			$options = Logistica_Select_Catalog::get_options( 'planeacion_complexity' );
			if ( is_array( $options ) && ! empty( $options ) ) {
				return $options;
			}
		}

		return self::COMPLEXITY_LABELS;
	}

	/**
	 * @return list<string>
	 */
	public static function non_working_day_types() {
		return class_exists( 'Logistica_User_Panel', false )
			? Logistica_User_Panel::NON_WORKING_DAY_TYPES
			: array( 'festivo', 'libre', 'vacaciones', 'permisos' );
	}

	/**
	 * @param string $day_type Tipo de día.
	 * @return bool
	 */
	public static function is_non_working_day_type( $day_type ) {
		return in_array( sanitize_key( (string) $day_type ), self::non_working_day_types(), true );
	}

	/**
	 * @param mixed $day_type Tipo de día.
	 * @return string
	 */
	public static function sanitize_day_type( $day_type ) {
		$key   = sanitize_key( (string) $day_type );
		$types = self::day_types();

		return isset( $types[ $key ] ) ? $key : '';
	}

	/**
	 * Marca domingos y festivos cuando el usuario no eligió tipo manualmente.
	 *
	 * @param array<string, array<string, mixed>> $day_hours  Horarios por día.
	 * @param string                              $week_start Y-m-d (lunes).
	 * @return array<string, array<string, mixed>>
	 */
	public static function apply_automatic_day_types( $day_hours, $week_start ) {
		if ( ! is_array( $day_hours ) ) {
			return array();
		}

		$dates_by_index = array();
		foreach ( self::week_days( $week_start ) as $day ) {
			$dates_by_index[ (string) (int) ( $day['index'] ?? 0 ) ] = (string) ( $day['date'] ?? '' );
		}

		foreach ( $day_hours as $day_key => $slot ) {
			if ( ! is_array( $slot ) ) {
				continue;
			}

			if ( ! empty( $slot['day_type_manual'] ) ) {
				$day_hours[ $day_key ]['day_type'] = self::sanitize_day_type( $slot['day_type'] ?? '' );
				continue;
			}

			$date = $dates_by_index[ (string) (int) $day_key ] ?? '';
			if ( '' === $date ) {
				continue;
			}

			$auto = Logistica_Settings::resolve_automatic_day_type( $date );
			$day_hours[ $day_key ]['day_type'] = '' !== $auto
				? self::sanitize_day_type( $auto )
				: '';
		}

		return $day_hours;
	}

	/**
	 * @return string
	 */
	public static function table_name() {
		return Logistica_Database::table( 'planeacion_impresion' );
	}

	/**
	 * @return list<string>
	 */
	public static function default_checklist_options() {
		return array_values(
			array_filter(
				self::CHECKLIST_VALUES,
				static function ( $value ) {
					return '' !== $value;
				}
			)
		);
	}

	/**
	 * @return array<string, array<string, string>|list<string>>
	 */
	public static function default_field_options() {
		$checklist = self::default_checklist_options();

		return array(
			'papel_materials' => self::PAPEL_MATERIALS,
			'montaje'         => $checklist,
			'prep_tinta'      => $checklist,
			'planchas'        => $checklist,
			'meta'            => $checklist,
		);
	}

	/**
	 * @return array<string, array<string, string>|list<string>>
	 */
	public static function get_field_options() {
		$defaults = self::default_field_options();
		$saved    = get_option( self::FIELD_OPTIONS_OPTION, array() );

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

		if ( ! empty( $saved['papel_materials'] ) && is_array( $saved['papel_materials'] ) ) {
			$materials = self::sanitize_papel_materials_map( $saved['papel_materials'] );
			if ( ! empty( $materials ) ) {
				$defaults['papel_materials'] = $materials;
			}
		}

		foreach ( self::CHECKLIST_FIELDS as $field ) {
			if ( empty( $saved[ $field ] ) || ! is_array( $saved[ $field ] ) ) {
				continue;
			}

			$list = self::sanitize_checklist_list( $saved[ $field ] );
			if ( ! empty( $list ) ) {
				$defaults[ $field ] = $list;
			}
		}

		return $defaults;
	}

	/**
	 * @return array<string, string>
	 */
	public static function get_papel_materials() {
		$options = self::get_field_options();

		return isset( $options['papel_materials'] ) && is_array( $options['papel_materials'] )
			? $options['papel_materials']
			: self::PAPEL_MATERIALS;
	}

	/**
	 * @param string $field Campo checklist.
	 * @return list<string>
	 */
	public static function get_checklist_options( $field ) {
		$field   = sanitize_key( (string) $field );
		$options = self::get_field_options();

		if ( ! in_array( $field, self::CHECKLIST_FIELDS, true ) ) {
			return self::default_checklist_options();
		}

		$list = $options[ $field ] ?? self::default_checklist_options();

		return is_array( $list ) ? array_values( $list ) : self::default_checklist_options();
	}

	/**
	 * @param mixed $payload Datos del formulario.
	 * @return array<string, array<string, string>|list<string>>|\WP_Error
	 */
	public static function save_field_options( $payload ) {
		if ( ! is_array( $payload ) ) {
			return new WP_Error( 'invalid_options', __( 'Opciones inválidas.', 'logistica' ) );
		}

		$options = self::default_field_options();

		if ( isset( $payload['papel_materials'] ) ) {
			$materials = self::parse_papel_materials_input( $payload['papel_materials'] );
			if ( is_wp_error( $materials ) ) {
				return $materials;
			}
			$options['papel_materials'] = $materials;
		}

		foreach ( self::CHECKLIST_FIELDS as $field ) {
			if ( ! isset( $payload[ $field ] ) ) {
				continue;
			}

			$list = self::parse_checklist_input( $payload[ $field ] );
			if ( is_wp_error( $list ) ) {
				return $list;
			}
			$options[ $field ] = $list;
		}

		update_option( self::FIELD_OPTIONS_OPTION, $options, false );

		return $options;
	}

	/**
	 * @param mixed $value Texto multilínea o lista.
	 * @return list<string>|\WP_Error
	 */
	public static function parse_checklist_input( $value ) {
		$lines = self::parse_multiline_input( $value );
		$clean = self::sanitize_checklist_list( $lines );

		if ( empty( $clean ) ) {
			return new WP_Error( 'invalid_checklist', __( 'Indica al menos una opción en la lista.', 'logistica' ) );
		}

		return $clean;
	}

	/**
	 * @param mixed $value Texto multilínea o mapa.
	 * @return array<string, string>|\WP_Error
	 */
	public static function parse_papel_materials_input( $value ) {
		if ( is_array( $value ) && self::is_assoc_array( $value ) ) {
			$materials = self::sanitize_papel_materials_map( $value );
		} else {
			$materials = self::parse_papel_materials_lines( self::parse_multiline_input( $value ) );
		}

		if ( empty( $materials ) ) {
			return new WP_Error( 'invalid_materials', __( 'Indica al menos un material.', 'logistica' ) );
		}

		if ( ! isset( $materials['otro'] ) ) {
			$materials['otro'] = 'Otro';
		}

		return $materials;
	}

	/**
	 * @param list<string> $lines Líneas de material.
	 * @return array<string, string>
	 */
	public static function parse_papel_materials_lines( array $lines ) {
		$materials = array();

		foreach ( $lines as $line ) {
			$label = sanitize_text_field( trim( (string) $line ) );
			if ( '' === $label ) {
				continue;
			}

			$upper = strtoupper( $label );
			if ( 'OTRO' === $upper ) {
				$materials['otro'] = 'Otro';
				continue;
			}

			if ( in_array( $upper, array( 'P1', 'P2', 'GCU' ), true ) ) {
				$materials[ $upper ] = $label;
				continue;
			}

			$slug = self::machine_slug_from_label( $label );
			$seq  = 2;
			while ( isset( $materials[ $slug ] ) ) {
				$slug = self::machine_slug_from_label( $label ) . '_' . $seq;
				++$seq;
			}

			$materials[ $slug ] = $label;
		}

		return $materials;
	}

	/**
	 * @param mixed $value Entrada multilínea.
	 * @return list<string>
	 */
	private static function parse_multiline_input( $value ) {
		if ( is_array( $value ) ) {
			return array_map(
				static function ( $line ) {
					return (string) $line;
				},
				$value
			);
		}

		$text = sanitize_textarea_field( (string) $value );

		return preg_split( '/\r\n|\r|\n/', $text ) ?: array();
	}

	/**
	 * @param list<string> $lines Opciones.
	 * @return list<string>
	 */
	private static function sanitize_checklist_list( $lines ) {
		if ( ! is_array( $lines ) ) {
			return array();
		}

		$clean = array();
		foreach ( $lines as $line ) {
			$line = sanitize_text_field( trim( (string) $line ) );
			if ( '' === $line || in_array( $line, $clean, true ) ) {
				continue;
			}
			$clean[] = $line;
		}

		return $clean;
	}

	/**
	 * @param array<string, mixed> $materials Mapa slug => etiqueta.
	 * @return array<string, string>
	 */
	private static function sanitize_papel_materials_map( array $materials ) {
		$clean = array();

		foreach ( $materials as $slug => $label ) {
			$slug  = sanitize_key( (string) $slug );
			$label = sanitize_text_field( (string) $label );
			if ( '' === $slug || '' === $label ) {
				continue;
			}
			$clean[ $slug ] = $label;
		}

		return $clean;
	}

	/**
	 * @param array<mixed> $array Arreglo a evaluar.
	 * @return bool
	 */
	private static function is_assoc_array( array $array ) {
		if ( array() === $array ) {
			return false;
		}

		return array_keys( $array ) !== range( 0, count( $array ) - 1 );
	}

	/**
	 * @return array<string, string> slug => etiqueta.
	 */
	public static function get_machines() {
		$defaults = self::MACHINES;

		foreach ( self::get_machine_label_overrides() as $slug => $label ) {
			if ( isset( $defaults[ $slug ] ) ) {
				$defaults[ $slug ] = $label;
			}
		}

		foreach ( self::get_hidden_default_machine_slugs() as $slug ) {
			unset( $defaults[ $slug ] );
		}

		$custom = get_option( self::CUSTOM_MACHINES_OPTION, array() );
		if ( ! is_array( $custom ) ) {
			$custom = array();
		}

		$clean = array();
		foreach ( $custom as $slug => $label ) {
			$slug  = sanitize_key( (string) $slug );
			$label = sanitize_text_field( (string) $label );
			if ( '' === $slug || '' === $label ) {
				continue;
			}
			$clean[ $slug ] = $label;
		}

		return array_merge( $defaults, $clean );
	}

	/**
	 * @return array<string, string>
	 */
	public static function get_machine_label_overrides() {
		$overrides = get_option( self::MACHINE_LABEL_OVERRIDES_OPTION, array() );
		if ( ! is_array( $overrides ) ) {
			return array();
		}

		$clean = array();
		foreach ( $overrides as $slug => $label ) {
			$slug  = sanitize_key( (string) $slug );
			$label = sanitize_text_field( (string) $label );
			if ( '' === $slug || '' === $label || ! isset( self::MACHINES[ $slug ] ) ) {
				continue;
			}
			$clean[ $slug ] = $label;
		}

		return $clean;
	}

	/**
	 * @param string $slug Slug de máquina.
	 * @return bool
	 */
	public static function is_custom_machine( $slug ) {
		$slug = sanitize_key( (string) $slug );

		return '' !== $slug && ! isset( self::MACHINES[ $slug ] ) && isset( self::get_custom_machines()[ $slug ] );
	}

	/**
	 * @return array<string, string>
	 */
	public static function get_custom_machines() {
		$machines = self::get_machines();

		return array_diff_key( $machines, self::MACHINES );
	}

	/**
	 * @param string $label Nombre visible de la máquina.
	 * @return string|\WP_Error Slug creado.
	 */
	public static function add_custom_machine( $label ) {
		$label = sanitize_text_field( trim( (string) $label ) );
		if ( '' === $label ) {
			return new WP_Error( 'invalid_label', __( 'Indica el nombre de la máquina.', 'logistica' ) );
		}

		$machines = self::get_machines();
		foreach ( $machines as $existing_label ) {
			if ( 0 === strcasecmp( $existing_label, $label ) ) {
				return new WP_Error( 'duplicate_label', __( 'Ya existe una máquina con ese nombre.', 'logistica' ) );
			}
		}

		$custom = self::get_custom_machines();
		if ( count( $custom ) >= 30 ) {
			return new WP_Error( 'limit_reached', __( 'No se pueden registrar más máquinas personalizadas.', 'logistica' ) );
		}

		$base = self::machine_slug_from_label( $label );
		$slug = $base;
		$seq  = 2;
		while ( isset( $machines[ $slug ] ) ) {
			$slug = $base . '_' . $seq;
			++$seq;
		}

		$custom[ $slug ] = $label;
		update_option( self::CUSTOM_MACHINES_OPTION, $custom, false );

		return $slug;
	}

	/**
	 * @param string $slug  Slug existente.
	 * @param string $label Nuevo nombre visible.
	 * @return string|\WP_Error
	 */
	public static function update_machine_label( $slug, $label ) {
		$slug  = sanitize_key( (string) $slug );
		$label = sanitize_text_field( trim( (string) $label ) );

		if ( '' === $slug ) {
			return new WP_Error( 'invalid_machine', __( 'Máquina no válida.', 'logistica' ) );
		}

		if ( '' === $label ) {
			return new WP_Error( 'invalid_label', __( 'Indica el nombre de la máquina.', 'logistica' ) );
		}

		$machines = self::get_machines();
		if ( ! isset( $machines[ $slug ] ) ) {
			return new WP_Error( 'invalid_machine', __( 'Máquina no encontrada.', 'logistica' ) );
		}

		foreach ( $machines as $existing_slug => $existing_label ) {
			if ( $existing_slug !== $slug && 0 === strcasecmp( $existing_label, $label ) ) {
				return new WP_Error( 'duplicate_label', __( 'Ya existe una máquina con ese nombre.', 'logistica' ) );
			}
		}

		if ( isset( self::MACHINES[ $slug ] ) ) {
			$overrides = self::get_machine_label_overrides();
			if ( 0 === strcasecmp( self::MACHINES[ $slug ], $label ) ) {
				unset( $overrides[ $slug ] );
			} else {
				$overrides[ $slug ] = $label;
			}
			update_option( self::MACHINE_LABEL_OVERRIDES_OPTION, $overrides, false );
			return $slug;
		}

		$custom = get_option( self::CUSTOM_MACHINES_OPTION, array() );
		if ( ! is_array( $custom ) || ! isset( $custom[ $slug ] ) ) {
			return new WP_Error( 'invalid_machine', __( 'Máquina no encontrada.', 'logistica' ) );
		}

		$custom[ $slug ] = $label;
		update_option( self::CUSTOM_MACHINES_OPTION, $custom, false );

		return $slug;
	}

	/**
	 * @return list<string>
	 */
	public static function get_hidden_default_machine_slugs() {
		$hidden = get_option( self::HIDDEN_DEFAULT_MACHINES_OPTION, array() );
		if ( ! is_array( $hidden ) ) {
			return array();
		}

		$clean = array();
		foreach ( $hidden as $slug ) {
			$slug = sanitize_key( (string) $slug );
			if ( '' !== $slug && isset( self::MACHINES[ $slug ] ) ) {
				$clean[] = $slug;
			}
		}

		return array_values( array_unique( $clean ) );
	}

	/**
	 * @param string $slug Slug de máquina.
	 * @return true|\WP_Error
	 */
	public static function delete_machine( $slug ) {
		$slug     = sanitize_key( (string) $slug );
		$machines = self::get_machines();

		if ( '' === $slug || ! isset( $machines[ $slug ] ) ) {
			return new WP_Error( 'invalid_machine', __( 'Máquina no encontrada.', 'logistica' ) );
		}

		if ( count( $machines ) <= 1 ) {
			return new WP_Error( 'last_machine', __( 'Debe quedar al menos una máquina.', 'logistica' ) );
		}

		global $wpdb;

		$schedules  = $wpdb->get_results(
			$wpdb->prepare(
				'SELECT * FROM ' . self::table_name() . ' WHERE maquina = %s',
				$slug
			),
			ARRAY_A
		);
		$schedules  = is_array( $schedules ) ? $schedules : array();
		$is_default = isset( self::MACHINES[ $slug ] );
		$payload    = array(
			'slug'       => $slug,
			'is_default' => $is_default,
			'schedules'  => $schedules,
		);

		if ( $is_default ) {
			$overrides = self::get_machine_label_overrides();
			if ( isset( $overrides[ $slug ] ) ) {
				$payload['label_override'] = $overrides[ $slug ];
			}
		} else {
			$custom = get_option( self::CUSTOM_MACHINES_OPTION, array() );
			if ( is_array( $custom ) && isset( $custom[ $slug ] ) ) {
				$payload['custom_machine'] = array(
					'slug'  => $slug,
					'label' => sanitize_text_field( (string) $custom[ $slug ] ),
				);
			}
		}

		$result = Logistica_Option_Lifecycle::delete(
			'planeacion_impresion',
			'machine',
			$slug,
			(string) ( $machines[ $slug ] ?? $slug ),
			$payload,
			static function () use ( $slug, $is_default ) {
				if ( $is_default ) {
					$hidden   = self::get_hidden_default_machine_slugs();
					$hidden[] = $slug;
					update_option( self::HIDDEN_DEFAULT_MACHINES_OPTION, array_values( array_unique( $hidden ) ), false );

					$overrides = self::get_machine_label_overrides();
					if ( isset( $overrides[ $slug ] ) ) {
						unset( $overrides[ $slug ] );
						update_option( self::MACHINE_LABEL_OVERRIDES_OPTION, $overrides, false );
					}
					return;
				}

				$custom = get_option( self::CUSTOM_MACHINES_OPTION, array() );
				if ( is_array( $custom ) && isset( $custom[ $slug ] ) ) {
					unset( $custom[ $slug ] );
					update_option( self::CUSTOM_MACHINES_OPTION, $custom, false );
				}
			}
		);

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

		Logistica_Soft_Delete::mark_deleted_batch(
			'planeacion_impresion',
			array_map(
				static function ( $row ) {
					return (int) ( $row['id'] ?? 0 );
				},
				$schedules
			)
		);

		return true;
	}

	/**
	 * @param string $slug Slug de máquina personalizada.
	 * @return true|\WP_Error
	 */
	public static function delete_custom_machine( $slug ) {
		return self::delete_machine( $slug );
	}

	/**
	 * @param string $label Etiqueta.
	 * @return string
	 */
	public static function machine_slug_from_label( $label ) {
		$slug = sanitize_title( remove_accents( (string) $label ) );
		$slug = preg_replace( '/[^a-z0-9_]/', '_', (string) $slug );
		$slug = trim( (string) $slug, '_' );

		if ( '' === $slug ) {
			$slug = 'maquina';
		}

		if ( strlen( $slug ) > 32 ) {
			$slug = substr( $slug, 0, 32 );
		}

		return $slug;
	}

	/**
	 * @return list<string>
	 */
	public static function machine_slugs() {
		return array_keys( self::get_machines() );
	}

	/**
	 * Normaliza fecha al lunes de esa semana (Y-m-d).
	 *
	 * @param string $date Fecha opcional.
	 * @return string
	 */
	public static function week_start( $date = '' ) {
		$tz = new DateTimeZone( Logistica_User_Panel::PANEL_TIMEZONE );

		if ( '' !== $date ) {
			$dt = date_create( $date . ' 12:00:00', $tz );
		} else {
			$dt = new DateTime( 'now', $tz );
		}

		if ( ! $dt instanceof DateTime ) {
			$dt = new DateTime( 'now', $tz );
		}

		$dow = (int) $dt->format( 'N' );

		if ( $dow > 1 ) {
			$dt->modify( '-' . ( $dow - 1 ) . ' days' );
		}

		return $dt->format( 'Y-m-d' );
	}

	/**
	 * @param string $week_start Y-m-d (lunes).
	 * @param string $machine    Slug de máquina.
	 * @return array<string, mixed>
	 */
	public static function get_schedule( $week_start, $machine ) {
		global $wpdb;

		$week_start = self::week_start( $week_start );
		$machine    = self::sanitize_machine( $machine );

		$row = $wpdb->get_row(
			$wpdb->prepare(
				'SELECT * FROM ' . self::table_name() . ' WHERE semana_inicio = %s AND maquina = %s AND ' . Logistica_Soft_Delete::sql_not_deleted( 'planeacion_impresion' ) . ' LIMIT 1',
				$week_start,
				$machine
			),
			ARRAY_A
		);

		if ( ! $row ) {
			return self::default_schedule( $week_start, $machine );
		}

		$tasks = json_decode( (string) ( $row['tasks'] ?? '[]' ), true );
		if ( ! is_array( $tasks ) ) {
			$tasks = array();
		}

		$legacy_start = (int) ( $row['jornada_inicio'] ?? 420 );
		$legacy_end   = (int) ( $row['jornada_fin'] ?? 1020 );
		$day_hours    = self::sanitize_day_hours(
			json_decode( (string) ( $row['day_hours'] ?? '' ), true ),
			$legacy_start,
			$legacy_end,
			$week_start
		);
		$lunch        = self::sanitize_lunch_hours( $row['lunch_start'] ?? null, $row['lunch_end'] ?? null );

		return array(
			'id'             => (int) ( $row['id'] ?? 0 ),
			'semana_inicio'  => $week_start,
			'maquina'        => $machine,
			'jornada_inicio' => $legacy_start,
			'jornada_fin'    => $legacy_end,
			'day_hours'      => $day_hours,
			'include_lunch'  => (int) ( $row['include_lunch'] ?? 1 ),
			'lunch_start'    => $lunch['lunch_start'],
			'lunch_end'      => $lunch['lunch_end'],
			'tasks'          => self::sanitize_tasks( $tasks ),
			'updated_at'     => (string) ( $row['updated_at'] ?? '' ),
		);
	}

	/**
	 * @param string               $week_start Y-m-d.
	 * @param string               $machine    Slug.
	 * @param array<string, mixed> $payload    Datos del cliente.
	 * @return array<string, mixed>|\WP_Error
	 */
	public static function save_schedule( $week_start, $machine, $payload ) {
		global $wpdb;

		$week_start = self::week_start( $week_start );
		$machine    = self::sanitize_machine( $machine );

		if ( ! $machine ) {
			return new WP_Error( 'invalid_machine', __( 'Máquina no válida.', 'logistica' ) );
		}

		$jornada_inicio = isset( $payload['jornada_inicio'] ) ? (int) $payload['jornada_inicio'] : 420;
		$jornada_fin    = isset( $payload['jornada_fin'] ) ? (int) $payload['jornada_fin'] : 1020;
		$day_hours      = self::sanitize_day_hours(
			$payload['day_hours'] ?? null,
			$jornada_inicio,
			$jornada_fin,
			$week_start
		);

		if ( empty( $day_hours ) ) {
			return new WP_Error( 'invalid_hours', __( 'La jornada laboral no es válida.', 'logistica' ) );
		}

		$tasks_raw = isset( $payload['tasks'] ) && is_array( $payload['tasks'] ) ? $payload['tasks'] : array();
		$tasks     = self::sanitize_tasks( $tasks_raw );
		$lunch     = self::sanitize_lunch_hours(
			$payload['lunch_start'] ?? null,
			$payload['lunch_end'] ?? null
		);

		$table = self::table_name();
		$now   = Logistica_User_Panel::current_datetime();
		$user  = get_current_user_id();

		$existing_row = $wpdb->get_row(
			$wpdb->prepare(
				'SELECT * FROM ' . $table . ' WHERE semana_inicio = %s AND maquina = %s LIMIT 1',
				$week_start,
				$machine
			),
			ARRAY_A
		);
		$existing   = is_array( $existing_row ) ? (int) ( $existing_row['id'] ?? 0 ) : 0;
		$before_row = $existing > 0 ? $existing_row : null;

		$data = array(
			'semana_inicio'  => $week_start,
			'maquina'        => $machine,
			'jornada_inicio' => (int) ( $day_hours['0']['start'] ?? $jornada_inicio ),
			'jornada_fin'    => (int) ( $day_hours['0']['end'] ?? $jornada_fin ),
			'day_hours'      => wp_json_encode( $day_hours ),
			'include_lunch'  => ! empty( $payload['include_lunch'] ) ? 1 : 0,
			'lunch_start'    => $lunch['lunch_start'],
			'lunch_end'      => $lunch['lunch_end'],
			'tasks'          => wp_json_encode( $tasks ),
			'updated_at'     => $now,
			'updated_by'     => $user,
		);

		if ( $before_row && Logistica_Soft_Delete::row_is_deleted( $before_row ) ) {
			Logistica_Soft_Delete::restore( 'planeacion_impresion', $existing );
		}

		if ( $existing ) {
			$wpdb->update(
				$table,
				$data,
				array( 'id' => (int) $existing ),
				array( '%s', '%s', '%d', '%d', '%s', '%d', '%d', '%d', '%s', '%s', '%d' ),
				array( '%d' )
			);
		} else {
			$wpdb->insert(
				$table,
				$data,
				array( '%s', '%s', '%d', '%d', '%s', '%d', '%d', '%d', '%s', '%s', '%d' )
			);
		}

		$row_id = $existing ? (int) $existing : (int) $wpdb->insert_id;
		$after_row = Logistica_Soft_Delete::get_row( 'planeacion_impresion', $row_id, true );
		$summary   = sprintf(
			/* translators: 1: machine slug, 2: week start date */
			__( 'Plan impresión %1$s (%2$s)', 'logistica' ),
			$machine,
			$week_start
		);

		if ( $before_row && $after_row ) {
			Logistica_Entity_Lifecycle::record_update(
				'planeacion_impresion',
				'plan',
				(string) $row_id,
				$before_row,
				$after_row,
				$summary
			);
		} elseif ( $after_row ) {
			Logistica_Entity_Lifecycle::record_create(
				'planeacion_impresion',
				'plan',
				(string) $row_id,
				$after_row,
				$summary
			);
		}

		return self::get_schedule( $week_start, $machine );
	}

	/**
	 * @param string $week_start Y-m-d.
	 * @param string $machine    Slug.
	 * @return array<string, mixed>
	 */
	public static function default_schedule( $week_start, $machine ) {
		return array(
			'id'             => 0,
			'semana_inicio'  => self::week_start( $week_start ),
			'maquina'        => self::sanitize_machine( $machine ) ?: 'sm74',
			'jornada_inicio' => 420,
			'jornada_fin'    => 1020,
			'day_hours'      => self::apply_automatic_day_types( self::default_day_hours(), self::week_start( $week_start ) ),
			'include_lunch'  => 1,
			'lunch_start'    => 720,
			'lunch_end'      => 780,
			'tasks'          => array(),
			'updated_at'     => '',
		);
	}

	/**
	 * Horarios por defecto para cada día (0 = lunes … 6 = domingo).
	 *
	 * @return array<string, array{start:int,end:int,day_type:string,day_type_manual:bool}>
	 */
	public static function default_day_hours() {
		$hours = array();

		for ( $day = 0; $day <= 6; $day++ ) {
			$hours[ (string) $day ] = array(
				'start'           => 420,
				'end'             => 6 === $day ? 720 : ( 5 === $day ? 720 : 1020 ),
				'day_type'        => '',
				'day_type_manual' => false,
			);
		}

		return $hours;
	}

	/**
	 * Normaliza horario de almuerzo en minutos desde medianoche.
	 *
	 * @param mixed $start Inicio (minutos).
	 * @param mixed $end   Fin (minutos).
	 * @return array{lunch_start:int,lunch_end:int}
	 */
	public static function sanitize_lunch_hours( $start, $end ) {
		$default_start = 720;
		$default_end   = 780;
		$lunch_start   = is_numeric( $start ) ? (int) $start : $default_start;
		$lunch_end     = is_numeric( $end ) ? (int) $end : $default_end;

		$lunch_start = max( 0, min( 1439, $lunch_start ) );
		$lunch_end   = max( 0, min( 1440, $lunch_end ) );

		if ( $lunch_end <= $lunch_start ) {
			$lunch_end = min( $lunch_start + 60, 1440 );
		}

		return array(
			'lunch_start' => $lunch_start,
			'lunch_end'   => $lunch_end,
		);
	}

	/**
	 * @param mixed  $raw          Horarios por día.
	 * @param int    $legacy_start Inicio legacy.
	 * @param int    $legacy_end   Fin legacy.
	 * @param string $week_start   Y-m-d (lunes) para tipos automáticos.
	 * @return array<string, array{start:int,end:int,day_type:string,day_type_manual:bool}>
	 */
	public static function sanitize_day_hours( $raw, $legacy_start = 420, $legacy_end = 1020, $week_start = '' ) {
		$defaults = self::default_day_hours();
		$clean    = array();

		if ( is_array( $raw ) && ! empty( $raw ) ) {
			foreach ( $raw as $day_key => $slot ) {
				$day = (int) $day_key;
				if ( $day < 0 || $day > 6 || ! is_array( $slot ) ) {
					continue;
				}

				$start = max( 0, min( 1439, (int) ( $slot['start'] ?? $defaults[ (string) $day ]['start'] ) ) );
				$end   = max( $start + 15, min( 1440, (int) ( $slot['end'] ?? $defaults[ (string) $day ]['end'] ) ) );

				$clean[ (string) $day ] = array(
					'start'           => $start,
					'end'             => $end,
					'day_type'        => self::sanitize_day_type( $slot['day_type'] ?? '' ),
					'day_type_manual' => ! empty( $slot['day_type_manual'] ),
				);
			}
		}

		for ( $day = 0; $day <= 6; $day++ ) {
			$key = (string) $day;
			if ( isset( $clean[ $key ] ) ) {
				continue;
			}

			$start = max( 0, (int) $legacy_start );
			$end   = max( $start + 15, (int) $legacy_end );

			$clean[ $key ] = array(
				'start'           => $start,
				'end'             => $end,
				'day_type'        => '',
				'day_type_manual' => false,
			);
		}

		ksort( $clean, SORT_NUMERIC );

		if ( '' !== $week_start ) {
			$clean = self::apply_automatic_day_types( $clean, $week_start );
		}

		return $clean;
	}

	/**
	 * @param string $machine Slug.
	 * @return string
	 */
	public static function sanitize_machine( $machine ) {
		$machine = sanitize_key( (string) $machine );
		$machines = self::get_machines();

		return isset( $machines[ $machine ] ) ? $machine : '';
	}

	/**
	 * @param array<int, array<string, mixed>> $tasks Tareas.
	 * @return array<int, array<string, mixed>>
	 */
	public static function sanitize_tasks( $tasks ) {
		if ( ! is_array( $tasks ) ) {
			return array();
		}

		$clean = array();

		foreach ( $tasks as $index => $task ) {
			if ( ! is_array( $task ) ) {
				continue;
			}

			$id = isset( $task['id'] ) ? sanitize_text_field( (string) $task['id'] ) : '';
			if ( '' === $id ) {
				$id = 'task_' . wp_generate_password( 8, false, false );
			}

			$day = isset( $task['day'] ) ? (int) $task['day'] : 0;
			if ( $day < 0 || $day > 6 ) {
				$day = 0;
			}

			$status = isset( $task['status'] ) ? sanitize_key( (string) $task['status'] ) : 'pending';
			if ( ! isset( self::get_statuses()[ $status ] ) ) {
				$status = 'pending';
			}

			$tamanos_raw = sanitize_text_field( (string) ( $task['tamanos'] ?? '' ) );
			$complexity  = self::sanitize_complexity( $task['complexity'] ?? 'normal' );
			$labor_type  = self::infer_labor_type( $task );
			$task        = self::strip_task_labor_fields( $task, $labor_type );
			$tamanos_raw = sanitize_text_field( (string) ( $task['tamanos'] ?? '' ) );
			$complexity  = self::sanitize_complexity( $task['complexity'] ?? 'normal' );
			$duration    = self::resolve_duration_minutes( $tamanos_raw, $complexity, $task['duration_minutes'] ?? null, $labor_type );
			$labor_label = self::get_labor_types()[ $labor_type ] ?? '';
			$labor_text  = sanitize_text_field( (string) ( $task['labor'] ?? '' ) );
			if ( '' === $labor_text && 'otro' !== $labor_type ) {
				$labor_text = $labor_label;
			}

			$papel = self::labor_field_visible( $labor_type, 'papel' )
				? self::sanitize_papel_fields( $task )
				: array(
					'ancho'         => '',
					'largo'         => '',
					'gramaje'       => '',
					'material'      => '',
					'material_otro' => '',
					'summary'       => '',
				);

			$tintas_config = self::labor_field_visible( $labor_type, 'tintas' )
				? self::ensure_tintas_config( $task )
				: self::default_tintas_config();
			$tintas_summary = self::format_tintas_summaries( $tintas_config );

			$clean[] = array(
				'id'               => $id,
				'day'              => $day,
				'sort_order'       => isset( $task['sort_order'] ) ? (int) $task['sort_order'] : (int) $index,
				'start_minutes'    => isset( $task['start_minutes'] ) ? max( 0, (int) $task['start_minutes'] ) : 420,
				'duration_minutes' => $duration,
				'labor_type'       => $labor_type,
				'duration_manual'  => ! self::get_labor_field_rules( $labor_type )['auto_duration'],
				'complexity'       => $complexity,
				'op'               => sanitize_text_field( (string) ( $task['op'] ?? '' ) ),
				'labor'            => $labor_text,
				'tamanos'          => $tamanos_raw,
				'tintas_config'    => $tintas_config,
				'tintas'           => $tintas_summary['tintas'],
				'colores'          => $tintas_summary['colores'],
				'papel'            => $papel['summary'],
				'papel_ancho'      => $papel['ancho'],
				'papel_largo'      => $papel['largo'],
				'papel_gramaje'    => $papel['gramaje'],
				'papel_material'   => $papel['material'],
				'papel_material_otro' => $papel['material_otro'],
				'montaje'          => self::labor_field_visible( $labor_type, 'montaje' ) ? self::sanitize_checklist( $task['montaje'] ?? '', 'montaje' ) : '',
				'prep_tinta'       => self::labor_field_visible( $labor_type, 'prep_tinta' ) ? self::sanitize_checklist( $task['prep_tinta'] ?? '', 'prep_tinta' ) : '',
				'planchas'         => self::labor_field_visible( $labor_type, 'planchas' ) ? self::sanitize_checklist( $task['planchas'] ?? '', 'planchas' ) : '',
				'meta'             => self::labor_field_visible( $labor_type, 'meta' ) ? self::sanitize_checklist( $task['meta'] ?? '', 'meta' ) : '',
				'status'           => $status,
				'status_manual'    => ! empty( $task['status_manual'] ),
				'planned_day'      => self::sanitize_day_index( $task['planned_day'] ?? null ),
				'planned_start'    => self::sanitize_minutes( $task['planned_start'] ?? null ),
				'started_on_day'   => self::sanitize_day_index( $task['started_on_day'] ?? null ),
				'reschedule_delay' => ! empty( $task['reschedule_delay'] ),
				'actual_start'     => self::sanitize_minutes( $task['actual_start'] ?? null ),
				'actual_end'       => self::sanitize_minutes( $task['actual_end'] ?? null ),
				'notes'            => sanitize_textarea_field( (string) ( $task['notes'] ?? '' ) ),
				'assigned_person'  => sanitize_text_field( (string) ( $task['assigned_person'] ?? '' ) ),
				'board_priority'   => Logistica_Production_Board::sanitize_priority( $task['board_priority'] ?? 'plan_b' ),
				'board_alert'      => Logistica_Production_Board::sanitize_alert( $task['board_alert'] ?? '' ),
				'pedido_orden_linea_id' => max( 0, (int) ( $task['pedido_orden_linea_id'] ?? 0 ) ),
				'pedido_board_slug'     => sanitize_title( (string) ( $task['pedido_board_slug'] ?? '' ) ),
				'pedido_oc_numero'      => sanitize_text_field( (string) ( $task['pedido_oc_numero'] ?? '' ) ),
				'pedido_cliente'        => sanitize_text_field( (string) ( $task['pedido_cliente'] ?? '' ) ),
			);
		}

		usort(
			$clean,
			static function ( $a, $b ) {
				return (int) $a['sort_order'] <=> (int) $b['sort_order'];
			}
		);

		foreach ( $clean as $index => &$task ) {
			$task['sort_order'] = (int) $index;
		}
		unset( $task );

		return $clean;
	}

	/**
	 * Extrae el tiraje numérico desde textos como "867 TIR", "2000" o "1.167 TIR".
	 *
	 * @param string $value Texto de tamaños.
	 * @return int|null
	 */
	public static function parse_tamanos( $value ) {
		$text = strtoupper( trim( (string) $value ) );
		if ( '' === $text ) {
			return null;
		}

		$text = preg_replace( '/\s*TIR\b.*/i', '', $text );
		$text = trim( (string) $text );

		if ( ! preg_match( '/^([\d.,]+)/', $text, $matches ) ) {
			return null;
		}

		$num = (string) $matches[1];

		if ( preg_match( '/^\d{1,3}\.\d{3}$/', $num ) ) {
			return (int) str_replace( '.', '', $num );
		}

		$normalized = str_replace( array( '.', ',' ), array( '', '.' ), $num );
		if ( ! is_numeric( $normalized ) ) {
			return null;
		}

		$parsed = (float) $normalized;

		return $parsed > 0 ? (int) round( $parsed ) : null;
	}

	/**
	 * @param string     $complexity Clave de complejidad.
	 * @return string
	 */
	public static function sanitize_complexity( $complexity ) {
		$key = sanitize_key( (string) $complexity );

		return isset( self::COMPLEXITY_FACTORS[ $key ] ) ? $key : 'normal';
	}

	/**
	 * @param string $labor_type Tipo de labor.
	 * @return string
	 */
	public static function sanitize_labor_type( $labor_type ) {
		$key = sanitize_key( (string) $labor_type );

		return isset( self::get_labor_types()[ $key ] ) ? $key : 'otro';
	}

	/**
	 * @param array<string, mixed> $task Tarea cruda.
	 * @return string
	 */
	public static function infer_labor_type( $task ) {
		if ( isset( $task['labor_type'] ) && '' !== (string) $task['labor_type'] ) {
			return self::sanitize_labor_type( $task['labor_type'] );
		}

		$labor = strtolower( remove_accents( (string) ( $task['labor'] ?? '' ) ) );
		$map   = array(
			'prep_tinta'             => array( 'prepa tinta', 'preparacion tinta', 'preparación tinta' ),
			'limpieza_mantenimiento' => array( 'limpieza', 'mantenimiento', 'jornada de aseo' ),
			'prep_sig_trabajo'       => array( 'preparacion sig', 'preparación sig', 'sig. trabajo', 'siguiente trabajo' ),
			'lavado_maquina'         => array( 'lavado' ),
			'reunion'                => array( 'reunion', 'reunión' ),
		);

		foreach ( $map as $type => $needles ) {
			foreach ( $needles as $needle ) {
				if ( '' !== $labor && false !== strpos( $labor, $needle ) ) {
					return $type;
				}
			}
		}

		if ( null !== self::parse_tamanos( $task['tamanos'] ?? '' ) ) {
			return 'impresion';
		}

		if ( ! empty( $task['duration_manual'] ) ) {
			return 'otro';
		}

		return 'otro';
	}

	/**
	 * @param int $minutes Minutos.
	 * @return int
	 */
	public static function round_duration( $minutes ) {
		$minutes = max( 15, (int) $minutes );

		return (int) ( round( $minutes / 15 ) * 15 );
	}

	/**
	 * @param string $labor_type Tipo de labor.
	 * @return int
	 */
	public static function default_labor_minutes( $labor_type ) {
		$labor_type = self::sanitize_labor_type( $labor_type );

		return (int) ( self::LABOR_DEFAULT_MINUTES[ $labor_type ] ?? 60 );
	}

	/**
	 * Calcula minutos según tiraje (1000 tamaños = 60 min) y complejidad.
	 *
	 * @param int|null   $tamanos_count Tiraje.
	 * @param string     $complexity    Complejidad.
	 * @return int|null
	 */
	public static function duration_from_tamanos( $tamanos_count, $complexity = 'normal' ) {
		if ( null === $tamanos_count || $tamanos_count <= 0 ) {
			return null;
		}

		$factor  = self::COMPLEXITY_FACTORS[ self::sanitize_complexity( $complexity ) ];
		$minutes = ( $tamanos_count / self::TAMANOS_BASE ) * self::MINUTES_BASE * $factor;
		$minutes = (int) ( round( $minutes / 15 ) * 15 );

		return max( 15, $minutes );
	}

	/**
	 * @param string     $tamanos_raw      Texto tamaños.
	 * @param string     $complexity       Complejidad.
	 * @param mixed      $duration_fallback Duración manual o ajustada.
	 * @param string     $labor_type       Tipo de labor.
	 * @return int
	 */
	public static function resolve_duration_minutes( $tamanos_raw, $complexity, $duration_fallback, $labor_type = 'impresion' ) {
		$labor_type = self::sanitize_labor_type( $labor_type );

		if ( self::get_labor_field_rules( $labor_type )['auto_duration'] ) {
			$count = self::parse_tamanos( $tamanos_raw );
			$auto  = self::duration_from_tamanos( $count, $complexity );
			if ( null !== $auto ) {
				return $auto;
			}
		}

		$fallback = isset( $duration_fallback ) ? (int) $duration_fallback : 0;
		if ( $fallback <= 0 ) {
			$fallback = self::default_labor_minutes( $labor_type );
		}

		return self::round_duration( $fallback );
	}

	/**
	 * @param mixed  $value Valor de checklist.
	 * @param string $field Campo (montaje, prep_tinta, planchas, meta).
	 * @return string
	 */
	private static function sanitize_checklist( $value, $field = 'montaje' ) {
		$value   = sanitize_text_field( (string) $value );
		$allowed = self::get_checklist_options( $field );

		return in_array( $value, $allowed, true ) ? $value : '';
	}

	/**
	 * @param mixed $value Minutos o null.
	 * @return int|null
	 */
	private static function sanitize_minutes( $value ) {
		if ( null === $value || '' === $value ) {
			return null;
		}

		return max( 0, (int) $value );
	}

	/**
	 * @param mixed $value Índice de día (0-6) o null.
	 * @return int|null
	 */
	private static function sanitize_day_index( $value ) {
		if ( null === $value || '' === $value ) {
			return null;
		}

		$day = (int) $value;

		return ( $day >= 0 && $day <= 6 ) ? $day : null;
	}

	/**
	 * @param string $material Material de papel.
	 * @return string
	 */
	public static function sanitize_papel_material( $material ) {
		$key       = strtolower( trim( (string) $material ) );
		$materials = self::get_papel_materials();

		if ( 'otro' === $key ) {
			return 'otro';
		}

		$upper = strtoupper( trim( (string) $material ) );
		if ( isset( $materials[ $upper ] ) ) {
			return $upper;
		}

		if ( isset( $materials[ $key ] ) ) {
			return $key;
		}

		return '';
	}

	/**
	 * @param array<string, mixed> $task Tarea cruda.
	 * @return array{ancho:string,largo:string,gramaje:string,material:string,material_otro:string,summary:string}
	 */
	public static function sanitize_papel_fields( $task ) {
		$ancho   = sanitize_text_field( (string) ( $task['papel_ancho'] ?? '' ) );
		$largo   = sanitize_text_field( (string) ( $task['papel_largo'] ?? '' ) );
		$gramaje = sanitize_text_field( (string) ( $task['papel_gramaje'] ?? '' ) );
		$material = self::sanitize_papel_material( $task['papel_material'] ?? '' );
		$material_otro = sanitize_text_field( (string) ( $task['papel_material_otro'] ?? '' ) );

		if ( '' === $ancho && '' === $largo && '' === $gramaje && '' === $material ) {
			$legacy = sanitize_text_field( (string) ( $task['papel'] ?? '' ) );
			if ( '' !== $legacy ) {
				$parsed = self::parse_legacy_papel( $legacy );
				$ancho   = $parsed['ancho'];
				$largo   = $parsed['largo'];
				$gramaje = $parsed['gramaje'];
				$material = $parsed['material'];
				$material_otro = $parsed['material_otro'];
			}
		}

		if ( 'otro' !== $material ) {
			$material_otro = '';
		}

		return array(
			'ancho'         => $ancho,
			'largo'         => $largo,
			'gramaje'       => $gramaje,
			'material'      => $material,
			'material_otro' => $material_otro,
			'summary'       => self::format_papel_summary( $ancho, $largo, $gramaje, $material, $material_otro ),
		);
	}

	/**
	 * @param string $legacy Texto legacy de papel.
	 * @return array{ancho:string,largo:string,gramaje:string,material:string,material_otro:string}
	 */
	public static function parse_legacy_papel( $legacy ) {
		$result = array(
			'ancho'         => '',
			'largo'         => '',
			'gramaje'       => '',
			'material'      => '',
			'material_otro' => '',
		);

		$text = trim( (string) $legacy );
		if ( '' === $text ) {
			return $result;
		}

		if ( preg_match( '/^([\d.,]+)\s*[xX×]\s*([\d.,]+)/u', $text, $size_match ) ) {
			$result['ancho'] = str_replace( ',', '.', $size_match[1] );
			$result['largo'] = str_replace( ',', '.', $size_match[2] );
		}

		if ( preg_match( '/\b(\d{2,4})\s*(?:g|gr|gramaje)?\b/i', $text, $gramaje_match ) ) {
			$result['gramaje'] = $gramaje_match[1];
		} elseif ( preg_match( '/[xX×]\s*[\d.,]+\s+(\d{1,2})\b/u', $text, $short_gramaje_match ) ) {
			$result['gramaje'] = $short_gramaje_match[1];
		}

		if ( preg_match( '/\b(P1S?|P2S?|GCU\w*)\b/i', $text, $material_match ) ) {
			$token = strtoupper( $material_match[1] );
			if ( str_starts_with( $token, 'P1' ) ) {
				$result['material'] = 'P1';
			} elseif ( str_starts_with( $token, 'P2' ) ) {
				$result['material'] = 'P2';
			} elseif ( str_starts_with( $token, 'GCU' ) ) {
				$result['material'] = 'GCU';
			}
		}

		return $result;
	}

	/**
	 * @param string $ancho Ancho.
	 * @param string $largo Largo.
	 * @param string $gramaje Gramaje.
	 * @param string $material Material.
	 * @param string $material_otro Material personalizado.
	 * @return string
	 */
	public static function format_papel_summary( $ancho, $largo, $gramaje, $material, $material_otro = '' ) {
		$parts = array();

		if ( '' !== $ancho && '' !== $largo ) {
			$parts[] = $ancho . ' x ' . $largo . ' cm';
		} elseif ( '' !== $ancho || '' !== $largo ) {
			$parts[] = trim( $ancho . ' ' . $largo );
		}

		if ( '' !== $gramaje ) {
			$parts[] = $gramaje . ' g';
		}

		if ( 'otro' === $material && '' !== $material_otro ) {
			$parts[] = $material_otro;
		} elseif ( '' !== $material && isset( self::PAPEL_MATERIALS[ $material ] ) ) {
			$parts[] = self::PAPEL_MATERIALS[ $material ];
		}

		return implode( ' · ', $parts );
	}

	/**
	 * Convierte minutos desde medianoche a HH:MM.
	 *
	 * @param int $minutes Minutos.
	 * @return string
	 */
	public static function minutes_to_time( $minutes ) {
		$minutes = max( 0, (int) $minutes );
		$h       = (int) floor( $minutes / 60 );
		$m       = $minutes % 60;

		return sprintf( '%d:%02d', $h, $m );
	}

	/**
	 * Formato HH:MM para inputs type="time".
	 *
	 * @param int $minutes Minutos.
	 * @return string
	 */
	public static function minutes_to_time_input( $minutes ) {
		$minutes = max( 0, (int) $minutes );

		return sprintf( '%02d:%02d', (int) floor( $minutes / 60 ), $minutes % 60 );
	}

	/**
	 * @param string $week_start Y-m-d.
	 * @return array<int, array{label:string, date:string}>
	 */
	public static function week_days( $week_start ) {
		$start = self::week_start( $week_start );
		$ts    = strtotime( $start );
		$days  = array();

		foreach ( self::DAYS as $index => $label ) {
			$day_ts = $ts + ( $index * DAY_IN_SECONDS );
			$days[] = array(
				'index' => $index,
				'label' => $label,
				'date'  => wp_date( 'Y-m-d', $day_ts ),
				'short' => wp_date( 'd/m', $day_ts ),
			);
		}

		return $days;
	}
}
