<?php

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

class Niwa_AI_Admin_Autonomy_Telegram {
    const OPTION_KEY         = Niwa_AI_Constants::OPT_AUTONOMY;
    const UPDATES_OPTION     = Niwa_AI_Constants::OPT_AUTONOMY_TG_UPDATES;
    const POLL_STATE_OPTION  = Niwa_AI_Constants::OPT_AUTONOMY_TG_POLL_STATE;
    const RUNNER_STATE_OPTION = Niwa_AI_Constants::OPT_AUTONOMY_TG_RUNNER_STATE;
    const WEBHOOK_STATE_OPTION = Niwa_AI_Constants::OPT_AUTONOMY_TG_WEBHOOK_STATE;
    const INTRO_LOG_OPTION   = Niwa_AI_Constants::OPT_AUTONOMY_TG_INTRO_LOG;
    const UPDATE_RESERVATION_OPTION_PREFIX = 'niwa_ai_admin_autonomy_tg_update_lock_';
    const MAX_STORED_UPDATES = 200;
    const MAX_STOCK_MANAGERS = 500;
    const MESSAGE_TEXT_LIMIT = 4000;
    const CAPTION_TEXT_LIMIT = 1024;
    const INTRO_THROTTLE_TTL = DAY_IN_SECONDS;
    const INTRO_RATE_LIMIT_MAX    = 3;
    const INTRO_RATE_LIMIT_WINDOW = HOUR_IN_SECONDS;
    const INTRO_LOG_MAX      = 10;

    public function get_settings() {
        $settings = get_option( self::OPTION_KEY, array() );
        if ( ! is_array( $settings ) ) {
            $settings = array();
        }

        $settings = wp_parse_args( $settings, $this->get_defaults() );

        // Transparently decrypt bot_token and auto-migrate plaintext → encrypted.
        $raw_token = isset( $settings['bot_token'] ) ? trim( (string) $settings['bot_token'] ) : '';
        if ( '' !== $raw_token ) {
            if ( self::is_token_encrypted( $raw_token ) ) {
                $settings['bot_token'] = self::decrypt_token( $raw_token );
            } elseif ( function_exists( 'openssl_encrypt' ) ) {
                // Auto-migrate: encrypt the plaintext token and persist.
                $encrypted = self::encrypt_token( $raw_token );
                if ( $encrypted !== $raw_token ) {
                    $persist               = get_option( self::OPTION_KEY, array() );
                    $persist               = is_array( $persist ) ? $persist : array();
                    $persist['bot_token']   = $encrypted;
                    update_option( self::OPTION_KEY, $persist );
                }
                // Return plaintext for current request.
            }
        }

        return $settings;
    }

    public function get_defaults() {
        return array(
            'enabled'           => 0,
            'transport'         => 'webhook',
            'bot_token'         => '',
            'owner_chat_id'     => '',
            'founder_notes_seed' => '',
            'founder_auth_mode' => '',
            'founder_agent_model' => '',
            'founder_subagent_model' => '',
            'founder_image_quality' => 'medium',
            'rate_limit_max'    => 120,
            'rate_limit_window' => 60,
            'poll_timeout'      => 5,
            'poll_limit'        => 10,
            'daily_report_enabled' => 1,
            'daily_report_hour'    => 8,
            'daily_report_minute'  => 30,
            'admin_launcher_scope'  => 'niwa_dashboard',
            'show_tooluse_in_telegram_chat' => 0,
            'stock_managers'    => array(),
            'unauthorized_intro_enabled'  => 1,
            'unauthorized_intro_template' => '',
        );
    }

    public function should_show_tooluse_in_telegram_chat() {
        $settings = $this->get_settings();
        return ! empty( $settings['show_tooluse_in_telegram_chat'] );
    }

    public function is_enabled() {
        $settings = $this->get_settings();
        return ! empty( $settings['enabled'] );
    }

    public function get_transport() {
        $settings  = $this->get_settings();
        $transport = isset( $settings['transport'] ) ? sanitize_key( (string) $settings['transport'] ) : 'webhook';
        return 'polling' === $transport ? 'polling' : 'webhook';
    }

    public function is_webhook_transport_enabled() {
        return 'webhook' === $this->get_transport();
    }

    public function get_bot_token() {
        return $this->get_bot_token_from_settings( $this->get_settings() );
    }

    public function is_outbound_enabled( $chat_id = '' ) {
        $status = $this->get_outbound_status( $chat_id );
        return ! empty( $status['available'] );
    }

    public function is_outbound_transport_enabled() {
        $outbound = $this->require_outbound_bot( '', false );
        return ! empty( $outbound['success'] );
    }

    public function get_outbound_status( $chat_id = '' ) {
        $settings  = $this->get_settings();
        $chat_id   = trim( (string) $chat_id );
        $bot_token = $this->get_bot_token_from_settings( $settings );

        if ( empty( $settings['enabled'] ) ) {
            return array(
                'available' => false,
                'code'      => 'telegram_disabled',
                'error'     => 'Telegram bot je iskljucen.',
            );
        }

        if ( '' === $bot_token ) {
            return array(
                'available' => false,
                'code'      => 'telegram_bot_token_missing',
                'error'     => 'Telegram bot token nije konfigurisan.',
            );
        }

        if ( '' === $chat_id ) {
            return array(
                'available' => false,
                'code'      => 'telegram_chat_missing',
                'error'     => 'Telegram chat ID nije konfigurisan.',
            );
        }

        return array(
            'available' => true,
            'code'      => 'available',
            'error'     => '',
        );
    }

    private function get_bot_token_from_settings( $settings ) {
        $settings = is_array( $settings ) ? $settings : array();
        return isset( $settings['bot_token'] ) ? trim( (string) $settings['bot_token'] ) : '';
    }

    private function require_outbound_bot( $chat_id = '', $require_chat_id = true ) {
        $settings  = $this->get_settings();
        $chat_id   = trim( (string) $chat_id );
        $bot_token = $this->get_bot_token_from_settings( $settings );

        if ( empty( $settings['enabled'] ) ) {
            return array(
                'success' => false,
                'code'    => 'telegram_disabled',
                'error'   => 'Telegram bot je iskljucen.',
            );
        }

        if ( '' === $bot_token ) {
            return array(
                'success' => false,
                'code'    => 'telegram_bot_token_missing',
                'error'   => 'Telegram bot token nije konfigurisan.',
            );
        }

        if ( $require_chat_id && '' === $chat_id ) {
            return array(
                'success' => false,
                'code'    => 'telegram_chat_missing',
                'error'   => 'Telegram chat ID nije konfigurisan.',
            );
        }

        return array(
            'success'   => true,
            'bot_token' => $bot_token,
            'chat_id'   => $chat_id,
        );
    }

    public function get_webhook_url( $secret = '' ) {
        if ( ! isset( $GLOBALS['wp_rewrite'] ) || ! is_object( $GLOBALS['wp_rewrite'] ) ) {
            $url = set_url_scheme( home_url( '/wp-json/niwa-ai/v1/telegram/webhook' ), 'https' );
        } else {
            $url = set_url_scheme( rest_url( 'niwa-ai/v1/telegram/webhook' ), 'https' );
        }

        $secret = trim( (string) $secret );
        if ( '' === $secret ) {
            $runner = $this->get_runner_state();
            $secret = isset( $runner['secret'] ) ? trim( (string) $runner['secret'] ) : '';
        }

        if ( '' !== $secret ) {
            $url = add_query_arg( 'niwa_tg', substr( hash( 'sha256', $secret ), 0, 12 ), $url );
        }

        return $url;
    }

    public function get_allowed_updates() {
        return array( 'message', 'edited_message', 'callback_query', 'message_reaction' );
    }

    public function get_webhook_state() {
        $state = get_option( self::WEBHOOK_STATE_OPTION, array() );
        return $this->normalize_webhook_state( $state );
    }

    public function mark_webhook_delivery() {
        $settings  = $this->get_settings();
        $bot_token = $this->get_bot_token_from_settings( $settings );
        $state     = $this->get_webhook_state();

        $state['active']           = true;
        $state['url']              = $this->get_webhook_url();
        $state['bot_hash']         = '' !== $bot_token ? $this->get_bot_hash( $bot_token ) : $state['bot_hash'];
        $state['updated_at']       = time();
        $state['last_delivery_at'] = time();
        $state['last_error_date']  = 0;
        $state['last_error']       = '';

        $this->update_webhook_state( $state );
        return $state;
    }

    public function ensure_webhook_registered( $force = false ) {
        $settings  = $this->get_settings();
        $bot_token = $this->get_bot_token_from_settings( $settings );

        if ( empty( $settings['enabled'] ) || '' === $bot_token ) {
            return array(
                'success' => false,
                'error'   => 'Telegram webhook nije konfigurisan.',
            );
        }

        if ( ! $this->is_webhook_transport_enabled() ) {
            return array(
                'success' => false,
                'error'   => 'telegram_transport_polling',
            );
        }

        $state    = $this->get_webhook_state();
        $bot_hash = $this->get_bot_hash( $bot_token );
        $url      = $this->get_webhook_url();
        if ( ! $force && $this->webhook_state_has_delivery_block( $state ) ) {
            return array(
                'success' => false,
                'error'   => 'telegram_webhook_delivery_blocked',
                'state'   => $state,
            );
        }

        if (
            ! $force
            && ! empty( $state['active'] )
            && $state['bot_hash'] === $bot_hash
            && $state['url'] === $url
        ) {
            return array(
                'success' => true,
                'active'  => true,
                'state'   => $state,
            );
        }

        if ( ! $force && intval( $state['last_attempt_at'] ) > ( time() - 300 ) ) {
            return array(
                'success'   => false,
                'throttled' => true,
                'state'     => $state,
            );
        }

        return $this->set_webhook();
    }

    public function get_poll_state() {
        $state = get_option( self::POLL_STATE_OPTION, array() );
        return $this->normalize_poll_state( $state );
    }

    public function get_runner_state() {
        $state = get_option( self::RUNNER_STATE_OPTION, array() );
        return $this->normalize_runner_state( $state );
    }

    public function update_runner_state( $state ) {
        $normalized = $this->normalize_runner_state( $state );
        update_option( self::RUNNER_STATE_OPTION, $normalized, false );
        return $normalized;
    }

    public function reset_runtime_state() {
        $runner = $this->get_runner_state();
        $runner['last_seen_at']    = 0;
        $runner['last_spawned_at'] = 0;
        $runner['active_until']    = 0;
        $runner['active_runner_id'] = '';
        $runner['active_transport'] = '';
        $runner['active_pid']       = 0;
        $runner['last_error']      = '';
        $runner['wake_pending']    = 0;
        $runner['wake_reason']     = '';
        $runner['wake_requested_at'] = 0;
        $this->update_runner_state( $runner );

        delete_option( 'niwa_ai_admin_autonomy_lock_telegram_poll' );
        delete_option( 'niwa_ai_admin_autonomy_lock_loopback_runner' );
    }

    public function schedule_poll_restart( $delay = 5 ) {
        if ( ! $this->is_enabled() ) {
            if ( function_exists( 'wp_clear_scheduled_hook' ) ) {
                wp_clear_scheduled_hook( 'niwa_ai_admin_autonomy_poll_telegram' );
            }
            return;
        }

        $delay = max( 1, intval( $delay ) );
        $slug = class_exists( 'Niwa_AI_Performance_Settings' )
            ? Niwa_AI_Performance_Settings::resolve_schedule_for_hook( 'niwa_ai_admin_autonomy_poll_telegram', 'niwa_ai_thirty_seconds' )
            : 'niwa_ai_thirty_seconds';

        if ( class_exists( 'Niwa_AI_Cron_Maintenance' ) ) {
            Niwa_AI_Cron_Maintenance::replace_single_recurring_event(
                'niwa_ai_admin_autonomy_poll_telegram',
                $slug,
                $delay
            );
            return;
        }

        wp_clear_scheduled_hook( 'niwa_ai_admin_autonomy_poll_telegram' );
        if ( ! wp_next_scheduled( 'niwa_ai_admin_autonomy_poll_telegram' ) ) {
            wp_schedule_event( time() + $delay, $slug, 'niwa_ai_admin_autonomy_poll_telegram' );
        }
    }

    public function ensure_runner_secret() {
        $state = $this->get_runner_state();
        if ( '' !== $state['secret'] ) {
            return $state['secret'];
        }

        $state['secret'] = function_exists( 'wp_generate_password' ) ? wp_generate_password( 48, false, false ) : md5( uniqid( 'niwa_runner_', true ) );
        $this->update_runner_state( $state );
        return $state['secret'];
    }

