<?php
/**
 * Engine: item (page) exclusion. "Which items render" is identical across
 * consumers, so the exclusion store, its management API, the discovery filter,
 * and the core-list-table bulk actions all live here. Generalized from SSAA's
 * post-ID-only exclusion (SSAA_Content_Discovery::exclude/reinclude +
 * SSAA_List_Actions) to be DESCRIPTOR-AWARE: a ref is 'post:ID' for a real post
 * or 'url:sha1' for a synthesized archive URL.
 *
 * Stored as one non-autoloaded option (config exclusions['option_key']) so
 * discovery avoids meta_query and an Excluded view can enumerate cheaply.
 *
 * The persistence effect of excluding (dropping a consumer's stored result
 * rows) is NOT done here - the engine fires the handler's on_items_excluded /
 * on_items_reincluded so the consumer drops or keeps its own rows. Store +
 * discovery filter are engine-owned; only that side-effect is delegated.
 *
 * The bulk actions appear on the core Posts / Pages / CPT list tables for users
 * who can manage_options, so an operator can filter a list with any third-party
 * filter, select all, and exclude the set in one move - composing with filters
 * the engine knows nothing about. Action labels come from config so each
 * consumer reads naturally ("Exclude from scan" / "Exclude from audit").
 *
 * CANONICAL ENGINE SOURCE - edit here in _shared/shared-render-engine/src/, re-run sync.sh.
 *
 * @package plugupp-render-engine
 */

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

class SSAA_Engine_Exclusions {

	public static function init() {
		// admin_init runs after plugins_loaded, so any premium content types
		// have already joined the scope list (post_types() applies its filter).
		add_action( 'admin_init', array( __CLASS__, 'register_bulk_actions' ) );
		add_action( 'admin_notices', array( __CLASS__, 'bulk_notice' ) );
	}

	// -------------------------------------------------------------------------
	// Store + API
	// -------------------------------------------------------------------------

	/**
	 * Option key holding the excluded ref list.
	 *
	 * @return string
	 */
	private static function option_key() {
		$cfg = (array) SSAA_Engine::config( 'exclusions', array() );
		return ! empty( $cfg['option_key'] ) ? (string) $cfg['option_key'] : SSAA_Engine::key( 'excluded' );
	}

	/**
	 * The raw STORED exclusions ('post:ID' / 'url:hash') - the manual set only,
	 * WITHOUT the excluded_refs filter. A Manual-Exclusions view reads this so a
	 * premium rule layer's computed additions (which join via all()) do not appear
	 * in the manual list.
	 *
	 * @return string[]
	 */
	public static function stored() {
		return self::normalize_refs( (array) get_option( self::option_key(), array() ) );
	}

	/**
	 * All excluded refs ('post:ID' / 'url:hash').
	 *
	 * @return string[]
	 */
	public static function all() {
		$refs = self::stored();
		/**
		 * Filter the full excluded-refs set. Add-ons (e.g. a premium rule layer)
		 * append computed refs ('post:ID' / 'url:hash') here; because
		 * excluded_post_ids() and filter_descriptors() both read all(), the
		 * additions reach the discovery query AND the descriptor pass. A
		 * subscriber should cache its result per scan. No subscribers in the base
		 * (free) build.
		 *
		 * @param string[] $refs Canonical excluded refs.
		 */
		$refs = (array) apply_filters( SSAA_Engine::key( 'excluded_refs' ), self::normalize_refs( $refs ) );
		return self::normalize_refs( $refs );
	}

	/**
	 * Excluded post IDs (derived from 'post:ID' refs) for WP_Query post__not_in.
	 *
	 * @return int[]
	 */
	public static function excluded_post_ids() {
		$ids = array();
		foreach ( self::all() as $ref ) {
			if ( 0 === strpos( $ref, 'post:' ) ) {
				$id = absint( substr( $ref, 5 ) );
				if ( $id > 0 ) {
					$ids[] = $id;
				}
			}
		}
		return array_values( array_unique( $ids ) );
	}

	/**
	 * Whether a ref is currently excluded.
	 *
	 * @param string $ref Engine ref.
	 * @return bool
	 */
	public static function is_excluded( $ref ) {
		return in_array( self::normalize_ref( $ref ), self::all(), true );
	}

