<?php
/**
 * External KB Keys manager for BoostBot.
 *
 * Manages API keys that grant external consumers (e.g. BoostCalendar's
 * WhatsApp bot) scoped, rate-limited access to the site's Knowledge Base
 * via the /kb/* REST endpoints.
 *
 * Security model:
 * - The raw key ("bbkb_" + 40 random chars) is returned ONCE on creation.
 * - Only a SHA-256 hash is persisted (option: boostbot_external_kb_keys).
 * - Every request is authenticated against the stored hash (timing-safe),
 *   checked for enabled state, per-endpoint flags, optional IP allowlist,
 *   and a per-key per-minute rate limit (transients).
 *
 * @package BoostBot
 * @since 2.3.0
 */

// Prevent direct access to the file.
if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

/**
 * BoostBot_External_KB_Keys class.
 *
 * Static manager for external KB API keys.
 *
 * @since 2.3.0
 */
class BoostBot_External_KB_Keys {

    /**
     * Option name where key records are stored.
     *
     * @since 2.3.0
     * @var string
     */
    const OPTION_NAME = 'boostbot_external_kb_keys';

    /**
     * Raw key prefix.
     *
     * @since 2.3.0
     * @var string
     */
    const KEY_PREFIX = 'bbkb_';

    /**
     * Length of the random part of the raw key.
     *
     * @since 2.3.0
     * @var int
     */
    const KEY_RANDOM_LENGTH = 40;

    /**
     * Request header carrying the raw key.
     *
     * @since 2.3.0
     * @var string
     */
    const AUTH_HEADER = 'X-BoostBot-KB-Key';

    /**
     * Minimum interval (seconds) between last_used_at option writes,
     * to avoid option churn on busy keys.
     *
     * @since 2.3.0
     * @var int
     */
    const LAST_USED_THROTTLE = 60;

    /**
     * Hard ceiling for per-key top_k.
     *
     * @since 2.3.0
     * @var int
     */
    const MAX_TOP_K_CEILING = 20;

    /**
     * Hard ceiling for per-key rate limit per minute.
     *
     * @since 2.3.0
     * @var int
     */
    const RATE_LIMIT_CEILING = 600;

    // =========================================================================
    // CRUD
    // =========================================================================

    /**
     * Get all key records (including key_hash — internal use only).
     *
     * @since 2.3.0
     * @return array List of key records.
     */
    public static function get_all() {
        $keys = get_option( self::OPTION_NAME, array() );
        return is_array( $keys ) ? array_values( $keys ) : array();
    }

    /**
     * Get all key records prepared for external output (no hashes).
     *
     * @since 2.3.0
     * @return array List of sanitized key records.
     */
    public static function get_all_public() {
        return array_map( array( __CLASS__, 'to_public' ), self::get_all() );
    }

    /**
     * Get a single key record by ID.
     *
     * @since 2.3.0
     * @param string $id The key ID (kbk_xxx).
     * @return array|null The record or null.
     */
    public static function get( $id ) {
        $id = sanitize_text_field( (string) $id );
        foreach ( self::get_all() as $record ) {
            if ( isset( $record['id'] ) && $record['id'] === $id ) {
                return $record;
            }
        }
        return null;
    }

