<?php

namespace CleantalkSP\Common;

/**
 * Abstract class EarlyTranslation
 *
 * Provides early translation functionality for WordPress plugins before the 'init' hook.
 * Handles loading MO files, tracking untranslated strings, and logging translation errors.
 *
 * Use this ONLY for cases that need to run before the 'init' hook.
 *
 * @package CleantalkSP\Common
 */
abstract class EarlyTranslation
{
    /**
     * Text domain for translations
     *
     * @var string
     */
    public $text_domain;

    /**
     * Plugin directory path
     *
     * @var string
     */
    private $plugin_directory;

    /**
     * WordPress content directory path
     *
     * @var string
     */
    private $wp_content_dir;

    /**
     * Storage for untranslated text entities
     *
     * @var array
     */
    private $untranslated_entities = array();

    /**
     * Storage for translation error messages
     *
     * @var array
     */
    private $translator_errors = array();

    /**
     * Cached early translations
     *
     * @var array|null
     */
    private $early_translations = null;

    /**
     * Current tracking level for error logging
     * 0 - disabled, 1 - errors only, 2 - errors and untranslated strings
     *
     * @var int
     */
    protected $error_tracking_level = 0;

    /**
     * EarlyTranslation constructor.
     *
     * @param string $plugin_directory Plugin root directory path
     * @param string $wp_content_dir  WordPress content directory path
     */
    public function __construct($plugin_directory, $wp_content_dir)
    {
        $this->plugin_directory = $plugin_directory;
        $this->wp_content_dir = $wp_content_dir;
        $this->error_tracking_level = $this->setErrorTrackingLevel();

        // Register shutdown hook only if tracking is enabled
        if ($this->error_tracking_level !== 0) {
            $this->registerShutdown();
        }
    }

    /**
     * Get an early translation for a given text
     *
     * Use this ONLY for cases that need to run before init hook.
     *
     * @param string $text Text to translate
     *
     * @return mixed|string|null Translated text or original if translation not found
     */
    public function translate(string $text)
    {
        if (!$this->isStringWPTranslateReady($text)) {
            $this->echoError('Method ' . __METHOD__ . '() was called, but the text is not in translation registry.');
            $this->addUntranslatedEntity($text);
            return $text;
        }

        // Default flow break after WordPress init - method should not be called after init, return text
        if (function_exists('did_action') && did_action('init')) {
            $this->echoError('Method ' . __METHOD__ . '() was called after init hook, use __() to comply WP translations rules');
            $this->addUntranslatedEntity($text);
            return $text;
        }

        // Early flow before WordPress init
        return $this->translateEarly($text);
    }

    /**
     * Translate text before WordPress 'init' hook
     *
     * @param mixed $text Text to translate
     *
     * @return mixed|string Translated text or original if translation not found
     */
    public function translateEarly($text)
    {
        $translations = $this->loadEarlyTranslations();

        if (is_string($text)) {
            if (isset($translations[$text])) {
                return $translations[$text];
            } else {
                // Track untranslated string if not empty to avoid logging empty strings
                !empty($text) && $this->addUntranslatedEntity($text);
            }
        }

        return $text;
    }

    /**
     * Load early translations from MO files
     *
     * Caches translations after first load to avoid repeated file operations.
     *
     * @return array|null Array of translations or empty array on failure
     */
    public function loadEarlyTranslations()
    {
        // Return cached translations if already loaded
        if ($this->early_translations !== null) {
            return $this->early_translations;
        }

        try {
            $mo_filepaths = $this->getPossibleMOFilePaths();
            $this->early_translations = $this->getMOTranslations($mo_filepaths);
        } catch (\Exception $e) {
            $this->addTranslationError($e->getMessage());
            $this->early_translations = [];
        }

        return $this->early_translations;
    }

