<?php
/**
 * SettingsDataStore class.
 *
 * @package DokanKits\Core\Data\Stores
 * @since   3.0.0
 */

namespace DokanKits\Core\Data\Stores;

use DokanKits\Core\Data\DataStore;
use DokanKits\Core\Data\Models\Settings;

/**
 * Settings Data Store
 *
 * Handles storage and retrieval of Settings data
 *
 * @since 3.0.0
 * @package DokanKits\Core\Data\Stores
 */
class SettingsDataStore extends DataStore {
	/**
	 * Meta type for settings
	 *
	 * @var string
	 */
	protected $meta_type = 'settings';

	/**
	 * Object type this store handles
	 *
	 * @var string
	 */
	protected $object_type = 'settings';

	/**
	 * Data stored in meta keys, but not considered "meta" for an object.
	 *
	 * @var array<string>
	 */
	protected $internal_meta_keys = array(
		'_name',
		'_value',
		'_default',
		'_date_created',
		'_date_modified',
	);


	/**
	 * Database table name for settings storage
	 *
	 * @var string
	 * @since 3.0.0
	 */
	protected string $table_name;

	/**
	 * Constructor
	 */
	public function __construct() {
		global $wpdb;
		$this->table_name = $wpdb->prefix . 'dokan_kits_settings';
		parent::__construct();
	}

	/**
	 * Method to read settings from the database
	 *
	 * @param Settings $settings Settings object.
	 *
	 * @return void
	 */
	public function read( &$settings ): void {
		$data = null;

		$settings->set_defaults();

		$settings_id   = $settings->get_id();
		$settings_name = $settings->get_name( 'edit' );

		// Try different ways to get the settings.
		if ( $settings_id ) {
			$data = $this->get_settings_by_id( $settings_id );
		} elseif ( $settings_name ) {
			$data = $this->get_settings_by_name( $settings_name );
			if ( $data ) {
				$settings->set_id( $data->get_id() );
			}
		}

		if ( null === $data ) {
			$settings->set_object_read( true );
			return;
		}

		// Set settings data.
		$settings->set_props(
			array(
				'name'          => $data->name,
				'value'         => $this->prepare_value_from_db( $data->value ),
				'default'       => $this->prepare_value_from_db( $data->default ),
				'date_created'  => $this->string_to_timestamp( $data->date_created ),
				'date_modified' => $this->string_to_timestamp( $data->date_modified ),
			)
		);

		$settings->set_object_read( true );

		// Read meta data.
		$settings->read_meta_data();

		/**
		 * Action that fires after reading a settings record
		 *
		 * @since 3.0.0
		 *
		 * @param int               $settings_id Settings ID.
		 * @param Settings          $settings    Settings object.
		 * @param SettingsDataStore $this        Data store instance.
		 */
		do_action( 'dokan_kits_settings_read', $settings_id, $settings, $this );
	}

	/**
	 * Method to create a new setting in the database
	 *
	 * @param Settings $settings Settings object.
	 *
	 * @return void
	 * @throws \InvalidArgumentException When the settings name is empty.
	 */
	public function create( &$settings ): void {
		global $wpdb;

		// Validate required fields.
		if ( empty( $settings->get_name( 'edit' ) ) ) {
			throw new \InvalidArgumentException( 'Settings name cannot be empty' );
		}

		if ( ! $settings->get_date_created( 'edit' ) ) {
			$settings->set_date_created( time() );
		}

		if ( ! $settings->get_date_modified( 'edit' ) ) {
			$settings->set_date_modified( time() );
		}

		/**
		 * Action that fires before creating a settings record
		 *
		 * @since 3.0.0
		 *
		 * @param Settings          $settings Settings object.
		 * @param SettingsDataStore $this     Data store instance.
		 */
		do_action( 'dokan_kits_settings_before_create', $settings, $this );

		$data = array(
			'name'          => $settings->get_name( 'edit' ),
			'value'         => $this->prepare_value_for_db( $settings->get_value( 'edit' ) ),
			'default'       => $this->prepare_value_for_db( $settings->get_default( 'edit' ) ),
			'date_created'  => gmdate( 'Y-m-d H:i:s', $settings->get_date_created( 'edit' ) ? $settings->get_date_created( 'edit' )->getTimestamp() : time() ),
			'date_modified' => gmdate( 'Y-m-d H:i:s', $settings->get_date_modified( 'edit' ) ? $settings->get_date_modified( 'edit' )->getTimestamp() : time() ),
		);

		$result = $wpdb->insert(
			$this->table_name,
			$data,
			array(
				'%s', // name.
				'%s', // value.
				'%s', // default.
				'%s', // date_created.
				'%s', // date_modified.
			)
		);

		if ( false === $result ) {
			$this->handle_db_error( $wpdb->last_error, 'creating' );
		}

		$settings_id = $wpdb->insert_id;
		$settings->set_id( $settings_id );
		$settings->apply_changes();

		// Save meta data.
		$settings->save_meta_data();

		/**
		 * Action that fires after creating a settings record
		 *
		 * @since 3.0.0
		 *
		 * @param int               $settings_id Settings ID.
		 * @param Settings          $settings    Settings object.
		 * @param SettingsDataStore $this        Data store instance.
		 */
		do_action( 'dokan_kits_settings_create', $settings_id, $settings, $this );
	}

