<?php
/**
 * Payout Schedules List Table
 *
 * Lists Automatic Payout schedules on the Payout Schedules tab, built on the
 * shared List_Table base so it matches the Payouts and Payout Batches tables
 * (same scroller, headers, status badge, sticky Actions column + kebab, and
 * whole-row click to open the drawer).
 *
 * @package     AffiliateWP
 * @subpackage  Admin/Payouts
 * @copyright   Copyright (c) 2026, Awesome Motive, Inc
 * @license     http://opensource.org/licenses/gpl-2.0.php GNU Public License
 * @since       2.35.0
 */

use AffWP\Admin\List_Table;

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

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

/**
 * Payout Schedules list table.
 *
 * @since 2.35.0
 */
class AffWP_Payout_Schedules_Table extends List_Table {

	/**
	 * Number of schedules to show per page.
	 *
	 * @since 2.35.0
	 * @var   int
	 */
	public $per_page = 30;

	/**
	 * Total number of schedules.
	 *
	 * @since 2.35.0
	 * @var   int
	 */
	public $total_count = 0;

	/**
	 * Active schedule count.
	 *
	 * @since 2.35.0
	 * @var   int
	 */
	public $active_count = 0;

	/**
	 * Paused schedule count.
	 *
	 * @since 2.35.0
	 * @var   int
	 */
	public $paused_count = 0;

	/**
	 * Constructor.
	 *
	 * @since 2.35.0
	 *
	 * @param array $args Optional. Arbitrary display and query arguments to pass to the list table.
	 */
	public function __construct( $args = array() ) {
		$args = wp_parse_args(
			$args,
			array(
				'singular' => 'schedule',
				'plural'   => 'schedules',
			)
		);

		parent::__construct( $args );

		$this->get_schedule_counts();
	}

	/**
	 * Retrieves the schedule status counts for the view tabs.
	 *
	 * @since 2.35.0
	 */
	public function get_schedule_counts() {

		// DevTools populated-state preview: counts derive from the injected rows so the view tabs and pager match.
		$preview = $this->devtools_preview_rows();
		if ( null !== $preview ) {
			$this->active_count = count( array_filter( $preview, static function ( $s ) { return 'active' === $s->status; } ) );
			$this->total_count  = count( $preview );
			$this->paused_count = $this->total_count - $this->active_count;
			return;
		}

		$this->active_count = (int) affiliate_wp()->affiliates->payout_schedules->count( array( 'status' => 'active' ) );
		$this->paused_count = (int) affiliate_wp()->affiliates->payout_schedules->count( array( 'status' => 'paused' ) );
		$this->total_count  = $this->active_count + $this->paused_count;
	}

	/**
	 * DevTools-only seam: a populated-state preview (the Schedules_Preview tool)
	 * can inject synthetic schedule rows so the tab can be screenshotted without
	 * creating real, runnable schedules. Returns null on production and whenever
	 * no preview is active, so the normal query path is used untouched.
	 *
	 * @since 2.35.0
	 *
	 * @return \AffWP\Affiliate\PayoutSchedule[]|null Synthetic rows, or null.
	 */
	private function devtools_preview_rows() {
		if ( ! ( defined( 'AFFWP_DEVTOOLS' ) && AFFWP_DEVTOOLS ) ) {
			return null;
		}
		$rows = apply_filters( 'affwp_devtools_payout_schedule_rows', null );
		return ( is_array( $rows ) && ! empty( $rows ) ) ? $rows : null;
	}

	/**
	 * Retrieves the list table columns.
	 *
	 * @since 2.35.0
	 *
	 * @return array $columns Array of all the list table columns.
	 */
	public function get_columns() {
		$columns = array(
			'name'       => _x( 'Schedule', 'payout schedule', 'affiliate-wp' ),
			'affiliates' => __( 'Affiliates', 'affiliate-wp' ),
			'frequency'  => __( 'Frequency', 'affiliate-wp' ),
			'minimum'    => __( 'Minimum', 'affiliate-wp' ),
			'last_run'   => __( 'Last run', 'affiliate-wp' ),
			'next_run'   => __( 'Next run', 'affiliate-wp' ),
			'status'     => _x( 'Status', 'payout schedule', 'affiliate-wp' ),
			'actions'    => '<span class="screen-reader-text">' . esc_html__( 'Actions', 'affiliate-wp' ) . '</span>',
		);

		/**
		 * Filters the payout schedules list table columns.
		 *
		 * @since 2.35.0
		 *
		 * @param array $columns List table columns.
		 */
		return apply_filters( 'affwp_payout_schedules_table_columns', $this->prepare_columns( $columns ) );
	}

