<?php
/**
 * Helper
 *
 * @class Helper
 * @package KimaAI
 * @since 1.1.0
 */

declare(strict_types=1);

namespace KimaAI;

use Exception;
use KimaAI\Admin\Settings;
use KimaAI\Ai\AiModels;
use KimaAI\Ai\ToolRegistry;
use WP_REST_Request;

defined('ABSPATH') || exit;

class Helper {

  /**
   * Param options as a single flat SelectField-ready list with per-model conditions.
   *
   * This avoids introducing new props like "modelField" / "optionsByModel" and relies only on
   * SelectField's existing per-option condition filtering.
   *
   * Example output:
   * [
   *   ['value'=>'low','label'=>'Low','condition'=>['field'=>'chatAiModel','operator'=>'=','value'=>'o1']],
   *   ['value'=>'medium','label'=>'Medium','condition'=>['field'=>'chatAiModel','operator'=>'=','value'=>'o1']],
   *   ...
   * ]
   *
   * @param string $provider openai|replicate.
   * @param string $param_key E.g. reasoning|verbosity.
   * @param string $model_field The controlling field id, e.g. chatAiModel.
   * @return array<int, array<string, mixed>>
   */
  public static function aiModelParamOptions(string $provider, string $param_key, string $model_field): array {
    $model_field = trim((string) $model_field);
    if ($model_field === '') {
      return [];
    }

    $by_model = AiModels::paramValuesByModel($provider, $param_key);
    $out = [];

    foreach ($by_model as $model_id => $vals) {
      foreach ($vals as $val) {
        $out[] = [
          'value' => $val,
          'label' => self::formatEnumLabel($val),
          'condition' => [
            'field' => $model_field,
            'operator' => '=',
            'value' => (string) $model_id,
          ],
        ];
      }
    }

    return $out;
  }


  /**
   * Model ids that support a specific param (e.g. reasoning, verbosity).
   *
   * @param string $provider openai|replicate.
   * @param string $param_key The parameter key.
   * @return string[]
   */
  public static function aiModelIdsWithParam(string $provider, string $param_key): array {
    return AiModels::modelIdsWithParam($provider, $param_key);
  }

  /**
   * Union enum (all possible values) for a param across models for a provider.
   * Useful to set a schema-level enum (server-side validation).
   *
   * @param string $provider The AI provider.
   * @param string $param_key The parameter key.
   * @return string[]
   */
  public static function aiModelParamEnum(string $provider, string $param_key): array {
    $by_model = AiModels::paramValuesByModel($provider, $param_key);
    $all = [];
    foreach ($by_model as $vals) {
      $all = array_merge($all, $vals);
    }
    $all = array_values(array_unique(array_values(array_filter($all))));
    sort($all, SORT_STRING);
    return $all;
  }

  /**
   * Param options per model id (SelectField-ready), so each model can have its own allowed list.
   *
   * Shape:
   * [
   *   'gpt-5.2' => [ ['value'=>'low','label'=>'Low'], ... ],
   *   'gpt-5.2-pro' => [ ... ],
   * ]
   *
   * @param string $provider The AI provider.
   * @param string $param_key The parameter key.
   * @return array<string, array<int, array{value:string,label:string}>>
   */
  public static function aiModelParamOptionsByModel(string $provider, string $param_key): array {
    $by_model = AiModels::paramValuesByModel($provider, $param_key);
    $out = [];
    foreach ($by_model as $model_id => $vals) {
      $opts = [];
      foreach ($vals as $val) {
        $opts[] = [
          'value' => $val,
          'label' => self::formatEnumLabel($val),
        ];
      }
      $out[(string) $model_id] = $opts;
    }
    ksort($out, SORT_STRING);
    return $out;
  }

