<?php

/**
 * Emoji picker — the safe emoji set, the per-user favorites/quick shelf, and localized search data.
 *
 * Ported from ListoWP's Listo_Emoji. Unlike Reactions, this inserts a Unicode glyph into the text the
 * user is composing (post body, and — in later passes — comments & messages); it is not a reaction.
 *
 * THE SET is standard-derived, not hand-curated: `assets/emoji/set.json` is generated from Unicode
 * **emoji-test.txt** at a pinned baseline (VERSION) by 3/classes/developer-tools/emoji-make-data.php —
 * every glyph is RGI, fully-qualified, a SINGLE OS-rendered scalar (no skin-tone / ZWJ / flag / keycap),
 * so it renders natively on essentially every reasonably-recent device (E12.0 = 2019 baseline). It is
 * loaded lazily and cached statically, NOT parsed on every request. Shape:
 * { glyph => { g: group, en: "english keywords" } } in emoji-test order.
 *
 * KEYWORDS for localized search are NOT bundled — they are hosted as a versioned JSON at
 * `<base>/<VERSION>/<locale>.json` (generated from CLDR by the same dev tool). Default host is
 * KEYWORDS_BASE_URL below; fetched server-side, cached in PeepSo3_Mayfly, English-fallback when
 * unreachable. Override the host with a `PEEPSO_EMOJI_KEYWORDS_URL` constant or the
 * `peepso_emoji_keywords_base_url` filter (set '' to disable → English-only).
 *
 * FAVORITES: each user's most-used emoji are tracked in user meta (account-synced) and surface as the
 * picker's quick shelf — frequency-sorted, capped at one row (QUICK_MAX), no backfill (only glyphs the
 * user has actually inserted, so it's empty until they use one).
 *
 * The full client payload (set + active-locale keywords + quick shelf) is served on demand by
 * /peepso/v1/emoji (PeepSo3_REST_V1_Endpoint_Emoji), not inlined on every page load — the set is ~1.2k
 * glyphs.
 */
class PeepSoEmoji
{
	private static $_instance = NULL;

	/** Pinned emoji baseline — also the keyword path segment AND the mayfly cache-key version. */
	const VERSION = '12.0';

	/** Default keyword host. Resolves to `<base>/<VERSION>/<locale>.json`, e.g.
	 *  https://peepso.com/static/emoji/12.0/pl.json. Set '' (or override via constant/filter) to
	 *  disable remote fetch → English-only. */
	const KEYWORDS_BASE_URL = 'https://peepso.com/static/emoji';

	/** Locales we publish keyword files for. A WP locale not listed falls back to its base language. */
	const LOCALES = array('en', 'pl', 'es', 'it', 'de', 'fr', 'nl', 'pt', 'pt_BR', 'ru', 'uk', 'ja', 'bg', 'mk', 'tr', 'vi', 'id', 'zh');

	/** Quick shelf shows at most this many — one full picker row (6 columns). No backfill: the shelf
	 *  holds ONLY glyphs the user has actually inserted, so a fresh user sees no Favorites section. */
	const QUICK_MAX = 6;

	/** Favorites are kept top-24, so the shelf survives a few one-offs without churn. */
	const FAVORITES_MAX = 24;

	/** WP user-meta key for the per-user frequency map. */
	const META_FAVORITES = 'peepso_emoji_favorites';

	/** emoji-test group → picker category key. Smileys & Emotion + People & Body merge to "smileys". */
	const GROUP_MAP = array(
		'Smileys & Emotion' => 'smileys',
		'People & Body'     => 'smileys',
		'Animals & Nature'  => 'nature',
		'Food & Drink'      => 'food',
		'Travel & Places'   => 'places',
		'Activities'        => 'activities',
		'Objects'           => 'objects',
		'Symbols'           => 'symbols',
		'Flags'             => 'flags',
	);

	/** @var array<string,array>|null lazily-loaded glyph => ['g'=>group,'en'=>keywords] */
	private static $set = NULL;

	private function __construct()
	{
		add_action('peepso_init', array(&$this, 'init'));
	}

	public static function get_instance()
	{
		if (NULL === self::$_instance) {
			self::$_instance = new self();
		}
		return (self::$_instance);
	}

