<?php
/**
 * AbstractModel.php.
 *
 * @package RewardsWP
 */

namespace Awesomemotive\Rewardswp\Abstracts;

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

use Exception;
use InvalidArgumentException;
use function Awesomemotive\Rewardswp\Helpers\get_current_time;

/**
 * Base abstract model with validation, sanitation and hydration helpers.
 */
abstract class AbstractModel {
	/**
	 * Models must declare a method to validate the props from an object before creation.
	 *
	 * @since 1.0.0
	 *
	 * @return bool True if passes on the validation.
	 */
	abstract function validate_create(): bool;

	/**
	 * Models must declare a method to validate the props from an object before updating.
	 *
	 * @since 1.0.0
	 *
	 * @return bool True if passes on the validation.
	 */
	abstract function validate_update(): bool;

	/**
	 * Models must declare a method to sanitize the object props.
	 *
	 * Ideally used before saving the object to the database.
	 *
	 * @since 1.0.0
	 *
	 * @return void.
	 */
	abstract function sanitize(): void;

	/**
	 * Models must declare a list of labels, based on key => values.
	 *
	 * This can mix main table columns and custom meta data.
	 *
	 * @since 1.0.0
	 * @return array
	 */
	abstract function get_labels(): array;

	/**
	 * Retrieve the public properties from an object as an array.
	 *
	 * @since 1.0.0
	 * @return array
	 */
	public function get_props(): array {
		$data    = [];
		$columns = array_keys( $this->db_table->get_columns() );

		foreach ( get_object_vars( $this ) as $property => $value ) {
			if ( ! in_array( $property, $columns, true ) ) {
				continue;
			}

			$data[ $property ] = $value;
		}

		return $data;
	}

	/**
	 * Populates the object's properties with the given data.
	 *
	 * This method fills the object's properties with the values provided in the
	 * associative array. It only sets properties that exist in the object to
	 * avoid undefined property errors.
	 *
	 * @since 1.0.0
	 *
	 * @param array $data An associative array where keys are property names
	 *                    and values are the corresponding property values.
	 *
	 * @return void
	 */
	public function hydrate( array $data ): void {
		if ( empty( $data ) ) {
			return;
		}

		foreach ( $data as $property => $value ) {
			if ( ! property_exists( $this, $property ) ) {
				continue;
			}

			$this->$property = $value;
		}
	}

	/**
	 * Create a new record in the database.
	 *
	 * @since 1.0.0
	 *
	 * @return bool True on success, false on failure.
	 * @throws Exception If validation fails.
	 */
	public function create(): bool {
		// Sanitize the data before inserting into the database.
		$this->sanitize();

		// Validate the data for creation.
		$this->validate_create();

		// Get the columns and values for the database insert.
		$data = array_intersect_key(
			$this->get_props(),
			$this->db_table->get_columns()
		);

		// Set created_at only if not already provided.
		if ( empty( $data['created_at'] ) ) {
			$data['created_at'] = get_current_time();
		}

		// Insert the data into the database.
		$data['id'] = $this->db_handler->insert( $this->db_table->get_prefixed_table_name(), $data );

		if ( empty( $data['id'] ) ) {
			if ( empty( $this->db_handler->get_last_error() ) ) {
				throw new Exception( esc_html__( 'Failed to insert the new record.', 'rewardswp' ) );
			} else {
				throw new Exception( esc_html( sprintf( 'Database error: %s', $this->db_handler->get_last_error() ) ) );
			}
		}

		// Hydrate the model with the new data.
		$this->hydrate( $data );

		// Run after the object is saved.
		$this->after_save();

		return true;
	}