  /**
   * Human-friendly labels for enum-like strings (low/medium/high/xhigh, etc.).
   *
   * @param string $val The enum value to format.
   */
  private static function formatEnumLabel(string $val): string {
    $val = strtolower(trim($val));
    if ($val === '') {
      return '';
    }
    // Small opinionated mapping for nicer UI.
    $map = [
      'xhigh' => 'Extra High',
    ];
    if (isset($map[$val])) {
      return $map[$val];
    }
    // Convert snake/kebab to words.
    $val = str_replace(['_', '-'], ' ', $val);
    return ucwords($val);
  }

  /**
   * Returns the respective AI provider models options.
   *
   * Usage examples:
   * - Completion models: Helper::aiProviderModelOptions('provider', ['completion'])
   * - Embeddings: Helper::aiProviderModelOptions('provider', ['embedding'])
   * - Both: Helper::aiProviderModelOptions('provider', ['completion', 'embedding'])
   * - Everything OpenAI: Helper::aiProviderModelOptions('provider', [])
   *
   * @param string|null $field The field to check for the condition (optional).
   * @param string[] $features Feature filter (e.g. ['completion'], ['embedding']). Empty array means no filter.
   *
   * @return array
   */
  public static function aiProviderModelOptions(?string $field = null, array $features = []): array {
    $options = [];
    $has_field = !empty($field);

    // Normalize feature filter.
    $features = array_values(array_filter(array_map(static function ($item) {
      return is_string($item) ? strtolower(trim($item)) : '';
    }, $features)));

    $matches_features = static function (array $model_features) use ($features): bool {
      if (empty($features)) {
        return true; // No filter.
      }
      $model_features = array_values(array_filter(array_map(static function ($item) {
        return is_string($item) ? strtolower(trim($item)) : '';
      }, $model_features)));

      // Union semantics: match if ANY requested feature is supported by the model.
      return !empty(array_intersect($features, $model_features));
    };

    $add_option = static function (string $provider, string $value, string $label) use (&$options, $has_field, $field): void {
      $option = [
        'value' => $value,
        'label' => $label,
      ];

      if ($has_field) {
        $option['condition'] = [
          'field' => $field,
          'operator' => 'contains',
          'value' => $provider,
        ];
      }

      $options[] = $option;
    };

    /**
     * Build provider options from AiModels registry.
     *
     * @param string $provider_key openai|replicate
     * @param array $models list from AiModels::<provider>Models()
     */
    $append_provider_models = static function (string $provider_key, array $models) use ($matches_features, $add_option): void {
      // Use id => label to dedupe. Stable sort by label.
      $by_id = [];

      foreach ($models as $model) {
        if (!is_array($model)) {
          continue;
        }

        $id = isset($model['model']) ? (string) $model['model'] : '';
        $label = isset($model['name']) ? (string) $model['name'] : '';

        if ($id === '' || $label === '') {
          continue;
        }

        $model_features = isset($model['features']) && is_array($model['features']) ? $model['features'] : [];
        if (!$matches_features($model_features)) {
          continue;
        }

        $by_id[$id] = $label; // Last-write-wins.
      }

      asort($by_id, SORT_NATURAL | SORT_FLAG_CASE);

      foreach ($by_id as $id => $label) {
        $add_option($provider_key, $id, $label);
      }
    };

    // OpenAI-compatible options.
    $append_provider_models('openai', AiModels::openAiModels());
    $append_provider_models('openrouter', AiModels::openRouterModels());

    // Replicate options.
    $append_provider_models('replicate', AiModels::replicateModels());

    $append_provider_models('ollama', AiModels::ollamaModels());

    return $options;
  }