    public function poll_updates() {
        $settings  = $this->get_settings();
        $bot_token = $this->get_bot_token_from_settings( $settings );

        if ( empty( $settings['enabled'] ) ) {
            return array(
                'success' => false,
                'error'   => 'Telegram fallback polling nije ukljucen.',
            );
        }

        if ( '' === $bot_token ) {
            return array(
                'success' => false,
                'error'   => 'Telegram bot token nije konfigurisan.',
            );
        }

        $state    = $this->get_poll_state();
        $bot_hash = $this->get_bot_hash( $bot_token );
        $skip_webhook_register = false;
        if ( $state['bot_hash'] !== $bot_hash ) {
            $state = $this->reset_poll_state( $bot_hash );
            update_option( self::UPDATES_OPTION, array(), false );
        }

        $webhook_state = $this->get_webhook_state();
        if ( ! $this->is_webhook_transport_enabled() || $this->webhook_state_has_delivery_block( $webhook_state ) ) {
            $skip_webhook_register = true;
        }

        if (
            $this->is_webhook_transport_enabled()
            &&
            ! empty( $webhook_state['active'] )
            && $webhook_state['bot_hash'] === $bot_hash
            && $webhook_state['url'] === $this->get_webhook_url()
        ) {
            $health = $this->get_webhook_info( $bot_token );
            if ( ! empty( $health['healthy'] ) || empty( $health['success'] ) ) {
                $state['last_polled_at'] = time();
                $this->update_poll_state( $state );

                return array(
                    'success'        => true,
                    'updates'        => array(),
                    'count'          => 0,
                    'state'          => $state,
                    'webhook_active' => true,
                    'webhook_health_error' => empty( $health['success'] ) && ! empty( $health['error'] ) ? (string) $health['error'] : '',
                );
            }

            $this->delete_webhook();
            $skip_webhook_register = true;
            $state['webhook_cleared_for'] = '';
            $this->update_poll_state( $state );
        }

        if ( ! $skip_webhook_register ) {
            $webhook = $this->ensure_webhook_registered( false );
            if ( ! empty( $webhook['success'] ) ) {
                $state['last_polled_at'] = time();
                $this->update_poll_state( $state );

                return array(
                    'success'        => true,
                    'updates'        => array(),
                    'count'          => 0,
                    'state'          => $state,
                    'webhook_active' => true,
                );
            }
        }

        $prepared = $this->ensure_polling_preconditions( $bot_token, $state );
        if ( empty( $prepared['success'] ) ) {
            return $prepared;
        }

        $state     = isset( $prepared['state'] ) ? $prepared['state'] : $state;
        $timeout   = max( 1, min( 25, intval( $settings['poll_timeout'] ) ) );
        $limit     = max( 1, min( 50, intval( $settings['poll_limit'] ) ) );
        $response  = wp_remote_post(
            $this->bot_api_url( 'getUpdates', $bot_token ),
            array(
                'timeout' => max( 15, $timeout + 10 ),
                'body'    => array(
                    'offset'          => intval( $state['offset'] ),
                    'limit'           => $limit,
                    'timeout'         => $timeout,
                    'allowed_updates' => wp_json_encode( $this->get_allowed_updates() ),
                ),
            )
        );

        if ( is_wp_error( $response ) ) {
            return array(
                'success' => false,
                'error'   => $response->get_error_message(),
            );
        }

        $status = wp_remote_retrieve_response_code( $response );
        $data   = json_decode( (string) wp_remote_retrieve_body( $response ), true );
        if ( $status < 200 || $status >= 300 || empty( $data['ok'] ) ) {
            return array(
                'success' => false,
                'error'   => isset( $data['description'] ) ? (string) $data['description'] : 'Telegram getUpdates failed.',
            );
        }

        $updates        = isset( $data['result'] ) && is_array( $data['result'] ) ? $data['result'] : array();
        $max_update_id  = -1;
        foreach ( $updates as $update ) {
            if ( ! is_array( $update ) || ! isset( $update['update_id'] ) ) {
                continue;
            }
            $max_update_id = max( $max_update_id, intval( $update['update_id'] ) );
        }

        $state['last_polled_at'] = time();
        if ( $max_update_id >= 0 ) {
            $state['offset'] = $max_update_id + 1;
        }
        $this->update_poll_state( $state );

        return array(
            'success' => true,
            'updates' => $updates,
            'count'   => count( $updates ),
            'state'   => $state,
        );
    }

    public function extract_message_payload( $update ) {
        if ( ! is_array( $update ) ) {
            return array(
                'success' => false,
                'error'   => 'Telegram update payload nije validan objekat.',
            );
        }

        if ( isset( $update['callback_query'] ) && is_array( $update['callback_query'] ) ) {
            return $this->extract_callback_payload( $update );
        }

        // Phase 7 — message_reaction is delivered as its own update type when
        // requested in allowed_updates. Surface it as kind=reaction so the
        // agent can read what the user reacted with on which message,
        // without queueing a turn (handle_incoming_message treats kind=reaction
        // as info-only).
        if ( isset( $update['message_reaction'] ) && is_array( $update['message_reaction'] ) ) {
            return $this->extract_reaction_payload( $update );
        }

        $is_edited = false;
        $message   = null;
        if ( isset( $update['message'] ) && is_array( $update['message'] ) ) {
            $message = $update['message'];
        } elseif ( isset( $update['edited_message'] ) && is_array( $update['edited_message'] ) ) {
            $message   = $update['edited_message'];
            $is_edited = true;
        }

        if ( empty( $message ) || empty( $message['chat']['id'] ) ) {
            return array(
                'success' => false,
                'ignore'  => true,
                'error'   => 'Update nema podrzanu Telegram message strukturu.',
            );
        }

        // Identity (Phase 1).
        $from_id         = isset( $message['from']['id'] ) ? (string) $message['from']['id'] : '';
        $from_username   = isset( $message['from']['username'] ) ? (string) $message['from']['username'] : '';
        $from_first_name = isset( $message['from']['first_name'] ) ? (string) $message['from']['first_name'] : '';
        $language_code   = isset( $message['from']['language_code'] ) ? (string) $message['from']['language_code'] : '';

        // Phase 2 — rich extraction via dedicated helpers.
        $text_parts       = $this->extract_text_parts( $message );
        $media_refs       = $this->collect_inbound_media_refs( $message );
        $reply_media_refs = $this->collect_reply_target_media_refs( $message );
        $forward          = $this->extract_forward_metadata( $message );
        $location         = $this->extract_location( $message );
        $contact          = $this->extract_contact( $message );
        $placeholder      = (string) ( $this->resolve_media_placeholder( $message ) ?? '' );
        $kind             = $this->classify_message_kind( $message );
        $media_group_id   = isset( $message['media_group_id'] ) ? (string) $message['media_group_id'] : '';

        $text          = $text_parts['text'];
        $caption       = $text_parts['caption'];
        $has_user_text = ( '' !== $text ) || ( '' !== $caption );

        // Empty-payload guard: no text, no caption, no media, no location, no contact.
        if ( ! $has_user_text && empty( $media_refs ) && null === $location && null === $contact ) {
            return array(
                'success' => false,
                'ignore'  => true,
                'error'   => 'Admin poruka mora imati tekst, caption, media, location ili contact.',
            );
        }

        // Phase 7 — `text` retains the Phase 1 BC overload (text || caption)
        // so existing consumers that read $message['text'] keep working with
        // photo+caption messages. The dedicated `text_only` and `caption`
        // fields below provide the strict-semantic split for Phase 5+ code.
        $effective_text = '' !== $text ? $text : $caption;

        return array(
            // Core.
            'success'         => true,
            'channel'         => 'telegram',
            'update_id'       => isset( $update['update_id'] ) ? intval( $update['update_id'] ) : 0,
            'chat_id'         => (string) $message['chat']['id'],
            'message_id'      => isset( $message['message_id'] ) ? intval( $message['message_id'] ) : 0,
            'is_edited'       => $is_edited,

            // Identity.
            'from_id'         => $from_id,
            'from_username'   => $from_username,
            'from_first_name' => $from_first_name,
            'language_code'   => $language_code,

            // Text/caption.
            'text'              => $effective_text,
            'text_only'         => $text,
            'caption'           => $caption,
            'entities'          => $text_parts['entities'],
            'caption_entities'  => $text_parts['caption_entities'],
            'has_user_text'     => $has_user_text,

            // Media.
            'kind'              => $kind,
            'placeholder'       => $placeholder,
            'media_refs'        => $media_refs,
            'reply_media_refs'  => $reply_media_refs,
            'media_group_id'    => $media_group_id,

            // Message metadata.
            'forward'           => $forward,
            'location'          => $location,
            'contact'           => $contact,

            // Empty callback fields (only set by extract_callback_payload).
            'callback_query_id' => '',
            'callback_data'     => '',
            'callback_origin_message_id' => 0,
        );
    }

    /**
     * Phase 7 — extract a `message_reaction` update.
     *
     * @param array $update
     * @return array
     */
    private function extract_reaction_payload( $update ) {
        $reaction = isset( $update['message_reaction'] ) && is_array( $update['message_reaction'] ) ? $update['message_reaction'] : array();
        $chat_id  = isset( $reaction['chat']['id'] ) ? (string) $reaction['chat']['id'] : '';

        if ( '' === $chat_id ) {
            return array(
                'success' => false,
                'ignore'  => true,
                'error'   => 'message_reaction nema chat kontekst.',
            );
        }

        $emojis = array();
        if ( ! empty( $reaction['new_reaction'] ) && is_array( $reaction['new_reaction'] ) ) {
            foreach ( $reaction['new_reaction'] as $r ) {
                if ( is_array( $r ) && 'emoji' === ( $r['type'] ?? '' ) && ! empty( $r['emoji'] ) ) {
                    $emojis[] = (string) $r['emoji'];
                }
            }
        }

        return array(
            'success'         => true,
            'channel'         => 'telegram',
            'update_id'       => isset( $update['update_id'] ) ? intval( $update['update_id'] ) : 0,
            'chat_id'         => $chat_id,
            'message_id'      => isset( $reaction['message_id'] ) ? intval( $reaction['message_id'] ) : 0,
            'is_edited'       => false,

            'from_id'         => isset( $reaction['user']['id'] ) ? (string) $reaction['user']['id'] : '',
            'from_username'   => isset( $reaction['user']['username'] ) ? (string) $reaction['user']['username'] : '',
            'from_first_name' => isset( $reaction['user']['first_name'] ) ? (string) $reaction['user']['first_name'] : '',
            'language_code'   => isset( $reaction['user']['language_code'] ) ? (string) $reaction['user']['language_code'] : '',

            'text'              => '',
            'text_only'         => '',
            'caption'           => '',
            'entities'          => array(),
            'caption_entities'  => array(),
            'has_user_text'     => false,

            'kind'              => 'reaction',
            'placeholder'       => '<media:reaction>',
            'media_refs'        => array(),
            'reply_media_refs'  => array(),
            'media_group_id'    => '',

            'forward'           => $this->empty_forward(),
            'location'          => null,
            'contact'           => null,

            'reaction_emojis'   => $emojis,
            'reaction_date'     => isset( $reaction['date'] ) ? intval( $reaction['date'] ) : 0,

            'callback_query_id' => '',
            'callback_data'     => '',
            'callback_origin_message_id' => 0,
        );
    }

    public function is_owner_chat( $chat_id ) {
        $settings = $this->get_settings();
        $owner    = isset( $settings['owner_chat_id'] ) ? trim( (string) $settings['owner_chat_id'] ) : '';
        return '' !== $owner && $owner === trim( (string) $chat_id );
    }

    public function is_authorized_chat( $chat_id ) {
        if ( $this->is_owner_chat( $chat_id ) ) {
            return true;
        }

        return null !== $this->find_stock_manager( $chat_id );
    }

    public function get_user_role( $chat_id ) {
        $chat_id = trim( (string) $chat_id );
        if ( '' === $chat_id ) {
            return null;
        }

        if ( $this->is_owner_chat( $chat_id ) ) {
            return 'admin';
        }

        if ( null !== $this->find_stock_manager( $chat_id ) ) {
            return 'stock_manager';
        }

        return null;
    }

    public function get_stock_managers() {
        $settings = $this->get_settings();
        $managers = isset( $settings['stock_managers'] ) ? $settings['stock_managers'] : array();
        if ( ! is_array( $managers ) ) {
            return array();
        }

        return array_values( array_filter( $managers, function ( $m ) {
            return is_array( $m ) && ! empty( $m['chat_id'] );
        } ) );
    }

    public function find_stock_manager( $chat_id ) {
        $chat_id  = trim( (string) $chat_id );
        if ( '' === $chat_id ) {
            return null;
        }

        $managers = $this->get_stock_managers();
        foreach ( $managers as $manager ) {
            if ( trim( (string) $manager['chat_id'] ) === $chat_id ) {
                return $manager;
            }
        }

        return null;
    }

    public function is_duplicate_update( $update_id ) {
        if ( $this->is_processed_update_reserved( $update_id ) ) {
            return true;
        }

        $cache = $this->get_processed_updates();
        return isset( $cache[ (string) intval( $update_id ) ] );
    }

    public function reserve_processed_update( $update_id, $ttl = DAY_IN_SECONDS ) {
        $update_id = intval( $update_id );
        if ( $update_id <= 0 ) {
            return false;
        }

        $cache = $this->get_processed_updates();
        if ( isset( $cache[ (string) $update_id ] ) ) {
            return false;
        }

        $now     = time();
        $ttl     = max( MINUTE_IN_SECONDS, intval( $ttl ) );
        $expires = $now + $ttl;
        $key     = $this->processed_update_reservation_key( $update_id );

        if ( add_option( $key, (string) $expires, '', 'no' ) ) {
            return true;
        }

        wp_cache_delete( $key, 'options' );
        $current = intval( get_option( $key, 0 ) );
        if ( $current > $now ) {
            return false;
        }

        update_option( $key, (string) $expires, false );
        wp_cache_delete( $key, 'options' );

        return true;
    }

    public function release_processed_update_reservation( $update_id ) {
        $update_id = intval( $update_id );
        if ( $update_id <= 0 ) {
            return;
        }

        delete_option( $this->processed_update_reservation_key( $update_id ) );
    }

    public function remember_processed_update( $update_id ) {
        $key           = (string) intval( $update_id );
        $cache         = $this->get_processed_updates();
        $cache[ $key ] = time();

        $cache = $this->prune_updates( $cache );
        update_option( self::UPDATES_OPTION, $cache, false );
    }

    private function is_processed_update_reserved( $update_id ) {
        $update_id = intval( $update_id );
        if ( $update_id <= 0 ) {
            return false;
        }

        $key     = $this->processed_update_reservation_key( $update_id );
        $expires = intval( get_option( $key, 0 ) );
        if ( $expires <= 0 ) {
            return false;
        }

        if ( $expires < time() ) {
            delete_option( $key );
            return false;
        }

        return true;
    }

    private function processed_update_reservation_key( $update_id ) {
        return self::UPDATE_RESERVATION_OPTION_PREFIX . intval( $update_id );
    }

    public function consume_rate_limit( $chat_id ) {
        $settings = $this->get_settings();
        $max      = max( 1, intval( $settings['rate_limit_max'] ) );
        $window   = max( 10, intval( $settings['rate_limit_window'] ) );
        $key      = 'niwa_ai_admin_autonomy_tg_rl_' . md5( (string) $chat_id );
        $bucket   = get_transient( $key );
        $bucket   = is_array( $bucket ) ? $bucket : array();
        $now      = time();

        $bucket = array_values(
            array_filter(
                $bucket,
                function ( $timestamp ) use ( $now, $window ) {
                    return intval( $timestamp ) > ( $now - $window );
                }
            )
        );

        if ( count( $bucket ) >= $max ) {
            return array(
                'allowed' => false,
            );
        }

        $bucket[] = $now;
        set_transient( $key, $bucket, $window );

        return array(
            'allowed' => true,
        );
    }

