<?php
/**
 * Shared sitewide-findings banner (canonical source - vendored per plugin via
 * sync.sh; the placeholder tokens are rewritten to the consumer's prefix
 * EXCEPT the deliberately-shared `ssaa_site_notice` stem, see below).
 *
 * The cross-plugin contract (_standards/docs/DECISIONS.md "Sitewide-issue
 * notices", 2026-07-23): sitewide findings surface as ONE consistent
 * dismissible info banner on each plugin's own Dashboard. The component owns
 * the render, the per-user TIMESTAMP dismissal, and the re-surface rule
 * (DOIG favicon-notice semantics, canonical): a newer check that still finds
 * the issue re-surfaces the banner; a clean check clears it for everyone.
 * Detection and storage stay per-plugin - the consumer feeds findings via a
 * getter callable at boot.
 *
 * TWO THINGS ARE SHARED ACROSS ALL PLUGUPP PLUGINS BY DESIGN (the sync
 * exempts the `ssaa_site_notice` stem from prefix rewriting):
 *
 * 1. THE DISMISSAL STORE - user meta `_plugupp_site_notice_dismissals`, an
 *    array of check code => unix timestamp. Dismissing a check on ONE
 *    PlugUpp dashboard dismisses it on ALL of them (a user should never
 *    dismiss "no favicon" twice on two of our plugins).
 * 2. THE OWNERSHIP FILTER - `plugupp_site_notice_owner_{code}`: a plugin
 *    that authoritatively detects a check code adds_filter(..., '__return_true')
 *    so sibling plugins with a courtesy detector for the same code can DEFER
 *    (skip their own detection/rendering). See the favicon flip decision,
 *    2026-07-24: SSAA owns favicon_missing; DOIG defers when SSAA answers.
 *
 * Finding shape (from the consumer's getter):
 *   { code: string,        stable check code - the dedupe/dismissal key
 *     label: string,       short human name
 *     message: string,     one-paragraph explanation (already translated)
 *     checked_at: int }    unix time of the check that produced it
 *
 * @package search-social-appearance-audit
 */

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

class SSAA_Site_Notices {

	/** SHARED across all PlugUpp plugins - never prefix-rewritten. */
	const DISMISSALS_META = '_plugupp_site_notice_dismissals';

	/** @var array Boot config. */
	private static $config = array();

	/**
	 * Boot the banner for this plugin.
	 *
	 * @param array $config {
	 *   @type string[] $screen_ids      Screen ids the banner may render on
	 *                                   (the plugin's own Dashboard - a nudge,
	 *                                   never a sitewide admin alarm).
	 *   @type callable $findings_getter Returns the finding list (shape above).
	 *                                   Called only on the configured screens.
	 *   @type string   $findings_url    "See details" destination.
	 *   @type string   $heading         Optional heading override.
	 * }
	 */
	public static function boot( array $config ) {
		self::$config = $config;

		add_action( 'admin_init', array( __CLASS__, 'maybe_handle_dismiss' ) );
		add_action( 'admin_notices', array( __CLASS__, 'maybe_render' ) );
	}

