<?php
/**
 * Affiliates Admin List Table
 *
 * @package     AffiliateWP
 * @subpackage  Admin/Affiliates
 * @copyright   Copyright (c) 2014, Sandhills Development, LLC
 * @license     http://opensource.org/licenses/gpl-2.0.php GNU Public License
 * @since       1.9
 */

use AffWP\Admin\List_Table;

use function AffiliateWP\Functions\Affiliates\get_pending_affiliate_count;
use function AffiliateWP\Functions\Affiliates\get_next_pending_affiliate_id;

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

/**
 * AffWP_Affiliates_Table Class
 *
 * Renders the Affiliates table on the Affiliates page
 *
 * @since 1.0
 *
 * @see \AffWP\Admin\List_Table
 */
class AffWP_Affiliates_Table extends List_Table {

	/**
	 * Default number of items to show per page
	 *
	 * @var string
	 * @since 1.0
	 */
	public $per_page = 30;

	/**
	 * Total number of affiliates found
	 *
	 * @var int
	 * @since 1.0
	 */
	public $total_count;

	/**
	 * Number of active affiliates found
	 *
	 * @var string
	 * @since 1.0
	 */
	public $active_count;

	/**
	 *  Number of inactive affiliates found
	 *
	 * @var string
	 * @since 1.0
	 */
	public $inactive_count;

	/**
	 * Number of pending affiliates found
	 *
	 * @var string
	 * @since 1.0
	 */
	public $pending_count;

	/**
	 * Number of rejected affiliates found
	 *
	 * @var string
	 * @since 1.0
	 */
	public $rejected_count;

	/**
	 * Number of flagged affiliates found (IP velocity).
	 *
	 * @var int
	 * @since 2.31.0
	 */
	public $flagged_count = 0;

	/**
	 * Get things started
	 *
	 * @access public
	 * @since  1.0
	 *
	 * @see WP_List_Table::__construct()
	 *
	 * @param array $args Optional. Arbitrary display and query arguments to pass through
	 *                    the list table. Default empty array.
	 */
	public function __construct( $args = [] ) {
		$args = wp_parse_args(
			$args,
			[
				'singular' => 'affiliate',
				'plural'   => 'affiliates',
			]
		);

		parent::__construct( $args );

		$this->get_affiliate_counts();

		$this->load_review_with_ai_functionality();
	}

	/**
	 * Show the search field
	 *
	 * @access public
	 * @since 1.0
	 *
	 * @param string $text Label for the search box
	 * @param string $input_id ID of the search box
	 *
	 * @return void
	 */
	public function search_box( $text, $input_id ) {
		if ( empty( $_REQUEST['s'] ) && ! $this->has_items() ) {
			return;
		}

		$input_id = $input_id . '-search-input';

		if ( ! empty( $_REQUEST['orderby'] ) ) {
			echo '<input type="hidden" name="orderby" value="' . esc_attr( $_REQUEST['orderby'] ) . '" />';
		}
		if ( ! empty( $_REQUEST['order'] ) ) {
			echo '<input type="hidden" name="order" value="' . esc_attr( $_REQUEST['order'] ) . '" />';
		}
		?>
		<p class="search-box">
			<label class="screen-reader-text" for="<?php echo $input_id; ?>"><?php echo $text; ?>:</label>
			<input type="search" id="<?php echo $input_id; ?>" name="s" value="<?php _admin_search_query(); ?>" />
			<?php submit_button( $text, 'button', false, false, [ 'ID' => 'search-submit' ] ); ?>
		</p>
		<?php
	}

	/**
	 * Retrieve the view types
	 *
	 * @access public
	 * @since 1.0
	 * @return array $views All the views available
	 */
	public function get_views() {
		$base = affwp_admin_url( 'affiliates' );

		$current         = isset( $_GET['status'] ) ? $_GET['status'] : '';
		$is_flagged_view = isset( $_GET['flagged'] ) && '1' === $_GET['flagged'];
		$total_count     = '&nbsp;<span class="count">(' . $this->total_count . ')</span>';
		$active_count    = '&nbsp;<span class="count">(' . $this->active_count . ')</span>';
		$inactive_count  = '&nbsp;<span class="count">(' . $this->inactive_count . ')</span>';
		$pending_count   = '&nbsp;<span class="count">(' . $this->pending_count . ')</span>';
		$rejected_count  = '&nbsp;<span class="count">(' . $this->rejected_count . ')</span>';

		$labels = affwp_get_affiliate_statuses();

		// When viewing flagged affiliates, no status tab should be "current".
		$views = [
			'all'      => sprintf( '<a href="%s"%s>%s</a>', esc_url( remove_query_arg( [ 'status', 'flagged' ], $base ) ), ! $is_flagged_view && ( $current === 'all' || $current == '' ) ? ' class="current"' : '', __( 'All', 'affiliate-wp' ) . $total_count ),
			'active'   => sprintf( '<a href="%s"%s>%s</a>', esc_url( add_query_arg( 'status', 'active', remove_query_arg( 'flagged', $base ) ) ), ! $is_flagged_view && $current === 'active' ? ' class="current"' : '', $labels['active'] . $active_count ),
			'inactive' => sprintf( '<a href="%s"%s>%s</a>', esc_url( add_query_arg( 'status', 'inactive', remove_query_arg( 'flagged', $base ) ) ), ! $is_flagged_view && $current === 'inactive' ? ' class="current"' : '', $labels['inactive'] . $inactive_count ),
			'pending'  => sprintf( '<a href="%s"%s>%s</a>', esc_url( add_query_arg( 'status', 'pending', remove_query_arg( 'flagged', $base ) ) ), ! $is_flagged_view && $current === 'pending' ? ' class="current"' : '', $labels['pending'] . $pending_count ),
			'rejected' => sprintf( '<a href="%s"%s>%s</a>', esc_url( add_query_arg( 'status', 'rejected', remove_query_arg( 'flagged', $base ) ) ), ! $is_flagged_view && $current === 'rejected' ? ' class="current"' : '', $labels['rejected'] . $rejected_count ),
		];

		// Add Flagged view.
		$flagged_count   = '&nbsp;<span class="count">(' . $this->flagged_count . ')</span>';
		$flagged_current = $is_flagged_view ? ' class="current"' : '';
		$flagged_url     = add_query_arg( 'flagged', '1', remove_query_arg( 'status', $base ) );

		$views['flagged'] = sprintf(
			'<a href="%s"%s>%s</a>',
			esc_url( $flagged_url ),
			$flagged_current,
			__( 'Flagged', 'affiliate-wp' ) . $flagged_count
		);

		return $views;
	}

