# Skill: Translation Engine

## Identity
- **Skill ID**: `translation-engine`
- **Domain**: AI Translation Service Integration
- **Technologies**: Press.Zone Backend API, WordPress REST API, WP-Cron
- **Source Agent**: `expert.md`

## When to Load This Skill
- Task involves translation functionality
- Working with TranslationBridge, TranslationService, CharacterEstimator
- Implementing job sending/receiving, webhooks
- Working with MetaBox or BulkActions translation UI
- Files matching: `includes/Translation/**/*.php`

## Core Patterns

### TranslationBridge (Internal Convenience Wrapper)
The TranslationBridge replaces the old IMultilingualBridge + adapter pattern.
Instead of going through BridgeFactory -> MPZAdapter -> external plugin,
TranslationBridge calls LanguageManager and ContentManager DIRECTLY within
the same plugin.

```php
namespace InternationalPressZone\Translation;

use InternationalPressZone\Core\Plugin;

class TranslationBridge {
    public function is_active(): bool { return true; }

    public function get_active_languages(): array {
        $manager = Plugin::instance()->getLanguageManager();
        $languages = $manager->getActiveLanguages();
        $result = [];
        foreach ($languages as $lang) {
            $result[$lang->getCode()] = $lang->getName();
        }
        return $result;
    }

    public function get_all_configured_languages(): array {
        // Same as above but includes inactive languages
        $manager = Plugin::instance()->getLanguageManager();
        $languages = $manager->getAllLanguages();
        $result = [];
        foreach ($languages as $lang) {
            $result[$lang->getCode()] = $lang->getName();
        }
        return $result;
    }

    public function get_default_language(): string {
        return Plugin::instance()->getLanguageManager()
            ->getDefaultLanguage()->getCode();
    }

    public function get_post_language(int $post_id): string {
        // Query ipz_translations table for this post's language
        global $wpdb;
        $table = $wpdb->prefix . 'presszone_international_translations';
        return $wpdb->get_var($wpdb->prepare(
            "SELECT language_code FROM {$table} WHERE post_id = %d",
            $post_id
        )) ?: $this->get_default_language();
    }

    public function get_all_translations(int $post_id): array {
        // Returns ['lang_code' => post_id] map from ipz_translations
        global $wpdb;
        $table = $wpdb->prefix . 'presszone_international_translations';

        // First get the translation group for this post
        $group = $wpdb->get_var($wpdb->prepare(
            "SELECT translation_group FROM {$table} WHERE post_id = %d",
            $post_id
        ));

        if (!$group) {
            return [];
        }

        $rows = $wpdb->get_results($wpdb->prepare(
            "SELECT language_code, post_id FROM {$table} WHERE translation_group = %d",
            $group
        ));

        $result = [];
        foreach ($rows as $row) {
            $result[$row->language_code] = (int) $row->post_id;
        }
        return $result;
    }

    public function create_translation(int $source_id, string $lang, array $data): int|\WP_Error {
        // Create new post, link in ipz_translations table
        $source = get_post($source_id);
        if (!$source) {
            return new \WP_Error('invalid_source', __('Source post not found.', 'international-press-zone'));
        }

        $new_post_id = wp_insert_post([
            'post_title'   => sanitize_text_field($data['title'] ?? $source->post_title),
            'post_content' => wp_kses_post($data['content'] ?? ''),
            'post_excerpt'  => sanitize_textarea_field($data['excerpt'] ?? ''),
            'post_status'  => 'draft',
            'post_type'    => $source->post_type,
            'post_author'  => get_current_user_id(),
        ], true);

        if (is_wp_error($new_post_id)) {
            return $new_post_id;
        }

        // Link in translations table
        global $wpdb;
        $table = $wpdb->prefix . 'presszone_international_translations';

        // Get or create translation group
        $group = $wpdb->get_var($wpdb->prepare(
            "SELECT translation_group FROM {$table} WHERE post_id = %d",
            $source_id
        ));

        if (!$group) {
            // Create new group for the source post first
            $group = $wpdb->get_var("SELECT COALESCE(MAX(translation_group), 0) + 1 FROM {$table}");
            $wpdb->insert($table, [
                'post_id' => $source_id,
                'language_code' => $this->get_post_language($source_id),
                'translation_group' => $group,
                'status' => 'original',
                'created_at' => current_time('mysql'),
            ], ['%d', '%s', '%d', '%s', '%s']);
        }

        // Insert the translation link
        $wpdb->insert($table, [
            'post_id' => $new_post_id,
            'language_code' => sanitize_key($lang),
            'source_language' => $this->get_post_language($source_id),
            'translation_group' => $group,
            'status' => 'translated',
            'created_at' => current_time('mysql'),
        ], ['%d', '%s', '%s', '%d', '%s', '%s']);

        return $new_post_id;
    }

    public function get_missing_translations(int $post_id): array {
        // All configured languages minus existing translations
        $all_languages = $this->get_all_configured_languages();
        $existing = $this->get_all_translations($post_id);

        return array_diff_key($all_languages, $existing);
    }

    public function get_posts_translation_status(array $post_ids): array {
        // Batch SQL query on ipz_translations for performance
        if (empty($post_ids)) {
            return [];
        }

        global $wpdb;
        $table = $wpdb->prefix . 'presszone_international_translations';

        $placeholders = implode(',', array_fill(0, count($post_ids), '%d'));
        $query = $wpdb->prepare(
            "SELECT t1.post_id, t2.language_code, t2.post_id AS translated_post_id, t2.status
             FROM {$table} t1
             JOIN {$table} t2 ON t1.translation_group = t2.translation_group AND t1.post_id != t2.post_id
             WHERE t1.post_id IN ({$placeholders})",
            ...$post_ids
        );

        $rows = $wpdb->get_results($query);

        $status = [];
        foreach ($rows as $row) {
            $pid = (int) $row->post_id;
            if (!isset($status[$pid])) {
                $status[$pid] = [];
            }
            $status[$pid][$row->language_code] = [
                'post_id' => (int) $row->translated_post_id,
                'status' => $row->status,
            ];
        }

        return $status;
    }
}
```

