<?php
/**
 * Plugin Name: MQTT-Plug
 * Description: Lightweight, general purpose MQTT-over-WebSocket viewer for WordPress; supports Live (admin) and Observer (shareable) modes.
 * Version: 1.0
 * Requires at least: 6.0
 * Requires PHP: 7.4
 * Author: potar
 * License: GPL-2.0-or-later
 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain: mqtt-plug
 */

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

final class MQTT_Plug {
    const VER = '0.2.2';
    const OPT_KEY  = 'mqtt_plug_options';

    // Snapshot storage (observer mode); kept small and fast.
    const OPT_SNAP_LATEST = 'mqtt_plug_snapshot_latest';
    const OPT_SNAP_LOG    = 'mqtt_plug_snapshot_log';

    // REST namespace.
    const REST_NS = 'mqtt-plug/v1';

    public static function init(){
        add_action('admin_menu', [__CLASS__, 'admin_menu']);
        add_action('admin_init', [__CLASS__, 'register_settings']);

        add_filter('kses_allowed_protocols', [__CLASS__, 'allow_ws_protocols']);
        add_action('wp_enqueue_scripts', [__CLASS__, 'frontend_assets']);
        add_shortcode('mqtt_plug', [__CLASS__, 'shortcode']);

        add_action('rest_api_init', [__CLASS__, 'register_rest_routes']);

        register_activation_hook(__FILE__, [__CLASS__, 'activate']);
    }

    public static function activate(){
        $o = self::get_options();
        if (empty($o['ingest_token'])) {
            $o['ingest_token'] = wp_generate_password(32, false, false);
            update_option(self::OPT_KEY, $o);
        }
    }

    public static function defaults(){
        return [
            // WebSocket URL to broker; example: wss://mqtt.example.com:9001/mqtt
            'ws_url'        => '',
            'username'      => '',
            'password'      => '',
            'client_prefix' => 'mqtt-plug-',
            'keepalive'     => 20,
            'reconnect_ms'  => 5000,
            'debug'         => 0,
            'autoconnect'   => 1,

            // Observer snapshots.
            'server_cache'       => 1,
            'ingest_token'       => '',
            'observer_poll_ms'   => 5000,
            'offline_after_sec'  => 300,

            // Retention limits for snapshot log.
            'max_log_items'      => 200,
            'max_latest_items'   => 100,
            'retention_hours'    => 24,

            // Security: allowlist topic patterns for snapshot ingestion and snapshot viewing.
            // Supports MQTT wildcards + and #.
            // Leave empty to allow all (not recommended on public sites).
            'topic_allowlist'    => "#",
        ];
    }

    
public static function allow_ws_protocols($protocols){
    if (!in_array('ws', $protocols, true))  { $protocols[] = 'ws'; }
    if (!in_array('wss', $protocols, true)) { $protocols[] = 'wss'; }
    return $protocols;
}

public static function get_options(){
        $opts = get_option(self::OPT_KEY, []);
        if (!is_array($opts)) { $opts = []; }
        return array_merge(self::defaults(), $opts);
    }

    public static function admin_menu(){
        add_options_page(
            __('MQTT-Plug', 'mqtt-plug'),
            __('MQTT-Plug', 'mqtt-plug'),
            'manage_options',
            'mqtt-plug',
            [__CLASS__, 'settings_page']
        );
    }

    public static function register_settings(){
        register_setting('mqtt_plug', self::OPT_KEY, [
            'type'              => 'array',
            'sanitize_callback' => [__CLASS__, 'sanitize_options'],
            'default'           => self::defaults(),
        ]);
    }