	/**
	 * Method to update settings in the database
	 *
	 * @param Settings $settings Settings object.
	 *
	 * @return void
	 * @throws \InvalidArgumentException When the settings name is empty.
	 */
	public function update( &$settings ): void {
		global $wpdb;

		// Validate required fields.
		if ( empty( $settings->get_id() ) ) {
			throw new \InvalidArgumentException( 'Settings ID cannot be empty for update operation' );
		}

		if ( empty( $settings->get_name( 'edit' ) ) ) {
			throw new \InvalidArgumentException( 'Settings name cannot be empty' );
		}

		// Set updated timestamp.
		$settings->set_date_modified( time() );

		/**
		 * Action that fires before updating a settings record
		 *
		 * @since 3.0.0
		 *
		 * @param int               $settings_id Settings ID.
		 * @param Settings          $settings    Settings object.
		 * @param SettingsDataStore $this        Data store instance.
		 */
		do_action( 'dokan_kits_settings_before_update', $settings->get_id(), $settings, $this );

		$data = array(
			'name'          => $settings->get_name( 'edit' ),
			'value'         => $this->prepare_value_for_db( $settings->get_value( 'edit' ) ),
			'default'       => $this->prepare_value_for_db( $settings->get_default( 'edit' ) ),
			'date_modified' => gmdate( 'Y-m-d H:i:s', $settings->get_date_modified( 'edit' ) ? $settings->get_date_modified( 'edit' )->getTimestamp() : time() ),
		);

		$format = array(
			'%s', // name.
			'%s', // value.
			'%s', // default.
			'%s', // date_modified.
		);

		$result = $wpdb->update(
			$this->table_name,
			$data,
			array( 'id' => $settings->get_id() ),
			$format,
			array( '%d' )
		);

		if ( false === $result ) {
			$this->handle_db_error( $wpdb->last_error, 'updating' );
		}

		$settings->apply_changes();

		// Update meta data.
		$settings->save_meta_data();

		/**
		 * Action that fires after updating a settings record
		 *
		 * @since 3.0.0
		 *
		 * @param int               $settings_id Settings ID.
		 * @param Settings          $settings    Settings object.
		 * @param SettingsDataStore $this        Data store instance.
		 */
		do_action( 'dokan_kits_settings_update', $settings->get_id(), $settings, $this );
	}

	/**
	 * Method to delete settings from the database
	 *
	 * @param Settings $settings Settings object.
	 * @param array    $args     Array of args to pass to the delete method.
	 *
	 * @return bool
	 */
	public function delete( &$settings, $args = array() ): bool {
		global $wpdb;

		$id = $settings->get_id();
		if ( ! $id ) {
			return false;
		}

		/**
		 * Filter to allow settings to be deleted or not
		 *
		 * @since 3.0.0
		 *
		 * @param bool              $delete   Whether to delete the settings.
		 * @param int               $id       Settings ID.
		 * @param Settings          $settings Settings object.
		 * @param array             $args     Array of args passed to the delete method.
		 * @param SettingsDataStore $this     Data store instance.
		 */
		$delete = apply_filters( 'dokan_kits_settings_pre_delete', true, $id, $settings, $args, $this );

		if ( ! $delete ) {
			return false;
		}

		$args = wp_parse_args(
			$args,
			array(
				'force_delete' => false,
			)
		);

		/**
		 * Action that fires before deleting a settings record
		 *
		 * @since 3.0.0
		 *
		 * @param int               $id       Settings ID.
		 * @param Settings          $settings Settings object.
		 * @param array             $args     Array of args passed to the delete method.
		 * @param SettingsDataStore $this     Data store instance.
		 */
		do_action( 'dokan_kits_settings_before_delete', $id, $settings, $args, $this );

		if ( $args['force_delete'] ) {
			// Delete metadata.
			$wpdb->delete(
				$wpdb->prefix . 'dokan_kits_settingsmeta',
				array( 'settings_id' => $id ),
				array( '%d' )
			);

			// Delete settings.
			$result = $wpdb->delete(
				$this->table_name,
				array( 'id' => $id ),
				array( '%d' )
			);

			if ( ! $result ) {
				$this->handle_db_error( $wpdb->last_error, 'deleting' );
			}

			$settings->set_id( 0 );

			/**
			 * Action that fires after deleting a settings record
			 *
			 * @since 3.0.0
			 *
			 * @param int               $id       Settings ID.
			 * @param Settings          $settings Settings object.
			 * @param SettingsDataStore $this     Data store instance.
			 */
			do_action( 'dokan_kits_settings_delete', $id, $settings, $this );
		} else {
			// Soft delete could be implemented here.
			/**
			 * Action that fires after trashing a settings record
			 *
			 * @since 3.0.0
			 *
			 * @param int               $id       Settings ID.
			 * @param Settings          $settings Settings object.
			 * @param SettingsDataStore $this     Data store instance.
			 */
			do_action( 'dokan_kits_settings_trash', $id, $settings, $this );
		}

		return true;
	}

