<?php
/**
 * AI integration — bring-your-own API key.
 *
 * The user enters their personal Mistral AI key in the AI tab. Mistral is
 * EU-based, matching the suite's privacy-first / made-in-EU stance. The key is
 * encrypted at rest and sent only to Mistral's API. The provider layer is kept
 * pluggable (providers()/model_options()) so further EU providers can be added
 * without touching the callers.
 *
 * Settings shape (stored under option `seoforge_ai`):
 *   array(
 *     'enabled'  => bool,
 *     'provider' => 'mistral',
 *     'key_enc'  => string,
 *     'model'    => string,            // e.g. mistral-small-latest
 *     'monthly_call_cap' => int,       // soft client-side cap
 *     'calls_this_month' => int,
 *     'month_anchor'     => 'YYYY-MM',
 *     'last_test'        => string,
 *     'last_error'       => string,
 *   )
 *
 * @package Brainwerk SEO Suite
 */

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

class Seoforge_AI {

    const OPTION_KEY       = 'seoforge_ai';
    const ENDPOINT_MISTRAL = 'https://api.mistral.ai/v1/chat/completions';
    const CACHE_TTL        = WEEK_IN_SECONDS;

    /** Available AI providers (label shown in the AI-tab dropdown). Mistral
     * is EU-based, matching the plugin's privacy-first / made-in-EU stance. */
    public static function providers() {
        return array(
            'mistral' => __( 'Mistral AI — EU-based', 'brainwerk-seo-suite' ),
        );
    }

    /** Default model id for a provider. */
    public static function default_model( $provider = 'mistral' ) {
        return 'mistral-small-latest';
    }

    /**
     * Curated model list for a provider. Defaults to the saved provider when
     * none is given. Model ids use the provider's stable "-latest" aliases.
     */
    public static function model_options( $provider = null ) {
        return array(
            'mistral-small-latest'  => __( 'Mistral Small — fast & cheap (recommended)', 'brainwerk-seo-suite' ),
            'mistral-medium-latest' => __( 'Mistral Medium — balanced', 'brainwerk-seo-suite' ),
            'mistral-large-latest'  => __( 'Mistral Large — best quality', 'brainwerk-seo-suite' ),
            'ministral-8b-latest'   => __( 'Ministral 8B — lightweight & cheapest', 'brainwerk-seo-suite' ),
        );
    }

    public static function get() {
        $defaults = array(
            'enabled'           => false,
            'provider'          => 'mistral',
            'key_enc'           => '',
            'model'             => 'mistral-small-latest',
            'monthly_call_cap'  => 200,
            'calls_this_month'  => 0,
            'month_anchor'      => gmdate( 'Y-m' ),
            'last_test'         => '',
            'last_error'        => '',
        );
        $existing = is_multisite()
            ? get_site_option( self::OPTION_KEY, array() )
            : get_option( self::OPTION_KEY, array() );
        $state = array_replace( $defaults, is_array( $existing ) ? $existing : array() );
        // Coerce any retired provider (e.g. a legacy 'anthropic' config) back to
        // a supported one so callers can rely on a valid provider + model.
        if ( ! array_key_exists( $state['provider'], self::providers() ) ) {
            $state['provider'] = 'mistral';
            if ( ! array_key_exists( $state['model'], self::model_options( 'mistral' ) ) ) {
                $state['model'] = self::default_model( 'mistral' );
            }
        }
        return $state;
    }

    private static function set( array $patch ) {
        $next = array_replace( self::get(), $patch );
        if ( is_multisite() ) {
            update_site_option( self::OPTION_KEY, $next );
        } else {
            update_option( self::OPTION_KEY, $next );
        }
        return $next;
    }

    public static function set_key( $plain_key ) {
        $plain_key = trim( (string) $plain_key );
        if ( '' === $plain_key ) {
            return self::set( array( 'key_enc' => '', 'enabled' => false ) );
        }
        return self::set( array(
            'key_enc' => Seoforge_Crypto::encrypt( $plain_key ),
            'enabled' => true,
        ) );
    }