    public static function sanitize_options($in){
        $d = self::defaults();
        $o = is_array($in) ? $in : [];

        $out = [];
        $out['ws_url'] = isset($o['ws_url'])
            ? esc_url_raw(trim((string)$o['ws_url']), ['http','https','ws','wss'])
            : $d['ws_url'];
        $out['username']      = isset($o['username']) ? sanitize_text_field((string)$o['username']) : $d['username'];

        // Password: store as plain text option; typical for broker creds; consider application passwords or secrets vault later.
        $out['password']      = isset($o['password']) ? (string)$o['password'] : $d['password'];

        $out['client_prefix'] = isset($o['client_prefix']) ? sanitize_key((string)$o['client_prefix']) : $d['client_prefix'];
        if ($out['client_prefix'] === '') { $out['client_prefix'] = $d['client_prefix']; }

        $out['keepalive']     = max(5, min(300, intval($o['keepalive'] ?? $d['keepalive'])));
        $out['reconnect_ms']  = max(250, min(600000, intval($o['reconnect_ms'] ?? $d['reconnect_ms'])));
        $out['debug']         = !empty($o['debug']) ? 1 : 0;
        $out['autoconnect']   = !empty($o['autoconnect']) ? 1 : 0;

        $out['server_cache']      = !empty($o['server_cache']) ? 1 : 0;
        $out['ingest_token']      = sanitize_text_field((string)($o['ingest_token'] ?? $d['ingest_token']));
        $out['observer_poll_ms']  = max(1000, min(600000, intval($o['observer_poll_ms'] ?? $d['observer_poll_ms'])));
        $out['offline_after_sec'] = max(30, min(86400, intval($o['offline_after_sec'] ?? $d['offline_after_sec'])));

        $out['max_log_items']    = max(50, min(2000, intval($o['max_log_items'] ?? $d['max_log_items'])));
        $out['max_latest_items'] = max(10, min(500, intval($o['max_latest_items'] ?? $d['max_latest_items'])));
        $out['retention_hours']  = max(1, min(720, intval($o['retention_hours'] ?? $d['retention_hours'])));

        $allow = isset($o['topic_allowlist']) ? trim((string)$o['topic_allowlist']) : $d['topic_allowlist'];
        $allow = preg_replace('/[^\w\-\+\/\#\.\:\,\s]/', '', $allow); // conservative allowlist characters
        $out['topic_allowlist'] = $allow === '' ? $d['topic_allowlist'] : $allow;

        // Ensure token exists if server cache is enabled.
// Do NOT rotate token on every save; only create if missing in DB.
$stored = get_option(self::OPT_KEY, []);
$stored_token = (is_array($stored) && !empty($stored['ingest_token'])) ? (string)$stored['ingest_token'] : '';

if ($out['server_cache']) {
    if ($out['ingest_token'] === '') {
        $out['ingest_token'] = $stored_token !== '' ? $stored_token : wp_generate_password(32, false, false);
    }
} else {
    // Keep token stable even if server cache is disabled.
    if ($out['ingest_token'] === '' && $stored_token !== '') {
        $out['ingest_token'] = $stored_token;
    }
}

// Ensure token exists if server cache is enabled.
// Important: do NOT rotate the token on every save. Only create if the stored option is empty.
$stored = get_option(self::OPT_KEY, []);
$stored_token = (is_array($stored) && !empty($stored['ingest_token'])) ? (string)$stored['ingest_token'] : '';

if ($out['server_cache']) {
    if ($out['ingest_token'] === '') {
        $out['ingest_token'] = $stored_token !== '' ? $stored_token : wp_generate_password(32, false, false);
    }
} else {
    // Keep token stable even if server_cache is disabled; do not clear it automatically.
    if ($out['ingest_token'] === '' && $stored_token !== '') {
        $out['ingest_token'] = $stored_token;
    }
}

return $out;

    }