    /**
     * Create a new key.
     *
     * @since 2.3.0
     * @param array $args {
     *     Key specification.
     *
     *     @type string $label              Human-readable label. Required.
     *     @type array  $allowed_consumers  Consumer identifiers (informational).
     *     @type array  $ip_allowlist       Allowed IPs / IPv4 CIDRs. Empty = any.
     *     @type array  $scopes             Allowed post types for export/retrieve.
     *     @type int    $max_top_k          Max chunks per retrieve call.
     *     @type int    $rate_limit_per_min Requests per minute for this key.
     *     @type bool   $export_enabled     Whether /kb/export is allowed.
     *     @type bool   $retrieve_enabled   Whether /kb/retrieve is allowed.
     * }
     * @return array|WP_Error { 'record' => array (public), 'raw_key' => string } or error.
     */
    public static function create( array $args ) {
        $label = isset( $args['label'] ) ? sanitize_text_field( (string) $args['label'] ) : '';
        if ( '' === $label ) {
            return new WP_Error( 'boostbot_kb_key_invalid', __( 'Key label is required', 'boostbot' ), array( 'status' => 400 ) );
        }

        $raw_key = self::generate_raw_key();

        $record = array(
            'id'                 => 'kbk_' . self::random_hex( 12 ),
            'label'              => $label,
            'key_hash'           => 'sha256:' . hash( 'sha256', $raw_key ),
            'allowed_consumers'  => self::sanitize_string_list( $args['allowed_consumers'] ?? array() ),
            'ip_allowlist'       => self::sanitize_ip_list( $args['ip_allowlist'] ?? array() ),
            'scopes'             => self::sanitize_scopes( $args['scopes'] ?? array( 'post', 'page', 'product' ) ),
            'max_top_k'          => self::clamp_int( $args['max_top_k'] ?? 8, 1, self::MAX_TOP_K_CEILING ),
            'rate_limit_per_min' => self::clamp_int( $args['rate_limit_per_min'] ?? 30, 1, self::RATE_LIMIT_CEILING ),
            'export_enabled'     => (bool) ( $args['export_enabled'] ?? true ),
            'retrieve_enabled'   => (bool) ( $args['retrieve_enabled'] ?? true ),
            'enabled'            => true,
            'created_at'         => gmdate( 'c' ),
            'last_used_at'       => null,
        );

        $keys   = self::get_all();
        $keys[] = $record;
        update_option( self::OPTION_NAME, $keys, false );

        BoostBot_Logger::info( sprintf( '[External KB] Key created: %s (%s)', $record['id'], $record['label'] ) );

        return array(
            'record'  => self::to_public( $record ),
            'raw_key' => $raw_key,
        );
    }

    /**
     * Update an existing key's specification.
     *
     * The raw key / hash can never be changed — revoke and create a new key instead.
     *
     * @since 2.3.0
     * @param string $id   The key ID.
     * @param array  $args Fields to update (subset of create() args + 'enabled').
     * @return array|WP_Error The updated public record or error.
     */
    public static function update( $id, array $args ) {
        $id      = sanitize_text_field( (string) $id );
        $keys    = self::get_all();
        $updated = null;

        foreach ( $keys as $i => $record ) {
            if ( ! isset( $record['id'] ) || $record['id'] !== $id ) {
                continue;
            }

            if ( array_key_exists( 'label', $args ) ) {
                $label = sanitize_text_field( (string) $args['label'] );
                if ( '' !== $label ) {
                    $record['label'] = $label;
                }
            }
            if ( array_key_exists( 'allowed_consumers', $args ) ) {
                $record['allowed_consumers'] = self::sanitize_string_list( $args['allowed_consumers'] );
            }
            if ( array_key_exists( 'ip_allowlist', $args ) ) {
                $record['ip_allowlist'] = self::sanitize_ip_list( $args['ip_allowlist'] );
            }
            if ( array_key_exists( 'scopes', $args ) ) {
                $record['scopes'] = self::sanitize_scopes( $args['scopes'] );
            }
            if ( array_key_exists( 'max_top_k', $args ) ) {
                $record['max_top_k'] = self::clamp_int( $args['max_top_k'], 1, self::MAX_TOP_K_CEILING );
            }
            if ( array_key_exists( 'rate_limit_per_min', $args ) ) {
                $record['rate_limit_per_min'] = self::clamp_int( $args['rate_limit_per_min'], 1, self::RATE_LIMIT_CEILING );
            }
            if ( array_key_exists( 'export_enabled', $args ) ) {
                $record['export_enabled'] = (bool) $args['export_enabled'];
            }
            if ( array_key_exists( 'retrieve_enabled', $args ) ) {
                $record['retrieve_enabled'] = (bool) $args['retrieve_enabled'];
            }
            if ( array_key_exists( 'enabled', $args ) ) {
                $record['enabled'] = (bool) $args['enabled'];
            }

            $keys[ $i ] = $record;
            $updated    = $record;
            break;
        }

        if ( null === $updated ) {
            return new WP_Error( 'boostbot_kb_key_not_found', __( 'Key not found', 'boostbot' ), array( 'status' => 404 ) );
        }

        update_option( self::OPTION_NAME, $keys, false );
        BoostBot_Logger::info( sprintf( '[External KB] Key updated: %s', $id ) );

        return self::to_public( $updated );
    }

