<?php
/**
 * Widget Editor Support
 *
 * Preserves Alpine.js widgets, <link>, <style>, and <script> tags when editing
 * posts in WordPress. Forces the Classic Editor for posts containing widgets,
 * whitelists Alpine.js directives through KSES, and injects CSS/JS into the
 * TinyMCE iframe for live preview.
 *
 * Ported from the standalone "Wisewand Widget Editor Support" plugin (v1.0.0)
 * by the CTO into the main plugin.
 *
 * @package Wisewand
 * @since 2.8.0
 * @license GPL-2.0-or-later
 *
 * Copyright (C) 2024 WiseWand
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU General Public License for more details.
 */
namespace Wisewand\Editor;

// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

// phpcs:disable WordPress.PHP.DevelopmentFunctions.error_log_error_log -- error_log() is only used within WP_DEBUG conditionals for debugging

class Widget_Editor_Support {

	/**
	 * Cached Alpine.js KSES attributes.
	 *
	 * @var array|null
	 */
	private $alpine_attrs = null;

	/**
	 * Alpine.js version for cache busting.
	 *
	 * @var string
	 */
	const ALPINE_VERSION = '3.14.1';

	/**
	 * Constructor.
	 */
	public function __construct() {
		// No dependencies needed — this component is self-contained.
	}

	/**
	 * Get the local Alpine.js bundle URL.
	 *
	 * @since 2.8.0
	 * @updated 2.9.10 — switched from CSP build to full build so widgets can
	 *                   evaluate object literals, assignments, ternaries, and
	 *                   method calls inside x-data / x-effect expressions.
	 *
	 * @return string Local URL to the Alpine.js build.
	 */
	private function get_alpine_url() {
		return WISEWAND_PLUGIN_URL . 'assets/js/vendor/alpine.min.js';
	}

