<?php
/**
 * Model: Member.php.
 *
 * @package RewardsWP
 */

namespace Awesomemotive\Rewardswp\Models;

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

use Awesomemotive\Rewardswp\Abstracts\AbstractModelWithMeta;
use Awesomemotive\Rewardswp\Database\DatabaseHandler;
use Awesomemotive\Rewardswp\Database\Tables\MembersTable;
use Awesomemotive\Rewardswp\Factories\IntegrationsFactory;
use Awesomemotive\Rewardswp\Interfaces\IntegrationWithCouponInterface;
use Awesomemotive\Rewardswp\Settings\Options\Referrals\ReferralVariableOption;
use Exception;
use InvalidArgumentException;
use WP_User;
use function Awesomemotive\Rewardswp\Helpers\delete_points_by_member_id;
use function Awesomemotive\Rewardswp\Helpers\delete_referrals_by_member_id;
use function Awesomemotive\Rewardswp\Helpers\delete_rewards_by_member_id;
use function Awesomemotive\Rewardswp\Helpers\generate_custom_hash;
use function Awesomemotive\Rewardswp\Helpers\get_current_time;
use function Awesomemotive\Rewardswp\Helpers\get_option_value;
use function Awesomemotive\Rewardswp\Helpers\get_referrals;
use function Awesomemotive\Rewardswp\Helpers\get_user_first_name;
use function Awesomemotive\Rewardswp\Helpers\get_user_last_name;
use function Awesomemotive\Rewardswp\Helpers\sanitize_float;

/**
 * Class Member
 *
 * Represents the member entity and handles database interactions.
 *
 * @since 1.0.0
 */
class Member extends AbstractModelWithMeta {
	/**
	 * WordPress database handler.
	 *
	 * @since 1.0.0
	 * @var DatabaseHandler
	 */
	protected DatabaseHandler $db_handler;

	/**
	 * Table name.
	 *
	 * @since 1.0.0
	 * @var MembersTable
	 */
	protected MembersTable $db_table;

	/**
	 * Integrations factory.
	 *
	 * @since 1.1.0
	 * @var IntegrationsFactory
	 */
	private IntegrationsFactory $integrations_factory;

	/**
	 * Properties representing member fields.
	 *
	 * @since 1.0.0
	 * @var int|null
	 */
	public int $id = 0;

	/**
	 * Associated WordPress user ID.
	 *
	 * @since 1.0.0
	 * @var int|null
	 */
	public ?int $user_id = null;

	/**
	 * Member email address.
	 *
	 * @since 1.0.0
	 * @var string|null
	 */
	public ?string $email = '';

	/**
	 * Member first name.
	 *
	 * @since 1.0.0
	 * @var string
	 */
	public string $first_name = '';

	/**
	 * Member last name.
	 *
	 * @since 1.0.0
	 * @var string
	 */
	public string $last_name = '';

	/**
	 * Unique referral code.
	 *
	 * @since 1.0.0
	 * @var string
	 */
	public string $referral_code = '';

	/**
	 * Guest token or identifier.
	 *
	 * @since 1.0.0
	 * @var string|null
	 */
	public ?string $token = null;

	/**
	 * Total referrals count.
	 *
	 * @since 1.0.0
	 * @var int
	 */
	public int $total_referrals = 0;

	/**
	 * Completed referrals count.
	 *
	 * @since 1.0.0
	 * @var int
	 */
	public int $completed_referrals = 0;

	/**
	 * Current points balance.
	 *
	 * @since 1.0.1
	 * @var int|null
	 */
	public ?int $points_balance = null;

	/**
	 * Member status.
	 *
	 * @since 1.0.0
	 * @var string
	 */
	public string $status = 'active';

	/**
	 * Creation timestamp.
	 *
	 * @since 1.0.0
	 * @var string
	 */
	public string $created_at = '';

	/**
	 * Allowed member statuses.
	 *
	 * @since 1.0.0
	 * @var array
	 */
	public array $statuses;

	/**
	 * Constructor.
	 *
	 * @param DatabaseHandler     $db_handler            Database handler instance.
	 * @param MembersTable        $db_table              Members table instance.
	 * @param IntegrationsFactory $integrations_factory  Integrations factory instance.
	 * @since 1.0.0
	 */
	public function __construct(
		DatabaseHandler $db_handler,
		MembersTable $db_table,
		IntegrationsFactory $integrations_factory
	) {
		$this->db_handler           = $db_handler;
		$this->db_table             = $db_table;
		$this->integrations_factory = $integrations_factory;

		// Set the referral statuses.
		$this->statuses = [
			'active'   => esc_html__( 'Member', 'rewardswp' ),
			'blocked'  => esc_html__( 'Blocked', 'rewardswp' ),
			'pending'  => esc_html__( 'Pending', 'rewardswp' ),
			'guest'    => esc_html__( 'Guest', 'rewardswp' ),
			'excluded' => esc_html__( 'Excluded', 'rewardswp' ),
		];
	}

