<?php
if (!defined('ABSPATH')) exit;

require_once __DIR__ . '/class-forge-crypto.php';

class LF_Forge_API {
    private static $instance = null;
    private $api_url = 'https://api.avakode.com';
    private $license_key = '';
    private $site_token = '';
    private $trial_token = '';

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

    private function __construct() {
        if (defined('FORGE_API_URL') && FORGE_API_URL) {
            $this->api_url = rtrim(FORGE_API_URL, '/');
        }
        $data = get_option('lf_license_data', []);
        $this->license_key = $data['key'] ?? '';
        $this->site_token = Forge_Crypto::decrypt(isset($data['site_token']) ? (string) $data['site_token'] : '');
        $this->trial_token = Forge_Crypto::decrypt(isset($data['trial_token']) ? (string) $data['trial_token'] : '');

        // Fallback: use Freemius license key for real paying users
        if (empty($this->license_key) && function_exists('lf_freemius')) {
            $fs = lf_freemius();
            if ($fs && $fs->is_registered()) {
                $license = $fs->_get_license();
                if (is_object($license) && !empty($license->secret_key)) {
                    $this->license_key = $license->secret_key;
                }
            }
        }
    }

    /**
 * Validate license against the server.
 * Returns: ['valid' => bool, 'plan' => string, 'expires_at' => string|null, 'features' => array]
 */
    public function validate_license($key = null) {
        $key = $key ?: $this->license_key;
        if (empty($key)) return ['valid' => false, 'plan' => 'free', 'features' => []];

        $response = $this->post('/licenses/validate', [
            'license_key' => $key,
            'product' => 'langforge',
            'domain' => home_url('/'),
        ]);

        if (!is_wp_error($response) && is_array($response) && !empty($response['token'])) {
            $this->persist_site_token((string) $response['token'], $key);
        }

        // Propagate WP_Error to callers (canon: seoforge) so is_wp_error
        // fallbacks fire instead of being masked by a flat false-array.
        return $response ?: ['valid' => false, 'plan' => 'free', 'features' => []];
    }

    private function persist_site_token($site_token, $license_key = '') {
        if ($site_token === '') return;
        $data = get_option('lf_license_data', []);
        if (!is_array($data)) $data = [];
        if ($license_key !== '') {
            $data['key'] = $license_key;
        }
        $data['site_token'] = Forge_Crypto::encrypt($site_token);
        $data['site_url'] = home_url('/');
        $data['site_token_refreshed_at'] = time();
        update_option('lf_license_data', $data, false);
        $this->site_token = $site_token;
    }

    /**
 * Ensure a per-site trial token exists (#39). Called on the first AI request
 * from a site with no license so the user gets their free trial credits
 * without any manual setup. Best-effort.
 *
 * @return bool True if a trial token is available afterwards.
 */
    public function maybe_start_trial() {
        if ($this->trial_token !== '') return true;
        if (!empty($this->license_key)) return false;
        $this->start_trial();
        return $this->trial_token !== '';
    }

    /**
 * Activate a trial against the Worker and persist the returned per-site
 * token (encrypted at rest). Defaults to the site admin email.
 *
 * @param string $email Optional override; defaults to admin_email.
 * @return array|\WP_Error
 */
    public function start_trial($email = '') {
        $email = $email !== '' ? $email : (string) get_option('admin_email');
        $response = $this->post('/public/trial/activate', [
            'product' => 'langforge',
            'domain'  => home_url('/'),
            'email'   => $email,
        ]);
        if (!is_wp_error($response) && is_array($response)
            && !empty($response['success']) && !empty($response['trial']['token'])) {
            $this->persist_trial_token((string) $response['trial']['token'], $response['trial']);
        }
        return $response;
    }

    private function persist_trial_token($token, $trial = []) {
        if ($token === '') return;
        $data = get_option('lf_license_data', []);
        if (!is_array($data)) $data = [];
        $data['trial_token'] = Forge_Crypto::encrypt($token);
        if (!empty($trial['expires_at'])) {
            $data['trial_expires_at'] = (string) $trial['expires_at'];
            $data['trial_expires'] = (string) $trial['expires_at'];
        }
        if (isset($trial['credits_remaining'])) $data['trial_credits_remaining'] = (int) $trial['credits_remaining'];
        if (isset($trial['credits_limit'])) $data['trial_credits_limit'] = (int) $trial['credits_limit'];
        update_option('lf_license_data', $data, false);
        $this->trial_token = $token;
    }