	/**
	 * Retrieve the table columns
	 *
	 * @access public
	 * @since 1.0
	 * @return array $columns Array of all the list table columns
	 */
	public function get_columns() {
		$columns = [
			'cb'              => '<input type="checkbox" />',
			'name'            => __( 'Name', 'affiliate-wp' ),
			'status'          => __( 'Status', 'affiliate-wp' ),
			'affiliate_id'    => __( 'Affiliate ID', 'affiliate-wp' ),
			'username'        => __( 'Username', 'affiliate-wp' ),
			'earnings'        => __( 'Paid Earnings', 'affiliate-wp' ),
			'unpaid_earnings' => __( 'Unpaid Earnings', 'affiliate-wp' ),
			'rate'            => __( 'Rate', 'affiliate-wp' ),
			'unpaid'          => __( 'Unpaid Referrals', 'affiliate-wp' ),
			'paid'            => __( 'Paid Referrals', 'affiliate-wp' ),
			'visits'          => __( 'Visits', 'affiliate-wp' ),
			'kyc_status'      => __( 'Identity Verification', 'affiliate-wp' ),
			'actions'         => '<span class="screen-reader-text">' . esc_html__( 'Actions', 'affiliate-wp' ) . '</span>',
		];

		if ( ! affiliate_wp()->settings->get( 'enable_payouts_service' ) ) {
			unset( $columns['kyc_status'] );
		}

		/**
		 * Filters the affiliate list table columns.
		 *
		 * @since 1.0
		 *
		 * @param array                   $prepared_columns Prepared columns.
		 * @param array                   $columns          The columns for this list table.
		 * @param \AffWP_Affiliates_Table $this             List table instance.
		 */
		return apply_filters( 'affwp_affiliate_table_columns', $this->prepare_columns( $columns ), $columns, $this );
	}

	/**
	 * Retrieve the table's sortable columns
	 *
	 * @access public
	 * @since 1.0
	 * @return array Array of all the sortable columns
	 */
	public function get_sortable_columns() {
		$columns = [
			'username'        => [ 'username', false ],
			'name'            => [ 'name', false ],
			'affiliate_id'    => [ 'affiliate_id', false ],
			'earnings'        => [ 'earnings', false ],
			'unpaid_earnings' => [ 'unpaid_earnings', false ],
			'rate'            => [ 'rate', false ],
			'unpaid'          => [ 'unpaid', false ],
			'paid'            => [ 'paid', false ],
			'visits'          => [ 'visits', false ],
			'status'          => [ 'status', false ],
		];

		/**
		 * Filters the affiliates list table sortable columns.
		 *
		 * @since 1.0
		 *
		 * @param array                   $columns The sortable columns for this list table.
		 * @param \AffWP_Affiliates_Table $this    List table instance.
		 */
		return apply_filters( 'affwp_affiliate_table_sortable_columns', $columns, $this );
	}

	/**
	 * This function renders most of the columns in the list table.
	 *
	 * @access public
	 * @since 1.0
	 *
	 * @param \AffWP\Affiliate $affiliate   The current affiliate object.
	 * @param string           $column_name The name of the column
	 * @return string The column value.
	 */
	function column_default( $affiliate, $column_name ) {
		switch ( $column_name ) {

			default:
				$value = isset( $affiliate->$column_name ) ? $affiliate->$column_name : '';
				break;
		}

		/**
		 * Filters the default value for each affiliates list table column.
		 *
		 * This dynamic filter is appended with a suffix of the column name, for example:
		 *
		 *     `affwp_affiliate_table_referrals`
		 *
		 * @param string           $value     The column data.
		 * @param \AffWP\Affiliate $affiliate The current affiliate object
		 */
		return apply_filters( 'affwp_affiliate_table_' . $column_name, $value, $affiliate );
	}

