<?php
/**
 * Admin: Automatic Payout Schedule Actions
 *
 * Handlers for creating/updating a schedule (from the edit screen) and for the
 * row actions on the Automatic Payouts tab (pause, resume, run now, delete).
 * All run through the `affwp_action` dispatcher, verify a nonce + capability,
 * then redirect back to the Schedules tab with an `affwp_notice`.
 *
 * @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
 */

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

/**
 * Canonicalizes the legacy/seeded `?action=edit_schedule` URL to the Automatic
 * Payouts tab with a drawer-open hint, so the create/edit slide-over opens in
 * place (the schedule "page" is a drawer, not a standalone screen).
 *
 * - `?action=edit_schedule&schedule_id=N` → `?tab=schedules&edit_schedule=N`
 * - `?action=edit_schedule` (+ optional Save-as-schedule seed) → `?tab=schedules&new_schedule=1&…`
 *
 * Fires on admin_init (before output) so the redirect headers send cleanly.
 *
 * @since 2.35.0
 *
 * @return void
 */
function affwp_payout_schedule_edit_redirect() {

	if ( ! is_admin() ) {
		return;
	}
	if ( empty( $_GET['page'] ) || 'affiliate-wp-payouts' !== $_GET['page'] ) {
		return;
	}
	if ( empty( $_GET['action'] ) || 'edit_schedule' !== $_GET['action'] ) {
		return;
	}

	$args = array( 'tab' => 'schedules' );
	$sid  = isset( $_GET['schedule_id'] ) ? absint( $_GET['schedule_id'] ) : 0;

	if ( $sid ) {
		$args['edit_schedule'] = $sid;
	} else {
		$args['new_schedule'] = 1;
		// Carry "Save as schedule" seed criteria through to the drawer.
		if ( isset( $_GET['include_groups'] ) && is_array( $_GET['include_groups'] ) ) {
			$args['include_groups'] = array_values( array_filter( array_map( 'absint', wp_unslash( $_GET['include_groups'] ) ) ) );
		}
		if ( isset( $_GET['include_affiliates'] ) && is_array( $_GET['include_affiliates'] ) ) {
			$args['include_affiliates'] = array_values( array_filter( array_map( 'absint', wp_unslash( $_GET['include_affiliates'] ) ) ) );
		}
		if ( isset( $_GET['minimum'] ) && '' !== $_GET['minimum'] ) {
			$args['minimum'] = sanitize_text_field( wp_unslash( $_GET['minimum'] ) );
		}
	}

	wp_safe_redirect( affwp_admin_url( 'payouts', $args ) );
	exit;
}
add_action( 'admin_init', 'affwp_payout_schedule_edit_redirect' );

/**
 * Returns the Schedules tab URL, optionally with a notice key.
 *
 * @since 2.35.0
 *
 * @param string $notice Optional notice key to append.
 * @return string
 */
function affwp_payout_schedules_redirect_url( $notice = '' ) {
	$args = array( 'tab' => 'schedules' );
	if ( '' !== $notice ) {
		$args['affwp_notice'] = $notice;
	}
	return affwp_admin_url( 'payouts', $args );
}

/**
 * Resolves where to send the admin after saving a schedule.
 *
 * "Save as schedule" on the payout preview passes a `redirect_to` so the admin
 * returns to the preview (to finish paying) instead of being pulled to the
 * Schedules tab. Only internal Payouts-page URLs are honored; the "+ New
 * schedule" flow sends none and falls back to the tab. wp_safe_redirect() is a
 * second layer against off-host targets.
 *
 * @since 2.35.0
 *
 * @param array $args Query args to add (affwp_notice, affwp_new_schedule_id, …).
 * @return string
 */
