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

namespace AffWP\Admin;

// Load WP_List_Table if not loaded
if ( ! class_exists( '\WP_List_Table' ) ) {
	require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
}

#[\AllowDynamicProperties]

/**
 * List Table base class for use in the admin.
 *
 * @since 1.9
 *
 * @see \WP_List_Table
 */
abstract class List_Table extends \WP_List_Table {

	/**
	 * Optional arguments to pass when preparing items.
	 *
	 * @access public
	 * @since  1.9
	 * @var    array
	 */
	public $query_args = array();

	/**
	 * Optional arguments to pass when preparing items for display.
	 *
	 * @access public
	 * @since  1.9
	 * @var    array
	 */
	public $display_args = array();

	/**
	 * Current screen object.
	 *
	 * @access public
	 * @since  1.9
	 * @var    \WP_Screen
	 */
	public $screen;

	/**
	 * Total item count for the current query
	 *
	 * Used for the pagination controls with non-status filtered results.
	 *
	 * @access public
	 * @since  2.1
	 * @var    int
	 */
	public $current_count;

	/**
	 * Sets up the list table instance.
	 *
	 * @access public
	 * @since  1.9
	 *
	 * @see WP_List_Table::__construct()
	 *
	 * @param array $args {
	 *     Optional. Arbitrary display and query arguments to pass through to the list table.
	 *     Default empty array.
	 *
	 *     @type string $singular    Singular version of the list table item.
	 *     @type string $plural      Plural version of the list table item.
	 *     @type array $query_args   Optional. Arguments to pass through to the query used for preparing items.
	 *                               Accepts any valid arguments accepted by the given query methods.
	 *     @type array $display_args {
	 *         Optional. Arguments to pass through for use when displaying queried items.
	 *
	 *         @type string $pre_table_callback   Callback to fire at the top of the list table, just before the list
	 *                                            table navigation is displayed. Default empty (disabled).
	 *         @type bool   $hide_table_nav       Whether to hide the entire table navigation at the top and bottom
	 *                                            of the list table. Will hide the bulk actions, extra tablenav, and
	 *                                            pagination. Use `$hide_bulk_options`, or `$hide_pagination` for more
	 *                                            fine-grained control. Default false.
	 *         @type bool   $hide_bulk_options    Whether to hide the bulk options controls at the top and bottom of
	 *                                            the list table. Default false.
	 *         @type array  $hide_pagination      Whether to hide the pagination controls at the top and bottom of the
	 *                                            list table. Default false.
	 *         @type bool   $columns_to_hide      An array of column IDs to hide for the current instance of the list
	 *                                            table. Note: other columns may be already hidden depending on current
	 *                                            user settings determined by screen options column controls. Default
	 *                                            empty array.
	 *         @type bool   $hide_column_controls Whether to hide the screen options column controls for the list table.
	 *                                            This should always be enabled when instantiating a standalone list
	 *                                            table in sub-views such as view_affiliate or view_payout due to
	 *                                            conflicts introduced in column controls generated for list tables
	 *                                            instantiated at the primary-view level. Default false.
	 *     }
	 * }
	 */
	public function __construct( $args = array() ) {
		$this->screen = get_current_screen();

		$display_args = array(
			'pre_table_callback'   => '',
			'hide_table_nav'       => false,
			'hide_bulk_options'    => false,
			'hide_pagination'      => false,
			'columns_to_hide'      => array(),
			'hide_column_controls' => false,
		);

		$query_args = ! empty( $args['query_args'] ) ? $args['query_args'] : array();
		$screen_id  = isset( $this->screen->id ) ? $this->screen->id : '';

		/**
		 * Filters the list table query args.
		 *
		 * @since 2.8
		 *
		 * @param array  $query_args Optional arguments to pass when preparing items.
		 * @param string $screen_id  Screen ID.
		 */
		$query_args = apply_filters( 'affwp_list_table_query_args', $query_args, $screen_id );

		if ( ! empty( $query_args ) ) {
			$this->query_args = $query_args;
			unset( $args['query_args'] );
		}

		if ( ! empty( $args['display_args'] ) ) {
			$this->display_args = wp_parse_args( $args['display_args'], $display_args );

			unset( $args['display_args'] );
		} else {
			$this->display_args = $display_args;
		}

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

		parent::__construct( $args );
	}