	/**
	 * Renders the "Name" column in the affiliates list table.
	 *
	 * @access public
	 * @since  1.0
	 *
	 * @param \AffWP\Affiliate $affiliate The current affiliate object.
	 * @return string Data shown in the Name column.
	 */
	public function column_name( $affiliate ) {

		$name = affiliate_wp()->affiliates->get_affiliate_name( $affiliate->affiliate_id );

		$display_name = $name ? $name : __( '(user deleted)', 'affiliate-wp' );

		// Username (or "A") seeds the initials when the affiliate has no display name.
		$fallback_initial = '';
		if ( ! $name ) {
			$user             = get_userdata( $affiliate->user_id ?? 0 );
			$fallback_initial = $user ? $user->user_login : 'A';
		}

		// The reveal-on-hover tooltip is attached client-side only to names that actually
		// clip at the column cap (affwp-list-row-actions.js measures scrollWidth). The value
		// rides in data-truncate-tooltip, which the global tooltip auto-init ignores (it binds
		// data-tooltip-html), so non-truncated names never get a redundant tooltip.
		$name_tooltip = sprintf(
			' data-truncate-tooltip="%s" data-tooltip-arrow="false" data-tooltip-interactive="false"',
			esc_attr( affwp_dark_tooltip_html( $display_name, '320px' ) )
		);

		$avatar_html = affwp_render_avatar(
			[
				'name'     => $name,
				'source'   => $affiliate->user_id ?? 0,
				'size'     => 32,
				'fallback' => $fallback_initial,
			]
		);

		/**
		 * Filters the name column data for the affiliates list table.
		 *
		 * As of 2.33.0 the row actions live in the dedicated Actions column
		 * ({@see column_actions()}), not in this cell. The `affwp_affiliate_row_actions`
		 * filter still fires there; only the markup position changed.
		 *
		 * @since 1.0
		 *
		 * @param string           $value     Data shown in the Name column.
		 * @param \AffWP\Affiliate $affiliate The current affiliate object.
		 */
		return apply_filters(
			'affwp_affiliate_table_name',
			sprintf(
				'<div class="affwp-ui"><a class="name flex items-center min-w-0 no-underline hover:no-underline" style="gap:10px" href="%2$s">%1$s<span class="truncate max-w-48"%4$s>%3$s</span></a></div>',
				$avatar_html,
				esc_url( $this->get_affiliate_edit_url( $affiliate ) ),
				esc_html( $display_name ),
				$name_tooltip
			),
			$affiliate
		);
	}

	/**
	 * Builds the URL of the edit-affiliate screen for an affiliate. Used for the
	 * Name link, the whole-row click target, and the Edit Affiliate kebab item.
	 *
	 * @since 2.33.0
	 *
	 * @param \AffWP\Affiliate $affiliate Affiliate object.
	 * @return string Edit-affiliate admin URL.
	 */
	private function get_affiliate_edit_url( $affiliate ) {
		return add_query_arg(
			[
				'page'         => 'affiliate-wp-affiliates',
				'affiliate_id' => $affiliate->ID,
				'affwp_notice' => false,
				'action'       => 'edit_affiliate',
			],
			admin_url( 'admin.php' )
		);
	}

	/**
	 * Builds the status-dependent row actions for an affiliate.
	 *
	 * @since 2.33.0
	 *
	 * @param \AffWP\Affiliate $affiliate Affiliate object.
	 * @return array Key => `<a>` markup.
	 */
	private function build_row_actions( $affiliate ) {

		$base_query_args = [
			'page'         => 'affiliate-wp-affiliates',
			'affiliate_id' => $affiliate->ID,
		];

		$username = affwp_get_affiliate_username( $affiliate );

		$row_actions = [
			'reports' => $this->get_row_action_link(
				__( 'Reports', 'affiliate-wp' ),
				[
					'page'            => 'affiliate-wp-reports',
					'tab'             => 'referral',
					'affiliate_login' => $username ? $username : __( '(user deleted)', 'affiliate-wp' ),
					'range'           => 'this_month',
				],
			),
		];

		if ( affiliate_wp()->affiliates->get_affiliate_name( $affiliate->affiliate_id ) ) {
			$row_actions['edit_user'] = $this->get_row_action_link(
				__( 'Edit User', 'affiliate-wp' ),
				[],
				[ 'base_uri' => get_edit_user_link( $affiliate->user_id ) ]
			);
		}

		$row_actions['edit_affiliate'] = sprintf(
			'<a href="%s">%s</a>',
			esc_url( $this->get_affiliate_edit_url( $affiliate ) ),
			esc_html__( 'Edit Affiliate', 'affiliate-wp' )
		);

		if ( strtolower( $affiliate->status ) === 'active' ) {

			$row_actions['deactivate'] = $this->get_row_action_link(
				__( 'Deactivate Affiliate', 'affiliate-wp' ),
				array_merge( $base_query_args, [ 'affwp_notice' => 'affiliate_deactivated', 'action' => 'deactivate' ] ),
				[ 'nonce' => 'affiliate-nonce' ]
			);

		} elseif ( strtolower( $affiliate->status ) === 'pending' ) {

			$row_actions['review'] = $this->get_row_action_link(
				__( 'Review Affiliate', 'affiliate-wp' ),
				array_merge( $base_query_args, [ 'affwp_notice' => false, 'action' => 'review_affiliate' ] ),
				[ 'nonce' => 'affiliate-nonce' ]
			);
			$row_actions['accept'] = $this->get_row_action_link(
				__( 'Accept Affiliate', 'affiliate-wp' ),
				array_merge( $base_query_args, [ 'affwp_notice' => 'affiliate_accepted', 'action' => 'accept' ] ),
				[ 'nonce' => 'affiliate-nonce' ]
			);
			$row_actions['reject'] = $this->get_row_action_link(
				__( 'Reject Affiliate', 'affiliate-wp' ),
				array_merge( $base_query_args, [ 'affwp_notice' => 'affiliate_rejected', 'action' => 'reject' ] ),
				[ 'nonce' => 'affiliate-nonce' ]
			);

		} else {

			$row_actions['activate'] = $this->get_row_action_link(
				__( 'Activate Affiliate', 'affiliate-wp' ),
				array_merge( $base_query_args, [ 'affwp_notice' => 'affiliate_activated', 'action' => 'activate' ] ),
				[ 'nonce' => 'affiliate-nonce' ]
			);
		}

		$row_actions['delete'] = $this->get_row_action_link(
			__( 'Delete Affiliate', 'affiliate-wp' ),
			array_merge( $base_query_args, [ 'affwp_notice' => false, 'action' => 'delete' ] ),
			[ 'nonce' => 'affiliate-nonce' ]
		);

		return $row_actions;
	}