function affwp_payout_schedule_save_redirect_url( $args = array() ) {

	$target    = affwp_admin_url( 'payouts', array( 'tab' => 'schedules' ) );
	$requested = isset( $_REQUEST['redirect_to'] ) ? esc_url_raw( wp_unslash( $_REQUEST['redirect_to'] ) ) : '';

	if ( $requested && false !== strpos( $requested, 'page=affiliate-wp-payouts' ) ) {
		// Drop any stale drawer/notice params before re-adding our own.
		$target = remove_query_arg( array( 'affwp_notice', 'affwp_new_schedule_id', 'new_schedule', 'edit_schedule' ), $requested );
	}

	return empty( $args ) ? $target : add_query_arg( $args, $target );
}

/**
 * Post-create confirmation message, including the next run date so the admin
 * knows when money will next move. Shown as a toast on the preview after
 * "Save as schedule".
 *
 * @since 2.35.0
 *
 * @param int $schedule_id New schedule ID.
 * @return string
 */
function affwp_payout_schedule_created_message( $schedule_id ) {

	$schedule = affwp_get_payout_schedule( $schedule_id );
	if ( ! $schedule ) {
		return __( 'Schedule created.', 'affiliate-wp' );
	}

	if ( 'active' !== $schedule->status ) {
		return __( 'Schedule created and paused. Activate it under Payout Schedules to start paying.', 'affiliate-wp' );
	}

	$has_next_run = ! empty( $schedule->next_run ) && '0000-00-00 00:00:00' !== $schedule->next_run;
	if ( ! $has_next_run ) {
		return __( 'Schedule created.', 'affiliate-wp' );
	}

	return sprintf(
		/* translators: %s: date of the next scheduled run */
		__( 'Schedule created. Next run %s.', 'affiliate-wp' ),
		// next_run is stored in UTC; wp_date() renders it in the site timezone.
		wp_date( get_option( 'date_format' ), strtotime( $schedule->next_run ) )
	);
}

/**
 * Shared guard for schedule row actions: capability + nonce.
 *
 * @since 2.35.0
 *
 * @param array $data Request payload from the affwp_action dispatcher.
 * @return int Validated schedule ID, or 0 on failure (caller should bail).
 */
function affwp_payout_schedule_action_guard( $data, $require_pro = true ) {

	if ( ! is_admin() || ! current_user_can( 'manage_payouts' ) ) {
		return 0;
	}

	// Pro-gate here (capability alone doesn't — Plus/Personal has manage_payouts); delete/pause
	// pass $require_pro = false so a downgraded user isn't trapped with schedules they can't remove.
	if ( $require_pro && ! affwp_can_access_pro_features() ) {
		return 0;
	}

	if ( empty( $data['_wpnonce'] ) || ! wp_verify_nonce( $data['_wpnonce'], 'affwp-payout-schedule-action' ) ) {
		return 0;
	}

	return isset( $data['schedule_id'] ) ? absint( $data['schedule_id'] ) : 0;
}

/**
 * Build and persist a payout schedule from a request payload.
 *
 * Shared by the native form handler {@see affwp_process_save_payout_schedule()}
 * and the AJAX handler {@see affwp_ajax_schedule_save()}. Callers verify the
 * nonce + capability first; this only parses and writes.
 *
 * @since 2.35.0
 *
 * @param array $data Raw (slashed) request payload.
 * @return array {
 *     @type int  $schedule_id The created/updated schedule ID (0 on a failed create).
 *     @type bool $is_new      Whether this was a create (true) or an update (false).
 *     @type bool $success     Whether the write succeeded.
 * }
 */