	/**
	 * Builds and retrieves the HTML markup for a row action link.
	 *
	 * @access public
	 * @since  1.9
	 * @since  1.9.3 Added an optional 'base_uri' argument to `$args` for use when adding query args.
	 *
	 * @param string $label      Row action link label.
	 * @param array  $query_args Query arguments.
	 * @param array  $args {
	 *     Optional. Additional arguments for building a row action link.
	 *
	 *     @type false|string $nonce    Whether to nonce the URL. Accepts false (disabled) or a nonce name
	 *                                  to use. Default false.
	 *     @type string       $class    Class attribute value for the link.
	 *     @type string       $base_uri Base URI to add query args to. Default is the current screen.
	 *
	 * }
	 * @return string Row action link markup.
	 */
	public function get_row_action_link( $label, $query_args, $args = array() ) {

		$base_uri = empty( $args['base_uri'] ) ? false : $args['base_uri'];

		if ( empty( $args['nonce'] ) ) {
			$url = esc_url( add_query_arg( $query_args, $base_uri ) );
		} else {
			$url = wp_nonce_url( add_query_arg( $query_args, $base_uri ), $args['nonce'] );
		}

		$class = empty( $args['class'] ) ? '' : sprintf( ' class="%s"', esc_attr( $args['class'] ) );

		return sprintf( '<a href="%1$s"%2$s>%3$s</a>', $url, $class, esc_html( $label ) );
	}

	/**
	 * Renders the unified row-action pill: a hover-revealed icon rail plus a
	 * native-popover kebab holding every action as a text item.
	 *
	 * Shared by the Payouts-style list tables (Referrals, Affiliates). The
	 * caller builds the rail markup ($inline_html) for its high-frequency
	 * actions; this method builds the kebab from $row_actions (forcing Delete
	 * last, divided + red) and wraps both in the pill. The column's sticky
	 * positioning and hover reveal come from utilities applied in display().
	 *
	 * @since 2.33.0
	 *
	 * @param string $menu_id        Unique id for the popover/kebab pairing.
	 * @param string $inline_html    Pre-rendered hover-rail icon anchors.
	 * @param array  $row_actions    Key => `<a>` markup (already filtered).
	 * @param string $delete_confirm Optional confirm() copy for the Delete item.
	 * @return string Actions cell HTML.
	 */
	/**
	 * Builds the dark tooltip markup used by the row-action rail icons. Pair
	 * with `data-tooltip-arrow="false"` on the trigger so the arrow is dropped.
	 * Inline-styled (not Tailwind utilities) so it renders correctly as tippy
	 * content appended to <body>, independent of the admin CSS build.
	 *
	 * @since 2.33.0
	 *
	 * @param string $label Tooltip label.
	 * @return string Tooltip HTML for the `data-tooltip-html` attribute.
	 */
	protected function row_action_tooltip_html( $label ) {
		return affwp_dark_tooltip_html( $label );
	}

	/**
	 * Renders a single hover-rail action icon: the collapsed-until-hover circular
	 * icon button with the shared dark, arrow-less, non-animated tooltip.
	 *
	 * Single source of truth for the rail anchor across Payouts, Referrals, and
	 * Affiliates so the tooltip treatment can't drift between screens.
	 *
	 * @since 2.33.0
	 *
	 * @param string $href       Action URL (also the no-JS fallback).
	 * @param string $icon       Icon key for {@see \AffiliateWP\Utils\Icons::get()}.
	 * @param string $label      Accessible label + tooltip text.
	 * @param string $data_attrs Optional pre-escaped extra attributes (e.g. the
	 *                           `data-affwp-ajax` hooks), with a leading space.
	 * @param string $variant    Hover treatment: 'neutral' (grey) or 'danger'
	 *                           (red tint, for destructive actions like reject).
	 * @return string Rail anchor markup.
	 */
	protected function render_rail_action( $href, $icon, $label, $data_attrs = '', $variant = 'neutral' ) {
		$hover = 'danger' === $variant
			? 'hover:text-red-700 hover:bg-red-50'
			: 'hover:text-gray-700 hover:bg-gray-50';
		return sprintf(
			'<a href="%1$s"%2$s data-tooltip-html="%3$s" data-tooltip-interactive="false" data-tooltip-arrow="false" data-tooltip-animation="false" aria-label="%4$s" class="w-0 opacity-0 pointer-events-none overflow-hidden group-hover:w-6 group-hover:opacity-100 group-hover:pointer-events-auto focus-visible:w-6 focus-visible:opacity-100 focus-visible:pointer-events-auto transition-all inline-flex items-center justify-center size-6 shrink-0 rounded-full text-gray-500 %6$s focus:outline-hidden focus-visible:ring-2 focus-visible:ring-affwp-brand-500">%5$s</a>',
			esc_url( $href ),
			$data_attrs,
			esc_attr( $this->row_action_tooltip_html( $label ) ),
			esc_attr( $label ),
			\AffiliateWP\Utils\Icons::get( $icon, array( 'class' => 'w-4 h-4', 'aria-hidden' => 'true' ) ),
			$hover
		);
	}

