<?php
/**
 * Bulk "OG image source" actions on the core WordPress list tables
 * (Posts, Pages, and any Premium-enabled CPT).
 *
 * Lets operators drive the per-post Image Source straight from
 * `edit.php?post_type=...`, where third-party list filters (a category,
 * a tag, Page Generator Pro's generated-content filter, etc.) already
 * live. Select the rows that filter produces, pick the bulk action,
 * and the underlying `_doig_source` meta is written across the whole
 * selection in one step.
 *
 * Five actions, matching the five terminal modes the Gutenberg sidebar
 * exposes - minus Custom image, which requires a per-post Media Library
 * picker that bulk dropdowns cannot host:
 *
 *   OG image: Auto-screenshot              -> _doig_source = 'screenshot'
 *   OG image: Featured image               -> _doig_source = 'featured'
 *   OG image: Featured image (with         -> _doig_source = 'featured_with_fallback'
 *                screenshot when missing)
 *   OG image: Off                          -> _doig_source = 'off'
 *   OG image: Reset to default             -> _doig_source = ''  (inherit content-type default)
 *
 * Custom image is intentionally excluded from this list: bulk-setting
 * Custom without a media picker would leave every selected post in a
 * broken state (resolver returns null, no image renders). Custom mode
 * stays sidebar-only where the Media Library picker already lives, and
 * per-post Custom semantics ("different posts want different images")
 * argue against one bulk picker anyway. Decision recorded in
 * `docs/ROADMAP.md` under "Q2 Option C re-lock" amendment 2026-06-23.
 *
 * Pre-2.4.0 this class hosted three Enable/Disable/Reset actions writing
 * to the legacy `_doig_enabled` tri-state meta. M5 (2.4.0) replaces
 * those with the five Source-mode actions above. Every write also clears
 * the legacy `_doig_enabled` meta to match the Gutenberg sidebar's
 * handleSourceChange pattern - the resolver only consults the legacy
 * meta as a back-compat shim, and explicit Source writes supersede it.
 *
 * Reuses the same per-post meta store as the sidebar control, so the
 * two stay in sync automatically. Free, admin-only. Registered for
 * every post type DOIG is eligible to override on
 * (`DOIG_Cron::eligible_post_types()`), which means every public CPT
 * in Premium - not only those ticked under Settings > Custom post
 * types. The override capability is independent of the type-level
 * Source dropdown, same as Posts and Pages.
 *
 * @package dynamic-og-image-generator
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

class DOIG_List_Actions {

	// 2.4.0 M5: five Source-mode bulk actions replacing the pre-2.4.0
	// Enable / Disable / Reset trio. Keys match the resolver mode
	// constants in spirit (with the `doig_set_source_` prefix
	// disambiguating the bulk-action namespace from any future
	// non-bulk doig_ action). The Inherit action key intentionally
	// reads "inherit" rather than "reset" - the value written is the
	// empty MODE_INHERIT, and customers may rename "reset" in the
	// label without churning the URL parameter the redirect uses.
	const ACTION_SET_SOURCE_SCREENSHOT             = 'doig_set_source_screenshot';
	const ACTION_SET_SOURCE_FEATURED               = 'doig_set_source_featured';
	const ACTION_SET_SOURCE_FEATURED_WITH_FALLBACK = 'doig_set_source_featured_with_fallback';
	const ACTION_SET_SOURCE_OFF                    = 'doig_set_source_off';
	const ACTION_SET_SOURCE_INHERIT                = 'doig_set_source_inherit';

	/** Query arg appended to the redirect carrying the apply count. */
	const COUNT_QUERY_ARG = 'doig_bulk_n';

	/** Query arg appended to the redirect carrying which action was applied. */
	const APPLIED_QUERY_ARG = 'doig_bulk_action';

	public static function init() {
		// admin_init runs after plugins_loaded, so Premium CPTs have already
		// joined eligible_post_types() (via available_cpts() under the
		// is_premium() gate) by the time register() iterates.
		add_action( 'admin_init',    array( __CLASS__, 'register' ) );
		add_action( 'admin_notices', array( __CLASS__, 'notice' ) );
	}

	/**
	 * Action key -> [ source meta value, label, success-notice
	 * _n_noop pair ].
	 *
	 * Centralises the dispatch table so the dropdown labels, the meta
	 * writes, and the post-redirect notices all derive from one source.
	 * `_n_noop()` is the WordPress-blessed pattern for registering
	 * plurals in a deferred-translation table: gettext extractors pick
	 * up the literal singular/plural pair at the _n_noop() call site,
	 * and translate_nooped_plural() at the notice() render site
	 * resolves the locale-correct form when the count is known.
	 *
	 * @return array<string, array{source:string, label:string, notice:array}>
	 */
	private static function action_map() {
		return array(
			self::ACTION_SET_SOURCE_SCREENSHOT => array(
				'source' => DOIG_Source_Resolver::MODE_SCREENSHOT,
				'label'  => __( 'OG image: Auto-screenshot', 'dynamic-og-image-generator' ),
				/* translators: %d: number of items */
				'notice' => _n_noop(
					'%d item set to use an Auto-screenshot.',
					'%d items set to use an Auto-screenshot.',
					'dynamic-og-image-generator'
				),
			),
			self::ACTION_SET_SOURCE_FEATURED => array(
				'source' => DOIG_Source_Resolver::MODE_FEATURED,
				'label'  => __( 'OG image: Featured image', 'dynamic-og-image-generator' ),
				/* translators: %d: number of items */
				'notice' => _n_noop(
					'%d item set to use the Featured image.',
					'%d items set to use the Featured image.',
					'dynamic-og-image-generator'
				),
			),
			self::ACTION_SET_SOURCE_FEATURED_WITH_FALLBACK => array(
				'source' => DOIG_Source_Resolver::MODE_FEATURED_WITH_FALLBACK,
				'label'  => __( 'OG image: Featured image (with screenshot when missing)', 'dynamic-og-image-generator' ),
				/* translators: %d: number of items */
				'notice' => _n_noop(
					'%d item set to use the Featured image, with a screenshot fallback.',
					'%d items set to use the Featured image, with a screenshot fallback.',
					'dynamic-og-image-generator'
				),
			),
			self::ACTION_SET_SOURCE_OFF => array(
				'source' => DOIG_Source_Resolver::MODE_OFF,
				'label'  => __( 'OG image: Off', 'dynamic-og-image-generator' ),
				/* translators: %d: number of items */
				'notice' => _n_noop(
					'%d item set to skip Dynamic OG image generation.',
					'%d items set to skip Dynamic OG image generation.',
					'dynamic-og-image-generator'
				),
			),
			self::ACTION_SET_SOURCE_INHERIT => array(
				'source' => DOIG_Source_Resolver::MODE_INHERIT,
				'label'  => __( 'OG image: Reset to default', 'dynamic-og-image-generator' ),
				/* translators: %d: number of items */
				'notice' => _n_noop(
					'%d item reset to the site default for its post type.',
					'%d items reset to the site default for their post type.',
					'dynamic-og-image-generator'
				),
			),
		);
	}

	/**
	 * Hook the core bulk-action filters for every list table where DOIG
	 * is eligible to override behaviour. Adding filters is cheap and
	 * harmless on non-edit screens; the filters only fire when a list
	 * table actually renders or a bulk action posts back.
	 *
	 * 2.3.0: widened from supported_post_types() to eligible_post_types()
	 * so the bulk actions appear on every public CPT in Premium, not only
	 * those ticked under Settings > Custom post types.
	 */
	public static function register() {
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}

		foreach ( DOIG_Cron::eligible_post_types() as $type ) {
			$screen = 'edit-' . $type;
			add_filter( "bulk_actions-{$screen}",        array( __CLASS__, 'add_actions' ) );
			add_filter( "handle_bulk_actions-{$screen}", array( __CLASS__, 'handle' ), 10, 3 );
		}
	}

	/**
	 * Append the five Source-mode actions to a list table's Bulk
	 * actions dropdown, in the same display order as the Gutenberg
	 * sidebar SelectControl (Inherit anchors the bottom as the reset).
	 *
	 * @param array $actions Existing bulk actions.
	 * @return array
	 */
	public static function add_actions( $actions ) {
		foreach ( self::action_map() as $key => $entry ) {
			$actions[ $key ] = $entry['label'];
		}
		return $actions;
	}

	/**
	 * Apply a chosen bulk action and return the redirect URL. Core has
	 * already verified the list table's bulk nonce and the user's
	 * per-post edit capability before this fires; the manage_options
	 * gate in register() keeps the actions admin-only, and it is
	 * re-checked here.
	 *
	 * Writes both `_doig_source` (the 2.4.0 axis) and clears
	 * `_doig_enabled` (the legacy back-compat shim) so an explicit
	 * Source choice supersedes any stale legacy state. Matches the
	 * Gutenberg sidebar's handleSourceChange pattern.
	 *
	 * @param string $sendback Redirect URL core will send the user to.
	 * @param string $action   The chosen bulk action key.
	 * @param int[]  $post_ids Selected post IDs.
	 * @return string
	 */
	public static function handle( $sendback, $action, $post_ids ) {
		if ( ! current_user_can( 'manage_options' ) ) {
			return $sendback;
		}

		$map = self::action_map();
		if ( ! array_key_exists( $action, $map ) ) {
			return $sendback;
		}
		$new_source = $map[ $action ]['source'];

		$ids = array_values( array_filter( array_map( 'absint', (array) $post_ids ) ) );
		if ( empty( $ids ) ) {
			return $sendback;
		}

		$count = 0;
		foreach ( $ids as $post_id ) {
			// Per-post capability check matches what individual editors
			// must clear to flip the sidebar SelectControl on the same post.
			if ( ! current_user_can( 'edit_post', $post_id ) ) {
				continue;
			}
			DOIG_Post_Meta::set( $post_id, 'source',  $new_source );
			// Clear the pre-2.4.0 enabled shim. The resolver consults
			// _doig_enabled = '0' as a legacy force-off override, which
			// would otherwise mask the explicit Source write above (e.g.
			// a customer pre-2.4.0 who Disabled a post, then bulk-sets
			// it to Auto-screenshot, would still resolve to MODE_OFF).
			DOIG_Post_Meta::set( $post_id, 'enabled', '' );
			$count++;
		}

		$sendback = remove_query_arg( array( self::COUNT_QUERY_ARG, self::APPLIED_QUERY_ARG ), $sendback );
		$sendback = add_query_arg(
			array(
				self::COUNT_QUERY_ARG   => $count,
				self::APPLIED_QUERY_ARG => $action,
			),
			$sendback
		);

		return $sendback;
	}

	/**
	 * Success notice on the list-table screen after the redirect.
	 * Renders only on the eligible post-type edit screens (lockstep
	 * with the register() gate above).
	 *
	 * The notice text is sourced from the action_map() singular/plural
	 * pair so a single dispatch table drives both the dropdown labels
	 * and the post-apply confirmations.
	 */
	public static function notice() {
		$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
		if ( ! $screen || 'edit' !== $screen->base ) {
			return;
		}
		if ( ! in_array( $screen->post_type, DOIG_Cron::eligible_post_types(), true ) ) {
			return;
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display of a count from our own redirect; the mutation already happened behind the core bulk nonce.
		if ( ! isset( $_GET[ self::COUNT_QUERY_ARG ], $_GET[ self::APPLIED_QUERY_ARG ] ) ) {
			return;
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- see above.
		$n      = absint( wp_unslash( $_GET[ self::COUNT_QUERY_ARG ] ) );
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- see above.
		$action = sanitize_key( wp_unslash( $_GET[ self::APPLIED_QUERY_ARG ] ) );

		$map = self::action_map();
		if ( ! isset( $map[ $action ] ) ) {
			return;
		}

		$entry = $map[ $action ];
		$text  = sprintf(
			translate_nooped_plural( $entry['notice'], $n, 'dynamic-og-image-generator' ),
			$n
		);

		printf(
			'<div class="notice notice-success is-dismissible"><p>%s</p></div>',
			esc_html( $text )
		);
	}
}

add_action( 'plugins_loaded', array( 'DOIG_List_Actions', 'init' ), 11 );
