<?php
/**
 * Glossary - brand terminology dictionary for translation consistency
 *
 * @package LangForge
 */

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

class LF_Glossary {

    private static $instance = null;
    private $table_name;

    public static function instance() {
        if (self::$instance === null) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    private function __construct() {
        if (!LF_License::instance()->can('glossary')) {
            return;
        }

        global $wpdb;
        $this->table_name = $wpdb->prefix . 'lf_glossary';
        $this->init_hooks();
    }

    private function init_hooks() {
        add_action('rest_api_init', [$this, 'register_rest_routes']);
    }

    /**
 * Create glossary table
 */
    public static function create_table() {
        global $wpdb;
        $table = $wpdb->prefix . 'lf_glossary';
        $charset_collate = $wpdb->get_charset_collate();

        $sql = "CREATE TABLE IF NOT EXISTS $table (
            id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            source_term varchar(255) NOT NULL,
            translated_term varchar(255) NOT NULL DEFAULT '',
            source_lang varchar(10) NOT NULL,
            target_lang varchar(10) NOT NULL,
            description text DEFAULT '',
            do_not_translate tinyint(1) NOT NULL DEFAULT 0,
            case_sensitive tinyint(1) NOT NULL DEFAULT 0,
            created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
            PRIMARY KEY (id),
            UNIQUE KEY term_pair (source_term(191), source_lang, target_lang),
            KEY source_lang (source_lang),
            KEY target_lang (target_lang)
        ) $charset_collate;";

        require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
        dbDelta($sql);
    }

    /**
 * Register REST routes
 */
    public function register_rest_routes() {
        register_rest_route('langforge/v1', '/glossary', [
            [
                'methods' => 'GET',
                'callback' => [$this, 'rest_list'],
                'permission_callback' => function() { return current_user_can('edit_posts'); },
                'args' => [
                    'from' => ['type' => 'string', 'default' => ''],
                    'to' => ['type' => 'string', 'default' => ''],
                    'search' => ['type' => 'string', 'default' => ''],
                ],
            ],
            [
                'methods' => 'POST',
                'callback' => [$this, 'rest_create'],
                'permission_callback' => function() { return current_user_can('manage_options'); },
            ],
        ]);

        register_rest_route('langforge/v1', '/glossary/(?P<id>\d+)', [
            [
                'methods' => 'PUT',
                'callback' => [$this, 'rest_update'],
                'permission_callback' => function() { return current_user_can('manage_options'); },
            ],
            [
                'methods' => 'DELETE',
                'callback' => [$this, 'rest_delete'],
                'permission_callback' => function() { return current_user_can('manage_options'); },
            ],
        ]);

        register_rest_route('langforge/v1', '/glossary/check', [
            'methods' => 'POST',
            'callback' => [$this, 'rest_check_text'],
            'permission_callback' => function() { return current_user_can('edit_posts'); },
            'args' => [
                'text' => ['required' => true, 'type' => 'string'],
                'from' => ['required' => true, 'type' => 'string'],
                'to' => ['required' => true, 'type' => 'string'],
            ],
        ]);

        register_rest_route('langforge/v1', '/glossary/import', [
            'methods' => 'POST',
            'callback' => [$this, 'rest_import_csv'],
            'permission_callback' => function() { return current_user_can('manage_options'); },
        ]);
    }