    /**
     * Extract translations from MO files
     *
     * @param string[] $mo_filepaths Array of paths to MO files
     *
     * @return array Associative array of original => translation pairs
     *
     * @throws \Exception When domain is empty, WordPress constants not defined,
     *                    MO class not found, or import fails
     */
    public function getMOTranslations($mo_filepaths)
    {
        if (!is_string($this->text_domain)) {
            throw new \Exception('No correct text domain provided.');
        }

        // Ensure WordPress environment is available
        if (!defined('ABSPATH') || !defined('WPINC')) {
            throw new \Exception('WordPress constants not defined');
        }

        // Load MO class if not already available
        if (!class_exists('\MO')) {
            $mo_handler_path = ABSPATH . WPINC . '/pomo/mo.php';
            if (file_exists($mo_handler_path) && is_readable($mo_handler_path)) {
                require_once $mo_handler_path;
            }
            if (!class_exists('\MO')) {
                throw new \Exception('MO class not found.');
            }
        }

        // Try to import from available MO files
        $import_result = false;
        foreach ($mo_filepaths as $mo_filepath) {
            $mo = new \MO();
            if (!method_exists($mo, 'import_from_file')) {
                throw new \Exception('No import_from_file method exists on MO class.');
            }

            $import_result = $mo->import_from_file($mo_filepath);
            if ($import_result) {
                break;
            }
        }

        if (!$import_result || empty($mo->entries)) {
            throw new \Exception('Import from MO failed.');
        }

        // Build translations array
        $translations = [];

        foreach ($mo->entries as $entry) {
            if (
                isset($entry->singular, $entry->translations) &&
                is_string($entry->singular) &&
                is_array($entry->translations) &&
                !empty($entry->translations[0])
            ) {
                $translations[$entry->singular] = $entry->translations[0];
            }
        }

        return $translations;
    }

    /**
     * Get possible MO file paths based on current locale
     *
     * Checks standard WordPress locations for MO files:
     * - Plugin's i18n directory
     * - WordPress languages directory
     *
     * @return string[] Array of existing and readable MO file paths
     *
     * @throws \Exception When locale functions are not available or no MO files found
     */
    public function getPossibleMOFilePaths()
    {
        $paths = [];

        // Ensure locale functions are available
        if (!function_exists('determine_locale') && !function_exists('get_locale')) {
            throw new \Exception('No locale functions available');
        }

        // Determine current locale
        $locale = function_exists('determine_locale')
            ? determine_locale()
            : (function_exists('get_locale') ? get_locale() : 'en_US');

        // Check both full locale and language code (e.g., 'en_US' and 'en')
        $locales = [$locale];
        if (strpos($locale, '_') !== false) {
            // Extract language code from full locale (e.g., 'en' from 'en_US')
            $locales[] = substr($locale, 0, 2);
        }

        // Build standard paths
        $std_paths = [];
        foreach ($locales as $locale) {
            $std_paths[] = $this->plugin_directory . '/i18n/' . $this->text_domain . '-' . $locale . '.mo';
            $std_paths[] = $this->wp_content_dir . '/languages/plugins/' . $this->text_domain . '-' . $locale . '.mo';
        }

        // Filter to existing and readable files only
        foreach ($std_paths as $path) {
            if (file_exists($path) && is_readable($path)) {
                $paths[] = $path;
            }
        }

        if (empty($paths)) {
            throw new \Exception('No MO files found. Tried: ' . implode(', ', $std_paths));
        }

        return $paths;
    }

    /**
     * Add untranslated text entity to tracking storage
     *
     * @param string $text Untranslated text
     *
     * @return void
     */
    public function addUntranslatedEntity($text)
    {
        $text = is_string($text) ? $text : 'NON_TEXT_ENTITY';
        $this->addError($text, $this->untranslated_entities);
    }

    /**
     * Add translation error message to tracking storage
     *
     * @param string $error Error message
     *
     * @return void
     */
    public function addTranslationError($error)
    {
        $this->addError($error, $this->translator_errors);
    }

    /**
     * Internal method to add error/entity to storage with deduplication
     *
     * @param string $error   Error message or untranslated text
     * @param array  &$storage Reference to storage array
     *
     * @return void
     */
    private function addError($error, &$storage)
    {
        // Use MD5 hash as key for deduplication to prevent storing the same error multiple times
        $md = md5($error);
        $data = [
            'text' => $error,
            'time' => function_exists('current_datetime')
                ? current_datetime()->format('Y-m-d H:i:s')
                : date('Y-m-d H:i:s')
        ];

        // Store only if not already present to avoid duplicates
        !isset($storage[$md]) && $storage[$md] = $this->prepareJson($data);
    }

    /**
     * Register shutdown hook to process and save tracked errors
     *
     * @return void
     */
    public function registerShutdown()
    {
        add_action('shutdown', [$this, 'processErrors']);
    }