	/**
	 * Check if the member is a guest (no associated WordPress user).
	 *
	 * A member is considered a guest if:
	 * 1. They don't have a user_id (no WordPress user associated)
	 * 2. Their status is 'active' (not blocked, excluded, or pending)
	 *
	 * This ensures that blocked/excluded members are not treated as guests,
	 * even if they don't have a WordPress user account.
	 *
	 * @since 1.1.0
	 *
	 * @return bool True if the member is a guest, false otherwise.
	 */
	public function is_guest(): bool {
		return empty( $this->user_id ) && 'active' === $this->status;
	}

	/**
	 * Retrieve the member status.
	 *
	 * Returns 'guest' for members without a user_id and with 'active' status.
	 * Otherwise returns the stored status value.
	 *
	 * @since 1.0.1
	 *
	 * @return string
	 */
	public function get_status(): string {
		if ( $this->is_guest() ) {
			return 'guest';
		}

		return $this->status;
	}

	/**
	 * Retrieve the labels for the model columns and metadata.
	 *
	 * @since 1.0.0
	 * @return array The labels.
	 */
	public function get_labels(): array {
		return $this->db_table->get_columns();
	}

	/**
	 * Get the referral link.
	 *
	 * @since 1.0.0
	 *
	 * @return string The referral link.
	 */
	public function get_referral_url(): string {
		if ( empty( $this->referral_code ) ) {
			return '';
		}

		return add_query_arg(
			get_option_value( ReferralVariableOption::class ),
			$this->referral_code,
			home_url( '/' )
		);
	}

	/**
	 * Retrieve the WP_User object.
	 *
	 * @since 1.0.0
	 *
	 * @return WP_User|null WP_User object if any or null in case of failure.
	 */
	public function get_wp_user(): ?WP_User {
		if ( empty( $this->user_id ) ) {
			return null;
		}

		$user = get_user_by( 'ID', $this->user_id );

		return $user ?: null;
	}

	/**
	 * Retrieve the full name.
	 *
	 * @since 1.0.0
	 *
	 * @return string
	 */
	public function get_full_name(): string {
		return sprintf(
			'%s %s',
			$this->get_first_name(),
			$this->get_last_name()
		);
	}

	/**
	 * Retrieve the first name.
	 *
	 * @since 1.0.0
	 *
	 * @return string
	 */
	public function get_first_name(): string {
		// If we have a first name stored, use it.
		if ( ! empty( $this->first_name ) ) {
			return $this->first_name;
		}

		// If we have a user_id, try to get the name from WP user.
		if ( ! empty( $this->user_id ) ) {
			return (string) get_user_first_name( $this->user_id );
		}

		// For non-WP users without a first name, return empty string.
		return '';
	}

	/**
	 * Retrieve the last name.
	 *
	 * @since 1.0.0
	 *
	 * @return string
	 */
	public function get_last_name(): string {
		// If we have a last name stored, use it.
		if ( ! empty( $this->last_name ) ) {
			return $this->last_name;
		}

		// If we have a user_id, try to get the name from WP user.
		if ( ! empty( $this->user_id ) ) {
			return (string) get_user_last_name( $this->user_id );
		}

		// For non-WP users without a last name, return empty string.
		return '';
	}

	/**
	 * Generate a unique referral code.
	 *
	 * This method will generate a referral code and keep trying until a unique code is found.
	 *
	 * @since 1.0.0
	 * @param string $identifier Represents a value considered unique for a member object. Usually the email.
	 *
	 * @return string The unique referral code.
	 */
	public function generate_unique_code( string $identifier ): string {
		$attempt = 1;

		do {
			$code = generate_custom_hash( "{$identifier}_{$attempt}", '', 7 );
			++$attempt;
		} while ( ! $this->is_code_unique( $code ) );

		return $code;
	}

	/**
	 * Check if the referral code is unique.
	 *
	 * This method checks whether the referral code already exists in the database.
	 *
	 * @since 1.0.0
	 * @param string $code The referral code to check.
	 *
	 * @return bool True if the code is unique, false if it already exists.
	 */
	public function is_code_unique( string $code ): bool {
		return $this->db_handler->count( $this->db_table->get_prefixed_table_name(), [ 'referral_code' => $code ] ) === 0;
	}

