<?php
/**
 * Inicio — control de tareas y recordatorios: pendientes, agenda, checklist y compras.
 *
 * @package Logistica
 */

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

/**
 * Almacena y gestiona el panel operativo por usuario (user meta).
 */
class Logistica_User_Panel {
	const META_KEY = 'logistica_user_panel_v1';
	const RESUME_POINTS_MAX = 10;

	/** Zona horaria del panel (Colombia). */
	const PANEL_TIMEZONE = 'America/Bogota';

	/**
	 * @return DateTimeZone
	 */
	public static function panel_timezone() {
		static $timezone = null;

		if ( null === $timezone ) {
			try {
				$timezone = new DateTimeZone( self::PANEL_TIMEZONE );
			} catch ( Exception $exception ) {
				$timezone = wp_timezone();
			}
		}

		return $timezone;
	}

	/**
	 * @return DateTimeImmutable
	 */
	public static function now_immutable() {
		return new DateTimeImmutable( 'now', self::panel_timezone() );
	}

	/**
	 * @return string Y-m-d
	 */
	public static function today_date() {
		return self::now_immutable()->format( 'Y-m-d' );
	}

	/**
	 * @return string Y-m-d H:i:s
	 */
	public static function current_datetime() {
		return wp_date( 'Y-m-d H:i:s', null, self::panel_timezone() );
	}

	/**
	 * @return string H:i:s en zona Colombia.
	 */
	public static function current_time_hms() {
		return self::now_immutable()->format( 'H:i:s' );
	}

	/**
	 * @param int|null $timestamp Unix timestamp.
	 * @return string
	 */
	public static function format_panel_datetime( $timestamp = null ) {
		return wp_date( 'Y-m-d H:i:s', $timestamp, self::panel_timezone() );
	}

	/**
	 * @return array{label:string,time:string,weekday:string,date:string}
	 */
	public static function current_moment() {
		$now       = self::now_immutable();
		$week_days = self::week_days();
		$weekday   = '';

		foreach ( $week_days as $day ) {
			if ( ! empty( $day['is_today'] ) ) {
				$weekday = (string) $day['label'];
				break;
			}
		}

		if ( '' === $weekday ) {
			$weekday_labels = array(
				__( 'Domingo', 'logistica' ),
				__( 'Lunes', 'logistica' ),
				__( 'Martes', 'logistica' ),
				__( 'Miércoles', 'logistica' ),
				__( 'Jueves', 'logistica' ),
				__( 'Viernes', 'logistica' ),
				__( 'Sábado', 'logistica' ),
			);
			$weekday = $weekday_labels[ (int) $now->format( 'w' ) ];
		}

		$month_labels = array(
			1  => __( 'enero', 'logistica' ),
			2  => __( 'febrero', 'logistica' ),
			3  => __( 'marzo', 'logistica' ),
			4  => __( 'abril', 'logistica' ),
			5  => __( 'mayo', 'logistica' ),
			6  => __( 'junio', 'logistica' ),
			7  => __( 'julio', 'logistica' ),
			8  => __( 'agosto', 'logistica' ),
			9  => __( 'septiembre', 'logistica' ),
			10 => __( 'octubre', 'logistica' ),
			11 => __( 'noviembre', 'logistica' ),
			12 => __( 'diciembre', 'logistica' ),
		);
		$month = $month_labels[ (int) $now->format( 'n' ) ];

		return array(
			'label'   => sprintf(
				/* translators: 1: weekday, 2: day number, 3: month name, 4: year */
				__( '%1$s, %2$d de %3$s de %4$d', 'logistica' ),
				$weekday,
				(int) $now->format( 'j' ),
				$month,
				(int) $now->format( 'Y' )
			),
			'time'    => wp_date( 'H:i', $now->getTimestamp(), self::panel_timezone() ),
			'weekday' => $weekday,
			'date'    => $now->format( 'Y-m-d' ),
		);
	}

	/** @var array<string, string> */
	const PENDIENTE_STATUSES = array(
		'en_proceso'  => 'En proceso',
		'terminado'   => 'Terminado',
		'otro_cambio' => 'Otro cambio',
		'anotacion'   => 'Anotación',
	);

	/** @var array<string, string> */
	const DAY_TYPES = array(
		''           => 'Laborable',
		'festivo'    => 'Festivo',
		'libre'      => 'Día libre',
		'vacaciones' => 'Vacaciones',
		'permisos'   => 'Permisos',
	);

	/** @var list<string> */
	const NON_WORKING_DAY_TYPES = array(
		'festivo',
		'libre',
		'vacaciones',
		'permisos',
	);

	/** @var array<string, string> */
	const PRIORITIES = array(
		'baja'  => 'Baja',
		'media' => 'Media',
		'alta'  => 'Alta',
	);

	/**
	 * Prioridades configurables (agenda / compras).
	 *
	 * @return array<string, string>
	 */
	public static function get_priorities() {
		if ( class_exists( 'Logistica_Select_Catalog', false ) ) {
			$options = Logistica_Select_Catalog::get_options( 'priorities' );
			if ( is_array( $options ) && ! empty( $options ) ) {
				return $options;
			}
		}

		return self::PRIORITIES;
	}

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