	/**
	 * Exclude refs and tell the consumer to drop their stored rows.
	 *
	 * @param string[] $refs Engine refs (or bare post IDs, coerced to post:ID).
	 * @return int How many were newly excluded.
	 */
	public static function exclude( array $refs ) {
		$refs    = self::normalize_refs( $refs );
		$current = self::all();
		$new     = array_values( array_diff( $refs, $current ) );

		if ( $new ) {
			update_option( self::option_key(), array_values( array_merge( $current, $new ) ), false );

			$handler = SSAA_Engine::handler();
			if ( $handler instanceof SSAA_Engine_Handler ) {
				$handler->on_items_excluded( $new );
			}

			SSAA_Logger::info( 'items_excluded', array( 'refs' => $new ) );
		}

		return count( $new );
	}

	/**
	 * Re-include previously excluded refs. They reappear after the next scan.
	 *
	 * @param string[] $refs Engine refs (or bare post IDs).
	 * @return int How many were re-included.
	 */
	public static function reinclude( array $refs ) {
		$refs    = self::normalize_refs( $refs );
		$current = self::all();
		$removed = array_values( array_intersect( $current, $refs ) );
		$kept    = array_values( array_diff( $current, $refs ) );

		if ( $removed ) {
			update_option( self::option_key(), $kept, false );

			$handler = SSAA_Engine::handler();
			if ( $handler instanceof SSAA_Engine_Handler ) {
				$handler->on_items_reincluded( $removed );
			}

			SSAA_Logger::info( 'items_reincluded', array( 'refs' => $removed ) );
		}

		return count( $removed );
	}

	/**
	 * Drop excluded descriptors from a discovery list (both kinds). Each
	 * descriptor must carry a 'ref'.
	 *
	 * @param array[] $descriptors Descriptors from the handler's discover().
	 * @return array[] Descriptors with excluded refs removed.
	 */
	public static function filter_descriptors( array $descriptors ) {
		$excluded = self::all();
		if ( $excluded ) {
			$kept = array();
			foreach ( $descriptors as $descriptor ) {
				$ref = isset( $descriptor['ref'] ) ? self::normalize_ref( (string) $descriptor['ref'] ) : '';
				if ( '' === $ref || ! in_array( $ref, $excluded, true ) ) {
					$kept[] = $descriptor;
				}
			}
			$descriptors = $kept;
		}
		/**
		 * Filter the descriptor list after ref-based exclusions are applied.
		 * Add-ons (e.g. a premium URL-pattern rule) drop further descriptors by
		 * inspecting each descriptor's permalink. No subscribers in the base build.
		 *
		 * @param array[] $descriptors Surviving descriptors.
		 */
		return (array) apply_filters( SSAA_Engine::key( 'filter_descriptors' ), $descriptors );
	}

	/**
	 * Normalise a single ref. Bare digits become 'post:N'; 'post:N' and
	 * 'url:<40hex>' pass through; anything else returns '' (dropped).
	 *
	 * @param string $ref Raw ref.
	 * @return string Canonical ref or ''.
	 */
	private static function normalize_ref( $ref ) {
		$ref = trim( (string) $ref );

		if ( ctype_digit( $ref ) && (int) $ref > 0 ) {
			return 'post:' . absint( $ref );
		}
		if ( preg_match( '/^post:([0-9]+)$/', $ref, $m ) && (int) $m[1] > 0 ) {
			return 'post:' . absint( $m[1] );
		}
		if ( preg_match( '/^url:[a-f0-9]{40}$/', $ref ) ) {
			return $ref;
		}
		return '';
	}

	/**
	 * Normalise and de-dupe a list of refs, dropping invalid ones.
	 *
	 * @param array $refs Raw refs.
	 * @return string[]
	 */
	private static function normalize_refs( array $refs ) {
		$out = array();
		foreach ( $refs as $ref ) {
			$norm = self::normalize_ref( is_scalar( $ref ) ? (string) $ref : '' );
			if ( '' !== $norm ) {
				$out[] = $norm;
			}
		}
		return array_values( array_unique( $out ) );
	}

	// -------------------------------------------------------------------------
	// Core list-table bulk actions (Posts / Pages / CPTs)
	// -------------------------------------------------------------------------

	private static function action_exclude() {
		return SSAA_Engine::key( 'exclude_items' );
	}

	private static function action_include() {
		return SSAA_Engine::key( 'include_items' );
	}

	private static function arg_excluded() {
		return SSAA_Engine::key( 'excluded' );
	}

	private static function arg_included() {
		return SSAA_Engine::key( 'included' );
	}