	/**
	 * Retrieves the sortable columns.
	 *
	 * @since 2.35.0
	 *
	 * @return array Sortable columns.
	 */
	public function get_sortable_columns() {
		return array(
			'name'     => array( 'name', false ),
			'last_run' => array( 'last_run', false ),
			'next_run' => array( 'next_run', false ),
			'status'   => array( 'status', false ),
		);
	}

	/**
	 * Retrieves the "Views" (status filter tabs).
	 *
	 * @since 2.35.0
	 *
	 * @return array Views.
	 */
	public function get_views() {
		$base    = affwp_admin_url( 'payouts', array( 'tab' => 'schedules' ) );
		$current = isset( $_GET['status'] ) ? sanitize_key( $_GET['status'] ) : '';

		$count = static function ( $n ) {
			return '&nbsp;<span class="count">(' . number_format_i18n( absint( $n ) ) . ')</span>';
		};

		$views = array(
			'all'    => sprintf(
				'<a href="%s"%s>%s</a>',
				esc_url( remove_query_arg( 'status', $base ) ),
				( '' === $current || 'all' === $current ) ? ' class="current"' : '',
				_x( 'All', 'payout schedules', 'affiliate-wp' ) . $count( $this->total_count )
			),
			'active' => sprintf(
				'<a href="%s"%s>%s</a>',
				esc_url( add_query_arg( 'status', 'active', $base ) ),
				'active' === $current ? ' class="current"' : '',
				__( 'Active', 'affiliate-wp' ) . $count( $this->active_count )
			),
			'paused' => sprintf(
				'<a href="%s"%s>%s</a>',
				esc_url( add_query_arg( 'status', 'paused', $base ) ),
				'paused' === $current ? ' class="current"' : '',
				__( 'Paused', 'affiliate-wp' ) . $count( $this->paused_count )
			),
		);

		return $views;
	}

	/**
	 * Message shown when no schedules match the current filter.
	 *
	 * @since 2.35.0
	 */
	public function no_items() {
		esc_html_e( 'No payout schedules found.', 'affiliate-wp' );
	}

	/**
	 * Use the unstriped Tailwind table treatment (clean row dividers, no WP
	 * default striping/borders) — matches the Payouts and Batches tables.
	 *
	 * @since 2.35.0
	 *
	 * @return string[]
	 */
	protected function get_table_classes() {
		return $this->get_unstriped_table_classes();
	}

	/**
	 * Renders the table using the shared scrollable-table chrome (matches the
	 * Payouts and Batches tables: scroll shadows + sticky Actions column).
	 *
	 * @since 2.35.0
	 */
	public function display() {
		$this->display_scrollable_table(
			array(
				'scroller_class' => 'affwp-schedules-scroller',
				'clickable'      => true,
				'sticky_actions' => true,
			)
		);
	}

	/**
	 * Tags each row with the schedule id + JSON payload so the drawer store can
	 * open it on a whole-row click (see affwp-schedule-drawer.js).
	 *
	 * @since 2.35.0
	 *
	 * @param \AffWP\Affiliate\PayoutSchedule $schedule Schedule object.
	 */
	public function single_row( $schedule ) {
		printf(
			'<tr class="group" data-schedule-id="%d" data-schedule="%s">',
			(int) $schedule->schedule_id,
			esc_attr( $this->row_json( $schedule ) )
		);
		$this->single_row_columns( $schedule );
		echo '</tr>';
	}

	/**
	 * Default column renderer.
	 *
	 * @since 2.35.0
	 *
	 * @param \AffWP\Affiliate\PayoutSchedule $schedule Schedule object.
	 * @param string                         $column   Column name.
	 * @return string
	 */
	public function column_default( $schedule, $column ) {
		return isset( $schedule->$column ) ? esc_html( $schedule->$column ) : '';
	}

	/**
	 * Schedule name column (primary).
	 *
	 * @since 2.35.0
	 *
	 * @param \AffWP\Affiliate\PayoutSchedule $schedule Schedule object.
	 * @return string
	 */
	public function column_name( $schedule ) {
		$url = affwp_admin_url( 'payouts', array( 'tab' => 'schedules', 'edit_schedule' => (int) $schedule->schedule_id ) );
		return '<a href="' . esc_url( $url ) . '"><strong>' . esc_html( $schedule->name ) . '</strong></a>';
	}

