<?php
/**
 * PPOM meta table repository.
 *
 * @package PPOM
 */

/**
 *
 * @phpstan-type PPOM_Meta_Group_Row object{
 *     productmeta_id: numeric-string,
 *     productmeta_name: string,
 *     productmeta_validation: string|null,
 *     dynamic_price_display: string|null,
 *     send_file_attachment: string,
 *     show_cart_thumb: string|null,
 *     aviary_api_key: string|null,
 *     productmeta_style: string|null,
 *     productmeta_js: string|null,
 *     productmeta_categories: string|null,
 *     productmeta_tags: string|null,
 *     the_meta: string,
 *     productmeta_created: string
 * }
 * @phpstan-type PPOM_Meta_Group_Summary_Row object{
 *     productmeta_id: numeric-string,
 *     productmeta_categories: string|null,
 *     productmeta_tags: string|null
 * }
 * @phpstan-type PPOM_Meta_Group_Categories_Tags_Columns array{
 *     productmeta_categories?: string,
 *     productmeta_tags?: string
 * }
 * @phpstan-type PPOM_Meta_Group_ColumnKey 'productmeta_id'|'productmeta_name'|'productmeta_validation'|'dynamic_price_display'|'send_file_attachment'|'show_cart_thumb'|'aviary_api_key'|'productmeta_style'|'productmeta_js'|'productmeta_categories'|'productmeta_tags'|'the_meta'|'productmeta_created'
 * @phpstan-type PPOM_Meta_Group_ColumnData array<PPOM_Meta_Group_ColumnKey, mixed>
 * @phpstan-type PPOM_Meta_Demo_ColumnKey 'productmeta_name'|'productmeta_validation'|'dynamic_price_display'|'send_file_attachment'|'show_cart_thumb'|'aviary_api_key'|'productmeta_style'|'productmeta_js'|'productmeta_categories'|'productmeta_tags'|'the_meta'|'productmeta_created'
 * @phpstan-type PPOM_Meta_Demo_Export_Input array{
 *     productmeta_name?: string,
 *     productmeta_validation?: string,
 *     dynamic_price_display?: string,
 *     send_file_attachment?: string,
 *     show_cart_thumb?: string,
 *     aviary_api_key?: string,
 *     productmeta_style?: string,
 *     productmeta_js?: string,
 *     productmeta_categories?: string,
 *     productmeta_tags?: string,
 *     the_meta?: string,
 *     productmeta_created?: string
 * }
 *
 * @package PPOM
 * @subpackage Database
 *
 * @see PPOM_TABLE_META
 * @see NM_PersonalizedProduct::upgrade_database()
 */
class PPOM_Meta_Repository {

	/**
	 * Default object cache group for PPOM meta rows.
	 */
	private const CACHE_GROUP_DEFAULT = 'ppom_meta';

	/**
	 * Object-cache key: full `SELECT *` list (e.g. export / existing-meta screen).
	 */
	private const CACHE_KEY_ALL_ROWS_FULL = 'ppom_meta_all_rows_full';

	/**
	 * Object-cache key: slim id+name list (admin metabox, bulk actions, admin bar).
	 */
	private const CACHE_KEY_ALL_ROWS_LIST_UI = 'ppom_meta_all_rows_list_ui';

	/**
	 * Object-cache key: category/tag assignment summary scan.
	 */
	private const CACHE_KEY_CATEGORY_TAG_SUMMARY = 'ppom_category_tag_summary_rows';

	/**
	 * Shared instance.
	 *
	 * @var PPOM_Meta_Repository|null
	 */
	private static $instance = null;

	/**
	 * WordPress database object.
	 *
	 * @var wpdb
	 */
	private $wpdb;

	/**
	 * Constructor.
	 *
	 * @param wpdb|null $wpdb Optional database handle (for tests).
	 */
	public function __construct( ?wpdb $wpdb = null ) {
		$this->wpdb = $wpdb ? $wpdb : $GLOBALS['wpdb'];
	}