    public static function settings_page(){
        if (!current_user_can('manage_options')) { return; }
        $o = self::get_options();

        ?>
        <div class="wrap">
            <h1><?php echo esc_html__('MQTT-Plug Settings', 'mqtt-plug'); ?></h1>

            <form method="post" action="options.php">
                <?php settings_fields('mqtt_plug'); ?>
                <?php $key = self::OPT_KEY; // Escaped on output. ?>

                <h2><?php echo esc_html__('Broker Connection', 'mqtt-plug'); ?></h2>
                <table class="form-table" role="presentation">
                    <tr>
                        <th scope="row"><label for="<?php echo esc_attr($key); ?>_ws_url"><?php echo esc_html__('WebSocket URL', 'mqtt-plug'); ?></label></th>
                        <td>
                            <input class="regular-text" type="url" id="<?php echo esc_attr($key); ?>_ws_url" name="<?php echo esc_attr($key); ?>[ws_url]" value="<?php echo esc_attr($o['ws_url']); ?>" placeholder="wss://mqtt.example.com:9001/mqtt" />
                            <p class="description"><?php echo esc_html__('Your broker WebSocket endpoint (WS/WSS).', 'mqtt-plug'); ?></p>
                        </td>
                    </tr>
                    <tr>
                        <th scope="row"><label for="<?php echo esc_attr($key); ?>_username"><?php echo esc_html__('Username', 'mqtt-plug'); ?></label></th>
                        <td><input class="regular-text" type="text" id="<?php echo esc_attr($key); ?>_username" name="<?php echo esc_attr($key); ?>[username]" value="<?php echo esc_attr($o['username']); ?>" /></td>
                    </tr>
                    <tr>
                        <th scope="row"><label for="<?php echo esc_attr($key); ?>_password"><?php echo esc_html__('Password', 'mqtt-plug'); ?></label></th>
                        <td><input class="regular-text" type="password" id="<?php echo esc_attr($key); ?>_password" name="<?php echo esc_attr($key); ?>[password]" value="<?php echo esc_attr($o['password']); ?>" autocomplete="new-password" /></td>
                    </tr>
                    <tr>
                        <th scope="row"><label for="<?php echo esc_attr($key); ?>_client_prefix"><?php echo esc_html__('Client ID Prefix', 'mqtt-plug'); ?></label></th>
                        <td>
                            <input class="regular-text" type="text" id="<?php echo esc_attr($key); ?>_client_prefix" name="<?php echo esc_attr($key); ?>[client_prefix]" value="<?php echo esc_attr($o['client_prefix']); ?>" />
                            <p class="description"><?php echo esc_html__('A random suffix is added per connection.', 'mqtt-plug'); ?></p>
                        </td>
                    </tr>
                    <tr>
                        <th scope="row"><label for="<?php echo esc_attr($key); ?>_keepalive"><?php echo esc_html__('Keepalive (seconds)', 'mqtt-plug'); ?></label></th>
                        <td><input type="number" id="<?php echo esc_attr($key); ?>_keepalive" name="<?php echo esc_attr($key); ?>[keepalive]" value="<?php echo esc_attr($o['keepalive']); ?>" min="5" max="300" /></td>
                    </tr>
                    <tr>
                        <th scope="row"><label for="<?php echo esc_attr($key); ?>_reconnect_ms"><?php echo esc_html__('Reconnect Delay (ms)', 'mqtt-plug'); ?></label></th>
                        <td><input type="number" id="<?php echo esc_attr($key); ?>_reconnect_ms" name="<?php echo esc_attr($key); ?>[reconnect_ms]" value="<?php echo esc_attr($o['reconnect_ms']); ?>" min="250" max="600000" /></td>
                    </tr>
                    <tr>
                        <th scope="row"><?php echo esc_html__('Debug', 'mqtt-plug'); ?></th>
                        <td><label><input type="checkbox" name="<?php echo esc_attr($key); ?>[debug]" value="1" <?php checked(1, intval($o['debug'])); ?> /> <?php echo esc_html__('Enable console debug logs', 'mqtt-plug'); ?></label></td>
                    </tr>
                </table>

                <h2><?php echo esc_html__('Observer Mode Snapshots', 'mqtt-plug'); ?></h2>
                <table class="form-table" role="presentation">
                    <tr>
                        <th scope="row"><?php echo esc_html__('Enable Server Snapshot Cache', 'mqtt-plug'); ?></th>
                        <td>
                            <label><input type="checkbox" name="<?php echo esc_attr($key); ?>[server_cache]" value="1" <?php checked(1, intval($o['server_cache'])); ?> /> <?php echo esc_html__('Allow shareable observer dashboards without exposing broker credentials', 'mqtt-plug'); ?></label>
                        </td>
                    </tr>
                    <tr>
                        <th scope="row"><label for="<?php echo esc_attr($key); ?>_observer_poll_ms"><?php echo esc_html__('Observer Poll Interval (ms)', 'mqtt-plug'); ?></label></th>
                        <td><input type="number" id="<?php echo esc_attr($key); ?>_observer_poll_ms" name="<?php echo esc_attr($key); ?>[observer_poll_ms]" value="<?php echo esc_attr($o['observer_poll_ms']); ?>" min="1000" max="600000" /></td>
                    </tr>
                    <tr>
                        <th scope="row"><label for="<?php echo esc_attr($key); ?>_offline_after_sec"><?php echo esc_html__('Offline After (seconds)', 'mqtt-plug'); ?></label></th>
                        <td><input type="number" id="<?php echo esc_attr($key); ?>_offline_after_sec" name="<?php echo esc_attr($key); ?>[offline_after_sec]" value="<?php echo esc_attr($o['offline_after_sec']); ?>" min="30" max="86400" /></td>
                    </tr>
                    <tr>
                        <th scope="row"><label for="<?php echo esc_attr($key); ?>_topic_allowlist"><?php echo esc_html__('Topic Allowlist', 'mqtt-plug'); ?></label></th>
                        <td>
                            <textarea class="large-text" rows="3" id="<?php echo esc_attr($key); ?>_topic_allowlist" name="<?php echo esc_attr($key); ?>[topic_allowlist]"><?php echo esc_textarea($o['topic_allowlist']); ?></textarea>
                            <p class="description"><?php echo esc_html__('Comma or whitespace separated patterns. Supports MQTT wildcards + and #. Example: sensors/#, building/+/status', 'mqtt-plug'); ?></p>
                        </td>
                    </tr>
                    <tr>
                        <th scope="row"><label for="<?php echo esc_attr($key); ?>_max_latest_items"><?php echo esc_html__('Max Latest Items', 'mqtt-plug'); ?></label></th>
                        <td><input type="number" id="<?php echo esc_attr($key); ?>_max_latest_items" name="<?php echo esc_attr($key); ?>[max_latest_items]" value="<?php echo esc_attr($o['max_latest_items']); ?>" min="10" max="500" /></td>
                    </tr>
                    <tr>
                        <th scope="row"><label for="<?php echo esc_attr($key); ?>_max_log_items"><?php echo esc_html__('Max Log Items', 'mqtt-plug'); ?></label></th>
                        <td><input type="number" id="<?php echo esc_attr($key); ?>_max_log_items" name="<?php echo esc_attr($key); ?>[max_log_items]" value="<?php echo esc_attr($o['max_log_items']); ?>" min="50" max="2000" /></td>
                    </tr>
                    <tr>
                        <th scope="row"><label for="<?php echo esc_attr($key); ?>_retention_hours"><?php echo esc_html__('Retention (hours)', 'mqtt-plug'); ?></label></th>
                        <td><input type="number" id="<?php echo esc_attr($key); ?>_retention_hours" name="<?php echo esc_attr($key); ?>[retention_hours]" value="<?php echo esc_attr($o['retention_hours']); ?>" min="1" max="720" /></td>
                    </tr>
                    <tr>
    <th scope="row"><?php echo esc_html__('Ingest Token', 'mqtt-plug'); ?></th>
    <td>
        <input
            class="regular-text"
            type="text"
            readonly
            style="font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;"
            name="<?php echo esc_attr($key); ?>[ingest_token]"
            value="<?php echo esc_attr($o['ingest_token']); ?>"
        />
        <p class="description"><?php echo esc_html__('Collector uses this token to POST snapshots to /ingest. Read-only.', 'mqtt-plug'); ?></p>
    </td>
</tr>
                </table>

                <?php submit_button(); ?>
            </form>

            <hr />
            <h2><?php echo esc_html__('REST Endpoints', 'mqtt-plug'); ?></h2>
            <p><code><?php echo esc_html(rest_url(self::REST_NS . '/snapshot')); ?></code></p>
            <p><code><?php echo esc_html(rest_url(self::REST_NS . '/ingest')); ?></code></p>
            <p><code><?php echo esc_html(rest_url(self::REST_NS . '/push')); ?></code></p>
        </div>
        <?php
    }