	/**
	 * Renders a hover-rail action as a text label rather than an icon.
	 *
	 * Used for primary, consequential actions where a word is clearer than a glyph
	 * (e.g. "Pay"). Shares the collapse-until-hover reveal of {@see render_rail_action()}
	 * but sizes to its text and sits inside the same white pill chrome.
	 *
	 * @since 2.33.0
	 *
	 * @param string $href       Action URL (also the no-JS fallback).
	 * @param string $label      Visible text + accessible label.
	 * @param string $data_attrs Optional pre-escaped extra attributes, with a leading space.
	 * @return string Rail text-action markup.
	 */
	protected function render_rail_text_action( $href, $label, $data_attrs = '' ) {
		return sprintf(
			'<a href="%1$s"%2$s aria-label="%3$s" class="max-w-0 opacity-0 pointer-events-none overflow-hidden group-hover:max-w-[120px] group-hover:opacity-100 group-hover:pointer-events-auto focus-visible:max-w-[120px] focus-visible:opacity-100 focus-visible:pointer-events-auto transition-all inline-flex items-center h-6 px-2.5 shrink-0 rounded-full text-[13px] font-medium text-gray-700 no-underline whitespace-nowrap hover:bg-gray-100 hover:text-gray-900 focus:outline-hidden focus-visible:ring-2 focus-visible:ring-affwp-brand-500">%4$s</a>',
			esc_url( $href ),
			$data_attrs,
			esc_attr( $label ),
			esc_html( $label )
		);
	}