	/**
	 * Singleton accessor.
	 *
	 * @return self
	 */
	public static function instance() {
		if ( null === self::$instance ) {
			self::$instance = new self();
		}

		return self::$instance;
	}

	/**
	 * Filterable cache group name.
	 *
	 * @return string
	 */
	public function cache_group() {
		return (string) apply_filters( 'ppom_meta_cache_group', self::CACHE_GROUP_DEFAULT );
	}

	/**
	 * Prefixed table name.
	 *
	 * @return string
	 */
	public function table_name() {
		return $this->wpdb->prefix . PPOM_TABLE_META;
	}

	/**
	 * Creates or upgrades the PPOM meta table via dbDelta.
	 *
	 * @return void
	 */
	public static function ensure_schema() {
		global $wpdb;

		$table           = $wpdb->prefix . PPOM_TABLE_META;
		$charset_collate = $wpdb->get_charset_collate();

		$sql = "CREATE TABLE {$table} (
		productmeta_id INT(5) NOT NULL AUTO_INCREMENT,
		productmeta_name VARCHAR(50) NOT NULL,
		productmeta_validation VARCHAR(3),
        dynamic_price_display VARCHAR(10),
        send_file_attachment VARCHAR(3) NOT NULL,
        show_cart_thumb VARCHAR(3),
		aviary_api_key VARCHAR(40),
		productmeta_style MEDIUMTEXT,
		productmeta_js MEDIUMTEXT,
		productmeta_categories MEDIUMTEXT,
		productmeta_tags LONGTEXT,
		the_meta MEDIUMTEXT NOT NULL,
		productmeta_created DATETIME NOT NULL,
		PRIMARY KEY  (productmeta_id)
		) {$charset_collate};";

		require_once ABSPATH . 'wp-admin/includes/upgrade.php';
		dbDelta( $sql );