    public static function frontend_assets(){
        // Only enqueue if shortcode present somewhere; cheap heuristic.
        // We still register so wp_add_inline_script works.
        wp_register_style('mqtt-plug', plugin_dir_url(__FILE__) . 'mqtt-plug.css', [], self::VER);
        wp_register_script('mqtt-plug', plugin_dir_url(__FILE__) . 'mqtt-plug.js', [], self::VER, true);

        if (!is_singular() && !is_front_page()) {
            // Still allow enqueue if user wants; but default only when shortcode is used.
        }
    }

    private static function enqueue_frontend(){
        wp_enqueue_style('mqtt-plug');
        wp_enqueue_script('mqtt-plug');

        $o = self::get_options();
        $can_live = current_user_can('manage_options') ? 1 : 0;

        $settings = [
            'wsUrl'        => (string)($o['ws_url'] ?? ''),
            'username'     => (string)($o['username'] ?? ''),
            'password'     => (string)($o['password'] ?? ''),
            'clientPrefix' => (string)($o['client_prefix'] ?? 'mqtt-plug-'),
            'keepalive'    => intval($o['keepalive'] ?? 20),
            'reconnectMs'  => intval($o['reconnect_ms'] ?? 5000),
            'debug'        => intval($o['debug'] ?? 0),
            'autoconnect'  => intval($o['autoconnect'] ?? 1),

            'serverCache'  => intval($o['server_cache'] ?? 0),
            'observerPollMs' => intval($o['observer_poll_ms'] ?? 5000),
            'offlineAfterSec' => intval($o['offline_after_sec'] ?? 300),

            'restUrl'      => esc_url_raw(rest_url(self::REST_NS)),
            'restNonce'    => (is_user_logged_in() ? wp_create_nonce('wp_rest') : ''),
            'canLive'      => $can_live,
            'canPush'      => (is_user_logged_in() && current_user_can('manage_options') ? 1 : 0),
        ];

        wp_add_inline_script('mqtt-plug', 'window.MQTTP_SETTINGS=' . wp_json_encode($settings) . ';', 'before');
    }