    /**
 * AI Translation — routed through API proxy.
 * OpenAI key stays on the server, never in WordPress.
 */
    public function ai_translate($text, $source_lang, $target_lang, $context = '', $glossary = []) {
        return $this->post('/ai/process', [
            'action' => 'translate',
            'product' => 'langforge',
            'license_key' => $this->license_key,
            'payload' => [
                'text' => $text,
                'source_lang' => $source_lang,
                'target_lang' => $target_lang,
                'context' => $context,
                'glossary' => $glossary,
                'model'  => $this->get_translation_model(),
                'domain' => $this->get_domain(),
            ],
        ]);
    }

    /**
 * Return the translation model selected in Settings → LangForge →
 * Translation Model. Values map 1:1 to forge-api's validation.ts:
 * openai-mini | openai-gpt4
 * Translation runs only through OpenAI GPT models. A legacy stored
 * 'deepl' value — or any unknown value — degrades to openai-mini so
 * translation always works.
 */
    public function get_translation_model() {
        $m = (string) get_option('lf_translation_model', 'openai-mini');
        return in_array($m, ['openai-mini', 'openai-gpt4'], true)
            ? $m
            : 'openai-mini';
    }

    /**
 * Translate an entire post (title + content + excerpt) in a single API call.
 * Returns: ['title' => '...', 'content' => '...', 'excerpt' => '...'] or WP_Error.
 */
    public function ai_translate_post($title, $content, $excerpt, $source_lang, $target_lang) {
        $payload = [
            'source_lang' => $source_lang,
            'target_lang' => $target_lang,
            'domain'      => $this->get_domain(),
        ];
        if (!empty($title))   $payload['title']   = $title;
        if (!empty($content)) $payload['content']  = $content;
        if (!empty($excerpt)) $payload['excerpt']  = $excerpt;

        return $this->post('/ai/process', [
            'action'      => 'translate_post',
            'product'     => 'langforge',
            'license_key' => $this->license_key,
            'payload'     => $payload,
        ]);
    }

    /**
 * Translate title, content, and excerpt in PARALLEL using curl_multi.
 * Content is split into chunks at paragraph boundaries for parallel processing.
 * Returns ['title' => ..., 'content' => ..., 'excerpt' => ...] or WP_Error.
 *
 * R3-G1 (audit-r3): an optional 6th `$glossary` arg is forwarded to
 * each `translate` chunk request so glossary terms are enforced for
 * full-post AI translations (the path Bulk Actions uses). The
 * `translate_post` action used for title+excerpt does not currently
 * read glossary on the worker (validation.ts:81 vs no
 * translatePostPayloadSchema), but the field is harmless if added —
 * the LLM just receives one extra hint line.
 *
 * @param string $title
 * @param string $content
 * @param string $excerpt
 * @param string $source_lang
 * @param string $target_lang
 * @param array $glossary Optional [{source, target}] payload from
 * LF_Glossary::get_payload_for_translate.
 */
    public function ai_translate_parallel($title, $content, $excerpt, $source_lang, $target_lang, $glossary = []) {
        $url = $this->api_url . '/ai/process';
        // #39: attach the per-site trial token for license-less sites. The
        // trial is started explicitly via the credits-widget button now, not
        // auto-started here; we only forward a token that already exists.
        $headers = ['Content-Type: application/json', 'X-Site-URL: ' . $this->get_domain()];
        if (empty($this->license_key) && $this->trial_token !== '') {
            $headers[] = 'X-Trial-Token: ' . $this->trial_token;
        }
        $domain = $this->get_domain();
        $bodies = [];

        $glossary_arr = is_array($glossary) ? $glossary : [];

        // Request 0: title + excerpt. Worker schema for translate_post
        // doesn't currently read glossary, but include it anyway —
        // forward-compat once the worker grows a translatePostPayload
        // schema.
        $bodies[] = ['key' => 'meta', 'body' => wp_json_encode([
            'action' => 'translate_post', 'product' => 'langforge', 'license_key' => $this->license_key,
            'payload' => [
                'title' => $title,
                'excerpt' => $excerpt ?: '',
                'source_lang' => $source_lang,
                'target_lang' => $target_lang,
                'domain' => $domain,
                'glossary' => $glossary_arr,
            ],
        ])];

        // Split content into chunks (~2000 chars each) at </p> or </h3> boundaries
        if (mb_strlen($content) > 2500) {
            $chunks = $this->split_html_content($content);
        } else {
            $chunks = [$content];
        }

        foreach ($chunks as $i => $chunk) {
            $context = count($chunks) > 1
                ? "HTML content part " . ($i + 1) . " of " . count($chunks) . ". Preserve all HTML tags."
                : "blog post HTML content, preserve all HTML tags";
            $bodies[] = ['key' => 'content_' . $i, 'body' => wp_json_encode([
                'action' => 'translate', 'product' => 'langforge', 'license_key' => $this->license_key,
                'payload' => [
                    'text' => $chunk,
                    'source_lang' => $source_lang,
                    'target_lang' => $target_lang,
                    'context' => $context,
                    'domain' => $domain,
                    'glossary' => $glossary_arr,
                ],
            ])];
        }

        // Execute ALL requests in parallel via curl_multi
        $mh = curl_multi_init();
        $handles = [];
        foreach ($bodies as $i => $req) {
            $ch = curl_init($url);
            curl_setopt_array($ch, [
                CURLOPT_POST => true, CURLOPT_POSTFIELDS => $req['body'],
                CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true,
                CURLOPT_TIMEOUT => 90, CURLOPT_CONNECTTIMEOUT => 10,
                CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2,
            ]);
            curl_multi_add_handle($mh, $ch);
            $handles[$i] = $ch;
        }

        $running = null;
        do { curl_multi_exec($mh, $running); curl_multi_select($mh); } while ($running > 0);

        $responses = [];
        foreach ($handles as $i => $ch) {
            $responses[$i] = json_decode(curl_multi_getcontent($ch), true);
            curl_multi_remove_handle($mh, $ch);
            curl_close($ch);
        }
        curl_multi_close($mh);

        // Parse results
        $result = ['title' => '', 'content' => '', 'excerpt' => ''];
        $total_tokens = 0;

        // Response 0: title + excerpt
        $r0 = $responses[0] ?? [];
        if (!empty($r0['success']) && is_array($r0['result'])) {
            $result['title'] = $r0['result']['title'] ?? '';
            $result['excerpt'] = $r0['result']['excerpt'] ?? '';
            $total_tokens += $r0['usage']['total_tokens'] ?? 0;
        } elseif (!empty($r0['error'])) {
            return new \WP_Error('api_error', $r0['error']);
        }

        // Responses 1+: content chunks — reassemble in order
        $content_parts = [];
        for ($i = 1; $i < count($responses); $i++) {
            $r = $responses[$i] ?? [];
            if (!empty($r['success'])) {
                $content_parts[] = is_string($r['result']) ? $r['result'] : '';
                $total_tokens += $r['usage']['total_tokens'] ?? 0;
            } else {
                $content_parts[] = $chunks[$i - 1]; // Keep original on error
            }
        }
        $result['content'] = implode("\n", $content_parts);

        return ['success' => true, 'result' => $result, 'usage' => ['total_tokens' => $total_tokens]];
    }