	/**
	 * Whitelist Alpine.js directives on HTML elements through KSES.
	 *
	 * Adds x-* attributes to common HTML tags so KSES does not strip them
	 * when saving post content.
	 *
	 * @since 2.8.0
	 *
	 * @param array  $tags    Allowed HTML tags and their attributes.
	 * @param string $context The context (e.g., 'post').
	 * @return array Modified allowed tags.
	 */
	public function allow_alpine_kses( $tags, $context ) {
		if ( 'post' !== $context ) {
			return $tags;
		}

		// Build Alpine.js attributes list once and cache.
		if ( null === $this->alpine_attrs ) {
			$this->alpine_attrs = array(
				// Core directives.
				'x-data'      => true,
				'x-init'      => true,
				'x-show'      => true,
				'x-bind'      => true,
				'x-on'        => true,
				'x-text'      => true,
				'x-model'     => true,
				'x-modelable' => true,
				'x-for'       => true,
				'x-transition' => true,
				'x-effect'    => true,
				'x-ignore'    => true,
				'x-ref'       => true,
				'x-cloak'     => true,
				'x-teleport'  => true,
				'x-if'        => true,
				'x-id'        => true,
				// Plugin directives.
				'x-mask'      => true,
				'x-intersect' => true,
				'x-trap'      => true,
				'x-collapse'  => true,
				// Events (x-on:*).
				'x-on:click'           => true,
				'x-on:click.prevent'   => true,
				'x-on:click.stop'      => true,
				'x-on:click.outside'   => true,
				'x-on:submit'          => true,
				'x-on:submit.prevent'  => true,
				'x-on:input'           => true,
				'x-on:change'          => true,
				'x-on:focus'           => true,
				'x-on:blur'            => true,
				'x-on:keydown'         => true,
				'x-on:keydown.enter'   => true,
				'x-on:keydown.escape'  => true,
				'x-on:keyup'           => true,
				'x-on:mouseenter'      => true,
				'x-on:mouseleave'      => true,
				// Bindings (x-bind:*).
				'x-bind:class'         => true,
				'x-bind:style'         => true,
				'x-bind:id'            => true,
				'x-bind:disabled'      => true,
				'x-bind:hidden'        => true,
				'x-bind:open'          => true,
				'x-bind:href'          => true,
				'x-bind:src'           => true,
				'x-bind:value'         => true,
				'x-bind:aria-expanded' => true,
				'x-bind:aria-hidden'   => true,
				'x-bind:role'          => true,
				'x-bind:tabindex'      => true,
				// Modifiers.
				'x-model.lazy'         => true,
				'x-model.number'       => true,
				'x-transition:enter'   => true,
				'x-transition:leave'   => true,
			);
		}

		// Apply Alpine.js attributes to all common HTML elements.
		$html_tags = array(
			'div', 'span', 'section', 'article', 'nav', 'header', 'footer',
			'main', 'form', 'button', 'input', 'select', 'textarea', 'option',
			'optgroup', 'fieldset', 'legend', 'datalist', 'output', 'progress', 'meter',
			'a', 'p', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
			'table', 'tr', 'td', 'th', 'img', 'label', 'template', 'details', 'summary',
		);

		// Form-element HTML attributes. WordPress's default "post" KSES context
		// does not include <input>/<select>/etc. at all, so without this list
		// interactive widget inputs lose their `type`, `name`, `value`, `checked`,
		// `min`, `max`, etc. on save and render as empty text boxes.
		// @since 2.9.11
		$form_attrs = array(
			'type'             => true,
			'name'             => true,
			'value'            => true,
			'id'               => true,
			'class'            => true,
			'style'            => true,
			'title'            => true,
			'placeholder'      => true,
			'min'              => true,
			'max'              => true,
			'step'             => true,
			'minlength'        => true,
			'maxlength'        => true,
			'pattern'          => true,
			'required'         => true,
			'disabled'         => true,
			'readonly'         => true,
			'checked'          => true,
			'selected'         => true,
			'multiple'         => true,
			'size'             => true,
			'for'              => true,
			'form'             => true,
			'autocomplete'     => true,
			'autofocus'        => true,
			'inputmode'        => true,
			'accept'           => true,
			'action'           => true,
			'method'           => true,
			'enctype'          => true,
			'novalidate'       => true,
			'target'           => true,
			'rows'             => true,
			'cols'             => true,
			'wrap'             => true,
			'role'             => true,
			'tabindex'         => true,
			'aria-label'       => true,
			'aria-labelledby'  => true,
			'aria-describedby' => true,
			'aria-hidden'      => true,
			'aria-expanded'    => true,
			'aria-controls'    => true,
			'aria-live'        => true,
		);

		$form_tags = array(
			'form', 'input', 'select', 'textarea', 'option', 'optgroup',
			'button', 'label', 'fieldset', 'legend', 'datalist', 'output',
		);
		$form_tags_lookup = array_flip( $form_tags );

		foreach ( $html_tags as $tag ) {
			if ( ! isset( $tags[ $tag ] ) ) {
				$tags[ $tag ] = array();
			}
			if ( is_array( $tags[ $tag ] ) ) {
				$tags[ $tag ] = array_merge( $tags[ $tag ], $this->alpine_attrs );
				if ( isset( $form_tags_lookup[ $tag ] ) ) {
					$tags[ $tag ] = array_merge( $tags[ $tag ], $form_attrs );
				}
			}
		}

		// Allow <link> and <style> with safe attributes only.
		$tags['link'] = array(
			'rel'         => true,
			'href'        => true,
			'type'        => true,
			'media'       => true,
			'crossorigin' => true,
		);

		$tags['style'] = array(
			'type'  => true,
			'media' => true,
		);

		return $tags;
	}