    public static function shortcode($atts){
        $atts = shortcode_atts([
            'height' => '520px',
            'mode'   => 'auto',        // auto|live|observer
            'topic'  => '#',
            'poll_ms'=> '',
            'view'   => 'both',        // latest|log|both
            'max'    => '',
            'json'   => '',
        ], $atts, 'mqtt_plug');

        $o = self::get_options();

        $id = 'mqtt-plug-' . wp_generate_password(10, false, false);
        $h  = trim((string)$atts['height']);
        if ($h === '') { $h = '520px'; }

        $topic = trim((string)$atts['topic']);
        if ($topic === '') { $topic = '#'; }

        $mode = strtolower(trim((string)$atts['mode']));
        $can_live = current_user_can('manage_options');

        if ($mode === 'auto' || $mode === '') {
            $hasCreds = !empty($o['username']) || !empty($o['password']);
            $mode = ($can_live && $hasCreds) ? 'live' : 'observer';
        }
        if ($mode !== 'live' && $mode !== 'observer') {
            $mode = $can_live ? 'live' : 'observer';
        }

        $poll_ms = (string)$atts['poll_ms'];
        $poll_ms = $poll_ms !== '' ? intval($poll_ms) : intval($o['observer_poll_ms'] ?? 5000);
        $poll_ms = max(1000, min(600000, $poll_ms));

        $view = strtolower(trim((string)$atts['view']));
        if (!in_array($view, ['latest', 'log', 'both'], true)) { $view = 'both'; }

        $max = (string)$atts['max'];
        $max = $max !== '' ? intval($max) : 0;
        if ($max <= 0) { $max = intval($o['max_log_items'] ?? 200); }
        $max = max(50, min(2000, $max));

        $json = (string)$atts['json'];
        $json = ($json === '' ? 1 : (intval($json) ? 1 : 0));

        // Ensure assets + settings are present once per page.
        self::enqueue_frontend();

        // Track mounts for footer inline mount list.
        if (empty($GLOBALS['mqtt_plug_mounts'])) { $GLOBALS['mqtt_plug_mounts'] = []; }
        $GLOBALS['mqtt_plug_mounts'][] = $id;

        $html  = '<div id="' . esc_attr($id) . '" class="mqtt-plug-root"'
            . ' data-mode="' . esc_attr($mode) . '"'
            . ' data-topic="' . esc_attr($topic) . '"'
            . ' data-poll-ms="' . esc_attr((string)$poll_ms) . '"'
            . ' data-max="' . esc_attr((string)$max) . '"'
            . ' data-json="' . esc_attr((string)$json) . '"'
            . ' data-rest-url="' . esc_attr(rest_url(self::REST_NS)) . '"'
            . ' data-rest="' . esc_attr(rest_url(self::REST_NS)) . '"'
            . '>';

        $html .= '  <div class="mqtt-plug-header">';
        $html .= '    <div class="mqtt-plug-status">';
        $html .= '      <span class="mqtt-plug-dot" data-role="status-dot" aria-hidden="true"></span>';
        $html .= '      <span class="mqtt-plug-statustext" data-role="status-text">' . esc_html__('Disconnected', 'mqtt-plug') . '</span>';
        $html .= '    </div>';
        $html .= '    <div class="mqtt-plug-meta">';
        $html .= '      <span class="mqtt-plug-badge" data-role="mode-badge">' . esc_html(strtoupper($mode)) . '</span>';
        $html .= '      <span class="mqtt-plug-muted" data-role="topic-badge">' . esc_html($topic) . '</span>';
        $html .= '    </div>';
        $html .= '  </div>';

        // Tabs.
        $show_latest = ($view === 'latest' || $view === 'both');
        $show_log    = ($view === 'log' || $view === 'both');

        $html .= '  <div class="mqtt-plug-tabs" role="tablist">';
        if ($show_latest) {
            $html .= '    <button type="button" class="mqtt-plug-tab is-active" data-tab="latest">' . esc_html__('Latest', 'mqtt-plug') . '</button>';
        }
        if ($show_log) {
            $html .= '    <button type="button" class="mqtt-plug-tab' . ($show_latest ? '' : ' is-active') . '" data-tab="log">' . esc_html__('Log', 'mqtt-plug') . '</button>';
        }
        $html .= '    <button type="button" class="mqtt-plug-tab" data-tab="history">' . esc_html__('History', 'mqtt-plug') . '</button>';
        $html .= '    <div class="mqtt-plug-spacer"></div>';
        $html .= '    <button type="button" class="mqtt-plug-btn" data-action="pause">' . esc_html__('Pause', 'mqtt-plug') . '</button>';
        $html .= '    <button type="button" class="mqtt-plug-btn" data-action="clear">' . esc_html__('Clear', 'mqtt-plug') . '</button>';
        $html .= '  </div>';

        if ($show_latest) {
            $html .= '  <div class="mqtt-plug-pane" data-pane="latest">';
            $html .= '    <div class="mqtt-plug-cards" data-role="cards"><div class="mqtt-plug-muted">' . esc_html__('No messages yet.', 'mqtt-plug') . '</div></div>';
            $html .= '    <div class="mqtt-plug-tablewrap">';
            $html .= '      <table class="mqtt-plug-table">';
            $html .= '        <thead><tr>';
            $html .= '          <th>' . esc_html__('Time', 'mqtt-plug') . '</th>';
            $html .= '          <th>' . esc_html__('Topic', 'mqtt-plug') . '</th>';
            $html .= '          <th>' . esc_html__('Payload', 'mqtt-plug') . '</th>';
            $html .= '        </tr></thead>';
            $html .= '        <tbody data-role="latest-body"><tr><td colspan="3" class="mqtt-plug-muted">' . esc_html__('No messages yet.', 'mqtt-plug') . '</td></tr></tbody>';
            $html .= '      </table>';
            $html .= '    </div>';
            $html .= '  </div>';
        }

        if ($show_log) {
            $html .= '  <div class="mqtt-plug-pane" data-pane="log"' . ($show_latest ? ' style="display:none"' : '') . '>';
            $html .= '    <pre class="mqtt-plug-log" data-role="log" style="height:' . esc_attr($h) . '"></pre>';
            $html .= '  </div>';
        }

        // History pane (snapshot-driven); always available as a tab.
        $html .= '  <div class="mqtt-plug-pane" data-pane="history" style="display:none">';
        $html .= '    <div class="mqtt-plug-historybar">';
        $html .= '      <label class="mqtt-plug-historylabel">' . esc_html__('Range', 'mqtt-plug') . ' '
            . '<select data-role="history-range">'
            . '<option value="1">1h</option>'
            . '<option value="6">6h</option>'
            . '<option value="24" selected>24h</option>'
            . '<option value="72">3d</option>'
            . '<option value="168">7d</option>'
            . '</select></label>';
        $html .= '      <label class="mqtt-plug-historylabel">' . esc_html__('Max', 'mqtt-plug') . ' '
            . '<select data-role="history-limit">'
            . '<option value="200" selected>200</option>'
            . '<option value="500">500</option>'
            . '<option value="1000">1000</option>'
            . '</select></label>';
        $html .= '      <button type="button" class="mqtt-plug-btn" data-action="history-refresh">' . esc_html__('Refresh', 'mqtt-plug') . '</button>';
        $html .= '    </div>';
        $html .= '    <div class="mqtt-plug-tablewrap">';
        $html .= '      <table class="mqtt-plug-table">';
        $html .= '        <thead><tr>';
        $html .= '          <th>' . esc_html__('Time', 'mqtt-plug') . '</th>';
        $html .= '          <th>' . esc_html__('Topic', 'mqtt-plug') . '</th>';
        $html .= '          <th>' . esc_html__('Payload', 'mqtt-plug') . '</th>';
        $html .= '        </tr></thead>';
        $html .= '        <tbody data-role="history-body"><tr><td colspan="3" class="mqtt-plug-muted">' . esc_html__('No history yet.', 'mqtt-plug') . '</td></tr></tbody>';
        $html .= '      </table>';
        $html .= '    </div>';
        $html .= '  </div>';

        $html .= '</div>';

        if (empty($GLOBALS['mqtt_plug_footer_hooked'])) {
            $GLOBALS['mqtt_plug_footer_hooked'] = true;
            add_action('wp_footer', [__CLASS__, 'print_mounts'], 1);
        }

        return $html;
    }