	/**
	 * Renders the Actions column: a hover-revealed icon rail (Review / Accept /
	 * Reject on pending affiliates) plus a native-popover kebab with every
	 * action. Accept/Reject fire in-place AJAX; Review is a plain link. Mirrors
	 * the Payouts/Referrals pattern via the shared pill renderer.
	 *
	 * @since 2.33.0
	 *
	 * @param \AffWP\Affiliate $affiliate Affiliate object.
	 * @return string Actions cell HTML.
	 */
	public function column_actions( $affiliate ) {

		/** This filter is documented in includes/admin/affiliates/class-list-table.php (pre-2.33.0 it fired in column_name). */
		$row_actions = apply_filters( 'affwp_affiliate_row_actions', $this->build_row_actions( $affiliate ), $affiliate );

		$affiliate_id = (int) $affiliate->ID;

		// Hover rail: pending affiliates get Review plus in-place Accept/Reject; other statuses show the kebab only.
		$rail = [];
		if ( strtolower( $affiliate->status ) === 'pending' ) {
			// Fuller labels here since icon tooltips need them; the kebab keeps the short labels.
			$rail = [
				'review' => [ 'icon' => 'eye-lens', 'label' => __( 'Review affiliate', 'affiliate-wp' ) ],
				'accept' => [ 'icon' => 'check',    'label' => __( 'Accept affiliate', 'affiliate-wp' ), 'status' => 'active' ],
				'reject' => [ 'icon' => 'x-mark',   'label' => __( 'Reject affiliate', 'affiliate-wp' ), 'status' => 'rejected' ],
			];
		}

		$inline_html = '';
		foreach ( $rail as $key => $spec ) {
			if ( empty( $row_actions[ $key ] ) || ! preg_match( '/href=(["\'])(.*?)\1/i', $row_actions[ $key ], $m ) ) {
				continue;
			}

			// Accept/Reject carry AJAX hooks; Review stays a plain navigation link.
			$data_attrs = isset( $spec['status'] )
				? sprintf( ' data-affwp-ajax="affiliate" data-affwp-id="%d" data-affwp-status="%s"', $affiliate_id, esc_attr( $spec['status'] ) )
				: '';

			$inline_html .= $this->render_rail_action(
				html_entity_decode( $m[2], ENT_QUOTES ),
				$spec['icon'],
				$spec['label'],
				$data_attrs,
				'reject' === $key ? 'danger' : 'neutral'
			);
		}

		return $this->render_row_actions_pill(
			'affwp-affiliate-actions-' . $affiliate_id,
			$inline_html,
			$row_actions
		);
	}

	/**
	 * Renders the "Username" column in the affiliates list table.
	 *
	 * @access public
	 * @since  1.8
	 *
	 * @param \AffWP\Affiliate $affiliate The current affiliate object.
	 * @return string Data shown in the Username column.
	 */
	public function column_username( $affiliate ) {

		$row_actions = [];

		$username = affwp_get_affiliate_username( $affiliate );

		/**
		 * Filters the username column data for the affiliates list table.
		 *
		 * @since 1.8
		 *
		 * @param string           $username     Data shown in the Username column.
		 * @param \AffWP\Affiliate $affiliate The current affiliate object.
		 */
		return apply_filters(
			'affwp_affiliate_table_username',
			sprintf(
				'<span class="username">%1$s</span>',
				$username ? $username : __( '(user deleted)', 'affiliate-wp' )
			),
			$affiliate
		);
	}

	/**
	 * Renders the checkbox column in the affiliates list table.
	 *
	 * @access public
	 * @since  1.0
	 *
	 * @param \AffWP\Affiliate $affiliate The current affiliate object.
	 * @return string Displays a checkbox.
	 */
	function column_cb( $affiliate ) {
		return '<input type="checkbox" name="affiliate_id[]" value="' . absint( $affiliate->affiliate_id ) . '" />';
	}

	/**
	 * Renders the earnings column in the affiliates list table.
	 *
	 * @access public
	 * @since  1.0
	 *
	 * @param \AffWP\Affiliate $affiliate The current affiliate object.
	 * @return string Affiliate paid earnings link.
	 */
	function column_earnings( $affiliate ) {
		$value = sprintf(
			'<span class="block! min-[783px]:text-right! tabular-nums!">%s</span>',
			affwp_get_affiliate_earnings( $affiliate->affiliate_id, true )
		);

		/**
		 * Filters the earnings column data for the affiliates list table.
		 *
		 * @since 1.0
		 *
		 * @param string           $value     Data shown in the Earnings column.
		 * @param \AffWP\Affiliate $affiliate The current affiliate object.
		 */
		return apply_filters( 'affwp_affiliate_table_earnings', $value, $affiliate );
	}