	protected function render_row_actions_pill( $menu_id, $inline_html, array $row_actions, $delete_confirm = '' ) {

		// Delete is forced last (addons may append actions after it via the filter), divided off and toned red.
		$delete_markup = '';
		if ( isset( $row_actions['delete'] ) ) {
			$delete_markup = $row_actions['delete'];
			unset( $row_actions['delete'] );
		}

		// Parse each action once into two presentations: the desktop popover
		// menu items, and the always-visible card links shown on mobile.
		$items_html  = '';
		$mobile_html = '';
		foreach ( $row_actions as $markup ) {
			$parsed = $this->parse_row_action_link( $markup );
			if ( ! $parsed ) {
				continue;
			}
			$hidden = $parsed['hidden'] ? ' hidden' : '';
			$items_html  .= sprintf(
				'<a href="%s" role="menuitem"%s class="block w-full text-left rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 focus:outline-hidden focus-visible:bg-gray-50%s">%s</a>',
				$parsed['href'],
				$parsed['data'],
				$hidden,
				$parsed['label']
			);
			$mobile_html .= sprintf(
				'<a href="%s"%s class="block py-2 text-sm text-[#2271b1] hover:text-[#135e96] no-underline%s">%s</a>',
				$parsed['href'],
				$parsed['data'],
				$hidden,
				$parsed['label']
			);
		}

		$parsed_delete = $delete_markup ? $this->parse_row_action_link( $delete_markup ) : null;
		if ( $parsed_delete ) {
			$confirm_attr = '' !== $delete_confirm
				? sprintf( ' onclick="return confirm(%s);"', esc_attr( wp_json_encode( $delete_confirm ) ) )
				: '';
			if ( '' !== $items_html ) {
				$items_html .= '<div role="separator" class="-mx-1.5 my-1 h-px bg-gray-200"></div>';
			}
			$items_html  .= sprintf(
				'<a href="%s" role="menuitem"%s%s class="block w-full text-left rounded-md px-3 py-2 text-sm text-red-600 hover:text-red-700 hover:bg-red-50 focus:outline-hidden focus-visible:bg-red-50">%s</a>',
				$parsed_delete['href'],
				$parsed_delete['data'],
				$confirm_attr,
				$parsed_delete['label']
			);
			$mobile_html .= sprintf(
				'<a href="%s"%s%s class="block py-2 text-sm text-red-600 hover:text-red-700 no-underline">%s</a>',
				$parsed_delete['href'],
				$parsed_delete['data'],
				$confirm_attr,
				$parsed_delete['label']
			);
		}

		$kebab_html = '';
		if ( '' !== $items_html ) {
			$kebab_html = sprintf(
				'<button type="button" popovertarget="%1$s" aria-haspopup="menu" aria-expanded="false" class="cursor-pointer inline-flex items-center justify-center size-6 rounded-full border-0 bg-transparent p-0 text-gray-500 hover:text-gray-900 hover:bg-gray-100 aria-expanded:bg-gray-100 aria-expanded:text-gray-900 focus:outline-hidden focus-visible:ring-2 focus-visible:ring-affwp-brand-500 transition-colors"><span class="sr-only">%2$s</span>%3$s</button><div popover id="%1$s" role="menu" data-affwp-popover-anchor="%1$s" class="m-0 min-w-[160px] origin-top-right rounded-lg border-0! bg-white p-1.5 ring-1 ring-black/8 shadow-[0_12px_28px_-8px_rgba(0,0,0,0.18),0_4px_10px_-4px_rgba(0,0,0,0.06)]">%4$s</div>',
				esc_attr( $menu_id ),
				esc_html__( 'More actions', 'affiliate-wp' ),
				\AffiliateWP\Utils\Icons::get( 'dots-horizontal', array( 'class' => 'w-4 h-4', 'aria-hidden' => 'true' ) ),
				$items_html
			);
		}

		$pill_chrome = ' group-hover:bg-white group-hover:shadow-[0_4px_10px_-2px_rgba(0,0,0,0.08),0_2px_4px_-2px_rgba(0,0,0,0.06)] group-hover:ring-1 group-hover:ring-black/8';

		// Desktop (>=783px): absolutely-positioned hover rail + kebab popover at
		// the row's right edge. The `.affwp-rowactions-desktop`/`-mobile` hooks
		// drive the breakpoint swap from admin.css rather than Tailwind display
		// utilities, which don't reliably override a base display class here.
		$desktop = sprintf(
			'<div class="affwp-rowactions-desktop affwp-ui absolute right-2.5 top-1/2 -translate-y-1/2 inline-flex items-center gap-1 rounded-full p-0.5 transition-[background-color,box-shadow] duration-150%3$s">%1$s%2$s</div>',
			$inline_html,
			$kebab_html,
			$pill_chrome
		);

		// Mobile (<=782px): the same actions as plain, always-visible links
		// stacked in the card. The cell carries WP's "Actions" label (::before),
		// so this reads as a normal card field. Full width comes from ungating
		// the column's 48px pin and collapsing the table at this breakpoint (see
		// get_scroller_classes() + the responsive rules in admin.css).
		$mobile = '' === $mobile_html
			? ''
			: sprintf( '<div class="affwp-rowactions-mobile affwp-ui">%s</div>', $mobile_html );

		return $desktop . $mobile;
	}

	/**
	 * Parse a pre-built row-action `<a>` into the pieces the pill renderer needs.
	 *
	 * The renderer owns each item's base classes so they can't drift between
	 * tables, but it carries through any `data-*` attributes (so AJAX-driven
	 * actions survive the rebuild) and a source `hidden` class (an action hidden
	 * until a state flips client-side, e.g. "Run now" on a paused schedule).
	 *
	 * @since 2.35.0
	 *
	 * @param string $markup Row-action anchor markup.
	 * @return array|null { href, label, data (pre-escaped attr string), hidden (bool) } or null if unparseable.
	 */
	private function parse_row_action_link( $markup ) {
		if ( ! preg_match( '/<a\b([^>]*)>(.*?)<\/a>/is', $markup, $m ) ) {
			return null;
		}
		$attrs = $m[1];
		if ( ! preg_match( '/\bhref=(["\'])(.*?)\1/is', $attrs, $h ) ) {
			return null;
		}
		// Carry through data-* attributes (valued or valueless), already escaped
		// by the caller, so AJAX hooks like data-schedule-action survive.
		$data = '';
		if ( preg_match_all( '/\sdata-[\w-]+(?:=(["\']).*?\1)?/is', $attrs, $dm ) ) {
			$data = implode( '', $dm[0] );
		}
		return array(
			'href'   => esc_url( html_entity_decode( $h[2], ENT_QUOTES ) ),
			'label'  => wp_kses( $m[2], array( 'strong' => array(), 'em' => array(), 'span' => array( 'class' => true ) ) ),
			'data'   => $data,
			'hidden' => (bool) preg_match( '/\bclass=(["\'])[^"\']*\bhidden\b[^"\']*\1/is', $attrs ),
		);
	}