  /**
   * Returns AI environments as an array of options for the select field.
   *
   * @param bool $provider_as_id Whether to use the provider as the ID.
   * @param bool $add_default Whether to add a default option.
   * @param array $exclude_providers Providers to exclude.
   * @return array The AI environments.
   */
  public static function getAiEnvsOptions(bool $provider_as_id = false, bool $add_default = false, array $exclude_providers = []): array {
    $ai_envs = Settings::getStaticOption(null, 'aiServices', 'aiEnvs');
    $exclude_providers = array_map(
      static fn($provider) => strtolower(trim((string) $provider)),
      $exclude_providers
    );

    $options = [];
    if ($add_default) {
      $options = [
        [
          'value' => 'default',
          'label' => __('Default (Set AI Presets)', 'kimaai'),
        ],
      ];
    }
    if (!$ai_envs) {
      $options[] = [
        'value' => 'none',
        'label' => __('Set an AI Environment first', 'kimaai'),
      ];
    }
    foreach ($ai_envs ?? [] as $env) {
      $env_id = $env['id'] ?? '';
      $provider = strtolower((string) ($env['provider'] ?? ''));
      if ($provider !== '' && in_array($provider, $exclude_providers, true)) {
        continue;
      }
      $options[] = [
        'value' => $provider . '-' . $env_id,
        'label' => $provider . ' - ' . $env['name'] . ' - ' . $env_id,
      ];
    }

    return $options;
  }

  /**
   * Returns Embedding environments as an array of options for the select field.
   *
   * @return array The Embedding environments.
   */
  public static function getEmbedEnvsOptions(): array {
    $embedding_envs = Settings::getStaticOption(null, 'embedding', 'embedEnvs');

    $options = [];
    if (!$embedding_envs) {
      $options[] = [
        'value' => 'none',
        'label' => __('Set an Embedding Environment first', 'kimaai'),
      ];
    }
    foreach ($embedding_envs ?? [] as $env) {
      $env_id = $env['id'] ?? 'id-not-set';
      $env_name = $env['name'] ?? 'name-not-set';
      $env_provider = $env['provider'] ?? 'provider-not-set';
      $options[] = [
        'value' => "$env_provider-$env_id",
        'label' => "$env_provider-$env_name-$env_id",
      ];
    }

    return $options;
  }

  /**
   * Returns the plugin version from header.
   */
  public function getVersion(): string {
    $plugin_data = get_file_data(KIMAAI_PLUGIN_FILE, ['Version' => 'Version']);
    return $plugin_data['Version'] ?? '';
  }

  /**
   * Ensures we have a valid session ID, creating one if needed.
   *
   * @param bool $is_playground Whether the request is from the playground.
   * @param bool $force_new Whether to force a new session ID.
   * @return string The session ID.
   */
  public static function ensureSessionId(bool $is_playground = false, bool $force_new = false): string {
    // Playground: return an ID but do NOT set a cookie.
    if ($is_playground) {
      try {
        $session_id = bin2hex(random_bytes(16));
      } catch (Exception $exception) {
        $session_id = bin2hex(openssl_random_pseudo_bytes(16));
      }
      return $session_id;
    }

    $cookie_name = 'kimaai_chatbot_session_id';
    $has_cookie = isset($_COOKIE[$cookie_name]) && $_COOKIE[$cookie_name] !== '';

    // Need a new one if forced or cookie missing.
    if ($force_new || !$has_cookie) {
      try {
        $session_id = bin2hex(random_bytes(16));
      } catch (Exception $exception) {
        $session_id = bin2hex(openssl_random_pseudo_bytes(16));
      }
      $domain = isset($_SERVER['SERVER_NAME']) ? sanitize_text_field(wp_unslash($_SERVER['SERVER_NAME'])) : '';
      setcookie(
        $cookie_name,
        $session_id,
        [
          'expires' => time() + 60 * 60 * 24 * 365,
          'path' => '/',
          'domain' => $domain,
          'secure' => is_ssl(),
          'httponly' => true,
          'samesite' => 'Strict',
        ]
      );

      // Keep PHP's superglobal in sync so the new id is available immediately.
      $_COOKIE[$cookie_name] = $session_id;

      return $session_id;
    }

    return sanitize_text_field(wp_unslash($_COOKIE[$cookie_name] ?? ''));
  }