	public function init()
	{
		if (is_admin()) {
			return;
		}

		// Render OS-native emoji everywhere (picker + posted content). WP 7.0's emoji detection over-replaces
		// — it probes a 2025 Unicode 17.0 glyph (🫈) no shipping OS has yet, fails, and swaps the whole set
		// for Twemoji images via a MutationObserver — and PeepSo's existing JS disable loses an async race.
		// Doing it server-side is reliable and version-proof. See disable_wp_emoji().
		$this->disable_wp_emoji();

		add_action('wp_enqueue_scripts', array(&$this, 'enqueue_scripts'));

		// Post composer: only the new postbox is wired (the legacy postbox is out of scope).
		if (PeepSo::get_option('postbox_new_enabled', 1)) {
			add_filter('peepso_postbox_options', array(&$this, 'filter_postbox_options'), 25);
		}

		// Comment/reply composer (independent of the postbox setting). Late priority so we run after other
		// plugins (photos, GIF, …) have added their buttons, then prepend emoji to lead the toolbar.
		add_filter('peepso_commentsbox_interactions', array(&$this, 'filter_commentsbox_interactions'), 999, 2);

		// Legacy postbox toolbar (independent of the postbox setting). This is the composer used for EDITING a
		// post (activity.option_edit → ps_postbox, template general/postbox.php) and the legacy main composer.
		// Priority 25 places emoji right after Mood (added at 20), mirroring the new postbox's mood(20)→emoji(25)
		// option order so both composers read "mood, emoji".
		add_filter('peepso_postbox_interactions', array(&$this, 'filter_postbox_interactions'), 25, 2);

		// Chat message composer (peepso-core-chat). The messagebox toolbar drives the full conversation
		// view and the "new message" modal; the filter is harmless if chat is inactive.
		add_filter('peepso_messagebox_options', array(&$this, 'filter_messagebox_options'), 999);

		// Chat pop-up widget — a separate addon row in the floating window input.
		add_filter('peepso_message_input_addons', array(&$this, 'filter_message_input_addons'), 999, 1);
	}

	/**
	 * Register + enqueue the emoji bundle and its localized chrome strings.
	 */
	public function enqueue_scripts()
	{
		if (!is_user_logged_in()) {
			return;
		}

		wp_register_script('peepso-emoji', PeepSo::get_asset('js/emoji.min.js'), array('peepso', 'peepso-postbox'), PeepSo::PLUGIN_VERSION, TRUE);

		wp_localize_script('peepso-emoji', 'peepsoemojidata', array(
			'version'  => self::VERSION,
			'locale'   => self::active_locale_code(),
			'rest'     => esc_url_raw(rest_url('peepso/v1/emoji')),
			// The picker shell (search + grid). Mounted into a body-level popover by the JS so it floats
			// anchored to the trigger instead of covering the composer — reusable across surfaces.
			'template' => PeepSoTemplate::exec_template('general', 'emoji-picker', NULL, TRUE),
			'lang'     => array(
				'title'     => __('Emoji', 'peepso-core'),
				'search'    => __('Search', 'peepso-core'),
				'favorites' => __('Frequently used', 'peepso-core'),
				'empty'     => __('No emoji found', 'peepso-core'),
				'close'     => __('Close', 'peepso-core'),
			),
		));

		wp_enqueue_script('peepso-emoji');
	}

	/**
	 * Add the emoji button to the new postbox toolbar. No dropdown `html` — the JS opens a body-mounted
	 * popover anchored to this button (see assets/js/emoji/popover.js) so the composer isn't covered.
	 */
	public function filter_postbox_options($options = array())
	{
		$options['emoji'] = array(
			'icon'  => 'pso-i-face-smile-hearts',
			'label' => __('Emoji', 'peepso-core'),
		);
		return $options;
	}

	/**
	 * Add the emoji button to the comment/reply composer toolbar. No `click` handler — the JS
	 * (assets/js/emoji/comment.js) binds a delegated listener on .ps-js-comment-emoji, opens the shared
	 * EmojiPopover anchored to the button, and inserts the glyph into that comment's textarea.
	 */
	public function filter_commentsbox_interactions($acts = array(), $post_id = FALSE)
	{
		$emoji = array(
			'emoji' => array(
				'icon'  => 'pso-i-face-smile-hearts',
				'class' => 'ps-comments__input-action ps-js-comment-emoji',
				'title' => __('Emoji', 'peepso-core'),
			),
		);
		// Union with emoji first so it leads the toolbar ahead of any other plugins' buttons.
		return $emoji + (is_array($acts) ? $acts : array());
	}