function affwp_persist_payout_schedule_from_data( $data ) {

	$schedule_id = isset( $data['schedule_id'] ) ? absint( $data['schedule_id'] ) : 0;
	$frequency   = isset( $data['frequency'] ) && 'weekly' === $data['frequency'] ? 'weekly' : 'monthly';

	// Day comes from one of two selects depending on frequency.
	if ( 'weekly' === $frequency ) {
		$frequency_day = isset( $data['frequency_day_week'] ) ? absint( $data['frequency_day_week'] ) % 7 : 5;
	} else {
		$frequency_day = isset( $data['frequency_day_month'] ) ? absint( $data['frequency_day_month'] ) : 1;
		// Clamp to a valid month day or the "last day" sentinel.
		if ( 99 !== $frequency_day ) {
			$frequency_day = max( 1, min( 28, $frequency_day ) );
		}
	}

	// Scope: a union of included affiliates + groups (matches the Pay Affiliates "Include" filter).
	// Both empty = every eligible affiliate. Stored as JSON id lists.
	$affiliate_ids = ( isset( $data['include_affiliates'] ) && is_array( $data['include_affiliates'] ) )
		? array_values( array_unique( array_filter( array_map( 'absint', wp_unslash( $data['include_affiliates'] ) ) ) ) )
		: array();
	$group_ids = ( isset( $data['include_groups'] ) && is_array( $data['include_groups'] ) )
		? array_values( array_unique( array_filter( array_map( 'absint', wp_unslash( $data['include_groups'] ) ) ) ) )
		: array();

	/*
	 * 45 chars: sized to the tightest display surface, the drawer header
	 * (~52 avg chars/line at 18px semibold), with margin for wide glyphs.
	 * Keeps the name to one line there and un-truncated everywhere else;
	 * display truncation stays as a safety net.
	 */
	$name = isset( $data['name'] ) ? mb_substr( sanitize_text_field( wp_unslash( $data['name'] ) ), 0, 45 ) : '';
	if ( '' === $name ) {
		$name = ( 'weekly' === $frequency ) ? __( 'Weekly payout', 'affiliate-wp' ) : __( 'Monthly payout', 'affiliate-wp' );
	}

	$args = array(
		'name'                  => $name,
		'frequency'             => $frequency,
		'frequency_day'         => $frequency_day,
		'affiliate_group_ids'   => ! empty( $group_ids ) ? wp_json_encode( $group_ids ) : null,
		'affiliate_ids'         => ! empty( $affiliate_ids ) ? wp_json_encode( $affiliate_ids ) : null,
		'minimum_amount'        => isset( $data['minimum_amount'] ) ? affwp_sanitize_amount( wp_unslash( $data['minimum_amount'] ) ) : 0,
		'bypass_holding_period' => empty( $data['bypass_holding_period'] ) ? 0 : 1,
	);

	if ( $schedule_id ) {
		// Status is editable only when updating an existing schedule.
		$args['status'] = ( isset( $data['status'] ) && 'paused' === $data['status'] ) ? 'paused' : 'active';
		$updated        = affwp_update_payout_schedule( $schedule_id, $args );
		return array( 'schedule_id' => $schedule_id, 'is_new' => false, 'success' => (bool) $updated );
	}

	$new_id = affwp_add_payout_schedule( $args );
	return array( 'schedule_id' => (int) $new_id, 'is_new' => true, 'success' => (bool) $new_id );
}

/**
 * Handle the native (no-JS) payout schedule form POST: persist, then redirect.
 *
 * With JS the drawer saves in place via {@see affwp_ajax_schedule_save()}; this
 * is the fallback and the target the form's action points at.
 *
 * @since 2.35.0
 *
 * @param array $data POSTed form payload.
 * @return void
 */