	/**
	 * Convert Alpine.js shorthand to full directives on classic save.
	 *
	 * Converts @click → x-on:click, :class → x-bind:class, etc.
	 * Runs early (priority 5) before KSES processes the content.
	 *
	 * @since 2.8.0
	 *
	 * @param string $content Post content.
	 * @return string Modified content.
	 */
	public function convert_shorthand( $content ) {
		if ( empty( $content ) ) {
			return $content;
		}

		// @event → x-on:event.
		$content = preg_replace( '/(<[^>]*)\s@([\w][\w.:-]*)\s*=/s', '$1 x-on:$2=', $content );

		// :attr → x-bind:attr.
		$content = preg_replace( '/(<[^>]*)\s:([\w][\w.-]*)\s*=/s', '$1 x-bind:$2=', $content );

		return $content;
	}

	/**
	 * Convert Alpine.js shorthand for REST API saves.
	 *
	 * Same conversion as convert_shorthand() but for the REST API path.
	 *
	 * @since 2.8.0
	 *
	 * @param \stdClass        $post    Post object to be inserted.
	 * @param \WP_REST_Request $request REST request object.
	 * @return \stdClass Modified post object.
	 */
	public function convert_rest( $post, $request ) {
		if ( isset( $post->post_content ) ) {
			$post->post_content = $this->convert_shorthand( $post->post_content );
		}
		return $post;
	}

	/**
	 * Protect script/link/style tags before the editor loads.
	 *
	 * Base64-encodes allowed tags as HTML comments so TinyMCE and KSES
	 * do not strip them. Only protects the Alpine.js CDN script (not
	 * arbitrary scripts).
	 *
	 * @since 2.8.0
	 *
	 * @param string $content Editor content.
	 * @return string Content with protected tags encoded as comments.
	 */
	public function protect_tags_before_editor( $content ) {
		if ( ! current_user_can( 'unfiltered_html' ) ) {
			return $content;
		}

		// Alpine.js script (CDN or local bundle, CSP or full build).
		$content = preg_replace_callback(
			'/<script\b[^>]*\bsrc\s*=\s*["\'][^"\']*alpine[^"\']*["\'][^>]*>\s*<\/script>/is',
			function ( $m ) {
				// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Used for TinyMCE tag protection, not obfuscation
				return '<!--PROTECTED_SCRIPT:' . base64_encode( $m[0] ) . ':SCRIPT_END-->';
			},
			$content
		);

		// <link ... /> or <link ...>.
		$content = preg_replace_callback(
			'/<link\b[^>]*\/?>/i',
			function ( $m ) {
				// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
				return '<!--PROTECTED_LINK:' . base64_encode( $m[0] ) . ':LINK_END-->';
			},
			$content
		);

		// <style ...>...</style>.
		$content = preg_replace_callback(
			'/<style\b[^>]*>.*?<\/style>/is',
			function ( $m ) {
				// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
				return '<!--PROTECTED_STYLE:' . base64_encode( $m[0] ) . ':STYLE_END-->';
			},
			$content
		);

		return $content;
	}

	/**
	 * Restore protected tags after KSES has run.
	 *
	 * Decodes base64 HTML comments back to real tags. Runs at priority 50
	 * (after KSES at priority 10).
	 *
	 * @since 2.8.0
	 *
	 * @param string $content Post content.
	 * @return string Content with tags restored.
	 */
	public function restore_tags_after_kses( $content ) {
		if ( ! current_user_can( 'unfiltered_html' ) ) {
			return $content;
		}

		$content = preg_replace_callback(
			'/<!--PROTECTED_SCRIPT:([A-Za-z0-9+\/=]+):SCRIPT_END-->/',
			function ( $m ) {
				// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
				return base64_decode( $m[1] );
			},
			$content
		);

		$content = preg_replace_callback(
			'/<!--PROTECTED_LINK:([A-Za-z0-9+\/=]+):LINK_END-->/',
			function ( $m ) {
				// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
				return base64_decode( $m[1] );
			},
			$content
		);

		$content = preg_replace_callback(
			'/<!--PROTECTED_STYLE:([A-Za-z0-9+\/=]+):STYLE_END-->/',
			function ( $m ) {
				// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
				return base64_decode( $m[1] );
			},
			$content
		);

		return $content;
	}