    /**
 * Split HTML content into chunks at paragraph/heading boundaries.
 * Tries to keep chunks ~2000 chars each for optimal parallel translation speed.
 */
    private function split_html_content($html) {
        // Split at </p>, </h2>, </h3>, </ul>, </ol> boundaries
        $parts = preg_split('/(<\/(?:p|h[23]|ul|ol|blockquote)>)/i', $html, -1, PREG_SPLIT_DELIM_CAPTURE);

        $chunks = [];
        $current = '';
        $target_size = 2000;

        for ($i = 0; $i < count($parts); $i++) {
            $current .= $parts[$i];
            // If this was a closing tag and we're over target size, split here
            if (preg_match('/<\/(?:p|h[23]|ul|ol|blockquote)>/i', $parts[$i]) && mb_strlen($current) >= $target_size) {
                $chunks[] = trim($current);
                $current = '';
            }
        }
        if (trim($current)) {
            $chunks[] = trim($current);
        }

        // If only 1 chunk (couldn't split), return as-is
        return count($chunks) > 1 ? $chunks : [$html];
    }

    /**
 * Custom AI call — routed through API proxy.
 * Used for QA, glossary, and other non-translation AI prompts.
 */
    public function ai_custom($system_prompt, $user_prompt) {
        return $this->post('/ai/process', [
            'action'      => 'custom',
            'product'     => 'langforge',
            'license_key' => $this->license_key,
            'payload'     => [
                'system_prompt' => $system_prompt,
                'user_prompt'   => $user_prompt,
                'domain'        => $this->get_domain(),
            ],
        ]);
    }