	/**
	 * Add the emoji button to the legacy postbox toolbar — this is the composer used for EDITING a post
	 * (and the legacy main composer). Rendered by PeepSoPostbox::post_interactions() as an interaction item;
	 * assets/js/emoji/postedit.js binds a delegated click on .ps-js-postedit-emoji, opens the shared
	 * EmojiPopover anchored to it, and inserts into the postbox textarea. `click` makes post_interactions()
	 * emit the styled toggle anchor; the pick is handled in JS. Appended (at priority 25, right after Mood)
	 * so the toolbar reads "mood, emoji" — matching the new postbox.
	 */
	public function filter_postbox_interactions($interactions = array(), $params = array())
	{
		if (!is_array($interactions)) {
			$interactions = array();
		}
		$interactions['emoji'] = array(
			'id'    => 'ps-postbox-emoji',
			'class' => 'ps-js-postedit-emoji',
			'icon'  => 'pso-i-face-smile-hearts',
			'title' => __('Emoji', 'peepso-core'),
			'click' => 'return false;',
		);
		return $interactions;
	}

	/**
	 * Add the emoji button to the chat messagebox toolbar (full conversation view + "new message" modal).
	 * Button-only (`html` empty) — assets/js/emoji/messagebox.js opens the shared EmojiPopover anchored to
	 * it on `messagebox_init` and inserts into `messagebox.$textarea`. Prepended so it leads the toolbar.
	 */
	public function filter_messagebox_options($options = array())
	{
		$emoji = array(
			'emoji' => array(
				'icon'  => 'pso-i-face-smile-hearts',
				'label' => __('Emoji', 'peepso-core'),
				'html'  => '',
			),
		);
		return $emoji + (is_array($options) ? $options : array());
	}

	/**
	 * Add the emoji trigger to the chat pop-up window input addons (the floating widget). Mirrors Giphy's
	 * trigger (templates/giphy/message-input.php); assets/js/emoji/chatwindow.js binds a delegated click on
	 * .ps-js-chat-emoji, opens the shared EmojiPopover, and inserts into that window's .ps-chat__window-input.
	 * Prepended to lead the addon row.
	 */
	public function filter_message_input_addons($addons)
	{
		$html = '<i class="ps-chat__window-input-addon pso-i-face-smile-hearts ps-js-chat-emoji" role="button" tabindex="0" title="' . esc_attr__('Emoji', 'peepso-core') . '"></i>';
		if (!is_array($addons)) {
			$addons = array();
		}
		array_unshift($addons, $html);
		return $addons;
	}

	/**
	 * Stop WordPress's emoji (Twemoji) replacement on the frontend so emoji always render as native OS
	 * glyphs — in the picker and in posted content — instead of s.w.org img.wp-smiley images. This is what
	 * PeepSo already attempts in JS (assets/js/activity.js), but WP 7.0's async, Worker-based detection
	 * recomputes supports.everything after the fact and wins the race; removing the detection script
	 * server-side is the reliable, version-proof fix. Runs frontend-only for all users (content is viewed by
	 * guests too). Called from init() (peepso_init, before wp_head's priority-7 detection hook). Email/feed
	 * staticizing is left alone (different rendering target).
	 */
	public function disable_wp_emoji()
	{
		remove_action('wp_head', 'print_emoji_detection_script', 7);
		remove_action('wp_print_styles', 'print_emoji_styles');

		add_filter('tiny_mce_plugins', array(&$this, 'remove_tinymce_emoji'));
		add_filter('wp_resource_hints', array(&$this, 'remove_emoji_dns_prefetch'), 10, 2);
	}

	/** Drop the emoji TinyMCE plugin so frontend editors don't reintroduce Twemoji. */
	public function remove_tinymce_emoji($plugins)
	{
		return is_array($plugins) ? array_diff($plugins, array('wpemoji')) : array();
	}