	/**
	 * Affiliates (scope) column.
	 *
	 * @since 2.35.0
	 *
	 * @param \AffWP\Affiliate\PayoutSchedule $schedule Schedule object.
	 * @return string
	 */
	public function column_affiliates( $schedule ) {
		return esc_html( $this->scope_label( $schedule ) );
	}

	/**
	 * Frequency (cadence) column.
	 *
	 * @since 2.35.0
	 *
	 * @param \AffWP\Affiliate\PayoutSchedule $schedule Schedule object.
	 * @return string
	 */
	public function column_frequency( $schedule ) {
		return esc_html( $this->cadence_label( $schedule ) );
	}

	/**
	 * Minimum balance column.
	 *
	 * @since 2.35.0
	 *
	 * @param \AffWP\Affiliate\PayoutSchedule $schedule Schedule object.
	 * @return string
	 */
	public function column_minimum( $schedule ) {
		return '<span class="tabular-nums">' . affwp_currency_filter( affwp_format_amount( $schedule->minimum_amount ) ) . '</span>';
	}

	/**
	 * Next run column.
	 *
	 * @since 2.35.0
	 *
	 * @param \AffWP\Affiliate\PayoutSchedule $schedule Schedule object.
	 * @return string
	 */
	public function column_next_run( $schedule ) {
		// Shared helper so the AJAX row-refresh produces identical markup (incl. the gated state).
		return affwp_payout_schedule_next_run_cell( $schedule );
	}

	/**
	 * Last run column: when the schedule last ran and how it went.
	 *
	 * The outcome badge is the trust signal — a partial/failed run means some
	 * affiliates weren't paid, which the owner must be able to notice at a glance
	 * without opening the Batches tab. Outcome comes from the most recent run log.
	 *
	 * @since 2.35.0
	 *
	 * @param \AffWP\Affiliate\PayoutSchedule $schedule Schedule object.
	 * @return string
	 */
	public function column_last_run( $schedule ) {
		// Shared renderer so the AJAX row-refresh (after Run now) produces identical markup.
		return affwp_payout_schedule_last_run_cell( $schedule );
	}

	/**
	 * Status badge column.
	 *
	 * @since 2.35.0
	 *
	 * @param \AffWP\Affiliate\PayoutSchedule $schedule Schedule object.
	 * @return string
	 */
	public function column_status( $schedule ) {
		// Shared helper so the AJAX row-refresh produces identical markup (incl. the gated state).
		return affwp_payout_schedule_status_cell( $schedule );
	}

	/**
	 * Actions column — kebab (meatball) menu, matching the Batches table.
	 *
	 * @since 2.35.0
	 *
	 * @param \AffWP\Affiliate\PayoutSchedule $schedule Schedule object.
	 * @return string
	 */
	public function column_actions( $schedule ) {
		$sid       = (int) $schedule->schedule_id;
		$nonce     = wp_create_nonce( 'affwp-payout-schedule-action' );
		$is_active = 'active' === $schedule->status;
		$actions = array();
		$actions['edit'] = sprintf(
			'<a href="%s">%s</a>',
			esc_url( affwp_admin_url( 'payouts', array( 'tab' => 'schedules', 'edit_schedule' => $sid ) ) ),
			esc_html__( 'Edit', 'affiliate-wp' )
		);

		/*
		 * Pause/Resume, Run now and Delete carry data-schedule-action so the
		 * schedule JS runs them in place (no page reload); the href stays as a
		 * no-JS fallback. Run now is always rendered but hidden while paused, so
		 * a client-side pause/resume can toggle it without a re-render. The shared
		 * pill renderer preserves these data-* hooks and the hidden class.
		 */
		$actions['toggle'] = sprintf(
			'<a href="%s" data-schedule-action="%s" data-schedule-id="%d" data-schedule-toggle-item>%s</a>',
			esc_url( affwp_admin_url( 'payouts', array( 'affwp_action' => $is_active ? 'pause_payout_schedule' : 'resume_payout_schedule', 'schedule_id' => $sid, '_wpnonce' => $nonce ) ) ),
			$is_active ? 'pause' : 'resume',
			$sid,
			esc_html( $is_active ? __( 'Pause', 'affiliate-wp' ) : __( 'Resume', 'affiliate-wp' ) )
		);
		$actions['run'] = sprintf(
			'<a href="%s"%s data-schedule-action="run" data-schedule-id="%d" data-schedule-run-item>%s</a>',
			esc_url( affwp_admin_url( 'payouts', array( 'affwp_action' => 'run_payout_schedule_now', 'schedule_id' => $sid, '_wpnonce' => $nonce ) ) ),
			$is_active ? '' : ' class="hidden"',
			$sid,
			esc_html__( 'Run now', 'affiliate-wp' )
		);
		$actions['delete'] = sprintf(
			'<a href="%s" data-schedule-action="delete" data-schedule-id="%d" data-affwp-confirm="%s">%s</a>',
			esc_url( affwp_admin_url( 'payouts', array( 'affwp_action' => 'delete_payout_schedule', 'schedule_id' => $sid, '_wpnonce' => $nonce ) ) ),
			$sid,
			esc_attr__( 'Delete this schedule? This cannot be undone.', 'affiliate-wp' ),
			esc_html__( 'Delete', 'affiliate-wp' )
		);

		/**
		 * Filters the payout schedule row actions.
		 *
		 * Each value is the `<a>` markup for a kebab item; the shared pill renderer
		 * applies the consistent item styling, forces the 'delete' key last, and
		 * divides + tones it red.
		 *
		 * @since 2.35.0
		 *
		 * @param array<string,string>           $actions  Keyed kebab menu <a> markup.
		 * @param \AffWP\Affiliate\PayoutSchedule $schedule Schedule object.
		 */
		$actions = apply_filters( 'affwp_payout_schedule_row_actions', $actions, $schedule );

		/*
		 * Reuse the shared row-actions pill (same as Referrals/Payouts/Affiliates)
		 * so the menu chrome, divider and destructive treatment can't drift. The
		 * delete's own JS confirm (data-affwp-confirm) handles confirmation, so no
		 * server-side confirm string is passed.
		 */
		return $this->render_row_actions_pill( 'affwp-schedule-actions-' . $sid, '', $actions, '' );
	}