    /**
 * Generic POST to api.avakode.com
 */
    private function post($endpoint, $body) {
        $is_ai = strpos($endpoint, '/ai/') === 0;
        // Trial activation (#39) is no longer triggered implicitly here — the
        // user starts it explicitly via the "Start free trial" button in the
        // shared credits widget (Forge_Credits_Widget::ajax_start_trial →
        // start_trial). Once a trial token is stored we still attach it to
        // license-less AI calls below.

        $headers = [
            'Content-Type' => 'application/json',
            'X-Site-URL'   => $this->get_domain(),
        ];
        if ($is_ai && empty($this->license_key) && $this->trial_token !== '') {
            $headers['X-Trial-Token'] = $this->trial_token;
        }

        $start = microtime(true);
        $response = wp_remote_post($this->api_url . $endpoint, [
            'timeout' => 60,
            'headers' => $headers,
            'body' => wp_json_encode($body),
        ]);
        $duration_ms = (int) round((microtime(true) - $start) * 1000);
        $action = isset($body['action']) ? (string) $body['action'] : '';

        if (is_wp_error($response)) {
            self::telemetry_error($endpoint, $action, 0, 'network', $response->get_error_message());
            return $response;
        }

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

        if ($code === 402 && isset($data['error']) && $data['error'] === 'credits_exhausted') {
            self::telemetry_error($endpoint, $action, 402, 'credits_exhausted', $data['error'] ?? '');
            return new \WP_Error('credits_exhausted', __('Monthly AI credits exhausted.', 'langforge'), [
                'status' => 402,
                'remaining' => $data['remaining'] ?? 0,
                'reset_date' => $data['reset_date'] ?? '',
            ]);
        }

        // Push fresh credit balance to widget cache (bundle = shared pool).
        if ($code < 400 && strpos($endpoint, '/ai/process') !== false
            && is_array($data) && isset($data['credits'])
            && class_exists('Forge_Credits_Widget')) {
            \Forge_Credits_Widget::push_credits($data['credits']);
        }

        if ($code >= 400) {
            $err_msg = is_array($data) ? (string) ($data['error'] ?? 'API error') : 'API error';
            $err_class = self::classify_error($code, $err_msg);
            self::telemetry_error($endpoint, $action, $code, $err_class, $err_msg);
            return new \WP_Error('forge_api_error', $err_msg, ['status' => $code]);
        }

        if (class_exists('Forge_Telemetry') && strpos($endpoint, '/ai/') !== false) {
            Forge_Telemetry::track('ai.request', [
                'endpoint'        => $endpoint,
                'action'          => $action,
                'status_code'     => $code,
                'duration_bucket' => self::bucket_duration($duration_ms),
                'success'         => true,
            ]);
        }

        return $data;
    }

    /** Shared bucketing + error classification so the data we log stays coarse. */
    private static function bucket_duration(int $ms): string {
        if ($ms < 500)    return '0-500';
        if ($ms < 2000)   return '500-2k';
        if ($ms < 5000)   return '2k-5k';
        if ($ms < 15000)  return '5k-15k';
        if ($ms < 60000)  return '15k-60k';
        return '60k+';
    }
    private static function classify_error(int $code, string $msg): string {
        if ($code === 401 || $code === 403) return 'auth';
        if ($code === 429) return 'rate_limit';
        if ($code === 402) return 'credits_exhausted';
        if ($code >= 500)  return 'upstream_5xx';
        if (stripos($msg, 'country') !== false && stripos($msg, 'not supported') !== false) return 'geo_block';
        if ($code >= 400)  return 'client_4xx';
        return 'other';
    }
    private static function telemetry_error(string $endpoint, string $action, int $code, string $err_class, string $msg): void {
        if (!class_exists('Forge_Telemetry')) return;
        Forge_Telemetry::track_error('forge_api', [
            'endpoint'    => $endpoint,
            'action'      => $action,
            'status_code' => $code,
            'error_class' => $err_class,
            'message'     => $msg,
            // 2026-04-27 alert investigation: each plugin's class-forge-api.php
            // sends the call; tag with its own slug so worker aggregator can
            // attribute the error to the actual plugin that fired the request,
            // not the first-loaded plugin on a multi-plugin install.
            'plugin_slug' => self::plugin_slug_from_class(),
        ]);
    }

    /**
 * Derive the plugin slug from the calling class's prefix. Each plugin
 * has its own class-forge-api.php with a unique prefix:
 * LF_Forge_API → langforge
 * SEOFORGE_Forge_API → seoforge
 * FORMFORGE_Forge_API → formforge
 * FIELDFORGE_Forge_API → fieldforge
 * Use late-static binding so each plugin's class returns its own slug
 * even though the file ships across all 4 plugins.
 */
    private static function plugin_slug_from_class(): string {
        $cls = static::class;
        if (strpos($cls, 'SEOFORGE_')   === 0) return 'seoforge';
        if (strpos($cls, 'FORMFORGE_')  === 0) return 'formforge';
        if (strpos($cls, 'FIELDFORGE_') === 0) return 'fieldforge';
        if (strpos($cls, 'LF_')         === 0) return 'langforge';
        return '';
    }

    private function get_domain() {
        return parse_url(home_url(), PHP_URL_HOST);
    }

    public function get_license_key() {
        return $this->license_key;
    }

    public function set_license_key($key) {
        $this->license_key = $key;
    }
}