	/**
	 * Generate a unique access token.
	 *
	 * @since 1.0.0
	 * @return string The generated token.
	 */
	public function generate_unique_token(): string {
		$attempt = 1;

		do {
			$token = wp_generate_password( 64, false );
			++$attempt;
		} while ( ! $this->is_token_unique( $token ) );

		return $token;
	}

	/**
	 * Check if the token is unique.
	 *
	 * @since 1.0.0
	 * @param string $token The token to check.
	 * @return bool True if the token is unique, false if it already exists.
	 */
	public function is_token_unique( string $token ): bool {
		return $this->db_handler->count( $this->db_table->get_prefixed_table_name(), [ 'token' => $token ] ) === 0;
	}

	/**
	 * Calculate the referral conversion rate.
	 *
	 * Calculates the percentage of total referrals that were completed.
	 *
	 * @since 1.1.0
	 * @return float The conversion rate as a percentage (e.g., 50.0 for 50%). Returns 0.0 if total_referrals is zero.
	 */
	public function get_referral_conversion_rate(): float {
		if ( empty( $this->total_referrals ) ) {
			return 0.0;
		}

		return round( ( ( (int) $this->completed_referrals / (int) $this->total_referrals ) * 100 ), 2 );
	}

	/**
	 * Get the total referral revenue generated by this member.
	 *
	 * @since 1.1.0
	 * @return float
	 */
	public function get_total_referral_revenue(): float {
		if ( ! $this->id ) {
			return 0.0;
		}

		$completed_referrals = get_referrals(
			[
				'status'      => 'completed',
				'advocate_id' => $this->id,
			]
		);

		if ( empty( $completed_referrals ) ) {
			return 0.0;
		}

		$revenue = 0.0;

		foreach ( $completed_referrals as $referral ) {
			if ( ! isset( $referral->source ) || empty( $referral->order_id ) ) {
				continue;
			}

			$integration = $this->integrations_factory->get_integration( $referral->source );

			if ( ! $integration instanceof IntegrationWithCouponInterface ) {
				continue;
			}

			$revenue += $integration->get_order_total( $referral->order_id );
		}

		return floatval( $revenue );
	}

	/**
	 * Sanitize data.
	 *
	 * Must be used before saving data into the DB.
	 *
	 * @since 1.0.0
	 * @return void
	 */
	public function sanitize(): void {
		$this->user_id             = $this->user_id ? (int) absint( $this->user_id ) : null;
		$this->email               = sanitize_email( $this->email );
		$this->first_name          = sanitize_text_field( $this->first_name );
		$this->last_name           = sanitize_text_field( $this->last_name );
		$this->referral_code       = sanitize_text_field( $this->referral_code );
		$this->total_referrals     = (int) absint( $this->total_referrals );
		$this->completed_referrals = (int) absint( $this->completed_referrals );
		$this->status              = sanitize_text_field( $this->status );
		$this->created_at          = sanitize_text_field( $this->created_at );
		$this->points_balance      = absint( $this->points_balance ?? 0 );
	}

	/**
	 * Validates data before creating.
	 *
	 * @since 1.0.0
	 * @return bool When validates successfully.
	 * @throws InvalidArgumentException If validation fails.
	 */
	public function validate_create(): bool {
		// If an email is supplied, check if it is a valid email.
		if ( ! filter_var( $this->email, FILTER_VALIDATE_EMAIL ) ) {
			throw new InvalidArgumentException( esc_html__( 'Invalid email format.', 'rewardswp' ) );
		}

		// Check if the email it is already in use.
		$existing_member = $this->get_by( 'email', $this->email );

		if ( $existing_member ) {
			throw new InvalidArgumentException( esc_html__( 'Email already exists for another member.', 'rewardswp' ) );
		}

		// If a user ID is supplied, then check if it is a valid WP user.
		if ( ! empty( $this->user_id ) && false === get_user_by( 'ID', $this->user_id ) ) {
			throw new InvalidArgumentException( esc_html__( 'The user ID supplied is not a valid WP user.', 'rewardswp' ) );
		}

		// Get the status keys to test against it.
		$allowed_statuses = array_keys( $this->statuses );

		if ( ! in_array( $this->status, $allowed_statuses, true ) ) {
			throw new InvalidArgumentException(
				sprintf(
				/* translators: %s is a placeholder for allowed statuses */
					esc_html__(
						'Wrong status found. Only %s are allowed.',
						'rewardswp'
					),
					esc_html( implode( ', ', $allowed_statuses ) )
				)
			);
		}

		// Return true if member is being created by token method.
		if ( ! empty( $this->token ) && empty( $this->user_id ) ) {

			// Check if user_id is already in use.
			$existing_member = $this->get_by( 'token', $this->token );

			if ( $existing_member ) {
				throw new InvalidArgumentException( esc_html__( 'Token already used by another member.', 'rewardswp' ) );
			}

			return true;
		}

		// Check if user_id is already in use by another member (only if user_id is not null).
		if ( $this->user_id !== null ) {
			$existing_member = $this->get_by( 'user_id', $this->user_id );

			if ( $existing_member && absint( $existing_member->id ) !== $this->id ) {
				throw new InvalidArgumentException( esc_html__( 'User ID already exists for another member.', 'rewardswp' ) );
			}
		}

		return true;
	}