	/** Drop the s.w.org emoji dns-prefetch hint that the detection script would otherwise add. */
	public function remove_emoji_dns_prefetch($urls, $relation_type)
	{
		if ('dns-prefetch' === $relation_type) {
			$svg = 'https://s.w.org/images/core/emoji/';
			$urls = array_filter($urls, function ($u) use ($svg) {
				$href = is_array($u) && isset($u['href']) ? $u['href'] : (is_string($u) ? $u : '');
				return FALSE === strpos($href, $svg);
			});
		}
		return $urls;
	}

	// ── The set ──────────────────────────────────────────────────────────────────────────────────

	/** The generated safe set (lazy, cached). Empty array if set.json is missing. */
	public static function set()
	{
		if (NULL === self::$set) {
			$file = dirname(__DIR__) . '/assets/emoji/set.json';
			$json = is_readable($file) ? file_get_contents($file) : '';
			$data = $json ? json_decode($json, TRUE) : NULL;
			self::$set = is_array($data) ? $data : array();
		}
		return self::$set;
	}

	/** True if $glyph is in the safe set (the only emojis the endpoint will record as a favorite). */
	public static function is_allowed($glyph)
	{
		return is_string($glyph) && '' !== $glyph && array_key_exists($glyph, self::set());
	}

	/** Resolved keyword host base URL ('' disables remote fetch → English-only search). */
	public static function base_url()
	{
		$url = defined('PEEPSO_EMOJI_KEYWORDS_URL') ? PEEPSO_EMOJI_KEYWORDS_URL : self::KEYWORDS_BASE_URL;
		return (string) apply_filters('peepso_emoji_keywords_base_url', $url);
	}

	// ── Favorites (account-synced, drives the quick shelf) ─────────────────────────────────────────

	/** The current user's emoji-frequency map [ glyph => count ]. */
	public static function favorites()
	{
		$uid = get_current_user_id();
		if (!$uid) {
			return array();
		}
		$f = get_user_meta($uid, self::META_FAVORITES, TRUE);
		return is_array($f) ? $f : array();
	}

	/** Record one use of a glyph for the current user (top-24, written on each insert). */
	public static function bump_favorite($glyph)
	{
		$uid = get_current_user_id();
		if (!$uid || !self::is_allowed($glyph)) {
			return;
		}
		$f = self::favorites();
		$f[$glyph] = (int) (isset($f[$glyph]) ? $f[$glyph] : 0) + 1;
		arsort($f);
		if (count($f) > self::FAVORITES_MAX) {
			$f = array_slice($f, 0, self::FAVORITES_MAX, TRUE);
		}
		update_user_meta($uid, self::META_FAVORITES, $f);
	}

	/** The quick shelf: the user's most-used favorites, frequency-sorted, capped at QUICK_MAX (one row).
	 *  No backfill — only glyphs actually inserted, so it's empty for a user who hasn't used one yet. */
	public static function quick_shelf()
	{
		$fav = self::favorites();
		arsort($fav);

		$shelf = array();
		foreach (array_keys($fav) as $g) {
			if (self::is_allowed($g)) {
				$shelf[] = $g;
				if (count($shelf) >= self::QUICK_MAX) {
					break;
				}
			}
		}
		return $shelf;
	}

	// ── Client payload + keyword fetch ─────────────────────────────────────────────────────────────

	/** Translated label for a picker category key (the few group headers — frontend chrome). */
	private static function group_label($key)
	{
		switch ($key) {
			case 'smileys':    return __('Smileys & People', 'peepso-core');
			case 'nature':     return __('Animals & Nature', 'peepso-core');
			case 'food':       return __('Food & Drink', 'peepso-core');
			case 'places':     return __('Travel & Places', 'peepso-core');
			case 'activities': return __('Activities', 'peepso-core');
			case 'objects':    return __('Objects', 'peepso-core');
			case 'symbols':    return __('Symbols', 'peepso-core');
			case 'flags':      return __('Flags', 'peepso-core');
		}
		return $key;
	}