    public static function print_mounts(){
        if (empty($GLOBALS['mqtt_plug_mounts']) || !is_array($GLOBALS['mqtt_plug_mounts'])) { return; }
        $mounts = array_values(array_unique(array_filter($GLOBALS['mqtt_plug_mounts'], 'is_string')));
        if (empty($mounts)) { return; }

        if (!wp_script_is('mqtt-plug', 'enqueued') && !wp_script_is('mqtt-plug', 'registered')) { return; }
        wp_enqueue_script('mqtt-plug');
        wp_enqueue_style('mqtt-plug');

        wp_add_inline_script('mqtt-plug', 'window.MQTTP_MOUNTS=' . wp_json_encode($mounts) . ';', 'before');
    }

    public static function register_rest_routes(){
        register_rest_route(self::REST_NS, '/snapshot', [
            'methods'             => 'GET',
            'permission_callback' => '__return_true',
            'callback'            => [__CLASS__, 'rest_snapshot'],
            'args'                => [
                'filter' => [
                    'type' => 'string',
                    'required' => false,
                ],
                'limit' => [
                    'type' => 'integer',
                    'required' => false,
                ],
            ],
        ]);

        register_rest_route(self::REST_NS, '/push', [
            'methods'             => 'POST',
            'permission_callback' => [__CLASS__, 'rest_can_push'],
            'callback'            => [__CLASS__, 'rest_push'],
        ]);

        register_rest_route(self::REST_NS, '/ingest', [
            'methods'             => 'POST',
            'permission_callback' => [__CLASS__, 'rest_can_ingest'],
            'callback'            => [__CLASS__, 'rest_ingest'],
        ]);
    }

