<?php
/**
 * Niwa AI — Telegram sticker description cache (Phase 5b / feature/telegram-and-authonomy).
 *
 * Telegram stickers carry rich metadata (`emoji`, `set_name`, `file_unique_id`)
 * that almost always describes the sticker well enough for an LLM to act on
 * — "👍 sticker from \"BlobsHD\"" carries the same intent as the underlying
 * image. The minority of stickers that arrive without an emoji can optionally
 * fall back to a Vision describe call; either way, we cache the resulting
 * description by `file_unique_id` so the same sticker never gets re-described.
 *
 * Stored as a single WP option, LRU-pruned at 200 entries (Openclaw uses a
 * similar cap). Animated `.tgs` and video `.webm` stickers are intentionally
 * skipped at the call site — they can't produce a still-image description and
 * Telegram already gives the receiving client enough to render them.
 *
 * @package Niwa_AI
 */

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

class Niwa_AI_Admin_Autonomy_Telegram_Sticker_Cache {

    const MAX_ENTRIES    = 200;
    const MAX_DESC_CHARS = 300;

    /**
     * Look up a cached sticker description.
     *
     * @param string $file_unique_id Telegram file_unique_id (NOT file_id; the
     *                               unique id is stable across forwards).
     * @return array|null { description, emoji, set_name, cached_at } or null on miss.
     */
    public static function get( $file_unique_id ) {
        $key = self::sanitize_key( $file_unique_id );
        if ( '' === $key ) {
            return null;
        }
        $cache = self::read_cache();
        if ( ! isset( $cache[ $key ] ) ) {
            return null;
        }
        $entry = $cache[ $key ];
        if ( ! is_array( $entry ) || ! isset( $entry['description'] ) ) {
            return null;
        }
        return $entry;
    }

    /**
     * Store a sticker description and update the LRU.
     *
     * @param string $file_unique_id
     * @param string $description    Description text (truncated to MAX_DESC_CHARS).
     * @param string $emoji          Sticker emoji (optional).
     * @param string $set_name       Sticker set name (optional).
     * @return bool True on success, false on bad input.
     */
    public static function set( $file_unique_id, $description, $emoji = '', $set_name = '' ) {
        $key  = self::sanitize_key( $file_unique_id );
        $desc = self::sanitize_description( $description );
        if ( '' === $key || '' === $desc ) {
            return false;
        }

        $cache = self::read_cache();
        $cache[ $key ] = array(
            'description' => $desc,
            'emoji'       => (string) $emoji,
            'set_name'    => (string) $set_name,
            'cached_at'   => time(),
        );

        self::write_cache( self::prune( $cache ) );
        return true;
    }

    /**
     * Drop the entire cache (test seam + admin reset hook).
     */
    public static function clear() {
        delete_option( Niwa_AI_Constants::OPT_AUTONOMY_TG_STICKER_CACHE );
    }

    /**
     * Inspect the full cache (admin debug and tests).
     *
     * @return array<string, array{description:string, emoji:string, set_name:string, cached_at:int}>
     */
    public static function all() {
        return self::read_cache();
    }

    /**
     * Build a description from sticker metadata alone — used as the cache
     * fallback when no Vision describe is available. Telegram populates the
     * emoji + set_name fields for almost every sticker, so this path is the
     * common case and avoids any LLM round-trip.
     *
     * @param string $emoji
     * @param string $set_name
     * @return string Description text, or empty string when neither is set.
     */
    public static function describe_from_metadata( $emoji, $set_name ) {
        $emoji    = trim( (string) $emoji );
        $set_name = trim( (string) $set_name );
        if ( '' === $emoji && '' === $set_name ) {
            return '';
        }

        $parts = array();
        if ( '' !== $emoji ) {
            $parts[] = $emoji . ' sticker';
        } else {
            $parts[] = 'sticker';
        }
        if ( '' !== $set_name ) {
            $parts[] = 'from "' . $set_name . '"';
        }
        return implode( ' ', $parts );
    }

    /* ───── Internal helpers ─────────────────────────────────────────── */

    private static function read_cache() {
        $cache = get_option( Niwa_AI_Constants::OPT_AUTONOMY_TG_STICKER_CACHE, array() );
        return is_array( $cache ) ? $cache : array();
    }

    private static function write_cache( $cache ) {
        update_option( Niwa_AI_Constants::OPT_AUTONOMY_TG_STICKER_CACHE, $cache, false );
    }

    /**
     * LRU prune: keep the MAX_ENTRIES most-recently-cached entries (highest
     * `cached_at` timestamp wins). Stable for tied timestamps via array_slice
     * preserving original order.
     */
    private static function prune( $cache ) {
        if ( count( $cache ) <= self::MAX_ENTRIES ) {
            return $cache;
        }

        // Sort by cached_at DESC; keep first MAX_ENTRIES.
        uasort( $cache, function ( $a, $b ) {
            return intval( $b['cached_at'] ?? 0 ) - intval( $a['cached_at'] ?? 0 );
        } );
        return array_slice( $cache, 0, self::MAX_ENTRIES, true );
    }

    private static function sanitize_key( $file_unique_id ) {
        $k = trim( (string) $file_unique_id );
        // file_unique_id is alphanumeric + dash + underscore in practice; keep
        // it conservative to ensure the option stays array-keyable.
        if ( ! preg_match( '/^[A-Za-z0-9_\-]{1,128}$/', $k ) ) {
            return '';
        }
        return $k;
    }

    private static function sanitize_description( $description ) {
        $d = trim( (string) $description );
        if ( '' === $d ) {
            return '';
        }
        if ( function_exists( 'mb_substr' ) ) {
            return mb_substr( $d, 0, self::MAX_DESC_CHARS, 'UTF-8' );
        }
        return substr( $d, 0, self::MAX_DESC_CHARS );
    }
}