function affwp_process_save_payout_schedule( $data ) {

	if ( ! is_admin() || ! current_user_can( 'manage_payouts' ) ) {
		return;
	}

	// Pro-gated: block the no-JS create/update path for non-Pro plans (the drawer form is inert in the UI, but the POST target must enforce it too).
	if ( ! affwp_can_access_pro_features() ) {
		return;
	}

	if ( empty( $data['_wpnonce'] ) || ! wp_verify_nonce( $data['_wpnonce'], 'affwp-save-payout-schedule' ) ) {
		return;
	}

	$result = affwp_persist_payout_schedule_from_data( $data );

	if ( ! $result['is_new'] ) {
		$redirect_args = array( 'affwp_notice' => 'schedule_updated' );
	} else {
		$redirect_args = array( 'affwp_notice' => $result['success'] ? 'schedule_created' : 'schedule_save_failed' );
		if ( $result['success'] ) {
			// Lets the preview toast look up the new schedule's next run.
			$redirect_args['affwp_new_schedule_id'] = $result['schedule_id'];
		}
	}

	// Return to the origin (the payout preview, when opened from "Save as schedule")
	// so the admin can finish paying; falls back to the Schedules tab.
	wp_safe_redirect( affwp_payout_schedule_save_redirect_url( $redirect_args ) );
	exit;
}
add_action( 'affwp_save_payout_schedule', 'affwp_process_save_payout_schedule' );

/**
 * Pauses a schedule.
 *
 * @since 2.35.0
 *
 * @param array $data Request payload.
 * @return void
 */
function affwp_process_pause_payout_schedule( $data ) {
	$schedule_id = affwp_payout_schedule_action_guard( $data, false ); // Pausing is allowed while gated.
	if ( ! $schedule_id ) {
		return;
	}
	affwp_pause_payout_schedule( $schedule_id );
	wp_safe_redirect( affwp_payout_schedules_redirect_url( 'schedule_paused' ) );
	exit;
}
add_action( 'affwp_pause_payout_schedule', 'affwp_process_pause_payout_schedule' );

/**
 * Resumes a paused schedule.
 *
 * @since 2.35.0
 *
 * @param array $data Request payload.
 * @return void
 */
function affwp_process_resume_payout_schedule( $data ) {
	$schedule_id = affwp_payout_schedule_action_guard( $data );
	if ( ! $schedule_id ) {
		return;
	}
	affwp_resume_payout_schedule( $schedule_id );
	wp_safe_redirect( affwp_payout_schedules_redirect_url( 'schedule_resumed' ) );
	exit;
}
add_action( 'affwp_resume_payout_schedule', 'affwp_process_resume_payout_schedule' );

/**
 * Runs a schedule immediately (synchronous), producing a batch under the Batches tab.
 *
 * @since 2.35.0
 *
 * @param array $data Request payload.
 * @return void
 */
function affwp_process_run_payout_schedule_now( $data ) {
	$schedule_id = affwp_payout_schedule_action_guard( $data );
	if ( ! $schedule_id ) {
		return;
	}

	if ( class_exists( 'Affiliate_WP_Automatic_Payout_Processor' ) ) {
		// Synchronous run — the admin initiated it and waits for the redirect.
		( new Affiliate_WP_Automatic_Payout_Processor() )->process( $schedule_id );
	}

	wp_safe_redirect( affwp_payout_schedules_redirect_url( 'schedule_ran' ) );
	exit;
}
add_action( 'affwp_run_payout_schedule_now', 'affwp_process_run_payout_schedule_now' );

/**
 * Deletes a schedule and unschedules its pending Action Scheduler run.
 *
 * @since 2.35.0
 *
 * @param array $data Request payload.
 * @return void
 */
function affwp_process_delete_payout_schedule( $data ) {
	$schedule_id = affwp_payout_schedule_action_guard( $data, false ); // Deleting (cleanup) is allowed while gated.
	if ( ! $schedule_id ) {
		return;
	}

	// Cancel any pending run before removing the record.
	if ( function_exists( 'as_unschedule_action' ) ) {
		as_unschedule_action( 'affwp_run_automatic_payout', array( $schedule_id ), 'affiliatewp' );
	}

	affwp_delete_payout_schedule( $schedule_id );
	wp_safe_redirect( affwp_payout_schedules_redirect_url( 'schedule_deleted' ) );
	exit;
}
add_action( 'affwp_delete_payout_schedule', 'affwp_process_delete_payout_schedule' );
