<?php
/**
 * Custom Database Tables Handler for Extra Product Options
 *
 * This class manages custom database tables for optimal performance
 * and scalability of addon data storage.
 *
 * @package Extra_Product_Options_For_WooCommerce
 * @since   4.4.0
 */

// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
// Table names in this file are constructed from $wpdb->prefix which is safe.

if ( ! defined( 'ABSPATH' ) ) {
	exit; // Exit if accessed directly.
}

/**
 * EPOFW Custom Tables Handler
 */
class EPOFW_Custom_Tables {

	/**
	 * Database version for schema updates
	 *
	 * @var string
	 */
	const DB_VERSION = '2.2.0'; // v2.2: Added sub_row_id column to epofw_rows table for sub-row support.

	/**
	 * Option name to store DB version
	 *
	 * @var string
	 */
	const DB_VERSION_OPTION = 'epofw_custom_tables_version';

	/**
	 * Initialize custom tables
	 *
	 * @since 4.4.0
	 * @return void
	 */
	public static function init() {
		// Check if tables need to be created or updated.
		$installed_version = get_option( self::DB_VERSION_OPTION );

		if ( empty( $installed_version ) || version_compare( $installed_version, self::DB_VERSION, '<' ) ) {
			// Create or update tables (dbDelta handles upgrades automatically).
			self::create_tables();
			update_option( self::DB_VERSION_OPTION, self::DB_VERSION );
		}

		// Hook into post deletion to clean up custom tables.
		add_action( 'before_delete_post', array( __CLASS__, 'on_addon_delete' ), 10, 2 );

		// Multisite support: Create tables when a new site is created.
		if ( is_multisite() ) {
			add_action( 'wpmu_new_blog', array( __CLASS__, 'create_tables_for_new_site' ), 10, 6 );
		}

		// Initialize background migration class early (needed for AJAX handlers).
		include_once EPOFW_PLUGIN_DIR . '/includes/class-epofw-bg-migration.php';
		if ( class_exists( 'EPOFW_Bg_Migration' ) ) {
			EPOFW_Bg_Migration::get_instance();

			// Start background migration if needed.
			self::maybe_start_background_migration();
		}
	}

	/**
	 * Create custom tables for a new site in multisite network
	 *
	 * @since 4.4.0
	 *
	 * @param int    $blog_id    Blog ID of the new site.
	 * @param int    $user_id    User ID of the site creator.
	 * @param string $domain     Domain of the new site.
	 * @param string $path       Path of the new site.
	 * @param int    $site_id    Site ID (network ID).
	 * @param array  $meta       Meta data for the new site.
	 *
	 * @return void
	 */
	public static function create_tables_for_new_site( $blog_id, $user_id, $domain, $path, $site_id, $meta ) {
		// Switch to the new blog context.
		switch_to_blog( $blog_id );

		// Create tables for this site (uses site-specific prefix automatically).
		self::create_tables();
		update_option( self::DB_VERSION_OPTION, self::DB_VERSION );

		// Restore original blog context.
		restore_current_blog();
	}

	/**
	 * Check and start background migration to custom tables if needed.
	 *
	 * @since 4.4.0
	 * @return void
	 */
	public static function maybe_start_background_migration() {
		// Skip if background migration class not available.
		if ( ! class_exists( 'EPOFW_Bg_Migration' ) ) {
			return;
		}

		// Skip if migration already complete.
		if ( get_option( 'epofw_custom_table_migration_complete' ) ) {
			return;
		}

		// Skip if migration is already running.
		if ( get_transient( 'epofw_custom_table_migration_running' ) ) {
			return;
		}

		// Get background migration instance.
		$bg_migration = EPOFW_Bg_Migration::get_instance();

		// Set transient to prevent duplicate runs.
		set_transient( 'epofw_custom_table_migration_running', true, HOUR_IN_SECONDS );

		// Reset migration count.
		update_option( 'epofw_custom_table_migration_count', 0 );

		$total_addons = 0;

		// Handle multisite: migrate all sites in the network.
		if ( is_multisite() ) {
			$sites = get_sites( array( 'number' => 0 ) ); // Get all sites.

			foreach ( $sites as $site ) {
				switch_to_blog( $site->blog_id );

				// Get all addons that need migration for this site.
				$addons_to_migrate = self::get_addons_to_migrate();

				if ( ! empty( $addons_to_migrate ) ) {
					foreach ( $addons_to_migrate as $addon_id ) {
						$bg_migration->push_to_queue(
							array(
								'blog_id'  => $site->blog_id,
								'addon_id' => $addon_id,
							)
						);
					}
					$total_addons += count( $addons_to_migrate );
				}

				restore_current_blog();
			}
		} else {
			// Single site: get addons from current site.
			$addons_to_migrate = self::get_addons_to_migrate();

			if ( ! empty( $addons_to_migrate ) ) {
				foreach ( $addons_to_migrate as $addon_id ) {
					$bg_migration->push_to_queue( $addon_id );
				}
				$total_addons = count( $addons_to_migrate );
			}
		}

		// If no addons to migrate, mark as complete.
		if ( 0 === (int) $total_addons ) {
			update_option( 'epofw_custom_table_migration_complete', true );
			delete_transient( 'epofw_custom_table_migration_running' );
			return;
		}

		// Save and dispatch.
		$bg_migration->save()->dispatch();

		// Store total count for progress tracking.
		update_option( 'epofw_custom_table_migration_total', $total_addons );
	}

	/**
	 * Get list of addon IDs that need migration (for current blog context).
	 *
	 * @since 4.4.0
	 * @return array Array of addon IDs.
	 */
	private static function get_addons_to_migrate() {
		$addons_to_migrate = array();

		$addon_args = array(
			'post_type'      => EPOFW_DFT_POST_TYPE,
			'post_status'    => array( 'draft', 'publish' ),
			'posts_per_page' => -1,
			'fields'         => 'ids',
		);

		$addon_query = new WP_Query( $addon_args );

		if ( $addon_query->have_posts() ) {
			foreach ( $addon_query->posts as $addon_id ) {
				// Check if not already migrated.
				if ( ! get_post_meta( $addon_id, 'epofw_migrated_to_custom_tables', true ) ) {
					// Check if has post meta data to migrate.
					$has_post_meta = get_post_meta( $addon_id, 'epofw_prd_opt_data', true );
					if ( ! empty( $has_post_meta ) ) {
						$addons_to_migrate[] = $addon_id;
					} else {
						// No post meta data, check if already in custom tables.
						$custom_table_data = self::load_addon( $addon_id );
						if ( ! empty( $custom_table_data ) ) {
							// Already in custom tables, mark as migrated.
							update_post_meta( $addon_id, 'epofw_migrated_to_custom_tables', true );
						}
					}
				}
			}
		}

		return $addons_to_migrate;
	}

	/**
	 * Handle addon deletion
	 *
	 * @since 4.4.0
	 *
	 * @param int     $post_id Post ID being deleted.
	 * @param WP_Post $post    Post object being deleted.
	 *
	 * @return void
	 */
	public static function on_addon_delete( $post_id, $post ) {
		// Only handle our custom post type.
		if ( EPOFW_DFT_POST_TYPE !== $post->post_type ) {
			return;
		}

		// Delete from custom tables.
		self::delete_addon( $post_id );
	}

	/**
	 * Create custom database tables
	 *
	 * @since 4.4.0
	 *
	 * @return void
	 */
	public static function create_tables() {
		global $wpdb;

		$charset_collate = $wpdb->get_charset_collate();
		$table_prefix    = $wpdb->prefix;

		// Table 1: Addons (Main addon configuration).
		$sql_addons = "CREATE TABLE IF NOT EXISTS `{$table_prefix}epofw_addons` (
			id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
			post_id BIGINT(20) UNSIGNED NOT NULL,
			addon_name VARCHAR(255) DEFAULT '',
			addon_status VARCHAR(20) DEFAULT 'on',
			addon_position VARCHAR(50) DEFAULT 'before_add_to_cart',
			additional_rules LONGTEXT,
			created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
			updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
			PRIMARY KEY (id),
			UNIQUE KEY post_id (post_id),
			KEY addon_status (addon_status),
			KEY created_at (created_at)
		) $charset_collate;";