	/**
	 * Update an existing record in the database.
	 *
	 * @since 1.0.0
	 *
	 * @return bool True on success, false on failure.
	 * @throws Exception If validation fails.
	 */
	public function update(): bool {
		// Sanitize the data before updating the database.
		$this->sanitize();

		// Validate the data for updating.
		$this->validate_update();

		// Get the columns and values for the database update.
		$data = array_intersect_key(
			$this->get_props(),
			$this->db_table->get_columns()
		);

		// Update the data in the database.
		$result = $this->db_handler->update( $this->db_table->get_prefixed_table_name(), $data, [ 'id' => $this->id ] );

		if ( false === $result ) {
			throw new Exception( esc_html__( 'Failed to update the record.', 'rewardswp' ) );
		}

		// Hydrate the model with the updated data.
		$this->hydrate( $data );

		// Run after the object is saved.
		$this->after_save();

		return true;
	}

	/**
	 * Update only specific fields in the database.
	 *
	 * This method performs a targeted update of only the specified fields,
	 * avoiding the risk of overwriting other fields that may have been
	 * modified elsewhere (e.g., by hooks or other processes).
	 *
	 * Use this instead of update() when you only need to modify specific
	 * fields and want to preserve other field values in the database.
	 *
	 * @since 1.1.0
	 *
	 * @param array $fields Associative array of field => value pairs to update.
	 *                      Only fields that exist in the table columns will be updated.
	 *
	 * @return bool True on success, false on failure.
	 * @throws InvalidArgumentException If object ID is not set.
	 * @throws Exception If the update fails.
	 */
	public function update_fields( array $fields ): bool {
		if ( empty( $this->id ) ) {
			throw new InvalidArgumentException( esc_html__( 'Object ID is required for update.', 'rewardswp' ) );
		}

		if ( empty( $fields ) ) {
			return true; // Nothing to update.
		}

		// Filter to only include valid table columns.
		$valid_columns = $this->db_table->get_columns();
		$data          = array_intersect_key( $fields, $valid_columns );

		if ( empty( $data ) ) {
			return true; // No valid fields to update.
		}

		// Don't allow updating the ID.
		unset( $data['id'] );

		// Update the data in the database.
		$result = $this->db_handler->update(
			$this->db_table->get_prefixed_table_name(),
			$data,
			[ 'id' => $this->id ]
		);

		if ( false === $result ) {
			throw new Exception( esc_html__( 'Failed to update the record.', 'rewardswp' ) );
		}

		// Update the object properties with the new values.
		foreach ( $data as $property => $value ) {
			if ( property_exists( $this, $property ) ) {
				$this->$property = $value;
			}
		}

		return true;
	}

	/**
	 * Loads the object from the database by ID and update the props for the current referral object.
	 *
	 * @since 1.0.0
	 * @param int $object_id Object ID.
	 *
	 * @return void
	 */
	public function load( int $object_id ) {
		$data = $this->db_handler->get_row(
			$this->db_table->get_prefixed_table_name(),
			[
				'id' => $object_id,
			],
			array_keys( $this->db_table->get_columns() )
		);

		if ( empty( $data ) ) {
			return;
		}

		$this->hydrate( get_object_vars( $data ) );
	}

	/**
	 * Deletes the object from the database.
	 *
	 * @since 1.0.0
	 * @return bool True on success, false on failure.
	 * @throws Exception If the object ID is not set or if the deletion fails.
	 */
	public function delete(): bool {
		if ( empty( $this->id ) ) {
			throw new InvalidArgumentException( esc_html__( 'Object ID is required for deletion.', 'rewardswp' ) );
		}

		// Call before_delete() hook if it exists in the child class.
		if ( method_exists( $this, 'before_delete' ) ) {
			$this->before_delete();
		}

		// Attempt to delete the object by ID.
		$result = $this->db_handler->delete( $this->db_table->get_prefixed_table_name(), [ 'id' => $this->id ] );

		if ( false === $result ) {
			throw new Exception( esc_html__( 'Failed to delete object.', 'rewardswp' ) );
		}

		return true;
	}

	/**
	 * A method that runs after the object is saved.
	 *
	 * @since 1.0.0
	 *
	 * @return void
	 */
	public function after_save(): void {
		return;
	}
}