    /**
     * Delete a key.
     *
     * @since 2.3.0
     * @param string $id The key ID.
     * @return bool True if deleted, false if not found.
     */
    public static function delete( $id ) {
        $id    = sanitize_text_field( (string) $id );
        $keys  = self::get_all();
        $found = false;

        foreach ( $keys as $i => $record ) {
            if ( isset( $record['id'] ) && $record['id'] === $id ) {
                unset( $keys[ $i ] );
                $found = true;
                break;
            }
        }

        if ( ! $found ) {
            return false;
        }

        update_option( self::OPTION_NAME, array_values( $keys ), false );
        BoostBot_Logger::info( sprintf( '[External KB] Key deleted: %s', $id ) );

        return true;
    }

    // =========================================================================
    // Authentication
    // =========================================================================

    /**
     * Authenticate a REST request against the stored keys.
     *
     * Performs, in order: header presence, hash lookup (timing-safe),
     * enabled check, per-endpoint flag check, optional IP allowlist check,
     * and per-key per-minute rate limiting. Auth failures are logged.
     *
     * @since 2.3.0
     * @param WP_REST_Request $request  The REST request.
     * @param string          $endpoint One of 'status', 'export', 'retrieve'.
     * @return array|WP_Error The matching key record on success, WP_Error otherwise.
     */
    public static function authenticate( $request, $endpoint ) {
        $raw_key = (string) $request->get_header( self::AUTH_HEADER );

        if ( '' === $raw_key ) {
            self::log_auth_failure( $endpoint, 'missing key header' );
            return new WP_Error( 'boostbot_kb_unauthorized', __( 'Missing API key', 'boostbot' ), array( 'status' => 401 ) );
        }

        $incoming_hash = 'sha256:' . hash( 'sha256', $raw_key );
        $match         = null;

        foreach ( self::get_all() as $record ) {
            if ( isset( $record['key_hash'] ) && hash_equals( (string) $record['key_hash'], $incoming_hash ) ) {
                $match = $record;
                break;
            }
        }

        if ( null === $match ) {
            self::log_auth_failure( $endpoint, 'unknown key' );
            return new WP_Error( 'boostbot_kb_unauthorized', __( 'Invalid API key', 'boostbot' ), array( 'status' => 401 ) );
        }

        // Disabled key = immediate block (checked on every call, no caching).
        if ( empty( $match['enabled'] ) ) {
            self::log_auth_failure( $endpoint, 'disabled key ' . $match['id'] );
            return new WP_Error( 'boostbot_kb_forbidden', __( 'API key is disabled', 'boostbot' ), array( 'status' => 403 ) );
        }

        // Per-endpoint capability flags.
        if ( 'export' === $endpoint && empty( $match['export_enabled'] ) ) {
            self::log_auth_failure( $endpoint, 'export disabled for key ' . $match['id'] );
            return new WP_Error( 'boostbot_kb_forbidden', __( 'Export is not enabled for this key', 'boostbot' ), array( 'status' => 403 ) );
        }
        if ( 'retrieve' === $endpoint && empty( $match['retrieve_enabled'] ) ) {
            self::log_auth_failure( $endpoint, 'retrieve disabled for key ' . $match['id'] );
            return new WP_Error( 'boostbot_kb_forbidden', __( 'Retrieve is not enabled for this key', 'boostbot' ), array( 'status' => 403 ) );
        }

        // Optional IP allowlist.
        if ( ! empty( $match['ip_allowlist'] ) && is_array( $match['ip_allowlist'] ) ) {
            $client_ip = self::get_client_ip();
            if ( ! self::ip_in_allowlist( $client_ip, $match['ip_allowlist'] ) ) {
                self::log_auth_failure( $endpoint, sprintf( 'IP %s not in allowlist for key %s', $client_ip, $match['id'] ) );
                return new WP_Error( 'boostbot_kb_forbidden', __( 'Request origin not allowed', 'boostbot' ), array( 'status' => 403 ) );
            }
        }

        // Per-key rate limit (transient bucket per minute).
        $rate_check = self::check_rate_limit( $match );
        if ( is_wp_error( $rate_check ) ) {
            return $rate_check;
        }

        self::touch_last_used( $match['id'] );

        return $match;
    }