    /**
     * Process tracked errors and untranslated entities on shutdown
     *
     * Collects data based on the current tracking level and triggers save.
     *
     * @return void
     */
    public function processErrors()
    {
        $errors_to_save = [
            'errors' => [],
            'untranslated' => [],
        ];

        // Level 1+: Save translation errors
        if ($this->error_tracking_level >= 1) {
            foreach ($this->translator_errors as $_md => $error) {
                $errors_to_save['errors'][] = $error;
            }
        }

        // Level 2: Save untranslated entities as well
        if ($this->error_tracking_level === 2) {
            foreach ($this->untranslated_entities as $_md => $untranslated) {
                $errors_to_save['untranslated'][] = $untranslated;
            }
        }

        // Save only if there is data to persist
        if (!empty($errors_to_save['errors']) || !empty($errors_to_save['untranslated'])) {
            $this->saveErrors($errors_to_save);
        }
    }

    /**
     * Encode data to JSON format
     *
     * Provides fallback error message if encoding fails.
     *
     * @param mixed $data Data to encode
     *
     * @return string JSON encoded string
     */
    protected function prepareJson($data)
    {
        $encoded = json_encode($data, JSON_UNESCAPED_UNICODE);
        if ($encoded === false) {
            $encoded = json_encode(['error' => 'JSON encoding failed']);
        }
        return $encoded;
    }

    /**
     * Checks if a given string is ready for WordPress translation based on the registry keys.
     *
     * @param string $text The string to be checked against the WordPress translation registry keys.
     *
     * @return bool Returns true if the string exists in the registry keys, otherwise false.
     */
    protected function isStringWPTranslateReady($text)
    {
        $registry_keys = $this->getRawStringRegistryKeys();
        return in_array($text, $registry_keys, true);
    }

    /**
     * Hook action.
     *
     * Validates if WordPress translation registry keys match the translation calls.
     *
     * This method checks whether the number of keys in the WordPress translation registry
     * aligns with the number of translation calls and verifies that each corresponding key
     * matches the expected value.
     *
     * Logs errors if any mismatches or count discrepancies are found.
     *
     * @return void
     */
    public function checkWPTranslationReady()
    {
        $call_to = $this->getWPTranslatePairs(); //DO NOT DELETE THIS
        $registry_keys = $this->getRawStringRegistryKeys();
        if (empty($call_to) || empty($registry_keys)) {
            $this->echoError(__METHOD__ . ' Translation registry is empty!');
            return;
        }
        // Verify that the number of registry keys matches the number of translation calls
        $call_to_keys = array_keys($call_to); // save before overwriting
        if (count($call_to_keys) !== count($registry_keys)) {
            $this->echoError(__METHOD__ . ' Register keys count does not match text calls count.');
        } else {
            for ($i = 0; $i < count($registry_keys); $i++) {
                if ($call_to_keys[$i] !== $registry_keys[$i]) {
                    $this->echoError(__METHOD__ . ' Register key element mismatch with a called one.');
                    break;
                }
            }
        }
    }

    /**
     * Log an error message if the current tracking level is set to 1 or higher.
     * @param string $error The error message to log.
     * @return void
     */
    protected function echoError($error)
    {
        if (is_string($error) && $this->error_tracking_level >= 1 && function_exists('error_log') ) {
            /** @psalm-suppress ForbiddenCode */
            error_log($error);
        }
    }

    /**
     * Save tracked errors to persistent storage
     *
     * @param array $errors Errors data to save
     *
     * @return void
     */
    abstract public function saveErrors($errors): void;

    /**
     * Set the current tracking level
     *
     * Should return:
     * - 0: Tracking disabled
     * - 1: Track translation errors only
     * - 2: Track both errors and untranslated strings
     *
     * @return int Tracking level
     */
    abstract public function setErrorTrackingLevel(): int;

    /**
     * Abstract method to retrieve and translate registry values within WordPress.
     *
     * Add a string to translate as an array key you need to use in $this->translate method and a value
     * that HAS TO call __() function as an array value that WP parser handles!
     *
     * Attention! Keys of the array should be equal to getRawStringRegistryKeys keys
     *
     * Example
     *
     * `return ['Text to translate' => __('Text to translate', 'your-dmoain-as-text')]`
     *
     * @see checkWPTranslationReady to understand what have to be checked during development
     * @return array Translated registry values as an associative array
     */
    abstract public function getWPTranslatePairs(): array;

    /**
     * Retrieves an array of keys from the WordPress translation registry.
     *
     * Add a string to translate as an array key you need to use in $this->translate method
     *
     * Attention! Keys of the array should be equal to getWPTranslatePairs keys.
     *
     * Example:
     *
     * `return ['Text to translate']`
     *
     * @see checkWPTranslationReady to understand what have to be checked during development
     * @return array An array of translation registry keys.
     */
    abstract public function getRawStringRegistryKeys(): array;
}
