<?php

defined( 'ABSPATH' ) || exit;

/**
 * Scheduled Post Actions module.
 *
 * Per-post scheduled trigger: at a chosen date+time in the future, run one or
 * more actions against the post. Each action is one of:
 *  - change_status  : set post_status to publish / draft / private / pending / trash.
 *  - add_terms      : append term(s) in a taxonomy (e.g. add "past" to an event).
 *  - remove_terms   : drop term(s) from a taxonomy (e.g. remove "new" after launch).
 *
 * A single trigger can chain multiple actions — e.g. add a "past" term, remove
 * a "current" term, and flip status to draft, all at the same datetime.
 *
 * Replaces the standalone "Post Expirator" / "PublishPress Future" plugins for
 * the typical Bowden Works workflow. Supports any public post type + any
 * taxonomy registered for that post type — works with custom post types and
 * custom taxonomies.
 *
 * Storage (post meta, underscore-prefixed so it stays out of the custom-fields UI):
 *  - _bw_dev_psa_timestamp : UTC unix timestamp; presence = enabled.
 *  - _bw_dev_psa_actions   : JSON-encoded list of action objects, e.g.
 *      [ { "type": "add_terms", "taxonomy": "category", "terms": [1,2] },
 *        { "type": "change_status", "status": "draft" } ].
 *
 * Legacy meta keys (read-only, for migration from the pre-1.11.1 single-action
 * format — never written by current code; cleaned up on save / cron-fire):
 *  - _bw_dev_psa_action   : 'change_status' | 'add_terms' | 'remove_terms'.
 *  - _bw_dev_psa_status   : post status slug.
 *  - _bw_dev_psa_taxonomy : taxonomy slug.
 *  - _bw_dev_psa_terms    : comma-separated term IDs.
 *
 * Cron model: one recurring event `bw_dev_psa_run` (hourly by default) that
 * queries posts where `_bw_dev_psa_timestamp <= now()` and executes every
 * action in their list. Single recurring poll is more reliable than per-post
 * `wp_schedule_single_event` on low-traffic sites where WP cron only fires on
 * page loads. After the actions run the meta is cleared so it can't re-fire.
 *
 * Hour-level precision is the trade-off — a 9:30 target fires at the top of
 * the next hour. Filter `bw_dev_psa_interval` to override the schedule slug
 * (e.g. return a custom every-five-minutes interval registered via the
 * `cron_schedules` filter) when minute precision is required.
 *
 * @package BW_Dev
 */

class BW_Dev_Module_Scheduled_Actions implements BW_Dev_Module_Interface {

	const CRON_HOOK        = 'bw_dev_psa_run';
	const META_TIMESTAMP   = '_bw_dev_psa_timestamp';
	const META_ACTIONS     = '_bw_dev_psa_actions';
	// Legacy single-action meta keys (read on migration; never written now).
	const META_ACTION      = '_bw_dev_psa_action';
	const META_STATUS      = '_bw_dev_psa_status';
	const META_TAXONOMY    = '_bw_dev_psa_taxonomy';
	const META_TERMS       = '_bw_dev_psa_terms';
	const NONCE_ACTION     = 'bw_dev_psa_save';
	const NONCE_FIELD      = 'bw_dev_psa_nonce';
	const FORM_FIELD       = 'bw_dev_psa';
	const ALLOWED_TYPES    = array( 'change_status', 'add_terms', 'remove_terms' );
	const ALLOWED_STATUSES = array( 'publish', 'draft', 'private', 'pending', 'trash' );
	const BATCH_SIZE       = 100;

	public function slug(): string {
		return 'scheduled_actions';
	}

	public function label(): string {
		return __( 'Scheduled Post Actions', 'bw-dev' );
	}

	public function group(): string {
		return 'editor_admin';
	}

	public function default_settings(): array {
		return array(
			'post_types' => array( 'post', 'page' ),
		);
	}

	public function sanitize( array $data ): array {
		$post_types_raw = isset( $data['post_types'] ) && is_array( $data['post_types'] ) ? $data['post_types'] : array();
		$post_types     = array();
		foreach ( $post_types_raw as $pt ) {
			$pt = sanitize_key( (string) $pt );
			if ( '' !== $pt && post_type_exists( $pt ) ) {
				$post_types[] = $pt;
			}
		}
		return array(
			'post_types' => array_values( array_unique( $post_types ) ),
		);
	}

	public function register(): void {
		// Idempotent — ensures the recurring event exists even on sites that
		// pick this module up via auto-update (no reactivation pass).
		add_action( 'init',           array( __CLASS__, 'schedule_cron' ) );
		add_action( 'add_meta_boxes', array( $this, 'register_meta_box' ) );
		add_action( 'save_post',      array( $this, 'save_post' ), 10, 2 );
		add_action( self::CRON_HOOK,  array( $this, 'run_due_actions' ) );
	}

	public static function enabled_post_types(): array {
		$pts = bw_dev()->settings()->get( 'scheduled_actions', 'post_types', array( 'post', 'page' ) );
		if ( ! is_array( $pts ) || empty( $pts ) ) {
			return array( 'post', 'page' );
		}
		return array_values( array_filter( array_map( 'strval', $pts ), 'post_type_exists' ) );
	}