	/**
	 * The findings currently visible to this user: fresher than the user's
	 * shared dismissal stamp for that code (or never dismissed), and not
	 * deferred to an owning sibling plugin.
	 *
	 * @return array[]
	 */
	public static function visible_findings() {
		if ( empty( self::$config['findings_getter'] ) || ! is_callable( self::$config['findings_getter'] ) ) {
			return array();
		}

		$dismissals = (array) get_user_meta( get_current_user_id(), self::DISMISSALS_META, true );
		$visible    = array();

		foreach ( (array) call_user_func( self::$config['findings_getter'] ) as $finding ) {
			$finding = (array) $finding;
			$code    = isset( $finding['code'] ) ? (string) $finding['code'] : '';
			if ( '' === $code ) {
				continue;
			}

			/**
			 * Cross-plugin ownership/deferral (SHARED - never rewritten): when
			 * a sibling plugin authoritatively owns this check code, this
			 * plugin's courtesy copy of the finding is suppressed here. The
			 * OWNER never suppresses its own findings because the owner is the
			 * one who added the filter - it reports the code through its own
			 * getter and renders normally.
			 *
			 * Consumers with a courtesy detector should ALSO consult this
			 * filter before running detection at all (skip the work, not just
			 * the render); this render-time check is the safety net.
			 *
			 * @param bool $owned_elsewhere Default false.
			 */
			// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- deliberately CROSS-PLUGIN: 'plugupp' is this vendor's org-level prefix and this filter is the shared ownership/deferral channel between PlugUpp plugins (see the class docblock); a per-plugin prefix would defeat it. Protected from the sync's prefix rewrite for the same reason.
			if ( ! empty( $finding['deferrable'] ) && apply_filters( 'plugupp_site_notice_owner_' . $code, false ) ) {
				continue;
			}

			$checked_at   = isset( $finding['checked_at'] ) ? (int) $finding['checked_at'] : 0;
			$dismissed_at = isset( $dismissals[ $code ] ) ? (int) $dismissals[ $code ] : 0;
			if ( $dismissed_at >= $checked_at ) {
				continue; // Dismissed since the check that produced it.
			}

			$visible[ $code ] = $finding;
		}

		return array_values( $visible );
	}

	/**
	 * Render the banner on the configured screens when findings are visible.
	 */
	public static function maybe_render() {
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}

		$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
		if ( ! $screen || empty( self::$config['screen_ids'] ) || ! in_array( $screen->id, (array) self::$config['screen_ids'], true ) ) {
			return;
		}

		$findings = self::visible_findings();
		if ( empty( $findings ) ) {
			return;
		}

		$heading = ! empty( self::$config['heading'] )
			? (string) self::$config['heading']
			: sprintf(
				/* translators: %d: number of site-wide findings */
				_n( '%d site-wide finding needs a look.', '%d site-wide findings need a look.', count( $findings ), 'search-social-appearance-audit' ),
				count( $findings )
			);

		$dismiss_url = wp_nonce_url( add_query_arg( 'ssaa_dismiss_notices', '1' ), 'ssaa_dismiss_site_notices' );

		echo '<div class="notice notice-info plugupp-site-notice">';
		echo '<p><strong>' . esc_html( $heading ) . '</strong></p>';
		echo '<ul style="list-style:disc;margin:0 0 8px 20px;">';
		foreach ( $findings as $finding ) {
			echo '<li><strong>' . esc_html( (string) $finding['label'] ) . '</strong> - ' . esc_html( (string) $finding['message'] ) . '</li>';
		}
		echo '</ul>';
		echo '<p style="margin-bottom:0;">';
		if ( ! empty( self::$config['findings_url'] ) ) {
			echo '<a href="' . esc_url( (string) self::$config['findings_url'] ) . '">' . esc_html__( 'See details', 'search-social-appearance-audit' ) . '</a> &nbsp;·&nbsp; ';
		}
		echo '<a href="' . esc_url( $dismiss_url ) . '" class="button-link" style="color:#646970;">' . esc_html__( 'Dismiss this notice', 'search-social-appearance-audit' ) . '</a>';
		echo '</p>';
		echo '</div>';
	}

	/**
	 * Handle the GET dismissal: stamp NOW against every currently-visible
	 * check code in the SHARED store, so the dismissal covers the same codes
	 * on every PlugUpp dashboard. Timestamps, not flags - a later check that
	 * still finds an issue re-surfaces it.
	 */
	public static function maybe_handle_dismiss() {
		if ( ! isset( $_GET['ssaa_dismiss_notices'] ) ) {
			return;
		}
		if ( ! current_user_can( 'manage_options' ) || ! check_admin_referer( 'ssaa_dismiss_site_notices' ) ) {
			return;
		}

		$user_id    = get_current_user_id();
		$dismissals = (array) get_user_meta( $user_id, self::DISMISSALS_META, true );
		foreach ( self::visible_findings() as $finding ) {
			$dismissals[ (string) $finding['code'] ] = time();
		}
		update_user_meta( $user_id, self::DISMISSALS_META, $dismissals );

		wp_safe_redirect( remove_query_arg( array( 'ssaa_dismiss_notices', '_wpnonce' ) ) );
		exit;
	}
}