### Structured Fields Translation
```php
// Translation accepts structured fields (title, excerpt, content separately)
$result = $service->translate([
    'title' => $post->post_title,
    'excerpt' => wp_strip_all_tags($post->post_excerpt),
    'content' => $post->post_content,
], $source_lang, $target_lang, $options);

// Character count formula (must match backend):
$char_count = mb_strlen($title) + mb_strlen(strip_tags($excerpt)) + mb_strlen(strip_tags($content));
```

### Job Lifecycle
1. User triggers translation (MetaBox, BulkActions, or admin SPA)
2. TranslationService calls backend API (sync or async)
3. For async: JobSender stores in presszone_international_jobs table
4. Backend translates through Gemini `gemini-3.1-flash-lite`; development-mode fallback is `gemini-3.5-flash-lite`
5. Webhook callback to /wp-json/presszone-international/v1/callback
6. TranslateJobsController handles the single `/callback` webhook endpoint via `handleCallback`
7. `verifyWebhookSignature` validates `X-Webhook-Signature` (with `X-TPZ-Signature` fallback), `X-TPZ-Timestamp` in Unix milliseconds within ±300 seconds, and an HMAC-SHA256 over `timestamp.body`; it applies a per-IP `REMOTE_ADDR` rate limit of 100/hour before signature computation and uses `deliveryId` for idempotency
8. TranslationBridge creates/updates translation post

### Job Table Schema
```php
public static function create_jobs_table() {
    global $wpdb;
    $charset_collate = $wpdb->get_charset_collate();
    $table_name = $wpdb->prefix . 'presszone_international_jobs';

    $sql = "CREATE TABLE {$table_name} (
        id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
        post_id bigint(20) unsigned NOT NULL,
        source_language varchar(10) NOT NULL,
        target_language varchar(10) NOT NULL,
        status varchar(20) NOT NULL DEFAULT 'pending',
        backend_job_id varchar(100) DEFAULT NULL,
        character_count int(11) DEFAULT 0,
        error_message text DEFAULT NULL,
        created_at datetime DEFAULT CURRENT_TIMESTAMP NOT NULL,
        updated_at datetime DEFAULT CURRENT_TIMESTAMP NOT NULL,
        completed_at datetime DEFAULT NULL,
        PRIMARY KEY  (id),
        KEY post_id (post_id),
        KEY status (status),
        KEY backend_job_id (backend_job_id)
    ) {$charset_collate};";

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

### EstimateManager (Pre-computed Caching)
```php
namespace InternationalPressZone\Translation;

class EstimateManager {
    const META_KEY = '_ipz_char_estimate';
    const BATCH_SIZE = 10;
    const BATCH_DELAY = 5;

    /**
     * Store estimate in post meta for instant retrieval
     */
    public function store_estimate(int $post_id, int $estimate): void {
        update_post_meta($post_id, self::META_KEY, $estimate);
    }

    /**
     * Get cached estimate (zero N+1 queries when used with WP_Query)
     */
    public function get_estimate(int $post_id): ?int {
        $value = get_post_meta($post_id, self::META_KEY, true);
        return $value !== '' ? (int) $value : null;
    }