	/**
	 * Handle database operation errors
	 *
	 * @param string $message Error message.
	 * @param string $operation Operation that failed (create, update, delete).
	 *
	 * @throws \RuntimeException Always throws this exception.
	 * @return void
	 */
	private function handle_db_error( string $message, string $operation ): void {
		$error_message = sprintf( 'Error %s settings record: %s', $operation, $message );
		if ( function_exists( 'dokan_kits_logger' ) ) {
			dokan_kits_logger()->error( $error_message );
		}
		throw new \RuntimeException( esc_html( $error_message ) );
	}

	/**
	 * Create a Settings object from raw database data
	 *
	 * @param object $settings_data Raw settings data from database.
	 *
	 * @return Settings Settings object.
	 * @throws \Exception Throw exception.
	 */
	private function create_settings_from_data( $settings_data ): Settings {
		$settings_array = array(
			'id'            => $settings_data->id,
			'name'          => $settings_data->name,
			'value'         => $this->prepare_value_from_db( $settings_data->value ),
			'default'       => $this->prepare_value_from_db( $settings_data->default ),
			'date_created'  => $this->string_to_timestamp( $settings_data->date_created ),
			'date_modified' => $this->string_to_timestamp( $settings_data->date_modified ),
		);

		$settings = new Settings( $settings_array );
		$settings->set_object_read( true );

		return $settings;
	}