	/**
	 * Renders the Unpaid Earnings column in the affiliates list table.
	 *
	 * @access public
	 * @since  2.0
	 *
	 * @param \AffWP\Affiliate $affiliate The current affiliate object.
	 * @return string Affiliate paid earnings link.
	 */
	function column_unpaid_earnings( $affiliate ) {
		$value = sprintf(
			'<span class="block! min-[783px]:text-right! tabular-nums!">%s</span>',
			affwp_get_affiliate_unpaid_earnings( $affiliate, true )
		);

		/**
		 * Filters the Unpaid Earnings column data for the affiliates list table.
		 *
		 * @since 2.0
		 *
		 * @param string           $value     Data shown in the Unpaid Earnings column.
		 * @param \AffWP\Affiliate $affiliate The current affiliate object.
		 */
		return apply_filters( 'affwp_affiliate_table_unpaid_earnings', $value, $affiliate );
	}

	/**
	 * Renders the "Rate" column in the affiliates list table.
	 *
	 * @access public
	 * @since  1.0
	 *
	 * @param \AffWP\Affiliate $affiliate The current affiliate object.
	 * @return string Affiliate rate.
	 */
	function column_rate( $affiliate ) {
		$value = affwp_get_affiliate_rate( $affiliate->affiliate_id, true );

		/**
		 * Filters the rate column data for the affiliates list table.
		 *
		 * @since 1.0
		 *
		 * @param string           $value     Data shown in the Rate column.
		 * @param \AffWP\Affiliate $affiliate The current affiliate object.
		 */
		return apply_filters( 'affwp_affiliate_table_rate', $value, $affiliate );
	}


	/**
	 * Renders the unpaid referrals column in the affiliates list table.
	 *
	 * @access public
	 * @since  1.7.5
	 *
	 * @param \AffWP\Affiliate $affiliate   The current affiliate object.
	 * @return string The unpaid referrals link.
	 */
	function column_unpaid( $affiliate ) {
		$unpaid_count = affiliate_wp()->referrals->unpaid_count( '', $affiliate->affiliate_id );

		$value = affwp_admin_link( 'referrals', $unpaid_count, [ 'affiliate_id' => $affiliate->affiliate_id, 'status' => 'unpaid' ] );

		/**
		 * Filters the unpaid referrals column data for the affiliates list table.
		 *
		 * @since 1.7.5
		 *
		 * @param string           $value     Data shown in the Unpaid column.
		 * @param \AffWP\Affiliate $affiliate The current affiliate object.
		 */
		return apply_filters( 'affwp_affiliate_table_unpaid', $value, $affiliate );
	}


	/**
	 * Renders the paid referrals column in the affiliates list table.
	 *
	 * @access public
	 * @since  1.0
	 * @since 2.9.5 Renamed to 'column_paid' from 'column_referrals'.
	 *
	 * @param \AffWP\Affiliate $affiliate The current affiliate object.
	 * @return string The affiliate referrals link.
	 */
	function column_paid( $affiliate ) {
		$paid_count = affiliate_wp()->referrals->paid_count( '', $affiliate->affiliate_id );

		$value = affwp_admin_link( 'referrals', $paid_count, [ 'affiliate_id' => $affiliate->affiliate_id, 'status' => 'paid' ] );

		/**
		 * Filters the paid referrals column data for the affiliates list table.
		 *
		 * @since 1.0 as 'affwp_affiliate_table_referrals'
		 * @since 2.9.5 Renamed to 'affwp_affiliate_table_paid'
		 *
		 * @param string $value     Data shown in the Paid Referrals column.
		 * @param array  $affiliate Contains all the data of the affiliate.
		 */
		return apply_filters( 'affwp_affiliate_table_paid', $value, $affiliate );
	}

	/**
	 * Renders the visits column in the affiliates list table.
	 *
	 * @access public
	 * @since  1.0
	 *
	 * @param \AffWP\Affiliate $affiliate The current affiliate object.
	 * @return string visits link
	 */
	function column_visits( $affiliate ) {
		$value = affwp_admin_link( 'visits', affwp_get_affiliate_visit_count( $affiliate->affiliate_id ), [ 'affiliate' => $affiliate->affiliate_id ] );

		/**
		 * Filters the username visits data for the affiliates list table.
		 *
		 * @since 1.0
		 *
		 * @param string           $value     Data shown in the Visits column.
		 * @param \AffWP\Affiliate $affiliate The current affiliate object.
		 */
		return apply_filters( 'affwp_affiliate_table_visits', $value, $affiliate );
	}

	/**
	 * Renders the identity verification column in the affiliates list table.
	 *
	 * @since 2.4
	 *
	 * @param \AffWP\Affiliate $affiliate The current affiliate object.
	 * @return string Visits link.
	 */
	public function column_kyc_status( $affiliate ) {

		$value                        = '';
		$badge_html                   = '';
		$payouts_service_account_meta = affwp_get_affiliate_meta( $affiliate->ID, 'payouts_service_account', true );

		if ( $payouts_service_account_meta && isset( $payouts_service_account_meta['kyc_status'] ) ) {
			$value = $payouts_service_account_meta['kyc_status'];

			// Only show a red badge when verification is required
			if ( 'required' === strtolower( $value ) ) {
				$badge_html = affwp_get_badge(
					[
						'text'    => __( 'Required', 'affiliate-wp' ),
						'variant' => 'danger',
						'size'    => 'xs',
					]
				);
			}
		}

		/**
		 * Filters the identity verification data for the affiliates list table.
		 *
		 * @since 2.4
		 *
		 * @param string           $value     Data shown in the Visits column.
		 * @param \AffWP\Affiliate $affiliate The current affiliate object.
		 */
		return apply_filters( 'affwp_affiliate_table_kyc_status', ! empty( $badge_html ) ? $badge_html : $value, $affiliate );
	}