    public static function rest_can_ingest($req){
        $o = self::get_options();
        $token = (string)($o['ingest_token'] ?? '');
        if ($token === '') { return false; }

        // New header.
        $hdr = (string)$req->get_header('x-mqttplug-token');
        if ($hdr !== '' && hash_equals($token, $hdr)) { return true; }

        // Back-compat header (if you reuse any existing collector logic).
        $old = (string)$req->get_header('x-meshplug-token');
        if ($old !== '' && hash_equals($token, $old)) { return true; }

        // Bearer token.
        $auth = (string)$req->get_header('authorization');
        if (stripos($auth, 'bearer ') === 0) {
            $bearer = trim(substr($auth, 7));
            if ($bearer !== '' && hash_equals($token, $bearer)) { return true; }
        }

        return false;
    }

    private static function allowlist_patterns(){
        $o = self::get_options();
        $raw = (string)($o['topic_allowlist'] ?? '#');
        $raw = str_replace(["\r", "\n", "\t"], ' ', $raw);
        $parts = preg_split('/[\s,]+/', $raw, -1, PREG_SPLIT_NO_EMPTY);
        return is_array($parts) && !empty($parts) ? $parts : ['#'];
    }

    private static function topic_allowed($topic){
        $patterns = self::allowlist_patterns();
        foreach ($patterns as $p) {
            if (self::mqtt_match($p, $topic)) { return true; }
        }
        return false;
    }

    // Minimal MQTT wildcard matcher for + and #.
    // - + matches one level.
    // - # matches remaining levels (must be last level in pattern).
    public static function mqtt_match($pattern, $topic){
        $pattern = trim($pattern);
        $topic   = trim($topic);
        if ($pattern === '#') { return true; }
        if ($pattern === $topic) { return true; }

        $pp = explode('/', $pattern);
        $tt = explode('/', $topic);

        $pi = 0;
        $ti = 0;

        while ($pi < count($pp) && $ti < count($tt)) {
            $p = $pp[$pi];
            if ($p === '#') { return true; }
            if ($p === '+') {
                $pi++;
                $ti++;
                continue;
            }
            if ($p !== $tt[$ti]) { return false; }
            $pi++;
            $ti++;
        }

        // Consume trailing '#'
        if ($pi === count($pp) - 1 && $pp[$pi] === '#') { return true; }

        return ($pi === count($pp) && $ti === count($tt));
    }

    private static function get_snapshot_latest(){
        $x = get_option(self::OPT_SNAP_LATEST, []);
        return is_array($x) ? $x : [];
    }

    private static function get_snapshot_log(){
        $x = get_option(self::OPT_SNAP_LOG, []);
        return is_array($x) ? $x : [];
    }

    private static function prune_by_age($items, $retention_hours){
        $cut = time() - max(1, $retention_hours) * 3600;
        $out = [];
        foreach ($items as $it) {
            $ts = isset($it['ts']) ? intval($it['ts']) : 0;
            if ($ts >= $cut) { $out[] = $it; }
        }
        return $out;
    }

    /**
     * Write an item into the snapshot stores.
     * - Latest: one item per topic.
     * - Log: append-only list (bounded by age and count).
     */
    private static function ingest_into_snapshots($topic, $payload, $ts){
        $o = self::get_options();

        $topic = trim((string)$topic);
        if ($topic === '') { return; }

        $ts = intval($ts);
        if ($ts <= 0) { $ts = time(); }

        // Latest (associative by topic).
        $latest = self::get_snapshot_latest();
        $latest[$topic] = [
            'ts'      => $ts,
            'topic'   => $topic,
            'payload' => (string)$payload,
        ];

        // Enforce max_latest_items by removing oldest topics.
        $max_latest = intval($o['max_latest_items'] ?? 100);
        $max_latest = max(10, min(500, $max_latest));
        if ($max_latest > 0 && count($latest) > $max_latest) {
            uasort($latest, function($a, $b){
                return intval($a['ts'] ?? 0) <=> intval($b['ts'] ?? 0);
            });
            while (count($latest) > $max_latest) {
                $k = array_key_first($latest);
                if ($k === null) { break; }
                unset($latest[$k]);
            }
        }
        update_option(self::OPT_SNAP_LATEST, $latest, false);

        // Log (append).
        $log = self::get_snapshot_log();
        $log[] = [
            'ts'      => $ts,
            'topic'   => $topic,
            'payload' => (string)$payload,
        ];

        // Prune by age + count.
        $log = self::prune_by_age($log, intval($o['retention_hours'] ?? 24));
        $max_log = intval($o['max_log_items'] ?? 200);
        $max_log = max(50, min(2000, $max_log));
        if (count($log) > $max_log) {
            $log = array_slice($log, -$max_log);
        }
        update_option(self::OPT_SNAP_LOG, $log, false);
    }

    public static function rest_can_push($req){
        // Push is an authenticated helper endpoint used by live dashboards.
        // It stores recent browser-observed messages into the same snapshot cache.
        if (!is_user_logged_in()) { return false; }
        if (!current_user_can('manage_options')) { return false; }
        return true;
    }