  /**
   * Builds the message tree for OpenAI API.
   *
   * @param array $history The chat history.
   * @param string $current_message The current message.
   * @param bool $is_playground Whether the request is from the playground.
   * @param array|null $chatbot_settings The chatbot settings, if any.
   * @param int $post_id The current post ID that the chatbot is on.
   * @param string|null $kb_context The knowledge base context, if any.
   * @return array The message tree.
   */
  public static function buildMessageTree(
    array $history,
    string $current_message,
    bool $is_playground = false,
    ?array $chatbot_settings = null,
    int $post_id = 0,
    ?string $kb_context = null
  ): array {
    $chatbot_settings = $chatbot_settings ?: Settings::getStaticOption(null, 'chatbot', 'settings');
    $instructions = $chatbot_settings['instructions'];

    /**
     * Filters the instructions for the chatbot.
     *
     * @param string $instructions The instructions for the chatbot.
     * @param array $chatbot_settings The chatbot settings.
     * @param int $post_id The current post ID that the chatbot is on.
     * @return string The filtered instructions.
     * @since 1.2.5
     */
    $instructions = apply_filters('kimaai_chatbot_instructions', $instructions, $chatbot_settings, $post_id);

    // If the chatbot is in playground mode, and the instructions contain placeholders,
    // replace them with empty strings to avoid sending false data to the AI.
    // phpcs:ignore
    // @todo: We should have a separate set of instructions for the playground.
    if ($is_playground) {
      $tokens = [
        '{post_url}' => '',
        '{post_title}' => '',
        '{post_content}' => '',
      ];
      $instructions = strtr($instructions, $tokens);
    }

    $messages = [
      [
        'role' => 'system',
        'content' => $instructions,
      ],
    ];

    if ($kb_context) {
      $messages[] = [
        'role' => 'system',
        'content' => $kb_context,
      ];
    }

    // Add conversation history.
    foreach ($history as $item) {
      $messages[] = [
        'role' => 'user',
        'content' => $item['user_message'],
      ];

      $messages[] = [
        'role' => 'assistant',
        'content' => $item['ai_response'],
      ];
    }

    // Add current message.
    $messages[] = [
      'role' => 'user',
      'content' => $current_message,
    ];

    return $messages;
  }

  /**
   * Safely calculates the length of a string, handling multibyte characters.
   *
   * @param string $input The input string.
   * @param string $encoding The character encoding (default is 'UTF-8').
   * @return bool|int The length of the string or FALSE on failure.
   */
  public static function safeStrlen(string $input, string $encoding = 'UTF-8'): bool|int {
    if (function_exists('mb_strlen')) {
      return mb_strlen($input, $encoding);
    }
    else {
      // Fallback implementation for environments without mb_strlen extension.
      return preg_match_all('/./u', $input, $matches);
    }
  }