    public static function clear_key() {
        return self::set( array( 'key_enc' => '', 'enabled' => false ) );
    }

    public static function get_key() {
        $s = self::get();
        if ( '' === $s['key_enc'] ) {
            return '';
        }
        $plain = Seoforge_Crypto::decrypt( $s['key_enc'] );
        // decrypt() returns false when a stored key can't be decrypted (e.g.
        // after a WP salt rotation). Smooth it to '' so the string-based callers
        // below stay type-safe; key_decrypt_failed() surfaces the failure state.
        return is_string( $plain ) ? $plain : '';
    }

    /**
     * True when a key IS stored but cannot be decrypted — e.g. the WordPress
     * salts were rotated, or a DB dump was restored without the matching
     * wp-config.php. Distinct from "no key configured" (both make get_key()
     * return ''). Used to show a re-enter notice on the AI settings screen.
     */
    public static function key_decrypt_failed() {
        $s = self::get();
        return '' !== $s['key_enc'] && false === Seoforge_Crypto::decrypt( $s['key_enc'] );
    }

    public static function is_configured() {
        $s = self::get();
        return $s['enabled'] && '' !== self::get_key();
    }

    /** Returns true once the user has hit the soft monthly call cap. */
    public static function is_capped() {
        $s = self::get();
        if ( gmdate( 'Y-m' ) !== $s['month_anchor'] ) {
            self::set( array( 'month_anchor' => gmdate( 'Y-m' ), 'calls_this_month' => 0 ) );
            return false;
        }
        return $s['monthly_call_cap'] > 0 && $s['calls_this_month'] >= $s['monthly_call_cap'];
    }

    private static function bump_counter() {
        $s = self::get();
        if ( gmdate( 'Y-m' ) !== $s['month_anchor'] ) {
            $s['month_anchor']     = gmdate( 'Y-m' );
            $s['calls_this_month'] = 0;
        }
        $s['calls_this_month']++;
        self::set( array(
            'month_anchor'     => $s['month_anchor'],
            'calls_this_month' => $s['calls_this_month'],
        ) );
    }

    /**
     * Low-level call to the Mistral chat-completions API.
     *
     * @param string $system     The system prompt (sent as the first message).
     * @param string $user       The user message.
     * @param array  $opts       max_tokens, temperature, json (=true to force a JSON object response).
     * @param string $cache_key  Optional transient cache key.
     * @return array|WP_Error    Decoded JSON content as array, OR raw text under 'text'.
     */
    public static function complete( $system, $user, array $opts = array(), $cache_key = '' ) {
        if ( ! self::is_configured() ) {
            return new WP_Error( 'no_key', __( 'No AI API key configured.', 'brainwerk-seo-suite' ) );
        }
        if ( self::is_capped() ) {
            return new WP_Error( 'capped', __( 'Monthly AI call cap reached. Raise it in the AI tab.', 'brainwerk-seo-suite' ) );
        }
        if ( $cache_key ) {
            $cached = get_transient( 'seoforge_ai_' . $cache_key );
            if ( false !== $cached ) {
                return $cached;
            }
        }

        $s     = self::get();
        $key   = self::get_key();
        $model = isset( $opts['model'] ) ? (string) $opts['model'] : $s['model'];
        $max   = isset( $opts['max_tokens'] ) ? (int) $opts['max_tokens'] : 1024;
        $temp  = isset( $opts['temperature'] ) ? (float) $opts['temperature'] : null;

        // Mistral speaks the OpenAI-style chat-completions schema: the system
        // prompt is the first message, auth is a Bearer token, JSON mode is a
        // first-class response_format flag.
        $messages = array();
        if ( '' !== (string) $system ) {
            $messages[] = array( 'role' => 'system', 'content' => (string) $system );
        }
        $messages[] = array( 'role' => 'user', 'content' => (string) $user );
        $body = array(
            'model'      => $model,
            'max_tokens' => $max,
            'messages'   => $messages,
        );
        if ( null !== $temp ) {
            $body['temperature'] = $temp;
        }
        if ( ! empty( $opts['json'] ) ) {
            $body['response_format'] = array( 'type' => 'json_object' );
        }
        $endpoint = self::ENDPOINT_MISTRAL;
        $headers  = array(
            'Authorization' => 'Bearer ' . $key,
            'Content-Type'  => 'application/json',
        );

        $r = wp_remote_post( $endpoint, array(
            'timeout' => 30,
            'headers' => $headers,
            'body'    => wp_json_encode( $body ),
        ) );
        if ( is_wp_error( $r ) ) {
            self::set( array( 'last_error' => $r->get_error_message() ) );
            return $r;
        }
        $code = (int) wp_remote_retrieve_response_code( $r );
        $raw  = wp_remote_retrieve_body( $r );
        $resp = json_decode( $raw, true );
        if ( $code < 200 || $code >= 300 || ! is_array( $resp ) ) {
            $msg = self::extract_error( $resp, $code );
            self::set( array( 'last_error' => $msg ) );
            return new WP_Error( 'ai_http', $msg );
        }
        self::bump_counter();
        self::set( array( 'last_error' => '' ) );

        $text = '';
        if ( isset( $resp['choices'][0]['message']['content'] ) ) {
            $text = (string) $resp['choices'][0]['message']['content'];
        }
        $out = array( 'text' => $text, 'raw' => $resp );
        if ( ! empty( $opts['json'] ) ) {
            $parsed = self::parse_json_response( $text );
            if ( null !== $parsed ) {
                $out['data'] = $parsed;
            }
        }
        if ( $cache_key ) {
            set_transient( 'seoforge_ai_' . $cache_key, $out, self::CACHE_TTL );
        }
        return $out;
    }