    public function send_message( $chat_id, $text, $reply_to_message_id = 0, $reply_markup = array() ) {
        $outbound = $this->require_outbound_bot( $chat_id );
        if ( empty( $outbound['success'] ) ) {
            return $outbound;
        }
        $bot_token = (string) $outbound['bot_token'];
        $chat_id   = (string) $outbound['chat_id'];

        if ( class_exists( 'Niwa_AI_Admin_Autonomy_Delivery_Envelope_Normalizer' ) ) {
            $envelope = Niwa_AI_Admin_Autonomy_Delivery_Envelope_Normalizer::normalize( $text );
            if ( empty( $envelope['send'] ) ) {
                return array(
                    'success' => true,
                    'skipped' => true,
                    'reason'  => ! empty( $envelope['unwrapped'] ) ? 'delivery_envelope_send_false' : 'empty_message',
                );
            }
            $text = isset( $envelope['message'] ) ? (string) $envelope['message'] : '';
        }

        $text        = $this->sanitize_outbound_message_text( $text );
        $chunks      = $this->split_text_for_telegram_messages( $text, self::MESSAGE_TEXT_LIMIT );
        $message_ids = array();

        foreach ( $chunks as $index => $chunk ) {
            $body = array(
                'chat_id'                  => (string) $chat_id,
                'text'                     => $this->format_rich_text_for_telegram( $chunk, self::MESSAGE_TEXT_LIMIT ),
                'disable_web_page_preview' => true,
                'parse_mode'               => 'MarkdownV2',
            );

            if ( $reply_to_message_id > 0 && 0 === $index ) {
                $body['reply_parameters'] = wp_json_encode(
                    array(
                        'message_id' => intval( $reply_to_message_id ),
                    )
                );
            }

            if ( ! empty( $reply_markup ) && is_array( $reply_markup ) && 0 === $index ) {
                $body['reply_markup'] = wp_json_encode( $reply_markup );
            }

            $response = wp_remote_post(
                $this->bot_api_url( 'sendMessage', $bot_token ),
                array(
                    'timeout' => 20,
                    'body'    => $body,
                )
            );

            if ( is_wp_error( $response ) ) {
                return array(
                    'success' => false,
                    'error'   => $response->get_error_message(),
                );
            }

            $status = wp_remote_retrieve_response_code( $response );
            $data   = json_decode( (string) wp_remote_retrieve_body( $response ), true );
            if ( $status < 200 || $status >= 300 || empty( $data['ok'] ) ) {
                $chunk_label = count( $chunks ) > 1 ? ' (chunk ' . ( $index + 1 ) . '/' . count( $chunks ) . ')' : '';
                $error       = isset( $data['description'] ) ? (string) $data['description'] : 'Telegram sendMessage failed.';
                if ( 0 === $index && ! empty( $body['reply_parameters'] ) && $this->is_missing_reply_target_error( $error ) ) {
                    $fallback_body = $body;
                    unset( $fallback_body['reply_parameters'] );

                    $fallback_response = wp_remote_post(
                        $this->bot_api_url( 'sendMessage', $bot_token ),
                        array(
                            'timeout' => 20,
                            'body'    => $fallback_body,
                        )
                    );
                    if ( ! is_wp_error( $fallback_response ) ) {
                        $fallback_status = wp_remote_retrieve_response_code( $fallback_response );
                        $fallback_data   = json_decode( (string) wp_remote_retrieve_body( $fallback_response ), true );
                        if ( $fallback_status >= 200 && $fallback_status < 300 && ! empty( $fallback_data['ok'] ) ) {
                            if ( isset( $fallback_data['result']['message_id'] ) ) {
                                $message_ids[] = intval( $fallback_data['result']['message_id'] );
                            }
                            continue;
                        }
                    }
                }

                if ( false !== stripos( $error, "can't parse entities" ) ) {
                    $plain_body = $body;
                    $plain_body['text'] = $this->format_plain_text_for_telegram( $chunk, self::MESSAGE_TEXT_LIMIT );
                    unset( $plain_body['parse_mode'] );

                    $retry_response = wp_remote_post(
                        $this->bot_api_url( 'sendMessage', $bot_token ),
                        array(
                            'timeout' => 20,
                            'body'    => $plain_body,
                        )
                    );
                    if ( ! is_wp_error( $retry_response ) ) {
                        $retry_status = wp_remote_retrieve_response_code( $retry_response );
                        $retry_data   = json_decode( (string) wp_remote_retrieve_body( $retry_response ), true );
                        if ( $retry_status >= 200 && $retry_status < 300 && ! empty( $retry_data['ok'] ) ) {
                            if ( isset( $retry_data['result']['message_id'] ) ) {
                                $message_ids[] = intval( $retry_data['result']['message_id'] );
                            }
                            continue;
                        }
                    }
                }

                return array(
                    'success' => false,
                    'error'   => $error . $chunk_label,
                );
            }

            if ( isset( $data['result']['message_id'] ) ) {
                $message_ids[] = intval( $data['result']['message_id'] );
            }
        }

        if ( empty( $chunks ) ) {
            return array(
                'success' => false,
                'error'   => 'Telegram message text je prazan.',
            );
        }

        return array(
            'success'       => true,
            'message_ids'   => $message_ids,
            'message_count' => count( $chunks ),
            'first_message_id' => ! empty( $message_ids ) ? intval( $message_ids[0] ) : 0,
            'last_message_id'  => ! empty( $message_ids ) ? intval( $message_ids[ count( $message_ids ) - 1 ] ) : 0,
        );
    }

    private function is_missing_reply_target_error( $error ) {
        $error = strtolower( (string) $error );
        return false !== strpos( $error, 'message to be replied not found' )
            || false !== strpos( $error, 'reply message not found' )
            || false !== strpos( $error, 'reply_to_message_id' )
            || false !== strpos( $error, 'field "message_id" must be a valid number' );
    }

    private function format_plain_text_for_telegram( $text, $limit = self::MESSAGE_TEXT_LIMIT ) {
        $text  = (string) $text;
        $limit = max( 1, intval( $limit ) );
        if ( $this->get_telegram_text_length( $text ) <= $limit ) {
            return $text;
        }

        return $this->telegram_substr( $text, 0, max( 1, $limit - 3 ) ) . '...';
    }

    public function send_document( $chat_id, $document, $caption = '', $reply_to_message_id = 0 ) {
        $outbound = $this->require_outbound_bot( $chat_id );
        if ( empty( $outbound['success'] ) ) {
            return $outbound;
        }
        $bot_token = (string) $outbound['bot_token'];
        $chat_id   = (string) $outbound['chat_id'];
        $document  = trim( (string) $document );

        if ( '' === $document ) {
            return array(
                'success' => false,
                'error'   => 'Telegram document payload nije potpun.',
            );
        }

        $caption = trim( $this->sanitize_outbound_message_text( $caption ) );
        if ( is_file( $document ) && class_exists( 'CURLFile' ) && function_exists( 'curl_init' ) ) {
            return $this->send_document_multipart( $bot_token, $chat_id, $document, $caption, $reply_to_message_id );
        }

        $body = array(
            'chat_id'  => $chat_id,
            'document' => $document,
        );

        $caption = trim( (string) $caption );
        if ( '' !== $caption ) {
            $body['caption']    = $this->format_rich_text_for_telegram( $caption, self::CAPTION_TEXT_LIMIT );
            $body['parse_mode'] = 'MarkdownV2';
        }

        if ( $reply_to_message_id > 0 ) {
            $body['reply_parameters'] = wp_json_encode(
                array(
                    'message_id' => intval( $reply_to_message_id ),
                )
            );
        }

        $response = wp_remote_post(
            $this->bot_api_url( 'sendDocument', $bot_token ),
            array(
                'timeout' => 30,
                'body'    => $body,
            )
        );

        if ( is_wp_error( $response ) ) {
            return array(
                'success' => false,
                'error'   => $response->get_error_message(),
            );
        }

        $status = wp_remote_retrieve_response_code( $response );
        $data   = json_decode( (string) wp_remote_retrieve_body( $response ), true );
        if ( $status < 200 || $status >= 300 || empty( $data['ok'] ) ) {
            return array(
                'success' => false,
                'error'   => isset( $data['description'] ) ? (string) $data['description'] : 'Telegram sendDocument failed.',
            );
        }

        return array(
            'success' => true,
        );
    }

    private function send_document_multipart( $bot_token, $chat_id, $file_path, $caption = '', $reply_to_message_id = 0 ) {
        if ( '' === $bot_token || '' === $chat_id || ! is_file( $file_path ) || ! class_exists( 'CURLFile' ) || ! function_exists( 'curl_init' ) ) {
            return array(
                'success' => false,
                'error'   => 'Telegram document multipart payload nije potpun.',
            );
        }

        $mime = function_exists( 'mime_content_type' ) ? mime_content_type( $file_path ) : 'application/octet-stream';
        if ( ! is_string( $mime ) || '' === $mime ) {
            $mime = 'application/octet-stream';
        }

        $body = array(
            'chat_id'  => $chat_id,
            'document' => new CURLFile( $file_path, $mime, basename( $file_path ) ),
        );

        $caption = trim( $this->sanitize_outbound_message_text( $caption ) );
        if ( '' !== $caption ) {
            $body['caption']    = $this->format_rich_text_for_telegram( $caption, self::CAPTION_TEXT_LIMIT );
            $body['parse_mode'] = 'MarkdownV2';
        }

        if ( $reply_to_message_id > 0 ) {
            $body['reply_parameters'] = wp_json_encode(
                array(
                    'message_id' => intval( $reply_to_message_id ),
                )
            );
        }

        $ch = curl_init( $this->bot_api_url( 'sendDocument', $bot_token ) );
        curl_setopt( $ch, CURLOPT_POST, true );
        curl_setopt( $ch, CURLOPT_POSTFIELDS, $body );
        curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
        curl_setopt( $ch, CURLOPT_TIMEOUT, 60 );

        $raw = curl_exec( $ch );
        if ( false === $raw ) {
            $error = curl_error( $ch );
            curl_close( $ch );
            return array(
                'success' => false,
                'error'   => '' !== $error ? $error : 'Telegram sendDocument multipart failed.',
            );
        }

        $status = curl_getinfo( $ch, CURLINFO_HTTP_CODE );
        curl_close( $ch );

        $data = json_decode( (string) $raw, true );
        if ( $status < 200 || $status >= 300 || empty( $data['ok'] ) ) {
            return array(
                'success' => false,
                'error'   => isset( $data['description'] ) ? (string) $data['description'] : 'Telegram sendDocument multipart failed.',
            );
        }

        return array(
            'success' => true,
        );
    }

    public function send_photo( $chat_id, $photo, $caption = '', $reply_to_message_id = 0 ) {
        $outbound = $this->require_outbound_bot( $chat_id );
        if ( empty( $outbound['success'] ) ) {
            return $outbound;
        }
        $bot_token = (string) $outbound['bot_token'];
        $chat_id   = (string) $outbound['chat_id'];
        $photo     = trim( (string) $photo );

        if ( '' === $photo ) {
            return array(
                'success' => false,
                'error'   => 'Telegram photo payload nije potpun.',
            );
        }

        $caption = trim( $this->sanitize_outbound_message_text( $caption ) );

        // Detect if $photo is a local file path (for multipart binary upload).
        $is_local_file = is_file( $photo );

        if ( $is_local_file && class_exists( 'CURLFile' ) && function_exists( 'curl_init' ) ) {
            return $this->send_photo_multipart( $bot_token, $chat_id, $photo, $caption, $reply_to_message_id );
        }

        $body = array(
            'chat_id' => $chat_id,
            'photo'   => $photo,
        );

        if ( '' !== $caption ) {
            $body['caption']    = $this->format_rich_text_for_telegram( $caption, self::CAPTION_TEXT_LIMIT );
            $body['parse_mode'] = 'MarkdownV2';
        }

        if ( $reply_to_message_id > 0 ) {
            $body['reply_parameters'] = wp_json_encode(
                array(
                    'message_id' => intval( $reply_to_message_id ),
                )
            );
        }

        $response = wp_remote_post(
            $this->bot_api_url( 'sendPhoto', $bot_token ),
            array(
                'timeout' => 30,
                'body'    => $body,
            )
        );

        if ( is_wp_error( $response ) ) {
            return array(
                'success' => false,
                'error'   => $response->get_error_message(),
            );
        }

        $status = wp_remote_retrieve_response_code( $response );
        $data   = json_decode( (string) wp_remote_retrieve_body( $response ), true );
        if ( $status < 200 || $status >= 300 || empty( $data['ok'] ) ) {
            return array(
                'success' => false,
                'error'   => isset( $data['description'] ) ? (string) $data['description'] : 'Telegram sendPhoto failed.',
            );
        }

        return array(
            'success' => true,
        );
    }

    public function send_sticker( $chat_id, $sticker, $reply_to_message_id = 0 ) {
        return $this->send_telegram_file_by_id_or_url(
            'sendSticker',
            'sticker',
            $chat_id,
            $sticker,
            '',
            $reply_to_message_id
        );
    }

    public function send_animation( $chat_id, $animation, $caption = '', $reply_to_message_id = 0 ) {
        return $this->send_telegram_file_by_id_or_url(
            'sendAnimation',
            'animation',
            $chat_id,
            $animation,
            $caption,
            $reply_to_message_id
        );
    }

    private function send_telegram_file_by_id_or_url( $method, $field, $chat_id, $file, $caption = '', $reply_to_message_id = 0 ) {
        $outbound = $this->require_outbound_bot( $chat_id );
        if ( empty( $outbound['success'] ) ) {
            return $outbound;
        }
        $bot_token = (string) $outbound['bot_token'];
        $chat_id   = (string) $outbound['chat_id'];
        $file      = trim( (string) $file );

        if ( '' === $file || '' === trim( (string) $method ) || '' === trim( (string) $field ) ) {
            return array(
                'success' => false,
                'error'   => 'Telegram media payload nije potpun.',
            );
        }

        $body = array(
            'chat_id' => $chat_id,
            $field    => $file,
        );

        $caption = trim( $this->sanitize_outbound_message_text( $caption ) );
        if ( '' !== $caption ) {
            $body['caption']    = $this->format_rich_text_for_telegram( $caption, self::CAPTION_TEXT_LIMIT );
            $body['parse_mode'] = 'MarkdownV2';
        }

        if ( $reply_to_message_id > 0 ) {
            $body['reply_parameters'] = wp_json_encode(
                array(
                    'message_id' => intval( $reply_to_message_id ),
                )
            );
        }

        $response = wp_remote_post(
            $this->bot_api_url( $method, $bot_token ),
            array(
                'timeout' => 30,
                'body'    => $body,
            )
        );

        if ( is_wp_error( $response ) ) {
            return array(
                'success' => false,
                'error'   => $response->get_error_message(),
            );
        }

        $status = wp_remote_retrieve_response_code( $response );
        $data   = json_decode( (string) wp_remote_retrieve_body( $response ), true );
        if ( $status < 200 || $status >= 300 || empty( $data['ok'] ) ) {
            return array(
                'success' => false,
                'error'   => isset( $data['description'] ) ? (string) $data['description'] : 'Telegram ' . $method . ' failed.',
            );
        }

        return array(
            'success' => true,
        );
    }