	/**
	 * Shared Tailwind utilities for the scroll-shadow table wrapper used by the
	 * Payouts-style list tables (Affiliates, Referrals, Payouts, Payout
	 * Batches): a stable left frame on the non-scrolling container (the table's
	 * own left border is dropped so it doesn't scroll away), hairline row
	 * dividers, a whole-row hover tint, and a sticky white Actions column.
	 *
	 * Row selectors target `tbody tr` generically rather than a per-table
	 * `data-*-id`, so the literal class strings live here once for Tailwind's
	 * source scanner to compile, instead of being rebuilt (and rescanned) per
	 * table. Tables append column-specific utilities (frozen leading columns,
	 * amount alignment, etc.) via $extra.
	 *
	 * The whole-row hover tint and `cursor-pointer` are gated behind $clickable
	 * (they signal "this row is a click target"), so read-only tables (e.g.
	 * Visits) get neither.
	 *
	 * Pair with {@see render_scroll_shadow_overlays()} inside an
	 * `x-data="affwpScrollShadow"` container, on the `x-ref="scroller"` element.
	 *
	 * @since 2.33.0
	 *
	 * @param string $extra     Additional space-separated utility classes to append.
	 * @param bool   $clickable Whether rows are whole-row click targets (adds the
	 *                          hover tint + pointer cursor). Default false.
	 * @return string Class attribute value (utility classes only).
	 */
	protected function get_scroller_classes( $extra = '', $clickable = false ) {
		$classes = 'affwp-list-scroller overflow-x-auto border-y-0 border-r-0 border-l border-solid border-l-affwp-table-border [&_table]:border-l-0! [&_table]:relative! [&_table]:table-auto! [&_table]:min-w-full [&_th]:whitespace-nowrap [&_th_a]:whitespace-nowrap [&_td]:whitespace-nowrap [&_tbody_td]:align-middle! min-[783px]:[&_.column-actions]:sticky min-[783px]:[&_.column-actions]:right-0 [&_.column-actions]:bg-white min-[783px]:[&_.column-actions]:z-10 min-[783px]:[&_.column-actions]:w-12! min-[783px]:[&_.column-actions]:min-w-12! min-[783px]:[&_.column-actions]:px-0! [&_tbody_td]:border-x-0 [&_tbody_td]:border-t-0 [&_tbody_td]:border-b [&_tbody_td]:border-solid [&_tbody_td]:border-affwp-row-divider [&_tbody_th]:border-x-0 [&_tbody_th]:border-t-0 [&_tbody_th]:border-b [&_tbody_th]:border-solid [&_tbody_th]:border-affwp-row-divider';

		if ( $clickable ) {
			// Hover tint is gated to desktop: on touch it sticks after a tap and
			// reads as a stuck selection. Cards (<=782px) get no row hover.
			$classes .= ' [&_tbody_tr]:cursor-pointer min-[783px]:[&_tbody_tr:hover_td]:bg-affwp-row-hover! min-[783px]:[&_tbody_tr:hover_th]:bg-affwp-row-hover!';
		}

		return '' !== $extra ? $classes . ' ' . $extra : $classes;
	}