	/**
	 * Renders the Status column in the affiliates list table.
	 *
	 * @since 2.7
	 * @since 2.16.0 Added html to color-code the status label.
	 *
	 * @param \AffWP\Affiliate $affiliate The current affiliate object.
	 * @return string Status label.
	 */
	public function column_status( $affiliate ) {
		return affwp_get_affiliate_status_badge( $affiliate->status );
	}

	/**
	 * Renders the message to be displayed when there are no affiliates.
	 *
	 * @access public
	 * @since  1.7.2
	 */
	function no_items() {
		_e( 'No affiliates found.', 'affiliate-wp' );
	}

	/**
	 * Tags each row with the `group` class (drives the hover-revealed action
	 * rail via `group-hover:`) and `data-edit-url` (whole-row click opens the
	 * edit-affiliate screen, handled by affwp-list-row-actions.js).
	 *
	 * @since 2.33.0
	 *
	 * @param \AffWP\Affiliate $affiliate Affiliate object.
	 */
	/**
	 * Drops WP core's `striped` class so rows render on a uniform white
	 * ground. Zebra striping at `#f6f7f7` collided with the whole-row hover
	 * highlight; a hairline row separator (see {@see display()}) carries the
	 * row separation instead.
	 *
	 * @since 2.33.0
	 *
	 * @return array Table classes minus `striped`.
	 */
	protected function get_table_classes() {
		return $this->get_unstriped_table_classes();
	}

	public function single_row( $affiliate ) {
		$affiliate_id = is_object( $affiliate ) ? (int) $affiliate->ID : 0;

		printf(
			'<tr id="affiliate-%1$d" class="group" data-affiliate-id="%1$d" data-edit-url="%2$s">',
			$affiliate_id,
			esc_url( $this->get_affiliate_edit_url( $affiliate ) )
		);
		$this->single_row_columns( $affiliate );
		echo '</tr>';
	}

	/**
	 * Wraps only the <table> element in a horizontally-scrollable container
	 * so narrow viewports can pan instead of forcing column headers to wrap
	 * ("Unpaid / Referrals", "Identity / Verification").
	 *
	 * The tablenavs (bulk actions, pagination, filters) deliberately sit
	 * OUTSIDE the scroll container so they stay anchored to the viewport
	 * when the user scrolls the table horizontally. Inlines the structure
	 * from \WP_List_Table::display() rather than wrapping parent::display()
	 * for that reason.
	 *
	 * The `affwpScrollShadow` Alpine component on the outer wrapper toggles
	 * edge-fade gradients depending on scroll position, hinting that there
	 * is more content off-screen.
	 *
	 * @since 2.33.0
	 */
	public function display() {
		$singular = $this->_args['singular'];

		$this->display_tablenav( 'top' );

		$this->screen->render_screen_reader_content( 'heading_list' );

		/*
		 * Keep sortable column headers on one line — WP core stacks the label
		 * and `.sorting-indicators` as block/grid boxes. See referrals display().
		 *
		 * Also freezes the checkbox + Name columns on horizontal scroll so the
		 * row's identity stays visible while the wider money/stat columns pan
		 * (mirrors the sticky Actions column on the right edge). The frozen cells
		 * are painted opaque white by Tailwind utilities on the scroller wrapper
		 * so panned content can't bleed through; only their sticky positioning
		 * lives here. Selectors target `.check-column` (WP renders the header cb
		 * as <td> and the body cb as <th scope=row>, so a tag-specific selector
		 * misses half the cells). Name's `left` reads a CSS var set by
		 * affwpScrollShadow from the cb column's real width, so it collapses to 0
		 * when no cb column is shown. Emitted here (rather than as a Tailwind
		 * `left-[var(...)]` utility) because the value is a runtime CSS variable.
		 */
		static $sortable_style_printed = false;
		if ( ! $sortable_style_printed ) {
			echo '<style>'
				. '.affwp-rowactions-scroller th.sortable a,.affwp-rowactions-scroller th.sorted a{display:flex;align-items:center;gap:4px;}'
				. '.affwp-rowactions-scroller th.column-earnings a,.affwp-rowactions-scroller th.column-unpaid_earnings a{justify-content:flex-end;}'
				. '.affwp-rowactions-scroller th.column-store_credit,.affwp-rowactions-scroller td.column-store_credit{text-align:right;}.affwp-rowactions-scroller td.column-store_credit{font-variant-numeric:tabular-nums;}'
				. '.affwp-rowactions-scroller .check-column,.affwp-rowactions-scroller .column-name{position:sticky;z-index:11;}'
				. '.affwp-rowactions-scroller .check-column{left:0;}'
				. '.affwp-rowactions-scroller .column-name{left:var(--affwp-freeze-name-left,0px);}'
				. '</style>';
			$sortable_style_printed = true;
		}

		?>
		<div x-data="affwpScrollShadow" class="relative clear-both">
			<div x-ref="scroller" data-freeze-cols="cb name" class="affwp-rowactions-scroller <?php echo esc_attr( $this->get_scroller_classes( '[&_.check-column]:bg-white [&_.column-name]:bg-white', true ) ); ?>">
				<table class="wp-list-table <?php echo esc_attr( implode( ' ', $this->get_table_classes() ) ); ?>">
					<thead>
						<tr>
							<?php $this->print_column_headers(); ?>
						</tr>
					</thead>

					<tbody id="the-list"<?php echo $singular ? " data-wp-lists='list:" . esc_attr( $singular ) . "'" : ''; ?>>
						<?php $this->display_rows_or_placeholder(); ?>
					</tbody>

					<tfoot>
						<tr>
							<?php $this->print_column_headers( false ); ?>
						</tr>
					</tfoot>
				</table>
			</div>
			<?php $this->render_scroll_shadow_overlays(); ?>
		</div>
		<?php

		$this->display_tablenav( 'bottom' );
	}