		// Table 2: Sections (Section information).
		// Note: Removed foreign key constraints for better WordPress/Multisite compatibility.
		$sql_sections = "CREATE TABLE IF NOT EXISTS `{$table_prefix}epofw_sections` (
			id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
			addon_id BIGINT(20) UNSIGNED NOT NULL,
			section_id VARCHAR(100) NOT NULL,
			section_title VARCHAR(255) DEFAULT '',
			section_order INT(11) DEFAULT 0,
			section_status VARCHAR(20) DEFAULT 'on',
			section_settings LONGTEXT,
			created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
			updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
			PRIMARY KEY (id),
			KEY addon_id (addon_id),
			KEY section_order (section_order),
			KEY section_status (section_status)
		) $charset_collate;";

		// Table 3: Row Groups (Layout groups within sections).
		// Note: Removed foreign key constraints for better WordPress/Multisite compatibility.
		// v2.3: Removed layout_type and columns_count (moved to columns_groups table).
		$sql_row_groups = "CREATE TABLE IF NOT EXISTS `{$table_prefix}epofw_row_groups` (
			id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
			section_id BIGINT(20) UNSIGNED NOT NULL,
			group_id VARCHAR(100) NOT NULL,
			group_order INT(11) DEFAULT 0,
			group_settings LONGTEXT,
			created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
			updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
			PRIMARY KEY (id),
			KEY section_id (section_id),
			KEY group_order (group_order)
		) $charset_collate;";

		// Table 4: Columns Groups (Groups of columns within a row group).
		// This creates a clear hierarchy: Row Group -> Columns Group -> Columns.
		// v2.3: New table for better structure and performance.
		// v2.3: Added layout_type (moved from row_groups) - each columns group can have its own layout.
		$sql_columns_groups = "CREATE TABLE IF NOT EXISTS `{$table_prefix}epofw_columns_groups` (
			id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
			row_group_id BIGINT(20) UNSIGNED NOT NULL,
			columns_id VARCHAR(100) NOT NULL,
			columns_order INT(11) DEFAULT 0,
			layout_type VARCHAR(20) DEFAULT 'single',
			columns_count INT(11) DEFAULT 1,
			created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
			updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
			PRIMARY KEY (id),
			KEY row_group_id (row_group_id),
			KEY columns_id (columns_id),
			KEY columns_order (columns_order),
			KEY layout_type (layout_type),
			UNIQUE KEY unique_columns_group (row_group_id, columns_id)
		) $charset_collate;";

		// Table 5: Rows/Columns (Individual row/column data with settings).
		// Note: 'row_number' is a reserved word in MariaDB/MySQL, so we use 'row_num' instead.
		// Note: Removed foreign key constraints for better WordPress/Multisite compatibility.
		// v2.2: Added sub_row_id for sub-row support (rows within rows).
		// v2.3: Added columns_group_id for new structure (keeping sub_row_id for backward compatibility).
		$sql_rows = "CREATE TABLE IF NOT EXISTS `{$table_prefix}epofw_rows` (
			id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
			row_group_id BIGINT(20) UNSIGNED NOT NULL,
			columns_group_id BIGINT(20) UNSIGNED DEFAULT NULL,
			row_id VARCHAR(100) NOT NULL,
			sub_row_id VARCHAR(100) DEFAULT NULL,
			row_num INT(11) DEFAULT 1,
			sub_row_order INT(11) DEFAULT 0,
			column_settings LONGTEXT,
			created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
			updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
			PRIMARY KEY (id),
			KEY row_group_id (row_group_id),
			KEY columns_group_id (columns_group_id),
			KEY row_num (row_num),
			KEY sub_row_id (sub_row_id),
			KEY sub_row_order (sub_row_order),
			UNIQUE KEY unique_row (columns_group_id, row_id)
		) $charset_collate;";

		// Table 6: Fields (Individual field data - now belongs to rows).
		// Note: Removed foreign key constraints for better WordPress/Multisite compatibility.
		$sql_fields = "CREATE TABLE IF NOT EXISTS `{$table_prefix}epofw_fields` (
			id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
			row_id BIGINT(20) UNSIGNED NOT NULL,
			field_id VARCHAR(100) NOT NULL,
			field_type VARCHAR(50) DEFAULT 'text',
			field_label VARCHAR(255) DEFAULT '',
			field_order INT(11) DEFAULT 0,
			field_status VARCHAR(20) DEFAULT 'on',
			field_data LONGTEXT,
			created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
			updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
			PRIMARY KEY (id),
			KEY row_id (row_id),
			KEY field_type (field_type),
			KEY field_order (field_order),
			KEY field_status (field_status),
			UNIQUE KEY unique_field (row_id, field_id)
		) $charset_collate;";

		require_once ABSPATH . 'wp-admin/includes/upgrade.php';

		// Create tables in order (parent tables first).
		dbDelta( $sql_addons );
		dbDelta( $sql_sections );
		dbDelta( $sql_row_groups );
		dbDelta( $sql_columns_groups );
		dbDelta( $sql_rows );
		dbDelta( $sql_fields );

		// v2.3: Add columns_group_id column to epofw_rows if it doesn't exist (for existing installations).
		self::maybe_add_columns_group_id_column();

		// v2.3: Add layout_type to epofw_columns_groups and remove from epofw_row_groups (schema migration).
		self::maybe_add_layout_type_to_columns_groups();
		self::maybe_remove_layout_type_from_row_groups();

		// v2.3: Migrate existing sub_row_id data to columns_groups table.
		self::maybe_migrate_sub_row_to_columns_groups();

		// Verify all tables exist, create missing ones directly if needed.
		$required_tables = array(
			'epofw_addons'         => $sql_addons,
			'epofw_sections'       => $sql_sections,
			'epofw_row_groups'     => $sql_row_groups,
			'epofw_columns_groups' => $sql_columns_groups,
			'epofw_rows'           => $sql_rows,
			'epofw_fields'         => $sql_fields,
		);

		foreach ( $required_tables as $table_name => $sql ) {
			$full_table_name = $table_prefix . $table_name;
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema check, one-time table creation.
			$table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $full_table_name ) );

			if ( $full_table_name !== $table_exists ) {
				// Table doesn't exist, try creating it directly.
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- DDL statement for table creation.
				$wpdb->query( $sql );

				// Verify creation.
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema verification.
				$table_exists_after = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $full_table_name ) );
			}
		}
	}

	/**
	 * Add columns_group_id column to epofw_rows table if it doesn't exist
	 * For backward compatibility with existing installations.
	 *
	 * @since 4.4.0 (v2.3)
	 * @return void
	 */
	private static function maybe_add_columns_group_id_column() {
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange -- Schema migration function.
		global $wpdb;

		$table_name = $wpdb->prefix . 'epofw_rows';

		$column_exists = $wpdb->get_results(
			$wpdb->prepare(
				"SHOW COLUMNS FROM `{$wpdb->prefix}epofw_rows` LIKE %s",
				'columns_group_id'
			)
		);

		if ( empty( $column_exists ) ) {
			// Add columns_group_id column after row_group_id.
			$wpdb->query(
				"ALTER TABLE `{$wpdb->prefix}epofw_rows`
				ADD COLUMN columns_group_id BIGINT(20) UNSIGNED DEFAULT NULL AFTER row_group_id,
				ADD KEY columns_group_id (columns_group_id)"
			);

			// Update unique constraint to use columns_group_id.
			// First, drop old unique constraint if it exists.
			$wpdb->query( "ALTER TABLE `{$wpdb->prefix}epofw_rows` DROP INDEX IF EXISTS unique_row" );

			// Add new unique constraint.
			$wpdb->query(
				"ALTER TABLE `{$wpdb->prefix}epofw_rows`
				ADD UNIQUE KEY unique_row (columns_group_id, row_id)"
			);
		}
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
	}

	/**
	 * Add layout_type column to epofw_columns_groups table if it doesn't exist
	 * For existing installations upgrading to v2.3.
	 *
	 * @since 4.4.0 (v2.3)
	 * @return void
	 */
	private static function maybe_add_layout_type_to_columns_groups() {
		// Static + persistent check to prevent any DB queries after migration is done.
		static $migration_done = null;

		if ( null === $migration_done ) {
			$migration_done = (bool) get_option( 'epofw_layout_type_migration_done' );
		}

		if ( $migration_done ) {
			return;
		}

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange -- Schema migration function.
		global $wpdb;

		$table_name = $wpdb->prefix . 'epofw_columns_groups';

		$column_exists = $wpdb->get_results(
			$wpdb->prepare(
				"SHOW COLUMNS FROM `{$wpdb->prefix}epofw_columns_groups` LIKE %s",
				'layout_type'
			)
		);

		if ( empty( $column_exists ) ) {
			// Add layout_type column after columns_order.
			$wpdb->query(
				"ALTER TABLE `{$wpdb->prefix}epofw_columns_groups`
				ADD COLUMN layout_type VARCHAR(20) DEFAULT 'single' AFTER columns_order,
				ADD KEY layout_type (layout_type)"
			);

			// Update existing rows with default layout_type based on columns_count.
			$wpdb->query(
				"UPDATE `{$wpdb->prefix}epofw_columns_groups`
				SET layout_type = CASE
					WHEN columns_count = 1 THEN 'single'
					WHEN columns_count = 2 THEN 'two'
					WHEN columns_count = 3 THEN 'three'
					WHEN columns_count = 4 THEN 'four'
					ELSE 'single'
				END
				WHERE layout_type = 'single' OR layout_type IS NULL"
			);
		}

		// Mark migration as complete - never run again.
		update_option( 'epofw_layout_type_migration_done', true, false );
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
	}

	/**
	 * Remove layout_type and columns_count columns from epofw_row_groups table
	 * For existing installations upgrading to v2.3.
	 *
	 * @since 4.4.0 (v2.3)
	 * @return void
	 */
	private static function maybe_remove_layout_type_from_row_groups() {
		// Static + persistent check to prevent any DB queries after migration is done.
		static $migration_done = null;

		if ( null === $migration_done ) {
			$migration_done = (bool) get_option( 'epofw_row_groups_cleanup_done' );
		}

		if ( $migration_done ) {
			return;
		}

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange -- Schema migration function.
		global $wpdb;

		$table_name = $wpdb->prefix . 'epofw_row_groups';

		// Check if layout_type column exists.
		$layout_type_exists = $wpdb->get_results(
			$wpdb->prepare(
				"SHOW COLUMNS FROM `{$wpdb->prefix}epofw_row_groups` LIKE %s",
				'layout_type'
			)
		);

		if ( ! empty( $layout_type_exists ) ) {
			// Remove layout_type column and its index.
			$wpdb->query( "ALTER TABLE `{$wpdb->prefix}epofw_row_groups` DROP INDEX IF EXISTS layout_type" );
			$wpdb->query( "ALTER TABLE `{$wpdb->prefix}epofw_row_groups` DROP COLUMN IF EXISTS layout_type" );
		}

		// Check if columns_count column exists.
		$columns_count_exists = $wpdb->get_results(
			$wpdb->prepare(
				"SHOW COLUMNS FROM `{$wpdb->prefix}epofw_row_groups` LIKE %s",
				'columns_count'
			)
		);

		if ( ! empty( $columns_count_exists ) ) {
			// Remove columns_count column.
			$wpdb->query( "ALTER TABLE `{$wpdb->prefix}epofw_row_groups` DROP COLUMN IF EXISTS columns_count" );
		}

		// Mark migration as complete - never run again.
		update_option( 'epofw_row_groups_cleanup_done', true, false );
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
	}

	/**
	 * Migrate existing rows with sub_row_id to new columns_groups structure
	 *
	 * @since 4.4.0 (v2.3)
	 * @return void
	 */
	private static function maybe_migrate_sub_row_to_columns_groups() {
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Migration function with multiple custom table operations.
		global $wpdb;

		// Check if migration already completed.
		if ( get_option( 'epofw_columns_groups_migration_complete' ) ) {
			return;
		}

		$rows_table           = $wpdb->prefix . 'epofw_rows';
		$columns_groups_table = $wpdb->prefix . 'epofw_columns_groups';

		// Get all rows that have sub_row_id but no columns_group_id.
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Migration query, table names from $wpdb->prefix.
		$rows_to_migrate = $wpdb->get_results(
			"SELECT * FROM {$rows_table} 
			WHERE sub_row_id IS NOT NULL 
			AND sub_row_id != '' 
			AND (columns_group_id IS NULL OR columns_group_id = 0)
			ORDER BY row_group_id, sub_row_id, sub_row_order"
		);

		if ( empty( $rows_to_migrate ) ) {
			// Mark migration as complete if nothing to migrate.
			update_option( 'epofw_columns_groups_migration_complete', true );
			return;
		}

		// Group rows by row_group_id and sub_row_id.
		$groups_map = array();
		foreach ( $rows_to_migrate as $row ) {
			$key = $row->row_group_id . '_' . $row->sub_row_id;
			if ( ! isset( $groups_map[ $key ] ) ) {
				$groups_map[ $key ] = array(
					'row_group_id'  => $row->row_group_id,
					'sub_row_id'    => $row->sub_row_id,
					'sub_row_order' => $row->sub_row_order,
					'rows'          => array(),
				);
			}
			$groups_map[ $key ]['rows'][] = $row;
		}

		// Create columns groups and update rows.
		foreach ( $groups_map as $key => $group_data ) {
			$row_group_id  = $group_data['row_group_id'];
			$columns_id    = $group_data['sub_row_id'];
			$columns_order = absint( $group_data['sub_row_order'] );
			$columns_count = count( $group_data['rows'] );

			// Check if columns group already exists.
			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Migration query, table name from $wpdb->prefix.
			$existing_group = $wpdb->get_var(
				$wpdb->prepare(
					"SELECT id FROM {$columns_groups_table} WHERE row_group_id = %d AND columns_id = %s",
					$row_group_id,
					$columns_id
				)
			);

			if ( ! $existing_group ) {
				// Determine layout_type from columns_count for migrated data.
				$layout_type = ( 1 === (int) $columns_count ) ? 'single' : ( ( 2 === (int) $columns_count ) ? 'two' : ( ( 3 === (int) $columns_count ) ? 'three' : ( ( 4 === (int) $columns_count ) ? 'four' : 'single' ) ) );

				// Create new columns group.
				$wpdb->insert(
					$columns_groups_table,
					array(
						'row_group_id'  => $row_group_id,
						'columns_id'    => $columns_id,
						'columns_order' => $columns_order,
						'layout_type'   => $layout_type,
						'columns_count' => $columns_count,
					),
					array( '%d', '%s', '%d', '%s', '%d' )
				);
				$columns_group_id = $wpdb->insert_id;
			} else {
				$columns_group_id = $existing_group;
			}

			// Update all rows in this group to use the new columns_group_id.
			foreach ( $group_data['rows'] as $row ) {
				$wpdb->update(
					$rows_table,
					array( 'columns_group_id' => $columns_group_id ),
					array( 'id' => $row->id ),
					array( '%d' ),
					array( '%d' )
				);
			}
		}

		// Mark migration as complete.
		update_option( 'epofw_columns_groups_migration_complete', true );
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
	}

	/**
	 * Save complete addon data to custom tables
	 *
	 * @since 4.4.0
	 *
	 * @param int   $post_id     Post ID.
	 * @param array $addon_data  Complete addon data array.
	 *
	 * @return bool True on success, false on failure.
	 *
	 * @throws Exception If addon data is not an array or if saving fails.
	 */
	public static function save_addon( $post_id, $addon_data ) {
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Custom table save with transactions.
		global $wpdb;

		try {
			// Validate addon data structure.
			if ( ! is_array( $addon_data ) ) {
				throw new Exception( 'Addon data is not an array' );
			}

			// Start transaction.
			$wpdb->query( 'START TRANSACTION' );

			// 1. Save/Update addon.
			$addon_id = self::save_addon_meta( $post_id, $addon_data );
			if ( ! $addon_id ) {
				throw new Exception( 'Failed to save addon meta' );
			}

			// 2. Collect IDs from new data for smart cleanup (only delete removed items).
			$new_section_ids = array();
			$new_group_ids   = array();
			$new_columns_ids = array();
			$new_row_ids     = array();
			$new_field_ids   = array();

			if ( ! empty( $addon_data['general'] ) && is_array( $addon_data['general'] ) ) {
				foreach ( $addon_data['general'] as $section_id => $section_data ) {
					if ( ! is_array( $section_data ) ) {
						continue;
					}
					// Use section_id from inside section_data if available, otherwise use array key.
					// This ensures we use the correct section_id regardless of format (old: timestamp, new: post_id_timestamp).
					$actual_section_id = isset( $section_data['section_id'] ) ? sanitize_text_field( $section_data['section_id'] ) : sanitize_text_field( $section_id );
					$new_section_ids[] = $actual_section_id;

					if ( ! empty( $section_data['row_groups'] ) ) {
						foreach ( $section_data['row_groups'] as $group_id => $group_data ) {
							if ( ! is_array( $group_data ) ) {
								continue;
							}
							$new_group_ids[] = sanitize_text_field( $group_id );

							// Collect from columns_groups structure.
							if ( ! empty( $group_data['columns_groups'] ) ) {
								foreach ( $group_data['columns_groups'] as $columns_id => $columns_group_data ) {
									$new_columns_ids[] = sanitize_text_field( $columns_id );

									if ( ! empty( $columns_group_data['rows'] ) ) {
										foreach ( $columns_group_data['rows'] as $row_id => $row_data ) {
											$new_row_ids[] = sanitize_text_field( $row_id );

											if ( ! empty( $row_data['fields'] ) ) {
												foreach ( $row_data['fields'] as $field_id => $field_data ) {
													$new_field_ids[] = sanitize_text_field( $field_id );
												}
											}
										}
									}
								}
							}

							// Collect from legacy rows structure.
							if ( ! empty( $group_data['rows'] ) ) {
								foreach ( $group_data['rows'] as $row_id => $row_data ) {
									$new_row_ids[] = sanitize_text_field( $row_id );
									if ( isset( $row_data['sub_row_id'] ) && ! empty( $row_data['sub_row_id'] ) ) {
										$new_columns_ids[] = sanitize_text_field( $row_data['sub_row_id'] );
									}

									if ( ! empty( $row_data['fields'] ) ) {
										foreach ( $row_data['fields'] as $field_id => $field_data ) {
											$new_field_ids[] = sanitize_text_field( $field_id );
										}
									}
								}
							}
						}
					}
				}
			}

			// Remove duplicates.
			$new_section_ids = array_unique( $new_section_ids );
			$new_group_ids   = array_unique( $new_group_ids );
			$new_columns_ids = array_unique( $new_columns_ids );
			$new_row_ids     = array_unique( $new_row_ids );
			$new_field_ids   = array_unique( $new_field_ids );

			// 3. Delete only items that are NOT in the new data.
			// Get existing DB section IDs for this addon.
			$db_section_ids = $wpdb->get_col(
				$wpdb->prepare(
					"SELECT id FROM {$wpdb->prefix}epofw_sections WHERE addon_id = %d",
					$addon_id
				)
			);

			if ( ! empty( $db_section_ids ) ) {
				// Get existing section_id values (not DB ids).
				$existing_sections = $wpdb->get_results(
					$wpdb->prepare(
						"SELECT id, section_id FROM {$wpdb->prefix}epofw_sections WHERE addon_id = %d",
						$addon_id
					),
					OBJECT_K
				);

				// Find sections to delete (exist in DB but not in new data).
				$sections_to_delete = array();
				foreach ( $existing_sections as $db_id => $section ) {
					if ( ! in_array( $section->section_id, $new_section_ids, true ) ) {
						$sections_to_delete[] = $db_id;
					}
				}

				if ( ! empty( $sections_to_delete ) ) {
					// Cascade delete for removed sections.
					$section_id_list = implode( ',', array_map( 'intval', $sections_to_delete ) );

					// Get row_group IDs for deleted sections.
					$groups_to_delete = $wpdb->get_col(
						"SELECT id FROM {$wpdb->prefix}epofw_row_groups WHERE section_id IN ({$section_id_list})"
					);

					if ( ! empty( $groups_to_delete ) ) {
						$group_id_list = implode( ',', array_map( 'intval', $groups_to_delete ) );

						// Get columns_group IDs.
						$columns_to_delete = $wpdb->get_col(
							"SELECT id FROM {$wpdb->prefix}epofw_columns_groups WHERE row_group_id IN ({$group_id_list})"
						);

						if ( ! empty( $columns_to_delete ) ) {
							$columns_id_list = implode( ',', array_map( 'intval', $columns_to_delete ) );

							// Get row IDs.
							$rows_to_delete = $wpdb->get_col(
								"SELECT id FROM {$wpdb->prefix}epofw_rows WHERE columns_group_id IN ({$columns_id_list})"
							);

							if ( ! empty( $rows_to_delete ) ) {
								$row_id_list = implode( ',', array_map( 'intval', $rows_to_delete ) );
								// Delete fields.
								$wpdb->query( "DELETE FROM {$wpdb->prefix}epofw_fields WHERE row_id IN ({$row_id_list})" );
							}

							// Delete rows.
							$wpdb->query( "DELETE FROM {$wpdb->prefix}epofw_rows WHERE columns_group_id IN ({$columns_id_list})" );
						}

						// Delete columns groups.
						$wpdb->query( "DELETE FROM {$wpdb->prefix}epofw_columns_groups WHERE row_group_id IN ({$group_id_list})" );

						// Delete row groups.
						$wpdb->query( "DELETE FROM {$wpdb->prefix}epofw_row_groups WHERE section_id IN ({$section_id_list})" );
					}

					// Delete sections.
					$wpdb->query( "DELETE FROM {$wpdb->prefix}epofw_sections WHERE id IN ({$section_id_list})" );
				}

				// Also clean up orphaned items within remaining sections.
				// Delete fields that no longer exist in new data.
				if ( ! empty( $new_field_ids ) ) {
					$field_placeholders = implode( ',', array_fill( 0, count( $new_field_ids ), '%s' ) );
					$wpdb->query(
						$wpdb->prepare(
							"DELETE f FROM {$wpdb->prefix}epofw_fields f
							INNER JOIN {$wpdb->prefix}epofw_rows r ON f.row_id = r.id
							INNER JOIN {$wpdb->prefix}epofw_columns_groups cg ON r.columns_group_id = cg.id
							INNER JOIN {$wpdb->prefix}epofw_row_groups rg ON cg.row_group_id = rg.id
							INNER JOIN {$wpdb->prefix}epofw_sections s ON rg.section_id = s.id
							WHERE s.addon_id = %d AND f.field_id NOT IN ({$field_placeholders})",
							array_merge( array( $addon_id ), $new_field_ids )
						)
					);
				} else {
					// If no fields in new data, delete ALL fields for this addon.
					$wpdb->query(
						$wpdb->prepare(
							"DELETE f FROM {$wpdb->prefix}epofw_fields f
							INNER JOIN {$wpdb->prefix}epofw_rows r ON f.row_id = r.id
							INNER JOIN {$wpdb->prefix}epofw_columns_groups cg ON r.columns_group_id = cg.id
							INNER JOIN {$wpdb->prefix}epofw_row_groups rg ON cg.row_group_id = rg.id
							INNER JOIN {$wpdb->prefix}epofw_sections s ON rg.section_id = s.id
							WHERE s.addon_id = %d",
							$addon_id
						)
					);
				}

				// Delete rows that no longer exist in new data.
				if ( ! empty( $new_row_ids ) ) {
					$row_placeholders = implode( ',', array_fill( 0, count( $new_row_ids ), '%s' ) );
					// First delete fields for rows being removed.
					$wpdb->query(
						$wpdb->prepare(
							"DELETE f FROM {$wpdb->prefix}epofw_fields f
							INNER JOIN {$wpdb->prefix}epofw_rows r ON f.row_id = r.id
							INNER JOIN {$wpdb->prefix}epofw_columns_groups cg ON r.columns_group_id = cg.id
							INNER JOIN {$wpdb->prefix}epofw_row_groups rg ON cg.row_group_id = rg.id
							INNER JOIN {$wpdb->prefix}epofw_sections s ON rg.section_id = s.id
							WHERE s.addon_id = %d AND r.row_id NOT IN ({$row_placeholders})",
							array_merge( array( $addon_id ), $new_row_ids )
						)
					);
					// Then delete the rows.
					$wpdb->query(
						$wpdb->prepare(
							"DELETE r FROM {$wpdb->prefix}epofw_rows r
							INNER JOIN {$wpdb->prefix}epofw_columns_groups cg ON r.columns_group_id = cg.id
							INNER JOIN {$wpdb->prefix}epofw_row_groups rg ON cg.row_group_id = rg.id
							INNER JOIN {$wpdb->prefix}epofw_sections s ON rg.section_id = s.id
							WHERE s.addon_id = %d AND r.row_id NOT IN ({$row_placeholders})",
							array_merge( array( $addon_id ), $new_row_ids )
						)
					);
				} elseif ( ! empty( $new_columns_ids ) ) {
					// If no rows but have columns_groups, delete all rows and their fields for this addon.
					// First delete fields.
					$wpdb->query(
						$wpdb->prepare(
							"DELETE f FROM {$wpdb->prefix}epofw_fields f
							INNER JOIN {$wpdb->prefix}epofw_rows r ON f.row_id = r.id
							INNER JOIN {$wpdb->prefix}epofw_columns_groups cg ON r.columns_group_id = cg.id
							INNER JOIN {$wpdb->prefix}epofw_row_groups rg ON cg.row_group_id = rg.id
							INNER JOIN {$wpdb->prefix}epofw_sections s ON rg.section_id = s.id
							WHERE s.addon_id = %d",
							$addon_id
						)
					);
					// Then delete rows.
					$wpdb->query(
						$wpdb->prepare(
							"DELETE r FROM {$wpdb->prefix}epofw_rows r
							INNER JOIN {$wpdb->prefix}epofw_columns_groups cg ON r.columns_group_id = cg.id
							INNER JOIN {$wpdb->prefix}epofw_row_groups rg ON cg.row_group_id = rg.id
							INNER JOIN {$wpdb->prefix}epofw_sections s ON rg.section_id = s.id
							WHERE s.addon_id = %d",
							$addon_id
						)
					);
				}

				// Delete columns groups that no longer exist in new data.
				if ( ! empty( $new_columns_ids ) ) {
					$columns_placeholders = implode( ',', array_fill( 0, count( $new_columns_ids ), '%s' ) );
					// First delete rows and fields for columns groups being removed.
					$wpdb->query(
						$wpdb->prepare(
							"DELETE f FROM {$wpdb->prefix}epofw_fields f
							INNER JOIN {$wpdb->prefix}epofw_rows r ON f.row_id = r.id
							INNER JOIN {$wpdb->prefix}epofw_columns_groups cg ON r.columns_group_id = cg.id
							INNER JOIN {$wpdb->prefix}epofw_row_groups rg ON cg.row_group_id = rg.id
							INNER JOIN {$wpdb->prefix}epofw_sections s ON rg.section_id = s.id
							WHERE s.addon_id = %d AND cg.columns_id NOT IN ({$columns_placeholders})",
							array_merge( array( $addon_id ), $new_columns_ids )
						)
					);
					$wpdb->query(
						$wpdb->prepare(
							"DELETE r FROM {$wpdb->prefix}epofw_rows r
							INNER JOIN {$wpdb->prefix}epofw_columns_groups cg ON r.columns_group_id = cg.id
							INNER JOIN {$wpdb->prefix}epofw_row_groups rg ON cg.row_group_id = rg.id
							INNER JOIN {$wpdb->prefix}epofw_sections s ON rg.section_id = s.id
							WHERE s.addon_id = %d AND cg.columns_id NOT IN ({$columns_placeholders})",
							array_merge( array( $addon_id ), $new_columns_ids )
						)
					);
					// Then delete the columns groups.
					$wpdb->query(
						$wpdb->prepare(
							"DELETE cg FROM {$wpdb->prefix}epofw_columns_groups cg
							INNER JOIN {$wpdb->prefix}epofw_row_groups rg ON cg.row_group_id = rg.id
							INNER JOIN {$wpdb->prefix}epofw_sections s ON rg.section_id = s.id
							WHERE s.addon_id = %d AND cg.columns_id NOT IN ({$columns_placeholders})",
							array_merge( array( $addon_id ), $new_columns_ids )
						)
					);
				} elseif ( ! empty( $new_group_ids ) ) {
					// If no columns_groups but have groups, delete all columns_groups for this addon.
					// IMPORTANT: First delete associated rows and fields before deleting columns_groups.
					// Delete fields first.
					$wpdb->query(
						$wpdb->prepare(
							"DELETE f FROM {$wpdb->prefix}epofw_fields f
							INNER JOIN {$wpdb->prefix}epofw_rows r ON f.row_id = r.id
							INNER JOIN {$wpdb->prefix}epofw_columns_groups cg ON r.columns_group_id = cg.id
							INNER JOIN {$wpdb->prefix}epofw_row_groups rg ON cg.row_group_id = rg.id
							INNER JOIN {$wpdb->prefix}epofw_sections s ON rg.section_id = s.id
							WHERE s.addon_id = %d",
							$addon_id
						)
					);
					// Delete rows.
					$wpdb->query(
						$wpdb->prepare(
							"DELETE r FROM {$wpdb->prefix}epofw_rows r
							INNER JOIN {$wpdb->prefix}epofw_columns_groups cg ON r.columns_group_id = cg.id
							INNER JOIN {$wpdb->prefix}epofw_row_groups rg ON cg.row_group_id = rg.id
							INNER JOIN {$wpdb->prefix}epofw_sections s ON rg.section_id = s.id
							WHERE s.addon_id = %d",
							$addon_id
						)
					);
					// Now delete columns_groups.
					$wpdb->query(
						$wpdb->prepare(
							"DELETE cg FROM {$wpdb->prefix}epofw_columns_groups cg
							INNER JOIN {$wpdb->prefix}epofw_row_groups rg ON cg.row_group_id = rg.id
							INNER JOIN {$wpdb->prefix}epofw_sections s ON rg.section_id = s.id
							WHERE s.addon_id = %d",
							$addon_id
						)
					);
				}

				// Delete row groups that no longer exist in new data.
				if ( ! empty( $new_group_ids ) ) {
					$group_placeholders = implode( ',', array_fill( 0, count( $new_group_ids ), '%s' ) );
					// Cascade delete for removed row groups.
					$orphaned_groups = $wpdb->get_col(
						$wpdb->prepare(
							"SELECT rg.id FROM {$wpdb->prefix}epofw_row_groups rg
							INNER JOIN {$wpdb->prefix}epofw_sections s ON rg.section_id = s.id
							WHERE s.addon_id = %d AND rg.group_id NOT IN ({$group_placeholders})",
							array_merge( array( $addon_id ), $new_group_ids )
						)
					);

					if ( ! empty( $orphaned_groups ) ) {
						$orphaned_list = implode( ',', array_map( 'intval', $orphaned_groups ) );

						// Delete associated columns groups, rows, and fields.
						$orphaned_columns = $wpdb->get_col(
							"SELECT id FROM {$wpdb->prefix}epofw_columns_groups WHERE row_group_id IN ({$orphaned_list})"
						);

						if ( ! empty( $orphaned_columns ) ) {
							$orphaned_columns_list = implode( ',', array_map( 'intval', $orphaned_columns ) );
							$orphaned_rows         = $wpdb->get_col(
								"SELECT id FROM {$wpdb->prefix}epofw_rows WHERE columns_group_id IN ({$orphaned_columns_list})"
							);

							if ( ! empty( $orphaned_rows ) ) {
								$orphaned_rows_list = implode( ',', array_map( 'intval', $orphaned_rows ) );
								$wpdb->query( "DELETE FROM {$wpdb->prefix}epofw_fields WHERE row_id IN ({$orphaned_rows_list})" );
							}

							$wpdb->query( "DELETE FROM {$wpdb->prefix}epofw_rows WHERE columns_group_id IN ({$orphaned_columns_list})" );
							$wpdb->query( "DELETE FROM {$wpdb->prefix}epofw_columns_groups WHERE row_group_id IN ({$orphaned_list})" );
						}

						$wpdb->query( "DELETE FROM {$wpdb->prefix}epofw_row_groups WHERE id IN ({$orphaned_list})" );
					}
				}
			}

			// 4. Save/Update sections, row groups, and fields.
			if ( ! empty( $addon_data['general'] ) && is_array( $addon_data['general'] ) ) {
				foreach ( $addon_data['general'] as $section_id => $section_data ) {
					if ( ! is_array( $section_data ) ) {
						continue;
					}

					// Use section_id from inside section_data if available, otherwise use array key.
					// This ensures we use the correct section_id regardless of format (old: timestamp, new: post_id_timestamp).
					$actual_section_id = isset( $section_data['section_id'] ) ? sanitize_text_field( $section_data['section_id'] ) : sanitize_text_field( $section_id );

					// Check if section_id is in old format (numeric only, no underscore with post_id).
					// If old format, migrate to new format: post_id_timestamp.
					$is_old_format = is_numeric( $actual_section_id ) && strpos( $actual_section_id, '_' ) === false;
					if ( $is_old_format ) {
						// Generate new format section_id: post_id_timestamp.
						$new_section_id = $post_id . '_' . $actual_section_id;

						// First, find the existing section in database by old section_id.
						$old_section_db = $wpdb->get_row(
							$wpdb->prepare(
								"SELECT id FROM {$wpdb->prefix}epofw_sections WHERE addon_id = %d AND section_id = %s",
								$addon_id,
								$actual_section_id
							)
						);

						if ( $old_section_db ) {
							// Update the section_id in the database.
							$wpdb->update(
								$wpdb->prefix . 'epofw_sections',
								array( 'section_id' => $new_section_id ),
								array( 'id' => $old_section_db->id ),
								array( '%s' ),
								array( '%d' )
							);

							// Update section_id in section_data.
							$section_data['section_id'] = $new_section_id;
							$actual_section_id          = $new_section_id;

							// Update section_id in addon_data array key.
							$addon_data['general'][ $new_section_id ] = $section_data;
							unset( $addon_data['general'][ $section_id ] );
						}
					}

					$new_section_ids[] = $actual_section_id;

					// Use actual section_id from section_data to ensure correct lookup (supports both old and new formats).
					$db_section_id = self::save_section( $addon_id, $actual_section_id, $section_data );

					if ( $db_section_id && ! empty( $section_data['row_groups'] ) ) {
						foreach ( $section_data['row_groups'] as $group_id => $group_data ) {
							if ( ! is_array( $group_data ) ) {
								continue;
							}

							$db_group_id = self::save_row_group( $db_section_id, $group_id, $group_data );

							// v2.2: Use new columns_groups structure if available, otherwise fall back to legacy rows structure.
							if ( $db_group_id && ! empty( $group_data['columns_groups'] ) ) {
								// New structure: columns_groups are already organized.
								foreach ( $group_data['columns_groups'] as $columns_id => $columns_group_data ) {
									$columns_count = isset( $columns_group_data['rows'] ) ? count( $columns_group_data['rows'] ) : 0;
									$columns_order = isset( $columns_group_data['columns_order'] ) ? absint( $columns_group_data['columns_order'] ) : 0;
									$layout_type   = isset( $columns_group_data['layout_type'] ) ? sanitize_text_field( $columns_group_data['layout_type'] ) : 'single';

									// Determine layout_type from columns_count if not provided.
									if ( 'single' === $layout_type && (int) $columns_count > 1 ) {
										$layout_type = ( 2 === (int) $columns_count ) ? 'two' : ( ( 3 === (int) $columns_count ) ? 'three' : ( ( 4 === (int) $columns_count ) ? 'four' : 'single' ) );
									}

									$db_columns_group_id = self::save_columns_group( $db_group_id, $columns_id, $columns_order, $columns_count, $layout_type );

									if ( $db_columns_group_id && ! empty( $columns_group_data['rows'] ) ) {
										foreach ( $columns_group_data['rows'] as $row_id => $row_data ) {
											$row_number      = isset( $row_data['row_number'] ) ? absint( $row_data['row_number'] ) : 1;
											$column_settings = isset( $row_data['column_settings'] ) ? $row_data['column_settings'] : array();

											$db_row_id = self::save_row( $db_columns_group_id, $row_id, $row_number, $column_settings );

											if ( $db_row_id && ! empty( $row_data['fields'] ) ) {
												self::save_fields( $db_row_id, $row_data['fields'] );
											}
										}
									}
								}
							} elseif ( $db_group_id && ! empty( $group_data['rows'] ) ) {
								// Legacy structure: Group rows by columns_id (sub_row_id).
								$columns_groups_map = array();
								$ungrouped_rows     = array();

								foreach ( $group_data['rows'] as $row_id => $row_data ) {
									$columns_id = isset( $row_data['sub_row_id'] ) && ! empty( $row_data['sub_row_id'] ) ? $row_data['sub_row_id'] : null;

									if ( $columns_id ) {
										if ( ! isset( $columns_groups_map[ $columns_id ] ) ) {
											$columns_groups_map[ $columns_id ] = array(
												'columns_order' => isset( $row_data['sub_row_order'] ) ? absint( $row_data['sub_row_order'] ) : 0,
												'rows' => array(),
											);
										}
										$columns_groups_map[ $columns_id ]['rows'][ $row_id ] = $row_data;
									} else {
										// Legacy: rows without columns_id go into a default group.
										$ungrouped_rows[ $row_id ] = $row_data;
									}
								}

								// Save columns groups and their rows.
								foreach ( $columns_groups_map as $columns_id => $columns_group_data ) {
									$columns_count = count( $columns_group_data['rows'] );
									// Determine layout_type from columns_count for legacy data.
									$layout_type         = ( 1 === (int) $columns_count ) ? 'single' : ( ( 2 === (int) $columns_count ) ? 'two' : ( ( 3 === (int) $columns_count ) ? 'three' : ( ( 4 === (int) $columns_count ) ? 'four' : 'single' ) ) );
									$db_columns_group_id = self::save_columns_group( $db_group_id, $columns_id, $columns_group_data['columns_order'], $columns_count, $layout_type );

									if ( $db_columns_group_id ) {
										foreach ( $columns_group_data['rows'] as $row_id => $row_data ) {
											$row_number      = isset( $row_data['row_number'] ) ? absint( $row_data['row_number'] ) : 1;
											$column_settings = isset( $row_data['column_settings'] ) ? $row_data['column_settings'] : array();

											$db_row_id = self::save_row( $db_columns_group_id, $row_id, $row_number, $column_settings );

											if ( $db_row_id && ! empty( $row_data['fields'] ) ) {
												self::save_fields( $db_row_id, $row_data['fields'] );
											}
										}
									}
								}

								// Handle ungrouped rows - create a default columns group with proper ID.
								if ( ! empty( $ungrouped_rows ) ) {
									// Use proper columns_id format: columns_<timestamp> instead of legacy_group_.
									$columns_id          = 'columns_' . $section_id . '_' . $group_id;
									$columns_count       = count( $ungrouped_rows );
									$layout_type         = ( 1 === (int) $columns_count ) ? 'single' : ( ( 2 === (int) $columns_count ) ? 'two' : ( ( 3 === (int) $columns_count ) ? 'three' : ( ( 4 === (int) $columns_count ) ? 'four' : 'single' ) ) );
									$db_columns_group_id = self::save_columns_group( $db_group_id, $columns_id, 0, $columns_count, $layout_type );

									if ( $db_columns_group_id ) {
										foreach ( $ungrouped_rows as $row_id => $row_data ) {
											$row_number      = isset( $row_data['row_number'] ) ? absint( $row_data['row_number'] ) : 1;
											$column_settings = isset( $row_data['column_settings'] ) ? $row_data['column_settings'] : array();

											$db_row_id = self::save_row( $db_columns_group_id, $row_id, $row_number, $column_settings );

											if ( $db_row_id && ! empty( $row_data['fields'] ) ) {
												self::save_fields( $db_row_id, $row_data['fields'] );
											}
										}
									}
								}
							}
						}
					}
				}
			}

			// Clean up orphaned rows - rows whose columns_group_id no longer exists.
			// This handles cases where columns_groups were deleted but rows weren't cleaned up properly.
			// First, get all orphaned row IDs for this addon.
			// Orphaned rows are those where columns_group_id is NOT NULL but the columns_group doesn't exist.
			$orphaned_rows = $wpdb->get_col(
				$wpdb->prepare(
					"SELECT r.id FROM {$wpdb->prefix}epofw_rows r
					INNER JOIN {$wpdb->prefix}epofw_row_groups rg ON r.row_group_id = rg.id
					INNER JOIN {$wpdb->prefix}epofw_sections s ON rg.section_id = s.id
					LEFT JOIN {$wpdb->prefix}epofw_columns_groups cg ON r.columns_group_id = cg.id
					WHERE s.addon_id = %d 
					AND r.columns_group_id IS NOT NULL 
					AND r.columns_group_id != 0 
					AND cg.id IS NULL",
					$addon_id
				)
			);

			if ( ! empty( $orphaned_rows ) ) {
				$orphaned_rows_list = implode( ',', array_map( 'intval', $orphaned_rows ) );
				// Delete fields for orphaned rows.
				$wpdb->query( "DELETE FROM {$wpdb->prefix}epofw_fields WHERE row_id IN ({$orphaned_rows_list})" );
				// Delete orphaned rows.
				$wpdb->query( "DELETE FROM {$wpdb->prefix}epofw_rows WHERE id IN ({$orphaned_rows_list})" );
			}

			// Commit transaction.
			$wpdb->query( 'COMMIT' );

			// Clear cache.
			wp_cache_delete( "epofw_addon_{$post_id}", 'epofw' );
			wp_cache_delete( "epofw_data_{$post_id}", 'epofw' ); // Clear frontend cache.
			// Clear valid addon post IDs cache when addon is saved.
			wp_cache_delete( 'epofw_valid_addon_post_ids_1000', 'epofw' );

			return true;

		} catch ( Exception $e ) {
			// Rollback on error.
			$wpdb->query( 'ROLLBACK' );

			return false;
		}
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
	}

	/**
	 * Save addon meta information
	 *
	 * @since 4.4.0
	 * @param int   $post_id     Post ID.
	 * @param array $addon_data  Addon data.
	 * @return int|false Addon ID or false on failure.
	 */
	private static function save_addon_meta( $post_id, $addon_data ) {
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table operations.
		global $wpdb;

		$table_name = $wpdb->prefix . 'epofw_addons';

		$addon_row = array(
			'post_id'          => $post_id,
			'addon_name'       => isset( $addon_data['epofw_addon_name'] ) ? sanitize_text_field( $addon_data['epofw_addon_name'] ) : '',
			'addon_status'     => isset( $addon_data['epofw_addon_status'] ) ? sanitize_text_field( $addon_data['epofw_addon_status'] ) : 'on',
			'addon_position'   => isset( $addon_data['epofw_addon_position'] ) ? sanitize_text_field( $addon_data['epofw_addon_position'] ) : 'before_add_to_cart',
			'additional_rules' => isset( $addon_data['additional_rule_data'] ) ? wp_json_encode( $addon_data['additional_rule_data'] ) : '',
		);

		/**
		 * Filter addon data before saving to database.
		 *
		 * Allows modules to add extra addon-level data (e.g., section layout setting).
		 *
		 * @since 4.4.6
		 *
		 * @param array $addon_row  Array of addon data to be saved.
		 * @param int   $post_id    Post ID.
		 * @param array $addon_data Original addon data from form submission.
		 */
		$addon_row = apply_filters( 'epofw_addon_save_data', $addon_row, $post_id, $addon_data );

		// Check if addon exists.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table query.
		$existing_addon = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT id FROM `{$wpdb->prefix}epofw_addons` WHERE post_id = %d",
				$post_id
			)
		);

		if ( $existing_addon ) {
			$wpdb->update(
				$table_name,
				$addon_row,
				array( 'id' => $existing_addon ),
				array( '%d', '%s', '%s', '%s', '%s' ),
				array( '%d' )
			);

			return $existing_addon;
		} else {
			$wpdb->insert(
				$table_name,
				$addon_row,
				array( '%d', '%s', '%s', '%s', '%s' )
			);

			return $wpdb->insert_id;
		}
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
	}

	/**
	 * Save section data
	 *
	 * @since 4.4.0
	 * @param int    $addon_id      Addon ID.
	 * @param string $section_id    Section ID.
	 * @param array  $section_data  Section data.
	 * @return int|false Section DB ID or false on failure.
	 */
	private static function save_section( $addon_id, $section_id, $section_data ) {
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table operations.
		global $wpdb;

		$table_name = $wpdb->prefix . 'epofw_sections';

		// Check if section already exists (by addon_id and section_id) to preserve status if not provided.
		$existing_section = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT id, section_status FROM `{$wpdb->prefix}epofw_sections` WHERE addon_id = %d AND section_id = %s",
				$addon_id,
				$section_id
			)
		);

		// Determine section status: use provided value, or preserve existing, or default to 'on'.
		// Empty string means explicitly disabled, not set means preserve from DB.
		$section_status = 'on';
		if ( isset( $section_data['section_status'] ) ) {
			// Status is explicitly provided (even if empty string = disabled).
			$section_status = sanitize_text_field( $section_data['section_status'] );
		} elseif ( $existing_section ) {
			// Status not provided - preserve from DB.
			$section_status = $existing_section->section_status;
		}

		// Handle section title - preserve ampersand characters.
		$section_title_value = '';
		if ( isset( $section_data['section_title'] ) ) {
			// Decode HTML entities (e.g., &amp; becomes &).
			$section_title_value = html_entity_decode( $section_data['section_title'], ENT_QUOTES | ENT_HTML5, 'UTF-8' );
			// Strip tags and trim, but preserve & character.
			$section_title_value = wp_strip_all_tags( $section_title_value );
			$section_title_value = trim( $section_title_value );
			// Remove invalid UTF-8 characters.
			$section_title_value = wp_check_invalid_utf8( $section_title_value, true );
		}

		$section_row = array(
			'addon_id'         => $addon_id,
			'section_id'       => sanitize_text_field( $section_id ),
			'section_title'    => $section_title_value,
			'section_order'    => isset( $section_data['section_order'] ) ? absint( $section_data['section_order'] ) : 0,
			'section_status'   => $section_status,
			'section_settings' => isset( $section_data['section_settings'] ) ? wp_json_encode( $section_data['section_settings'] ) : '',
		);

		if ( $existing_section ) {
			// Update existing section.
			$wpdb->update(
				$table_name,
				$section_row,
				array( 'id' => $existing_section->id ),
				array( '%d', '%s', '%s', '%d', '%s', '%s' ),
				array( '%d' )
			);
			return $existing_section->id;
		} else {
			// Insert new section.
			$wpdb->insert(
				$table_name,
				$section_row,
				array( '%d', '%s', '%s', '%d', '%s', '%s' )
			);

			return $wpdb->insert_id;
		}
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
	}

	/**
	 * Save row group data
	 *
	 * @since 4.4.0
	 * @param int    $section_id   Section ID.
	 * @param string $group_id     Group ID.
	 * @param array  $group_data   Group data.
	 * @return int|false Row group ID or false on failure.
	 */
	private static function save_row_group( $section_id, $group_id, $group_data ) {
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table operations.
		global $wpdb;

		$table_name = $wpdb->prefix . 'epofw_row_groups';

		// Generate group_id if empty (safety fallback).
		if ( empty( $group_id ) ) {
			$group_id = 'group_' . $section_id . '_' . time() . '_' . wp_rand( 1000, 9999 );
		}

		// v2.3: Removed layout_type and columns_count from row_groups (moved to columns_groups).
		$group_row = array(
			'section_id'     => $section_id,
			'group_id'       => sanitize_text_field( $group_id ),
			'group_order'    => 0,
			'group_settings' => isset( $group_data['row_group_settings'] ) ? wp_json_encode( $group_data['row_group_settings'] ) : '',
		);

		// Check if row group already exists (by section_id and group_id).
		$existing = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT id FROM `{$wpdb->prefix}epofw_row_groups` WHERE section_id = %d AND group_id = %s",
				$section_id,
				$group_id
			)
		);

		if ( $existing ) {
			// Update existing row group.
			$wpdb->update(
				$table_name,
				$group_row,
				array( 'id' => $existing ),
				array( '%d', '%s', '%d', '%s' ),
				array( '%d' )
			);
			return $existing;
		} else {
			// Insert new row group.
			$wpdb->insert(
				$table_name,
				$group_row,
				array( '%d', '%s', '%d', '%s' )
			);
			return $wpdb->insert_id;
		}
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
	}

	/**
	 * Save columns group
	 *
	 * @since 4.4.0 (v2.3 schema)
	 * @param int    $row_group_id    Row group DB ID.
	 * @param string $columns_id      Columns group identifier.
	 * @param int    $columns_order   Columns group order.
	 * @param int    $columns_count   Number of columns in this group.
	 * @param string $layout_type     Layout type ('single', 'two', 'three', 'four').
	 * @return int|false Columns group DB ID or false on failure.
	 */
	private static function save_columns_group( $row_group_id, $columns_id, $columns_order = 0, $columns_count = 1, $layout_type = 'single' ) {
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table operations.
		global $wpdb;

		$table_name = $wpdb->prefix . 'epofw_columns_groups';

		// Check if columns group already exists.
		$existing = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT id FROM `{$wpdb->prefix}epofw_columns_groups` WHERE row_group_id = %d AND columns_id = %s",
				$row_group_id,
				$columns_id
			)
		);

		$columns_group_data = array(
			'row_group_id'  => $row_group_id,
			'columns_id'    => sanitize_text_field( $columns_id ),
			'columns_order' => absint( $columns_order ),
			'layout_type'   => sanitize_text_field( $layout_type ),
			'columns_count' => absint( $columns_count ),
		);

		if ( $existing ) {
			$wpdb->update(
				$table_name,
				$columns_group_data,
				array( 'id' => $existing ),
				array( '%d', '%s', '%d', '%s', '%d' ),
				array( '%d' )
			);
			return $existing;
		} else {
			$wpdb->insert(
				$table_name,
				$columns_group_data,
				array( '%d', '%s', '%d', '%s', '%d' )
			);
			return $wpdb->insert_id;
		}
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
	}

	/**
	 * Save individual row/column data
	 *
	 * @since 4.4.0 (v2.0 schema)
	 * @since 4.4.x (v2.2 schema) Added sub_row_id and sub_row_order support.
	 * @since 4.4.x (v2.3 schema) Changed to use columns_group_id instead of sub_row_id.
	 * @param int    $columns_group_id Columns group DB ID.
	 * @param string $row_id           Row identifier.
	 * @param int    $row_number       Row number.
	 * @param array  $column_settings  Column settings array.
	 * @return int|false Row DB ID or false on failure.
	 */
	private static function save_row( $columns_group_id, $row_id, $row_number, $column_settings ) {
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table operations.
		global $wpdb;

		$table_name = $wpdb->prefix . 'epofw_rows';

		// Get row_group_id from columns_group_id for backward compatibility.
		$row_group_id = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT row_group_id FROM {$wpdb->prefix}epofw_columns_groups WHERE id = %d",
				$columns_group_id
			)
		);

		if ( ! $row_group_id ) {
			return false;
		}

		// Generate row_id if empty (safety fallback).
		if ( empty( $row_id ) ) {
			$row_id = 'row_' . $columns_group_id . '_' . time() . '_' . wp_rand( 1000, 9999 );
		}

		$row_data = array(
			'row_group_id'     => $row_group_id,
			'columns_group_id' => $columns_group_id,
			'row_id'           => sanitize_text_field( $row_id ),
			'row_num'          => absint( $row_number ),
			'column_settings'  => ! empty( $column_settings ) ? wp_json_encode( $column_settings ) : '',
			// Keep sub_row_id for backward compatibility (will be populated from columns_groups if needed).
			'sub_row_id'       => null,
			'sub_row_order'    => 0,
		);

		// Check if row already exists.
		$existing = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT id FROM `{$wpdb->prefix}epofw_rows` WHERE columns_group_id = %d AND row_id = %s",
				$columns_group_id,
				$row_id
			)
		);

		if ( $existing ) {
			$wpdb->update(
				$table_name,
				$row_data,
				array( 'id' => $existing ),
				array( '%d', '%d', '%s', '%d', '%s', '%s', '%d' ),
				array( '%d' )
			);
			return $existing;
		} else {
			$result = $wpdb->insert(
				$table_name,
				$row_data,
				array( '%d', '%d', '%s', '%d', '%s', '%s', '%d' )
			);

			if ( false === $result ) {
				return false;
			}

			return $wpdb->insert_id;
		}
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
	}

	/**
	 * Save fields data
	 *
	 * @since 4.4.0
	 * @param int   $row_id  Row DB ID (updated to link fields to rows).
	 * @param array $fields  Fields array.
	 * @return bool True on success.
	 */
	private static function save_fields( $row_id, $fields ) {
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table operations.
		global $wpdb;

		$table_name = $wpdb->prefix . 'epofw_fields';

		$field_order = 0;

		foreach ( $fields as $field_id => $field_data ) {
			// Validate field data.
			if ( ! is_array( $field_data ) ) {
				continue;
			}

			$field_type  = isset( $field_data['field']['type'] ) ? sanitize_text_field( $field_data['field']['type'] ) : 'text';
			$field_label = isset( $field_data['label']['title'] ) ? sanitize_text_field( $field_data['label']['title'] ) : 'Untitled';

			$field_row = array(
				'row_id'       => $row_id,
				'field_id'     => sanitize_text_field( $field_id ),
				'field_type'   => $field_type,
				'field_label'  => $field_label,
				'field_order'  => isset( $field_data['field_order'] ) ? absint( $field_data['field_order'] ) : $field_order,
				'field_status' => isset( $field_data['field_status'] ) && 'on' === $field_data['field_status'] ? sanitize_text_field( $field_data['field_status'] ) : '',
				'field_data'   => wp_json_encode( $field_data ),
			);

			// Check if field already exists (by row_id and field_id).
			$existing = $wpdb->get_var(
				$wpdb->prepare(
					"SELECT id FROM `{$wpdb->prefix}epofw_fields` WHERE row_id = %d AND field_id = %s",
					$row_id,
					$field_id
				)
			);

			if ( $existing ) {
				// Update existing field.
				$wpdb->update(
					$table_name,
					$field_row,
					array( 'id' => $existing ),
					array( '%d', '%s', '%s', '%s', '%d', '%s', '%s' ),
					array( '%d' )
				);
			} else {
				// Insert new field.
				$wpdb->insert(
					$table_name,
					$field_row,
					array( '%d', '%s', '%s', '%s', '%d', '%s', '%s' )
				);
			}

			++$field_order;
		}

		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		return true;
	}

	/**
	 * Load complete addon data from custom tables
	 *
	 * @since 4.4.0
	 * @param int $post_id Post ID.
	 * @return array|false Addon data array or false on failure.
	 */
	public static function load_addon( $post_id ) {
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table read with caching implemented.
		global $wpdb;

		// Note: Schema migrations are handled during plugin init(), not on every load_addon() call.
		// This prevents unnecessary SHOW COLUMNS queries on every page load.

		// Check cache first.
		$cached = wp_cache_get( "epofw_addon_{$post_id}", 'epofw' );
		if ( false !== $cached ) {
			return $cached;
		}

		// Single efficient query with JOINs (includes new columns_groups table).
		// v2.3: Changed to use columns_groups table instead of sub_row_id in rows table.
		$sql = "
			SELECT
				a.id as addon_db_id,
				a.addon_name,
				a.addon_status,
				a.addon_position,
				a.additional_rules,
				s.id as section_db_id,
				s.section_id,
				s.section_title,
				s.section_order,
				s.section_status,
				s.section_settings,
				rg.id as row_group_db_id,
				rg.group_id,
				rg.group_settings,
				cg.id as columns_group_db_id,
				cg.columns_id,
				cg.columns_order,
				cg.layout_type,
				cg.columns_count as columns_group_count,
				r.id as row_db_id,
				r.row_id,
				r.row_num as row_num_value,
				r.column_settings,
				f.field_id,
				f.field_type,
				f.field_label,
				f.field_order,
				f.field_status,
				f.field_data
			FROM {$wpdb->prefix}epofw_addons a
			LEFT JOIN {$wpdb->prefix}epofw_sections s ON a.id = s.addon_id
			LEFT JOIN {$wpdb->prefix}epofw_row_groups rg ON s.id = rg.section_id
			LEFT JOIN {$wpdb->prefix}epofw_columns_groups cg ON rg.id = cg.row_group_id
			LEFT JOIN {$wpdb->prefix}epofw_rows r ON cg.id = r.columns_group_id
			LEFT JOIN {$wpdb->prefix}epofw_fields f ON r.id = f.row_id
			WHERE a.post_id = %d
			ORDER BY s.section_order, cg.columns_order, r.row_num, f.field_order
		";

		$results = $wpdb->get_results( $wpdb->prepare( $sql, $post_id ) );

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

		// Build structured array.
		$addon_data = self::build_addon_structure( $results );

		// Cache the result.
		wp_cache_set( "epofw_addon_{$post_id}", $addon_data, 'epofw', 3600 );

		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
		return $addon_data;
	}

	/**
	 * Build addon structure from database results
	 *
	 * @since 4.4.0
	 * @param array $results Database query results.
	 * @return array Structured addon data.
	 */
	private static function build_addon_structure( $results ) {
		$addon_data     = array();
		$sections       = array();
		$row_groups     = array();
		$columns_groups = array();
		$rows           = array();

		foreach ( $results as $row ) {
			// Build addon meta (once).
			if ( empty( $addon_data ) ) {
				$addon_data = array(
					'epofw_addon_name'     => $row->addon_name,
					'epofw_addon_status'   => $row->addon_status,
					'epofw_addon_position' => $row->addon_position,
					'additional_rule_data' => ! empty( $row->additional_rules ) ? json_decode( $row->additional_rules, true ) : array(),
					'general'              => array(),
				);
			}

			// Build sections.
			if ( $row->section_id && ! isset( $sections[ $row->section_id ] ) ) {
				$sections[ $row->section_id ] = array(
					'section_id'       => $row->section_id,
					'section_title'    => $row->section_title,
					'section_order'    => (int) $row->section_order,
					'section_status'   => $row->section_status,
					'section_settings' => ! empty( $row->section_settings ) ? json_decode( $row->section_settings, true ) : array(),
					'row_groups'       => array(),
				);
			}

			// Build row groups.
			if ( $row->group_id && ! isset( $row_groups[ $row->group_id ] ) ) {
				$row_groups[ $row->group_id ] = array(
					'section_id'         => $row->section_id,
					'row_group_settings' => ! empty( $row->group_settings ) ? json_decode( $row->group_settings, true ) : array(),
					'columns_groups'     => array(),
				);
			}

			// Build columns groups (v2.3: new hierarchy level).
			// Note: columns_id might be NULL for legacy rows, so we check both columns_id and row existence.
			if ( $row->group_id ) {
				// If we have a columns_id from the query, use it.
				if ( $row->columns_id && ! isset( $columns_groups[ $row->columns_id ] ) ) {
					// IMPORTANT: Use layout_type from database, not default 'single'.
					$db_layout_type                     = isset( $row->layout_type ) && ! empty( $row->layout_type ) ? sanitize_text_field( $row->layout_type ) : 'single';
					$columns_groups[ $row->columns_id ] = array(
						'group_id'      => $row->group_id,
						'columns_id'    => $row->columns_id,
						'columns_order' => isset( $row->columns_order ) ? (int) $row->columns_order : 0,
						'layout_type'   => $db_layout_type,
						'columns_count' => isset( $row->columns_group_count ) ? (int) $row->columns_group_count : 1,
						'rows'          => array(),
					);
				}
				// If we have a row_id but no columns_id, we'll create a legacy group later (line 1235).
			}

			// Build rows (columns) - v2.3: Now grouped by columns_group instead of sub_row_id.
			if ( $row->row_id && ! isset( $rows[ $row->row_id ] ) ) {
				$rows[ $row->row_id ] = array(
					'group_id'        => $row->group_id,
					'columns_id'      => isset( $row->columns_id ) ? $row->columns_id : null,
					'row_number'      => (int) ( isset( $row->row_num_value ) ? $row->row_num_value : 1 ),
					'column_settings' => ! empty( $row->column_settings ) ? json_decode( $row->column_settings, true ) : array(),
					'fields'          => array(),
				);
			}

			// Build fields - now organized by row_id (from database!).
			if ( $row->field_id && $row->row_id ) {
				$field_data = ! empty( $row->field_data ) ? json_decode( $row->field_data, true ) : array();

				// Validate decoded field data.
				if ( ! is_array( $field_data ) ) {
					$field_data = array();
				}

				// Explicitly set field_status from database column (not just from JSON).
				// This ensures field_status is always correct even if JSON is missing it.
				if ( isset( $row->field_status ) ) {
					$field_data['field_status'] = sanitize_text_field( $row->field_status );
				}

				// Inject row_association data from database context.
				// This ensures fields know their section, group, columns_group, and row when re-saved.
				$field_data['row_association'] = array(
					'section_id'    => $row->section_id,
					'group_id'      => $row->group_id,
					'columns_id'    => isset( $row->columns_id ) ? $row->columns_id : null,
					'columns_order' => isset( $row->columns_order ) ? (int) $row->columns_order : 0,
					'row_id'        => $row->row_id,
					'layout'        => isset( $field_data['row_association']['layout'] ) ? $field_data['row_association']['layout'] : 'single',
					'columns'       => isset( $field_data['row_association']['columns'] ) ? $field_data['row_association']['columns'] : 1,
				);

				// Also ensure section_id is at field level for parser compatibility.
				$field_data['section_id'] = $row->section_id;

				// Add field to its row.
				$rows[ $row->row_id ]['fields'][ $row->field_id ] = $field_data;
			}
		}

		// Assemble structure: organize rows by columns_id within their group_id.
		foreach ( $rows as $row_id => $row_data ) {
			$group_id   = $row_data['group_id'];
			$columns_id = $row_data['columns_id'];

			if ( ! $columns_id && isset( $row_groups[ $group_id ] ) ) {
				// Rows without columns_id go into a default columns_group.
				$columns_id = 'columns_' . $group_id;
				if ( ! isset( $columns_groups[ $columns_id ] ) ) {
					$columns_groups[ $columns_id ] = array(
						'group_id'      => $group_id,
						'columns_id'    => $columns_id,
						'columns_order' => 0,
						'layout_type'   => 'single',
						'columns_count' => 1,
						'rows'          => array(),
					);
				}
			}

			if ( $columns_id ) {
				// Ensure columns_group exists.
				if ( ! isset( $columns_groups[ $columns_id ] ) ) {
					$columns_groups[ $columns_id ] = array(
						'group_id'      => $group_id,
						'columns_id'    => $columns_id,
						'columns_order' => 0,
						'layout_type'   => 'single',
						'columns_count' => 1,
						'rows'          => array(),
					);
				}
				$columns_groups[ $columns_id ]['rows'][ $row_id ] = array(
					'row_number'      => $row_data['row_number'],
					'column_settings' => $row_data['column_settings'],
					'fields'          => $row_data['fields'],
				);
			}
		}

		// Then, add columns groups to row groups.
		foreach ( $columns_groups as $columns_id => $columns_group_data ) {
			$group_id = $columns_group_data['group_id'];

			if ( isset( $row_groups[ $group_id ] ) ) {
				// IMPORTANT: Include layout_type from columns_group_data.
				$row_groups[ $group_id ]['columns_groups'][ $columns_id ] = array(
					'columns_id'    => $columns_group_data['columns_id'],
					'columns_order' => $columns_group_data['columns_order'],
					'layout_type'   => $columns_group_data['layout_type'],
					'columns_count' => $columns_group_data['columns_count'],
					'rows'          => $columns_group_data['rows'],
				);
			}
		}

		// Then, add row groups to sections.
		foreach ( $row_groups as $group_id => $group_data ) {
			$section_id = $group_data['section_id'];

			if ( isset( $sections[ $section_id ] ) ) {
				$sections[ $section_id ]['row_groups'][ $group_id ] = $group_data;
			}
		}

		// Add sections to addon data.
		$addon_data['general'] = $sections;

		/**
		 * Filter addon data after loading from database.
		 *
		 * Allows modules to add/modify addon-level data (e.g., section layout setting).
		 *
		 * @since 4.4.6
		 *
		 * @param array $addon_data Structured addon data array.
		 */
		$addon_data = apply_filters( 'epofw_addon_load_data', $addon_data );

		return $addon_data;
	}

	/**
	 * Delete addon data from custom tables
	 *
	 * @since 4.4.0
	 * @param int $post_id Post ID of the addon being deleted.
	 * @return bool True on success, false on failure.
	 * @throws Exception If database transaction fails.
	 */
	public static function delete_addon( $post_id ) {
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Custom table deletion with transactions.
		global $wpdb;

		if ( ! self::is_enabled() ) {
			return true; // Not using custom tables, nothing to delete.
		}

		try {
			// Start transaction.
			$wpdb->query( 'START TRANSACTION' );

			// Find the addon DB ID.
			$addon_id = $wpdb->get_var(
				$wpdb->prepare(
					"SELECT id FROM {$wpdb->prefix}epofw_addons WHERE post_id = %d",
					$post_id
				)
			);

			if ( ! $addon_id ) {
				return true; // Not an error, just nothing to delete.
			}

			// Get all section IDs for this addon.
			$section_ids = $wpdb->get_col(
				$wpdb->prepare(
					"SELECT id FROM {$wpdb->prefix}epofw_sections WHERE addon_id = %d",
					$addon_id
				)
			);

			if ( ! empty( $section_ids ) ) {
				// Get all row group IDs for these sections.
				$row_group_ids = $wpdb->get_col(
					"SELECT id FROM {$wpdb->prefix}epofw_row_groups WHERE section_id IN (" . implode( ',', array_map( 'intval', $section_ids ) ) . ')'
				);

				if ( ! empty( $row_group_ids ) ) {
					// Get all row IDs for these row groups.
					$row_ids = $wpdb->get_col(
						"SELECT id FROM {$wpdb->prefix}epofw_rows WHERE row_group_id IN (" . implode( ',', array_map( 'intval', $row_group_ids ) ) . ')'
					);

					if ( ! empty( $row_ids ) ) {
						// Delete fields first (child of rows).
						$fields_deleted = $wpdb->query(
							"DELETE FROM {$wpdb->prefix}epofw_fields WHERE row_id IN (" . implode( ',', array_map( 'intval', $row_ids ) ) . ')'
						);

						if ( false === $fields_deleted ) {
							throw new Exception( 'Failed to delete fields' );
						}

						// Delete rows.
						$rows_deleted = $wpdb->query(
							"DELETE FROM {$wpdb->prefix}epofw_rows WHERE row_group_id IN (" . implode( ',', array_map( 'intval', $row_group_ids ) ) . ')'
						);

						if ( false === $rows_deleted ) {
							throw new Exception( 'Failed to delete rows' );
						}
					}

					// Delete row groups.
					$row_groups_deleted = $wpdb->query(
						"DELETE FROM {$wpdb->prefix}epofw_row_groups WHERE section_id IN (" . implode( ',', array_map( 'intval', $section_ids ) ) . ')'
					);

					if ( false === $row_groups_deleted ) {
						throw new Exception( 'Failed to delete row groups' );
					}
				}

				// Delete sections.
				$sections_deleted = $wpdb->delete(
					$wpdb->prefix . 'epofw_sections',
					array( 'addon_id' => $addon_id ),
					array( '%d' )
				);

				if ( false === $sections_deleted ) {
					throw new Exception( 'Failed to delete sections' );
				}
			}

			// Delete the addon record.
			$addon_deleted = $wpdb->delete(
				$wpdb->prefix . 'epofw_addons',
				array( 'id' => $addon_id ),
				array( '%d' )
			);

			if ( false === $addon_deleted ) {
				throw new Exception( 'Failed to delete addon record' );
			}

			// Commit transaction.
			$wpdb->query( 'COMMIT' );

			// Clear cache.
			wp_cache_delete( "epofw_addon_{$post_id}", 'epofw' );
			wp_cache_delete( "epofw_data_{$post_id}", 'epofw' ); // Clear frontend cache.
			// Clear valid addon post IDs cache when addon is deleted.
			wp_cache_delete( 'epofw_valid_addon_post_ids_1000', 'epofw' );

			return true;

		} catch ( Exception $e ) {
			// Rollback on error.
			$wpdb->query( 'ROLLBACK' );

			return false;
		}
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
	}

	/**
	 * Drop all custom tables (for uninstall)
	 *
	 * @since 4.4.0
	 * @return void
	 */
	public static function drop_tables() {
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Uninstall table drop operation.
		global $wpdb;

		// Temporarily disable foreign key checks to avoid constraint issues.
		$wpdb->query( 'SET FOREIGN_KEY_CHECKS = 0' );

		// Drop in reverse order of dependencies.
		$tables = array(
			$wpdb->prefix . 'epofw_fields',      // Child of rows.
			$wpdb->prefix . 'epofw_rows',        // Child of row_groups.
			$wpdb->prefix . 'epofw_row_groups',  // Child of sections.
			$wpdb->prefix . 'epofw_sections',    // Child of addons.
			$wpdb->prefix . 'epofw_addons',      // Parent table.
		);

		foreach ( $tables as $table ) {
			// Check if table exists before trying to drop.
			$table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );

			if ( $table === $table_exists ) {
				// Try to drop any foreign keys first (in case they exist from old schema).
				// Get all foreign key constraints for this table.
				$table_name_escaped = esc_sql( $table );
				$dbname_escaped     = esc_sql( $wpdb->dbname );

				$foreign_keys = $wpdb->get_results(
					"SELECT CONSTRAINT_NAME 
					FROM information_schema.TABLE_CONSTRAINTS 
					WHERE TABLE_SCHEMA = '{$dbname_escaped}' 
					AND TABLE_NAME = '{$table_name_escaped}' 
					AND CONSTRAINT_TYPE = 'FOREIGN KEY'"
				);

				// Drop each foreign key constraint.
				if ( ! empty( $foreign_keys ) ) {
					foreach ( $foreign_keys as $fk ) {
						// phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- MySQL column name.
						$constraint_name = esc_sql( $fk->CONSTRAINT_NAME );
						$table_escaped   = esc_sql( $table );
						// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange, PluginCheck.Security.DirectDB.UnescapedDBParameter -- DDL statement for table cleanup, table name escaped.
						$wpdb->query( "ALTER TABLE `{$table_escaped}` DROP FOREIGN KEY `{$constraint_name}`" );
					}
				}

				// Now drop the table.
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange, PluginCheck.Security.DirectDB.UnescapedDBParameter -- DDL statement for table cleanup.
				$result = $wpdb->query( "DROP TABLE IF EXISTS {$table}" );

				if ( false === $result ) {
					// Try one more time with a simpler approach.
					// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange, PluginCheck.Security.DirectDB.UnescapedDBParameter -- DDL statement for table cleanup.
					$wpdb->query( "DROP TABLE {$table}" );
				}
			}
		}

		// Re-enable foreign key checks.
		$wpdb->query( 'SET FOREIGN_KEY_CHECKS = 1' );

		delete_option( self::DB_VERSION_OPTION );
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
	}

	/**
	 * Check if custom tables are enabled
	 *
	 * @since 4.4.0
	 * @return bool True if enabled.
	 */
	public static function is_enabled() {
		return (bool) get_option( 'epofw_use_custom_tables', false );
	}

	/**
	 * Enable custom tables
	 *
	 * @since 4.4.0
	 * @return void
	 */
	public static function enable() {
		update_option( 'epofw_use_custom_tables', true );
		self::init();
	}

	/**
	 * Disable custom tables
	 *
	 * @since 4.4.0
	 * @return void
	 */
	public static function disable() {
		update_option( 'epofw_use_custom_tables', false );
	}
}