	/**
	 * Emits the scroll-shadow edge overlays for the scroll-shadow table
	 * wrapper: a soft left fade at the frozen-column seam (`frozenLeftW`, which
	 * is 0 when no columns are frozen, so it sits at the container edge), a soft
	 * right fade, and a hairline right frame. Emit as siblings of the
	 * `x-ref="scroller"` element, inside the `x-data="affwpScrollShadow"`
	 * container. {@see get_scroller_classes()}.
	 *
	 * @since 2.33.0
	 *
	 * @param bool $sticky_actions Whether the table has a sticky Actions column
	 *                             (width `w-12`). When true the right fade sits at
	 *                             that column's left seam; when false it sits at
	 *                             the container edge. Default true.
	 */
	protected function render_scroll_shadow_overlays( $sticky_actions = true ) {
		$right_fade = $sticky_actions ? 'right-12' : 'right-0';
		?>
		<div x-cloak :style="'left:' + frozenLeftW + 'px;bottom:' + scrollbarH + 'px;opacity:' + ( scrollLeft ? 1 : 0 ) + ';transition:opacity .15s'" class="pointer-events-none absolute top-0 w-1.5 bg-linear-to-r from-black/8 to-transparent"></div>
		<div x-show="scrollRight" x-cloak x-transition.opacity.duration.150ms :style="'bottom:' + scrollbarH + 'px'" class="pointer-events-none absolute top-0 <?php echo esc_attr( $right_fade ); ?> w-1.5 bg-linear-to-l from-black/8 to-transparent"></div>
		<div x-show="scrollRight" x-cloak x-transition.opacity.duration.150ms :style="'bottom:' + scrollbarH + 'px'" class="pointer-events-none absolute top-0 right-0 z-20 w-px bg-affwp-table-border"></div>
		<?php
	}

	/**
	 * Returns the table's CSS classes with WP's `striped` removed, so rows
	 * render on a uniform white ground (the scroll-shadow tables use hairline
	 * dividers + a hover tint instead of zebra striping). Tables opt in by
	 * returning this from their own `get_table_classes()`.
	 *
	 * @since 2.33.0
	 *
	 * @return array
	 */
	protected function get_unstriped_table_classes() {
		return array_values( array_diff( $this->get_table_classes_with_stripes(), array( 'striped' ) ) );
	}

	/**
	 * The default AffiliateWP table classes (WP core classes + `affwp-table`),
	 * including `striped`. Split out so {@see get_unstriped_table_classes()} can
	 * reuse it without a table's own `get_table_classes()` override recursing.
	 *
	 * @since 2.33.0
	 *
	 * @return array
	 */
	private function get_table_classes_with_stripes() {
		return array_merge( parent::get_table_classes(), array( 'affwp-table' ) );
	}