    /**
 * Add a term to the glossary
 */
    public function add_term($source_term, $translated_term, $source_lang, $target_lang, $args = []) {
        global $wpdb;

        $data = [
            'source_term' => sanitize_text_field($source_term),
            'translated_term' => sanitize_text_field($translated_term),
            'source_lang' => sanitize_text_field($source_lang),
            'target_lang' => sanitize_text_field($target_lang),
            'description' => isset($args['description']) ? sanitize_textarea_field($args['description']) : '',
            'do_not_translate' => !empty($args['do_not_translate']) ? 1 : 0,
            'case_sensitive' => !empty($args['case_sensitive']) ? 1 : 0,
        ];

        $existing = $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; one-time/admin read
            "SELECT id FROM {$this->table_name} WHERE source_term = %s AND source_lang = %s AND target_lang = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from $wpdb->prefix, values are %-prepared.
            $data['source_term'], $data['source_lang'], $data['target_lang']
        ));

        if ($existing) {
            $wpdb->update($this->table_name, $data, ['id' => $existing]); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query intended.
            $this->invalidate_tm_for_term($data['source_term'], $data['source_lang'], $data['target_lang']);
            return (int) $existing;
        }

        $wpdb->insert($this->table_name, $data); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query intended.
        $this->invalidate_tm_for_term($data['source_term'], $data['source_lang'], $data['target_lang']);
        return $wpdb->insert_id;
    }

    /**
 * Option B (Glossary > Translation Memory): after a glossary term is
 * added / updated / deleted, drop the now-stale TM entries whose source
 * text contains that term so the next AI translation regenerates with the
 * current glossary instead of replaying the cached value. No-op when the
 * Pro Translation-Memory module isn't loaded (TM is a Pro feature).
 *
 * @param string $source_term
 * @param string $source_lang
 * @param string $target_lang
 */
    private function invalidate_tm_for_term($source_term, $source_lang, $target_lang) {
        if (!class_exists('LF_Translation_Memory')) return;
        $source_term = is_string($source_term) ? trim($source_term) : '';
        if ($source_term === '' || empty($source_lang) || empty($target_lang)) return;
        $tm = LF_Translation_Memory::instance();
        if (is_object($tm) && method_exists($tm, 'invalidate_containing')) {
            $tm->invalidate_containing($source_term, $source_lang, $target_lang);
        }
    }

    /**
 * Get glossary terms for a language pair
 */
    public function get_terms($source_lang, $target_lang, $search = '') {
        global $wpdb;

        $where = "source_lang = %s AND target_lang = %s";
        $args = [$source_lang, $target_lang];

        if ($search) {
            $where .= " AND (source_term LIKE %s OR translated_term LIKE %s)";
            $like = '%' . $wpdb->esc_like($search) . '%';
            $args[] = $like;
            $args[] = $like;
        }

        return $wpdb->get_results($wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; $where is a literal placeholder clause
            "SELECT * FROM {$this->table_name} WHERE $where ORDER BY source_term ASC", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Table name from $wpdb->prefix; $where is a literal placeholder clause, values are %-prepared.
            ...$args
        ));
    }

    /**
 * Check text against glossary - find terms that should be translated per glossary
 */
    public function check_text($text, $source_lang, $target_lang) {
        $terms = $this->get_terms($source_lang, $target_lang);
        $found = [];

        foreach ($terms as $term) {
            $pattern = $term->case_sensitive
                ? '/\b' . preg_quote($term->source_term, '/') . '\b/'
                : '/\b' . preg_quote($term->source_term, '/') . '\b/iu';

            if (preg_match($pattern, $text)) {
                $found[] = [
                    'source_term' => $term->source_term,
                    'translated_term' => $term->translated_term,
                    'do_not_translate' => (bool) $term->do_not_translate,
                    'description' => $term->description,
                ];
            }
        }

        return $found;
    }

    /**
 * Build a forge-api-compatible glossary payload for a translate request.
 *
 * Worker /ai/process expects `[{source: string, target: string}]`
 * (validation.ts:86). LF_Glossary stores rows with `source_term` /
 * `translated_term` plus `do_not_translate` / `case_sensitive` flags
 * — the worker doesn't read those flags, so we encode the user's
 * intent into the {source, target} pair we send:
 *
 * - do_not_translate=1 → target == source_term (instructs the
 * LLM to keep the term verbatim during generation)
 * - regular row → target == translated_term (instructs
 * the LLM to use the user's preferred translation)
 * - row with empty translated_term → skipped (not actionable)
 *
 * If `$text` is non-empty we only send terms that actually appear in
 * the source segment (worker prompts include each term as a
 * `"…" → "…"` line, so unbounded growth = wasted prompt tokens for
 * a 200-term glossary used to translate one sentence).
 *
 * Returns when:
 * - LF_License::can('glossary') is false (no PRO),
 * - lang pair has no terms,
 * - $text is supplied and contains none of the terms.
 *
 * @param string $source_lang Source BCP-47 lang code.
 * @param string $target_lang Target BCP-47 lang code.
 * @param string $text Optional text — if provided, filters to
 * terms appearing in the text. Pass '' to
 * get every term for the lang pair.
 * @return array<int, array{source: string, target: string}>
 */
    public function get_payload_for_translate($source_lang, $target_lang, $text = '') {
        // PRO gate. Free sites don't have access to the glossary table
        // at all (constructor early-returns when can('glossary') is
        // false), so attempting to query would error.
        if (!LF_License::instance()->can('glossary')) {
            return [];
        }

        if (empty($source_lang) || empty($target_lang) || $source_lang === $target_lang) {
            return [];
        }

        $terms = $this->get_terms($source_lang, $target_lang);
        if (empty($terms)) {
            return [];
        }

        $payload = [];
        $text_str = (string) $text;
        $has_text = $text_str !== '';

        foreach ($terms as $term) {
            $source_term = (string) $term->source_term;
            if ($source_term === '') continue;

            // case_sensitive=0 means the term should match in any case;
            // case_sensitive=1 means only literal-case matches count
            // (e.g. brand `iOS` should NOT match the word `ios`).
            $pattern = !empty($term->case_sensitive)
                ? '/\b' . preg_quote($source_term, '/') . '\b/u'
                : '/\b' . preg_quote($source_term, '/') . '\b/iu';

            if ($has_text && !preg_match($pattern, $text_str)) {
                continue;
            }

            // Map plugin schema → worker schema. Worker only reads
            // `source` and `target`; do_not_translate / case_sensitive
            // are encoded into the pair itself.
            if (!empty($term->do_not_translate)) {
                // Tell the LLM "keep this verbatim" by mapping
                // source → source. The system prompt and the bracketed
                // glossary block in services/ai.ts:264 instruct it to
                // honour the listed translation exactly.
                $payload[] = [
                    'source' => $source_term,
                    'target' => $source_term,
                ];
            } else {
                $translated = (string) $term->translated_term;
                if ($translated === '') continue; // nothing to enforce
                $payload[] = [
                    'source' => $source_term,
                    'target' => $translated,
                ];
            }
        }

        return $payload;
    }

    /**
 * Validate translation against glossary
 */
    public function validate_translation($source_text, $translated_text, $source_lang, $target_lang) {
        $terms = $this->check_text($source_text, $source_lang, $target_lang);
        $warnings = [];

        foreach ($terms as $term) {
            if ($term['do_not_translate']) {
                // Term should appear as-is in translation
                if (mb_stripos($translated_text, $term['source_term']) === false) {
                    $warnings[] = sprintf(
                        /* translators: %s: the glossary source term that should be kept untranslated. */
                        __('"%s" should not be translated (keep as-is).', 'langforge'),
                        $term['source_term']
                    );
                }
            } else {
                // Term should use glossary translation
                if (!empty($term['translated_term']) && mb_stripos($translated_text, $term['translated_term']) === false) {
                    $warnings[] = sprintf(
                        /* translators: 1: the glossary source term, 2: the expected translated term per glossary. */
                        __('"%1$s" should be translated as "%2$s" per glossary.', 'langforge'),
                        $term['source_term'],
                        $term['translated_term']
                    );
                }
            }
        }

        return $warnings;
    }

    /**
 * Import terms from CSV string.
 *
 * Columns (in order): source_term, translated_term, description,
 * do_not_translate (0/1), case_sensitive (0/1).
 *
 * Backwards-compat: a 4-column CSV (without case_sensitive) is still
 * accepted — the 5th column defaults to 0. The header row is detected
 * by presence of "source" anywhere in the first line; if a CSV has no
 * header, all rows are parsed as data.
 */
    public function import_csv($csv_content, $source_lang, $target_lang) {
        $lines = explode("\n", trim($csv_content));
        $count = 0;

        foreach ($lines as $i => $line) {
            if ($i === 0 && stripos($line, 'source') !== false) continue; // skip header

            // Explicit delimiter/enclosure/escape — PHP 8.x deprecates calling
            // str_getcsv without $escape; these match the historical defaults.
            $parts = str_getcsv($line, ',', '"', "\\");
            if (count($parts) < 2) continue;

            $source = trim($parts[0]);
            $translated = trim($parts[1]);
            $description = isset($parts[2]) ? trim($parts[2]) : '';
            $dnt = isset($parts[3]) ? (int) trim($parts[3]) : 0;
            // Round 2 audit fix (R2-2): preserve case_sensitive on round-trip.
            // Default 0 if column is absent (4-col legacy CSVs from before
            // the round-1 case_sensitive UI shipped).
            $cs  = isset($parts[4]) ? (int) trim($parts[4]) : 0;

            if ($source) {
                $this->add_term($source, $translated, $source_lang, $target_lang, [
                    'description' => $description,
                    'do_not_translate' => $dnt,
                    'case_sensitive' => $cs,
                ]);
                $count++;
            }
        }

        return $count;
    }

    /**
 * Export terms as CSV string.
 *
 * Columns: source_term, translated_term, description, do_not_translate,
 * case_sensitive — both flags as integers 0/1 to keep the file
 * spreadsheet-friendly. Round 2 audit fix (R2-2): case_sensitive added
 * so an export → import round-trip on the same site preserves the flag
 * that the round-1 UI added.
 */
    public function export_csv($source_lang, $target_lang) {
        $terms = $this->get_terms($source_lang, $target_lang);
        $csv = "source_term,translated_term,description,do_not_translate,case_sensitive\n";

        foreach ($terms as $term) {
            $csv .= sprintf(
                '"%s","%s","%s",%d,%d' . "\n",
                str_replace('"', '""', $term->source_term),
                str_replace('"', '""', $term->translated_term),
                str_replace('"', '""', $term->description),
                (int) $term->do_not_translate,
                (int) $term->case_sensitive
            );
        }

        return $csv;
    }

    // --- REST Callbacks ---

    public function rest_list($request) {
        $from = sanitize_text_field($request['from']);
        $to = sanitize_text_field($request['to']);
        $search = sanitize_text_field($request['search']);

        if (!$from || !$to) {
            $default = LF_Core::instance()->get_default_language();
            $languages = LF_Core::instance()->get_active_languages();
            $from = $from ?: $default;
            $to = $to ?: array_key_first(array_diff_key($languages, [$default => true]));
        }

        $terms = $this->get_terms($from, $to, $search);
        return rest_ensure_response($terms);
    }

    public function rest_create($request) {
        $body = $request->get_json_params();
        $id = $this->add_term(
            $body['source_term'] ?? '',
            $body['translated_term'] ?? '',
            $body['source_lang'] ?? '',
            $body['target_lang'] ?? '',
            $body
        );
        return rest_ensure_response(['success' => true, 'id' => $id]);
    }

    public function rest_update($request) {
        global $wpdb;
        $id = (int) $request['id'];
        $body = $request->get_json_params();

        $data = [];
        if (isset($body['source_term'])) $data['source_term'] = sanitize_text_field($body['source_term']);
        if (isset($body['translated_term'])) $data['translated_term'] = sanitize_text_field($body['translated_term']);
        if (isset($body['description'])) $data['description'] = sanitize_textarea_field($body['description']);
        if (isset($body['do_not_translate'])) $data['do_not_translate'] = (int) $body['do_not_translate'];
        if (isset($body['case_sensitive'])) $data['case_sensitive'] = (int) $body['case_sensitive'];

        $wpdb->update($this->table_name, $data, ['id' => $id]); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query intended.
        // Glossary changed → drop stale TM entries for this term (Option B).
        $row = $wpdb->get_row($wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; one-time/admin read
            "SELECT source_term, source_lang, target_lang FROM {$this->table_name} WHERE id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from $wpdb->prefix, value is %-prepared.
            $id
        ));
        if ($row) {
            $this->invalidate_tm_for_term($row->source_term, $row->source_lang, $row->target_lang);
        }
        return rest_ensure_response(['success' => true]);
    }

    public function rest_delete($request) {
        global $wpdb;
        $id = (int) $request['id'];
        // Capture the term BEFORE deleting so we can invalidate its TM entries.
        $row = $wpdb->get_row($wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; one-time/admin read
            "SELECT source_term, source_lang, target_lang FROM {$this->table_name} WHERE id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from $wpdb->prefix, value is %-prepared.
            $id
        ));
        $wpdb->delete($this->table_name, ['id' => $id]); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query intended.
        if ($row) {
            $this->invalidate_tm_for_term($row->source_term, $row->source_lang, $row->target_lang);
        }
        return rest_ensure_response(['success' => true]);
    }

    public function rest_check_text($request) {
        $text = sanitize_text_field($request['text']);
        $from = sanitize_text_field($request['from']);
        $to = sanitize_text_field($request['to']);

        $found = $this->check_text($text, $from, $to);
        return rest_ensure_response($found);
    }

    public function rest_import_csv($request) {
        $body = $request->get_json_params();
        $count = $this->import_csv(
            $body['csv'] ?? '',
            $body['source_lang'] ?? '',
            $body['target_lang'] ?? ''
        );
        return rest_ensure_response(['success' => true, 'count' => $count]);
    }
}