	/**
	 * Hook the core bulk-action filters for every in-scope post type's list
	 * table. Adding filters is cheap and harmless on non-edit screens.
	 */
	public static function register_bulk_actions() {
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}

		foreach ( SSAA_Engine_Discovery::post_types() as $type ) {
			$screen = 'edit-' . $type;
			add_filter( "bulk_actions-{$screen}", array( __CLASS__, 'add_bulk_actions' ) );
			add_filter( "handle_bulk_actions-{$screen}", array( __CLASS__, 'handle_bulk_action' ), 10, 3 );
		}
	}

	/**
	 * Append the two actions to a list table's Bulk actions dropdown. Labels
	 * come from config so each consumer reads naturally.
	 *
	 * @param array $actions Existing bulk actions.
	 * @return array
	 */
	public static function add_bulk_actions( $actions ) {
		$cfg = (array) SSAA_Engine::config( 'exclusions', array() );

		$exclude_label = ! empty( $cfg['exclude_label'] ) ? (string) $cfg['exclude_label'] : __( 'Exclude from scan', 'search-social-appearance-audit' );
		$include_label = ! empty( $cfg['include_label'] ) ? (string) $cfg['include_label'] : __( 'Include in scan', 'search-social-appearance-audit' );

		$actions[ self::action_exclude() ] = $exclude_label;
		$actions[ self::action_include() ] = $include_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_bulk_actions() keeps the actions admin-only, re-checked here.
	 *
	 * @param string $sendback Redirect URL core will send the user to.
	 * @param string $action   Chosen bulk action key.
	 * @param int[]  $post_ids Selected post IDs.
	 * @return string
	 */
	public static function handle_bulk_action( $sendback, $action, $post_ids ) {
		if ( ! current_user_can( 'manage_options' ) ) {
			return $sendback;
		}

		$ids      = array_values( array_filter( array_map( 'absint', (array) $post_ids ) ) );
		$sendback = remove_query_arg( array( self::arg_excluded(), self::arg_included() ), $sendback );

		if ( self::action_exclude() === $action ) {
			$count = self::exclude( $ids ); // bare IDs coerced to post:ID refs.
			return add_query_arg( self::arg_excluded(), $count, $sendback );
		}
		if ( self::action_include() === $action ) {
			$count = self::reinclude( $ids );
			return add_query_arg( self::arg_included(), $count, $sendback );
		}

		return $sendback;
	}

	/**
	 * Success notice on the list-table screen after the redirect. Shown only on
	 * an in-scope post type's edit screen.
	 */
	public static function bulk_notice() {
		$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
		if ( ! $screen || 'edit' !== $screen->base ) {
			return;
		}
		if ( ! in_array( $screen->post_type, SSAA_Engine_Discovery::post_types(), true ) ) {
			return;
		}

		// The product noun (config 'exclusions.noun') so the notice names the same
		// audit as the bulk-action labels - unambiguous when several PlugUpp plugins
		// add actions to the same list table. Falls back to 'scan' for consumers
		// that do not set it.
		$cfg  = (array) SSAA_Engine::config( 'exclusions', array() );
		$noun = ! empty( $cfg['noun'] ) ? (string) $cfg['noun'] : __( 'scan', 'search-social-appearance-audit' );

		// 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::arg_excluded() ] ) ) {
			// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- see above.
			$n = absint( wp_unslash( $_GET[ self::arg_excluded() ] ) );
			printf(
				'<div class="notice notice-success is-dismissible"><p>%s</p></div>',
				esc_html( sprintf(
					/* translators: 1: number of items, 2: audit name (e.g. "Link Health audit") */
					_n( '%1$d item excluded from the %2$s.', '%1$d items excluded from the %2$s.', $n, 'search-social-appearance-audit' ),
					$n,
					$noun
				) )
			);
		}
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display of a count from our own redirect.
		if ( isset( $_GET[ self::arg_included() ] ) ) {
			// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- see above.
			$n = absint( wp_unslash( $_GET[ self::arg_included() ] ) );
			printf(
				'<div class="notice notice-success is-dismissible"><p>%s</p></div>',
				esc_html( sprintf(
					/* translators: 1: number of items, 2: audit name (e.g. "Link Health audit") */
					_n( '%1$d item included in the %2$s. It reappears after the next scan.', '%1$d items included in the %2$s. They reappear after the next scan.', $n, 'search-social-appearance-audit' ),
					$n,
					$noun
				) )
			);
		}
	}
}