	/**
	 * The full picker payload (served by /peepso/v1/emoji): { set:[{glyph,keywords,group}],
	 * groups:[{key,label}], quick:[glyphs], search:bool }. Each item's `group` is the picker category key
	 * (see GROUP_MAP); `groups` is the ordered, translated category list for the section headers.
	 * Keywords = bundled English UNION the active locale's hosted keywords.
	 */
	public static function data_for_client()
	{
		$code      = self::active_locale_code();
		$localized = ('en' === $code) ? NULL : self::keywords_for_locale($code);

		$set    = array();
		$groups = array();
		$seen   = array();
		foreach (self::set() as $glyph => $meta) {
			$keywords = isset($meta['en']) ? (string) $meta['en'] : '';
			if ($localized && !empty($localized[$glyph])) {
				$keywords = self::merge_keywords($keywords, $localized[$glyph]);
			}
			$raw = isset($meta['g']) ? (string) $meta['g'] : '';
			$key = isset(self::GROUP_MAP[$raw]) ? self::GROUP_MAP[$raw] : 'symbols';
			$set[] = array('glyph' => $glyph, 'keywords' => $keywords, 'group' => $key);
			if (empty($seen[$key])) {
				$seen[$key] = 1;
				$groups[]   = array('key' => $key, 'label' => self::group_label($key));
			}
		}

		return array('set' => $set, 'groups' => $groups, 'quick' => self::quick_shelf(), 'search' => !empty($set));
	}

	/**
	 * Localized keyword map for a locale code: glyph => "space separated terms", or null when no hosted
	 * data is reachable. Cached in mayfly (success 7d, miss 1h). Non-English misses fall back to 'en'.
	 */
	public static function keywords_for_locale($code)
	{
		$key    = 'emoji_kw_' . str_replace(array('.', '-'), '_', self::VERSION . '_' . $code);
		$cached = PeepSo3_Mayfly::get($key);
		if (is_array($cached)) {
			return isset($cached['__miss']) ? NULL : $cached;
		}

		$day  = defined('DAY_IN_SECONDS') ? DAY_IN_SECONDS : 86400;
		$hour = defined('HOUR_IN_SECONDS') ? HOUR_IN_SECONDS : 3600;

		$data = self::fetch_remote($code);
		if (is_array($data) && $data) {
			PeepSo3_Mayfly::set($key, $data, 7 * $day);
			return $data;
		}

		PeepSo3_Mayfly::set($key, array('__miss' => 1), $hour); // negative-cache a down/empty host
		return ('en' !== $code) ? self::keywords_for_locale('en') : NULL;
	}

	/** The active WP locale narrowed to a published keyword-file code (base-language fallback). */
	private static function active_locale_code()
	{
		$locale = function_exists('determine_locale') ? determine_locale() : get_locale();

		if (in_array($locale, self::LOCALES, TRUE)) {
			return $locale;
		}
		$lang = strtolower(substr((string) $locale, 0, 2));
		return in_array($lang, self::LOCALES, TRUE) ? $lang : 'en';
	}

	/** GET the hosted <base>/<version>/<code>.json and reduce it to {glyph => "terms"} for the SET. */
	private static function fetch_remote($code)
	{
		$base = self::base_url();
		if ('' === $base) {
			return NULL;
		}

		$url  = trailingslashit($base) . self::VERSION . '/' . $code . '.json';
		$resp = wp_remote_get($url, array('timeout' => 4, 'redirection' => 2));
		if (is_wp_error($resp) || 200 !== (int) wp_remote_retrieve_response_code($resp)) {
			return NULL;
		}

		$data = json_decode(wp_remote_retrieve_body($resp), TRUE);
		if (!is_array($data)) {
			return NULL;
		}

		$out = array();
		foreach (self::set() as $glyph => $meta) {
			if (empty($data[$glyph])) {
				continue;
			}
			$kw          = $data[$glyph];
			$out[$glyph] = is_array($kw) ? implode(' ', array_map('strval', $kw)) : (string) $kw;
		}
		return $out;
	}

	/** English terms + localized terms, lower-cased, whitespace-collapsed, de-duplicated, re-joined. */
	private static function merge_keywords($en, $loc)
	{
		$terms = preg_split('/\s+/u', strtolower(trim($en . ' ' . $loc)));
		$terms = array_values(array_unique(array_filter((array) $terms, 'strlen')));
		return implode(' ', $terms);
	}
}

// EOF
