<?php

namespace The7\Admin\Notices\Announcements;

defined( 'ABSPATH' ) || exit;

/**
 * Announcement-only shortcode vocabulary.
 *
 * Server-authored announcement messages may embed a small set of theme-provided
 * shortcodes (e.g. [upgrade_button]). This class expands them at render time and
 * strips any unsupported shortcode-shaped tokens. The tags are never registered as
 * global WordPress shortcodes: they exist only for the duration of process().
 * See docs/adr/0008-announcement-shortcodes.md.
 */
class Announcement_Shortcodes {

	/**
	 * Element id of the License Swap modal container.
	 */
	const LICENSE_SWAP_POPUP_ID = 'the7-license-swap-popup';

	/**
	 * Whether the License Swap modal markup is already on the page.
	 *
	 * A message may carry [license_swap_button] more than once, and two announcements
	 * may render on the same load. Only the first occurrence emits the form; every later
	 * button targets that same modal, so ids stay unique.
	 *
	 * @var bool
	 */
	private static $license_swap_form_rendered = false;

	/**
	 * Forget that the License Swap modal was rendered.
	 *
	 * The guard tracks one page render, which is normally one request. Anything that
	 * renders more than one page per request (tests) must reset it in between.
	 *
	 * @return void
	 */
	public static function reset_license_swap_form() {
		self::$license_swap_form_rendered = false;
	}

	/**
	 * Render a raw announcement message: sanitize, expand our shortcodes, strip the rest.
	 *
	 * @param string $raw Raw server-authored message.
	 *
	 * @return string
	 */
	public function process( $raw ) {
		$content = wp_kses_post( (string) $raw );
		$content = $this->expand_shortcodes( $content );
		$content = $this->strip_unsupported_shortcodes( $content );

		return $content;
	}

	/**
	 * Announcement-scoped shortcode registry: tag => callback.
	 *
	 * @return array
	 */
	private function get_registry() {
		return [
			'upgrade_button'      => [ $this, 'upgrade_button' ],
			'license_swap_button' => [ $this, 'license_swap_button' ],
		];
	}

	/**
	 * Expand our shortcodes while our registry is the only one WordPress knows about.
	 *
	 * The global $shortcode_tags is swapped for our private registry, run through WP's
	 * own parser, and restored in a finally block so a callback fault cannot leak our
	 * registry (or an emptied registry) into the rest of the request.
	 *
	 * @param string $content Sanitized content.
	 *
	 * @return string
	 */
	private function expand_shortcodes( $content ) {
		global $shortcode_tags;

		$backup         = $shortcode_tags;
		$shortcode_tags = $this->get_registry();

		try {
			return do_shortcode( $content );
		} finally {
			$shortcode_tags = $backup;
		}
	}

	/**
	 * Remove any remaining (unsupported) shortcodes, together with what they enclose.
	 *
	 * After expansion the only bracketed tokens left are shortcodes we do not support.
	 * An enclosing pair and everything between it goes as one unit: the enclosed text is
	 * an argument to a tag this theme cannot honour, so showing it bare would leak copy
	 * the server meant as a button label or a hidden block. Removal repeats because one
	 * pass only clears the innermost pair of a nested tag; the orphan pass then takes the
	 * self-closing tags plus any half of a pair whose partner never arrived.
	 *
	 * The patterns are kept conservative (a leading bracket followed by a tag-name shape)
	 * to minimize false positives on literal brackets in server prose.
	 *
	 * @param string $content Expanded content.
	 *
	 * @return string
	 */
	private function strip_unsupported_shortcodes( $content ) {
		$pairs = '/\[([a-z0-9_-]+)[^\]]*\][\s\S]*?\[\/\1\]/i';

		do {
			$content = preg_replace( $pairs, '', $content, -1, $removed );
		} while ( $removed > 0 );

		return preg_replace( '/\[\/?[a-z0-9_-]+[^\]]*\]/i', '', $content );
	}

	/**
	 * [upgrade_button]Label[/upgrade_button] — theme-owned upgrade button.
	 *
	 * Forces the newer line from the Upgrade Channel through WP's theme upgrader. The
	 * label (shortcode content) is server-authored, so it is escaped here; expansion
	 * runs after wp_kses_post, so the callback owns the safety of its own output.
	 * Renders nothing when the label is empty or the theme-upgrade module is absent.
	 *
	 * @param array       $atts    Shortcode attributes (unused).
	 * @param string|null $content Enclosed label text.
	 *
	 * @return string
	 */
	public function upgrade_button( $atts, $content = null ) {
		$text = trim( (string) $content );

		if ( '' === $text ) {
			return '';
		}

		if ( ! $this->theme_update_module_available() ) {
			return '';
		}

		return sprintf(
			'<a class="button button-primary" href="%s">%s</a>',
			esc_url( \Presscore_Modules_ThemeUpdateModule::get_theme_upgrade_url() ),
			esc_html( $text )
		);
	}

	/**
	 * [license_swap_button]Label[/license_swap_button] — open the License Swap modal.
	 *
	 * Renders a button plus (once per page) the hidden modal holding a registration form
	 * without the consent checkbox, so an already-registered install can move onto a new
	 * purchase code without deregistering first. The form posts to the ordinary
	 * registration handler; see docs/adr/0011-license-swap-registration-post.md.
	 *
	 * The theme applies no platform check: which customers see the button is the server's
	 * decision, expressed by placing (or omitting) the tag. The capability check mirrors
	 * the dashboard's own registration block, so the button never appears to a user the
	 * POST handler would reject.
	 *
	 * Renders nothing when the label is empty or the user may not manage the license.
	 *
	 * @param array       $atts    Shortcode attributes (unused).
	 * @param string|null $content Enclosed label text.
	 *
	 * @return string
	 */
	public function license_swap_button( $atts, $content = null ) {
		$text = trim( (string) $content );

		if ( '' === $text ) {
			return '';
		}

		if ( ! is_super_admin() ) {
			return '';
		}

		// The modal's form posts to this module and reads its Swap marker.
		if ( ! $this->theme_update_module_available() ) {
			return '';
		}

		$button = sprintf(
			'<a href="#" data-the7-popup="%s">%s</a>',
			esc_attr( self::LICENSE_SWAP_POPUP_ID ),
			esc_html( $text )
		);

		if ( self::$license_swap_form_rendered ) {
			return $button;
		}

		self::$license_swap_form_rendered = true;

		return $button . sprintf(
			'<div id="%s" style="display:none;">%s</div>',
			esc_attr( self::LICENSE_SWAP_POPUP_ID ),
			$this->get_license_swap_form()
		);
	}

	/**
	 * Whether the theme-update module both tags depend on is loaded.
	 *
	 * It is in the always-load set, so this is a guard rather than a real branch: the
	 * module can only be absent when something has filtered it out, and both callbacks
	 * dereference it — one for the upgrade URL, one for the Swap marker.
	 *
	 * @return bool
	 */
	private function theme_update_module_available() {
		return class_exists( \Presscore_Modules_ThemeUpdateModule::class );
	}

	/**
	 * Capture the License Swap form partial.
	 *
	 * The partial is theme-authored markup (settings_fields() echoes), so it is buffered
	 * rather than sanitized — unlike the server-authored message around it.
	 *
	 * @return string
	 */
	private function get_license_swap_form() {
		ob_start();
		require __DIR__ . '/../../screens/partials/dashboard/license-swap-form.php';

		return (string) ob_get_clean();
	}
}