    /**
     * Check and increment the per-key per-minute rate limit.
     *
     * @since 2.3.0
     * @param array $record The key record.
     * @return true|WP_Error True if allowed, WP_Error(429) if limited.
     */
    private static function check_rate_limit( array $record ) {
        $limit = isset( $record['rate_limit_per_min'] ) ? (int) $record['rate_limit_per_min'] : 30;
        if ( $limit <= 0 ) {
            $limit = 30;
        }

        $bucket    = 'boostbot_kbk_rl_' . md5( $record['id'] . '_' . (string) floor( time() / MINUTE_IN_SECONDS ) );
        $count     = (int) get_transient( $bucket );

        if ( $count >= $limit ) {
            BoostBot_Logger::warning( sprintf( '[External KB] Rate limit hit for key %s (%d/min)', $record['id'], $limit ) );
            return new WP_Error(
                'boostbot_kb_rate_limited',
                __( 'Too many requests. Please slow down.', 'boostbot' ),
                array( 'status' => 429 )
            );
        }

        set_transient( $bucket, $count + 1, 2 * MINUTE_IN_SECONDS );

        return true;
    }

    /**
     * Update the key's last_used_at, throttled to once per minute
     * to avoid rewriting the option on every request.
     *
     * @since 2.3.0
     * @param string $id The key ID.
     */
    private static function touch_last_used( $id ) {
        $keys = self::get_all();

        foreach ( $keys as $i => $record ) {
            if ( ! isset( $record['id'] ) || $record['id'] !== $id ) {
                continue;
            }

            $last = isset( $record['last_used_at'] ) ? strtotime( (string) $record['last_used_at'] ) : 0;
            if ( $last && ( time() - $last ) < self::LAST_USED_THROTTLE ) {
                return; // Updated recently — skip to avoid option churn.
            }

            $keys[ $i ]['last_used_at'] = gmdate( 'c' );
            update_option( self::OPTION_NAME, $keys, false );
            return;
        }
    }

    // =========================================================================
    // Helpers
    // =========================================================================

    /**
     * Prepare a record for external output — never expose the hash.
     *
     * @since 2.3.0
     * @param array $record The internal record.
     * @return array The public record.
     */
    public static function to_public( array $record ) {
        unset( $record['key_hash'] );
        return $record;
    }

    /**
     * Generate a new raw key: "bbkb_" + 40 random alphanumeric chars.
     *
     * @since 2.3.0
     * @return string The raw key.
     */
    private static function generate_raw_key() {
        $alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
        $max      = strlen( $alphabet ) - 1;
        $random   = '';
        for ( $i = 0; $i < self::KEY_RANDOM_LENGTH; $i++ ) {
            $random .= $alphabet[ random_int( 0, $max ) ];
        }
        return self::KEY_PREFIX . $random;
    }

    /**
     * Generate a random lowercase hex string.
     *
     * @since 2.3.0
     * @param int $length Number of hex chars (must be even-friendly).
     * @return string Random hex.
     */
    private static function random_hex( $length ) {
        return substr( bin2hex( random_bytes( (int) ceil( $length / 2 ) ) ), 0, $length );
    }

    /**
     * Sanitize a flat list of short strings.
     *
     * @since 2.3.0
     * @param mixed $list Raw input.
     * @return array Clean list.
     */
    private static function sanitize_string_list( $list ) {
        if ( ! is_array( $list ) ) {
            return array();
        }
        $clean = array();
        foreach ( $list as $item ) {
            if ( ! is_string( $item ) ) {
                continue;
            }
            $item = sanitize_text_field( $item );
            if ( '' !== $item && ! in_array( $item, $clean, true ) ) {
                $clean[] = $item;
            }
        }
        return array_slice( $clean, 0, 20 );
    }