	/**
	 * Validates data before updating.
	 *
	 * @since 1.0.0
	 * @return bool When validates successfully.
	 * @throws InvalidArgumentException If validation fails.
	 */
	public function validate_update(): bool {
		// Check if member ID is provided.
		if ( empty( $this->id ) ) {
			throw new InvalidArgumentException( esc_html__( 'Member ID is required for updates.', 'rewardswp' ) );
		}

		// If an email is supplied, check if it is a valid email.
		if ( ! filter_var( $this->email, FILTER_VALIDATE_EMAIL ) ) {
			throw new InvalidArgumentException( esc_html__( 'Invalid email format.', 'rewardswp' ) );
		}

		// Check if email is already in use by another member.
		$existing_member = $this->get_by( 'email', $this->email );

		if ( $existing_member && absint( $existing_member->id ) !== $this->id ) {
			throw new InvalidArgumentException( esc_html__( 'Email already exists for another member.', 'rewardswp' ) );
		}

		// Check if user_id is already in use by another member (only if user_id is not null).
		if ( $this->user_id !== null ) {
			$existing_member = $this->get_by( 'user_id', $this->user_id );

			if ( $existing_member && absint( $existing_member->id ) !== $this->id ) {
				throw new InvalidArgumentException( esc_html__( 'User ID already exists for another member.', 'rewardswp' ) );
			}
		}

		// Validate referral code to ensure it's not in use by another member.
		$existing_member = $this->get_by( 'referral_code', $this->referral_code );

		if ( $existing_member && absint( $existing_member->id ) !== $this->id ) {
			throw new InvalidArgumentException( esc_html__( 'Referral code already exists for another member.', 'rewardswp' ) );
		}

		// Validate that referral code is not numeric only.
		if ( ! empty( $this->referral_code ) && is_numeric( $this->referral_code ) ) {
			throw new InvalidArgumentException( esc_html__( 'Referral code cannot be numeric only.', 'rewardswp' ) );
		}

		return true;
	}

	/**
	 * Creates a new member in the database.
	 *
	 * @since 1.0.0
	 *
	 * @return bool Will return true in case of success.
	 *
	 * @throws InvalidArgumentException If validation fails.
	 * @throws Exception If fails to create the member.
	 */
	public function create(): bool {
		// Sanitize data before inserting into DB.
		$this->sanitize();

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

		$data = array_intersect_key( $this->get_props(), $this->db_table->get_columns() );

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

		if ( empty( $data['referral_code'] ) ) {
			$data['referral_code'] = $this->generate_unique_code( $this->email );
		}

		if ( empty( $data['token'] ) && empty( $this->user_id ) ) {
			$data['token'] = $this->generate_unique_token();
		}

		// Try to insert the member into the DB.
		$data['id'] = $this->db_handler->insert( $this->db_table->get_prefixed_table_name(), $data );

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

		// Update the internal props.
		$this->hydrate( $data );

		return true;
	}

	/**
	 * Loads a member from the database by ID and update the props for the current member object.
	 *
	 * @since 1.0.0
	 * @param int $object_id Member ID.
	 *
	 * @return void
	 */
	public function load( int $object_id ) {
		$this->load_by( 'id', $object_id );
	}

	/**
	 * Loads a member from the database using a specific column and value.
	 *
	 * This method allows loading the member using any unique column like 'id', 'user_id', 'email', or 'referral_code'.
	 *
	 * @since 1.0.0
	 * @param string $source The column to search by (e.g., 'id', 'user_id', 'email', 'referral_code').
	 * @param mixed  $value The value to search for.
	 *
	 * @return bool True if data was loaded successfully, false otherwise.
	 * @throws InvalidArgumentException If the source column is invalid.
	 */
	public function load_by( string $source, $value ): bool {
		try {
			// Use the get_by method to retrieve the member's data based on the source.
			$data = $this->get_by( $source, $value );

			if ( empty( $data ) ) {
				return false; // Return false if no data is found.
			}

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

			return true;
		} catch ( InvalidArgumentException $e ) {
			return false;
		}
	}