	public static function schedule_cron(): void {
		if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
			$interval = (string) apply_filters( 'bw_dev_psa_interval', 'hourly' );
			wp_schedule_event( time() + 60, $interval, self::CRON_HOOK );
		}
	}

	/* ---------------------------------------------------------------------
	 * Actions read / write / sanitize
	 * ------------------------------------------------------------------- */

	/**
	 * Read the sanitized actions list for a post.
	 *
	 * Transparently migrates legacy single-action meta keys
	 * (_bw_dev_psa_action / _status / _taxonomy / _terms) to the new
	 * `_bw_dev_psa_actions` JSON format on read. The migration is read-only
	 * here — the legacy keys are cleared on the next save / cron-fire via
	 * `clear_post_schedule()`.
	 */
	private function read_actions( int $post_id ): array {
		$raw = (string) get_post_meta( $post_id, self::META_ACTIONS, true );
		if ( '' !== $raw ) {
			$decoded = json_decode( $raw, true );
			if ( is_array( $decoded ) ) {
				return $this->sanitize_actions_list( $decoded );
			}
		}

		// Legacy fallback — build a one-element array from the old meta keys.
		$legacy_type     = (string) get_post_meta( $post_id, self::META_ACTION,   true );
		$legacy_status   = (string) get_post_meta( $post_id, self::META_STATUS,   true );
		$legacy_taxonomy = (string) get_post_meta( $post_id, self::META_TAXONOMY, true );
		$legacy_terms    = array_filter( array_map( 'intval', explode( ',', (string) get_post_meta( $post_id, self::META_TERMS, true ) ) ) );

		if ( '' === $legacy_type ) {
			return array();
		}
		return $this->sanitize_actions_list( array(
			array(
				'type'     => $legacy_type,
				'status'   => $legacy_status,
				'taxonomy' => $legacy_taxonomy,
				'terms'    => $legacy_terms,
			),
		) );
	}

	private function write_actions( int $post_id, array $actions ): void {
		$encoded = wp_json_encode( array_values( $actions ) );
		if ( false === $encoded ) {
			return;
		}
		update_post_meta( $post_id, self::META_ACTIONS, wp_slash( $encoded ) );
		// Clear legacy keys — we own this row now.
		delete_post_meta( $post_id, self::META_ACTION );
		delete_post_meta( $post_id, self::META_STATUS );
		delete_post_meta( $post_id, self::META_TAXONOMY );
		delete_post_meta( $post_id, self::META_TERMS );
	}

	private function sanitize_actions_list( array $list ): array {
		$out = array();
		foreach ( $list as $entry ) {
			if ( ! is_array( $entry ) ) {
				continue;
			}
			$clean = $this->sanitize_action( $entry );
			if ( null !== $clean ) {
				$out[] = $clean;
			}
		}
		return $out;
	}

	private function sanitize_action( array $action ): ?array {
		$type = isset( $action['type'] ) ? sanitize_key( (string) $action['type'] ) : '';
		if ( ! in_array( $type, self::ALLOWED_TYPES, true ) ) {
			return null;
		}

		if ( 'change_status' === $type ) {
			$status = isset( $action['status'] ) ? sanitize_key( (string) $action['status'] ) : '';
			if ( ! in_array( $status, self::ALLOWED_STATUSES, true ) ) {
				return null;
			}
			return array(
				'type'   => 'change_status',
				'status' => $status,
			);
		}

		// add_terms / remove_terms
		$taxonomy = isset( $action['taxonomy'] ) ? sanitize_key( (string) $action['taxonomy'] ) : '';
		if ( '' === $taxonomy || ! taxonomy_exists( $taxonomy ) ) {
			return null;
		}
		$terms_raw = array();
		if ( isset( $action['terms'] ) && is_array( $action['terms'] ) ) {
			$terms_raw = $action['terms'];
		}
		$terms = array();
		foreach ( $terms_raw as $tid ) {
			$tid = (int) $tid;
			if ( $tid > 0 ) {
				$terms[] = $tid;
			}
		}
		$terms = array_values( array_unique( $terms ) );
		if ( empty( $terms ) ) {
			return null;
		}
		return array(
			'type'     => $type,
			'taxonomy' => $taxonomy,
			'terms'    => $terms,
		);
	}

	/* ---------------------------------------------------------------------
	 * Meta box
	 * ------------------------------------------------------------------- */

	public function register_meta_box(): void {
		foreach ( self::enabled_post_types() as $post_type ) {
			add_meta_box(
				'bw_dev_scheduled_actions',
				__( 'Scheduled Action (BW Dev)', 'bw-dev' ),
				array( $this, 'render_meta_box' ),
				$post_type,
				'side',
				'default'
			);
		}
	}

	public function render_meta_box( $post ): void {
		if ( ! ( $post instanceof WP_Post ) ) {
			return;
		}

		$timestamp     = (int) get_post_meta( $post->ID, self::META_TIMESTAMP, true );
		$is_enabled    = $timestamp > 0;
		$datetime_str  = $is_enabled ? wp_date( 'Y-m-d\TH:i', $timestamp ) : '';
		$actions       = $this->read_actions( $post->ID );
		$taxonomies    = get_object_taxonomies( $post->post_type, 'objects' );
		$status_labels = $this->status_labels();
		$base          = self::FORM_FIELD;

		// Render at least one (blank) row so the UI is never empty.
		if ( empty( $actions ) ) {
			$actions = array( array( 'type' => 'change_status', 'status' => 'draft', 'taxonomy' => '', 'terms' => array() ) );
		}

		wp_nonce_field( self::NONCE_ACTION, self::NONCE_FIELD );
		?>
		<p>
			<label>
				<input type="checkbox" name="<?php echo esc_attr( $base . '[enabled]' ); ?>" value="1" <?php checked( $is_enabled ); ?> />
				<strong><?php esc_html_e( 'Schedule action(s)', 'bw-dev' ); ?></strong>
			</label>
		</p>

		<p>
			<label for="bw-dev-psa-datetime"><?php esc_html_e( 'Run at', 'bw-dev' ); ?></label>
			<input type="datetime-local" id="bw-dev-psa-datetime" name="<?php echo esc_attr( $base . '[datetime]' ); ?>" value="<?php echo esc_attr( $datetime_str ); ?>" style="width:100%;" />
			<span class="description" style="display:block;margin-top:4px;">
				<?php
				/* translators: %s: site timezone */
				printf( esc_html__( 'Site timezone: %s', 'bw-dev' ), '<code>' . esc_html( wp_timezone_string() ) . '</code>' );
				?>
			</span>
		</p>

		<div class="bw-dev-psa-rows" data-next-index="<?php echo esc_attr( (string) count( $actions ) ); ?>">
			<?php foreach ( $actions as $idx => $action ) : ?>
				<?php $this->render_row( (int) $idx, $action, $taxonomies, $status_labels, $base ); ?>
			<?php endforeach; ?>
		</div>

		<p style="margin-top:6px;">
			<button type="button" class="button button-secondary bw-dev-psa-add-row">
				<span class="dashicons dashicons-plus-alt2" style="vertical-align:text-bottom;"></span>
				<?php esc_html_e( 'Add another action', 'bw-dev' ); ?>
			</button>
		</p>

		<?php if ( $is_enabled ) : ?>
			<p class="description" style="background:#f6f7f7;padding:8px 10px;border-left:3px solid #2271b1;">
				<strong><?php esc_html_e( 'Currently scheduled.', 'bw-dev' ); ?></strong>
				<br />
				<?php
				/* translators: %s: human-readable date+time */
				printf( esc_html__( 'Runs at: %s', 'bw-dev' ), esc_html( wp_date( 'Y-m-d H:i', $timestamp ) ) );
				?>
			</p>
		<?php endif; ?>

		<template id="bw-dev-psa-row-template">
			<?php $this->render_row( 0, array( 'type' => 'change_status', 'status' => 'draft', 'taxonomy' => '', 'terms' => array() ), $taxonomies, $status_labels, $base, true ); ?>
		</template>

		<style>
			.bw-dev-psa-row { border:1px solid #dcdcde; border-radius:4px; padding:10px 12px; margin-bottom:10px; background:#fbfbfc; position:relative; }
			.bw-dev-psa-row .bw-dev-psa-row-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:6px; }
			.bw-dev-psa-row .bw-dev-psa-row-header strong { color:#50575e; font-size:11px; text-transform:uppercase; letter-spacing:0.5px; }
			.bw-dev-psa-row .bw-dev-psa-remove-row { color:#b32d2e; cursor:pointer; background:transparent; border:0; padding:0; font-size:12px; }
			.bw-dev-psa-row .bw-dev-psa-remove-row:hover { text-decoration:underline; }
			.bw-dev-psa-rows .bw-dev-psa-row:only-child .bw-dev-psa-remove-row { display:none; }
		</style>

		<script>
		(function () {
			var container = document.querySelector('.bw-dev-psa-rows');
			var addBtn    = document.querySelector('.bw-dev-psa-add-row');
			var template  = document.getElementById('bw-dev-psa-row-template');
			if (!container || !addBtn || !template) return;

			function wireRow(row) {
				var actionSelect   = row.querySelector('.bw-dev-psa-action-select');
				var taxonomySelect = row.querySelector('.bw-dev-psa-taxonomy-select');
				var statusSection  = row.querySelector('.bw-dev-psa-section-status');
				var termsSection   = row.querySelector('.bw-dev-psa-section-terms');
				var removeBtn      = row.querySelector('.bw-dev-psa-remove-row');

				function showSections() {
					if (!actionSelect) return;
					var act = actionSelect.value;
					if (statusSection) statusSection.style.display = (act === 'change_status') ? '' : 'none';
					if (termsSection)  termsSection.style.display  = (act === 'add_terms' || act === 'remove_terms') ? '' : 'none';
				}
				function showTermsFor() {
					var slug = taxonomySelect ? taxonomySelect.value : '';
					row.querySelectorAll('.bw-dev-psa-terms-for').forEach(function (el) {
						el.style.display = (el.getAttribute('data-taxonomy') === slug) ? '' : 'none';
					});
				}

				if (actionSelect)   actionSelect.addEventListener('change', showSections);
				if (taxonomySelect) taxonomySelect.addEventListener('change', showTermsFor);
				if (removeBtn) {
					removeBtn.addEventListener('click', function () {
						if (container.querySelectorAll('.bw-dev-psa-row').length <= 1) return;
						row.parentNode.removeChild(row);
					});
				}
				showSections();
				showTermsFor();
			}

			container.querySelectorAll('.bw-dev-psa-row').forEach(wireRow);

			addBtn.addEventListener('click', function () {
				var nextIdx = parseInt(container.getAttribute('data-next-index'), 10) || container.querySelectorAll('.bw-dev-psa-row').length;
				var html    = template.innerHTML.replace(/__INDEX__/g, String(nextIdx));
				var wrap    = document.createElement('div');
				wrap.innerHTML = html.trim();
				var newRow  = wrap.firstElementChild;
				if (!newRow) return;
				container.appendChild(newRow);
				container.setAttribute('data-next-index', String(nextIdx + 1));
				wireRow(newRow);
			});
		})();
		</script>
		<?php
	}

	/**
	 * Render a single action row. When $is_template is true, $idx is replaced
	 * with the literal placeholder `__INDEX__` so the JS clone code can swap
	 * in the next live index.
	 *
	 * @param int   $idx           Row index for form field names.
	 * @param array $action        Pre-populated action data (type/status/taxonomy/terms).
	 * @param array $taxonomies    get_object_taxonomies( $post->post_type, 'objects' ) output.
	 * @param array $status_labels self::status_labels() output.
	 * @param string $base         self::FORM_FIELD.
	 * @param bool   $is_template  True when rendering the JS-clone template.
	 */
	private function render_row( int $idx, array $action, array $taxonomies, array $status_labels, string $base, bool $is_template = false ): void {
		$idx_str  = $is_template ? '__INDEX__' : (string) $idx;
		$type     = isset( $action['type'] ) ? (string) $action['type'] : 'change_status';
		$status   = isset( $action['status'] ) ? (string) $action['status'] : 'draft';
		$taxonomy = isset( $action['taxonomy'] ) ? (string) $action['taxonomy'] : '';
		$term_ids = isset( $action['terms'] ) && is_array( $action['terms'] ) ? array_map( 'intval', $action['terms'] ) : array();

		$row_base = $base . '[actions][' . $idx_str . ']';
		?>
		<div class="bw-dev-psa-row" data-row-index="<?php echo esc_attr( $idx_str ); ?>">
			<div class="bw-dev-psa-row-header">
				<strong><?php esc_html_e( 'Action', 'bw-dev' ); ?></strong>
				<button type="button" class="bw-dev-psa-remove-row" aria-label="<?php esc_attr_e( 'Remove this action', 'bw-dev' ); ?>">
					<span class="dashicons dashicons-no-alt" style="font-size:14px;width:14px;height:14px;vertical-align:text-bottom;"></span>
					<?php esc_html_e( 'Remove', 'bw-dev' ); ?>
				</button>
			</div>

			<p style="margin:4px 0;">
				<select class="bw-dev-psa-action-select" name="<?php echo esc_attr( $row_base . '[type]' ); ?>" style="width:100%;">
					<option value="change_status" <?php selected( $type, 'change_status' ); ?>><?php esc_html_e( 'Change post status', 'bw-dev' ); ?></option>
					<option value="add_terms" <?php selected( $type, 'add_terms' ); ?>><?php esc_html_e( 'Add taxonomy term(s)', 'bw-dev' ); ?></option>
					<option value="remove_terms" <?php selected( $type, 'remove_terms' ); ?>><?php esc_html_e( 'Remove taxonomy term(s)', 'bw-dev' ); ?></option>
				</select>
			</p>

			<div class="bw-dev-psa-section-status">
				<p style="margin:4px 0;">
					<label><?php esc_html_e( 'New status', 'bw-dev' ); ?></label>
					<select name="<?php echo esc_attr( $row_base . '[status]' ); ?>" style="width:100%;">
						<?php foreach ( $status_labels as $value => $label ) : ?>
							<option value="<?php echo esc_attr( $value ); ?>" <?php selected( $status, $value ); ?>><?php echo esc_html( $label ); ?></option>
						<?php endforeach; ?>
					</select>
				</p>
			</div>

			<div class="bw-dev-psa-section-terms">
				<p style="margin:4px 0;">
					<label><?php esc_html_e( 'Taxonomy', 'bw-dev' ); ?></label>
					<select class="bw-dev-psa-taxonomy-select" name="<?php echo esc_attr( $row_base . '[taxonomy]' ); ?>" style="width:100%;">
						<option value=""><?php esc_html_e( '— select —', 'bw-dev' ); ?></option>
						<?php foreach ( $taxonomies as $tax_slug => $tax_obj ) : ?>
							<option value="<?php echo esc_attr( $tax_slug ); ?>" <?php selected( $taxonomy, $tax_slug ); ?>><?php echo esc_html( $tax_obj->labels->singular_name ); ?> (<code><?php echo esc_html( $tax_slug ); ?></code>)</option>
						<?php endforeach; ?>
					</select>
				</p>

				<?php foreach ( $taxonomies as $tax_slug => $tax_obj ) :
					$terms = get_terms( array(
						'taxonomy'   => $tax_slug,
						'hide_empty' => false,
						'number'     => 500,
					) );
					if ( is_wp_error( $terms ) || empty( $terms ) ) {
						continue;
					}
					?>
					<div class="bw-dev-psa-terms-for" data-taxonomy="<?php echo esc_attr( $tax_slug ); ?>" style="<?php echo $taxonomy === $tax_slug ? '' : 'display:none;'; ?>">
						<p style="margin:4px 0;">
							<label><?php esc_html_e( 'Terms', 'bw-dev' ); ?></label>
							<select multiple size="6" name="<?php echo esc_attr( $row_base . '[terms][' . $tax_slug . '][]' ); ?>" style="width:100%;">
								<?php foreach ( $terms as $term ) : ?>
									<option value="<?php echo esc_attr( (string) $term->term_id ); ?>" <?php selected( $taxonomy === $tax_slug && in_array( (int) $term->term_id, $term_ids, true ) ); ?>><?php echo esc_html( $term->name ); ?></option>
								<?php endforeach; ?>
							</select>
							<span class="description" style="display:block;margin-top:4px;">
								<?php esc_html_e( 'Hold Ctrl / Cmd to pick multiple.', 'bw-dev' ); ?>
							</span>
						</p>
					</div>
				<?php endforeach; ?>
			</div>
		</div>
		<?php
	}

	/* ---------------------------------------------------------------------
	 * Save handler
	 * ------------------------------------------------------------------- */

	public function save_post( $post_id, $post ): void {
		if ( ! ( $post instanceof WP_Post ) ) {
			return;
		}
		if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
			return;
		}
		$posted = wp_unslash( $_POST ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
		if ( empty( $posted[ self::NONCE_FIELD ] ) ) {
			// Meta box wasn't rendered for this request — nothing to do.
			return;
		}
		if ( ! wp_verify_nonce( (string) $posted[ self::NONCE_FIELD ], self::NONCE_ACTION ) ) {
			return;
		}
		if ( ! current_user_can( 'edit_post', $post_id ) ) {
			return;
		}
		if ( ! in_array( $post->post_type, self::enabled_post_types(), true ) ) {
			return;
		}

		$payload = isset( $posted[ self::FORM_FIELD ] ) && is_array( $posted[ self::FORM_FIELD ] )
			? $posted[ self::FORM_FIELD ]
			: array();

		// Disabled / empty datetime → clear the schedule entirely.
		if ( empty( $payload['enabled'] ) || empty( $payload['datetime'] ) ) {
			$this->clear_post_schedule( $post_id );
			return;
		}

		$local_dt = sanitize_text_field( (string) $payload['datetime'] );
		// Convert HTML5 datetime-local (Y-m-d\TH:i) → space-separated for get_gmt_from_date().
		$local_dt = str_replace( 'T', ' ', $local_dt );
		$utc_ts   = (int) get_gmt_from_date( $local_dt, 'U' );
		if ( $utc_ts <= 0 ) {
			$this->clear_post_schedule( $post_id );
			return;
		}

		// Normalize the row payload: the form posts `[actions][N]` rows where N
		// may be non-contiguous (rows removed via JS leave gaps). Reduce to a
		// flat list of action arrays.
		$rows_raw = array();
		if ( isset( $payload['actions'] ) && is_array( $payload['actions'] ) ) {
			foreach ( $payload['actions'] as $row ) {
				if ( is_array( $row ) ) {
					// Flatten `[terms][taxonomy_slug][...]` → `[terms][...]` for
					// the currently-selected taxonomy only; the others are
					// ignored.
					$tax = isset( $row['taxonomy'] ) ? sanitize_key( (string) $row['taxonomy'] ) : '';
					$terms_for_selected_tax = array();
					if ( '' !== $tax && isset( $row['terms'][ $tax ] ) && is_array( $row['terms'][ $tax ] ) ) {
						foreach ( $row['terms'][ $tax ] as $tid ) {
							$terms_for_selected_tax[] = (int) $tid;
						}
					}
					$rows_raw[] = array(
						'type'     => isset( $row['type'] ) ? (string) $row['type'] : '',
						'status'   => isset( $row['status'] ) ? (string) $row['status'] : '',
						'taxonomy' => $tax,
						'terms'    => $terms_for_selected_tax,
					);
				}
			}
		}

		$actions = $this->sanitize_actions_list( $rows_raw );
		if ( empty( $actions ) ) {
			// No valid actions → don't create a no-op schedule.
			$this->clear_post_schedule( $post_id );
			return;
		}

		update_post_meta( $post_id, self::META_TIMESTAMP, $utc_ts );
		$this->write_actions( $post_id, $actions );
	}

	private function clear_post_schedule( int $post_id ): void {
		delete_post_meta( $post_id, self::META_TIMESTAMP );
		delete_post_meta( $post_id, self::META_ACTIONS );
		// Legacy keys — clear too so a stale single-action payload doesn't
		// survive on a post whose new-format actions just got wiped.
		delete_post_meta( $post_id, self::META_ACTION );
		delete_post_meta( $post_id, self::META_STATUS );
		delete_post_meta( $post_id, self::META_TAXONOMY );
		delete_post_meta( $post_id, self::META_TERMS );
	}

	/* ---------------------------------------------------------------------
	 * Cron runner
	 * ------------------------------------------------------------------- */

	public function run_due_actions(): void {
		$post_types = self::enabled_post_types();
		if ( empty( $post_types ) ) {
			return;
		}

		$ids = get_posts(
			array(
				'post_type'        => $post_types,
				'post_status'      => 'any',
				'posts_per_page'   => self::BATCH_SIZE,
				'fields'           => 'ids',
				'no_found_rows'    => true,
				'orderby'          => 'meta_value_num',
				'order'            => 'ASC',
				'meta_query'       => array(
					array(
						'key'     => self::META_TIMESTAMP,
						'value'   => time(),
						'compare' => '<=',
						'type'    => 'NUMERIC',
					),
				),
			)
		);

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

		foreach ( $ids as $post_id ) {
			$this->execute_actions_for_post( (int) $post_id );
		}
	}

	private function execute_actions_for_post( int $post_id ): void {
		$actions = $this->read_actions( $post_id );
		if ( empty( $actions ) ) {
			$this->clear_post_schedule( $post_id );
			return;
		}

		foreach ( $actions as $action ) {
			$this->execute_single_action( $post_id, $action );
		}

		$this->clear_post_schedule( $post_id );
		do_action( 'bw_dev_psa_executed', $post_id, $actions );
	}

	private function execute_single_action( int $post_id, array $action ): void {
		$type = isset( $action['type'] ) ? (string) $action['type'] : '';
		if ( 'change_status' === $type ) {
			$new_status = isset( $action['status'] ) ? (string) $action['status'] : '';
			if ( in_array( $new_status, self::ALLOWED_STATUSES, true ) ) {
				wp_update_post( array(
					'ID'          => $post_id,
					'post_status' => $new_status,
				) );
			}
			return;
		}
		if ( 'add_terms' === $type || 'remove_terms' === $type ) {
			$taxonomy = isset( $action['taxonomy'] ) ? (string) $action['taxonomy'] : '';
			$terms    = isset( $action['terms'] ) && is_array( $action['terms'] )
				? array_filter( array_map( 'intval', $action['terms'] ) )
				: array();
			if ( '' === $taxonomy || ! taxonomy_exists( $taxonomy ) || empty( $terms ) ) {
				return;
			}
			if ( 'add_terms' === $type ) {
				wp_set_object_terms( $post_id, $terms, $taxonomy, true );
			} else {
				wp_remove_object_terms( $post_id, $terms, $taxonomy );
			}
		}
	}

	/* ---------------------------------------------------------------------
	 * Settings tab
	 * ------------------------------------------------------------------- */

	public function render_tab(): void {
		$enabled    = self::enabled_post_types();
		$enabled_map = array_flip( $enabled );
		$pt_objects = get_post_types( array( 'public' => true ), 'objects' );
		$base       = BW_Dev_Settings::OPTION . '[' . $this->slug() . ']';
		$next       = wp_next_scheduled( self::CRON_HOOK );
		?>
		<p class="description">
			<?php esc_html_e( 'Schedule one or more actions against a specific post in the future: change its status, add taxonomy term(s), or remove taxonomy term(s). Chain multiple actions in one trigger — e.g. add a "past" term, remove a "current" term, and flip status to draft, all at the same datetime.', 'bw-dev' ); ?>
		</p>

		<h3><?php esc_html_e( 'Enabled post types', 'bw-dev' ); ?></h3>
		<p class="description"><?php esc_html_e( 'Only checked post types get the "Scheduled Action (BW Dev)" meta box in the editor.', 'bw-dev' ); ?></p>
		<table class="form-table" role="presentation">
			<tbody>
				<tr>
					<th scope="row"><?php esc_html_e( 'Post types', 'bw-dev' ); ?></th>
					<td>
						<fieldset>
							<?php foreach ( $pt_objects as $pt_slug => $pt_obj ) :
								$field = $base . '[post_types][]';
								$checked = isset( $enabled_map[ $pt_slug ] );
								?>
								<label style="display:inline-block;margin-right:14px;">
									<input type="checkbox" name="<?php echo esc_attr( $field ); ?>" value="<?php echo esc_attr( $pt_slug ); ?>" <?php checked( $checked ); ?> />
									<?php echo esc_html( $pt_obj->labels->singular_name ); ?>
									<code style="color:#646970;font-size:11px;"><?php echo esc_html( $pt_slug ); ?></code>
								</label>
							<?php endforeach; ?>
						</fieldset>
					</td>
				</tr>
			</tbody>
		</table>

		<h3><?php esc_html_e( 'How it works', 'bw-dev' ); ?></h3>
		<ul style="list-style:disc;margin-left:20px;">
			<li><?php esc_html_e( 'Each post can carry one trigger datetime and any number of actions. All actions fire together when the trigger time passes.', 'bw-dev' ); ?></li>
			<li><?php esc_html_e( 'A recurring hourly cron event checks all posts for due actions. The trigger fires on the next cron run after the chosen time — hour-level precision, not minute-level.', 'bw-dev' ); ?></li>
			<li><?php esc_html_e( 'Site timezone is used to display + accept the target time; storage is in UTC.', 'bw-dev' ); ?></li>
			<li><?php esc_html_e( 'After the actions run successfully, the schedule is cleared so it cannot re-fire.', 'bw-dev' ); ?></li>
			<li><?php esc_html_e( 'On low-traffic sites WP-Cron only fires on page loads. If the site might sit idle through the trigger window, switch to a real system cron via DISABLE_WP_CRON + wp-cron.php from system crontab.', 'bw-dev' ); ?></li>
			<li><?php esc_html_e( 'Filter `bw_dev_psa_interval` to override the cron interval (e.g. for a custom every-five-minutes schedule registered via cron_schedules).', 'bw-dev' ); ?></li>
		</ul>

		<h3><?php esc_html_e( 'Cron status', 'bw-dev' ); ?></h3>
		<?php if ( $next ) : ?>
			<p>
				<?php
				/* translators: %s: human-readable date+time */
				printf( esc_html__( 'Next run scheduled at: %s (site time)', 'bw-dev' ), '<code>' . esc_html( wp_date( 'Y-m-d H:i', (int) $next ) ) . '</code>' );
				?>
			</p>
		<?php else : ?>
			<div style="background:#fff;border-left:4px solid #dba617;padding:12px 16px;margin:14px 0;max-width:720px;">
				<strong><?php esc_html_e( 'Cron not scheduled', 'bw-dev' ); ?></strong>
				<p style="margin:6px 0 0;">
					<?php esc_html_e( 'Deactivate and reactivate the plugin to re-register the recurring event.', 'bw-dev' ); ?>
				</p>
			</div>
		<?php endif; ?>

		<h3><?php esc_html_e( 'Use-case recipes', 'bw-dev' ); ?></h3>
		<ul style="list-style:disc;margin-left:20px;">
			<li><strong><?php esc_html_e( 'Unpublish on expiry', 'bw-dev' ); ?></strong> — <?php esc_html_e( 'one action: Change status → Draft.', 'bw-dev' ); ?></li>
			<li><strong><?php esc_html_e( 'Drop a "new" tag after the launch window', 'bw-dev' ); ?></strong> — <?php esc_html_e( 'one action: Remove taxonomy term(s) → Tag "new".', 'bw-dev' ); ?></li>
			<li><strong><?php esc_html_e( 'Mark an event as past — full transition', 'bw-dev' ); ?></strong> — <?php esc_html_e( 'three actions chained: Add term "past", Remove term "current", Change status to Draft. All fire at the event\'s end datetime.', 'bw-dev' ); ?></li>
		</ul>

		<?php $this->render_schedule_index(); ?>
		<?php
	}

	/* ---------------------------------------------------------------------
	 * Schedule index
	 * ------------------------------------------------------------------- */

	/**
	 * Direct $wpdb query for posts that currently have a scheduled action.
	 * Joins on `_bw_dev_psa_timestamp` (the canonical "is scheduled" key);
	 * the actions list is pulled per-row via read_actions(), which transparently
	 * handles the new JSON format + legacy single-action fallback.
	 */
	private function posts_with_schedules(): array {
		global $wpdb;
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		$rows = $wpdb->get_results( $wpdb->prepare(
			"SELECT p.ID, p.post_title, p.post_type, p.post_status, p.post_author, pm.meta_value AS ts
			   FROM {$wpdb->posts} p
			   INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
			  WHERE pm.meta_key = %s
			    AND pm.meta_value <> ''
			    AND p.post_status NOT IN ('auto-draft', 'trash')
			  ORDER BY CAST(pm.meta_value AS UNSIGNED) ASC",
			self::META_TIMESTAMP
		) );
		if ( empty( $rows ) ) {
			return array();
		}

		$now = time();
		$out = array();
		foreach ( $rows as $row ) {
			$post_id = (int) $row->ID;
			$ts      = (int) $row->ts;
			$actions = $this->read_actions( $post_id );

			$pt_obj      = get_post_type_object( $row->post_type );
			$status_obj  = get_post_status_object( $row->post_status );
			$delta       = $ts - $now;
			$out[]       = array(
				'id'                  => $post_id,
				'title'               => $row->post_title !== '' ? $row->post_title : __( '(no title)', 'bw-dev' ),
				'post_type'           => $row->post_type,
				'post_type_label'     => $pt_obj ? $pt_obj->labels->singular_name : $row->post_type,
				'post_status'         => $row->post_status,
				'post_status_label'   => $status_obj ? $status_obj->label : ucfirst( $row->post_status ),
				'timestamp'           => $ts,
				'datetime_display'    => wp_date( 'Y-m-d H:i', $ts ),
				'delta_seconds'       => $delta,
				'delta_label'         => $this->format_delta( $delta ),
				'actions'             => $actions,
				'actions_summary'     => $this->format_actions_summary( $actions ),
				'edit_link'           => get_edit_post_link( $post_id, 'raw' ),
				'view_link'           => get_permalink( $post_id ),
			);
		}
		return $out;
	}

	private function format_delta( int $delta ): string {
		if ( $delta <= 0 ) {
			$ago_hours = (int) floor( abs( $delta ) / 3600 );
			if ( $ago_hours < 1 ) {
				return __( 'due now', 'bw-dev' );
			}
			/* translators: %d: hours overdue */
			return sprintf( _n( 'overdue %dh', 'overdue %dh', $ago_hours, 'bw-dev' ), $ago_hours );
		}
		$hours = (int) floor( $delta / 3600 );
		if ( $hours < 1 ) {
			$minutes = (int) ceil( $delta / 60 );
			/* translators: %d: minutes until */
			return sprintf( _n( 'in %dm', 'in %dm', $minutes, 'bw-dev' ), max( 1, $minutes ) );
		}
		if ( $hours < 48 ) {
			/* translators: %d: hours until */
			return sprintf( _n( 'in %dh', 'in %dh', $hours, 'bw-dev' ), $hours );
		}
		$days = (int) floor( $hours / 24 );
		/* translators: %d: days until */
		return sprintf( _n( 'in %dd', 'in %dd', $days, 'bw-dev' ), $days );
	}

	private function format_actions_summary( array $actions ): string {
		if ( empty( $actions ) ) {
			return __( '(no actions)', 'bw-dev' );
		}
		$parts = array();
		foreach ( $actions as $a ) {
			$parts[] = $this->format_single_action_summary( $a );
		}
		return implode( '; ', $parts );
	}

	private function format_single_action_summary( array $action ): string {
		$type = isset( $action['type'] ) ? (string) $action['type'] : '';
		if ( 'change_status' === $type ) {
			$labels = $this->status_labels();
			$status = isset( $action['status'] ) ? (string) $action['status'] : '';
			$label  = isset( $labels[ $status ] ) ? $labels[ $status ] : $status;
			/* translators: %s: post status label */
			return sprintf( __( 'Change status → %s', 'bw-dev' ), $label );
		}
		if ( 'add_terms' === $type || 'remove_terms' === $type ) {
			$taxonomy = isset( $action['taxonomy'] ) ? (string) $action['taxonomy'] : '';
			$term_ids = isset( $action['terms'] ) && is_array( $action['terms'] ) ? $action['terms'] : array();
			$tax_obj  = '' !== $taxonomy && taxonomy_exists( $taxonomy ) ? get_taxonomy( $taxonomy ) : null;
			$tax_lbl  = $tax_obj ? $tax_obj->labels->singular_name : $taxonomy;
			$names    = array();
			foreach ( $term_ids as $tid ) {
				$term = get_term( (int) $tid, $taxonomy );
				if ( $term instanceof WP_Term ) {
					$names[] = $term->name;
				}
			}
			$names_str = ! empty( $names ) ? implode( ', ', $names ) : __( '(no terms)', 'bw-dev' );
			$verb      = 'add_terms' === $type ? __( 'Add term(s)', 'bw-dev' ) : __( 'Remove term(s)', 'bw-dev' );
			/* translators: 1: verb (Add/Remove term(s)), 2: taxonomy label, 3: comma-separated term names */
			return sprintf( __( '%1$s → %2$s: %3$s', 'bw-dev' ), $verb, $tax_lbl, $names_str );
		}
		return __( '(unknown action)', 'bw-dev' );
	}

	private function render_schedule_index(): void {
		$rows = $this->posts_with_schedules();
		?>
		<div class="bw-dev-psa-index" style="margin-top:30px;">
			<h3><?php esc_html_e( 'Scheduled actions index', 'bw-dev' ); ?></h3>
			<p class="description"><?php esc_html_e( 'All posts that currently have scheduled actions. Soonest first; overdue entries are flagged. Posts with multiple chained actions show every action in the Actions column.', 'bw-dev' ); ?></p>

			<?php if ( empty( $rows ) ) : ?>
				<div style="background:#f0f0f1;padding:18px;border-radius:4px;margin-top:12px;">
					<p style="margin:0;color:#50575e;">
						<span class="dashicons dashicons-info" style="margin-right:5px;"></span>
						<?php esc_html_e( 'No posts with scheduled actions.', 'bw-dev' ); ?>
					</p>
				</div>
			<?php else : ?>
				<table class="wp-list-table widefat fixed striped" style="margin-top:12px;">
					<thead>
						<tr>
							<th scope="col" style="width:24%;"><?php esc_html_e( 'Title', 'bw-dev' ); ?></th>
							<th scope="col" style="width:10%;"><?php esc_html_e( 'Type', 'bw-dev' ); ?></th>
							<th scope="col" style="width:10%;"><?php esc_html_e( 'Status', 'bw-dev' ); ?></th>
							<th scope="col" style="width:18%;"><?php esc_html_e( 'Scheduled', 'bw-dev' ); ?></th>
							<th scope="col" style="width:38%;"><?php esc_html_e( 'Actions', 'bw-dev' ); ?></th>
						</tr>
					</thead>
					<tbody>
						<?php
						$status_colors = array(
							'publish' => '#00a32a',
							'draft'   => '#d63638',
							'pending' => '#dba617',
							'private' => '#2271b1',
							'future'  => '#8c8f94',
							'trash'   => '#8c8f94',
						);
						foreach ( $rows as $r ) :
							$color    = $status_colors[ $r['post_status'] ] ?? '#8c8f94';
							$is_due   = $r['delta_seconds'] <= 0;
							$delta_bg = $is_due ? '#fcf0f1' : '#f6f7f7';
							$delta_fg = $is_due ? '#d63638' : '#646970';
							$count    = count( $r['actions'] );
							?>
							<tr>
								<td>
									<strong>
										<?php if ( $r['edit_link'] ) : ?>
											<a href="<?php echo esc_url( $r['edit_link'] ); ?>"><?php echo esc_html( $r['title'] ); ?></a>
										<?php else : ?>
											<?php echo esc_html( $r['title'] ); ?>
										<?php endif; ?>
									</strong>
									<div class="row-actions">
										<?php if ( $r['edit_link'] ) : ?>
											<span class="edit"><a href="<?php echo esc_url( $r['edit_link'] ); ?>"><?php esc_html_e( 'Edit', 'bw-dev' ); ?></a></span>
										<?php endif; ?>
										<?php if ( $r['view_link'] ) : ?>
											<?php if ( $r['edit_link'] ) : ?> | <?php endif; ?>
											<span class="view"><a href="<?php echo esc_url( $r['view_link'] ); ?>" target="_blank"><?php esc_html_e( 'View', 'bw-dev' ); ?></a></span>
										<?php endif; ?>
									</div>
								</td>
								<td>
									<span style="background:#f0f0f1;padding:3px 8px;border-radius:3px;font-size:12px;">
										<?php echo esc_html( $r['post_type_label'] ); ?>
									</span>
								</td>
								<td><span style="color:<?php echo esc_attr( $color ); ?>;font-weight:500;"><?php echo esc_html( $r['post_status_label'] ); ?></span></td>
								<td>
									<div><code style="font-size:12px;"><?php echo esc_html( $r['datetime_display'] ); ?></code></div>
									<div style="display:inline-block;margin-top:4px;background:<?php echo esc_attr( $delta_bg ); ?>;color:<?php echo esc_attr( $delta_fg ); ?>;padding:2px 8px;border-radius:3px;font-size:11px;font-weight:500;">
										<?php echo esc_html( $r['delta_label'] ); ?>
									</div>
								</td>
								<td style="font-size:13px;color:#50575e;line-height:1.5;">
									<?php if ( $count > 1 ) : ?>
										<div style="font-size:11px;color:#8c8f94;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:4px;">
											<?php
											/* translators: %d: number of chained actions */
											printf( esc_html( _n( '%d action', '%d actions chained', $count, 'bw-dev' ) ), (int) $count );
											?>
										</div>
									<?php endif; ?>
									<?php if ( $count <= 1 ) : ?>
										<?php echo esc_html( $r['actions_summary'] ); ?>
									<?php else : ?>
										<ol style="margin:0 0 0 18px;padding:0;">
											<?php foreach ( $r['actions'] as $a ) : ?>
												<li><?php echo esc_html( $this->format_single_action_summary( $a ) ); ?></li>
											<?php endforeach; ?>
										</ol>
									<?php endif; ?>
								</td>
							</tr>
						<?php endforeach; ?>
					</tbody>
				</table>
				<p style="margin-top:12px;color:#50575e;">
					<?php
					printf(
						/* translators: %d: count */
						esc_html( _n( '%d post with scheduled actions.', '%d posts with scheduled actions.', count( $rows ), 'bw-dev' ) ),
						count( $rows )
					);
					?>
				</p>
			<?php endif; ?>
		</div>
		<?php
	}

	private function status_labels(): array {
		return array(
			'publish' => __( 'Published', 'bw-dev' ),
			'draft'   => __( 'Draft', 'bw-dev' ),
			'private' => __( 'Private', 'bw-dev' ),
			'pending' => __( 'Pending Review', 'bw-dev' ),
			'trash'   => __( 'Trash', 'bw-dev' ),
		);
	}

	public function uninstall(): void {
		// Cron + post-meta cleanup is handled by uninstall.php at plugin-level
		// (it can drop in bulk via $wpdb->delete and wp_clear_scheduled_hook).
	}
}