    /**
     * Parse a JSON object/array out of an LLM response. Tolerates ```json fences.
     */
    public static function parse_json_response( $text ) {
        $text = trim( (string) $text );
        if ( '' === $text ) { return null; }
        // Strip markdown fences: ```json ... ```
        $text = preg_replace( '/^```(?:json)?\s*|\s*```$/m', '', $text );
        $first = strpos( $text, '{' );
        $last  = strrpos( $text, '}' );
        if ( false === $first || false === $last || $last < $first ) {
            $first = strpos( $text, '[' );
            $last  = strrpos( $text, ']' );
            if ( false === $first || false === $last || $last < $first ) {
                return null;
            }
        }
        $candidate = substr( $text, $first, $last - $first + 1 );
        $decoded   = json_decode( $candidate, true );
        return null === $decoded ? null : $decoded;
    }

    /** Best-effort error message across Mistral's response shapes. */
    private static function extract_error( $resp, $code ) {
        if ( is_array( $resp ) ) {
            if ( ! empty( $resp['error']['message'] ) ) {
                return (string) $resp['error']['message'];
            }
            if ( ! empty( $resp['message'] ) && is_string( $resp['message'] ) ) {
                return (string) $resp['message'];
            }
            if ( ! empty( $resp['detail'] ) ) {
                return is_string( $resp['detail'] ) ? $resp['detail'] : wp_json_encode( $resp['detail'] );
            }
        }
        /* translators: %d HTTP status code */
        return sprintf( __( 'Mistral returned HTTP %d', 'brainwerk-seo-suite' ), $code );
    }

    /**
     * Connection test — minimal call to verify the key + model.
     */
    public static function test_connection() {
        if ( ! self::is_configured() ) {
            return new WP_Error( 'no_key', __( 'Please save an API key first.', 'brainwerk-seo-suite' ) );
        }
        $r = self::complete(
            'You are a one-word echo bot.',
            'Reply with the single word: ok',
            array( 'max_tokens' => 16 )
        );
        if ( is_wp_error( $r ) ) {
            return $r;
        }
        self::set( array( 'last_test' => current_time( 'mysql', 1 ) ) );
        return $r;
    }
}