	/**
	 * Retrieve the bulk actions
	 *
	 * @access public
	 * @since 1.0
	 * @return array $actions Array of the bulk actions
	 */
	public function get_bulk_actions() {
		$actions = [
			'accept'     => __( 'Accept', 'affiliate-wp' ),
			'reject'     => __( 'Reject', 'affiliate-wp' ),
			'activate'   => __( 'Activate', 'affiliate-wp' ),
			'deactivate' => __( 'Deactivate', 'affiliate-wp' ),
			'delete'     => __( 'Delete', 'affiliate-wp' ),
		];

		/**
		 * Filters the bulk actions to return in the affiliates list table.
		 *
		 * @since 1.0
		 *
		 * @param array $actions Bulk actions.
		 */
		return apply_filters( 'affwp_affiliates_bulk_actions', $actions );
	}

	/**
	 * Process the bulk actions
	 *
	 * @access public
	 * @since 1.0
	 * @return void
	 */
	public function process_bulk_action() {

		if ( empty( $_REQUEST['_wpnonce'] ) ) {
			return;
		}

		if ( ! wp_verify_nonce( $_REQUEST['_wpnonce'], 'bulk-affiliates' ) && ! wp_verify_nonce( $_REQUEST['_wpnonce'], 'affiliate-nonce' ) ) {
			return;
		}

		$ids = isset( $_GET['affiliate_id'] ) ? $_GET['affiliate_id'] : false;

		if ( ! is_array( $ids ) ) {
			$ids = [ $ids ];
		}

		$ids = array_map( 'absint', $ids );

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

		if ( 'delete' === $this->current_action() ) {

			$redirect = affwp_admin_url(
				'affiliates',
				[
					'action'                        => 'delete',
					'affiliate_id'                  => $ids,
					'affwp_delete_affiliates_nonce' => wp_create_nonce( 'affwp_delete_affiliates_nonce' ),
				]
			);

			wp_safe_redirect( $redirect );
			exit;
		}

		$dynamic_coupons_enabled = affiliate_wp()->settings->get( 'dynamic_coupons' );

		foreach ( $ids as $id ) {

			if ( $dynamic_coupons_enabled && in_array( $this->current_action(), [ 'accept', 'activate' ] ) ) {
				$coupons = affwp_get_dynamic_affiliate_coupons( $id, false );

				if ( empty( $coupons ) ) {
					$coupon_added = affiliate_wp()->affiliates->coupons->add( [ 'affiliate_id' => $id ] );

					if ( false === $coupon_added ) {
						affiliate_wp()->utils->log( sprintf( 'Coupon could not be added for affiliate #%1$d.', $id ) );
					}
				}
			}

			if ( 'accept' === $this->current_action() ) {
				affwp_set_affiliate_status( $id, 'active' );
			}

			if ( 'reject' === $this->current_action() ) {
				affwp_set_affiliate_status( $id, 'rejected' );
			}

			if ( 'activate' === $this->current_action() ) {
				affwp_set_affiliate_status( $id, 'active' );
			}

			if ( 'deactivate' === $this->current_action() ) {
				affwp_set_affiliate_status( $id, 'inactive' );
			}

			/**
			 * Fires after an affiliate bulk action is performed.
			 *
			 * The dynamic portion of the hook name, `$this->current_action()` refers
			 * to the current bulk action being performed.
			 *
			 * @since 2.0.2
			 *
			 * @param int $id The ID of the object.
			 */
			do_action( 'affwp_affiliates_do_bulk_action_' . $this->current_action(), $id );

		}
	}

	/**
	 * Retrieve the discount code counts
	 *
	 * @access public
	 * @since 1.0
	 * @return void
	 */
	public function get_affiliate_counts() {

		$search = isset( $_GET['s'] ) ? $_GET['s'] : '';

		$this->active_count = affiliate_wp()->affiliates->count(
			array_merge(
				$this->query_args,
				[
					'status' => 'active',
					'search' => $search,
				]
			)
		);

		$this->inactive_count = affiliate_wp()->affiliates->count(
			array_merge(
				$this->query_args,
				[
					'status' => 'inactive',
					'search' => $search,
				]
			)
		);

		$this->pending_count = affiliate_wp()->affiliates->count(
			array_merge(
				$this->query_args,
				[
					'status' => 'pending',
					'search' => $search,
				]
			)
		);

		$this->rejected_count = affiliate_wp()->affiliates->count(
			array_merge(
				$this->query_args,
				[
					'status' => 'rejected',
					'search' => $search,
				]
			)
		);

		$this->total_count = $this->active_count + $this->inactive_count + $this->pending_count + $this->rejected_count;

		// Count affiliates flagged by IP velocity (stored as affiliate meta).
		global $wpdb;

		$meta_table       = affiliate_wp()->affiliate_meta->table_name;
		$affiliates_table = affiliate_wp()->affiliates->table_name;

		$this->flagged_count = (int) $wpdb->get_var(
			"SELECT COUNT(DISTINCT a.affiliate_id)
			FROM {$affiliates_table} a
			INNER JOIN {$meta_table} am ON a.affiliate_id = am.affiliate_id
			WHERE am.meta_key = 'ip_velocity_flag'
			AND am.meta_value != ''"
		);
	}