		return array(
			'pendiente'  => 'Pendiente',
			'en_proceso' => 'En proceso',
			'retrasado'  => 'Retrasado',
			'completado' => 'Completado',
		);
	}

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

		return self::DAY_TYPES;
	}

	/** @var array<string, string> */
	const COMPRA_STATUSES = array(
		'pendiente'             => 'Pendiente',
		'revision_cartera'      => 'Revisión cartera',
		'pago_anticipado'       => 'Pago anticipado',
		'credito_vencido'       => 'Crédito vencido',
		'esperando_aprobacion'  => 'Esperando aprobación',
		'esperando_cotizacion'  => 'Esperando cotización',
		'recibido'              => 'Recibido',
	);

	/**
	 * Estados de cotización/compra (configurables desde wp-admin).
	 *
	 * @return array<string, string>
	 */
	public static function get_compra_statuses() {
		return Logistica_Settings::get_compra_statuses();
	}

	/** @var array<string, string> */
	const CHECKLIST_RECURRENCES = array(
		'repetitivo' => 'Repetitivo',
		'unico'      => 'Único',
		'diario'     => 'Diario',
	);

	/** @var int Máximo de accesos rápidos fijados por usuario. */
	const MAX_QUICK_LINKS = 6;

	/** @var list<string> Pestañas que no pueden fijarse como acceso rápido. */
	const QUICK_LINK_EXCLUDED_TABS = array(
		'logistica',
		'logistica-configuracion',
		'logistica-ayuda',
	);

	/** @var string|null Nonce del panel reutilizado en la misma petición. */
	private static $panel_nonce = null;

	/**
	 * @return array<string, mixed>
	 */
	public static function default_data() {
		return array(
			'pendientes'             => array(),
			'resume_points'          => array(),
			'quick_links'            => array(),
			'agenda'                 => array(),
			'checklist_days'         => array(),
			'checklist_week_history' => array(),
			'compras'                => array(),
			'proveedores'            => array(),
			'avisos'                 => array(),
		);
	}

	/**
	 * @param int $user_id User ID.
	 * @return array<string, mixed>
	 */
	public static function get_user_info( $user_id ) {
		$user = get_userdata( $user_id );
		if ( ! $user ) {
			return array();
		}

		$roles = array_values( (array) $user->roles );
		$role_label = $roles ? ucfirst( str_replace( '_', ' ', (string) $roles[0] ) ) : '';

		$jb_role = '';
		if ( class_exists( 'Logistica_Roles', false ) ) {
			$jb_roles = Logistica_Roles::get_user_jb_roles( $user );
			if ( ! empty( $jb_roles[0] ) ) {
				$defs = Logistica_Roles::get_role_definitions();
				$slug = (string) $jb_roles[0];
				$jb_role = isset( $defs[ $slug ]['label'] ) ? (string) $defs[ $slug ]['label'] : $slug;
			}
		}

		return array(
			'id'           => (int) $user->ID,
			'display_name' => $user->display_name,
			'login'        => $user->user_login,
			'email'        => $user->user_email,
			'role'         => $jb_role ? $jb_role : $role_label,
			'registered'   => (string) $user->user_registered,
			'avatar_url'   => get_avatar_url( $user->ID, array( 'size' => 128 ) ),
		);
	}

	/**
	 * @param int $user_id User ID.
	 * @return array<string, mixed>
	 */
	public static function get_panel( $user_id ) {
		$user_id = absint( $user_id );
		if ( $user_id <= 0 ) {
			return self::default_data();
		}

		$raw  = get_user_meta( $user_id, self::META_KEY, true );
		$data = wp_parse_args( is_array( $raw ) ? $raw : array(), self::default_data() );

		$data['checklist_days'] = is_array( $data['checklist_days'] ) ? $data['checklist_days'] : array();
		$snapshot               = wp_json_encode( $data );

		$data                         = self::ensure_week_days( $data );
		$data                         = self::apply_automatic_day_types( $data );
		$data                         = self::sync_checklist_week_history( $data );
		$data['checklist_days']       = self::process_checklist_weekly_repetitive( $data['checklist_days'] );
		$data['checklist_days']       = self::process_checklist_rollover( $data['checklist_days'] );
		$data['resume_points']        = self::sanitize_resume_points( $data['resume_points'] ?? array() );
		$data['quick_links']          = self::sanitize_quick_links( $data['quick_links'] ?? array(), $user_id );
		$data['avisos']               = self::sanitize_avisos( $data['avisos'] ?? array() );
		$data                         = self::sync_overdue_avisos( $data );

		if ( wp_json_encode( $data ) !== $snapshot ) {
			update_user_meta( $user_id, self::META_KEY, $data );
		}

		return $data;
	}

	/**
	 * @param int                  $user_id User ID.
	 * @param array<string, mixed> $payload Panel payload.
	 * @return array<string, mixed>|\WP_Error
	 */
	public static function save_panel( $user_id, $payload ) {
		$user_id = absint( $user_id );
		if ( $user_id <= 0 ) {
			return new WP_Error( 'invalid_user', __( 'Usuario no válido.', 'logistica' ) );
		}

		if ( ! is_array( $payload ) ) {
			return new WP_Error( 'invalid_payload', __( 'Datos no válidos.', 'logistica' ) );
		}

		$existing_raw = get_user_meta( $user_id, self::META_KEY, true );
		$existing     = wp_parse_args( is_array( $existing_raw ) ? $existing_raw : array(), self::default_data() );

		$proveedores = self::sanitize_proveedores( $payload['proveedores'] ?? ( $existing['proveedores'] ?? array() ) );

		$data = array(
			'pendientes'             => self::sanitize_pendientes( $payload['pendientes'] ?? array() ),
			'resume_points'          => self::sanitize_resume_points( $existing['resume_points'] ?? array() ),
			'quick_links'            => self::sanitize_quick_links( $payload['quick_links'] ?? ( $existing['quick_links'] ?? array() ), $user_id ),
			'agenda'                 => self::sanitize_agenda( $payload['agenda'] ?? array() ),
			'checklist_days'         => self::sanitize_checklist_days( $payload['checklist_days'] ?? array() ),
			'checklist_week_history' => is_array( $existing['checklist_week_history'] ?? null ) ? $existing['checklist_week_history'] : array(),
			'proveedores'            => $proveedores,
			'compras'                => self::sanitize_compras(
				$payload['compras'] ?? array(),
				self::proveedor_ids( $proveedores )
			),
			'avisos'                 => self::sanitize_avisos( $payload['avisos'] ?? ( $existing['avisos'] ?? array() ) ),
		);

		$data                   = self::ensure_week_days( $data );
		$data                   = self::apply_automatic_day_types( $data );
		$data                   = self::sync_checklist_week_history( $data );
		$data['checklist_days'] = self::process_checklist_weekly_repetitive( $data['checklist_days'] );
		$data['checklist_days'] = self::process_checklist_rollover( $data['checklist_days'] );

		update_user_meta( $user_id, self::META_KEY, $data );

		return $data;
	}

	/**
	 * Registra o actualiza un punto de retoma automático al salir de una sección.
	 *
	 * @param int    $user_id   User ID.
	 * @param string $tab_slug  Tab slug.
	 * @param string $title     Resumen corto.
	 * @param string $context   Detalle opcional.
	 * @return array<string, mixed>|\WP_Error
	 */
	public static function record_resume_point( $user_id, $tab_slug, $title, $context = '', $deep_link = '' ) {
		$user_id  = absint( $user_id );
		$tab_slug = sanitize_key( (string) $tab_slug );

		if ( $user_id <= 0 ) {
			return new WP_Error( 'invalid_user', __( 'Usuario no válido.', 'logistica' ) );
		}

		if ( '' === $tab_slug || in_array( $tab_slug, array( 'logistica', 'logistica-configuracion', 'logistica-ayuda' ), true ) ) {
			return new WP_Error( 'invalid_tab', __( 'Sección no registrable.', 'logistica' ) );
		}

		$title     = sanitize_text_field( (string) $title );
		$context   = sanitize_textarea_field( (string) $context );
		$deep_link = self::sanitize_resume_deep_link( (string) $deep_link );

		if ( '' === $title ) {
			$tabs  = function_exists( 'logistica_get_nav_tabs' ) ? logistica_get_nav_tabs() : array();
			$label = $tabs[ $tab_slug ] ?? $tab_slug;
			$title = sprintf(
				/* translators: %s: section name */
				__( 'Trabajo en %s', 'logistica' ),
				$label
			);
		}

		$raw  = get_user_meta( $user_id, self::META_KEY, true );
		$data = wp_parse_args( is_array( $raw ) ? $raw : array(), self::default_data() );
		$points = is_array( $data['resume_points'] ?? null ) ? $data['resume_points'] : array();

		$now         = self::current_datetime();
		$found_index = null;
		$entry_id    = '';

		foreach ( $points as $index => $point ) {
			if ( ! is_array( $point ) ) {
				continue;
			}

			if ( (string) ( $point['tab_slug'] ?? '' ) === $tab_slug ) {
				$found_index = $index;
				$entry_id    = self::sanitize_id( $point['id'] ?? '' );
				break;
			}
		}

		if ( '' === $entry_id ) {
			$entry_id = 'resume_' . wp_generate_password( 10, false, false );
		}

		$entry = array(
			'id'         => $entry_id,
			'title'      => $title,
			'context'    => $context,
			'tab_slug'   => $tab_slug,
			'deep_link'  => $deep_link,
			'updated_at' => $now,
			'auto'       => true,
		);

		if ( null !== $found_index ) {
			$points[ $found_index ] = $entry;
		} else {
			array_unshift( $points, $entry );
		}

		$data['resume_points'] = self::sanitize_resume_points( $points );
		update_user_meta( $user_id, self::META_KEY, $data );

		return $entry;
	}

	/**
	 * Elimina un punto de retoma automático.
	 *
	 * @param int    $user_id  User ID.
	 * @param string $point_id Resume point ID.
	 * @return array<string, mixed>|\WP_Error
	 */
	public static function remove_resume_point( $user_id, $point_id ) {
		$user_id  = absint( $user_id );
		$point_id = self::sanitize_id( $point_id );

		if ( $user_id <= 0 || '' === $point_id ) {
			return new WP_Error( 'invalid_request', __( 'Solicitud no válida.', 'logistica' ) );
		}

		$raw  = get_user_meta( $user_id, self::META_KEY, true );
		$data = wp_parse_args( is_array( $raw ) ? $raw : array(), self::default_data() );
		$points = is_array( $data['resume_points'] ?? null ) ? $data['resume_points'] : array();

		$data['resume_points'] = array_values(
			array_filter(
				$points,
				static function ( $item ) use ( $point_id ) {
					return ! is_array( $item ) || (string) ( $item['id'] ?? '' ) !== $point_id;
				}
			)
		);

		$data['resume_points'] = self::sanitize_resume_points( $data['resume_points'] );
		update_user_meta( $user_id, self::META_KEY, $data );

		return $data['resume_points'];
	}

	/**
	 * @return array<int, array{date:string,label:string,weekday_initial:string,short:string,is_today:bool}>
	 */
	public static function week_days() {
		static $cache = null;

		if ( null !== $cache ) {
			return $cache;
		}

		$timezone = self::panel_timezone();
		$today    = self::now_immutable();
		$monday   = $today->modify( 'monday this week' );
		$days     = array();
		$labels    = array(
			__( 'Lunes', 'logistica' ),
			__( 'Martes', 'logistica' ),
			__( 'Miércoles', 'logistica' ),
			__( 'Jueves', 'logistica' ),
			__( 'Viernes', 'logistica' ),
			__( 'Sábado', 'logistica' ),
			__( 'Domingo', 'logistica' ),
		);
		$initials  = array( 'L', 'M', 'X', 'J', 'V', 'S', 'D' );

		for ( $i = 0; $i < 7; $i++ ) {
			$date = $monday->modify( '+' . $i . ' days' );
			$days[] = array(
				'date'            => $date->format( 'Y-m-d' ),
				'label'           => $labels[ $i ],
				'weekday_label'   => $labels[ $i ],
				'weekday_initial' => $initials[ $i ],
				'short'           => $date->format( 'd/m' ),
				'is_today'        => $date->format( 'Y-m-d' ) === $today->format( 'Y-m-d' ),
			);
		}

		$cache = $days;

		return $cache;
	}

	/**
	 * Configuración estática expuesta al cliente (etiquetas, URLs).
	 *
	 * @return array<string, mixed>
	 */
	public static function client_config() {
		$year = (int) wp_date( 'Y' );

		return array(
			'pendiente_statuses'     => self::PENDIENTE_STATUSES,
			'day_types'              => self::get_day_types(),
			'priorities'             => self::get_priorities(),
			'agenda_statuses'        => self::get_agenda_statuses(),
			'compra_statuses'        => self::get_compra_statuses(),
			'checklist_recurrences'  => self::CHECKLIST_RECURRENCES,
			'proveedores_export_url' => self::proveedores_export_url(),
			'compras_export_url'     => self::compras_export_url(),
			'profile_label'          => __( 'Ver perfil', 'logistica' ),
			'profile_tab'            => 'logistica-perfil',
			'holiday_settings'       => Logistica_Settings::get_panel_holidays_client_config(),
			'festivo_dates'          => Logistica_Settings::get_festivo_dates_map( $year - 1, $year + 2 ),
		);
	}

	/**
	 * Nonce compartido para peticiones AJAX del panel personal.
	 *
	 * @return string
	 */
	public static function get_panel_nonce() {
		if ( null === self::$panel_nonce ) {
			self::$panel_nonce = wp_create_nonce( 'logistica_user_panel' );
		}

		return self::$panel_nonce;
	}

	/**
	 * URL de descarga Excel de compras (nonce del panel, vía AJAX).
	 *
	 * @return string
	 */
	public static function compras_export_url() {
		return add_query_arg(
			array(
				'action' => 'logistica_export_panel_compras',
				'nonce'  => self::get_panel_nonce(),
			),
			admin_url( 'admin-ajax.php' )
		);
	}

	/**
	 * URL de descarga Excel de proveedores (nonce del panel, vía AJAX).
	 *
	 * @return string
	 */
	public static function proveedores_export_url() {
		return add_query_arg(
			array(
				'action' => 'logistica_export_panel_proveedores',
				'nonce'  => self::get_panel_nonce(),
			),
			admin_url( 'admin-ajax.php' )
		);
	}

	/**
	 * @return array<string, mixed>
	 */
	public static function clock_context() {
		$moment = self::current_moment();

		return array(
			'today'          => $moment['date'],
			'today_label'    => $moment['label'],
			'today_weekday'  => $moment['weekday'],
			'today_time'     => $moment['time'],
			'now_datetime'   => self::current_datetime(),
			'now_timestamp'  => self::now_immutable()->getTimestamp(),
			'timezone'       => self::PANEL_TIMEZONE,
			'timezone_label' => __( 'Colombia', 'logistica' ),
			'week_days'      => self::week_days(),
		);
	}

	/**
	 * @return array<string, mixed>
	 */
	public static function bootstrap( $user_id ) {
		$payload = array_merge(
			self::clock_context(),
			self::client_config(),
			array(
				'user'               => self::get_user_info( $user_id ),
				'panel'              => self::get_panel( $user_id ),
				'quick_link_options' => self::get_quick_link_options( $user_id ),
				'max_quick_links'    => self::MAX_QUICK_LINKS,
				'user_panel_nonce'   => self::get_panel_nonce(),
			)
		);

		// Correo matutino automático (una vez al día al ingresar después de las 6:00 Colombia).
		self::maybe_send_daily_digest( $user_id );

		return $payload;
	}

	/**
	 * Secciones operativas que el usuario puede fijar como acceso rápido.
	 *
	 * @param int $user_id User ID.
	 * @return list<array{slug:string,label:string,url:string}>
	 */
	public static function get_quick_link_options( $user_id = 0 ) {
		unset( $user_id );

		if ( ! class_exists( 'Logistica_Tabs', false ) ) {
			return array();
		}

		$tabs    = Logistica_Tabs::tabs_for_context( Logistica_Tabs::CONTEXT_OPERATIONS );
		$options = array();

		foreach ( $tabs as $slug => $label ) {
			if ( in_array( $slug, self::QUICK_LINK_EXCLUDED_TABS, true ) ) {
				continue;
			}

			$options[] = array(
				'slug'  => $slug,
				'label' => $label,
				'url'   => Logistica_Tabs::tab_url( $slug, Logistica_Tabs::CONTEXT_OPERATIONS ),
			);
		}

		return $options;
	}

	/**
	 * @param mixed $slugs   Tab slugs.
	 * @param int   $user_id User ID.
	 * @return list<string>
	 */
	public static function sanitize_quick_links( $slugs, $user_id = 0 ) {
		$allowed = array();

		foreach ( self::get_quick_link_options( $user_id ) as $option ) {
			if ( ! empty( $option['slug'] ) ) {
				$allowed[] = sanitize_key( (string) $option['slug'] );
			}
		}

		if ( empty( $allowed ) ) {
			return array();
		}

		$clean = array();

		foreach ( (array) $slugs as $slug ) {
			$slug = sanitize_key( (string) $slug );

			if ( '' === $slug || ! in_array( $slug, $allowed, true ) ) {
				continue;
			}

			if ( in_array( $slug, $clean, true ) ) {
				continue;
			}

			$clean[] = $slug;

			if ( count( $clean ) >= self::MAX_QUICK_LINKS ) {
				break;
			}
		}

		return $clean;
	}

	/**
	 * Guarda los accesos rápidos fijados por el usuario.
	 *
	 * @param int   $user_id User ID.
	 * @param mixed $slugs   Tab slugs.
	 * @return list<string>|\WP_Error
	 */
	public static function save_quick_links( $user_id, $slugs ) {
		$user_id = absint( $user_id );

		if ( $user_id <= 0 ) {
			return new WP_Error( 'invalid_user', __( 'Usuario no válido.', 'logistica' ) );
		}

		$clean = self::sanitize_quick_links( $slugs, $user_id );
		$raw   = get_user_meta( $user_id, self::META_KEY, true );
		$data  = wp_parse_args( is_array( $raw ) ? $raw : array(), self::default_data() );

		$data['quick_links'] = $clean;
		update_user_meta( $user_id, self::META_KEY, $data );

		return $clean;
	}

	/**
	 * @param array<string, mixed> $data Panel data.
	 * @return array<string, mixed>
	 */
	private static function ensure_week_days( $data ) {
		$days = is_array( $data['checklist_days'] ?? null ) ? $data['checklist_days'] : array();

		foreach ( self::week_days() as $day ) {
			$key = $day['date'];
			if ( ! isset( $days[ $key ] ) || ! is_array( $days[ $key ] ) ) {
				$days[ $key ] = array(
					'day_type'        => '',
					'day_type_manual' => false,
					'items'           => array(),
				);
				continue;
			}

			if ( ! isset( $days[ $key ]['items'] ) || ! is_array( $days[ $key ]['items'] ) ) {
				$days[ $key ]['items'] = array();
			}

			if ( ! isset( $days[ $key ]['day_type'] ) ) {
				$days[ $key ]['day_type'] = '';
			}

			if ( ! isset( $days[ $key ]['day_type_manual'] ) ) {
				$days[ $key ]['day_type_manual'] = false;
			}
		}

		$data['checklist_days'] = $days;

		return $data;
	}

	/**
	 * Marca domingos como festivo cuando el usuario no eligió tipo manualmente.
	 *
	 * @param array<string, mixed> $data Panel data.
	 * @return array<string, mixed>
	 */
	private static function apply_automatic_day_types( $data ) {
		$days = is_array( $data['checklist_days'] ?? null ) ? $data['checklist_days'] : array();

		foreach ( $days as $date => $day ) {
			if ( ! is_string( $date ) || ! is_array( $day ) ) {
				continue;
			}

			if ( ! empty( $day['day_type_manual'] ) ) {
				continue;
			}

			$auto = Logistica_Settings::resolve_automatic_day_type( $date );
			$days[ $date ]['day_type'] = '' !== $auto ? $auto : '';
		}

		$data['checklist_days'] = $days;

		return $data;
	}

	/**
	 * @param string $day_type Tipo de día del checklist.
	 * @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 );
	}

	/**
	 * Índice de día de la semana (0 = lunes … 6 = domingo).
	 *
	 * @param string $date Fecha Y-m-d.
	 * @return int
	 */
	private static function weekday_index( $date ) {
		$dt = DateTimeImmutable::createFromFormat( 'Y-m-d', $date, self::panel_timezone() );
		if ( ! $dt ) {
			return 0;
		}

		return ( (int) $dt->format( 'N' ) ) - 1;
	}

	/**
	 * Clave ISO de semana para comparar reinicios semanales.
	 *
	 * @param string $date Fecha Y-m-d.
	 * @return string
	 */
	private static function week_key_for_date( $date ) {
		$dt = DateTimeImmutable::createFromFormat( 'Y-m-d', $date, self::panel_timezone() );
		if ( ! $dt ) {
			return '';
		}

		return $dt->format( 'o' ) . '-W' . $dt->format( 'W' );
	}

	/**
	 * Lunes de la semana ISO que contiene la fecha.
	 *
	 * @param string $date Fecha Y-m-d.
	 * @return string
	 */
	private static function monday_for_date( $date ) {
		$dt = DateTimeImmutable::createFromFormat( 'Y-m-d', $date, self::panel_timezone() );
		if ( ! $dt ) {
			return '';
		}

		return $dt->modify( 'monday this week' )->format( 'Y-m-d' );
	}

	/**
	 * Copia inmutable de un día del checklist para historial.
	 *
	 * @param array<string, mixed> $day Día del checklist.
	 * @return array{day_type:string,items:list<array<string, mixed>>}
	 */
	private static function snapshot_checklist_day( $day ) {
		if ( ! is_array( $day ) ) {
			return array(
				'day_type' => '',
				'items'    => array(),
			);
		}

		$day_type = sanitize_key( (string) ( $day['day_type'] ?? '' ) );
		if ( ! isset( self::get_day_types()[ $day_type ] ) ) {
			$day_type = '';
		}

		$items = array();
		foreach ( array_slice( (array) ( $day['items'] ?? array() ), 0, 100 ) as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}

			$carried    = self::sanitize_date( $item['carried_from'] ?? '' );
			$recurrence = self::sanitize_checklist_recurrence( $item['recurrence'] ?? 'diario' );
			$item_id    = self::sanitize_id( $item['id'] ?? '' );
			$series_id  = self::sanitize_id( $item['series_id'] ?? '' );

			if ( 'repetitivo' === $recurrence && '' === $series_id ) {
				$series_id = $item_id;
			}

			$items[] = array(
				'id'               => $item_id,
				'series_id'        => $series_id,
				'title'            => sanitize_text_field( (string) ( $item['title'] ?? '' ) ),
				'duration_minutes' => max( 0, (int) ( $item['duration_minutes'] ?? 0 ) ),
				'priority'         => self::sanitize_priority( $item['priority'] ?? 'media' ),
				'recurrence'       => $recurrence,
				'done'             => ! empty( $item['done'] ),
				'carried_from'     => $carried ?: '',
				'last_reset_week'  => sanitize_text_field( (string) ( $item['last_reset_week'] ?? '' ) ),
			);
		}

		return array(
			'day_type' => $day_type,
			'items'    => $items,
		);
	}

	/**
	 * Archiva días anteriores a la semana actual antes del rollover diario.
	 *
	 * @param array<string, mixed> $data Panel data.
	 * @return array<string, mixed>
	 */
	private static function sync_checklist_week_history( $data ) {
		$days    = is_array( $data['checklist_days'] ?? null ) ? $data['checklist_days'] : array();
		$history = is_array( $data['checklist_week_history'] ?? null ) ? $data['checklist_week_history'] : array();
		$current = self::week_days();
		$monday  = $current[0]['date'] ?? self::today_date();

		$week_batches = array();
		foreach ( $days as $date => $day ) {
			if ( ! is_string( $date ) || $date >= $monday || ! is_array( $day ) ) {
				continue;
			}

			$week_start = self::monday_for_date( $date );
			if ( '' === $week_start ) {
				continue;
			}

			if ( ! isset( $week_batches[ $week_start ] ) ) {
				$week_batches[ $week_start ] = array();
			}

			$week_batches[ $week_start ][ $date ] = self::snapshot_checklist_day( $day );
		}

		foreach ( $week_batches as $week_start => $week_days ) {
			if ( empty( $week_days ) ) {
				continue;
			}

			$dates = array_keys( $week_days );
			sort( $dates );
			$week_end = (string) end( $dates );

			if ( ! isset( $history[ $week_start ] ) || ! is_array( $history[ $week_start ] ) ) {
				$history[ $week_start ] = array(
					'week_start'  => $week_start,
					'week_end'    => $week_end,
					'days'        => $week_days,
					'archived_at' => self::current_datetime(),
				);
				continue;
			}

			$existing_days = is_array( $history[ $week_start ]['days'] ?? null ) ? $history[ $week_start ]['days'] : array();
			foreach ( $week_days as $date => $snapshot ) {
				$existing_days[ $date ] = $snapshot;
			}

			$history[ $week_start ]['days']        = $existing_days;
			$history[ $week_start ]['week_end']  = max( (string) ( $history[ $week_start ]['week_end'] ?? '' ), $week_end );
			$history[ $week_start ]['archived_at'] = self::current_datetime();
		}

		if ( count( $history ) > 52 ) {
			ksort( $history );
			$history = array_slice( $history, -52, null, true );
		}

		$data['checklist_week_history'] = $history;

		return $data;
	}

	/**
	 * @return string
	 */
	private static function generate_checklist_id() {
		return 'check_' . wp_generate_password( 10, false, false );
	}

	/**
	 * @param mixed $value Recurrence type.
	 * @return string
	 */
	private static function sanitize_checklist_recurrence( $value ) {
		$recurrence = sanitize_key( (string) $value );

		if ( ! isset( self::CHECKLIST_RECURRENCES[ $recurrence ] ) ) {
			return 'diario';
		}

		return $recurrence;
	}

	/**
	 * Sincroniza actividades repetitivas al mismo día de cada semana.
	 *
	 * @param array<string, array<string, mixed>> $days Checklist days keyed by date.
	 * @return array<string, array<string, mixed>>
	 */
	private static function process_checklist_weekly_repetitive( $days ) {
		$today     = self::today_date();
		$templates = array();
		$week_dates = array();

		foreach ( self::week_days() as $week_day ) {
			$week_dates[ $week_day['date'] ] = true;
		}

		foreach ( $days as $date => $day ) {
			if ( ! isset( $week_dates[ $date ] ) || ! is_array( $day ) ) {
				continue;
			}

			foreach ( (array) ( $day['items'] ?? array() ) as $item ) {
				if ( ! is_array( $item ) || 'repetitivo' !== self::sanitize_checklist_recurrence( $item['recurrence'] ?? '' ) ) {
					continue;
				}

				$series_id = self::sanitize_id( $item['series_id'] ?? $item['id'] ?? '' );
				if ( '' === $series_id ) {
					continue;
				}

				if ( ! isset( $templates[ $series_id ] ) || $date > $templates[ $series_id ]['source_date'] ) {
					$templates[ $series_id ] = array(
						'series_id'        => $series_id,
						'title'            => self::normalize_panel_uppercase( $item['title'] ?? '' ),
						'duration_minutes' => max( 0, (int) ( $item['duration_minutes'] ?? 0 ) ),
						'priority'         => self::sanitize_priority( $item['priority'] ?? 'media' ),
						'weekday'          => self::weekday_index( $date ),
						'source_date'      => $date,
					);
				}
			}
		}

		if ( empty( $templates ) ) {
			return $days;
		}

		foreach ( self::week_days() as $week_day ) {
			$date     = $week_day['date'];
			$weekday  = self::weekday_index( $date );
			$week_key = self::week_key_for_date( $date );

			if ( ! isset( $days[ $date ] ) || ! is_array( $days[ $date ] ) ) {
				$days[ $date ] = array(
					'day_type' => '',
					'items'    => array(),
				);
			}

			$existing_keys = array();
			foreach ( (array) $days[ $date ]['items'] as $index => $item ) {
				if ( ! is_array( $item ) ) {
					continue;
				}

				$series_id = self::sanitize_id( $item['series_id'] ?? '' );
				if ( '' === $series_id && 'repetitivo' === self::sanitize_checklist_recurrence( $item['recurrence'] ?? '' ) ) {
					$series_id = self::sanitize_id( $item['id'] ?? '' );
				}

				if ( '' !== $series_id ) {
					$existing_keys[ $series_id ] = $index;
				}
			}

			foreach ( $templates as $template ) {
				if ( (int) $template['weekday'] !== $weekday ) {
					continue;
				}

				$series_id = $template['series_id'];

				if ( ! isset( $existing_keys[ $series_id ] ) ) {
					$days[ $date ]['items'][] = array(
						'id'               => self::generate_checklist_id(),
						'series_id'        => $series_id,
						'title'            => $template['title'],
						'duration_minutes' => $template['duration_minutes'],
						'priority'         => $template['priority'],
						'recurrence'       => 'repetitivo',
						'done'             => false,
						'carried_from'     => '',
						'last_reset_week'  => $date <= $today ? $week_key : '',
					);
					continue;
				}

				if ( $date > $today ) {
					continue;
				}

				$index = $existing_keys[ $series_id ];
				$item  = $days[ $date ]['items'][ $index ];
				if ( ! is_array( $item ) || 'repetitivo' !== self::sanitize_checklist_recurrence( $item['recurrence'] ?? '' ) ) {
					continue;
				}

				$item['title']            = $template['title'];
				$item['duration_minutes'] = $template['duration_minutes'];
				$item['priority']         = $template['priority'];
				$item['series_id']        = $series_id;

				$last_reset = (string) ( $item['last_reset_week'] ?? '' );
				if ( $last_reset !== $week_key ) {
					$item['done']            = false;
					$item['carried_from']    = '';
					$item['last_reset_week'] = $week_key;
				}

				$days[ $date ]['items'][ $index ] = $item;
			}
		}

		return $days;
	}

	/**
	 * Pasa actividades diarias no completadas de días pasados al día actual.
	 *
	 * @param array<string, array<string, mixed>> $days Checklist days keyed by date.
	 * @return array<string, array<string, mixed>>
	 */
	private static function process_checklist_rollover( $days ) {
		$today = self::today_date();
		$carry = array();

		foreach ( $days as $date => $day ) {
			if ( $date >= $today || ! is_array( $day ) ) {
				continue;
			}

			$day_type = sanitize_key( (string) ( $day['day_type'] ?? '' ) );
			if ( in_array( $day_type, array( 'libre', 'vacaciones', 'permisos', 'festivo' ), true ) ) {
				continue;
			}

			$remaining = array();
			foreach ( (array) ( $day['items'] ?? array() ) as $item ) {
				if ( ! is_array( $item ) ) {
					continue;
				}

				$recurrence = self::sanitize_checklist_recurrence( $item['recurrence'] ?? 'diario' );

				if ( ! empty( $item['done'] ) ) {
					$remaining[] = $item;
					continue;
				}

				if ( 'diario' !== $recurrence ) {
					$remaining[] = $item;
					continue;
				}

				$item['carried_from'] = $item['carried_from'] ?? $date;
				$carry[]              = $item;
			}

			$days[ $date ]['items'] = $remaining;
		}

		if ( empty( $carry ) ) {
			return $days;
		}

		if ( ! isset( $days[ $today ] ) || ! is_array( $days[ $today ] ) ) {
			$days[ $today ] = array(
				'day_type' => '',
				'items'    => array(),
			);
		}

		$today_ids = array();
		foreach ( (array) $days[ $today ]['items'] as $item ) {
			if ( is_array( $item ) && ! empty( $item['id'] ) ) {
				$today_ids[ (string) $item['id'] ] = true;
			}
		}

		foreach ( $carry as $item ) {
			$id = (string) ( $item['id'] ?? '' );
			if ( '' === $id || isset( $today_ids[ $id ] ) ) {
				continue;
			}

			$days[ $today ]['items'][] = $item;
			$today_ids[ $id ]          = true;
		}

		return $days;
	}

	/**
	 * Texto del panel en mayúsculas (actividad, producto, referencia, etc.).
	 *
	 * @param mixed $value Valor.
	 * @return string
	 */
	private static function normalize_panel_uppercase( $value ) {
		$value = sanitize_text_field( (string) $value );
		if ( '' === $value ) {
			return '';
		}

		if ( function_exists( 'mb_strtoupper' ) ) {
			return mb_strtoupper( $value, 'UTF-8' );
		}

		return strtoupper( $value );
	}

	/**
	 * @param mixed $items Pendientes.
	 * @return list<array<string, mixed>>
	 */
	private static function sanitize_pendientes( $items ) {
		if ( ! is_array( $items ) ) {
			return array();
		}

		$clean = array();
		foreach ( array_slice( $items, 0, 200 ) as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}

			$status = sanitize_key( (string) ( $item['status'] ?? 'en_proceso' ) );
			if ( ! isset( self::PENDIENTE_STATUSES[ $status ] ) ) {
				$status = 'en_proceso';
			}

			$now = self::current_datetime();
			$clean[] = array(
				'id'         => self::sanitize_id( $item['id'] ?? '' ),
				'title'      => self::normalize_panel_uppercase( $item['title'] ?? '' ),
				'status'     => $status,
				'notes'      => sanitize_textarea_field( (string) ( $item['notes'] ?? '' ) ),
				'updated_at' => self::sanitize_datetime( $item['updated_at'] ?? $now ) ?: $now,
				'created_at' => self::sanitize_datetime( $item['created_at'] ?? $now ) ?: $now,
			);
		}

		usort(
			$clean,
			static function ( $a, $b ) {
				$done_a = ( 'terminado' === ( $a['status'] ?? '' ) ) ? 1 : 0;
				$done_b = ( 'terminado' === ( $b['status'] ?? '' ) ) ? 1 : 0;
				if ( $done_a !== $done_b ) {
					return $done_a <=> $done_b;
				}

				return strcmp( (string) $b['updated_at'], (string) $a['updated_at'] );
			}
		);

		return $clean;
	}

	/**
	 * @param string $hash URL hash fragment.
	 * @return string
	 */
	private static function sanitize_resume_deep_link( $hash ) {
		$hash = trim( (string) $hash );

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

		if ( '#' !== $hash[0] ) {
			$hash = '#' . ltrim( $hash, '#' );
		}

		if ( ! preg_match( '/^#[a-z0-9][a-z0-9\-_]*$/i', $hash ) ) {
			return '';
		}

		return sanitize_text_field( $hash );
	}

	/**
	 * @param mixed $items Resume points.
	 * @return list<array<string, mixed>>
	 */
	private static function sanitize_resume_points( $items ) {
		if ( ! is_array( $items ) ) {
			return array();
		}

		$clean = array();
		foreach ( $items as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}

			if ( empty( $item['auto'] ) ) {
				continue;
			}

			$tab_slug = sanitize_key( (string) ( $item['tab_slug'] ?? '' ) );
			if ( '' === $tab_slug ) {
				continue;
			}

			$now = self::current_datetime();
			$clean[] = array(
				'id'         => self::sanitize_id( $item['id'] ?? '' ),
				'title'      => self::normalize_panel_uppercase( $item['title'] ?? '' ),
				'context'    => sanitize_textarea_field( (string) ( $item['context'] ?? '' ) ),
				'tab_slug'   => $tab_slug,
				'deep_link'  => self::sanitize_resume_deep_link( (string) ( $item['deep_link'] ?? '' ) ),
				'updated_at' => self::sanitize_datetime( $item['updated_at'] ?? $now ) ?: $now,
				'auto'       => ! empty( $item['auto'] ),
			);
		}

		usort(
			$clean,
			static function ( $a, $b ) {
				return strcmp( (string) $b['updated_at'], (string) $a['updated_at'] );
			}
		);

		return array_slice( $clean, 0, self::RESUME_POINTS_MAX );
	}

	/**
	 * @param mixed $items Agenda items.
	 * @return list<array<string, mixed>>
	 */
	private static function sanitize_agenda( $items ) {
		if ( ! is_array( $items ) ) {
			return array();
		}

		$clean    = array();
		foreach ( array_slice( $items, 0, 200 ) as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}

			$date = self::sanitize_date( $item['date'] ?? '' );
			if ( ! $date ) {
				continue;
			}

			$now        = self::current_datetime();
			$updated_at = self::sanitize_datetime( $item['updated_at'] ?? '' );
			$created_at = self::sanitize_datetime( $item['created_at'] ?? '' ) ?: $now;
			$status     = self::sanitize_agenda_status( $item['status'] ?? '', ! empty( $item['done'] ) );
			$gestion_started = self::sanitize_datetime( $item['gestion_started_at'] ?? '' ) ?: $created_at;
			$status_timeline = self::sanitize_status_timeline( $item['status_timeline'] ?? array(), $status, $gestion_started );
			$novedades       = self::sanitize_novedades( $item['novedades'] ?? array() );
			$done            = self::is_agenda_status_complete( $status );

			$clean[] = array(
				'id'                 => self::sanitize_id( $item['id'] ?? '' ),
				'title'              => self::normalize_panel_uppercase( $item['title'] ?? '' ),
				'date'               => $date,
				'time'               => sanitize_text_field( (string) ( $item['time'] ?? '' ) ),
				'notes'              => sanitize_textarea_field( (string) ( $item['notes'] ?? '' ) ),
				'priority'           => self::sanitize_priority( $item['priority'] ?? 'media' ),
				'status'             => $status,
				'done'               => $done,
				'created_at'         => $created_at,
				'gestion_started_at' => $gestion_started,
				'status_timeline'    => $status_timeline,
				'novedades'          => $novedades,
				'updated_at'         => $updated_at ?: '',
			);
		}

		usort(
			$clean,
			static function ( $a, $b ) {
				$done_a = ! empty( $a['done'] ) ? 1 : 0;
				$done_b = ! empty( $b['done'] ) ? 1 : 0;
				if ( $done_a !== $done_b ) {
					return $done_a <=> $done_b;
				}

				$cmp = strcmp( (string) $a['date'], (string) $b['date'] );
				if ( 0 !== $cmp ) {
					return $cmp;
				}

				return strcmp( (string) $a['time'], (string) $b['time'] );
			}
		);

		return $clean;
	}

	/**
	 * @param mixed $status      Status key.
	 * @param bool  $legacy_done Legacy done flag.
	 * @return string
	 */
	private static function sanitize_agenda_status( $status, $legacy_done = false ) {
		$key = sanitize_key( (string) $status );
		$map = self::get_agenda_statuses();
		if ( '' !== $key && isset( $map[ $key ] ) ) {
			return $key;
		}

		return $legacy_done ? 'completado' : 'pendiente';
	}

	/**
	 * @param string $status Agenda status.
	 * @return bool
	 */
	public static function is_agenda_status_complete( $status ) {
		return 'completado' === sanitize_key( (string) $status );
	}

	/**
	 * @param mixed $days Checklist days.
	 * @return array<string, array<string, mixed>>
	 */
	private static function sanitize_checklist_days( $days ) {
		if ( ! is_array( $days ) ) {
			return array();
		}

		$clean = array();
		foreach ( $days as $date => $day ) {
			$date_key = self::sanitize_date( $date );
			if ( ! $date_key || ! is_array( $day ) ) {
				continue;
			}

			$day_type = sanitize_key( (string) ( $day['day_type'] ?? '' ) );
			if ( ! isset( self::get_day_types()[ $day_type ] ) ) {
				$day_type = '';
			}

			$day_type_manual = ! empty( $day['day_type_manual'] );

			$items = array();
			foreach ( array_slice( (array) ( $day['items'] ?? array() ), 0, 100 ) as $item ) {
				if ( ! is_array( $item ) ) {
					continue;
				}

				$carried    = self::sanitize_date( $item['carried_from'] ?? '' );
				$recurrence = self::sanitize_checklist_recurrence( $item['recurrence'] ?? 'diario' );
				$item_id    = self::sanitize_id( $item['id'] ?? '' );
				if ( '' !== $item_id && 0 === strpos( $item_id, 'link_' ) ) {
					continue;
				}
				$series_id  = self::sanitize_id( $item['series_id'] ?? '' );

				if ( 'repetitivo' === $recurrence && '' === $series_id ) {
					$series_id = $item_id;
				}

				$items[] = array(
					'id'               => $item_id,
					'series_id'        => $series_id,
					'title'            => self::normalize_panel_uppercase( $item['title'] ?? '' ),
					'duration_minutes' => max( 0, (int) ( $item['duration_minutes'] ?? 0 ) ),
					'priority'         => self::sanitize_priority( $item['priority'] ?? 'media' ),
					'recurrence'       => $recurrence,
					'done'             => ! empty( $item['done'] ),
					'carried_from'     => $carried ?: '',
					'last_reset_week'  => sanitize_text_field( (string) ( $item['last_reset_week'] ?? '' ) ),
				);
			}

			$clean[ $date_key ] = array(
				'day_type'        => $day_type,
				'day_type_manual' => $day_type_manual,
				'items'           => $items,
			);
		}

		return $clean;
	}

	/**
	 * Importa proveedores desde filas Excel/CSV y fusiona con los existentes.
	 *
	 * @param int                             $user_id User ID.
	 * @param array<int, array<string, mixed>> $rows    Filas importadas.
	 * @return array{proveedores:list<array<string, mixed>>,message:string,created:int,updated:int,skipped:int}|\WP_Error
	 */
	public static function import_proveedores_rows( $user_id, $rows ) {
		$user_id = absint( $user_id );
		if ( $user_id <= 0 ) {
			return new WP_Error( 'invalid_user', __( 'Usuario no válido.', 'logistica' ) );
		}

		if ( ! is_array( $rows ) ) {
			return new WP_Error( 'invalid_rows', __( 'Archivo sin filas válidas.', 'logistica' ) );
		}

		$panel      = self::get_panel( $user_id );
		$existing   = is_array( $panel['proveedores'] ?? null ) ? $panel['proveedores'] : array();
		$by_name    = array();
		$created    = 0;
		$updated    = 0;
		$skipped    = 0;
		$now        = self::current_datetime();

		foreach ( $existing as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}
			$nombre = mb_strtolower( trim( (string) ( $item['nombre'] ?? '' ) ) );
			if ( '' !== $nombre ) {
				$by_name[ $nombre ] = $item;
			}
		}

		foreach ( $rows as $row ) {
			if ( ! is_array( $row ) ) {
				++$skipped;
				continue;
			}

			$nombre = self::normalize_panel_uppercase( $row['nombre'] ?? '' );
			if ( '' === $nombre ) {
				++$skipped;
				continue;
			}

			$key = mb_strtolower( $nombre );
			$entry = array(
				'id'          => isset( $by_name[ $key ]['id'] ) ? self::sanitize_id( $by_name[ $key ]['id'] ) : self::sanitize_id( 'prov_' . wp_generate_password( 10, false, false ) ),
				'nombre'      => $nombre,
				'email'       => sanitize_email( (string) ( $row['email'] ?? '' ) ),
				'telefono'    => sanitize_text_field( (string) ( $row['telefono'] ?? '' ) ),
				'contacto'    => sanitize_text_field( (string) ( $row['contacto'] ?? '' ) ),
				'descripcion' => sanitize_textarea_field( (string) ( $row['descripcion'] ?? '' ) ),
				'updated_at'  => $now,
			);

			if ( isset( $by_name[ $key ] ) ) {
				++$updated;
			} else {
				++$created;
			}

			$by_name[ $key ] = $entry;
		}

		$proveedores = self::sanitize_proveedores( array_values( $by_name ) );
		$panel['proveedores'] = $proveedores;
		update_user_meta( $user_id, self::META_KEY, $panel );

		return array(
			'proveedores' => $proveedores,
			'created'     => $created,
			'updated'     => $updated,
			'skipped'     => $skipped,
			'message'     => sprintf(
				/* translators: 1: created 2: updated 3: skipped */
				__( 'Importación completada. Nuevos: %1$d. Actualizados: %2$d. Omitidos: %3$d.', 'logistica' ),
				$created,
				$updated,
				$skipped
			),
		);
	}

	/**
	 * Importa compras desde filas Excel/CSV y fusiona con las existentes.
	 *
	 * @param int                             $user_id User ID.
	 * @param array<int, array<string, mixed>> $rows    Filas importadas.
	 * @return array{compras:list<array<string, mixed>>,message:string,created:int,updated:int,skipped:int}|\WP_Error
	 */
	public static function import_compras_rows( $user_id, $rows ) {
		$user_id = absint( $user_id );
		if ( $user_id <= 0 ) {
			return new WP_Error( 'invalid_user', __( 'Usuario no válido.', 'logistica' ) );
		}

		if ( ! is_array( $rows ) ) {
			return new WP_Error( 'invalid_rows', __( 'Archivo sin filas válidas.', 'logistica' ) );
		}

		$panel       = self::get_panel( $user_id );
		$proveedores = is_array( $panel['proveedores'] ?? null ) ? $panel['proveedores'] : array();
		$existing    = is_array( $panel['compras'] ?? null ) ? $panel['compras'] : array();
		$prov_by_name = array();
		$by_key       = array();
		$created      = 0;
		$updated      = 0;
		$skipped      = 0;
		$now          = self::current_datetime();

		foreach ( $proveedores as $proveedor ) {
			if ( ! is_array( $proveedor ) ) {
				continue;
			}
			$nombre = mb_strtolower( trim( (string) ( $proveedor['nombre'] ?? '' ) ) );
			if ( '' !== $nombre ) {
				$prov_by_name[ $nombre ] = $proveedor;
			}
		}

		foreach ( $existing as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}

			$doc = mb_strtolower( trim( (string) ( $item['documento_numero'] ?? '' ) ) );
			if ( '' !== $doc ) {
				$by_key[ 'doc:' . $doc ] = $item;
			}

			$key = self::compra_match_key(
				(string) ( $item['producto_ofrecido'] ?? $item['item'] ?? '' ),
				(string) ( $item['referencia'] ?? '' ),
				(string) ( $item['proveedor_id'] ?? '' )
			);
			if ( '' !== $key ) {
				$by_key[ 'row:' . $key ] = $item;
			}
		}

		foreach ( $rows as $row ) {
			if ( ! is_array( $row ) ) {
				++$skipped;
				continue;
			}

			$producto = self::normalize_panel_uppercase( $row['producto_ofrecido'] ?? '' );
			if ( '' === $producto ) {
				++$skipped;
				continue;
			}

			$referencia   = self::normalize_panel_uppercase( $row['referencia'] ?? '' );
			$empresa      = mb_strtolower( trim( (string) ( $row['empresa'] ?? '' ) ) );
			$proveedor_id = '';
			if ( '' !== $empresa && isset( $prov_by_name[ $empresa ]['id'] ) ) {
				$proveedor_id = self::sanitize_id( $prov_by_name[ $empresa ]['id'] );
			}

			$documento = self::normalize_panel_uppercase( $row['documento_numero'] ?? '' );
			$match_key = '' !== trim( $documento )
				? 'doc:' . mb_strtolower( trim( $documento ) )
				: 'row:' . self::compra_match_key( $producto, $referencia, $proveedor_id );

			$status = self::resolve_compra_status( $row['status'] ?? '' );
			$entry = array(
				'id'                => isset( $by_key[ $match_key ]['id'] ) ? self::sanitize_id( $by_key[ $match_key ]['id'] ) : self::sanitize_id( 'compra_' . wp_generate_password( 10, false, false ) ),
				'producto_ofrecido' => $producto,
				'referencia'        => $referencia,
				'precio'            => sanitize_text_field( (string) ( $row['precio'] ?? '' ) ),
				'cantidad'          => sanitize_text_field( (string) ( $row['cantidad'] ?? '' ) ),
				'unidad_medida'     => self::normalize_panel_uppercase( $row['unidad_medida'] ?? '' ),
				'fecha_requerida'   => self::sanitize_import_date( $row['fecha_requerida'] ?? '' ),
				'fecha_solicitud'   => self::sanitize_import_date( $row['fecha_solicitud'] ?? '' ),
				'status'            => $status,
				'priority'          => self::resolve_priority( $row['priority'] ?? '' ),
				'escala'            => self::normalize_panel_uppercase( $row['escala'] ?? '' ),
				'proveedor_id'      => $proveedor_id,
				'fecha'             => self::sanitize_import_date( $row['fecha'] ?? '' ),
				'documento_numero'  => $documento,
				'fecha_recibido'    => 'recibido' === $status ? $now : '',
				'updated_at'        => $now,
			);

			if ( isset( $by_key[ $match_key ] ) ) {
				++$updated;
			} else {
				++$created;
			}

			$by_key[ $match_key ] = $entry;
		}

		$merged_by_id = array();
		foreach ( $by_key as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}
			$id = (string) ( $item['id'] ?? '' );
			if ( '' !== $id ) {
				$merged_by_id[ $id ] = $item;
			}
		}

		$compras = self::sanitize_compras( array_values( $merged_by_id ), self::proveedor_ids( $proveedores ) );
		$panel['compras'] = $compras;
		update_user_meta( $user_id, self::META_KEY, $panel );

		return array(
			'compras' => $compras,
			'created' => $created,
			'updated' => $updated,
			'skipped' => $skipped,
			'message' => sprintf(
				/* translators: 1: created 2: updated 3: skipped */
				__( 'Importación completada. Nuevos: %1$d. Actualizados: %2$d. Omitidos: %3$d.', 'logistica' ),
				$created,
				$updated,
				$skipped
			),
		);
	}

	/**
	 * @param string $producto Producto.
	 * @param string $referencia Referencia.
	 * @param string $proveedor_id ID proveedor.
	 * @return string
	 */
	private static function compra_match_key( $producto, $referencia, $proveedor_id ) {
		return mb_strtolower( trim( $producto ) ) . '|' . mb_strtolower( trim( $referencia ) ) . '|' . sanitize_key( (string) $proveedor_id );
	}

	/**
	 * @param mixed $value Estado (clave o etiqueta).
	 * @return string
	 */
	private static function resolve_compra_status( $value ) {
		$text = trim( (string) $value );
		if ( '' === $text ) {
			return 'pendiente';
		}

		$key = sanitize_key( $text );
		$statuses = self::get_compra_statuses();
		if ( isset( $statuses[ $key ] ) ) {
			return $key;
		}

		$normalized = self::normalize_match_text( $text );
		foreach ( $statuses as $status_key => $label ) {
			if ( self::normalize_match_text( $label ) === $normalized ) {
				return $status_key;
			}
		}

		return 'pendiente';
	}

	/**
	 * @param mixed $value Prioridad importada.
	 * @return string
	 */
	private static function resolve_priority( $value ) {
		$text = trim( (string) $value );
		if ( '' === $text ) {
			return 'media';
		}

		$key = sanitize_key( $text );
		$priorities = self::get_priorities();
		if ( isset( $priorities[ $key ] ) ) {
			return $key;
		}

		$normalized = self::normalize_match_text( $text );
		foreach ( $priorities as $priority_key => $label ) {
			if ( self::normalize_match_text( $label ) === $normalized ) {
				return $priority_key;
			}
		}

		return 'media';
	}

	/**
	 * @param mixed $value Fecha importada.
	 * @return string Y-m-d o vacío.
	 */
	private static function sanitize_import_date( $value ) {
		$text = trim( (string) $value );
		if ( '' === $text ) {
			return '';
		}

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

		if ( preg_match( '/^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})$/', $text, $matches ) ) {
			return sprintf( '%04d-%02d-%02d', (int) $matches[3], (int) $matches[2], (int) $matches[1] );
		}

		$time = strtotime( $text );
		if ( false !== $time ) {
			return wp_date( 'Y-m-d', $time, self::panel_timezone() );
		}

		return '';
	}

	/**
	 * @param string $text Texto.
	 * @return string
	 */
	private static function normalize_match_text( $text ) {
		$text = remove_accents( mb_strtolower( trim( (string) $text ) ) );
		return preg_replace( '/\s+/', ' ', $text );
	}

	/**
	 * @param mixed $items Proveedores.
	 * @return list<string>
	 */
	private static function proveedor_ids( $items ) {
		if ( ! is_array( $items ) ) {
			return array();
		}

		$ids = array();
		foreach ( $items as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}
			$id = self::sanitize_id( $item['id'] ?? '' );
			if ( '' !== $id ) {
				$ids[] = $id;
			}
		}

		return $ids;
	}

	/**
	 * @param mixed $items Proveedores.
	 * @return list<array<string, mixed>>
	 */
	private static function sanitize_proveedores( $items ) {
		if ( ! is_array( $items ) ) {
			return array();
		}

		$clean = array();
		$now   = self::current_datetime();

		foreach ( array_slice( $items, 0, 500 ) as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}

			$nombre = self::normalize_panel_uppercase( $item['nombre'] ?? '' );
			if ( '' === $nombre ) {
				continue;
			}

			$clean[] = array(
				'id'          => self::sanitize_id( $item['id'] ?? '' ),
				'nombre'      => $nombre,
				'email'       => sanitize_email( (string) ( $item['email'] ?? '' ) ),
				'telefono'    => sanitize_text_field( (string) ( $item['telefono'] ?? '' ) ),
				'contacto'    => sanitize_text_field( (string) ( $item['contacto'] ?? '' ) ),
				'descripcion' => sanitize_textarea_field( (string) ( $item['descripcion'] ?? '' ) ),
				'updated_at'  => self::sanitize_datetime( $item['updated_at'] ?? $now ) ?: $now,
			);
		}

		usort(
			$clean,
			static function ( $a, $b ) {
				return strcasecmp( (string) $a['nombre'], (string) $b['nombre'] );
			}
		);

		return $clean;
	}

	/**
	 * @param mixed        $items         Compras.
	 * @param list<string> $proveedor_ids IDs válidos de proveedor.
	 * @return list<array<string, mixed>>
	 */
	private static function sanitize_compras( $items, $proveedor_ids = array() ) {
		if ( ! is_array( $items ) ) {
			return array();
		}

		$clean = array();
		foreach ( array_slice( $items, 0, 200 ) as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}

			$status = sanitize_key( (string) ( $item['status'] ?? 'pendiente' ) );
			if ( ! isset( self::get_compra_statuses()[ $status ] ) ) {
				$legacy_map = array(
					'pedido' => 'esperando_aprobacion',
				);
				$status = $legacy_map[ $status ] ?? 'pendiente';
			}

			$proveedor_id = self::sanitize_id( $item['proveedor_id'] ?? '' );
			if ( '' !== $proveedor_id && ! in_array( $proveedor_id, $proveedor_ids, true ) ) {
				$proveedor_id = '';
			}

			$now              = self::current_datetime();
			$updated_at       = self::sanitize_datetime( $item['updated_at'] ?? $now ) ?: $now;
			$created_at       = self::sanitize_datetime( $item['created_at'] ?? '' ) ?: $updated_at;
			$gestion_started  = self::sanitize_datetime( $item['gestion_started_at'] ?? '' ) ?: $created_at;
			$status_timeline  = self::sanitize_status_timeline( $item['status_timeline'] ?? array(), $status, $gestion_started );
			$novedades        = self::sanitize_novedades( $item['novedades'] ?? array() );

			$clean[] = array(
				'id'                  => self::sanitize_id( $item['id'] ?? '' ),
				'producto_ofrecido'   => self::normalize_panel_uppercase( $item['producto_ofrecido'] ?? $item['item'] ?? '' ),
				'referencia'          => self::normalize_panel_uppercase( $item['referencia'] ?? '' ),
				'precio'              => sanitize_text_field( (string) ( $item['precio'] ?? '' ) ),
				'cantidad'            => sanitize_text_field( (string) ( $item['cantidad'] ?? '' ) ),
				'unidad_medida'       => self::normalize_panel_uppercase( $item['unidad_medida'] ?? '' ),
				'fecha_requerida'     => self::sanitize_date( $item['fecha_requerida'] ?? '' ) ?: '',
				'fecha_solicitud'     => self::sanitize_date( $item['fecha_solicitud'] ?? '' ) ?: '',
				'status'              => $status,
				'priority'            => self::sanitize_priority( $item['priority'] ?? 'media' ),
				'escala'              => self::normalize_panel_uppercase( $item['escala'] ?? $item['quantity'] ?? '' ),
				'proveedor_id'        => $proveedor_id,
				'fecha'               => self::sanitize_date( $item['fecha'] ?? '' ) ?: '',
				'documento_numero'    => self::normalize_panel_uppercase( $item['documento_numero'] ?? '' ),
				'fecha_recibido'      => self::sanitize_compra_fecha_recibido(
					$status,
					$item['fecha_recibido'] ?? '',
					$item['updated_at'] ?? $now
				),
				'created_at'          => $created_at,
				'gestion_started_at'  => $gestion_started,
				'status_timeline'     => $status_timeline,
				'novedades'           => $novedades,
				'updated_at'          => $updated_at,
			);
		}

		usort(
			$clean,
			static function ( $a, $b ) {
				$done_a = ( 'recibido' === ( $a['status'] ?? '' ) ) ? 1 : 0;
				$done_b = ( 'recibido' === ( $b['status'] ?? '' ) ) ? 1 : 0;
				if ( $done_a !== $done_b ) {
					return $done_a <=> $done_b;
				}

				return strcmp( (string) $b['updated_at'], (string) $a['updated_at'] );
			}
		);

		return $clean;
	}

	/**
	 * Fecha en que la compra quedó recibida (solo si status = recibido).
	 *
	 * @param string $status         Estado compra.
	 * @param mixed  $fecha_recibido Valor recibido.
	 * @param mixed  $updated_at     Respaldo si no hay fecha explícita.
	 * @return string
	 */
	private static function sanitize_compra_fecha_recibido( $status, $fecha_recibido, $updated_at = '' ) {
		if ( 'recibido' !== sanitize_key( (string) $status ) ) {
			return '';
		}

		$clean = self::sanitize_datetime( $fecha_recibido );
		if ( '' !== $clean ) {
			return $clean;
		}

		$from_updated = self::sanitize_datetime( $updated_at );
		if ( '' !== $from_updated ) {
			return $from_updated;
		}

		return self::current_datetime();
	}

	/**
	 * @param mixed  $items           Timeline de estados.
	 * @param string $current_status  Estado actual.
	 * @param string $gestion_started Inicio de gestión.
	 * @return list<array<string, mixed>>
	 */
	private static function is_gestion_terminal_status( $status ) {
		$status = sanitize_key( (string) $status );
		return in_array( $status, array( 'completado', 'recibido' ), true );
	}

	private static function sanitize_status_timeline( $items, $current_status, $gestion_started ) {
		$current_status  = sanitize_key( (string) $current_status );
		$gestion_started = self::sanitize_datetime( $gestion_started ) ?: self::current_datetime();
		$clean           = array();

		if ( is_array( $items ) ) {
			foreach ( array_slice( $items, 0, 100 ) as $item ) {
				if ( ! is_array( $item ) ) {
					continue;
				}

				$status = sanitize_key( (string) ( $item['status'] ?? '' ) );
				if ( '' === $status ) {
					continue;
				}

				$started = self::sanitize_datetime( $item['started_at'] ?? '' );
				if ( '' === $started ) {
					continue;
				}

				$ended   = self::sanitize_datetime( $item['ended_at'] ?? '' );
				$seconds = max( 0, (int) ( $item['seconds'] ?? 0 ) );

				// Completado / Recibido no acumulan tiempo de gestión.
				if ( self::is_gestion_terminal_status( $status ) ) {
					$ended   = $started;
					$seconds = 0;
				}

				$clean[] = array(
					'status'     => $status,
					'started_at' => $started,
					'ended_at'   => $ended,
					'seconds'    => $seconds,
				);
			}
		}

		if ( ! $clean ) {
			$terminal = self::is_gestion_terminal_status( $current_status );
			$clean[]  = array(
				'status'     => $current_status ?: 'pendiente',
				'started_at' => $gestion_started,
				'ended_at'   => $terminal ? $gestion_started : '',
				'seconds'    => 0,
			);
		}

		return $clean;
	}

	/**
	 * @param mixed $items Novedades de compra.
	 * @return list<array<string, mixed>>
	 */
	private static function sanitize_novedades( $items ) {
		if ( ! is_array( $items ) ) {
			return array();
		}

		$clean = array();
		foreach ( array_slice( $items, 0, 200 ) as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}

			$texto = sanitize_textarea_field( (string) ( $item['texto'] ?? '' ) );
			$at    = self::sanitize_datetime( $item['at'] ?? '' );
			if ( '' === $texto || '' === $at ) {
				continue;
			}

			$clean[] = array(
				'id'     => self::sanitize_id( $item['id'] ?? '' ),
				'at'     => $at,
				'texto'  => $texto,
				'status' => sanitize_key( (string) ( $item['status'] ?? '' ) ),
			);
		}

		usort(
			$clean,
			static function ( $a, $b ) {
				return strcmp( (string) ( $a['at'] ?? '' ), (string) ( $b['at'] ?? '' ) );
			}
		);

		return $clean;
	}

	/**
	 * @param mixed $items Avisos del panel.
	 * @return list<array<string, mixed>>
	 */
	private static function sanitize_avisos( $items ) {
		if ( ! is_array( $items ) ) {
			return array();
		}

		$clean = array();
		foreach ( array_slice( $items, 0, 100 ) as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}

			$title = sanitize_text_field( (string) ( $item['title'] ?? '' ) );
			$body  = sanitize_textarea_field( (string) ( $item['body'] ?? '' ) );
			if ( '' === $title && '' === $body ) {
				continue;
			}

			$created = self::sanitize_datetime( $item['created_at'] ?? '' ) ?: self::current_datetime();

			$clean[] = array(
				'id'           => self::sanitize_id( $item['id'] ?? '' ),
				'title'        => $title,
				'body'         => $body,
				'created_at'   => $created,
				'read'         => ! empty( $item['read'] ),
				'email_sent'   => ! empty( $item['email_sent'] ),
				'source_type'  => sanitize_key( (string) ( $item['source_type'] ?? '' ) ),
				'source_id'    => self::sanitize_id( $item['source_id'] ?? '' ),
				'dedupe_key'   => sanitize_text_field( (string) ( $item['dedupe_key'] ?? '' ) ),
				'snooze_until' => self::sanitize_date( $item['snooze_until'] ?? '' ) ?: '',
			);
		}

		usort(
			$clean,
			static function ( $a, $b ) {
				return strcmp( (string) ( $b['created_at'] ?? '' ), (string) ( $a['created_at'] ?? '' ) );
			}
		);

		return $clean;
	}

	/**
	 * Genera o actualiza avisos por vencimiento, hoy y por vencer (uno por ítem).
	 *
	 * @param array<string, mixed> $data Panel.
	 * @return array<string, mixed>
	 */
	private static function sync_overdue_avisos( $data ) {
		$today      = self::current_moment()['date'];
		$soon_until = self::date_plus_days( $today, 2 );
		$avisos     = is_array( $data['avisos'] ?? null ) ? $data['avisos'] : array();
		$changed    = false;

		// Solo colapsa avisos automáticos de vencimiento (no novedades manuales).
		$by_source = array();
		$drop      = array();

		foreach ( $avisos as $index => $aviso ) {
			if ( ! is_array( $aviso ) || ! self::is_overdue_auto_aviso( $aviso ) ) {
				continue;
			}
			$source_type = sanitize_key( (string) ( $aviso['source_type'] ?? '' ) );
			$source_id   = self::sanitize_id( $aviso['source_id'] ?? '' );
			if ( '' === $source_type || '' === $source_id ) {
				continue;
			}
			$source_key = $source_type . ':' . $source_id;
			if ( ! isset( $by_source[ $source_key ] ) ) {
				$by_source[ $source_key ] = $index;
				continue;
			}

			$kept       = (int) $by_source[ $source_key ];
			$kept_row   = $avisos[ $kept ];
			$prefer_new = self::prefer_aviso_over( $aviso, $kept_row );
			if ( $prefer_new ) {
				$drop[ $kept ]            = true;
				$by_source[ $source_key ] = $index;
			} else {
				$drop[ $index ] = true;
			}
			$changed = true;
		}

		if ( ! empty( $drop ) ) {
			$avisos = array_values(
				array_filter(
					$avisos,
					static function ( $row, $index ) use ( $drop ) {
						return empty( $drop[ $index ] );
					},
					ARRAY_FILTER_USE_BOTH
				)
			);
			$by_source = array();
			foreach ( $avisos as $index => $aviso ) {
				if ( ! self::is_overdue_auto_aviso( $aviso ) ) {
					continue;
				}
				$source_type = sanitize_key( (string) ( $aviso['source_type'] ?? '' ) );
				$source_id   = self::sanitize_id( $aviso['source_id'] ?? '' );
				if ( '' !== $source_type && '' !== $source_id ) {
					$by_source[ $source_type . ':' . $source_id ] = $index;
				}
			}
		}

		// Normaliza claves legacy overdue_*_{fecha} a una clave estable por ítem.
		foreach ( $avisos as $index => $aviso ) {
			if ( ! self::is_overdue_auto_aviso( $aviso ) ) {
				continue;
			}
			$source_type = sanitize_key( (string) ( $aviso['source_type'] ?? '' ) );
			$source_id   = self::sanitize_id( $aviso['source_id'] ?? '' );
			if ( '' === $source_type || '' === $source_id ) {
				continue;
			}
			$stable = 'overdue_' . $source_type . '_' . $source_id;
			if ( (string) ( $aviso['dedupe_key'] ?? '' ) !== $stable ) {
				$avisos[ $index ]['dedupe_key'] = $stable;
				$changed                        = true;
			}
			$by_source[ $source_type . ':' . $source_id ] = $index;
		}

		$compras = is_array( $data['compras'] ?? null ) ? $data['compras'] : array();
		foreach ( $compras as $compra ) {
			if ( ! is_array( $compra ) ) {
				continue;
			}
			$status = (string) ( $compra['status'] ?? '' );
			if ( 'recibido' === $status ) {
				continue;
			}
			$fecha = (string) ( $compra['fecha_requerida'] ?? '' );
			$bucket = self::classify_due_bucket( $fecha, $today, $soon_until );
			if ( ! $bucket ) {
				continue;
			}
			$id = (string) ( $compra['id'] ?? '' );
			if ( '' === $id ) {
				continue;
			}

			$producto = (string) ( $compra['producto_ofrecido'] ?? $compra['item'] ?? __( 'Compra', 'logistica' ) );
			$payload  = self::build_due_aviso_payload( 'compra', $id, $producto, $fecha, $bucket );

			$result    = self::upsert_overdue_aviso( $avisos, $by_source, $payload, $today );
			$avisos    = $result['avisos'];
			$by_source = $result['by_source'];
			if ( $result['changed'] ) {
				$changed = true;
			}
		}

		$agenda = is_array( $data['agenda'] ?? null ) ? $data['agenda'] : array();
		foreach ( $agenda as $item ) {
			if ( ! is_array( $item ) || self::is_agenda_status_complete( (string) ( $item['status'] ?? '' ) ) || ! empty( $item['done'] ) ) {
				continue;
			}
			$fecha  = (string) ( $item['date'] ?? '' );
			$bucket = self::classify_due_bucket( $fecha, $today, $soon_until );
			if ( ! $bucket ) {
				continue;
			}
			$id = (string) ( $item['id'] ?? '' );
			if ( '' === $id ) {
				continue;
			}

			$title   = (string) ( $item['title'] ?? __( 'Actividad', 'logistica' ) );
			$payload = self::build_due_aviso_payload( 'agenda', $id, $title, $fecha, $bucket );

			$result    = self::upsert_overdue_aviso( $avisos, $by_source, $payload, $today );
			$avisos    = $result['avisos'];
			$by_source = $result['by_source'];
			if ( $result['changed'] ) {
				$changed = true;
			}
		}

		if ( $changed ) {
			$data['avisos'] = self::sanitize_avisos( $avisos );
		}

		return $data;
	}

	/**
	 * @param string $ymd   Y-m-d.
	 * @param int    $days  Días a sumar (puede ser negativo).
	 * @return string Y-m-d
	 */
	private static function date_plus_days( $ymd, $days ) {
		$dt = DateTimeImmutable::createFromFormat( 'Y-m-d', (string) $ymd, self::panel_timezone() );
		if ( ! $dt instanceof DateTimeImmutable ) {
			$dt = self::now_immutable();
		}

		return $dt->modify( ( (int) $days >= 0 ? '+' : '' ) . (int) $days . ' days' )->format( 'Y-m-d' );
	}

	/**
	 * Clasifica una fecha respecto a hoy: overdue|today|soon|null.
	 *
	 * @param string $fecha      Y-m-d.
	 * @param string $today      Y-m-d.
	 * @param string $soon_until Y-m-d inclusive.
	 * @return string|null
	 */
	private static function classify_due_bucket( $fecha, $today, $soon_until ) {
		$fecha = (string) $fecha;
		if ( '' === $fecha ) {
			return null;
		}
		if ( $fecha < $today ) {
			return 'overdue';
		}
		if ( $fecha === $today ) {
			return 'today';
		}
		if ( $fecha <= $soon_until ) {
			return 'soon';
		}

		return null;
	}

	/**
	 * @param string $source_type compra|agenda.
	 * @param string $id          ID origen.
	 * @param string $label       Producto o título.
	 * @param string $fecha       Y-m-d.
	 * @param string $bucket      overdue|today|soon.
	 * @return array<string, string>
	 */
	private static function build_due_aviso_payload( $source_type, $id, $label, $fecha, $bucket ) {
		$source_type = sanitize_key( $source_type );
		$is_compra   = ( 'compra' === $source_type || 'compras' === $source_type );
		$source_type = $is_compra ? 'compra' : 'agenda';

		if ( 'overdue' === $bucket ) {
			$title = $is_compra ? __( 'Compra vencida', 'logistica' ) : __( 'Actividad vencida', 'logistica' );
			$body  = $is_compra
				? sprintf(
					/* translators: 1: product name, 2: required date */
					__( 'La compra «%1$s» tenía fecha requerida el %2$s y sigue en gestión.', 'logistica' ),
					$label,
					$fecha
				)
				: sprintf(
					/* translators: 1: activity title, 2: date */
					__( 'La actividad «%1$s» del %2$s sigue pendiente.', 'logistica' ),
					$label,
					$fecha
				);
		} elseif ( 'today' === $bucket ) {
			$title = $is_compra ? __( 'Compra para hoy', 'logistica' ) : __( 'Actividad para hoy', 'logistica' );
			$body  = $is_compra
				? sprintf(
					/* translators: 1: product name, 2: required date */
					__( 'La compra «%1$s» vence hoy (%2$s).', 'logistica' ),
					$label,
					$fecha
				)
				: sprintf(
					/* translators: 1: activity title, 2: date */
					__( 'La actividad «%1$s» está programada para hoy (%2$s).', 'logistica' ),
					$label,
					$fecha
				);
		} else {
			$title = $is_compra ? __( 'Compra por vencer', 'logistica' ) : __( 'Actividad por vencer', 'logistica' );
			$body  = $is_compra
				? sprintf(
					/* translators: 1: product name, 2: required date */
					__( 'La compra «%1$s» tiene fecha requerida el %2$s.', 'logistica' ),
					$label,
					$fecha
				)
				: sprintf(
					/* translators: 1: activity title, 2: date */
					__( 'La actividad «%1$s» está programada para el %2$s.', 'logistica' ),
					$label,
					$fecha
				);
		}

		return array(
			'title'       => $title,
			'body'        => $body,
			'source_type' => $source_type,
			'source_id'   => $id,
			'dedupe_key'  => 'overdue_' . $source_type . '_' . $id,
			'due_bucket'  => $bucket,
		);
	}

	/**
	 * Severidad para reabrir aviso si sube la urgencia (por vencer → hoy → vencido).
	 *
	 * @param string $title Título del aviso.
	 * @return int
	 */
	private static function due_aviso_urgency_rank( $title ) {
		$title = (string) $title;
		if (
			__( 'Compra vencida', 'logistica' ) === $title
			|| __( 'Actividad vencida', 'logistica' ) === $title
		) {
			return 3;
		}
		if (
			__( 'Compra para hoy', 'logistica' ) === $title
			|| __( 'Actividad para hoy', 'logistica' ) === $title
		) {
			return 2;
		}
		if (
			__( 'Compra por vencer', 'logistica' ) === $title
			|| __( 'Actividad por vencer', 'logistica' ) === $title
		) {
			return 1;
		}

		return 0;
	}

	/**
	 * ¿Es un aviso automático de vencimiento / hoy / por vencer (compra/agenda)?
	 *
	 * @param array<string, mixed> $aviso Aviso.
	 * @return bool
	 */
	private static function is_overdue_auto_aviso( $aviso ) {
		$dedupe = (string) ( $aviso['dedupe_key'] ?? '' );
		if ( 0 === strpos( $dedupe, 'overdue_compra_' ) || 0 === strpos( $dedupe, 'overdue_agenda_' ) ) {
			return true;
		}

		$title = (string) ( $aviso['title'] ?? '' );
		return (
			__( 'Compra vencida', 'logistica' ) === $title
			|| __( 'Actividad vencida', 'logistica' ) === $title
			|| __( 'Compra para hoy', 'logistica' ) === $title
			|| __( 'Actividad para hoy', 'logistica' ) === $title
			|| __( 'Compra por vencer', 'logistica' ) === $title
			|| __( 'Actividad por vencer', 'logistica' ) === $title
		);
	}

	/**
	 * Prefiere el aviso más útil al colapsar duplicados (no leído > más reciente).
	 *
	 * @param array<string, mixed> $candidate Candidato.
	 * @param array<string, mixed> $current   Actualmente retenido.
	 * @return bool True si conviene conservar $candidate.
	 */
	private static function prefer_aviso_over( $candidate, $current ) {
		$cand_read = ! empty( $candidate['read'] );
		$cur_read  = ! empty( $current['read'] );
		if ( $cand_read !== $cur_read ) {
			return ! $cand_read;
		}

		$cand_rank = self::due_aviso_urgency_rank( (string) ( $candidate['title'] ?? '' ) );
		$cur_rank  = self::due_aviso_urgency_rank( (string) ( $current['title'] ?? '' ) );
		if ( $cand_rank !== $cur_rank ) {
			return $cand_rank > $cur_rank;
		}

		return strcmp( (string) ( $candidate['created_at'] ?? '' ), (string) ( $current['created_at'] ?? '' ) ) > 0;
	}

	/**
	 * Crea o actualiza un aviso de vencimiento para un origen concreto.
	 *
	 * @param list<array<string, mixed>> $avisos    Lista actual.
	 * @param array<string, int>         $by_source Índice source_type:source_id => índice.
	 * @param array<string, string>      $payload   title, body, source_*, dedupe_key.
	 * @param string                     $today     Y-m-d (panel).
	 * @return array{avisos: list<array<string, mixed>>, by_source: array<string, int>, changed: bool}
	 */
	private static function upsert_overdue_aviso( $avisos, $by_source, $payload, $today = '' ) {
		$source_type = sanitize_key( (string) ( $payload['source_type'] ?? '' ) );
		$source_id   = self::sanitize_id( $payload['source_id'] ?? '' );
		$source_key  = $source_type . ':' . $source_id;
		$title       = (string) ( $payload['title'] ?? '' );
		$body        = (string) ( $payload['body'] ?? '' );
		$dedupe_key  = (string) ( $payload['dedupe_key'] ?? ( 'overdue_' . $source_type . '_' . $source_id ) );
		$now         = self::current_datetime();
		$today       = $today ?: self::current_moment()['date'];
		$changed     = false;

		if ( isset( $by_source[ $source_key ] ) ) {
			$index = (int) $by_source[ $source_key ];
			$row   = is_array( $avisos[ $index ] ?? null ) ? $avisos[ $index ] : array();
			$content_changed = (
				(string) ( $row['title'] ?? '' ) !== $title
				|| (string) ( $row['body'] ?? '' ) !== $body
			);
			$key_changed = (string) ( $row['dedupe_key'] ?? '' ) !== $dedupe_key;

			$snooze_until = self::sanitize_date( $row['snooze_until'] ?? '' ) ?: '';
			$snoozed      = ( '' !== $snooze_until && $snooze_until > $today );
			$read         = ! empty( $row['read'] );

			if ( $snoozed ) {
				$read = true;
			} elseif ( '' !== $snooze_until && $snooze_until <= $today ) {
				// Venció el posponer: reabrir.
				$read         = false;
				$snooze_until = '';
				$changed      = true;
			} elseif (
				$content_changed
				&& self::due_aviso_urgency_rank( $title ) > self::due_aviso_urgency_rank( (string) ( $row['title'] ?? '' ) )
			) {
				// Subió la urgencia (por vencer → hoy → vencido): reabrir.
				$read = false;
			}

			if ( $content_changed || $key_changed || $changed || ( (bool) ! empty( $row['read'] ) !== (bool) $read ) || (string) ( $row['snooze_until'] ?? '' ) !== $snooze_until ) {
				$updated = array_merge(
					$row,
					array(
						'title'        => $title,
						'body'         => $body,
						'source_type'  => $source_type,
						'source_id'    => $source_id,
						'dedupe_key'   => $dedupe_key,
						'read'         => $read,
						'snooze_until' => $snooze_until,
					)
				);
				if ( $content_changed && ! $snoozed ) {
					$updated['created_at'] = $now;
				}
				$avisos[ $index ] = $updated;
				$changed          = true;
			}

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

		$avisos[] = array(
			'id'           => 'aviso_' . wp_generate_password( 8, false, false ),
			'title'        => $title,
			'body'         => $body,
			'created_at'   => $now,
			'read'         => false,
			'email_sent'   => false,
			'source_type'  => $source_type,
			'source_id'    => $source_id,
			'dedupe_key'   => $dedupe_key,
			'snooze_until' => '',
		);
		$by_source[ $source_key ] = count( $avisos ) - 1;

		return array(
			'avisos'    => $avisos,
			'by_source' => $by_source,
			'changed'   => true,
		);
	}

	/**
	 * Marca avisos como leídos.
	 *
	 * @param int        $user_id   User ID.
	 * @param list<string>|string $aviso_ids IDs o 'all'.
	 * @return array<string, mixed>|\WP_Error
	 */
	public static function mark_avisos_read( $user_id, $aviso_ids = 'all' ) {
		$user_id = absint( $user_id );
		if ( $user_id <= 0 ) {
			return new WP_Error( 'invalid_user', __( 'Usuario no válido.', 'logistica' ) );
		}

		$panel  = self::get_panel( $user_id );
		$avisos = is_array( $panel['avisos'] ?? null ) ? $panel['avisos'] : array();
		$mark_all = ( 'all' === $aviso_ids );
		$ids      = array();

		if ( ! $mark_all && is_array( $aviso_ids ) ) {
			foreach ( $aviso_ids as $id ) {
				$clean = self::sanitize_id( $id );
				if ( '' !== $clean ) {
					$ids[ $clean ] = true;
				}
			}
		}

		foreach ( $avisos as &$aviso ) {
			if ( $mark_all || isset( $ids[ (string) ( $aviso['id'] ?? '' ) ] ) ) {
				$aviso['read'] = true;
			}
		}
		unset( $aviso );

		$panel['avisos'] = self::sanitize_avisos( $avisos );
		update_user_meta( $user_id, self::META_KEY, $panel );

		return $panel;
	}

	/**
	 * Elimina avisos del panel (solo borrado explícito).
	 *
	 * @param int                 $user_id   User ID.
	 * @param list<string>|string $aviso_ids IDs, 'all' o 'read' (solo leídos).
	 * @return array<string, mixed>|\WP_Error
	 */
	public static function delete_avisos( $user_id, $aviso_ids ) {
		$user_id = absint( $user_id );
		if ( $user_id <= 0 ) {
			return new WP_Error( 'invalid_user', __( 'Usuario no válido.', 'logistica' ) );
		}

		$panel       = self::get_panel( $user_id );
		$avisos      = is_array( $panel['avisos'] ?? null ) ? $panel['avisos'] : array();
		$delete_all  = ( 'all' === $aviso_ids );
		$delete_read = ( 'read' === $aviso_ids );
		$ids         = array();

		if ( ! $delete_all && ! $delete_read && is_array( $aviso_ids ) ) {
			foreach ( $aviso_ids as $id ) {
				$clean = self::sanitize_id( $id );
				if ( '' !== $clean ) {
					$ids[ $clean ] = true;
				}
			}
		}

		if ( $delete_all ) {
			$avisos = array();
		} elseif ( $delete_read ) {
			$avisos = array_values(
				array_filter(
					$avisos,
					static function ( $aviso ) {
						return empty( $aviso['read'] );
					}
				)
			);
		} else {
			$avisos = array_values(
				array_filter(
					$avisos,
					static function ( $aviso ) use ( $ids ) {
						return ! isset( $ids[ (string) ( $aviso['id'] ?? '' ) ] );
					}
				)
			);
		}

		$panel['avisos'] = self::sanitize_avisos( $avisos );
		update_user_meta( $user_id, self::META_KEY, $panel );

		return $panel;
	}

	/** @var string Meta del último digest diario. */
	const META_DAILY_DIGEST = 'logistica_panel_daily_digest_date';

	/** @var int Hora Colombia desde la cual se envía el digest. */
	const DAILY_DIGEST_HOUR = 6;

	/**
	 * Envía el resumen matutino si corresponde (una vez por día por usuario).
	 *
	 * @param int  $user_id User ID.
	 * @param bool $force   Forzar envío.
	 * @return bool
	 */
	public static function maybe_send_daily_digest( $user_id, $force = false ) {
		$user_id = absint( $user_id );
		if ( $user_id <= 0 ) {
			return false;
		}

		$moment = self::current_moment();
		$today  = $moment['date'];
		$hour   = (int) wp_date( 'G', null, self::panel_timezone() );

		if ( ! $force && $hour < self::DAILY_DIGEST_HOUR ) {
			return false;
		}

		$last = (string) get_user_meta( $user_id, self::META_DAILY_DIGEST, true );
		if ( ! $force && $last === $today ) {
			return false;
		}

		$sent = self::send_daily_digest_email( $user_id );
		if ( $sent ) {
			update_user_meta( $user_id, self::META_DAILY_DIGEST, $today );
		}

		return $sent;
	}

	/**
	 * Correo diario de prioridades, pendientes y retrasos.
	 *
	 * @param int $user_id User ID.
	 * @return bool
	 */
	public static function send_daily_digest_email( $user_id ) {
		$user = get_userdata( $user_id );
		if ( ! $user || ! is_email( $user->user_email ) ) {
			return false;
		}

		$raw   = get_user_meta( $user_id, self::META_KEY, true );
		$panel = wp_parse_args( is_array( $raw ) ? $raw : array(), self::default_data() );
		$today = self::current_moment()['date'];
		$lines = array();

		$lines[] = sprintf(
			/* translators: %s: user display name */
			__( 'Hola %s,', 'logistica' ),
			$user->display_name ?: $user->user_login
		);
		$lines[] = '';
		$lines[] = sprintf(
			/* translators: %s: date */
			__( 'Resumen de prioridades — JB Planeación (%s)', 'logistica' ),
			$today
		);
		$lines[] = '';

		$agenda_overdue = array();
		$agenda_today   = array();
		$agenda_pending = array();
		foreach ( (array) ( $panel['agenda'] ?? array() ) as $item ) {
			if ( ! is_array( $item ) || self::is_agenda_status_complete( (string) ( $item['status'] ?? '' ) ) || ! empty( $item['done'] ) ) {
				continue;
			}
			$date  = (string) ( $item['date'] ?? '' );
			$title = (string) ( $item['title'] ?? __( 'Actividad', 'logistica' ) );
			$prio  = (string) ( $item['priority'] ?? 'media' );
			$st    = (string) ( $item['status'] ?? 'pendiente' );
			$line  = sprintf( '- [%s] %s (%s) — %s', strtoupper( $prio ), $title, $date, $st );
			if ( '' !== $date && $date < $today ) {
				$agenda_overdue[] = $line;
			} elseif ( $date === $today ) {
				$agenda_today[] = $line;
			} else {
				$agenda_pending[] = $line;
			}
		}

		$compra_overdue = array();
		$compra_today   = array();
		$compra_soon    = array();
		$compra_pending = array();
		$soon_until     = self::date_plus_days( $today, 2 );
		foreach ( (array) ( $panel['compras'] ?? array() ) as $item ) {
			if ( ! is_array( $item ) || 'recibido' === (string) ( $item['status'] ?? '' ) ) {
				continue;
			}
			$fecha = (string) ( $item['fecha_requerida'] ?? '' );
			$prod  = (string) ( $item['producto_ofrecido'] ?? $item['item'] ?? __( 'Compra', 'logistica' ) );
			$st    = (string) ( $item['status'] ?? 'pendiente' );
			$prio  = (string) ( $item['priority'] ?? 'media' );
			$line  = sprintf( '- [%s] %s (%s) — %s', strtoupper( $prio ), $prod, $fecha ?: 's/f', $st );
			$bucket = self::classify_due_bucket( $fecha, $today, $soon_until );
			if ( 'overdue' === $bucket ) {
				$compra_overdue[] = $line;
			} elseif ( 'today' === $bucket ) {
				$compra_today[] = $line;
			} elseif ( 'soon' === $bucket ) {
				$compra_soon[] = $line;
			} else {
				$compra_pending[] = $line;
			}
		}

		$sections = array(
			__( 'ACTIVIDADES CON RETRASO', 'logistica' )       => $agenda_overdue,
			__( 'ACTIVIDADES DE HOY', 'logistica' )            => $agenda_today,
			__( 'OTRAS ACTIVIDADES PENDIENTES', 'logistica' )  => array_slice( $agenda_pending, 0, 15 ),
			__( 'COMPRAS CON RETRASO', 'logistica' )           => $compra_overdue,
			__( 'COMPRAS DE HOY', 'logistica' )                => $compra_today,
			__( 'COMPRAS POR VENCER (48 H)', 'logistica' )     => $compra_soon,
			__( 'OTRAS COMPRAS PENDIENTES', 'logistica' )      => array_slice( $compra_pending, 0, 15 ),
		);

		$has_content = false;
		foreach ( $sections as $heading => $rows ) {
			if ( ! $rows ) {
				continue;
			}
			$has_content = true;
			$lines[]     = $heading;
			foreach ( $rows as $row ) {
				$lines[] = $row;
			}
			$lines[] = '';
		}

		if ( ! $has_content ) {
			$lines[] = __( 'No tienes actividades ni compras pendientes/retrasadas por ahora.', 'logistica' );
			$lines[] = '';
		}

		$lines[] = __( 'Ingresa a JB Planeación para dar prioridad y actualizar estados.', 'logistica' );

		$subject = sprintf(
			/* translators: %s: date */
			__( '[JB Planeación] Resumen matutino %s', 'logistica' ),
			$today
		);

		return (bool) wp_mail( $user->user_email, $subject, implode( "\n", $lines ) );
	}

	/**
	 * Digest para todos los usuarios con acceso al plugin.
	 *
	 * @param bool $force Forzar aunque ya se haya enviado hoy.
	 * @return array{sent:int,skipped:int}
	 */
	public static function send_daily_digest_to_all_users( $force = false ) {
		$sent    = 0;
		$skipped = 0;

		$users = get_users(
			array(
				'fields' => array( 'ID' ),
				'number' => 500,
			)
		);

		foreach ( $users as $user ) {
			$user_id = absint( is_object( $user ) ? ( $user->ID ?? 0 ) : $user );
			if ( $user_id <= 0 ) {
				continue;
			}
			if ( class_exists( 'Logistica_Roles', false ) && ! Logistica_Roles::user_can_access_plugin( $user_id ) ) {
				++$skipped;
				continue;
			}
			if ( self::maybe_send_daily_digest( $user_id, $force ) ) {
				++$sent;
			} else {
				++$skipped;
			}
		}

		return array(
			'sent'    => $sent,
			'skipped' => $skipped,
		);
	}

	/**
	 * Envía por correo un aviso del panel al usuario.
	 *
	 * @param int    $user_id  User ID.
	 * @param string $aviso_id Aviso ID.
	 * @return array<string, mixed>|\WP_Error
	 */
	public static function send_aviso_email( $user_id, $aviso_id ) {
		$user_id  = absint( $user_id );
		$aviso_id = self::sanitize_id( $aviso_id );

		if ( $user_id <= 0 || '' === $aviso_id ) {
			return new WP_Error( 'invalid_request', __( 'Solicitud no válida.', 'logistica' ) );
		}

		$user = get_userdata( $user_id );
		if ( ! $user || ! is_email( $user->user_email ) ) {
			return new WP_Error( 'no_email', __( 'El usuario no tiene un correo válido.', 'logistica' ) );
		}

		$panel  = self::get_panel( $user_id );
		$avisos = is_array( $panel['avisos'] ?? null ) ? $panel['avisos'] : array();
		$found  = null;
		$index  = -1;

		foreach ( $avisos as $i => $aviso ) {
			if ( (string) ( $aviso['id'] ?? '' ) === $aviso_id ) {
				$found = $aviso;
				$index = (int) $i;
				break;
			}
		}

		if ( ! $found || $index < 0 ) {
			return new WP_Error( 'not_found', __( 'Aviso no encontrado.', 'logistica' ) );
		}

		$subject = sprintf(
			/* translators: %s: aviso title */
			__( '[JB Planeación] %s', 'logistica' ),
			(string) ( $found['title'] ?? __( 'Aviso', 'logistica' ) )
		);
		$body = trim(
			(string) ( $found['title'] ?? '' ) . "\n\n" .
			(string) ( $found['body'] ?? '' ) . "\n\n" .
			__( 'Ingresa a JB Planeación para revisar el detalle.', 'logistica' )
		);

		$sent = wp_mail( $user->user_email, $subject, $body );
		if ( ! $sent ) {
			return new WP_Error( 'mail_failed', __( 'No se pudo enviar el correo.', 'logistica' ) );
		}

		$avisos[ $index ]['email_sent'] = true;
		$panel['avisos'] = self::sanitize_avisos( $avisos );
		update_user_meta( $user_id, self::META_KEY, $panel );

		return $panel;
	}

	/**
	 * @param mixed $value Priority.
	 * @return string
	 */
	private static function sanitize_priority( $value ) {
		$key = sanitize_key( (string) $value );

		return isset( self::get_priorities()[ $key ] ) ? $key : 'media';
	}

	/**
	 * @param mixed $value Item ID.
	 * @return string
	 */
	private static function sanitize_id( $value ) {
		$id = sanitize_key( (string) $value );
		if ( '' !== $id ) {
			return $id;
		}

		return 'item_' . wp_generate_password( 8, false, false );
	}

	/**
	 * @param mixed $value Date Y-m-d.
	 * @return string
	 */
	private static function sanitize_date( $value ) {
		$text = sanitize_text_field( (string) $value );
		if ( preg_match( '/^\d{4}-\d{2}-\d{2}$/', $text ) ) {
			return $text;
		}

		return '';
	}

	/**
	 * @param mixed $value Datetime.
	 * @return string
	 */
	private static function sanitize_datetime( $value ) {
		$text = sanitize_text_field( (string) $value );
		if ( '' === $text ) {
			return '';
		}

		$tz = self::panel_timezone();
		foreach ( array( 'Y-m-d H:i:s', 'Y-m-d H:i' ) as $format ) {
			$dt = DateTimeImmutable::createFromFormat( $format, $text, $tz );
			if ( $dt instanceof DateTimeImmutable ) {
				return $dt->format( 'Y-m-d H:i:s' );
			}
		}

		return '';
	}
}