	/**
	 * Configure TinyMCE for widget preview.
	 *
	 * Allows all elements, injects widget CSS and Alpine.js into the
	 * TinyMCE iframe for live WYSIWYG preview.
	 *
	 * @since 2.8.0
	 *
	 * @param array $settings TinyMCE settings.
	 * @return array Modified settings.
	 */
	public function configure_tinymce( $settings ) {
		global $post;

		$content = ( $post && ! empty( $post->post_content ) ) ? $post->post_content : '';

		// Inject external CSS into TinyMCE's content_css.
		if ( $content ) {
			$css_urls = array();

			// WiseWand widget CSS (fetched from API by prefix).
			foreach ( $this->get_widget_prefixes( $content ) as $prefix ) {
				$css_urls[] = 'https://app.wisewand.ai/api/css/' . $prefix;
			}

			// <link rel="stylesheet" href="..."> (both attribute orders).
			if ( preg_match_all( '/<link\b[^>]*\brel\s*=\s*["\']stylesheet["\'][^>]*\bhref\s*=\s*["\']([^"\']+)["\'][^>]*\/?>/i', $content, $m ) ) {
				$css_urls = array_merge( $css_urls, $m[1] );
			}
			if ( preg_match_all( '/<link\b[^>]*\bhref\s*=\s*["\']([^"\']+)["\'][^>]*\brel\s*=\s*["\']stylesheet["\'][^>]*\/?>/i', $content, $m ) ) {
				$css_urls = array_merge( $css_urls, $m[1] );
			}
			$css_urls = array_unique( $css_urls );

			if ( ! empty( $css_urls ) ) {
				$existing                = isset( $settings['content_css'] ) ? $settings['content_css'] : '';
				$settings['content_css'] = $existing
					? $existing . ',' . implode( ',', $css_urls )
					: implode( ',', $css_urls );
			}
		}

		// Allow all attributes on common HTML elements.
		$settings['extended_valid_elements'] = implode( ',', array(
			'div[*]', 'span[*]', 'button[*]', 'input[*]', 'form[*]',
			'section[*]', 'article[*]', 'nav[*]', 'header[*]', 'footer[*]',
			'p[*]', 'a[*]', 'ul[*]', 'ol[*]', 'li[*]', 'label[*]',
			'select[*]', 'option[*]', 'textarea[*]', 'table[*]', 'tr[*]',
			'td[*]', 'th[*]', 'template[*]', 'details[*]', 'summary[*]',
			'link[*]', 'script[*]', 'style[*]',
		) );

		$settings['valid_children']  = '+body[link|script|style],+div[link|script|style]';
		$settings['custom_elements'] = 'link,script,style';
		$settings['verify_html']     = false;

		// Inject Alpine.js and inline styles into the TinyMCE iframe.
		$alpine_url = esc_js( $this->get_alpine_url() );
		$js_inject  = "var s=doc.createElement('script');s.src='{$alpine_url}';s.defer=true;doc.head.appendChild(s);";

		// Collect inline <style> contents from post content.
		if ( $content && preg_match_all( '/<style\b[^>]*>(.*?)<\/style>/is', $content, $m ) ) {
			foreach ( $m[1] as $css ) {
				$safe       = esc_js( $css );
				$js_inject .= "var st=doc.createElement('style');st.textContent='{$safe}';doc.head.appendChild(st);";
			}
		}

		$existing_cb                           = isset( $settings['init_instance_callback'] ) ? $settings['init_instance_callback'] : '';
		$settings['init_instance_callback']    = "function(editor){var doc=editor.getDoc();{$js_inject}{$existing_cb}}";

		return $settings;
	}