    public static function rest_push($req){
        $o = self::get_options();
        if (empty($o['server_cache'])) {
            return new WP_REST_Response(['ok' => false, 'error' => 'server_cache_disabled'], 400);
        }

        $body = $req->get_json_params();
        if (!is_array($body)) { $body = []; }
        $items = isset($body['items']) && is_array($body['items']) ? $body['items'] : [];
        if (empty($items)) {
            return new WP_REST_Response(['ok' => true, 'ingested' => 0], 200);
        }

        $ingested = 0;
        foreach ($items as $it) {
            if (!is_array($it)) { continue; }
            $topic = isset($it['topic']) ? trim((string)$it['topic']) : '';
            if ($topic === '') { continue; }
            if (!self::topic_allowed($topic)) { continue; }
            $payload = isset($it['payload']) ? (string)$it['payload'] : '';
            $ts = isset($it['ts']) ? intval($it['ts']) : time();
            self::ingest_into_snapshots($topic, $payload, $ts);
            $ingested++;
        }

        return new WP_REST_Response(['ok' => true, 'ingested' => $ingested], 200);
    }

    public static function rest_ingest($req){
        $o = self::get_options();

        if (empty($o['server_cache'])) {
            return new WP_REST_Response(['ok' => false, 'error' => 'server_cache_disabled'], 400);
        }

        $body = $req->get_json_params();
        if (!is_array($body)) { $body = []; }

        $topic   = isset($body['topic']) ? trim((string)$body['topic']) : '';
        $payload = isset($body['payload']) ? (string)$body['payload'] : '';
        $ts      = isset($body['ts']) ? intval($body['ts']) : time();

        if ($topic === '') {
            return new WP_REST_Response(['ok' => false, 'error' => 'missing_topic'], 400);
        }

        if (!self::topic_allowed($topic)) {
            return new WP_REST_Response(['ok' => false, 'error' => 'topic_not_allowed'], 403);
        }

        self::ingest_into_snapshots($topic, $payload, $ts);

        return new WP_REST_Response(['ok' => true], 200);
}

    public static function rest_snapshot($req){
        $o = self::get_options();

        $filter = trim((string)$req->get_param('filter'));
        if ($filter === '') { $filter = '#'; }

        // Enforce allowlist on requested filter. We do this conservatively:
        // filter must match at least one allowlist pattern in either direction.
        // This blocks someone from querying unrelated topics on public pages.
        $allowed = false;
        foreach (self::allowlist_patterns() as $p) {
            if (self::mqtt_match($p, $filter) || self::mqtt_match($filter, $p)) {
                $allowed = true;
                break;
            }
        }
        if (!$allowed) {
            return new WP_REST_Response(['ok' => false, 'error' => 'filter_not_allowed'], 403);
        }

        $limit = intval($req->get_param('limit'));
        if ($limit <= 0) { $limit = intval($o['max_log_items'] ?? 200); }
        $limit = max(10, min(2000, $limit));

        $latest = self::get_snapshot_latest();
        $log    = self::get_snapshot_log();

        // Latest: filter.
        $latest_out = [];
        foreach ($latest as $t => $it) {
            $topic = (string)($it['topic'] ?? $t);
            if (self::mqtt_match($filter, $topic)) {
                $latest_out[] = [
                    'ts' => intval($it['ts'] ?? 0),
                    'topic' => $topic,
                    'payload' => (string)($it['payload'] ?? ''),
                ];
            }
        }
        usort($latest_out, function($a, $b){
            return intval($b['ts']) <=> intval($a['ts']);
        });

        $max_latest = intval($o['max_latest_items'] ?? 100);
        if ($max_latest > 0 && count($latest_out) > $max_latest) {
            $latest_out = array_slice($latest_out, 0, $max_latest);
        }

        // Log: filter + limit.
        $log = self::prune_by_age($log, intval($o['retention_hours'] ?? 24));
        $log_out = [];
        for ($i = count($log) - 1; $i >= 0; $i--) {
            $it = $log[$i];
            $topic = (string)($it['topic'] ?? '');
            if ($topic !== '' && self::mqtt_match($filter, $topic)) {
                $log_out[] = [
                    'ts' => intval($it['ts'] ?? 0),
                    'topic' => $topic,
                    'payload' => (string)($it['payload'] ?? ''),
                ];
                if (count($log_out) >= $limit) { break; }
            }
        }
        $log_out = array_reverse($log_out);

        return new WP_REST_Response([
            'ok' => true,
            'serverTime' => time(),
            'offlineAfterSec' => intval($o['offline_after_sec'] ?? 300),
            'latest' => $latest_out,
            'log' => $log_out,
        ], 200);
    }
}

MQTT_Plug::init();