  /**
   * Cleans sentences from a given text, ensuring uniqueness and length constraints.
   *
   * ▸ URLs are temporarily replaced with placeholders so that dots inside them
   *   are never mistaken for sentence boundaries.
   * ▸ Sentences are detected with IntlBreakIterator when available (locale‑aware
   *   and RTL‑safe); otherwise a regex that understands Persian punctuation is used.
   * ▸ Each sentence is NFC‑normalised before hashing so visually identical Persian
   *   strings hash identically.
   * ▸ As soon as adding the **next** sentence would exceed $max_length, the loop
   *   stops and the function returns the cleaned text.
   * ▸ if the **first** sentence alone is longer than $max_length, the function
   *   returns the leading $max_length code‑points of that sentence.
   *
   * @param string $text The input text to clean.
   * @param int $max_length Maximum length of the cleaned text (UTF‑8 code points).
   * @param string|null $locale ICU locale used for sentence breaking.
   *
   * @return string The cleaned, de‑duplicated text.
   */
  public static function cleanSentences(string $text, int $max_length = 4096, ?string $locale = null): string {
    /*
    ---------------------------------------------------------------
    * 1. Hide URLs behind placeholders.
    * ---------------------------------------------------------------
    */
    $url_pattern = '/\bhttps?:\/\/[^\s()<>]+(?:\([\w\d]+\)|([^[:punct:]\s]|\/))/u';
    preg_match_all($url_pattern, $text, $urls);
    $placeholders = [];
    foreach ($urls[0] as $i => $url) {
      $ph = '{urlPlaceholder' . $i . '}';
      $placeholders[$ph] = $url;
      $text = str_replace($url, $ph, $text);
    }

    /*
    ---------------------------------------------------------------
    * 2. Break into sentences (Intl preferred, regex fallback).
    * ---------------------------------------------------------------
    */
    if (class_exists('\IntlBreakIterator') && $locale) {
      $bi = \IntlBreakIterator::createSentenceInstance($locale);
      $bi->setText($text);

      $sentences = [];
      for ($start = $bi->first(), $end = $bi->next();
        $end !== \IntlBreakIterator::DONE;
        $start = $end, $end = $bi->next()) {

        $sentences[] = mb_substr($text, $start, $end - $start, 'UTF-8');
      }
    }
    else {
      // This regex should work for all languages like English, German, Persian, Arabic, etc.
      $sentences = preg_split(
        '/(?<=[\.\!\?؟…‽。．！])(?:\s+|$)/u',
        $text,
        -1,
        PREG_SPLIT_NO_EMPTY
      );
    }

    /*
    ---------------------------------------------------------------
    * 3. Deduplicate and assemble until the length ceiling is hit.
    * ---------------------------------------------------------------
    */
    $seen = [];
    $result = [];
    $total = 0;

    foreach ($sentences as $sentence) {
      // Trim bidi controls / whitespace and normalise.
      $sentence = \Normalizer::normalize(
        preg_replace('/^[\pZ\pC]+|[\pZ\pC]+$/u', '', $sentence),
        \Normalizer::FORM_C
      );

      if ($sentence === '') {
        continue;
      }

      $hash = md5($sentence);
      if (isset($seen[$hash])) {
        continue; // Duplicate sentence.
      }

      $len = mb_strlen($sentence, 'UTF-8');

      if ($len > $max_length) {
        // If the sentence is longer than the max length, truncate it.
        $sentence = mb_substr($sentence, 0, $max_length, 'UTF-8');
        $len = mb_strlen($sentence, 'UTF-8');
      }

      if ($total + $len > $max_length) {
        break; // Stop — next one would overflow.
      }

      $seen[$hash] = true;
      $result[] = $sentence;
      $total += $len;
    }

    /*
    ---------------------------------------------------------------
    * 4. Restore URLs and return.
    * ---------------------------------------------------------------
    */
    $clean = strtr(implode(' ', $result), $placeholders);

    return preg_replace('/^[\pZ\pC]+|[\pZ\pC]+$/u', '', $clean);
  }

  /**
   * Returns true if the plugin is running in premium mode.
   *
   * @return bool
   */
  public static function isPremium(): bool {
    if (defined('WP_DEBUG') && WP_DEBUG && get_option('kimaai_e2e_force_premium')) {
      return true;
    }

    return kimaai_fs()->can_use_premium_code__premium_only() && defined('KIMAAI_PRO_PLUGIN_FILE') && file_exists(KIMAAI_PRO_PLUGIN_FILE);
  }

  /**
   * Returns true if the user already put an API key in the settings.
   *
   * ```
   * wp eval 'KimaAI\Helper::hasApiKey();'
   * ```
   *
   * @return bool
   */
  public static function hasApiKey(): bool {
    $ai_service_options = Settings::getStaticOption(null, 'aiServices', null);
    if (empty($ai_service_options) || !is_array($ai_service_options)) {
      return false;
    }

    $ai_envs = $ai_service_options['aiEnvs'] ?? null;

    if (empty($ai_envs) || !is_array($ai_envs)) {
      return false;
    }

    foreach ($ai_envs as $env) {
      $key = $env['apiKey'] ?? null;
      if (is_string($key) && trim($key) !== '') {
        // At least one present — true.
        return true;
      }
    }

    return false;
  }