	/**
	 * Force Classic Editor for posts containing widgets or special tags.
	 *
	 * @since 2.8.0
	 *
	 * @param bool     $use  Whether to use the block editor.
	 * @param \WP_Post $post Post object.
	 * @return bool False to force Classic Editor, original value otherwise.
	 */
	public function maybe_force_classic_editor( $use, $post ) {
		if ( ! empty( $post->post_content ) && $this->needs_classic_editor( $post->post_content ) ) {
			return false;
		}
		return $use;
	}

	/**
	 * Show a bilingual notice when editing widget posts.
	 *
	 * Displayed at the top of the edit form to inform editors they should
	 * use the Text tab in the Classic Editor.
	 *
	 * @since 2.8.0
	 *
	 * @param \WP_Post $post Post being edited.
	 */
	public function show_wisewand_notice( $post ) {
		if ( empty( $post->post_content ) || ! $this->needs_classic_editor( $post->post_content ) ) {
			return;
		}

		$locale = function_exists( 'get_user_locale' ) ? get_user_locale() : get_locale();

		if ( 0 === strpos( $locale, 'fr' ) ) {
			$title = __( "Cet article contient des composants Wisewand ou du code HTML avanc\xc3\xa9", 'wisewand-ai-content-writer-seo-content-generator' );
			$text  = __( "Il est n\xc3\xa9cessaire d'utiliser l'\xc3\xa9diteur classique (onglet <strong>\xc2\xab Texte \xc2\xbb</strong>) pour pr\xc3\xa9server le code HTML, CSS et JS.", 'wisewand-ai-content-writer-seo-content-generator' );
		} else {
			$title = __( 'This post contains Wisewand components or advanced HTML code', 'wisewand-ai-content-writer-seo-content-generator' );
			$text  = __( 'You must use the Classic Editor (<strong>"Text"</strong> tab) to preserve the HTML, CSS and JS code.', 'wisewand-ai-content-writer-seo-content-generator' );
		}

		printf(
			'<div class="notice notice-warning" style="margin:15px 0;padding:12px;">'
			. '<strong>%s</strong><br>%s'
			. '</div>',
			esc_html( $title ),
			wp_kses( $text, array( 'strong' => array() ) )
		);
	}

	/**
	 * Enqueue Alpine.js CSP build on the frontend.
	 *
	 * Only loads on singular posts/pages whose content contains WiseWand
	 * widget markers (x-data attributes or *-widget CSS classes). This
	 * avoids loading ~15 KB of JS on pages that don't need it.
	 *
	 * @since 2.8.0
	 * @updated 2.9.1 — smart detection: only load when widgets are present.
	 */
	public function enqueue_alpine() {
		if ( is_admin() ) {
			return;
		}

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

		wp_enqueue_script(
			'alpinejs',
			$this->get_alpine_url(),
			array(),
			self::ALPINE_VERSION,
			true
		);
	}

	/**
	 * Prevent cache plugins from deferring or combining Alpine.js.
	 *
	 * Adds data-no-defer and data-no-optimize attributes to the Alpine.js
	 * script tag so LiteSpeed Cache, WP Rocket, and Autoptimize leave it alone.
	 *
	 * @since 2.9.1
	 *
	 * @param string $tag    The full script tag HTML.
	 * @param string $handle The script handle.
	 * @return string Modified script tag.
	 */
	public function exclude_alpine_from_cache( $tag, $handle ) {
		if ( 'alpinejs' !== $handle ) {
			return $tag;
		}

		// data-no-defer: LiteSpeed Cache exclusion.
		// data-no-optimize: Autoptimize / WP Rocket exclusion.
		return str_replace( ' src=', ' data-no-defer="1" data-no-optimize="1" src=', $tag );
	}