	/**
	 * Retrieve all the data for all the Affiliates
	 *
	 * @access public
	 * @since 1.0
	 * @since 2.24.1 Improved for additional security (see comments).
	 *
	 * @return array $affiliate_data Array of all the data for the Affiliates
	 */
	public function affiliate_data() {

		$page = ( isset( $_GET['paged'] ) && is_numeric( $_GET['paged'] ) )
			? absint( $_GET['paged'] )
			: 1;

		// Note: Status validated in Affiliate_WP_DB_Affiliates::get_affiliates() against all valid status'.
		$status = ( isset( $_GET['status'] ) && is_string( $_GET['status'] ) )
			? $_GET['status']
			: '';

		// Note: Escaped with %%LIKE%% in Affiliate_WP_DB_Affiliates::get_affiliates().
		$search = ( isset( $_GET['s'] ) && is_string( $_GET['s'] ) )
			? $_GET['s']
			: '';

		// Note: Sanitized in Affiliate_WP_DB_Affiliates::get_affiliates(), can only be ASC or DESC.
		$order = ( isset( $_GET['order'] ) && is_string( $_GET['order'] ) )
			? $_GET['order']
			: 'DESC';

		// Note: Sanitized in Affiliate_WP_DB_Affiliates::get_affiliates(), accepts only specific columns in the affiliates database table.
		$orderby = ( isset( $_GET['orderby'] ) && is_string( $_GET['orderby'] ) )
			? $_GET['orderby']
			: 'affiliate_id';

		$per_page = $this->get_items_per_page( 'affwp_edit_affiliates_per_page', $this->per_page );

		// When filtering by flagged, fetch IDs from affiliate meta and scope with 'include'.
		$flagged      = isset( $_GET['flagged'] ) && '1' === $_GET['flagged'];
		$flagged_ids  = [];

		if ( $flagged ) {
			global $wpdb;

			$meta_table       = affiliate_wp()->affiliate_meta->table_name;
			$affiliates_table = affiliate_wp()->affiliates->table_name;

			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names are safe.
			$flagged_ids = $wpdb->get_col(
				"SELECT DISTINCT a.affiliate_id
				FROM {$affiliates_table} a
				INNER JOIN {$meta_table} am ON a.affiliate_id = am.affiliate_id
				WHERE am.meta_key = 'ip_velocity_flag'
				AND am.meta_value != ''"
			);

			$flagged_ids = array_map( 'intval', $flagged_ids );

			// If no flagged affiliates, return empty immediately.
			if ( empty( $flagged_ids ) ) {
				$this->current_count = 0;
				return [];
			}
		}

		$args = wp_parse_args(
			$this->query_args,
			[
				'number'  => $per_page,
				'offset'  => $per_page * ( $page - 1 ),
				'status'  => $status,
				'search'  => $search,
				'orderby' => sanitize_text_field( $orderby ),
				'order'   => sanitize_text_field( $order ),
			]
		);

		// Scope to flagged affiliate IDs when the Flagged filter is active.
		if ( $flagged && ! empty( $flagged_ids ) ) {
			$args['include'] = $flagged_ids;
		}

		/**
		 * Filters the arguments used to retrieve affiliates for the Affiliates list table.
		 *
		 * @since 2.4.4
		 *
		 * @param array                   $args Arguments passed to get_affiliates() to retrieve
		 *                                      the affiliate records for display.
		 * @param \AffWP_Affiliates_Table $this Affiliates list table instance.
		 */
		$args = apply_filters( 'affwp_affiliate_table_get_affiliates', $args, $this );

		$affiliates = affiliate_wp()->affiliates->get_affiliates( $args );

		// Retrieve the "current" total count for pagination purposes.
		$args['number'] = -1;

		$this->current_count = affiliate_wp()->affiliates->count( $args );

		return $affiliates;
	}

	/**
	 * Setup the final data for the table
	 *
	 * @access public
	 * @since 1.0
	 * @uses AffWP_Affiliates_Table::get_columns()
	 * @uses AffWP_Affiliates_Table::get_sortable_columns()
	 * @uses AffWP_Affiliates_Table::process_bulk_action()
	 * @uses AffWP_Affiliates_Table::affiliate_data()
	 * @uses WP_List_Table::get_pagenum()
	 * @uses WP_List_Table::set_pagination_args()
	 * @return void
	 */
	public function prepare_items() {
		$per_page = $this->get_items_per_page( 'affwp_edit_affiliates_per_page', $this->per_page );

		$columns = $this->get_columns();

		$hidden = [];

		$sortable = $this->get_sortable_columns();

		$this->get_column_info();

		$this->process_bulk_action();

		$data = $this->affiliate_data();

		$current_page = $this->get_pagenum();

		$status  = isset( $_GET['status'] ) ? $_GET['status'] : 'any';
		$flagged = isset( $_GET['flagged'] ) && '1' === $_GET['flagged'];

		if ( $flagged ) {
			$total_items = $this->current_count;
		} else {
			switch ( $status ) {
				case 'active':
					$total_items = $this->active_count;
					break;
				case 'inactive':
					$total_items = $this->inactive_count;
					break;
				case 'pending':
					$total_items = $this->pending_count;
					break;
				case 'rejected':
					$total_items = $this->rejected_count;
					break;
				case 'any':
					$total_items = $this->current_count;
					break;
			}
		}

		$this->items = $data;

		$this->set_pagination_args(
			[
				'total_items' => $total_items,
				'per_page'    => $per_page,
				'total_pages' => ceil( $total_items / $per_page ),
			]
		);
	}
}