    /**
     * Sanitize scopes — restrict to safe post-type-like keys.
     *
     * @since 2.3.0
     * @param mixed $scopes Raw input.
     * @return array Clean scopes list.
     */
    private static function sanitize_scopes( $scopes ) {
        if ( ! is_array( $scopes ) ) {
            return array();
        }
        $clean = array();
        foreach ( $scopes as $scope ) {
            if ( ! is_string( $scope ) ) {
                continue;
            }
            $scope = sanitize_key( $scope );
            if ( '' !== $scope && ! in_array( $scope, $clean, true ) ) {
                $clean[] = $scope;
            }
        }
        return array_slice( $clean, 0, 20 );
    }

    /**
     * Sanitize an IP allowlist (exact IPs and IPv4 CIDR ranges).
     *
     * @since 2.3.0
     * @param mixed $list Raw input.
     * @return array Clean allowlist.
     */
    private static function sanitize_ip_list( $list ) {
        if ( ! is_array( $list ) ) {
            return array();
        }
        $clean = array();
        foreach ( $list as $entry ) {
            if ( ! is_string( $entry ) ) {
                continue;
            }
            $entry = trim( sanitize_text_field( $entry ) );
            if ( '' === $entry ) {
                continue;
            }
            // Accept exact IPv4/IPv6 or IPv4 CIDR (e.g. 76.76.21.0/24).
            $is_valid = false;
            if ( filter_var( $entry, FILTER_VALIDATE_IP ) ) {
                $is_valid = true;
            } elseif ( preg_match( '/^(\d{1,3}(?:\.\d{1,3}){3})\/(\d{1,2})$/', $entry, $m ) ) {
                $is_valid = filter_var( $m[1], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) && (int) $m[2] <= 32;
            }
            if ( $is_valid && ! in_array( $entry, $clean, true ) ) {
                $clean[] = $entry;
            }
        }
        return array_slice( $clean, 0, 20 );
    }

    /**
     * Clamp an integer to a range.
     *
     * @since 2.3.0
     * @param mixed $value Raw value.
     * @param int   $min   Minimum.
     * @param int   $max   Maximum.
     * @return int Clamped value.
     */
    private static function clamp_int( $value, $min, $max ) {
        return max( $min, min( $max, absint( $value ) ) );
    }

    /**
     * Get the client IP (REMOTE_ADDR only — cannot be spoofed).
     *
     * @since 2.3.0
     * @return string The client IP or ''.
     */
    private static function get_client_ip() {
        if ( isset( $_SERVER['REMOTE_ADDR'] ) ) {
            return sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) );
        }
        return '';
    }

    /**
     * Check whether an IP matches an allowlist of IPs / IPv4 CIDRs.
     *
     * @since 2.3.0
     * @param string $ip        Client IP.
     * @param array  $allowlist Allowlist entries.
     * @return bool True if allowed.
     */
    private static function ip_in_allowlist( $ip, array $allowlist ) {
        if ( '' === $ip ) {
            return false;
        }
        foreach ( $allowlist as $entry ) {
            if ( ! is_string( $entry ) || '' === $entry ) {
                continue;
            }
            if ( $entry === $ip ) {
                return true;
            }
            // IPv4 CIDR support.
            if ( strpos( $entry, '/' ) !== false && filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
                list( $subnet, $bits ) = explode( '/', $entry, 2 );
                $bits = (int) $bits;
                if ( $bits >= 0 && $bits <= 32 && filter_var( $subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
                    $ip_long     = ip2long( $ip );
                    $subnet_long = ip2long( $subnet );
                    $mask        = $bits === 0 ? 0 : ( -1 << ( 32 - $bits ) );
                    if ( ( $ip_long & $mask ) === ( $subnet_long & $mask ) ) {
                        return true;
                    }
                }
            }
        }
        return false;
    }

    /**
     * Log an authentication failure via BoostBot_Logger.
     *
     * @since 2.3.0
     * @param string $endpoint The endpoint being accessed.
     * @param string $reason   Failure reason.
     */
    private static function log_auth_failure( $endpoint, $reason ) {
        if ( class_exists( 'BoostBot_Logger' ) ) {
            BoostBot_Logger::warning( sprintf(
                '[External KB] Auth failure on /kb/%s: %s (IP: %s)',
                $endpoint,
                $reason,
                self::get_client_ip()
            ) );
        }
    }
}