    /**
     * Send a photo via Telegram using multipart/form-data (CURLFile) for local files.
     *
     * @param string $bot_token           Telegram bot token.
     * @param string $chat_id             Chat ID.
     * @param string $file_path           Local file path.
     * @param string $caption             Caption text.
     * @param int    $reply_to_message_id Reply message ID.
     * @return array { success: bool, error?: string }
     */
    private function send_photo_multipart( $bot_token, $chat_id, $file_path, $caption = '', $reply_to_message_id = 0 ) {
        $mime = '';
        if ( function_exists( 'mime_content_type' ) ) {
            $mime = (string) mime_content_type( $file_path );
        }
        if ( '' === $mime ) {
            $mime = 'image/jpeg';
        }

        $body = array(
            'chat_id' => $chat_id,
            'photo'   => new CURLFile( $file_path, $mime, basename( $file_path ) ),
        );

        $caption = trim( $this->sanitize_outbound_message_text( $caption ) );
        if ( '' !== $caption ) {
            $body['caption']    = $this->format_rich_text_for_telegram( $caption, self::CAPTION_TEXT_LIMIT );
            $body['parse_mode'] = 'MarkdownV2';
        }

        if ( $reply_to_message_id > 0 ) {
            $body['reply_parameters'] = wp_json_encode(
                array(
                    'message_id' => intval( $reply_to_message_id ),
                )
            );
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_init
        $ch = curl_init();
        // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt_array
        curl_setopt_array(
            $ch,
            array(
                CURLOPT_URL            => $this->bot_api_url( 'sendPhoto', $bot_token ),
                CURLOPT_POST           => true,
                CURLOPT_POSTFIELDS     => $body,
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_TIMEOUT        => 30,
            )
        );
        // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_exec
        $raw = curl_exec( $ch );
        // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_error
        $err = curl_error( $ch );
        // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_close
        curl_close( $ch );

        if ( '' !== $err ) {
            return array(
                'success' => false,
                'error'   => 'cURL multipart send failed: ' . $err,
            );
        }

        $data = json_decode( (string) $raw, true );
        if ( empty( $data['ok'] ) ) {
            return array(
                'success' => false,
                'error'   => isset( $data['description'] ) ? (string) $data['description'] : 'Telegram sendPhoto multipart failed.',
            );
        }

        return array(
            'success' => true,
        );
    }

    private function render_rich_text_for_telegram( $text ) {
        $text = trim( (string) $text );
        if ( '' === $text ) {
            return '';
        }

        return $this->render_markdown_v2_text_for_telegram( $text );
    }

    private function render_markdown_v2_text_for_telegram( $text ) {
        $text = (string) $text;
        if ( '' === $text ) {
            return '';
        }

        $parts = preg_split( '/(```[a-zA-Z0-9_-]*[ \t]*(?:\r?\n)?[\s\S]*?```)/', $text, -1, PREG_SPLIT_DELIM_CAPTURE );
        if ( ! is_array( $parts ) ) {
            return $this->render_markdown_v2_inline_text( $text );
        }

        $rendered = '';
        foreach ( $parts as $part ) {
            if ( '' === $part ) {
                continue;
            }

            if ( preg_match( '/^```([a-zA-Z0-9_-]*)[ \t]*(?:\r?\n)?([\s\S]*?)```$/', $part, $matches ) ) {
                $language = isset( $matches[1] ) ? (string) $matches[1] : '';
                $code     = isset( $matches[2] ) ? (string) $matches[2] : '';
                $rendered .= '```' . $language . "\n" . $this->escape_markdown_v2_code_text( trim( $code ) ) . "\n```";
                continue;
            }

            $rendered .= $this->render_markdown_v2_inline_text( $part );
        }

        return $rendered;
    }

    private function render_markdown_v2_inline_text( $text ) {
        $text   = (string) $text;
        $length = strlen( $text );
        $offset = 0;
        $out    = '';

        while ( $offset < $length ) {
            $next_bold = strpos( $text, '**', $offset );
            $next_code = strpos( $text, '`', $offset );
            $candidates = array();

            if ( false !== $next_bold ) {
                $candidates['bold'] = $next_bold;
            }
            if ( false !== $next_code ) {
                $candidates['code'] = $next_code;
            }

            if ( empty( $candidates ) ) {
                $out .= $this->escape_markdown_v2_text( substr( $text, $offset ) );
                break;
            }

            $type = array_search( min( $candidates ), $candidates, true );
            $pos  = $candidates[ $type ];
            if ( $pos > $offset ) {
                $out .= $this->escape_markdown_v2_text( substr( $text, $offset, $pos - $offset ) );
            }

            if ( 'bold' === $type ) {
                $end = strpos( $text, '**', $pos + 2 );
                if ( false === $end ) {
                    $out .= $this->escape_markdown_v2_text( substr( $text, $pos ) );
                    break;
                }

                $inner = substr( $text, $pos + 2, $end - $pos - 2 );
                $out  .= '*' . $this->escape_markdown_v2_text( $inner ) . '*';
                $offset = $end + 2;
                continue;
            }

            $end = strpos( $text, '`', $pos + 1 );
            if ( false === $end || false !== strpos( substr( $text, $pos + 1, $end - $pos - 1 ), "\n" ) ) {
                $out .= $this->escape_markdown_v2_text( substr( $text, $pos, 1 ) );
                $offset = $pos + 1;
                continue;
            }

            $inner = substr( $text, $pos + 1, $end - $pos - 1 );
            $out  .= '`' . $this->escape_markdown_v2_code_text( $inner ) . '`';
            $offset = $end + 1;
        }

        return $out;
    }

    private function escape_markdown_v2_text( $text ) {
        return preg_replace( '/([\\\\_\*\[\]\(\)~`>#+\-=|{}\.!])/', '\\\\$1', (string) $text );
    }

    private function escape_markdown_v2_code_text( $text ) {
        return str_replace( array( '\\', '`' ), array( '\\\\', '\\`' ), (string) $text );
    }

    private function sanitize_outbound_message_text( $text ) {
        $text = (string) $text;
        if ( '' === trim( $text ) ) {
            return $text;
        }

        $original = $text;

        // Never expose internal tool transcript envelopes to Telegram.
        $text = $this->strip_internal_protocol_xml_blocks( $text );

        $text = preg_replace(
            '/<tool_call\b[^>]*>.*?<\/tool_call>/is',
            '',
            $text
        );

        $text = preg_replace(
            '/<arguments\b[^>]*>.*?<\/arguments>/is',
            '',
            $text
        );

        $text = preg_replace(
            '/<([a-z0-9_]*?(?:coding|tool|workspace|lint|git|shell)[a-z0-9_]*)\b[^>]*>.*?<\/\1>/is',
            '',
            $text
        );

        // Strip raw tool-call JSON emitted as text, including adjacent empty
        // result objects like: {"tool_name":"main_coding_git_diff",...}{}
        $text = preg_replace(
            '/\{\s*"(?:tool_name|name)"\s*:\s*"[^"]*(?:coding|tool|workspace|lint|git|shell)[^"]*"\s*,\s*"(?:arguments|args)"\s*:\s*\{[^{}]*\}\s*\}\s*(?:\{\s*\}\s*)*/is',
            '',
            $text
        );
        $text = $this->strip_leading_protocol_json_objects( $text );

        // Remove standalone empty JSON objects left behind by stripped tool
        // output. Keep this conservative so normal prose is not touched.
        $text = preg_replace( '/(?:^|\n)\s*\{\s*\}\s*(?=\n|$)/', "\n", $text );

        if ( class_exists( 'Niwa_AI_Humanizer' ) ) {
            $text = ( new Niwa_AI_Humanizer() )->apply( $text );
        }

        $text = trim( preg_replace( "/[ \t]+\n/", "\n", (string) $text ) );
        $text = preg_replace( "/\n{3,}/", "\n\n", $text );

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

        // If the entire outbound payload was protocol noise, fail closed with
        // a human-safe status instead of leaking internals.
        if ( $original !== $text ) {
            return 'Interni alatni zapis je sakriven. Nastavljam bez slanja raw runtime output-a.';
        }

        return '';
    }

    private function strip_internal_protocol_xml_blocks( $text ) {
        $text = (string) $text;
        if ( '' === trim( $text ) || false === strpos( $text, '<' ) ) {
            return $text;
        }

        $previous = null;
        while ( $previous !== $text ) {
            $previous = $text;
            $text     = preg_replace_callback(
                '/<([a-z][a-z0-9_]*)(\b[^>]*)>(.*?)<\/\1>/is',
                function ( $matches ) {
                    $tag   = isset( $matches[1] ) ? strtolower( (string) $matches[1] ) : '';
                    $attrs = isset( $matches[2] ) ? (string) $matches[2] : '';
                    $body  = isset( $matches[3] ) ? (string) $matches[3] : '';

                    return $this->looks_like_internal_protocol_xml_block( $tag, $attrs, $body ) ? '' : $matches[0];
                },
                $text
            );
        }

        return (string) $text;
    }

    private function looks_like_internal_protocol_xml_block( $tag, $attrs, $body ) {
        $tag = strtolower( trim( (string) $tag ) );
        if ( '' === $tag ) {
            return false;
        }

        if ( in_array( $tag, array( 'tool_call', 'arguments' ), true ) ) {
            return true;
        }

        if ( strlen( $tag ) >= 7 && 0 === substr_compare( $tag, '_result', -7 ) ) {
            return true;
        }

        if ( false === strpos( $tag, '_' ) ) {
            return false;
        }

        if ( preg_match( '/\b(?:tool_call_id|status)\s*=/i', (string) $attrs ) ) {
            return true;
        }

        $body = ltrim( html_entity_decode( (string) $body, ENT_QUOTES | ENT_HTML5, 'UTF-8' ) );
        return '' !== $body && '{' === substr( $body, 0, 1 ) && $this->leading_balanced_json_object_length( $body ) > 0;
    }

    private function strip_leading_protocol_json_objects( $text ) {
        $text = (string) $text;

        while ( true ) {
            $trimmed = ltrim( $text );
            if ( '' === $trimmed || '{' !== substr( $trimmed, 0, 1 ) ) {
                return $text;
            }

            $json_length = $this->leading_balanced_json_object_length( $trimmed );
            if ( $json_length <= 0 ) {
                return $text;
            }

            $json    = substr( $trimmed, 0, $json_length );
            $decoded = json_decode( $json, true );
            if ( ! is_array( $decoded ) || ! $this->looks_like_protocol_json_object( $decoded ) ) {
                return $text;
            }

            $text = substr( $trimmed, $json_length );
        }
    }

    private function leading_balanced_json_object_length( $text ) {
        $text      = (string) $text;
        $length    = strlen( $text );
        $depth     = 0;
        $in_string = false;
        $escaped   = false;

        for ( $i = 0; $i < $length; $i++ ) {
            $char = $text[ $i ];

            if ( $in_string ) {
                if ( $escaped ) {
                    $escaped = false;
                } elseif ( '\\' === $char ) {
                    $escaped = true;
                } elseif ( '"' === $char ) {
                    $in_string = false;
                }
                continue;
            }

            if ( '"' === $char ) {
                $in_string = true;
                continue;
            }

            if ( '{' === $char ) {
                $depth++;
                continue;
            }

            if ( '}' === $char ) {
                $depth--;
                if ( 0 === $depth ) {
                    return $i + 1;
                }
            }
        }

        return 0;
    }

    private function looks_like_protocol_json_object( $decoded ) {
        if ( ! is_array( $decoded ) || empty( $decoded ) ) {
            return false;
        }

        $keys          = array_map( 'strval', array_keys( $decoded ) );
        $protocol_keys = array( 'tool_name', 'name', 'arguments', 'args', 'cmd', 'command', 'argv', 'query', 'timeout', 'timeout_seconds', 'max_output_chars' );
        if ( empty( array_intersect( $keys, $protocol_keys ) ) ) {
            return false;
        }

        $toolish = '';
        foreach ( array( 'tool_name', 'name', 'query' ) as $key ) {
            if ( isset( $decoded[ $key ] ) ) {
                $toolish .= ' ' . strtolower( (string) $decoded[ $key ] );
            }
        }

        if ( isset( $decoded['cmd'] ) || isset( $decoded['command'] ) || isset( $decoded['argv'] ) ) {
            return true;
        }

        return (bool) preg_match( '/\b(main_coding|tool_|workspace|lint|git|shell|code_|file_)/', $toolish );
    }

    private function format_rich_text_for_telegram( $text, $limit = self::MESSAGE_TEXT_LIMIT ) {
        $safe = $this->render_rich_text_for_telegram( $text );
        if ( '' === $safe ) {
            return '';
        }

        if ( $limit <= 0 ) {
            return $safe;
        }

        return function_exists( 'mb_substr' ) ? mb_substr( $safe, 0, $limit, 'UTF-8' ) : substr( $safe, 0, $limit );
    }

    private function split_text_for_telegram_messages( $text, $limit = self::MESSAGE_TEXT_LIMIT ) {
        $text  = trim( (string) $text );
        $limit = max( 32, intval( $limit ) );

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

        $chunks  = array();
        $pending = array( $text );

        while ( ! empty( $pending ) ) {
            $current = trim( (string) array_shift( $pending ) );
            if ( '' === $current ) {
                continue;
            }

            $formatted = $this->render_rich_text_for_telegram( $current );
            if ( $this->get_telegram_text_length( $formatted ) <= $limit ) {
                $chunks[] = $current;
                continue;
            }

            $split_pos = $this->find_telegram_split_position( $current, $limit );
            $length    = $this->get_telegram_text_length( $current );

            if ( $split_pos <= 0 || $split_pos >= $length ) {
                $split_pos = max( 1, min( $length - 1, intval( floor( $length / 2 ) ) ) );
            }

            $first  = trim( $this->telegram_substr( $current, 0, $split_pos ) );
            $second = trim( $this->telegram_substr( $current, $split_pos ) );

            if ( '' === $first || '' === $second ) {
                $fallback = max( 1, min( $length - 1, intval( floor( $length / 2 ) ) ) );
                $first    = trim( $this->telegram_substr( $current, 0, $fallback ) );
                $second   = trim( $this->telegram_substr( $current, $fallback ) );
            }

            if ( '' === $first || '' === $second ) {
                $chunks[] = $this->telegram_substr( $current, 0, max( 1, $limit - 3 ) ) . '...';
                continue;
            }

            array_unshift( $pending, $second );
            array_unshift( $pending, $first );
        }

        return $chunks;
    }

    private function find_telegram_split_position( $text, $limit ) {
        $text           = trim( (string) $text );
        $formatted      = $this->render_rich_text_for_telegram( $text );
        $raw_length     = $this->get_telegram_text_length( $text );
        $formatted_len  = $this->get_telegram_text_length( $formatted );

        if ( '' === $text || $raw_length <= 1 || $formatted_len <= $limit ) {
            return $raw_length;
        }

        $estimate = max( 1, min( $raw_length - 1, intval( floor( $raw_length * ( $limit / max( 1, $formatted_len ) ) ) ) ) );
        $min_pos  = max( 1, intval( floor( $estimate * 0.5 ) ) );
        $candidates = array_filter(
            array(
                $this->find_preferred_telegram_breakpoint( $text, $estimate ),
                $estimate,
            ),
            function ( $pos ) use ( $raw_length ) {
                return is_int( $pos ) && $pos > 0 && $pos < $raw_length;
            }
        );

        foreach ( array_unique( $candidates ) as $candidate ) {
            $adjusted = $candidate;
            while ( $adjusted > $min_pos ) {
                $first = trim( $this->telegram_substr( $text, 0, $adjusted ) );
                if ( '' !== $first && $this->get_telegram_text_length( $this->render_rich_text_for_telegram( $first ) ) <= $limit ) {
                    return $adjusted;
                }

                $next = $this->find_preferred_telegram_breakpoint( $text, $adjusted - 1 );
                if ( $next <= 0 || $next >= $adjusted ) {
                    $adjusted--;
                } else {
                    $adjusted = $next;
                }
            }
        }

        return max( 1, min( $raw_length - 1, $estimate ) );
    }

    private function find_preferred_telegram_breakpoint( $text, $target_pos ) {
        $target_pos = intval( $target_pos );
        if ( $target_pos <= 0 ) {
            return 0;
        }

        $prefix = $this->telegram_substr( $text, 0, $target_pos );
        if ( '' === $prefix ) {
            return 0;
        }

        if ( function_exists( 'mb_strrpos' ) ) {
            $paragraph = mb_strrpos( $prefix, "\n\n", 0, 'UTF-8' );
            if ( false !== $paragraph && $paragraph > 0 ) {
                return intval( $paragraph ) + 2;
            }

            $newline = mb_strrpos( $prefix, "\n", 0, 'UTF-8' );
            if ( false !== $newline && $newline > 0 ) {
                return intval( $newline ) + 1;
            }
        } else {
            $paragraph = strrpos( $prefix, "\n\n" );
            if ( false !== $paragraph && $paragraph > 0 ) {
                return intval( $paragraph ) + 2;
            }

            $newline = strrpos( $prefix, "\n" );
            if ( false !== $newline && $newline > 0 ) {
                return intval( $newline ) + 1;
            }
        }

        for ( $i = $this->get_telegram_text_length( $prefix ) - 1; $i > 0; $i-- ) {
            $char = $this->telegram_substr( $prefix, $i, 1 );
            if ( '' !== $char && preg_match( '/\s/u', $char ) ) {
                return $i + 1;
            }
        }

        return $target_pos;
    }

    private function get_telegram_text_length( $text ) {
        return function_exists( 'mb_strlen' ) ? mb_strlen( (string) $text, 'UTF-8' ) : strlen( (string) $text );
    }

    private function telegram_substr( $text, $start, $length = null ) {
        if ( function_exists( 'mb_substr' ) ) {
            return null === $length
                ? mb_substr( (string) $text, intval( $start ), null, 'UTF-8' )
                : mb_substr( (string) $text, intval( $start ), intval( $length ), 'UTF-8' );
        }

        return null === $length
            ? substr( (string) $text, intval( $start ) )
            : substr( (string) $text, intval( $start ), intval( $length ) );
    }

    public function send_chat_action( $chat_id, $action = 'typing' ) {
        $outbound = $this->require_outbound_bot( $chat_id );
        if ( empty( $outbound['success'] ) ) {
            return $outbound;
        }
        $bot_token = (string) $outbound['bot_token'];
        $chat_id   = (string) $outbound['chat_id'];
        $action    = trim( (string) $action );

        if ( '' === $action ) {
            return array(
                'success' => false,
                'error'   => 'Telegram chat action nema dovoljne podatke.',
            );
        }

        $response = wp_remote_post(
            $this->bot_api_url( 'sendChatAction', $bot_token ),
            array(
                'timeout' => 15,
                'body'    => array(
                    'chat_id' => $chat_id,
                    'action'  => $action,
                ),
            )
        );

        if ( is_wp_error( $response ) ) {
            return array(
                'success' => false,
                'error'   => $response->get_error_message(),
            );
        }

        $status = wp_remote_retrieve_response_code( $response );
        $data   = json_decode( (string) wp_remote_retrieve_body( $response ), true );
        if ( $status < 200 || $status >= 300 || empty( $data['ok'] ) ) {
            return array(
                'success' => false,
                'error'   => isset( $data['description'] ) ? (string) $data['description'] : 'Telegram sendChatAction failed.',
            );
        }

        return array(
            'success' => true,
        );
    }

    public function delete_message( $chat_id, $message_id ) {
        $outbound = $this->require_outbound_bot( $chat_id );
        if ( empty( $outbound['success'] ) ) {
            return $outbound;
        }
        $bot_token  = (string) $outbound['bot_token'];
        $chat_id    = (string) $outbound['chat_id'];
        $message_id = intval( $message_id );

        if ( $message_id <= 0 ) {
            return array(
                'success' => false,
                'error'   => 'Telegram deleteMessage nema dovoljne podatke.',
            );
        }

        $response = wp_remote_post(
            $this->bot_api_url( 'deleteMessage', $bot_token ),
            array(
                'timeout' => 15,
                'body'    => array(
                    'chat_id'    => $chat_id,
                    'message_id' => $message_id,
                ),
            )
        );

        if ( is_wp_error( $response ) ) {
            return array(
                'success' => false,
                'error'   => $response->get_error_message(),
            );
        }

        $status = wp_remote_retrieve_response_code( $response );
        $data   = json_decode( (string) wp_remote_retrieve_body( $response ), true );
        if ( $status < 200 || $status >= 300 || empty( $data['ok'] ) ) {
            return array(
                'success' => false,
                'error'   => isset( $data['description'] ) ? (string) $data['description'] : 'Telegram deleteMessage failed.',
            );
        }

        return array(
            'success' => true,
        );
    }

    public function answer_callback_query( $callback_query_id, $text = '' ) {
        $outbound = $this->require_outbound_bot( '', false );
        if ( empty( $outbound['success'] ) ) {
            return $outbound;
        }
        $bot_token = (string) $outbound['bot_token'];

        if ( '' === trim( (string) $callback_query_id ) ) {
            return array(
                'success' => false,
                'error'   => 'Callback query odgovor nije mogao da se posalje.',
            );
        }

        $body = array(
            'callback_query_id' => (string) $callback_query_id,
        );
        if ( '' !== trim( (string) $text ) ) {
            $body['text'] = function_exists( 'mb_substr' ) ? mb_substr( (string) $text, 0, 180 ) : substr( (string) $text, 0, 180 );
        }

        $response = wp_remote_post(
            $this->bot_api_url( 'answerCallbackQuery', $bot_token ),
            array(
                'timeout' => 20,
                'body'    => $body,
            )
        );

        if ( is_wp_error( $response ) ) {
            return array(
                'success' => false,
                'error'   => $response->get_error_message(),
            );
        }

        $status = wp_remote_retrieve_response_code( $response );
        $data   = json_decode( (string) wp_remote_retrieve_body( $response ), true );
        if ( $status < 200 || $status >= 300 || empty( $data['ok'] ) ) {
            return array(
                'success' => false,
                'error'   => isset( $data['description'] ) ? (string) $data['description'] : 'Telegram answerCallbackQuery failed.',
            );
        }

        return array(
            'success' => true,
        );
    }

    public function edit_message_reply_markup( $chat_id, $message_id, $reply_markup = null ) {
        $outbound = $this->require_outbound_bot( $chat_id );
        if ( empty( $outbound['success'] ) ) {
            return $outbound;
        }
        $bot_token = (string) $outbound['bot_token'];
        $chat_id   = (string) $outbound['chat_id'];

        if ( intval( $message_id ) <= 0 ) {
            return array(
                'success' => false,
                'error'   => 'Telegram editMessageReplyMarkup nema dovoljne podatke.',
            );
        }

        $body = array(
            'chat_id'    => $chat_id,
            'message_id' => intval( $message_id ),
        );
        if ( null !== $reply_markup ) {
            $body['reply_markup'] = wp_json_encode( $reply_markup );
        }

        $response = wp_remote_post(
            $this->bot_api_url( 'editMessageReplyMarkup', $bot_token ),
            array(
                'timeout' => 20,
                'body'    => $body,
            )
        );

        if ( is_wp_error( $response ) ) {
            return array(
                'success' => false,
                'error'   => $response->get_error_message(),
            );
        }

        $status = wp_remote_retrieve_response_code( $response );
        $data   = json_decode( (string) wp_remote_retrieve_body( $response ), true );
        if ( $status < 200 || $status >= 300 || empty( $data['ok'] ) ) {
            return array(
                'success' => false,
                'error'   => isset( $data['description'] ) ? (string) $data['description'] : 'Telegram editMessageReplyMarkup failed.',
            );
        }

        return array(
            'success' => true,
        );
    }

    public function delete_webhook() {
        $settings  = $this->get_settings();
        $bot_token = $this->get_bot_token_from_settings( $settings );

        if ( '' === $bot_token ) {
            return array(
                'success' => false,
                'error'   => 'Bot token nedostaje za deleteWebhook.',
            );
        }

        $response = wp_remote_post(
            $this->bot_api_url( 'deleteWebhook', $bot_token ),
            array(
                'timeout' => 20,
                'body'    => array(
                    'drop_pending_updates' => 'false',
                ),
            )
        );

        if ( is_wp_error( $response ) ) {
            return array(
                'success' => false,
                'error'   => $response->get_error_message(),
            );
        }

        $status = wp_remote_retrieve_response_code( $response );
        $data   = json_decode( (string) wp_remote_retrieve_body( $response ), true );
        if ( $status < 200 || $status >= 300 || empty( $data['ok'] ) ) {
            return array(
                'success' => false,
                'error'   => isset( $data['description'] ) ? (string) $data['description'] : 'Telegram deleteWebhook failed.',
            );
        }

        $this->update_webhook_state( array() );

        return array(
            'success' => true,
            'message' => isset( $data['description'] ) ? (string) $data['description'] : 'Webhook deleted.',
        );
    }

    public function set_webhook() {
        $settings  = $this->get_settings();
        $bot_token = $this->get_bot_token_from_settings( $settings );

        if ( empty( $settings['enabled'] ) ) {
            return array(
                'success' => false,
                'error'   => 'Telegram nije ukljucen.',
            );
        }

        if ( ! $this->is_webhook_transport_enabled() ) {
            return array(
                'success' => false,
                'error'   => 'telegram_transport_polling',
            );
        }

        if ( '' === $bot_token ) {
            return array(
                'success' => false,
                'error'   => 'Bot token nedostaje za setWebhook.',
            );
        }

        $secret = $this->ensure_runner_secret();
        $url    = $this->get_webhook_url( $secret );
        $bot_hash = $this->get_bot_hash( $bot_token );
        $state  = $this->get_webhook_state();
        $state['url']             = $url;
        $state['bot_hash']        = $bot_hash;
        $state['last_attempt_at'] = time();
        $state['updated_at']      = time();
        $this->update_webhook_state( $state );

        $response = wp_remote_post(
            $this->bot_api_url( 'setWebhook', $bot_token ),
            array(
                'timeout' => 20,
                'body'    => array(
                    'url'                  => $url,
                    'allowed_updates'      => wp_json_encode( $this->get_allowed_updates() ),
                    'drop_pending_updates' => 'false',
                    'max_connections'      => 40,
                    'secret_token'         => $secret,
                ),
            )
        );

        if ( is_wp_error( $response ) ) {
            $state               = $this->get_webhook_state();
            $state['active']     = false;
            $state['url']        = $url;
            $state['bot_hash']   = $bot_hash;
            $state['updated_at'] = time();
            $state['last_error'] = $response->get_error_message();
            $this->update_webhook_state( $state );

            return array(
                'success' => false,
                'error'   => $response->get_error_message(),
            );
        }

        $status = wp_remote_retrieve_response_code( $response );
        $data   = json_decode( (string) wp_remote_retrieve_body( $response ), true );
        if ( $status < 200 || $status >= 300 || empty( $data['ok'] ) ) {
            $error               = isset( $data['description'] ) ? (string) $data['description'] : 'Telegram setWebhook failed.';
            $state               = $this->get_webhook_state();
            $state['active']     = false;
            $state['url']        = $url;
            $state['bot_hash']   = $bot_hash;
            $state['updated_at'] = time();
            $state['last_error'] = $error;
            $this->update_webhook_state( $state );

            return array(
                'success' => false,
                'error'   => $error,
            );
        }

        $state = $this->get_webhook_state();
        $this->update_webhook_state(
            array(
                'active'           => true,
                'url'              => $url,
                'bot_hash'         => $bot_hash,
                'updated_at'       => time(),
                'last_attempt_at'  => isset( $state['last_attempt_at'] ) ? intval( $state['last_attempt_at'] ) : time(),
                'last_delivery_at' => isset( $state['last_delivery_at'] ) ? intval( $state['last_delivery_at'] ) : 0,
                'last_error_date'  => 0,
                'last_error'       => '',
            )
        );

        return array(
            'success' => true,
            'message' => isset( $data['description'] ) ? (string) $data['description'] : 'Webhook set.',
        );
    }

    public function get_webhook_info( $bot_token = '' ) {
        if ( '' === trim( (string) $bot_token ) ) {
            $settings  = $this->get_settings();
            $bot_token = $this->get_bot_token_from_settings( $settings );
        }

        if ( '' === $bot_token ) {
            return array(
                'success' => false,
                'healthy' => false,
                'error'   => 'Bot token nedostaje za getWebhookInfo.',
            );
        }

        $response = wp_remote_post(
            $this->bot_api_url( 'getWebhookInfo', $bot_token ),
            array(
                'timeout' => 15,
            )
        );

        if ( is_wp_error( $response ) ) {
            return array(
                'success' => false,
                'healthy' => false,
                'error'   => $response->get_error_message(),
            );
        }

        $status = wp_remote_retrieve_response_code( $response );
        $data   = json_decode( (string) wp_remote_retrieve_body( $response ), true );
        if ( $status < 200 || $status >= 300 || empty( $data['ok'] ) || ! isset( $data['result'] ) || ! is_array( $data['result'] ) ) {
            return array(
                'success' => false,
                'healthy' => false,
                'error'   => isset( $data['description'] ) ? (string) $data['description'] : 'Telegram getWebhookInfo failed.',
            );
        }

        $info            = $data['result'];
        $url             = isset( $info['url'] ) ? (string) $info['url'] : '';
        $last_error      = isset( $info['last_error_message'] ) ? trim( (string) $info['last_error_message'] ) : '';
        $last_error_date = isset( $info['last_error_date'] ) ? max( 0, intval( $info['last_error_date'] ) ) : 0;
        $state           = $this->get_webhook_state();
        $last_delivery   = intval( isset( $state['last_delivery_at'] ) ? $state['last_delivery_at'] : 0 );
        $error_is_stale  = '' !== $last_error && $last_error_date > 0 && $last_delivery >= $last_error_date;
        $healthy         = $url === $this->get_webhook_url() && ( '' === $last_error || $error_is_stale );

        if ( ! $healthy ) {
            $state['active']          = false;
            $state['last_error']      = '' !== $last_error ? $last_error : 'Webhook URL mismatch or inactive.';
            $state['last_error_date'] = $last_error_date;
            $state['updated_at']      = time();
            $this->update_webhook_state( $state );
        } else {
            $state['active']          = true;
            $state['url']             = $this->get_webhook_url();
            $state['last_error']      = $error_is_stale ? '' : $last_error;
            $state['last_error_date'] = $error_is_stale ? 0 : $last_error_date;
            $state['updated_at']      = time();
            $this->update_webhook_state( $state );
        }

        return array(
            'success' => true,
            'healthy' => $healthy,
            'info'    => $info,
            'error'   => $last_error,
        );
    }

    /**
     * Resolve a Telegram file_id to a downloadable URL.
     *
     * Phase 3 upgrade: retries transient failures up to 3 times with
     * exponential backoff and short-circuits on permanent errors (file too
     * big). The return shape matches the Phase 1/2 contract — `{success, file_url, error}`
     * — so existing callers keep working without changes.
     *
     * @param string $file_id
     * @return array
     */
    public function resolve_file_url( $file_id ) {
        $fetcher = $this->get_media_fetcher();
        if ( null === $fetcher ) {
            return array(
                'success' => false,
                'error'   => 'Bot token nedostaje za Telegram file resolve.',
            );
        }

        $result = $fetcher->resolve_file_path_with_retry( $file_id );
        // Trim the Phase 3 fields the legacy contract doesn't know about so we
        // don't accidentally leak them into existing downstream code paths.
        unset( $result['file_path'], $result['permanent'] );
        return $result;
    }

    /**
     * Phase 3 — full inbound-media ingestion: getFile (with retry) → binary
     * download (with retry) → magic-bytes content-type classification.
     *
     * @param array $ref MediaRef from collect_inbound_media_refs().
     * @return array { success, path?, content_type?, kind?, error?, permanent? }
     */
    public function download_inbound_media( $ref ) {
        $fetcher = $this->get_media_fetcher();
        if ( null === $fetcher ) {
            return array(
                'success' => false,
                'error'   => 'Bot token nedostaje za Telegram media download.',
            );
        }

        return $fetcher->download_inbound_media( $ref );
    }

    private function get_media_fetcher() {
        $settings  = $this->get_settings();
        $bot_token = $this->get_bot_token_from_settings( $settings );
        if ( '' === $bot_token || ! class_exists( 'Niwa_AI_Admin_Autonomy_Telegram_Media_Fetcher' ) ) {
            return null;
        }
        return new Niwa_AI_Admin_Autonomy_Telegram_Media_Fetcher( $bot_token );
    }

    private function ensure_polling_preconditions( $bot_token, $state ) {
        $bot_hash = $this->get_bot_hash( $bot_token );
        if ( $state['webhook_cleared_for'] === $bot_hash ) {
            return array(
                'success' => true,
                'state'   => $state,
            );
        }

        $deleted = $this->delete_webhook();
        if ( empty( $deleted['success'] ) ) {
            return array(
                'success' => false,
                'error'   => isset( $deleted['error'] ) ? (string) $deleted['error'] : 'Telegram webhook cleanup failed.',
            );
        }

        $state['webhook_cleared_for'] = $bot_hash;
        $this->update_poll_state( $state );

        return array(
            'success' => true,
            'state'   => $state,
        );
    }

    private function webhook_state_has_delivery_block( $state ) {
        $state = is_array( $state ) ? $state : array();
        $error = strtolower( trim( (string) ( isset( $state['last_error'] ) ? $state['last_error'] : '' ) ) );
        if ( '' === $error ) {
            return false;
        }

        return false !== strpos( $error, '403 forbidden' )
            || false !== strpos( $error, 'bot verification' );
    }

    private function reset_poll_state( $bot_hash ) {
        $state = array(
            'offset'              => 0,
            'bot_hash'            => (string) $bot_hash,
            'webhook_cleared_for' => '',
            'last_polled_at'      => 0,
        );
        $this->update_poll_state( $state );
        return $state;
    }

    private function update_poll_state( $state ) {
        update_option( self::POLL_STATE_OPTION, $this->normalize_poll_state( $state ), false );
    }

    private function normalize_poll_state( $state ) {
        if ( ! is_array( $state ) ) {
            $state = array();
        }

        return array(
            'offset'              => max( 0, intval( isset( $state['offset'] ) ? $state['offset'] : 0 ) ),
            'bot_hash'            => isset( $state['bot_hash'] ) ? sanitize_text_field( (string) $state['bot_hash'] ) : '',
            'webhook_cleared_for' => isset( $state['webhook_cleared_for'] ) ? sanitize_text_field( (string) $state['webhook_cleared_for'] ) : '',
            'last_polled_at'      => max( 0, intval( isset( $state['last_polled_at'] ) ? $state['last_polled_at'] : 0 ) ),
        );
    }

    private function normalize_runner_state( $state ) {
        if ( ! is_array( $state ) ) {
            $state = array();
        }

        return array(
            'secret'          => isset( $state['secret'] ) ? sanitize_text_field( (string) $state['secret'] ) : '',
            'last_seen_at'    => max( 0, intval( isset( $state['last_seen_at'] ) ? $state['last_seen_at'] : 0 ) ),
            'last_spawned_at' => max( 0, intval( isset( $state['last_spawned_at'] ) ? $state['last_spawned_at'] : 0 ) ),
            'active_until'    => max( 0, intval( isset( $state['active_until'] ) ? $state['active_until'] : 0 ) ),
            'last_error'      => isset( $state['last_error'] ) ? sanitize_text_field( (string) $state['last_error'] ) : '',
            'last_spawn_error' => isset( $state['last_spawn_error'] ) ? sanitize_text_field( (string) $state['last_spawn_error'] ) : '',
            'active_transport' => isset( $state['active_transport'] ) ? sanitize_key( (string) $state['active_transport'] ) : '',
            'last_transport'   => isset( $state['last_transport'] ) ? sanitize_key( (string) $state['last_transport'] ) : '',
            'active_pid'       => max( 0, intval( isset( $state['active_pid'] ) ? $state['active_pid'] : 0 ) ),
            'wake_pending'    => ! empty( $state['wake_pending'] ) ? 1 : 0,
            'wake_reason'     => isset( $state['wake_reason'] ) ? sanitize_key( (string) $state['wake_reason'] ) : '',
            'wake_requested_at' => max( 0, intval( isset( $state['wake_requested_at'] ) ? $state['wake_requested_at'] : 0 ) ),
            'active_runner_id' => isset( $state['active_runner_id'] ) ? sanitize_text_field( (string) $state['active_runner_id'] ) : '',
        );
    }

    private function normalize_webhook_state( $state ) {
        if ( ! is_array( $state ) ) {
            $state = array();
        }

        return array(
            'active'     => ! empty( $state['active'] ),
            'url'        => isset( $state['url'] ) ? esc_url_raw( (string) $state['url'] ) : '',
            'bot_hash'   => isset( $state['bot_hash'] ) ? sanitize_text_field( (string) $state['bot_hash'] ) : '',
            'updated_at' => max( 0, intval( isset( $state['updated_at'] ) ? $state['updated_at'] : 0 ) ),
            'last_attempt_at'  => max( 0, intval( isset( $state['last_attempt_at'] ) ? $state['last_attempt_at'] : 0 ) ),
            'last_delivery_at' => max( 0, intval( isset( $state['last_delivery_at'] ) ? $state['last_delivery_at'] : 0 ) ),
            'last_error_date'  => max( 0, intval( isset( $state['last_error_date'] ) ? $state['last_error_date'] : 0 ) ),
            'last_error' => isset( $state['last_error'] ) ? sanitize_text_field( (string) $state['last_error'] ) : '',
        );
    }

    private function update_webhook_state( $state ) {
        update_option( self::WEBHOOK_STATE_OPTION, $this->normalize_webhook_state( $state ), false );
    }

    private function get_processed_updates() {
        $cache = get_option( self::UPDATES_OPTION, array() );
        return is_array( $cache ) ? $this->prune_updates( $cache ) : array();
    }

    private function extract_callback_payload( $update ) {
        $callback = isset( $update['callback_query'] ) && is_array( $update['callback_query'] ) ? $update['callback_query'] : array();
        $message  = isset( $callback['message'] ) && is_array( $callback['message'] ) ? $callback['message'] : array();
        $chat_id  = isset( $message['chat']['id'] ) ? (string) $message['chat']['id'] : '';

        if ( '' === $chat_id ) {
            return array(
                'success' => false,
                'ignore'  => true,
                'error'   => 'Callback query nema chat kontekst.',
            );
        }

        return array(
            'success'         => true,
            'channel'         => 'telegram',
            'update_id'       => isset( $update['update_id'] ) ? intval( $update['update_id'] ) : 0,
            'chat_id'         => $chat_id,
            'from_id'         => isset( $callback['from']['id'] ) ? (string) $callback['from']['id'] : '',
            'from_username'   => isset( $callback['from']['username'] ) ? (string) $callback['from']['username'] : '',
            'from_first_name' => isset( $callback['from']['first_name'] ) ? (string) $callback['from']['first_name'] : '',
            'language_code'   => isset( $callback['from']['language_code'] ) ? (string) $callback['from']['language_code'] : '',
            'message_id'      => isset( $message['message_id'] ) ? intval( $message['message_id'] ) : 0,
            'is_edited'       => false,
            'text'            => isset( $callback['data'] ) ? (string) $callback['data'] : '',

            // Phase 2 — empty defaults so consumers can read these uniformly.
            'text_only'        => isset( $callback['data'] ) ? (string) $callback['data'] : '',
            'caption'          => '',
            'entities'         => array(),
            'caption_entities' => array(),
            'has_user_text'    => '' !== ( isset( $callback['data'] ) ? (string) $callback['data'] : '' ),

            'kind'             => 'callback',
            'placeholder'      => '',
            'media_refs'       => array(),
            'reply_media_refs' => array(),
            'media_group_id'   => '',
            'forward'          => $this->empty_forward(),
            'location'         => null,
            'contact'          => null,

            'callback_query_id' => isset( $callback['id'] ) ? (string) $callback['id'] : '',
            'callback_data'     => isset( $callback['data'] ) ? (string) $callback['data'] : '',
            'callback_origin_message_id' => isset( $message['message_id'] ) ? intval( $message['message_id'] ) : 0,
        );
    }

    /* -------------------------------------------------------------------
     * Phase 2 — robust media payload extraction helpers.
     *
     * Each helper takes a raw Telegram message object and returns the slice
     * it cares about, never mutating the input. The functions are kept tiny
     * and individually testable so the public extract_message_payload() reads
     * as a top-down assembly of the parts.
     * ------------------------------------------------------------------- */

    /**
     * Single-shot kind classification used for `kind` payload field and as a
     * fallback when consumers prefer a string discriminator over scanning
     * media_refs[]. Order mirrors the dominant content of the Telegram message.
     */
    private function classify_message_kind( $message ) {
        if ( ! is_array( $message ) ) {
            return 'empty';
        }
        if ( ! empty( $message['photo'] ) && is_array( $message['photo'] ) ) {
            return 'photo';
        }
        if ( ! empty( $message['video_note'] ) ) {
            return 'video_note';
        }
        if ( ! empty( $message['video'] ) ) {
            return 'video';
        }
        if ( ! empty( $message['animation'] ) ) {
            return 'animation';
        }
        if ( ! empty( $message['voice'] ) ) {
            return 'voice';
        }
        if ( ! empty( $message['audio'] ) ) {
            return 'audio';
        }
        if ( ! empty( $message['document'] ) ) {
            return 'document';
        }
        if ( ! empty( $message['sticker'] ) ) {
            return 'sticker';
        }
        if ( ! empty( $message['location'] ) ) {
            return 'location';
        }
        if ( ! empty( $message['contact'] ) ) {
            return 'contact';
        }
        $text    = isset( $message['text'] ) ? (string) $message['text'] : '';
        $caption = isset( $message['caption'] ) ? (string) $message['caption'] : '';
        if ( '' !== $text || '' !== $caption ) {
            return 'text';
        }
        return 'empty';
    }

    /**
     * Universal inbound media resolver. Returns an array of MediaRef arrays;
     * a single Telegram message can carry only one media slot in practice but
     * we keep the return shape uniform so albums (Phase 4) and reply-target
     * media reuse the same structure.
     *
     * Each ref:
     *   kind:           image | video | video_note | animation | document | voice | audio | sticker
     *   file_id:        Telegram file identifier (use with getFile)
     *   file_unique_id: Telegram-stable identifier (safe for caching)
     *   mime_hint:      lower-cased mime_type from Telegram (may be empty)
     *   file_name:      original filename when supplied (documents/audio)
     *   size:           bytes
     *   duration:       seconds (audio/voice/video/video_note/animation)
     *   width / height: pixels (image/video)
     *   sticker_*:      emoji, set name, animated/video flags (sticker only)
     *   is_reply_target: false for inbound; true when populated by collect_reply_target_media_refs
     */
    private function collect_inbound_media_refs( $message ) {
        if ( ! is_array( $message ) ) {
            return array();
        }

        $refs = array();

        // 1. Photo — Telegram sends multiple variants; the largest is the last.
        if ( ! empty( $message['photo'] ) && is_array( $message['photo'] ) ) {
            $largest = end( $message['photo'] );
            if ( is_array( $largest ) && ! empty( $largest['file_id'] ) ) {
                if ( empty( $largest['mime_type'] ) ) {
                    $largest['mime_type'] = 'image/jpeg';
                }
                $refs[] = $this->build_media_ref( 'image', $largest );
            }
        }

        // 2. Video / video_note / animation — all carry a file_id directly.
        if ( ! empty( $message['video'] ) && is_array( $message['video'] ) && ! empty( $message['video']['file_id'] ) ) {
            $refs[] = $this->build_media_ref( 'video', $message['video'] );
        }
        if ( ! empty( $message['video_note'] ) && is_array( $message['video_note'] ) && ! empty( $message['video_note']['file_id'] ) ) {
            $refs[] = $this->build_media_ref( 'video_note', $message['video_note'] );
        }
        if ( ! empty( $message['animation'] ) && is_array( $message['animation'] ) && ! empty( $message['animation']['file_id'] ) ) {
            $refs[] = $this->build_media_ref( 'animation', $message['animation'] );
        }

        // 3. Document — kept as kind=document regardless of mime_type. Consumers
        //    that want to treat image/* documents as photos should branch on
        //    mime_hint themselves; the BC shim below preserves the legacy
        //    `photo_file_id` mapping for unchanged consumers.
        if ( ! empty( $message['document'] ) && is_array( $message['document'] ) && ! empty( $message['document']['file_id'] ) ) {
            $refs[] = $this->build_media_ref( 'document', $message['document'] );
        }

        // 4. Voice (OGG/Opus from Telegram) and audio (uploaded music files).
        if ( ! empty( $message['voice'] ) && is_array( $message['voice'] ) && ! empty( $message['voice']['file_id'] ) ) {
            $refs[] = $this->build_media_ref( 'voice', $message['voice'] );
        }
        if ( ! empty( $message['audio'] ) && is_array( $message['audio'] ) && ! empty( $message['audio']['file_id'] ) ) {
            $refs[] = $this->build_media_ref( 'audio', $message['audio'] );
        }

        // 5. Sticker — keep extra metadata (emoji, set, animated/video flags)
        //    so Phase 5b sticker cache can act on file_unique_id.
        if ( ! empty( $message['sticker'] ) && is_array( $message['sticker'] ) && ! empty( $message['sticker']['file_id'] ) ) {
            $refs[] = $this->build_media_ref( 'sticker', $message['sticker'] );
        }

        return $refs;
    }

    private function collect_reply_target_media_refs( $message ) {
        if ( ! is_array( $message ) || empty( $message['reply_to_message'] ) || ! is_array( $message['reply_to_message'] ) ) {
            return array();
        }
        $refs = $this->collect_inbound_media_refs( $message['reply_to_message'] );
        foreach ( $refs as &$ref ) {
            $ref['is_reply_target'] = true;
        }
        unset( $ref );
        return $refs;
    }

    /**
     * Build a single MediaRef array from a Telegram media payload.
     *
     * @param string $kind     Resolved media kind (image, video, document, etc.)
     * @param array  $payload  Telegram media object (PhotoSize, Document, Voice, …)
     * @return array MediaRef shape (see collect_inbound_media_refs docblock)
     */
    private function build_media_ref( $kind, $payload ) {
        $payload = is_array( $payload ) ? $payload : array();

        $ref = array(
            'kind'            => (string) $kind,
            'file_id'         => isset( $payload['file_id'] ) ? (string) $payload['file_id'] : '',
            'file_unique_id'  => isset( $payload['file_unique_id'] ) ? (string) $payload['file_unique_id'] : '',
            'mime_hint'       => isset( $payload['mime_type'] ) ? strtolower( trim( (string) $payload['mime_type'] ) ) : '',
            'file_name'       => isset( $payload['file_name'] ) ? (string) $payload['file_name'] : '',
            'size'            => isset( $payload['file_size'] ) ? intval( $payload['file_size'] ) : 0,
            'duration'        => isset( $payload['duration'] ) ? intval( $payload['duration'] ) : 0,
            'width'           => isset( $payload['width'] ) ? intval( $payload['width'] ) : 0,
            'height'          => isset( $payload['height'] ) ? intval( $payload['height'] ) : 0,
            'is_reply_target' => false,
        );

        // Telegram ships a static thumbnail (JPG / WEBP) for video, animation,
        // video_note, document, and sticker objects. Capturing it here lets
        // build_incoming_media_entry_with_preview() materialize a viewable
        // preview without needing ffmpeg on the host — the thumbnail is
        // already a single decoded frame, even for animated .tgs stickers.
        // Modern Bot API uses `thumbnail`; legacy clients sent `thumb`.
        $thumb = null;
        if ( isset( $payload['thumbnail'] ) && is_array( $payload['thumbnail'] ) ) {
            $thumb = $payload['thumbnail'];
        } elseif ( isset( $payload['thumb'] ) && is_array( $payload['thumb'] ) ) {
            $thumb = $payload['thumb'];
        }
        if ( is_array( $thumb ) && ! empty( $thumb['file_id'] ) ) {
            $ref['thumb_file_id']        = (string) $thumb['file_id'];
            $ref['thumb_file_unique_id'] = isset( $thumb['file_unique_id'] ) ? (string) $thumb['file_unique_id'] : '';
            $ref['thumb_width']          = isset( $thumb['width'] ) ? intval( $thumb['width'] ) : 0;
            $ref['thumb_height']         = isset( $thumb['height'] ) ? intval( $thumb['height'] ) : 0;
            $ref['thumb_size']           = isset( $thumb['file_size'] ) ? intval( $thumb['file_size'] ) : 0;
        }

        if ( 'sticker' === $kind ) {
            $ref['sticker_emoji']        = isset( $payload['emoji'] ) ? (string) $payload['emoji'] : '';
            $ref['sticker_set']          = isset( $payload['set_name'] ) ? (string) $payload['set_name'] : '';
            $ref['is_animated_sticker']  = ! empty( $payload['is_animated'] );
            $ref['is_video_sticker']     = ! empty( $payload['is_video'] );
        }

        return $ref;
    }

    private function extract_text_parts( $message ) {
        $message = is_array( $message ) ? $message : array();
        return array(
            'text'             => isset( $message['text'] ) ? trim( (string) $message['text'] ) : '',
            'caption'          => isset( $message['caption'] ) ? trim( (string) $message['caption'] ) : '',
            'entities'         => isset( $message['entities'] ) && is_array( $message['entities'] ) ? array_values( $message['entities'] ) : array(),
            'caption_entities' => isset( $message['caption_entities'] ) && is_array( $message['caption_entities'] ) ? array_values( $message['caption_entities'] ) : array(),
        );
    }

    /**
     * Forwarded-message metadata extraction. Supports both the modern Bot API
     * 7.0+ MessageOrigin shape (`forward_origin`) and the legacy fields
     * (`forward_from`, `forward_from_chat`, `forward_sender_name`) so older
     * Telegram clients still produce useful labels.
     */
    private function extract_forward_metadata( $message ) {
        $message = is_array( $message ) ? $message : array();

        if ( ! empty( $message['forward_origin'] ) && is_array( $message['forward_origin'] ) ) {
            $origin = $message['forward_origin'];
            $type   = isset( $origin['type'] ) ? (string) $origin['type'] : 'unknown';
            return array(
                'is_forwarded'      => true,
                'origin_type'       => $type,
                'origin_label'      => $this->build_forward_origin_label( $origin, $type ),
                'origin_message_id' => isset( $origin['message_id'] ) ? intval( $origin['message_id'] ) : 0,
                'date'              => isset( $origin['date'] ) ? intval( $origin['date'] ) : 0,
            );
        }

        if ( ! empty( $message['forward_from'] ) && is_array( $message['forward_from'] ) ) {
            $from     = $message['forward_from'];
            $name     = trim( ( $from['first_name'] ?? '' ) . ' ' . ( $from['last_name'] ?? '' ) );
            $username = isset( $from['username'] ) ? (string) $from['username'] : '';
            return array(
                'is_forwarded'      => true,
                'origin_type'       => 'user',
                'origin_label'      => '' !== $name ? $name : ( '' !== $username ? '@' . $username : 'user' ),
                'origin_message_id' => 0,
                'date'              => isset( $message['forward_date'] ) ? intval( $message['forward_date'] ) : 0,
            );
        }

        if ( ! empty( $message['forward_from_chat'] ) && is_array( $message['forward_from_chat'] ) ) {
            $chat     = $message['forward_from_chat'];
            $title    = isset( $chat['title'] ) ? (string) $chat['title'] : '';
            $username = isset( $chat['username'] ) ? (string) $chat['username'] : '';
            $type     = isset( $chat['type'] ) ? (string) $chat['type'] : 'chat';
            return array(
                'is_forwarded'      => true,
                'origin_type'       => 'channel' === $type ? 'channel' : 'chat',
                'origin_label'      => '' !== $title ? $title : ( '' !== $username ? '@' . $username : 'chat' ),
                'origin_message_id' => isset( $message['forward_from_message_id'] ) ? intval( $message['forward_from_message_id'] ) : 0,
                'date'              => isset( $message['forward_date'] ) ? intval( $message['forward_date'] ) : 0,
            );
        }

        if ( ! empty( $message['forward_sender_name'] ) ) {
            return array(
                'is_forwarded'      => true,
                'origin_type'       => 'hidden_user',
                'origin_label'      => (string) $message['forward_sender_name'],
                'origin_message_id' => 0,
                'date'              => isset( $message['forward_date'] ) ? intval( $message['forward_date'] ) : 0,
            );
        }

        return $this->empty_forward();
    }

    private function build_forward_origin_label( $origin, $type ) {
        $origin = is_array( $origin ) ? $origin : array();
        switch ( $type ) {
            case 'user':
                $sender   = isset( $origin['sender_user'] ) && is_array( $origin['sender_user'] ) ? $origin['sender_user'] : array();
                $name     = trim( ( $sender['first_name'] ?? '' ) . ' ' . ( $sender['last_name'] ?? '' ) );
                $username = isset( $sender['username'] ) ? (string) $sender['username'] : '';
                return '' !== $name ? $name : ( '' !== $username ? '@' . $username : 'user' );
            case 'hidden_user':
                return isset( $origin['sender_user_name'] ) ? (string) $origin['sender_user_name'] : 'hidden user';
            case 'chat':
                $sender_chat = isset( $origin['sender_chat'] ) && is_array( $origin['sender_chat'] ) ? $origin['sender_chat'] : array();
                $title       = isset( $sender_chat['title'] ) ? (string) $sender_chat['title'] : '';
                $username    = isset( $sender_chat['username'] ) ? (string) $sender_chat['username'] : '';
                return '' !== $title ? $title : ( '' !== $username ? '@' . $username : 'chat' );
            case 'channel':
                $chat     = isset( $origin['chat'] ) && is_array( $origin['chat'] ) ? $origin['chat'] : array();
                $title    = isset( $chat['title'] ) ? (string) $chat['title'] : '';
                $username = isset( $chat['username'] ) ? (string) $chat['username'] : '';
                return '' !== $title ? $title : ( '' !== $username ? '@' . $username : 'channel' );
        }
        return 'unknown';
    }

    private function empty_forward() {
        return array(
            'is_forwarded'      => false,
            'origin_type'       => '',
            'origin_label'      => '',
            'origin_message_id' => 0,
            'date'              => 0,
        );
    }

    private function extract_location( $message ) {
        if ( ! is_array( $message ) || empty( $message['location'] ) || ! is_array( $message['location'] ) ) {
            return null;
        }
        $loc = $message['location'];
        return array(
            'lat'                    => isset( $loc['latitude'] ) ? (float) $loc['latitude'] : 0.0,
            'lng'                    => isset( $loc['longitude'] ) ? (float) $loc['longitude'] : 0.0,
            'horizontal_accuracy'    => isset( $loc['horizontal_accuracy'] ) ? (float) $loc['horizontal_accuracy'] : 0.0,
            'live_period'            => isset( $loc['live_period'] ) ? intval( $loc['live_period'] ) : 0,
            'heading'                => isset( $loc['heading'] ) ? intval( $loc['heading'] ) : 0,
            'proximity_alert_radius' => isset( $loc['proximity_alert_radius'] ) ? intval( $loc['proximity_alert_radius'] ) : 0,
        );
    }

    private function extract_contact( $message ) {
        if ( ! is_array( $message ) || empty( $message['contact'] ) || ! is_array( $message['contact'] ) ) {
            return null;
        }
        $c = $message['contact'];
        return array(
            'phone'      => isset( $c['phone_number'] ) ? (string) $c['phone_number'] : '',
            'first_name' => isset( $c['first_name'] ) ? (string) $c['first_name'] : '',
            'last_name'  => isset( $c['last_name'] ) ? (string) $c['last_name'] : '',
            'user_id'    => isset( $c['user_id'] ) ? (string) $c['user_id'] : '',
            'vcard'      => isset( $c['vcard'] ) ? (string) $c['vcard'] : '',
        );
    }

    private function resolve_media_placeholder( $message ) {
        $kind = $this->classify_message_kind( $message );
        switch ( $kind ) {
            case 'photo':
                return '<media:image>';
            case 'video':
            case 'video_note':
            case 'animation':
                return '<media:video>';
            case 'voice':
            case 'audio':
                return '<media:audio>';
            case 'document':
                return '<media:document>';
            case 'sticker':
                return '<media:sticker>';
            case 'location':
                return '<media:location>';
            case 'contact':
                return '<media:contact>';
        }
        return null;
    }


    private function prune_updates( $cache ) {
        if ( empty( $cache ) || ! is_array( $cache ) ) {
            return array();
        }

        $cutoff = time() - DAY_IN_SECONDS;
        foreach ( $cache as $update_id => $timestamp ) {
            if ( intval( $timestamp ) < $cutoff ) {
                unset( $cache[ $update_id ] );
            }
        }

        asort( $cache, SORT_NUMERIC );
        if ( count( $cache ) > self::MAX_STORED_UPDATES ) {
            $cache = array_slice( $cache, -1 * self::MAX_STORED_UPDATES, null, true );
        }

        return $cache;
    }

    private function bot_api_url( $method, $bot_token ) {
        return 'https://api.telegram.org/bot' . rawurlencode( $bot_token ) . '/' . rawurlencode( $method );
    }

    private function get_bot_hash( $bot_token ) {
        return substr( hash( 'sha256', (string) $bot_token ), 0, 24 );
    }

    /* -------------------------------------------------------------------
     * Unauthorized-user introduction (Phase 1 / feature/telegram-and-authonomy).
     *
     * When a Telegram message arrives from a chat ID that is neither the
     * configured owner nor any registered stock manager, Niwa replies once with
     * the user's own Telegram chat ID and short instructions in the plugin's
     * configured language. The throttle prevents repeated sends to the same
     * unknown chat; the rate limit caps how many fresh chats can trigger an
     * intro per hour to neutralize spam.
     * ------------------------------------------------------------------- */

    /**
     * Send the unauthorized-user introduction reply.
     *
     * @param array $payload Output of extract_message_payload(); requires chat_id.
     * @return array { sent: bool, reason: string }
     *               reason ∈ {sent, disabled, missing_chat, throttled, rate_limited, send_failed:<error>}
     */
    public function send_unauthorized_introduction( $payload ) {
        $payload = is_array( $payload ) ? $payload : array();
        $settings = $this->get_settings();

        if ( empty( $settings['unauthorized_intro_enabled'] ) ) {
            return array( 'sent' => false, 'reason' => 'disabled' );
        }

        $chat_id = isset( $payload['chat_id'] ) ? trim( (string) $payload['chat_id'] ) : '';
        if ( '' === $chat_id ) {
            return array( 'sent' => false, 'reason' => 'missing_chat' );
        }

        if ( $this->is_intro_throttled( $chat_id ) ) {
            return array( 'sent' => false, 'reason' => 'throttled' );
        }

        if ( ! $this->consume_intro_rate_limit( $chat_id ) ) {
            return array( 'sent' => false, 'reason' => 'rate_limited' );
        }

        $username   = isset( $payload['from_username'] ) ? (string) $payload['from_username'] : '';
        $first_name = isset( $payload['from_first_name'] ) ? (string) $payload['from_first_name'] : '';
        $message_id = isset( $payload['message_id'] ) ? intval( $payload['message_id'] ) : 0;

        $text   = $this->build_intro_message( $chat_id, $username, $first_name, isset( $settings['unauthorized_intro_template'] ) ? (string) $settings['unauthorized_intro_template'] : '' );
        $result = $this->send_message( $chat_id, $text, $message_id );

        if ( empty( $result['success'] ) ) {
            return array(
                'sent'   => false,
                'reason' => 'send_failed:' . ( isset( $result['error'] ) ? (string) $result['error'] : 'unknown' ),
            );
        }

        $this->mark_intro_sent( $chat_id );
        $this->record_unauthorized_chat( $chat_id, $username, $first_name );

        return array( 'sent' => true, 'reason' => 'sent' );
    }

    /**
     * Recent unauthorized chats list (for admin UI).
     *
     * @return array<int, array{chat_id:string, username:string, first_name:string, last_seen:int, count:int}>
     */
    public function get_recent_unauthorized_chats() {
        $log = get_option( self::INTRO_LOG_OPTION, array() );
        if ( ! is_array( $log ) ) {
            return array();
        }

        $items = array();
        foreach ( $log as $entry ) {
            if ( ! is_array( $entry ) || empty( $entry['chat_id'] ) ) {
                continue;
            }
            $items[] = array(
                'chat_id'    => (string) $entry['chat_id'],
                'username'   => isset( $entry['username'] ) ? (string) $entry['username'] : '',
                'first_name' => isset( $entry['first_name'] ) ? (string) $entry['first_name'] : '',
                'last_seen'  => isset( $entry['last_seen'] ) ? intval( $entry['last_seen'] ) : 0,
                'count'      => isset( $entry['count'] ) ? max( 1, intval( $entry['count'] ) ) : 1,
            );
        }

        usort( $items, function ( $a, $b ) {
            return intval( $b['last_seen'] ) - intval( $a['last_seen'] );
        } );

        return array_slice( $items, 0, self::INTRO_LOG_MAX );
    }

    /**
     * Test seam: clear all intro-related transients and the recent-unauthorized log.
     * Used by tests and the "Reset" admin button.
     */
    public function reset_intro_state( $chat_id = '' ) {
        $chat_id = trim( (string) $chat_id );
        if ( '' !== $chat_id ) {
            delete_transient( $this->intro_throttle_key( $chat_id ) );
            delete_transient( $this->intro_rate_limit_key( $chat_id ) );
            return;
        }

        delete_option( self::INTRO_LOG_OPTION );
    }

    private function is_intro_throttled( $chat_id ) {
        return false !== get_transient( $this->intro_throttle_key( $chat_id ) );
    }

    private function mark_intro_sent( $chat_id ) {
        set_transient( $this->intro_throttle_key( $chat_id ), time(), self::INTRO_THROTTLE_TTL );
    }

    private function consume_intro_rate_limit( $chat_id ) {
        $key    = $this->intro_rate_limit_key( $chat_id );
        $bucket = get_transient( $key );
        $bucket = is_array( $bucket ) ? $bucket : array();
        $now    = time();
        $window = self::INTRO_RATE_LIMIT_WINDOW;

        $bucket = array_values(
            array_filter(
                $bucket,
                function ( $ts ) use ( $now, $window ) {
                    return intval( $ts ) > ( $now - $window );
                }
            )
        );

        if ( count( $bucket ) >= self::INTRO_RATE_LIMIT_MAX ) {
            return false;
        }

        $bucket[] = $now;
        set_transient( $key, $bucket, $window );
        return true;
    }

    private function intro_throttle_key( $chat_id ) {
        return 'niwa_ai_tg_intro_' . sha1( (string) $chat_id );
    }

    private function intro_rate_limit_key( $chat_id ) {
        return 'niwa_ai_tg_intro_rl_' . sha1( (string) $chat_id );
    }

    private function build_intro_message( $chat_id, $username, $first_name, $template_override = '' ) {
        // NOTE: do NOT pre-escape Markdown or wrap in backticks here. send_message()
        // pipes the string through render_rich_text_for_telegram(), which
        // escapes Telegram MarkdownV2 and preserves markdown-style backticks.
        // Pre-escaping causes visible slashes in the user's chat.
        $chat_id    = (string) $chat_id;
        $username   = trim( (string) $username );
        $first_name = trim( (string) $first_name );

        $username_display = '' !== $username ? '@' . $username : '—';

        $template = trim( (string) $template_override );
        if ( '' !== $template ) {
            return $this->render_intro_template( $template, $chat_id, $username, $username_display, $first_name );
        }

        $strings = $this->get_intro_localized_strings( $this->resolve_intro_locale() );

        $greeting = $first_name !== ''
            ? sprintf( $strings['greeting_named'], $first_name )
            : $strings['greeting_anon'];

        $lines = array(
            '👋 ' . $greeting,
            '',
            $strings['not_authorized'],
            '',
            $strings['id_label'] . ': `' . $chat_id . '`',
            $strings['username_label'] . ': ' . $username_display,
            '',
            $strings['instruction'],
        );

        return implode( "\n", $lines );
    }

    private function render_intro_template( $template, $chat_id, $username, $username_display, $first_name ) {
        // Same rule as build_intro_message: pass raw values; the send pipeline
        // will escape them. Empty {first_name} falls back to a dash.
        $replacements = array(
            '{chat_id}'      => $chat_id,
            '{username}'     => $username_display,
            '{username_raw}' => $username,
            '{first_name}'   => '' !== $first_name ? $first_name : '—',
        );
        return strtr( $template, $replacements );
    }

    /**
     * Resolve the locale to use for the intro message.
     *
     * Uses determine_locale() (which falls back to the site locale in cron / REST
     * context). Filterable so admins / tests can pin a specific locale.
     */
    private function resolve_intro_locale() {
        $locale = function_exists( 'determine_locale' ) ? determine_locale() : ( function_exists( 'get_locale' ) ? get_locale() : 'en_US' );
        $locale = (string) apply_filters( 'niwa_ai_telegram_intro_locale', $locale );
        return '' !== $locale ? $locale : 'en_US';
    }

    /**
     * Builtin localization fallback for the intro strings.
     *
     * Keeps the feature working out-of-the-box for sites running in en_US,
     * sr_RS (Cyrillic), or sr_Latn_RS / sr_Latn (Latin) without shipping .mo
     * files. Other locales fall back to en_US. Wrapped values still pass
     * through __() first so a translator-supplied .po/.mo can override.
     */
    private function get_intro_localized_strings( $locale ) {
        $locale = (string) $locale;

        $en = array(
            'greeting_named' => __( 'Hi %s!', 'niwa-ai' ),
            'greeting_anon'  => __( 'Hi there!', 'niwa-ai' ),
            'not_authorized' => __( 'You are not yet authorized for this bot.', 'niwa-ai' ),
            'id_label'       => __( 'Your Telegram ID', 'niwa-ai' ),
            'username_label' => __( 'Your username', 'niwa-ai' ),
            'instruction'    => __( 'Send this ID to the site administrator. Once they add you to the allowed users list, you will be able to chat with the bot.', 'niwa-ai' ),
        );

        $fallbacks = array(
            'sr_RS'      => array(
                'greeting_named' => 'Здраво %s!',
                'greeting_anon'  => 'Здраво!',
                'not_authorized' => 'Ниси још аутентификован/а за овог бота.',
                'id_label'       => 'Твој Телеграм ID',
                'username_label' => 'Твој username',
                'instruction'    => 'Пошаљи овај ID администратору сајта. Када те дода у листу одобрених корисника, моћи ћеш да пишеш са ботом.',
            ),
            'sr_Latn_RS' => array(
                'greeting_named' => 'Zdravo %s!',
                'greeting_anon'  => 'Zdravo!',
                'not_authorized' => 'Nisi još autentifikovan/a za ovog bota.',
                'id_label'       => 'Tvoj Telegram ID',
                'username_label' => 'Tvoj username',
                'instruction'    => 'Pošalji ovaj ID administratoru sajta. Kad te doda u listu odobrenih korisnika, moći ćeš da pišeš sa botom.',
            ),
        );
        // Alias: sr_Latn (without _RS) maps to the Latin variant.
        $fallbacks['sr_Latn'] = $fallbacks['sr_Latn_RS'];

        if ( ! isset( $fallbacks[ $locale ] ) ) {
            return $en;
        }

        // Prefer __() result when a translation file is actually present;
        // detect "no translation" by comparing against the english source.
        $resolved = array();
        foreach ( $en as $key => $english_value ) {
            $translated = $english_value;
            if ( function_exists( '_x' ) ) {
                $translated = __( $english_value, 'niwa-ai' );
            }
            $resolved[ $key ] = ( $translated === $english_value ) ? $fallbacks[ $locale ][ $key ] : $translated;
        }
        return $resolved;
    }

    private function record_unauthorized_chat( $chat_id, $username, $first_name ) {
        // Stored newest-first. Several writes within the same wall-clock second
        // (typical in tests and high-traffic moments) must still produce a
        // deterministic order, so we sort by both last_seen and a monotonic
        // sequence counter persisted alongside the log.
        $log = get_option( self::INTRO_LOG_OPTION, array() );
        if ( ! is_array( $log ) ) {
            $log = array();
        }

        $key      = (string) $chat_id;
        $now      = time();
        $existing = null;
        $max_seq  = 0;
        foreach ( $log as $idx => $entry ) {
            if ( is_array( $entry ) && isset( $entry['_seq'] ) ) {
                $max_seq = max( $max_seq, intval( $entry['_seq'] ) );
            }
            if ( is_array( $entry ) && isset( $entry['chat_id'] ) && (string) $entry['chat_id'] === $key ) {
                $existing = $idx;
            }
        }
        $next_seq = $max_seq + 1;

        $record = array(
            'chat_id'    => $key,
            'username'   => (string) $username,
            'first_name' => (string) $first_name,
            'last_seen'  => $now,
            '_seq'       => $next_seq,
        );

        if ( null !== $existing ) {
            $record['count'] = max( 1, intval( $log[ $existing ]['count'] ?? 0 ) ) + 1;
            unset( $log[ $existing ] );
        } else {
            $record['count'] = 1;
        }

        // Prepend so newest is always at index 0; usort below normalizes after
        // dedupe in case the persisted log was tampered with externally.
        array_unshift( $log, $record );
        $log = array_values( $log );

        usort( $log, function ( $a, $b ) {
            $ats = intval( $a['last_seen'] ?? 0 );
            $bts = intval( $b['last_seen'] ?? 0 );
            if ( $ats !== $bts ) {
                return $bts - $ats;
            }
            return intval( $b['_seq'] ?? 0 ) - intval( $a['_seq'] ?? 0 );
        } );

        if ( count( $log ) > self::INTRO_LOG_MAX ) {
            $log = array_slice( $log, 0, self::INTRO_LOG_MAX );
        }

        update_option( self::INTRO_LOG_OPTION, $log, false );
    }

    /* -------------------------------------------------------------------
     * Bot-token encryption helpers  (C4 security hardening).
     *
     * The token is encrypted at rest in wp_options using AES-256-CBC with a
     * key derived from the WordPress LOGGED_IN_KEY constant.  On every read
     * the token is transparently decrypted; existing plaintext tokens are
     * auto-migrated on the first read after the upgrade.
     * ------------------------------------------------------------------- */

    /**
     * Derive a 32-byte encryption key from WordPress auth constants.
     *
     * @return string  Raw binary key for AES-256-CBC.
     */
    private static function get_token_encryption_key() {
        $raw = defined( 'LOGGED_IN_KEY' ) && '' !== LOGGED_IN_KEY
            ? LOGGED_IN_KEY
            : 'niwa-ai-default-key-' . DB_NAME;
        return hash( 'sha256', $raw, true );
    }

    /**
     * Encrypt a plaintext bot token.
     *
     * @param string $plaintext  The bot token.
     * @return string  Base64-encoded IV + ciphertext, prefixed with "enc:".
     */
    public static function encrypt_token( $plaintext ) {
        $plaintext = trim( (string) $plaintext );
        if ( '' === $plaintext || ! function_exists( 'openssl_encrypt' ) ) {
            return $plaintext;
        }

        $key = self::get_token_encryption_key();
        $iv  = openssl_random_pseudo_bytes( 16 );
        if ( false === $iv ) {
            return $plaintext; // Cannot generate IV — store plaintext.
        }

        $encrypted = openssl_encrypt( $plaintext, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv );
        if ( false === $encrypted ) {
            return $plaintext;
        }

        // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
        return 'enc:' . base64_encode( $iv . $encrypted );
    }

    /**
     * Decrypt an encrypted bot token.
     *
     * @param string $ciphertext  Base64-encoded token prefixed with "enc:".
     * @return string  The plaintext bot token.
     */
    public static function decrypt_token( $ciphertext ) {
        $ciphertext = trim( (string) $ciphertext );
        if ( '' === $ciphertext ) {
            return '';
        }

        // Not encrypted — plaintext token (pre-upgrade).
        if ( 0 !== strpos( $ciphertext, 'enc:' ) ) {
            return $ciphertext;
        }

        if ( ! function_exists( 'openssl_decrypt' ) ) {
            return ''; // Can't decrypt without openssl.
        }

        // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
        $data = base64_decode( substr( $ciphertext, 4 ), true );
        if ( false === $data || strlen( $data ) < 17 ) {
            return '';
        }

        $key       = self::get_token_encryption_key();
        $iv        = substr( $data, 0, 16 );
        $encrypted = substr( $data, 16 );
        $decrypted = openssl_decrypt( $encrypted, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv );

        return ( false !== $decrypted ) ? $decrypted : '';
    }

    /**
     * Check whether a stored token value is already encrypted.
     *
     * @param string $value  The stored value.
     * @return bool
     */
    public static function is_token_encrypted( $value ) {
        return 0 === strpos( trim( (string) $value ), 'enc:' );
    }

    public static function is_valid_bot_token( $value ) {
        return (bool) preg_match( '/^\d{6,20}:[A-Za-z0-9_-]{30,120}$/', trim( (string) $value ) );
    }
}