  /**
   * Generates a safe slug from the current site domain.
   * e.g. "https://sub.example.com" -> "sub-example-com"
   */
  public static function getSiteSlug(): string {
    $url = get_site_url();
    $host = wp_parse_url($url, PHP_URL_HOST) ?: 'default';
    // Remove www.
    $host = preg_replace('/^www\./', '', $host);
    // Replace dots/non-alphanumeric with dashes.
    $slug = preg_replace('/[^a-z0-9]+/i', '-', $host);
    return trim(strtolower($slug), '-');
  }

  /**
   * Returns custom function options formatted for a select field.
   *
   * @return array The custom function options.
   */
  public static function customFunctionsOptionsForSelect(): array {
    $rows = Settings::getStaticOption(null, 'chatbot', 'customFunctions');
    $opts = [];

    // Custom functions from settings (Pro).
    foreach ($rows ?? [] as $row) {
      $is_enabled = $row['enabled'] ?? true;
      if ($is_enabled && !empty($row['functionName'])) {
        $opts[] = [
          'value' => $row['functionName'],
          'label' => !empty($row['description']) ? $row['functionName'] . ' - ' . $row['description'] : $row['functionName'],
        ];
      }
    }

    $opts = array_merge($opts, ToolRegistry::asSelectOptions());

    // De-duplicate by "value" so multiple filters don't create duplicates.
    $seen = [];
    $unique = [];
    foreach ($opts as $opt) {
      if (!is_array($opt) || empty($opt['value'])) {
        continue;
      }
      if (isset($seen[$opt['value']])) {
        continue;
      }
      $seen[$opt['value']] = true;
      $unique[] = $opt;
    }

    return $unique;
  }

  /**
   * Sanitizes a PHP code value, returning an empty string for non-string input.
   *
   * @param mixed $value The value to sanitize.
   * @return string The sanitized value.
   */
  public static function sanitizePhpCode($value): string {
    return is_string($value) ? $value : '';
  }

  /**
   * Validates the REST API nonce.
   *
   * @param WP_REST_Request $request The REST request object.
   * @return bool True if the nonce is valid, false otherwise.
   */
  public static function validateNonce(WP_REST_Request $request): bool {
    $nonce = $request->get_header('X-WP-Nonce');
    return (bool) wp_verify_nonce($nonce, 'wp_rest');
  }

  /**
   * Returns all the public post types.
   *
   * @return array Array of public post types, both key => value is the post type name.
   */
  public static function getPublicPostTypes(): array {
    $post_types = get_post_types([
      'public' => true,
      'exclude_from_search' => false,
      '_builtin' => false,
      'show_ui' => true,
    ]);

    // Add post and page post type to the array — get_post_types with _builtin false does not return them.
    $post_types['post'] = 'post';
    $post_types['page'] = 'page';

    return $post_types;
  }

  /**
   * Returns public page types formatted for a select field.
   *
   * @return array The page type options.
   */
  public static function getPublicPageTypesForSelect(): array {
    $page_types = self::getPublicPostTypes();
    $options = [];
    foreach ($page_types as $key => $page_type) {
      $options[] = [
        'value' => $key,
        'label' => ucfirst($page_type),
      ];
    }
    return $options;
  }

  /**
   * Normalize tool trace values into JSON-safe data.
   *
   * @param mixed $value The value to normalize.
   * @return mixed The normalized value.
   */
  public static function normalizeToolTraceValue(mixed $value): mixed {
    if (is_null($value) || is_scalar($value)) {
      return $value;
    }

    if (is_array($value)) {
      $out = [];
      foreach ($value as $key => $item) {
        $out[$key] = self::normalizeToolTraceValue($item);
      }
      return $out;
    }

    if (is_object($value)) {
      if ($value instanceof \JsonSerializable) {
        return self::normalizeToolTraceValue($value->jsonSerialize());
      }

      if ($value instanceof \Stringable) {
        return (string) $value;
      }

      return self::normalizeToolTraceValue((array) $value);
    }

    return (string) $value;
  }
}