	/**
	 * Helper method to get settings by ID
	 *
	 * @param int $settings_id Settings ID.
	 *
	 * @return Settings|null Settings object or null if not found.
	 */
	public function get_settings_by_id( int $settings_id ): ?Settings {
		global $wpdb;

		if ( empty( $settings_id ) ) {
			return null;
		}

		// Get data from database.
		$settings_data = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT * FROM {$this->table_name} WHERE id = %d LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				$settings_id
			)
		);

		if ( ! $settings_data ) {
			return null;
		}

		$settings = $this->create_settings_from_data( $settings_data );

		/**
		 * Filter the settings object retrieved by ID
		 *
		 * @since 3.0.0
		 *
		 * @param Settings          $settings      Settings object.
		 * @param object            $settings_data Raw settings data.
		 * @param int               $settings_id   Settings ID.
		 * @param SettingsDataStore $data_store    Data store instance.
		 */
		return apply_filters( 'dokan_kits_settings_get_by_id', $settings, $settings_data, $settings_id, $this );
	}


	/**
	 * Helper method to get settings by name
	 *
	 * @param string $name Settings name.
	 *
	 * @return Settings|null Settings object or null if not found.
	 */
	public function get_settings_by_name( string $name ): ?Settings {
		global $wpdb;

		// Sanitize the name to prevent SQL injection.
		$name = sanitize_key( $name );

		if ( empty( $name ) ) {
			return null;
		}

		// Get data from database.
		$settings_data = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT * FROM {$this->table_name} WHERE name = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
				$name
			)
		);

		if ( ! $settings_data ) {
			return null;
		}

		$settings = $this->create_settings_from_data( $settings_data );

		/**
		 * Filter the settings object retrieved by name
		 *
		 * @since 3.0.0
		 *
		 * @param Settings          $settings      Settings object.
		 * @param object            $settings_data Raw settings data.
		 * @param string            $name          Settings name.
		 * @param SettingsDataStore $data_store    Data store instance.
		 */
		return apply_filters( 'dokan_kits_settings_get_by_name', $settings, $settings_data, $name, $this );
	}

	/**
	 * Clear caches for settings
	 *
	 * @param Settings $settings Settings object.
	 *
	 * @return void
	 */
	public function clear_caches( &$settings ): void {
		/**
		 * Action that fires after clearing caches for a settings object
		 *
		 * @since 3.0.0
		 *
		 * @param Settings          $settings Settings object.
		 * @param SettingsDataStore $this     Data store instance.
		 */
		do_action( 'dokan_kits_settings_clear_caches', $settings, $this );
	}

	/**
	 * Get all settings
	 *
	 * @param array $args {
	 *     Query arguments.
	 *
	 *     @type string $orderby       Field to order by. Default 'name'.
	 *     @type string $order         Order direction. Default 'ASC'.
	 *     @type int    $limit         Number of settings to retrieve. Default -1 (all).
	 *     @type int    $offset        Number of settings to skip. Default 0.
	 *     @type array  $in            Array of setting names to include. Default empty array.
	 *     @type bool   $force_refresh Whether to bypass cache and load fresh data. Default false.
	 * }
	 *
	 * @return array<Settings>
	 * @throws \Exception Show error message when failed.
	 */
	public function get_all( array $args = array() ): array {
		global $wpdb;

		$args = wp_parse_args(
			$args,
			array(
				'orderby'       => 'name',
				'order'         => 'ASC',
				'limit'         => -1,
				'offset'        => 0,
				'in'            => array(),
				'force_refresh' => false,
			)
		);

		/**
		 * Filter the arguments for get_all
		 *
		 * @since 3.0.0
		 *
		 * @param array             $args       Query arguments.
		 * @param SettingsDataStore $data_store Data store instance.
		 */
		$args = (array) apply_filters( 'dokan_kits_settings_get_all_args', $args, $this );

		$query_parts = array(
			'SELECT' => 'SELECT *',
			'FROM'   => "FROM {$this->table_name}",
			'WHERE'  => '',
			'ORDER'  => '',
			'LIMIT'  => '',
		);

		$where_conditions = array();
		$query_values     = array();

		// Filter by specific setting names if provided.
		if ( ! empty( $args['in'] ) && is_array( $args['in'] ) ) {
			$sanitized_names    = array_map( 'sanitize_key', $args['in'] );
			$placeholders       = array_fill( 0, count( $sanitized_names ), '%s' );
			$where_conditions[] = 'name IN (' . implode( ', ', $placeholders ) . ')';
			$query_values       = array_merge( $query_values, $sanitized_names );
		}

		// Build WHERE clause.
		if ( ! empty( $where_conditions ) ) {
			$query_parts['WHERE'] = 'WHERE ' . implode( ' AND ', $where_conditions );
		}

		// Add ORDER BY clause with validation.
		$allowed_orderby      = array( 'id', 'name', 'date_created', 'date_modified' );
		$orderby              = in_array( $args['orderby'], $allowed_orderby, true ) ? $args['orderby'] : 'name';
		$order                = strtoupper( $args['order'] ) === 'DESC' ? 'DESC' : 'ASC';
		$query_parts['ORDER'] = "ORDER BY {$orderby} {$order}";

		// Add LIMIT clause if needed.
		if ( -1 !== (int) $args['limit'] && $args['limit'] > 0 ) {
			$offset               = max( 0, (int) $args['offset'] );
			$limit                = (int) $args['limit'];
			$query_parts['LIMIT'] = "LIMIT {$offset}, {$limit}";
		}

		// Build final query.
		$query = implode( ' ', array_filter( $query_parts ) );

		// Prepare the query if we have values to inject.
		if ( ! empty( $query_values ) ) {
			$query = $wpdb->prepare( $query, $query_values ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
		}

		$results = $wpdb->get_results( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared

		$settings_objects = array_map(
			fn( $data ) => $this->create_settings_from_data( $data ),
			$results
		);

		/**
		 * Filter the settings objects retrieved
		 *
		 * @since 3.0.0
		 *
		 * @param array<Settings>   $settings_objects Settings objects.
		 * @param array             $args             Query arguments.
		 * @param array             $results          Settings data from the database.
		 * @param SettingsDataStore $data_store       Data store instance.
		 */
		return apply_filters( 'dokan_kits_settings_get_all', $settings_objects, $args, $results, $this );
	}

	/**
	 * Delete settings by name
	 *
	 * @param string $name Settings name.
	 *
	 * @return void
	 */
	public function delete_by_name( string $name ): void {
		$settings = $this->get_settings_by_name( $name );
		if ( $settings ) {
			$this->delete( $settings, array( 'force_delete' => true ) );
		}
	}

	/**
	 * Delete settings by names
	 *
	 * @param array<string> $names Array of settings names.
	 *
	 * @return void
	 */
	public function delete_by_names( array $names ): void {
		foreach ( $names as $name ) {
			$this->delete_by_name( $name );
		}
	}

	/**
	 * Reset a setting to its default value by name
	 *
	 * @since 3.0.0
	 *
	 * @param string $name The setting name to reset.
	 *
	 * @return void
	 */
	public function reset_by_name( string $name ): void {
		$settings = $this->get_settings_by_name( $name );
		if ( $settings ) {
			$settings->set_value( $settings->get_default() );
			$settings->save();
		}
	}

	/**
	 * Reset settings by names
	 *
	 * @param array<string> $names Array of settings names.
	 *
	 * @return void
	 */
	public function reset_by_names( array $names ): void {
		foreach ( $names as $name ) {
			$this->reset_by_name( $name );
		}
	}

	/**
	 * Get table schema for creating tables
	 *
	 * @param string $collate Collation to use.
	 *
	 * @return string SQL for table creation.
	 */
	protected function get_table_schema( string $collate ): string {
		global $wpdb;

		/**
		 * Filter the table schema SQL
		 *
		 * @since 3.0.0
		 *
		 * @param string            $sql        SQL schema.
		 * @param string            $collate    Collation.
		 * @param SettingsDataStore $data_store Data store instance.
		 */
		return apply_filters(
			'dokan_kits_settings_table_schema',
			"
			CREATE TABLE {$this->table_name} (
			  `id` bigint(20) unsigned NOT NULL auto_increment,
			  `name` varchar(200) NOT NULL,
			  `value` longtext NOT NULL,
			  `default` longtext NULL,
			  `date_created` datetime NOT NULL default CURRENT_TIMESTAMP,
			  `date_modified` datetime NOT NULL default CURRENT_TIMESTAMP,
			  PRIMARY KEY  (id),
			  UNIQUE KEY name (name)
			) $collate;

			CREATE TABLE {$wpdb->prefix}dokan_kits_settingsmeta (
			  `meta_id` bigint(20) unsigned NOT NULL auto_increment,
			  `settings_id` bigint(20) unsigned NOT NULL,
			  `meta_key` varchar(255) default NULL,
			  `meta_value` longtext,
			  PRIMARY KEY  (meta_id),
			  KEY settings_id (settings_id),
			  KEY meta_key (meta_key(191))
			) $collate;
			",
			$collate,
			$this
		);
	}

	/**
	 * Prepare value for database storage
	 *
	 * @param mixed $value Value to prepare.
	 *
	 * @return string
	 */
	protected function prepare_value_for_db( $value ): string {
		// Handle arrays and objects by serializing them.
		if ( is_array( $value ) || is_object( $value ) ) {
			return maybe_serialize( $value );
		}

		return (string) $value;
	}

	/**
	 * Get required columns for table verification
	 *
	 * @return array<string> Array of required columns.
	 */
	public function get_required_columns(): array {
		return array(
			'id',
			'name',
			'value',
			'default',
			'date_created',
			'date_modified',
		);
	}

	/**
	 * Create or get a settings object
	 *
	 * @param string $name         Settings name.
	 * @param mixed  $default_value Default value.
	 *
	 * @return Settings
	 */
	public function create_or_get( string $name, $default_value = '' ): Settings {
		$settings = new Settings();
		$settings->set_name( $name );
		$settings->get_data_store()->read( $settings );

		if ( ! $settings->get_id() ) {
			$settings->set_default( $default_value );
			$settings->set_value( $default_value );
			$settings->save();
		}

		/**
		 * Filter the settings object
		 *
		 * @since 3.0.0
		 *
		 * @param Settings          $settings      Settings object.
		 * @param string            $name          Settings name.
		 * @param mixed             $default_value Default value.
		 * @param SettingsDataStore $data_store    Data store instance.
		 */
		return apply_filters( 'dokan_kits_settings_create_or_get', $settings, $name, $default_value, $this );
	}

	/**
	 * Update database version
	 *
	 * @return void
	 */
	public function update_db_version(): void {
		$new_values = array();
		$settings   = $this->get_settings_by_name( 'system' );
		if ( null === $settings ) {
			$settings = dokan_kits_get_container()->get( Settings::class );
			$settings->set_name( 'system' );
		} else {
			$new_values = $settings->get_value();
		}

		$new_values = maybe_unserialize( $new_values );

		// Add the new value to the existing value.
		$new_values[ parent::DB_VERSION_KEY ] = DOKAN_KITS_VERSION;

		$settings->set_value( $new_values );
		$settings->save();

		parent::update_db_version();
	}
}