    /**
     * Invalidate on post save/delete
     */
    public function invalidate(int $post_id): void {
        delete_post_meta($post_id, self::META_KEY);
        // Schedule re-estimation
        wp_schedule_single_event(time(), 'presszone_international_estimate_single', [$post_id]);
    }

    /**
     * Batch processing via WP-Cron (10 posts/batch, 5s delay)
     */
    public function process_batch(): void {
        global $wpdb;

        $posts = $wpdb->get_col($wpdb->prepare(
            "SELECT p.ID FROM {$wpdb->posts} p
             LEFT JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id AND pm.meta_key = %s
             WHERE p.post_status IN ('publish', 'draft')
             AND p.post_type IN ('post', 'page')
             AND pm.meta_id IS NULL
             LIMIT %d",
            self::META_KEY,
            self::BATCH_SIZE
        ));

        foreach ($posts as $post_id) {
            $post = get_post($post_id);
            if (!$post) continue;

            $char_count = mb_strlen($post->post_title)
                + mb_strlen(strip_tags($post->post_excerpt))
                + mb_strlen(strip_tags($post->post_content));

            $this->store_estimate((int) $post_id, $char_count);
        }

        // Schedule next batch if more posts remain
        if (count($posts) === self::BATCH_SIZE) {
            wp_schedule_single_event(
                time() + self::BATCH_DELAY,
                'presszone_international_estimate_batch'
            );
        }
    }
}
```

### Webhook Callback Handler
```php
register_rest_route('presszone-international/v1', '/callback', [
    'methods' => 'POST',
    'callback' => [$this, 'handle_translation_callback'],
    'permission_callback' => function(\WP_REST_Request $request) {
        // HMAC verification MUST be in permission_callback
        $payload = $request->get_body();
        $signature = $request->get_header('X-Webhook-Signature');
        $secret = get_option('presszone_international_webhook_secret');

        if (!$signature || !$secret) {
            return false;
        }

        $expected = hash_hmac('sha256', $payload, $secret);
        return hash_equals($expected, $signature);
    }
]);
```

### MetaBox Pattern
```php
namespace InternationalPressZone\Admin;

class MetaBox {
    private TranslationBridge $bridge;

    public function register(): void {
        add_action('add_meta_boxes', [$this, 'add_meta_box']);
    }

    public function add_meta_box(): void {
        add_meta_box(
            'ipz-translation-meta-box',
            __('Translations', 'international-press-zone'),
            [$this, 'render'],
            ['post', 'page'],
            'side',
            'high'
        );
    }

    public function render(\WP_Post $post): void {
        $translations = $this->bridge->get_all_translations($post->ID);
        $missing = $this->bridge->get_missing_translations($post->ID);

        wp_nonce_field('presszone_international_nonce', 'ipz_nonce');

        // Render existing translations
        foreach ($translations as $lang_code => $trans_post_id) {
            $lang_name = esc_html($this->bridge->get_all_configured_languages()[$lang_code] ?? $lang_code);
            $edit_url = esc_url(get_edit_post_link($trans_post_id));
            printf(
                '<p>%s: <a href="%s">%s</a></p>',
                $lang_name,
                $edit_url,
                esc_html__('Edit', 'international-press-zone')
            );
        }

        // Render translate buttons for missing languages
        foreach ($missing as $lang_code => $lang_name) {
            printf(
                '<button type="button" class="button presszone-international-translate-btn" data-lang="%s" data-post="%d">%s %s</button>',
                esc_attr($lang_code),
                absint($post->ID),
                esc_html__('Translate to', 'international-press-zone'),
                esc_html($lang_name)
            );
        }
    }
}
```

### BulkActions Pattern
```php
namespace InternationalPressZone\Admin;

class BulkActions {
    const MAX_POSTS_PER_BULK = 10;

    public function register(): void {
        add_filter('bulk_actions-edit-post', [$this, 'add_bulk_actions']);
        add_filter('handle_bulk_actions-edit-post', [$this, 'handle_bulk_action'], 10, 3);
    }

    public function add_bulk_actions(array $actions): array {
        $bridge = new TranslationBridge();
        $languages = $bridge->get_all_configured_languages();
        $default = $bridge->get_default_language();

        foreach ($languages as $code => $name) {
            if ($code === $default) continue;
            $actions["ipz_translate_{$code}"] = sprintf(
                /* translators: %s: language name */
                __('Translate to %s', 'international-press-zone'),
                $name
            );
        }

        return $actions;
    }