		update_option( 'personalizedproduct_db_version', PPOM_DB_VERSION );
	}

	/**
	 * Object-cache key for one PPOM row.
	 *
	 * @param int $id Row id.
	 * @return string
	 */
	private function cache_key_row( $id ) {
		return 'ppom_group_' . (int) $id;
	}

	/**
	 * Drops one row from the object cache.
	 *
	 * @param int $id Row id.
	 * @return void
	 */
	private function invalidate_row_cache( $id ) {
		wp_cache_delete( $this->cache_key_row( (int) $id ), $this->cache_group() );
	}

	/**
	 * Clears cached aggregate queries (full list, list UI, category/tag summary).
	 *
	 * @return void
	 */
	private function invalidate_aggregate_list_caches() {
		$group = $this->cache_group();
		wp_cache_delete( self::CACHE_KEY_ALL_ROWS_FULL, $group );
		wp_cache_delete( self::CACHE_KEY_ALL_ROWS_LIST_UI, $group );
		wp_cache_delete( self::CACHE_KEY_CATEGORY_TAG_SUMMARY, $group );
	}

	/**
	 * Stores one row in the object cache.
	 *
	 * @param int    $id  Row id.
	 * @param object $row Full table row (`wpdb` OBJECT).
	 * @return void
	 */
	private function cache_set_row( $id, $row ) {
		wp_cache_set( $this->cache_key_row( (int) $id ), $row, $this->cache_group(), 0 );
	}

	/**
	 * All rows (admin export, existing-meta table — needs `the_meta` etc.).
	 *
	 * @return list<PPOM_Meta_Group_Row> Each OBJECT row with all table columns.
	 */
	public function get_all_rows() {
		$group  = $this->cache_group();
		$cached = wp_cache_get( self::CACHE_KEY_ALL_ROWS_FULL, $group );
		if ( false !== $cached && is_array( $cached ) ) {
			/**
			 * Full table rows from object cache.
			 *
			 * @var list<PPOM_Meta_Group_Row> $cached
			 */
			return $cached;
		}

		$table = $this->table_name();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is internal.
		$rows = $this->wpdb->get_results( "SELECT * FROM `{$table}` ORDER BY productmeta_id ASC" );

		/**
		 * Full table rows from wpdb.
		 *
		 * @var list<PPOM_Meta_Group_Row> $rows
		 */
		wp_cache_set( self::CACHE_KEY_ALL_ROWS_FULL, $rows, $group, 0 );

		return $rows;
	}

	/**
	 * Slim rows for admin UI lists (id + name only — smaller query + payload).
	 *
	 * @return list<stdClass> OBJECT rows with productmeta_id, productmeta_name.
	 * @phpstan-return list<stdClass>
	 */
	public function get_all_rows_list_ui() {
		$group  = $this->cache_group();
		$cached = wp_cache_get( self::CACHE_KEY_ALL_ROWS_LIST_UI, $group );
		if ( false !== $cached && is_array( $cached ) ) {
			/**
			 * Cached slim rows (0..n-1 keys).
			 *
			 * @var list<stdClass> $cached
			 */
			return $cached;
		}

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared -- %i identifier; literal projection.
		$raw = $this->wpdb->get_results(
			$this->wpdb->prepare(
				'SELECT productmeta_id, productmeta_name FROM %i ORDER BY productmeta_id ASC',
				$this->table_name()
			)
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared

		$rows = is_array( $raw ) ? $raw : array();

		/**
		 * Slim rows from wpdb.
		 *
		 * @var list<stdClass> $rows
		 */
		wp_cache_set( self::CACHE_KEY_ALL_ROWS_LIST_UI, $rows, $group, 0 );

		return $rows;
	}

	/**
	 * Count of rows. With `$limit`, returns how many rows exist up to that cap (survey / bounded metrics).
	 *
	 * @param int|null $limit When set, count is `min( actual_row_count, $limit )`; negative values are clamped to 0.
	 * @return int
	 */
	public function count_rows( $limit = null ) {
		$table = $this->table_name();
		$lim   = null === $limit ? null : max( 0, (int) $limit );

		if ( null === $lim ) {
			$sql = "SELECT COUNT(*) FROM `{$table}`";
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- table name is internal trusted constant.
			return (int) $this->wpdb->get_var( $sql );
		}

		$sql = "SELECT COUNT(*) FROM ( SELECT 1 FROM `{$table}` ORDER BY productmeta_id ASC LIMIT {$lim} ) AS ppom_count_capped";

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- table name internal; LIMIT is cast int.
		return (int) $this->wpdb->get_var( $sql );
	}

	/**
	 * Single row by id with object cache.
	 *
	 * @param int $id Product meta row id.
	 * @return PPOM_Meta_Group_Row|null OBJECT row; null if missing.
	 */
	public function get_row_by_id( $id ) {
		$id = (int) $id;
		if ( $id <= 0 ) {
			return null;
		}

		$cached = wp_cache_get( $this->cache_key_row( $id ), $this->cache_group() );
		if ( false !== $cached ) {
			/**
			 * Object-cache hit for a full row.
			 *
			 * @var PPOM_Meta_Group_Row $cached
			 */
			return $cached;
		}

		// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- %i is the supported identifier placeholder (WP 6.2+).
		$row = $this->wpdb->get_row(
			$this->wpdb->prepare(
				'SELECT * FROM %i WHERE productmeta_id = %d',
				$this->table_name(),
				$id
			)
		);
		// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared

		if ( ! $row ) {
			return null;
		}

		/**
		 * Full row loaded from the database.
		 *
		 * @var PPOM_Meta_Group_Row $row
		 */
		$this->cache_set_row( $id, $row );

		return $row;
	}

	/**
	 * Multiple rows by id: object-cache first, then one SQL `IN` for misses.
	 * Normalizes input (unique positive ints), returns rows in that order (skips missing).
	 *
	 * @param array<int|string> $ids Row ids.
	 * @return list<PPOM_Meta_Group_Row>
	 */
	public function get_rows_by_ids( array $ids ) {
		$ids = array_values(
			array_unique(
				array_filter(
					array_map( 'absint', $ids ),
					static function ( $v ) {
						return $v > 0;
					}
				)
			)
		);

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

		$by_id   = array();
		$missing = array();

		foreach ( $ids as $id ) {
			$cached = wp_cache_get( $this->cache_key_row( $id ), $this->cache_group() );
			if ( false !== $cached ) {
				$by_id[ $id ] = $cached;
			} else {
				$missing[] = $id;
			}
		}

		if ( ! empty( $missing ) ) {
			$table   = $this->table_name();
			$ids_sql = implode( ',', $missing );
			// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table from prefix + constant; IN list is absint'd ids only.
			$rows = $this->wpdb->get_results( "SELECT * FROM `{$table}` WHERE productmeta_id IN ({$ids_sql})" );
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared

			foreach ( (array) $rows as $row ) {
				if ( isset( $row->productmeta_id ) ) {
					$rid = (int) $row->productmeta_id;
					$this->cache_set_row( $rid, $row );
					$by_id[ $rid ] = $row;
				}
			}
		}

		$ordered = array();
		foreach ( $ids as $id ) {
			if ( isset( $by_id[ $id ] ) ) {
				$ordered[] = $by_id[ $id ];
			}
		}

		return $ordered;
	}

	/**
	 * Rows that have category or tag assignment data.
	 *
	 * @return list<PPOM_Meta_Group_Summary_Row> Each row: productmeta_id, productmeta_categories, productmeta_tags.
	 */
	public function get_category_tag_summary_rows() {
		$group  = $this->cache_group();
		$cached = wp_cache_get( self::CACHE_KEY_CATEGORY_TAG_SUMMARY, $group );
		if ( false !== $cached && is_array( $cached ) ) {
			/**
			 * Summary rows from object cache.
			 *
			 * @var list<PPOM_Meta_Group_Summary_Row> $cached
			 */
			return $cached;
		}

		$table = $this->table_name();

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- static predicate; table from trusted prefix.
		$results = $this->wpdb->get_results(
			"SELECT productmeta_id, productmeta_categories, productmeta_tags FROM `{$table}` WHERE productmeta_categories != '' OR productmeta_tags != ''"
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		/**
		 * Summary projection (id, categories, tags).
		 *
		 * @var list<PPOM_Meta_Group_Summary_Row> $results
		 */
		wp_cache_set( self::CACHE_KEY_CATEGORY_TAG_SUMMARY, $results, $group, 0 );

		return $results;
	}

	/**
	 * Categories and tags columns for attach UI.
	 *
	 * @param int $ppom_field_id Row id.
	 * @return PPOM_Meta_Group_Categories_Tags_Columns Empty when no row.
	 */
	public function get_categories_and_tags_columns( $ppom_field_id ) {
		$id = (int) $ppom_field_id;
		if ( $id <= 0 ) {
			return array();
		}

		// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- %i identifier placeholder (WP 6.2+).
		$rows = $this->wpdb->get_results(
			$this->wpdb->prepare(
				'SELECT productmeta_categories, productmeta_tags FROM %i WHERE productmeta_id = %d',
				$this->table_name(),
				$id
			),
			ARRAY_A
		);
		// phpcs:enable WordPress.DB.PreparedSQL.NotPrepared

		if ( is_array( $rows ) && isset( $rows[0] ) ) {
			/**
			 * First (and only) associative row.
			 *
			 * @var PPOM_Meta_Group_Categories_Tags_Columns $out
			 */
			$out = $rows[0];

			return $out;
		}

		return array();
	}

	/**
	 * Legacy detection: rows with inline style or JS.
	 *
	 * @return list<PPOM_Meta_Group_Row> Full OBJECT rows from SELECT *.
	 */
	public function get_rows_with_non_empty_style_or_js() {
		$table = $this->table_name();

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- static predicate; table from trusted prefix.
		$results = $this->wpdb->get_results(
			"SELECT * FROM `{$table}` WHERE `productmeta_js` != '' OR `productmeta_style` != ''"
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		/**
		 * Full rows that have non-empty style or JS.
		 *
		 * @var list<PPOM_Meta_Group_Row> $results
		 */
		return $results;
	}

	/**
	 * Insert a new field-group row.
	 *
	 * @param array<string, mixed> $data Column => value.
	 * @param array<int, string>   $format wpdb formats.
	 * @phpstan-param PPOM_Meta_Group_ColumnData $data
	 * @return int Insert id or 0 on failure.
	 */
	public function insert_group( array $data, array $format ) {
		$this->wpdb->insert( $this->table_name(), $data, $format );

		$new_id = (int) $this->wpdb->insert_id;
		if ( $new_id > 0 ) {
			$this->invalidate_aggregate_list_caches();
		}

		return $new_id;
	}

	/**
	 * Update an existing row.
	 *
	 * @param int                  $id Row id.
	 * @param array<string, mixed> $data Column => value.
	 * @param array<int, string>   $format wpdb formats.
	 * @param array<string, mixed> $where Where clause (e.g. productmeta_id => id).
	 * @param array<int, string>   $where_format Formats for where.
	 * @phpstan-param PPOM_Meta_Group_ColumnData $data
	 * @phpstan-param PPOM_Meta_Group_ColumnData $where
	 * @return int|false Rows matched or false.
	 */
	public function update_group( $id, array $data, array $format, array $where, array $where_format ) {
		$result = $this->wpdb->update( $this->table_name(), $data, $where, $format, $where_format );
		$this->invalidate_row_cache( (int) $id );
		$this->invalidate_aggregate_list_caches();

		return $result;
	}

	/**
	 * Update only the_meta JSON blob.
	 *
	 * @param int    $id   Row id.
	 * @param string $json Encoded field schema.
	 * @return int|false
	 */
	public function update_the_meta_only( $id, $json ) {
		$id = (int) $id;
		if ( $id <= 0 ) {
			return false;
		}

		$result = $this->wpdb->update(
			$this->table_name(),
			array( 'the_meta' => $json ),
			array( 'productmeta_id' => $id ),
			array( '%s' ),
			array( '%d' )
		);
		$this->invalidate_row_cache( $id );
		$this->invalidate_aggregate_list_caches();

		return $result;
	}

	/**
	 * Delete one row.
	 *
	 * @param int $id Row id.
	 * @return int|false Rows deleted or false.
	 */
	public function delete_by_id( $id ) {
		$id = (int) $id;
		if ( $id <= 0 ) {
			return false;
		}

		$result = $this->wpdb->delete(
			$this->table_name(),
			array( 'productmeta_id' => $id ),
			array( '%d' )
		);
		$this->invalidate_row_cache( $id );
		$this->invalidate_aggregate_list_caches();

		return $result;
	}

	/**
	 * Delete many rows in one prepared statement.
	 *
	 * @param array<int> $ids Row ids.
	 * @return int|false
	 */
	public function delete_by_ids( array $ids ) {
		$ids = array_values(
			array_filter(
				array_map( 'absint', $ids ),
				static function ( $v ) {
					return $v > 0;
				}
			)
		);

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

		/**
		 * Normalized unique row ids (> 0).
		 *
		 * @phpstan-var non-empty-list<positive-int> $ids
		 */
		$table   = $this->table_name();
		$ids_sql = implode( ',', $ids );
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table from prefix + constant; IN list is absint'd ids only.
		$result = $this->wpdb->query( "DELETE FROM `{$table}` WHERE productmeta_id IN ({$ids_sql})" );
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		foreach ( $ids as $id ) {
			$this->invalidate_row_cache( (int) $id );
		}
		$this->invalidate_aggregate_list_caches();

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

		return (int) $result;
	}

	/**
	 * Update category/tag attachment columns; omits productmeta_tags when $tags is false.
	 *
	 * @param int                      $ppom_id    Row id.
	 * @param array<int, string>       $categories Category slugs.
	 * @param array<int, string>|false $tags       Tag slugs, empty array to clear, false to skip column.
	 * @return int|false
	 * @phpstan-param list<string> $categories
	 * @phpstan-param list<string>|false $tags
	 */
	public function save_categories_and_tags( $ppom_id, array $categories, $tags ) {
		$ppom_id = (int) $ppom_id;
		if ( $ppom_id <= 0 ) {
			return false;
		}

		$data = array(
			'productmeta_categories' => implode( "\r\n", $categories ),
		);

		if ( is_array( $tags ) ) {
			// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- legacy DB format for productmeta_tags.
			$data['productmeta_tags'] = empty( $tags ) ? '' : serialize( $tags );
		}

		$format = array( '%s' );
		if ( is_array( $tags ) ) {
			$format[] = '%s';
		}

		$result = $this->wpdb->update(
			$this->table_name(),
			$data,
			array( 'productmeta_id' => $ppom_id ),
			$format,
			array( '%d' )
		);
		$this->invalidate_row_cache( $ppom_id );
		$this->invalidate_aggregate_list_caches();

		return $result;
	}

	/**
	 * Clone a field group row (admin).
	 *
	 * @param int $source_id Source row id.
	 * @return int New insert id or 0.
	 */
	public function clone_group_from( $source_id ) {
		$source_id = (int) $source_id;
		if ( $source_id <= 0 ) {
			return 0;
		}

		$table = $this->table_name();
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared -- %i identifier placeholders (WP 6.2+).
		$prepared = $this->wpdb->prepare(
			'INSERT INTO %i
		(productmeta_name, aviary_api_key, productmeta_style,productmeta_categories, the_meta, productmeta_created)
		SELECT CONCAT(productmeta_name, \' (clone)\'), aviary_api_key, productmeta_style,productmeta_categories, the_meta, productmeta_created
		FROM %i
		WHERE productmeta_id = %d',
			$table,
			$table,
			$source_id
		);
		if ( is_string( $prepared ) ) {
			$this->wpdb->query( $prepared );
		}
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared

		$new_id = (int) $this->wpdb->insert_id;
		if ( $new_id > 0 ) {
			$this->invalidate_row_cache( $new_id );
			$this->invalidate_aggregate_list_caches();
		}

		return $new_id;
	}

	/**
	 * Insert one demo row from whitelisted export columns (activation).
	 *
	 * @param array<string, string> $columns Column => value from JSON export.
	 * @phpstan-param PPOM_Meta_Demo_Export_Input&array<string, string> $columns
	 * @return bool Whether insert was attempted and succeeded.
	 */
	public function insert_demo_row( array $columns ) {
		$allowed = array(
			'productmeta_name',
			'productmeta_validation',
			'dynamic_price_display',
			'send_file_attachment',
			'show_cart_thumb',
			'aviary_api_key',
			'productmeta_style',
			'productmeta_js',
			'productmeta_categories',
			'productmeta_tags',
			'the_meta',
			'productmeta_created',
		);

		$row = array();
		foreach ( $allowed as $key ) {
			if ( array_key_exists( $key, $columns ) ) {
				$row[ $key ] = $columns[ $key ];
			}
		}

		$row['productmeta_name'] = 'PPOM Demo Field';

		if ( ! isset( $row['the_meta'] ) || '' === $row['the_meta'] ) {
			return false;
		}
		if ( ! isset( $row['productmeta_created'] ) ) {
			$row['productmeta_created'] = current_time( 'mysql' );
		}
		if ( ! isset( $row['send_file_attachment'] ) || '' === $row['send_file_attachment'] ) {
			$row['send_file_attachment'] = '';
		}

		$format = array_fill( 0, count( $row ), '%s' );

		/**
		 * Whitelisted string columns ready for wpdb insert.
		 *
		 * @phpstan-var array<PPOM_Meta_Demo_ColumnKey, string> $row
		 */
		$this->wpdb->insert( $this->table_name(), $row, $format );
		$new_id = (int) $this->wpdb->insert_id;
		if ( $new_id > 0 ) {
			$this->invalidate_row_cache( $new_id );
			$this->invalidate_aggregate_list_caches();
		}

		return $new_id > 0;
	}
}