	/**
	 * Check if the current page needs Alpine.js.
	 *
	 * Returns true when the queried post content contains WiseWand widget
	 * markers (x-data attributes or *-widget CSS classes).
	 *
	 * @since 2.9.1
	 *
	 * @return bool True if Alpine.js should be loaded.
	 */
	private function current_page_needs_alpine() {
		if ( ! is_singular() ) {
			return false;
		}

		$post = get_queried_object();

		if ( ! $post || ! isset( $post->post_content ) ) {
			return false;
		}

		$content = $post->post_content;

		// Check for Alpine.js directives or WiseWand widget CSS classes.
		if ( false !== strpos( $content, 'x-data' ) ) {
			return true;
		}
		if ( preg_match( '/class="[^"]*-widget/', $content ) ) {
			return true;
		}

		return false;
	}

	/**
	 * Deactivate the standalone Wisewand Widget Editor Support plugin.
	 *
	 * Checks if the standalone plugin is active and deactivates it,
	 * setting a transient to show a one-time admin notice.
	 *
	 * @since 2.8.0
	 */
	public function deactivate_standalone_plugin() {
		if ( ! function_exists( 'is_plugin_active' ) ) {
			require_once ABSPATH . 'wp-admin/includes/plugin.php';
		}

		$known_slugs = array(
			'wisewand-widget-editor-support-wordpress-plugin/wisewand-wordpress-widgets-extension.php',
		);

		foreach ( $known_slugs as $slug ) {
			if ( is_plugin_active( $slug ) ) {
				deactivate_plugins( $slug );
				set_transient( 'wisewand_standalone_widget_deactivated', $slug, 300 );

				if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
					error_log( 'Wisewand: Deactivated standalone Widget Editor Support plugin: ' . $slug );
				}
			}
		}
	}

	/**
	 * Show admin notice after standalone plugin deactivation.
	 *
	 * Displays a one-time dismissible notice informing the user that
	 * the standalone plugin has been deactivated.
	 *
	 * @since 2.8.0
	 */
	public function show_standalone_deactivation_notice() {
		$deactivated = get_transient( 'wisewand_standalone_widget_deactivated' );
		if ( ! $deactivated ) {
			return;
		}

		delete_transient( 'wisewand_standalone_widget_deactivated' );

		printf(
			'<div class="notice notice-success is-dismissible"><p>%s</p></div>',
			esc_html__(
				'The standalone "Wisewand Widget Editor Support" plugin has been deactivated. This functionality is now built into the main Wisewand plugin.',
				'wisewand-ai-content-writer-seo-content-generator'
			)
		);
	}

	/**
	 * Check if post content requires the Classic Editor.
	 *
	 * Returns true if the content contains WiseWand widget classes or
	 * link/style/script tags that would be stripped by the block editor.
	 *
	 * @since 2.8.0
	 *
	 * @param string $content Post content.
	 * @return bool True if Classic Editor is needed.
	 */
	private function needs_classic_editor( $content ) {
		// WiseWand widget class pattern.
		if ( preg_match( '/class\s*=\s*["\'][^"\']*\b[\w]+-widget\b[^"\']*["\']/', $content ) ) {
			return true;
		}

		// <link>, <style>, or <script> tags.
		if ( preg_match( '/<(link|style|script)\b/i', $content ) ) {
			return true;
		}

		return false;
	}

	/**
	 * Extract widget prefixes from CSS classes in content.
	 *
	 * Finds classes like "foo-widget" and returns the prefix ("foo").
	 * Used to load per-widget CSS from the WiseWand API.
	 *
	 * @since 2.8.0
	 *
	 * @param string $content Post content.
	 * @return array Unique widget prefixes.
	 */
	private function get_widget_prefixes( $content ) {
		preg_match_all( '/class\s*=\s*["\'][^"\']*\b([\w]+)-widget\b[^"\']*["\']/', $content, $matches );

		$prefixes = array();
		if ( ! empty( $matches[1] ) ) {
			foreach ( $matches[1] as $prefix ) {
				if ( 'wwc' !== $prefix ) {
					$prefixes[] = sanitize_key( $prefix );
				}
			}
		}

		return array_unique( $prefixes );
	}
}