	/**
	 * Renders a standard scroll-shadow list table: tablenav, the
	 * `affwpScrollShadow` wrapper around a `wp-list-table` (header/body/footer),
	 * and the edge overlays. This is the common case used by tables that don't
	 * need a bespoke `display()` (e.g. table-specific inline `<style>` or frozen
	 * leading columns); those keep their own `display()` but still share
	 * {@see get_scroller_classes()} and {@see render_scroll_shadow_overlays()}.
	 *
	 * @since 2.33.0
	 *
	 * @param array $args {
	 *     Optional.
	 *
	 *     @type string $scroller_class A static class to prefix on the scroller
	 *                                  element (e.g. `affwp-rowactions-scroller`).
	 *     @type string $scroller_extra Extra utility classes for the scroller,
	 *                                  forwarded to {@see get_scroller_classes()}.
	 *     @type bool   $clickable      Whether rows are whole-row click targets
	 *                                  (hover tint + pointer cursor). Default false.
	 *     @type bool   $sticky_actions Whether the table has a sticky Actions
	 *                                  column, so the right edge fade clears it.
	 *                                  Default false (read-only/plain tables).
	 * }
	 */
	protected function display_scrollable_table( $args = array() ) {
		$args = wp_parse_args(
			$args,
			array(
				'scroller_class' => 'affwp-rowactions-scroller',
				'scroller_extra' => '',
				'clickable'      => false,
				'sticky_actions' => false,
			)
		);

		$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 renders the label
		 * and the `.sorting-indicators` arrows as stacked block/grid boxes, so
		 * `white-space:nowrap` can't hold them together, they wrap to a second
		 * line in narrow columns (e.g. Visits' icon-only Converted column). Flex
		 * lays the label + arrows out inline. Emitted once per render via static
		 * guard.
		 */
		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-amount a{justify-content:flex-end;}</style>';
			$sortable_style_printed = true;
		}

		$scroller_class = trim( $args['scroller_class'] . ' ' . $this->get_scroller_classes( $args['scroller_extra'], $args['clickable'] ) );
		?>
		<div x-data="affwpScrollShadow" class="relative clear-both">
			<div x-ref="scroller" class="<?php echo esc_attr( $scroller_class ); ?>">
				<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( $args['sticky_actions'] ); ?>
		</div>
		<?php

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

	/**
	 * Generates the table navigation above or below the table.
	 *
	 * @access protected
	 * @since  1.9
	 *
	 * @param string $which Which location the builk actions are being rendered for.
	 *                      Will be 'top' or 'bottom'.
	 */
	protected function display_tablenav( $which ) {
		if ( 'top' === $which ) {
			wp_nonce_field( 'bulk-' . $this->_args['plural'] );
		}

		if ( ! empty( $this->display_args['pre_table_callback'] )
			&& is_callable( $this->display_args['pre_table_callback'] )
			&& 'top' === $which
		) {

			echo call_user_func( $this->display_args['pre_table_callback'] );
		}

		if ( true !== $this->display_args['hide_table_nav'] ) : ?>
			<div class="tablenav <?php echo esc_attr( $which ); ?>">

				<?php if ( $this->has_items() && true !== $this->display_args['hide_bulk_options'] ) : ?>
					<div class="alignleft actions bulkactions">
						<?php $this->bulk_actions( $which ); ?>
					</div>
				<?php endif;
				$this->extra_tablenav( $which );

				if ( true !== $this->display_args['hide_pagination'] ) :
					$this->pagination( $which );
				endif;
				?>

				<br class="clear" />
			</div>
		<?php endif;
	}

	/**
	 * Extra table navigation.
	 *
	 * Note, if you override this, and remove the filters, some functionality may
	 * be lost without them.
	 *
	 * @since 2.12.0
	 *
	 * @param string $which Either `top` or `bottom`.
	 */
	protected function extra_tablenav( $which ) {

		/**
		 * Before we show any extra tablenav.
		 *
		 * @since 2.12.0
		 *
		 * @param string $which Either `top` or `bottom`.
		 */
		do_action( "affwp_{$this->screen->id}_extra_tablenav_before", $which );

		parent::extra_tablenav( $which );

		/**
		 * After we show any extra tablenav.
		 *
		 * @since 2.12.0
		 *
		 * @param string $which Either `top` or `bottom`.
		 */
		do_action( "affwp_{$this->screen->id}_extra_tablenav_after", $which );
	}

	/**
	 * Prepares columns for display.
	 *
	 * Applies display arguments passed in the constructor to the list of columns.
	 *
	 * @access protected
	 * @since  1.9
	 *
	 * @param array $columns List of columns.
	 * @return array (Maybe) filtered list of columns.
	 */
	public function prepare_columns( $columns ) {
		if ( ! empty( $this->display_args['columns_to_hide'] ) ) {
			$columns_to_hide = $this->display_args['columns_to_hide'];

			foreach ( $columns_to_hide as $column ) {
				if ( array_key_exists( $column, $columns ) ) {
					unset( $columns[ $column ] );
				}
			}
		}

		/**
		 * Filter the columns.
		 *
		 * @since 2.12.0
		 *
		 * @param array      $columns Columns.
		 * @param \WP_Screen $screen  Current screen (for context).
		 */
		return apply_filters( 'affwp_list_table_columns', $columns, $this->screen );
	}

	/**
	 * Retrieves a list of all, hidden,sortable, and primary columns, with filters applied.
	 *
	 * Also sets up column show/hide controls.
	 *
	 * @access protected
	 * @since  1.9
	 *
	 * @return array Column headers.
	 */
	protected function get_column_info() {
		if ( true === $this->display_args['hide_column_controls'] ) {
			$columns = $this->get_columns();

			$hidden = array();

			$sortable = $this->get_sortable_columns();

			$this->_column_headers = array( $columns, $hidden, $sortable, $this->get_primary_column_name() );
		} else {
			$this->_column_headers = parent::get_column_info();
		}

		return $this->_column_headers;
	}

	/**
	 * Table Classes
	 *
	 * @since 2.21.0 Adds `affwp-table` to all tables.
	 *
	 * @return array
	 */
	protected function get_table_classes() {
		return $this->get_table_classes_with_stripes();
	}
}