    public function handle_bulk_action(string $redirect, string $action, array $post_ids): string {
        if (strpos($action, 'ipz_translate_') !== 0) {
            return $redirect;
        }

        $target_lang = sanitize_key(str_replace('ipz_translate_', '', $action));

        // Rate limit: max 10 posts per bulk action
        $post_ids = array_slice($post_ids, 0, self::MAX_POSTS_PER_BULK);

        $jobs_created = 0;
        foreach ($post_ids as $post_id) {
            $post_id = absint($post_id);
            if (!current_user_can('edit_post', $post_id)) continue;

            // Queue translation job
            $result = $this->queue_translation_job($post_id, $target_lang);
            if (!is_wp_error($result)) {
                $jobs_created++;
            }
        }

        return add_query_arg([
            'ipz_translated' => $jobs_created,
            'ipz_lang' => $target_lang,
        ], $redirect);
    }
}
```

### TranslationService API Call
```php
namespace InternationalPressZone\Translation;

class TranslationService {
    /**
     * Send translation request to backend API
     * Uses wp_safe_remote_post() - NEVER curl
     */
    public function translate(array $fields, string $source_lang, string $target_lang, array $options = []): array|\WP_Error {
        $api_key = presszone_international_get_api_key();
        if (!$api_key) {
            return new \WP_Error('no_api_key', __('API key not configured', 'international-press-zone'));
        }

        $body = [
            'source_language' => sanitize_key($source_lang),
            'target_language' => sanitize_key($target_lang),
        ];

        // Support both structured and string input
        if (is_array($fields)) {
            if (isset($fields['title'])) $body['title'] = $fields['title'];
            if (isset($fields['excerpt'])) $body['excerpt'] = $fields['excerpt'];
            if (isset($fields['content'])) $body['content'] = $fields['content'];
        } else {
            $body['content'] = $fields;
        }

        $response = wp_safe_remote_post(
            PRESSZONE_INTERNATIONAL_API_URL . '/v1/translate',
            [
                'timeout' => 60,
                'headers' => [
                    'Authorization' => 'Bearer ' . $api_key,
                    'Content-Type' => 'application/json',
                    'Accept' => 'application/json',
                    'X-Plugin-Version' => PRESSZONE_INTERNATIONAL_VERSION,
                    'X-Site-URL' => home_url(),
                ],
                'body' => wp_json_encode($body),
            ]
        );

        if (is_wp_error($response)) {
            return $response;
        }

        $code = wp_remote_retrieve_response_code($response);
        $decoded = json_decode(wp_remote_retrieve_body($response), true);

        if ($code >= 400) {
            return new \WP_Error(
                'api_error',
                $decoded['message'] ?? __('Translation failed', 'international-press-zone'),
                ['status' => $code]
            );
        }

        return $decoded;
    }
}
```

## Anti-Patterns (Forbidden)

| Mistake | Fix |
|---------|-----|
| Using IMultilingualBridge | Use TranslationBridge (internal) |
| Using BridgeFactory | TranslationBridge is instantiated directly |
| Using external adapters (WPML, Polylang) | Not needed -- plugin IS the multilingual system |
| Blocking API calls in loops | Use async jobs + webhooks |
| Hardcoded language codes | Get from TranslationBridge.get_all_configured_languages() |
| HMAC verification in handler body | Always in permission_callback |
| Missing character estimate before translate | Always estimate before API call |
| Using curl for backend API | Use wp_safe_remote_post() |
| N+1 queries for translation status | Use get_posts_translation_status() batch query |
| Storing estimates in options table | Use post meta (_ipz_char_estimate) |
| Skipping nonce on MetaBox actions | Always verify presszone_international_nonce |
| Allowing unlimited bulk actions | Cap at MAX_POSTS_PER_BULK (10) |

## Validation Checklist
- [ ] TranslationBridge used (not IMultilingualBridge)
- [ ] No WPML/Polylang adapter references
- [ ] Character count formula matches backend: `mb_strlen(title) + mb_strlen(strip_tags(excerpt)) + mb_strlen(strip_tags(content))`
- [ ] HMAC webhook verification in permission_callback
- [ ] Job table uses presszone_international_jobs name
- [ ] Estimate meta key is _ipz_char_estimate
- [ ] All REST endpoints under presszone-international/v1
- [ ] wp_safe_remote_post() for backend API calls
- [ ] Nonce + capability checks on all translation endpoints
- [ ] Bulk actions capped at 10 posts
- [ ] EstimateManager invalidates on save_post/delete_post
- [ ] Transient cache on REST list responses (2-minute TTL)
- [ ] All language codes sanitized with sanitize_key()
- [ ] Translation group IDs are integers (absint)