	/**
	 * Fetch member data based on a specific column and value.
	 *
	 * @since 1.0.0
	 *
	 * @param string $source The column to search by (e.g., 'id', 'user_id', 'email', 'referral_code').
	 * @param mixed  $value  The value to search for.
	 *
	 * @return object|null The member data if found, or null if not found.
	 */
	public function get_by( string $source, $value ): ?object {
		// Ensure the source column is valid.
		$valid_sources = [ 'id', 'user_id', 'email', 'referral_code', 'token' ];

		if ( ! in_array( $source, $valid_sources, true ) ) {
			throw new InvalidArgumentException( esc_html__( 'Invalid source for member lookup.', 'rewardswp' ) );
		}

		// Query the database using the specified source and value.
		return $this->db_handler->get_row(
			$this->db_table->get_prefixed_table_name(),
			[ $source => sanitize_text_field( $value ) ],
			array_keys( $this->db_table->get_columns() )
		);
	}

	/**
	 * Increase the member points balance by a given amount.
	 *
	 * @param int $points_to_add The amount of points to add.
	 * @param bool  $auto_update Whether to update the points balance in the database.
	 *
	 * @return void
	 *
	 * @throws Exception If persisting the updated balance fails.
	 *
	 * @since 1.0.2
	 */
	public function increase_points_balance( int $points_to_add, bool $auto_update = true ): void {
		if ( $points_to_add <= 0 ) {
			return;
		}

		$this->points_balance = (int) ( ( $this->points_balance ?? 0 ) + $points_to_add );

		if ( $auto_update ) {
			$this->update();
		}
	}

	/**
	 * Decrease the member points balance by a given amount.
	 *
	 * @param int $points_to_deduct The amount of points to deduct.
	 * @param bool  $auto_update Whether to update the points balance in the database.
	 * @param bool  $allow_negative Whether to allow negative balance (useful for imports).
	 *
	 * @return void
	 *
	 * @throws Exception If persisting the updated balance fails.
	 *
	 * @since 1.0.2
	 */
	public function deduct_points_balance( int $points_to_deduct, bool $auto_update = true, bool $allow_negative = false ): void {
		// Ensure points_to_deduct is positive.
		if ( $points_to_deduct <= 0 ) {
			throw new InvalidArgumentException( esc_html__( 'The amount of points to deduct must be a positive number.', 'rewardswp' ) );
		}

		// Ensure member has enough points (unless negative balance is allowed).
		if ( ! $allow_negative && $points_to_deduct > $this->points_balance ) {
			throw new InvalidArgumentException( esc_html__( 'Member does not have enough points for this deduction.', 'rewardswp' ) );
		}

		$this->points_balance = (int) ( $this->points_balance - $points_to_deduct );

		if ( $auto_update ) {
			$this->update();
		}
	}

	/**
	 * Get total number of referrals.
	 *
	 * @since 1.0.0
	 *
	 * @return int
	 */
	public function get_total_referral_count(): int {
		return $this->total_referrals;
	}

	/**
	 * Get number of pending referrals.
	 *
	 * @since 1.0.0
	 *
	 * @return int
	 */
	public function get_pending_referral_count(): int {
		$referrals = get_referrals(
			[
				'advocate_id' => $this->id,
				'status'      => 'pending',
			]
		);

		return count( $referrals );
	}

	/**
	 * Deletes the member, its associated metadata, and related data like referrals.
	 *
	 * Overrides the parent delete method to include deletion of associated data.
	 *
	 * @since 1.0.0
	 *
	 * @return bool True on success, false on failure.
	 * @throws InvalidArgumentException If the object ID is not set.
	 * @throws Exception If deletion of member, metadata, or associated data fails.
	 */
	public function delete(): bool {
		if ( empty( $this->id ) ) {
			throw new InvalidArgumentException( esc_html__( 'Object ID is required for deletion.', 'rewardswp' ) );
		}

		// Delete associated referrals first.
		delete_referrals_by_member_id( $this->id );

		// Delete associated rewards.
		delete_rewards_by_member_id( $this->id );

		// Delete associated points.
		delete_points_by_member_id( $this->id );

		// Call parent's delete method to delete the main record and its metadata.
		return parent::delete();
	}
}