	/**
	 * Retrieves the schedule data for the current page/filter.
	 *
	 * @since 2.35.0
	 *
	 * @return array Schedule objects.
	 */
	public function schedules_data() {

		// DevTools populated-state preview short-circuits the query with synthetic rows.
		$preview = $this->devtools_preview_rows();
		if ( null !== $preview ) {
			return $preview;
		}

		$page    = isset( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 1;
		$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'] ) : 'schedule_id';
		$search  = isset( $_REQUEST['s'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['s'] ) ) : '';

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

		return affwp_get_payout_schedules(
			array(
				'number'  => $per_page,
				'offset'  => $per_page * ( $page - 1 ),
				'status'  => ( 'all' === $status ) ? '' : $status,
				'search'  => $search,
				'order'   => $order,
				'orderby' => $orderby,
			)
		);
	}

	/**
	 * Prepares the items for display.
	 *
	 * @since 2.35.0
	 */
	public function prepare_items() {
		$per_page = $this->get_items_per_page( 'affwp_edit_payout_schedules_per_page', $this->per_page );

		$this->_column_headers = array(
			$this->get_columns(),
			array(),
			$this->get_sortable_columns(),
		);

		$status = isset( $_GET['status'] ) ? sanitize_key( $_GET['status'] ) : '';
		$search = isset( $_REQUEST['s'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['s'] ) ) : '';

		$this->items = $this->schedules_data();

		if ( '' !== $search ) {
			$total_items = (int) affiliate_wp()->affiliates->payout_schedules->count( array( 'search' => $search, 'status' => $status ) );
		} elseif ( 'active' === $status ) {
			$total_items = $this->active_count;
		} elseif ( 'paused' === $status ) {
			$total_items = $this->paused_count;
		} else {
			$total_items = $this->total_count;
		}

		$this->set_pagination_args(
			array(
				'total_items' => $total_items,
				'per_page'    => $per_page,
				'total_pages' => ceil( $total_items / max( 1, $per_page ) ),
			)
		);
	}

	/**
	 * Resolve a schedule's included affiliates + groups into {id, name} lists.
	 * The scope is a union of the two; both empty means "all eligible affiliates".
	 *
	 * @since 2.35.0
	 *
	 * @param \AffWP\Affiliate\PayoutSchedule $schedule Schedule object.
	 * @return array{affiliates:array<int,array{id:int,name:string}>,groups:array<int,array{id:int,name:string}>}
	 */
	private function scope_sets( $schedule ) {
		$aff_ids = ! empty( $schedule->affiliate_ids ) ? (array) json_decode( $schedule->affiliate_ids, true ) : array();
		$grp_ids = ! empty( $schedule->affiliate_group_ids ) ? (array) json_decode( $schedule->affiliate_group_ids, true ) : array();

		$affiliates = array();
		foreach ( array_filter( array_map( 'absint', $aff_ids ) ) as $aid ) {
			$affiliates[] = array( 'id' => $aid, 'name' => affwp_affiliate_picker_label( $aid ) );
		}
		$groups = array();
		foreach ( array_filter( array_map( 'absint', $grp_ids ) ) as $gid ) {
			$g       = affiliate_wp()->groups->get_group( $gid );
			$members = affiliate_wp()->connections->get_connected( 'affiliate', 'group', $gid );
			$groups[] = array(
				'id'              => $gid,
				'name'            => $g ? $g->get_title() : '',
				'type'            => 'group',
				'affiliate_count' => is_array( $members ) ? count( $members ) : 0,
			);
		}
		return array( 'affiliates' => $affiliates, 'groups' => $groups );
	}

	/**
	 * Plain-English scope label.
	 *
	 * @since 2.35.0
	 *
	 * @param \AffWP\Affiliate\PayoutSchedule $schedule Schedule object.
	 * @return string
	 */
	private function scope_label( $schedule ) {
		$sets = $this->scope_sets( $schedule );
		$aff  = $sets['affiliates'];
		$grp  = $sets['groups'];

		if ( empty( $aff ) && empty( $grp ) ) {
			return __( 'All affiliates', 'affiliate-wp' );
		}

		$parts = array();
		if ( ! empty( $aff ) ) {
			/* translators: %d: number of affiliates */
			$parts[] = sprintf( _n( '%d affiliate', '%d affiliates', count( $aff ), 'affiliate-wp' ), count( $aff ) );
		}
		if ( 1 === count( $grp ) ) {
			$parts[] = '' !== $grp[0]['name']
				? sprintf( /* translators: %s: affiliate group name */ __( '%s group', 'affiliate-wp' ), $grp[0]['name'] )
				: __( 'a deleted group', 'affiliate-wp' );
		} elseif ( count( $grp ) > 1 ) {
			/* translators: %d: number of groups */
			$parts[] = sprintf( _n( '%d group', '%d groups', count( $grp ), 'affiliate-wp' ), count( $grp ) );
		}

		return implode( ' + ', $parts );
	}

	/**
	 * Plain-English cadence label.
	 *
	 * @since 2.35.0
	 *
	 * @param \AffWP\Affiliate\PayoutSchedule $schedule Schedule object.
	 * @return string
	 */
	private function cadence_label( $schedule ) {
		if ( 'weekly' === $schedule->frequency ) {
			$days = array(
				__( 'Sunday', 'affiliate-wp' ),
				__( 'Monday', 'affiliate-wp' ),
				__( 'Tuesday', 'affiliate-wp' ),
				__( 'Wednesday', 'affiliate-wp' ),
				__( 'Thursday', 'affiliate-wp' ),
				__( 'Friday', 'affiliate-wp' ),
				__( 'Saturday', 'affiliate-wp' ),
			);
			$dow = (int) $schedule->frequency_day % 7;
			/* translators: %s: day of the week */
			return sprintf( __( 'Weekly on %s', 'affiliate-wp' ), $days[ $dow ] );
		}

		$day = (int) $schedule->frequency_day;
		if ( 99 === $day ) {
			return __( 'Monthly on the last day', 'affiliate-wp' );
		}

		/* translators: %s: ordinal day of the month (e.g. 1st) */
		return sprintf( __( 'Monthly on the %s', 'affiliate-wp' ), affwp_payout_schedule_ordinal( $day ) );
	}

	/**
	 * The row's JSON payload the drawer store reads to open in edit mode.
	 *
	 * @since 2.35.0
	 *
	 * @param \AffWP\Affiliate\PayoutSchedule $schedule Schedule object.
	 * @return string
	 */
	private function row_json( $schedule ) {
		$sets = $this->scope_sets( $schedule );
		return wp_json_encode(
			array(
				'id'            => (int) $schedule->schedule_id,
				'name'          => $schedule->name,
				'frequency'     => $schedule->frequency,
				'frequency_day' => (int) $schedule->frequency_day,
				'minimum'       => (float) $schedule->minimum_amount,
				'bypass'        => ( (int) $schedule->bypass_holding_period ) ? true : false,
				'status'        => $schedule->status,
				// {id,name} lists so the edit drawer's picker pre-fills its chips.
				'affiliates'    => $sets['affiliates'],
				'groups'        => $sets['groups'],
			)
		);
	}
}
