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

use AffWP\Admin\List_Table;

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

#[\AllowDynamicProperties]

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

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

	/**
	 * Total number of payouts found.
	 *
	 * @access public
	 * @since  1.9
	 * @var    int
	 */
	public $total_count;

	/**
	 * Number of 'processing' payouts found.
	 *
	 * @access public
	 * @since  2.4
	 * @var    int
	 */
	public $processing_count;

	/**
	 * Number of 'paid' payouts found.
	 *
	 * @access public
	 * @since  1.9
	 * @var    string
	 */
	public $paid_count;

	/**
	 * Number of 'failed' payouts found
	 *
	 * @access public
	 * @since  1.9
	 * @var    string
	 */
	public $failed_count;

	/**
	 * Payouts table constructor.
	 *
	 * @access public
	 * @since  1.9
	 *
	 * @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' => 'payout',
				'plural'   => 'payouts',
			]
		);

		parent::__construct( $args );

		/*
		 * Scope everything (list rows + status-tab counts) to a batch when the
		 * URL carries `?batch_id=X`. Caller can also pre-set this via
		 * `query_args` (e.g. the Batches tab's detail view), which wins.
		 */
		if ( empty( $this->query_args['batch_id'] ) && ! empty( $_GET['batch_id'] ) ) {
			$this->query_args['batch_id'] = absint( $_GET['batch_id'] );
		}

		/*
		 * Collapse retry chains: hide payouts superseded by a later retry so the
		 * list shows the current payout per chain, not every attempt. The
		 * superseded attempts are reachable from the current payout's drawer
		 * ("Replaced by #N"), so there's no list-level reveal. Both the rows and
		 * the status-tab counts read $this->query_args, keeping them consistent.
		 * A caller that explicitly set hide_superseded (e.g. an export) wins.
		 */
		if ( ! isset( $this->query_args['hide_superseded'] ) ) {
			$this->query_args['hide_superseded'] = true;
		}

		$this->get_payout_counts();
	}

	/**
	 * Displays the search field.
	 *
	 * @access public
	 * @since  1.9
	 *
	 * @param string $text     Label for the search box.
	 * @param string $input_id ID of the search box.
	 */
	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
	}

	/**
	 * Retrieves the payout view types.
	 *
	 * @access public
	 * @since  1.9
	 *
	 * @return array $views All the views available.
	 */
	public function get_views() {
		$base             = affwp_admin_url( 'payouts' );
		$current          = isset( $_GET['status'] ) ? $_GET['status'] : '';
		$total_count      = '&nbsp;<span class="count">(' . $this->total_count . ')</span>';
		$processing_count = '&nbsp;<span class="count">(' . $this->processing_count . ')</span>';
		$paid_count       = '&nbsp;<span class="count">(' . $this->paid_count . ')</span>';
		$failed_count     = '&nbsp;<span class="count">(' . $this->failed_count . ')</span>';

		$views = [
			'all'        => sprintf( '<a href="%s"%s>%s</a>', esc_url( remove_query_arg( 'status', $base ) ), $current === 'all' || $current == '' ? ' class="current"' : '', _x( 'All', 'payouts', 'affiliate-wp' ) . $total_count ),
			'processing' => sprintf( '<a href="%s"%s>%s</a>', esc_url( add_query_arg( 'status', 'processing', $base ) ), $current === 'processing' ? ' class="current"' : '', __( 'Processing', 'affiliate-wp' ) . $processing_count ),
			'paid'       => sprintf( '<a href="%s"%s>%s</a>', esc_url( add_query_arg( 'status', 'paid', $base ) ), $current === 'paid' ? ' class="current"' : '', __( 'Paid', 'affiliate-wp' ) . $paid_count ),
			'failed'     => sprintf( '<a href="%s"%s>%s</a>', esc_url( add_query_arg( 'status', 'failed', $base ) ), $current === 'failed' ? ' class="current"' : '', __( 'Failed', 'affiliate-wp' ) . $failed_count ),
		];

		return $views;
	}

	/**
	 * Retrieves the payouts table columns.
	 *
	 * @access public
	 * @since  1.9
	 *
	 * @return array $columns Array of all the payouts list table columns.
	 */
	public function get_columns() {
		/*
		 * Columns follow the scan order shared with the Batches table:
		 * identity, subject, money, state, method, detail, when. Affiliate
		 * (the human anchor the admin scans for) sits right after the
		 * Payout ID reference; Amount and Status stay adjacent so the
		 * money + state pairing reads together, while remaining independent
		 * columns so Amount can right-align with tabular-nums and a
		 * large-currency row (VND, IDR, KRW) can't widen Amount for every
		 * row beneath it.
		 *
		 * `owner` / "Generated By" stays in the set so it appears as a
		 * Screen Options toggle, but is hidden by default via a
		 * `default_hidden_columns` filter (see screen-options.php).
		 * Signal-to-noise is poor — most stores have one admin, so 95%+
		 * of rows share the same value and the data is already in the
		 * payout drawer for the rare audit case. Admins who need the
		 * column can re-enable it via Screen Options.
		 */
		$columns = [
			'cb'              => '<input type="checkbox" />',
			'payout_id'       => __( 'Payout ID', 'affiliate-wp' ),
			'affiliate'       => __( 'Affiliate', 'affiliate-wp' ),
			'amount'          => _x( 'Amount', 'payout', 'affiliate-wp' ),
			'status'          => __( 'Status', 'affiliate-wp' ),
			'payout_method'   => __( 'Payout Method', 'affiliate-wp' ),
			'referrals'       => __( 'Referrals', 'affiliate-wp' ),
			'owner'           => __( 'Generated By', 'affiliate-wp' ),
			'date'            => _x( 'Date', 'payout', 'affiliate-wp' ),
			'actions'         => '<span class="screen-reader-text">' . esc_html__( 'Actions', 'affiliate-wp' ) . '</span>',
		];

		/**
		 * Filters the payouts list table columns.
		 *
		 * @since 1.9
		 *
		 * @param array $columns List table columns.
		 */
		return apply_filters( 'affwp_payout_table_columns', $this->prepare_columns( $columns ) );
	}

	/**
	 * Retrieves the payouts table's sortable columns.
	 *
	 * @access public
	 * @since  1.9
	 *
	 * @return array Array of all the sortable columns
	 */
	public function get_sortable_columns() {
		$columns = [
			'payout_id'     => [ 'payout_id', false ],
			'amount'        => [ 'amount', false ],
			'status'        => [ 'status', false ],
			'affiliate'     => [ 'affiliate', false ],
			'payout_method' => [ 'payout_method', false ],
			'date'          => [ 'date', false ],
		];

		/**
		 * Filters the payouts list table sortable columns.
		 *
		 * @since 1.9
		 *
		 * @param array                $columns          The sortable columns for this list table.
		 * @param \AffWP_Payouts_Table $this             List table instance.
		 */
		return apply_filters( 'affwp_payout_table_sortable_columns', $columns, $this );
	}

	/**
	 * Renders the checkbox column.
	 *
	 * @access public
	 * @since  1.9
	 *
	 * @param \AffWP\Affiliate\Payout $payout Current payout object.
	 * @return string Checkbox markup.
	 */
	function column_cb( $payout ) {
		return '<input type="checkbox" name="payout_id[]" value="' . absint( $payout->ID ) . '" />';
	}

	/**
	 * Renders the 'Payout ID' column
	 *
	 * @access public
	 * @since  1.9
	 *
	 * @param \AffWP\Affiliate\Payout $payout Current payout object.
	 * @return string Payout ID.
	 */
	public function column_payout_id( $payout ) {
		/*
		 * Link the ID itself to open the drawer — matches the batch-ID convention
		 * on the Batches list and every modern admin (Stripe, Linear, Notion).
		 * Using `action=view_payout` picks up the existing click intercept in
		 * affwp-payout-drawer.js (no navigation — the drawer opens). If JS is
		 * disabled or the intercept misses, the same URL falls through to the
		 * classic view_payout render, so the link is never broken.
		 */
		$url = affwp_admin_url(
			'payouts',
			array(
				'action'    => 'view_payout',
				'payout_id' => (int) $payout->ID,
			)
		);

		$value = sprintf(
			'<a href="%s" class="row-title">%s</a>',
			esc_url( $url ),
			esc_html( $payout->ID )
		);

		/**
		 * Filters the value of the 'Payout ID' column in the payouts list table.
		 *
		 * @since 1.9
		 * @since 2.33.0 Value now includes an <a> wrapper linking to the payout drawer.
		 *
		 * @param string                  $value  Payout ID markup.
		 * @param \AffWP\Affiliate\Payout $payout Current payout object.
		 */
		return apply_filters( 'affwp_payout_table_payout_id', $value, $payout );
	}

	/**
	 * Renders the 'Amount' column.
	 *
	 * @access public
	 * @since  1.9
	 *
	 * @param \AffWP\Affiliate\Payout $payout Current payout object.
	 * @return string Payout ID.
	 */
	public function column_amount( $payout ) {
		// affwp_currency_filter() already returns entity-encoded HTML; esc_html() would re-escape the `&` in symbols like `&#36;`.
		$value = sprintf(
			'<span class="block! min-[783px]:text-right! tabular-nums!">%s</span>',
			affwp_currency_filter( affwp_format_amount( $payout->amount ) )
		);

		/**
		 * Filters the value of the 'Amount' column in the payouts list table.
		 *
		 * @since 1.9
		 *
		 * @param string                  $value  Formatted payout amount.
		 * @param \AffWP\Affiliate\Payout $payout Current payout object.
		 */
		return apply_filters( 'affwp_payout_table_amount', $value, $payout );
	}

	/**
	 * Renders the 'Affiliate' column.
	 *
	 * @access public
	 * @since  1.9
	 *
	 * @param \AffWP\Affiliate\Payout $payout Current payout object.
	 * @return string Linked affiliate name and ID.
	 */
	function column_affiliate( $payout ) {
		$url = affwp_admin_url(
			'affiliates',
			[
				'action'       => 'view_affiliate',
				'affiliate_id' => $payout->affiliate_id,
			]
		);

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

		if ( $affiliate && $name ) {
			$value = sprintf(
				'<a href="%1$s">%2$s</a> (ID: %3$s)',
				esc_url( $url ),
				esc_html( $name ),
				esc_html( $affiliate->ID )
			);
		} else {
			$value = __( '(user deleted)', 'affiliate-wp' );
		}

		/**
		 * Filters the value of the 'Affiliate' column in the payouts list table.
		 *
		 * @since 1.9
		 *
		 * @param mixed                   $value  Column value.
		 * @param \AffWP\Affiliate\Payout $payout Current payout object.
		 */
		return apply_filters( 'affwp_payout_table_affiliate', $value, $payout );
	}

	/**
	 * Renders the 'Referrals' column.
	 *
	 * @access public
	 * @since  1.9
	 *
	 * @param \AffWP\Affiliate\Payout $payout Current payout object.
	 * @return string Linked affiliate name and ID.
	 */
	public function column_referrals( $payout ) {
		$referrals = affiliate_wp()->affiliates->payouts->get_referral_ids( $payout );
		$links     = [];

		foreach ( $referrals as $referral_id ) {
			$links[] = affwp_admin_link( 'referrals', esc_html( $referral_id ), [ 'action' => 'edit_referral', 'referral_id' => $referral_id ] );
		}

		/*
		 * Preview threshold: show first N IDs inline, fold the remainder
		 * behind an Alpine toggle. The payout drawer (opened via the
		 * Payout ID click) carries the full, richer referral list so
		 * this cell is a glance-only preview, not a full index. Keeping
		 * the threshold low (3) means only genuinely noisy rows shrink;
		 * the common case (≤3 referrals) renders identically to before.
		 */
		$threshold = 3;
		$total     = count( $links );

		if ( 0 === $total ) {
			$value = '';
		} elseif ( $total <= $threshold ) {
			$value = implode( ', ', $links );
		} else {
			$visible_links = array_slice( $links, 0, $threshold );
			$hidden_links  = array_slice( $links, $threshold );
			$more_count    = count( $hidden_links );

			/*
			 * Toggle styling rationale: grey (not blue) so it reads as a
			 * UI affordance distinct from the referral-ID links it sits
			 * next to. All-blue made the toggle look like a 4th ID. A
			 * middle dot `·` separates the IDs from the toggle, giving
			 * the eye a clear visual break without adding chrome.
			 *
			 * `affwp-ui` scoped-preflight is applied to the button only
			 * so the <a> referral links outside the button keep their
			 * default WP admin blue; preflight inside the wrapper would
			 * reset anchor colour to inherit.
			 */
			$toggle_classes = 'affwp-ui cursor-pointer border-0 bg-transparent p-0 text-gray-500 hover:text-gray-700 hover:underline underline-offset-2 focus:outline-none focus:ring-2 focus:ring-affwp-brand-500 rounded-sm';
			$separator      = '<span class="text-gray-400 mx-1.5" aria-hidden="true">·</span>';
			$expand_id      = 'affwp-payout-refs-' . (int) $payout->ID;

			/*
			 * Expansion is a block below the preview line (not inline),
			 * so long lists wrap naturally instead of forcing the row
			 * to grow horizontally. `x-collapse` animates the height
			 * transition smoothly — avoids the snap-open/snap-shut
			 * jank that plain `x-show` gives on a list cell.
			 *
			 * `w-0 min-w-full` keeps the expanded block out of the
			 * table-auto column sizing (intrinsic width 0), so opening
			 * it never widens the column; `whitespace-normal` undoes the
			 * scroller's td nowrap so the IDs wrap at the width the
			 * preview line already earned.
			 */
			$value = sprintf(
				'<div x-data="{ expanded: false }">'
				. '<span>%1$s</span>'
				. '<span x-show="!expanded">'
					. '%2$s'
					. '<button type="button" @click="expanded = true" :aria-expanded="expanded.toString()" aria-controls="%3$s" class="%4$s">+ %5$d more</button>'
				. '</span>'
				. '<div id="%3$s" x-show="expanded" x-collapse x-cloak class="mt-1 w-0 min-w-full whitespace-normal">'
					. '<span>%6$s</span>'
					. '%2$s'
					. '<button type="button" @click="expanded = false" :aria-expanded="expanded.toString()" aria-controls="%3$s" class="%4$s">%7$s</button>'
				. '</div>'
				. '</div>',
				implode( ', ', $visible_links ),
				$separator,
				esc_attr( $expand_id ),
				esc_attr( $toggle_classes ),
				(int) $more_count,
				implode( ', ', $hidden_links ),
				esc_html__( 'show less', 'affiliate-wp' )
			);
		}

		/**
		 * Filters the value of the 'Referrals' column in the payouts list table.
		 *
		 * @since 1.9
		 *
		 * @param mixed                   $value  Column value.
		 * @param \AffWP\Affiliate\Payout $payout Current payout object.
		 */
		return apply_filters( 'affwp_payout_table_referrals', $value, $payout );
	}

	/**
	 * Renders the 'Generated By' column.
	 *
	 * @access public
	 * @since  1.9.5
	 *
	 * @param \AffWP\Affiliate\Payout $payout Current payout object.
	 */
	public function column_owner( $payout ) {

		// Handle for back-compat first (no owners pre-1.9.5).
		if ( 0 === $payout->owner ) {
			$value = _x( '(none)', 'payout owner', 'affiliate-wp' );
		} else {
			$user = get_user_by( 'id', $payout->owner );
			// If the owner exists, use it.
			if ( $user ) {
				$value = sprintf(
					'%1$s %2$s',
					affwp_admin_link( 'payouts', esc_html( $user->data->display_name ), [ 'owner' => $payout->owner ] ),
					/* translators: Payout owner ID */
					sprintf(
						_x( '(User ID: %d)', 'payout owner ID', 'affiliate-wp' ),
						esc_html( $payout->owner )
					)
				);
			} else {
				// Otherwise the owner doesn't exist.
				$value = __( '(user deleted)', 'affiliate-wp' );
			}
		}

		/**
		 * Filters the value of the 'Generated By' column in the payouts list table.
		 *
		 * @since 1.9.5
		 *
		 * @param string                  $value  Payout owner display name.
		 * @param \AffWP\Affiliate\Payout $payout Current payout object.
		 */
		return apply_filters( 'affwp_payout_table_owner', $value, $payout );
	}

	/**
	 * Renders the 'Payout Method' column.
	 *
	 * @access public
	 * @since  1.9
	 *
	 * @param \AffWP\Affiliate\Payout $payout Current payout object.
	 * @return string Payout method.
	 */
	public function column_payout_method( $payout ) {
		/*
		 * Route through the display-label helper so the column renders
		 * human labels ("PayPal", "Store Credit") instead of raw slugs.
		 * The Referrals list already does this via its filter path —
		 * this column previously echoed the raw enum value.
		 */
		if ( empty( $payout->payout_method ) ) {
			$value = __( '(none)', 'affiliate-wp' );
		} else {
			$value = esc_html( affwp_get_payout_method_display_label( $payout->payout_method ) );
		}

		/**
		 * Filters the value of the 'Payout Method' column in the payouts list table.
		 *
		 * @since 1.9
		 *
		 * @param string                  $value  Payout method.
		 * @param \AffWP\Affiliate\Payout $payout Current payout object.
		 */
		return apply_filters( 'affwp_payout_table_payout_method', $value, $payout );
	}

	/**
	 * Renders the 'Date' column.
	 *
	 * @access public
	 * @since  1.9
	 *
	 * @param \AffWP\Affiliate\Payout $payout Current payout object.
	 * @return string Localized payout date.
	 */
	public function column_date( $payout ) {
		$value = $payout->date_i18n();

		/**
		 * Filters the value of the 'Date' column in the payouts list table.
		 *
		 * @since 1.9
		 *
		 * @param string                  $value  Localized payout date.
		 * @param \AffWP\Affiliate\Payout $payout Current payout object.
		 */
		return apply_filters( 'affwp_payout_table_date', $value, $payout );
	}

	/**
	 * Renders the 'Actions' column.
	 *
	 * @access public
	 * @since  1.9
	 *
	 * @see WP_List_Table::row_actions()
	 *
	 * @param \AffWP\Affiliate\Payout $payout Current payout object.
	 * @return string Action links markup.
	 */
	/**
	 * Wraps only the <table> element in a horizontally-scrollable container
	 * so narrow viewports can pan instead of forcing column headers to wrap
	 * ("Referr / als", "Action / s").
	 *
	 * 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.
	 *
	 * Wrap-class notes:
	 * - `[&_table]:table-auto` overrides WP's `table-layout: fixed` so
	 *    columns size to their content instead of being equal-width.
	 * - `[&_table]:min-w-full` keeps the table at least 100% of the
	 *    container when content is narrow; lets it grow wider (and
	 *    trigger horizontal scroll) when content needs more room.
	 * - `[&_th]:whitespace-nowrap` keeps every header on one line.
	 * - `.column-amount` rules pin the Amount column to a stable
	 *    right edge so it doesn't jiggle between rows.
	 *
	 * @since 2.33.0
	 */
	public function display() {
		$singular = $this->_args['singular'];

		$this->display_tablenav( 'top' );

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

		?>
		<div x-data="affwpScrollShadow" class="relative clear-both">
			<div x-ref="scroller" class="<?php echo esc_attr( $this->get_scroller_classes( '[&_.column-amount]:w-32 [&_.column-amount_a]:flex! [&_.column-amount_a]:justify-end [&_.column-amount_a]:items-center', 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' );
	}

	/**
	 * Tags each row with `data-payout-id` so the global row-click handler in
	 * affwp-payout-drawer.js can open the drawer when the user clicks
	 * anywhere on the row outside the sticky Actions column or other
	 * interactive controls.
	 *
	 * @since 2.33.0
	 *
	 * @param \AffWP\Affiliate\Payout $payout Payout object.
	 */
	protected function get_table_classes() {
		return $this->get_unstriped_table_classes();
	}

	public function single_row( $payout ) {
		$payout_id = 0;
		if ( is_object( $payout ) ) {
			$payout_id = isset( $payout->payout_id ) ? (int) $payout->payout_id : (int) ( $payout->ID ?? 0 );
		} elseif ( is_array( $payout ) ) {
			$payout_id = isset( $payout['payout_id'] ) ? (int) $payout['payout_id'] : 0;
		}

		printf( '<tr class="group" data-payout-id="%d">', $payout_id );
		$this->single_row_columns( $payout );
		echo '</tr>';
	}

	function column_actions( $payout ) {

		/*
		 * Right-align the Actions column (header + cells) so the kebab
		 * sits on a stable right edge across every row. Without this,
		 * Retry-bearing rows are ~200px wider than View-only rows and
		 * the kebab jumps horizontally between rows — bad for queue
		 * triage. Emitted once per render via static guard so it lives
		 * next to the column it styles rather than in admin.css.
		 *
		 * Selector targets the column class directly rather than
		 * scoping under the parent form ID ("#affwp-payouts-filter").
		 * The form ID is only set on the main Payouts page; the
		 * affiliate view's "Recent Payouts" section reuses this same
		 * table inside a different form, so a form-ID-scoped selector
		 * never matched and the column fell back to left-aligned.
		 * `column-actions` is generated by the WP List Table parent
		 * from the column key, so it's specific enough on its own.
		 */
		static $style_printed = false;
		if ( ! $style_printed ) {
			echo '<style>@media (min-width:783px){th.column-actions,td.column-actions{text-align:right;}}</style>';
			$style_printed = true;
		}

		$base_query_args = [
			'page'      => 'affiliate-wp-payouts',
			'payout_id' => $payout->ID,
		];

		$view_url = add_query_arg(
			array_merge( $base_query_args, [ 'action' => 'view_payout', 'affwp_notice' => false ] ),
			admin_url( 'admin.php' )
		);

		$retry_url = '';
		if ( strtolower( $payout->status ) === 'failed' ) {
			$retry_url = wp_nonce_url(
				add_query_arg(
					array_merge( $base_query_args, [ 'affwp_action' => 'retry_payment' ] ),
					admin_url( 'admin.php' )
				),
				'payout-nonce'
			);
		}

		$delete_url = wp_nonce_url(
			add_query_arg(
				array_merge( $base_query_args, [ 'affwp_action' => 'process_delete_payout' ] ),
				admin_url( 'admin.php' )
			),
			'affwp_delete_payout_nonce'
		);

		/*
		 * All row actions live in the kebab dropdown. Whole-row click opens
		 * the drawer (see single_row + affwp-payout-drawer.js), so no
		 * always-visible View link. Retry is in the menu AND surfaces as a
		 * hover-only icon on failed rows for one-click access during queue
		 * triage.
		 */
		$row_actions = [];
		if ( $retry_url ) {
			$row_actions['retry'] = sprintf( '<a href="%s">%s</a>', esc_url( $retry_url ), esc_html__( 'Retry Payout', 'affiliate-wp' ) );
		}
		$row_actions['view'] = sprintf( '<a href="%s">%s</a>', esc_url( $view_url ), esc_html__( 'View', 'affiliate-wp' ) );
		$row_actions['delete'] = sprintf(
			'<a href="%s" class="delete">%s</a>',
			esc_url( $delete_url ),
			esc_html__( 'Delete', 'affiliate-wp' )
		);

		/**
		 * Filters the row actions for a payout in the payouts list table.
		 *
		 * All entries render inside the kebab dropdown. Entries must be
		 * `<a>` markup strings.
		 *
		 * @since 1.9
		 *
		 * @param array                   $row_actions Key => <a> markup.
		 * @param \AffWP\Affiliate\Payout $payout      Current payout object.
		 */
		$row_actions = apply_filters( 'affwp_payout_row_actions', $row_actions, $payout );

		/*
		 * Hover-only Retry icon for failed rows. Hidden by default; the
		 * `group` class on each <tr> (set in single_row) drives the
		 * `group-hover:` reveal. Click is intercepted by the standard
		 * retry-payout flow; the wrapping <a> keeps it keyboard-reachable.
		 */
		/*
		 * Hover-only Retry icon for failed rows. Inline-positioned inside
		 * the pill wrapper below. Collapses to zero width at rest so the
		 * pill is kebab-sized; on row hover the width animates open and
		 * the icon fades in. The pill itself picks up a white bg + shadow
		 * on the same hover, matching Stripe's row-action pattern.
		 */
		$inline_html = '';
		if ( $retry_url ) {
			$inline_html = $this->render_rail_action( $retry_url, 'arrow-rotate-cw', __( 'Retry payout', 'affiliate-wp' ) );
		}

		/*
		 * Render the kebab + pill via the shared helper so Payouts stays
		 * pixel-identical to Referrals/Affiliates/Creatives. The helper forces
		 * Delete last, divides it off, and tones it red. Delete is destructive
		 * (paid payouts revert their linked referrals to unpaid, easy to do by
		 * accident) so it carries a confirm, mirroring the drawer's Delete.
		 */
		return $this->render_row_actions_pill(
			'affwp-payout-actions-' . (int) $payout->ID,
			$inline_html,
			$row_actions,
			__( 'Delete this payout? Its referrals will revert to unpaid.', 'affiliate-wp' )
		);
	}

	/**
	 * Renders the 'Status' column.
	 *
	 * @access public
	 * @since  1.9
	 * @since  2.16.0 Added html to color-code the status label.
	 *
	 * @param \AffWP\Affiliate\Payout $payout Current payout object.
	 * @return string Payout status.
	 */
	public function column_status( $payout ) {
		// Use the new payout status badge helper.
		$value = affwp_get_payout_status_badge( $payout->status );

		/**
		 * Filters the value of the 'Status' column in the payouts list table.
		 *
		 * @since 1.9
		 *
		 * @param string                  $value  Payout status.
		 * @param \AffWP\Affiliate\Payout $payout Current payout object.
		 */
		return apply_filters( 'affwp_referral_table_status', $value, $payout );
	}

	/**
	 * Renders the default output for a custom column in the payouts list table.
	 *
	 * @access public
	 * @since  1.9
	 *
	 * @param \AffWP\Affiliate\Payout $payout      Current payout object.
	 * @param string                  $column_name The name of the column.
	 * @return string Column name.
	 */
	function column_default( $payout, $column_name ) {
		$value = isset( $payout->$column_name ) ? $payout->$column_name : '';

		/**
		 * Filters the value of the default column in the payouts list table.
		 *
		 * The dynamic portion of the hook name, `$column_name`, refers to the column name.
		 *
		 * @since 1.9
		 *
		 * @param mixed                   $value  Column value.
		 * @param \AffWP\Affiliate\Payout $payout Current payout object.
		 */
		return apply_filters( 'affwp_payout_table_' . $column_name, $value, $payout );
	}

	/**
	 * Message to be displayed when there are no items.
	 *
	 * @access public
	 * @since  1.9
	 */
	function no_items() {
		_e( 'No payouts found.', 'affiliate-wp' );
	}

	/**
	 * Retrieves the bulk actions.
	 *
	 * @access public
	 * @since  1.9
	 *
	 * @return array $actions Array of the bulk actions.
	 */
	public function get_bulk_actions() {
		$actions = [
			'retry_payment' => __( 'Retry Payout', 'affiliate-wp' ),
			'delete'        => __( 'Delete', 'affiliate-wp' ),
		];

		/**
		 * Filters the list of bulk actions for the payouts list table.
		 *
		 * @since 1.9
		 *
		 * @param array $actions Bulk actions.
		 */
		return apply_filters( 'affwp_payout_bulk_actions', $actions );
	}

	/**
	 * Processes the bulk actions.
	 *
	 * @access public
	 * @since  1.9
	 */
	public function process_bulk_action() {

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

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

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

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

		$ids    = array_map( 'absint', $ids );
		$action = ! empty( $_REQUEST['action'] ) ? $_REQUEST['action'] : false;

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

		foreach ( $ids as $id ) {

			if ( 'delete' === $this->current_action() ) {
				affwp_delete_payout( $id );
			}

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

	/**
	 * Retrieves the payout counts.
	 *
	 * @access public
	 * @since  1.9
	 */
	public function get_payout_counts() {
		$this->processing_count = affiliate_wp()->affiliates->payouts->count(
			array_merge( $this->query_args, [ 'status' => 'processing' ] )
		);

		$this->paid_count = affiliate_wp()->affiliates->payouts->count(
			array_merge( $this->query_args, [ 'status' => 'paid' ] )
		);

		$this->failed_count = affiliate_wp()->affiliates->payouts->count(
			array_merge( $this->query_args, [ 'status' => 'failed' ] )
		);

		$this->total_count = $this->processing_count + $this->paid_count + $this->failed_count;
	}

	/**
	 * Retrieves all the data for all the payouts.
	 *
	 * @access public
	 * @since  1.9
	 *
	 * @return array Array of all the data for the payouts.
	 */
	public function payouts_data() {

		$page    = isset( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 1;
		$owner   = isset( $_GET['owner'] ) ? absint( $_GET['owner'] ) : 0;
		$status  = isset( $_GET['status'] ) ? sanitize_key( $_GET['status'] ) : '';
		$order   = isset( $_GET['order'] ) ? sanitize_key( $_GET['order'] ) : 'DESC';
		$orderby = isset( $_GET['orderby'] ) ? sanitize_key( $_GET['orderby'] ) : 'payout_id';

		$is_search = false;

		/*
		 * `payout_id` in the URL is the drawer's deep-link param (opens the
		 * detail drawer on load). It must NOT collapse the list to a single row —
		 * bulk actions submit checkbox `payout_id[]` via POST, so filtering the
		 * list by a GET scalar serves no real feature. Leave empty here.
		 */
		$payout_ids = 0;

		if ( isset( $_GET['affiliate_id'] ) ) {
			$affiliates = sanitize_text_field( $_GET['affiliate_id'] );
		} else {
			$affiliates = 0;
		}

		/*
		 * Batch scope: `?batch_id=X` filters the list to one batch's children.
		 * Used by the Batches tab's detail view (same list-table, scoped query)
		 * and works as a generic filter anywhere else it's passed.
		 */
		$batch_id = isset( $_GET['batch_id'] ) ? absint( $_GET['batch_id'] ) : 0;

		if ( isset( $_GET['referrals'] ) ) {
			$referrals = sanitize_text_field( $_GET['referrals'] );
		} else {
			$referrals = [];
		}

		if ( ! empty( $_GET['s'] ) ) {

			$is_search = true;

			$search = sanitize_text_field( $_GET['s'] );

			if ( is_numeric( $search ) || preg_match( '/^([0-9]+\,[0-9]+)/', $search, $matches ) ) {
				// Searching for specific payouts.
				if ( ! empty( $matches[0] ) ) {
					$is_search  = false;
					$payout_ids = array_map( 'absint', explode( ',', $search ) );
				} else {
					$payout_ids = absint( $search );
				}
			} elseif ( strpos( $search, 'referrals:' ) !== false ) {
				$referrals = trim( str_replace( [ ' ', 'referrals:' ], '', $search ) );
				if ( false !== strpos( $referrals, ',' ) ) {
					$is_search = false;
					$referrals = array_map( 'absint', explode( ',', $referrals ) );
				} else {
					$referrals = absint( $referrals );
				}
			} elseif ( strpos( $search, 'affiliate:' ) !== false ) {
				$affiliates = trim( str_replace( [ ' ', 'affiliate:' ], '', $search ) );
				if ( false !== strpos( $affiliates, ',' ) ) {
					$is_search  = false;
					$affiliates = array_map( 'absint', explode( ',', $affiliates ) );
				} else {
					$affiliates = absint( $affiliates );
				}
			}
		}

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

		$args = wp_parse_args(
			$this->query_args,
			[
				'number'       => $per_page,
				'offset'       => $per_page * ( $page - 1 ),
				'payout_id'    => $payout_ids,
				'referrals'    => $referrals,
				'affiliate_id' => $affiliates,
				'batch_id'     => $batch_id,
				'owner'        => $owner,
				'status'       => $status,
				'search'       => $is_search,
				'orderby'      => $orderby,
				'order'        => $order,
			]
		);

		$payouts = affiliate_wp()->affiliates->payouts->get_payouts( $args );

		// Retrieve the "current" total count for pagination purposes.
		$args['number']      = -1;
		$this->current_count = affiliate_wp()->affiliates->payouts->count( $args );

		return $payouts;
	}

	/**
	 * Sets up the final data for the payouts list table.
	 *
	 * @access public
	 * @since  1.9
	 */
	public function prepare_items() {
		$per_page = $this->get_items_per_page( 'affwp_edit_payouts_per_page', $this->per_page );

		$this->get_column_info();

		$this->process_bulk_action();

		$data = $this->payouts_data();

		$current_page = $this->get_pagenum();

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

		switch ( $status ) {
			case 'processing':
				$total_items = $this->processing_count;
				break;
			case 'paid':
				$total_items = $this->paid_count;
				break;
			case 'failed':
				$total_items = $this->failed_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 ),
			]
		);
	}
}
