<?php
/**
 * RemoteIntelligence — Fetches updated threat intelligence data from the
 * UltraGuard intelligence server and falls back to bundled local JSON files.
 *
 * Architecture
 * ─────────────
 * Every seeder (SignatureSeeder, FirewallSeeder) calls fetch() to obtain data.
 * fetch() tries the remote endpoint first; if the request fails for any reason
 * (no license key, network unreachable, server error, timeout) it transparently
 * returns the local bundled JSON so seeding always completes.
 *
 * Remote endpoint contract
 * ─────────────────────────
 * Base URL:  https://intelligence.ultraguard.net/api/v1/
 *
 * Endpoints:
 *   GET /malware-signatures?since={version}&site={site_url}
 *   GET /attack-patterns?since={version}&site={site_url}
 *   GET /firewall-rules?since={version}&site={site_url}
 *   GET /country-codes           (static, version-gated only)
 *
 * Authentication: X-UltraGuard-License: {license_key}
 *
 * Successful response (200):
 *   {
 *     "_meta":  { "version": "1.2.0", "updated": "2026-03-19", "source": "remote" },
 *     "signatures|patterns|rules|countries": [ … ]
 *   }
 *
 * No-change response (304): server returns 304 when the 'since' version matches
 * the latest available — fetch() returns null so the caller skips re-seeding.
 *
 * Error response (4xx/5xx / network error): fetch() logs and returns local data.
 *
 * Caching
 * ────────
 * Successful remote responses are cached in a WP transient for
 * CACHE_TTL seconds (default 6 hours) per data type to avoid hammering
 * the intelligence server on every admin page load.
 *
 * @package UltraguardPro\support
 * @since   6.5.1
 */

namespace UltraguardPro\support;

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

class RemoteIntelligence {

    /** Remote cache TTL in seconds (6 hours). */
    const CACHE_TTL = 21600;

    /** Retry backoff after a transport failure (5 minutes). */
    const FAILURE_BACKOFF_TTL = 300;

    /** In-flight request lock TTL in seconds. */
    const REQUEST_LOCK_TTL = 30;

    /** HTTP request timeout in seconds. */
    const TIMEOUT = 15;

    /** Supported data types and their local JSON file names plus backend mapping. */
    const DATA_TYPES = [
        'malware-signatures' => [
            'file'          => 'malware-signatures.json',
            'endpoint'      => '/intelligence/signatures',
            'payload_key'   => 'signatures',
            'compare_param' => 'current_version',
        ],
        'attack-patterns'    => [
            'file'          => 'attack-patterns.json',
            'endpoint'      => '/intelligence/attack-patterns',
            'payload_key'   => 'patterns',
            'compare_param' => 'current_version',
        ],
        'firewall-rules'     => [
            'file'          => 'firewall-rules.json',
            'endpoint'      => '/intelligence/firewall-rules',
            'payload_key'   => 'rules',
            'compare_param' => 'current_hash',
        ],
        'country-codes'      => [
            'file'          => 'country-codes.json',
            'endpoint'      => '/intelligence/country-codes',
            'payload_key'   => 'countries',
            'compare_param' => 'current_version',
        ],
    ];

    /** Transient key prefix for cached remote responses. */
    const TRANSIENT_PREFIX = 'ultraguard_remote_intel_';

    /** Transient key prefix for remote failure backoff. */
    const FAILURE_TRANSIENT_PREFIX = 'ultraguard_remote_intel_fail_';

    /** Transient key prefix for remote in-flight request locks. */
    const REQUEST_LOCK_TRANSIENT_PREFIX = 'ultraguard_remote_intel_lock_';

    /** WP option key storing the last remote fetch timestamp per type. */
    const LAST_FETCH_OPTION = 'ultraguard_remote_intel_last_fetch';

    /** Option key prefix storing the remote failure backoff timestamp per type. */
    const FAILURE_OPTION_PREFIX = 'ultraguard_remote_intel_fail_until_';

    /** Option key prefix storing in-flight request locks per type. */
    const REQUEST_LOCK_OPTION_PREFIX = 'ultraguard_remote_intel_lock_until_';

    /** In-request negative cache for failed remote types. */
    private static array $request_failure_map = [];

    /** In-request cache for in-flight locks. */
    private static array $request_lock_map = [];

    // ── Public API ─────────────────────────────────────────────────────────────

    /**
     * Fetch intelligence data for the given type.
     *
     * Resolution order:
     *   1. WP transient cache (avoids re-fetching within CACHE_TTL)
     *   2. Remote intelligence server (requires valid license key)
     *   3. Bundled local JSON file (always available, never fails)
     *
     * @param  string $type     One of the DATA_TYPES keys.
     * @param  string $since    Currently installed data version (for delta requests).
     * @param  bool   $force    When true, bypasses the transient cache.
     * @return array{
     *   data:    array,          // The parsed JSON payload
     *   source:  'remote'|'local'|'cache',
     *   version: string,
     *   error:   string|null
     * }
     */
    public function fetch( string $type, string $since = '', bool $force = false ): array {
        if ( ! isset( self::DATA_TYPES[ $type ] ) ) {
            return $this->error_result( "Unknown data type: {$type}" );
        }

        // ── 1. Transient cache ─────────────────────────────────────────────────
        if ( ! $force ) {
            $cached = $this->get_cache( $type );
            if ( $cached !== null ) {
                return [
                    'data'    => $cached['data'],
                    'source'  => 'cache',
                    'version' => $cached['_meta']['version'] ?? '',
                    'up_to_date' => ! empty( $cached['_meta']['up_to_date'] ),
                    'error'   => null,
                ];
            }
        }

        // ── 2. Remote server ───────────────────────────────────────────────────
        $remote = $this->fetch_remote( $type, $since );
        if ( $remote !== null ) {
            if ( ! empty( $remote['_meta']['up_to_date'] ) ) {
                if ( '' === $since ) {
                    return $this->read_local( $type );
                }

                return [
                    'data'       => $remote,
                    'source'     => 'remote',
                    'version'    => $remote['_meta']['version'] ?? $since,
                    'up_to_date' => true,
                    'error'      => null,
                ];
            }

            // Cache only concrete remote payloads so cache hits always carry usable data.
            $this->set_cache( $type, $remote );
            $this->record_fetch_timestamp( $type );

            return [
                'data'       => $remote,
                'source'     => 'remote',
                'version'    => $remote['_meta']['version'] ?? '',
                'up_to_date' => false,
                'error'      => null,
            ];
        }

        // ── 3. Local JSON fallback ─────────────────────────────────────────────
        return $this->read_local( $type );
    }

    /**
     * Fetch all data types in sequence, returning a map of type → result.
     * Uses fetch() per type so each benefits from its own cache slot.
     *
     * @param  string[] $types   Subset of DATA_TYPES keys. Defaults to all.
     * @param  bool     $force   Bypass transient cache.
     * @return array<string, array>  type → fetch() result
     */
    public function fetch_all( array $types = [], bool $force = false ): array {
        if ( empty( $types ) ) {
            $types = array_keys( self::DATA_TYPES );
        }

        $results = [];
        foreach ( $types as $type ) {
            $results[ $type ] = $this->fetch( $type, '', $force );
        }
        return $results;
    }

    /**
     * Check whether a newer version of the given data type is available
     * on the remote server without downloading the full payload.
     *
     * Uses a lightweight HEAD request; falls back to false on error.
     *
     * @param  string $type     Data type key.
     * @param  string $current  Currently installed version string.
     * @return bool
     */
    public function remote_update_available( string $type, string $current ): bool {
        $remote = $this->fetch_remote( $type, $current );
        if ( null === $remote ) {
            return false;
        }

        if ( ! empty( $remote['_meta']['up_to_date'] ) || ! empty( $remote['_meta']['delayed_for_tier'] ) ) {
            return false;
        }

        $remote_version = (string) ( $remote['_meta']['version'] ?? '' );
        if ( '' === $remote_version ) {
            return false;
        }

        if ( '' === $current ) {
            return true;
        }

        return version_compare( $remote_version, $current, '>' );
    }

    /**
     * Purge all cached remote responses, forcing fresh fetches on next run.
     *
     * @return void
     */
    public function purge_cache(): void {
        foreach ( array_keys( self::DATA_TYPES ) as $type ) {
            delete_transient( self::TRANSIENT_PREFIX . $type );
        }
    }

    /**
     * Return the timestamp of the last successful remote fetch for a type,
     * or 0 if never fetched.
     *
     * @param  string $type
     * @return int  Unix timestamp
     */
    public function last_fetch_time( string $type ): int {
        $map = (array) get_option( self::LAST_FETCH_OPTION, [] );
        return (int) ( $map[ $type ] ?? 0 );
    }

    /**
     * Return the bundled local dataset version for the given type.
     *
     * @param string $type
     * @return string
     */
    public function local_version( string $type ): string {
        $local = $this->read_local( $type );
        return (string) ( $local['version'] ?? '' );
    }

    // ── Private: HTTP ──────────────────────────────────────────────────────────

    /**
     * Make the authenticated GET request to the intelligence server.
     *
     * Returns the decoded payload array on success, or null on any error
     * (network failure, non-200 status, invalid JSON, 304 not-modified).
     *
     * @param  string $type
     * @param  string $since
     * @param  string $license_key
     * @return array|null
     */
    private function fetch_remote( string $type, string $since ): ?array {
        if ( $this->is_in_failure_backoff( $type ) ) {
            return null;
        }

        if ( ! $this->acquire_request_lock( $type ) ) {
            return null;
        }

        $config = self::DATA_TYPES[ $type ];
        $signing_key = $this->get_signing_key();
        $identity_key = $this->get_identity_key();
        $errors = [];

        try {
            foreach ( $this->endpoint_candidates( (string) $config['endpoint'] ) as $url ) {
                $result = SecureRemoteTransport::signed_request_json(
                    'GET',
                    add_query_arg(
                        array_filter(
                            [
                                (string) $config['compare_param'] => $since,
                            ],
                            static fn( $value ): bool => '' !== (string) $value
                        ),
                        $url
                    ),
                    (string) $config['endpoint'],
                    [],
                    $signing_key,
                    home_url(),
                    'UltraGuard-Security-Suite/' . ( defined( 'ULTRAGUARD_PRO_VERSION' ) ? ULTRAGUARD_PRO_VERSION : '0' ),
                    [
                        'timeout'   => self::TIMEOUT,
                        'sslverify' => $this->should_verify_ssl_for_url( $url ),
                    ],
                    $this->require_signed_responses(),
                    $identity_key
                );

                if ( empty( $result['success'] ) ) {
                    $errors[] = "{$url}: " . ( $result['message'] ?? 'unknown error' );
                    continue;
                }

                $body = is_array( $result['body'] ?? null ) ? $result['body'] : [];
                if ( empty( $body['success'] ) ) {
                    $errors[] = "{$url}: " . ( $body['message'] ?? 'unknown error' );
                    continue;
                }

                $this->clear_failure_backoff( $type );
                return $this->normalize_remote_payload( $type, $body, $since );
            }
        } finally {
            $this->release_request_lock( $type );
        }

        if ( ! empty( $errors ) ) {
            $this->mark_failure_backoff( $type );
            $this->log( "Remote fetch failed [{$type}]: " . implode( ' | ', $errors ) );
        }

        return null;
    }

    private function require_signed_responses(): bool {
        return (bool) apply_filters( 'ultraguard_pro/remote_intelligence/require_signed_response', false );
    }

    // ── Private: local JSON ────────────────────────────────────────────────────

    /**
     * Read and return the bundled local JSON file for the given type.
     *
     * @param  string $type
     * @return array  fetch()-shaped result array
     */
    private function read_local( string $type ): array {
        $file = (string) self::DATA_TYPES[ $type ]['file'];
        $path = $this->json_dir() . $file;

        if ( ! file_exists( $path ) ) {
            return $this->error_result( "Local data file not found: {$path}" );
        }

        $raw = file_get_contents( $path );
        if ( $raw === false ) {
            return $this->error_result( "Could not read local data file: {$path}" );
        }

        $data = json_decode( $raw, true );
        if ( json_last_error() !== JSON_ERROR_NONE ) {
            return $this->error_result( "Invalid JSON in {$file}: " . json_last_error_msg() );
        }

        if ( isset( $data['_meta'] ) ) {
            $data['_meta']['source'] = 'local';
        }

        return [
            'data'    => $data,
            'source'  => 'local',
            'version' => $data['_meta']['version'] ?? '',
            'up_to_date' => false,
            'error'   => null,
        ];
    }

    // ── Private: cache ─────────────────────────────────────────────────────────

    /**
     * Retrieve a cached remote response.
     *
     * @param  string $type
     * @return array|null  Parsed JSON payload or null on cache miss
     */
    private function get_cache( string $type ): ?array {
        $cached = get_transient( self::TRANSIENT_PREFIX . $type );
        return is_array( $cached ) ? $cached : null;
    }

    /**
     * Store a remote response payload in the WP transient cache.
     *
     * @param  string $type
     * @param  array  $data
     * @return void
     */
    private function set_cache( string $type, array $data ): void {
        set_transient( self::TRANSIENT_PREFIX . $type, $data, self::CACHE_TTL );
    }

    /**
     * Record the current time as the last fetch timestamp for this type.
     *
     * @param  string $type
     * @return void
     */
    private function record_fetch_timestamp( string $type ): void {
        $map          = (array) get_option( self::LAST_FETCH_OPTION, [] );
        $map[ $type ] = time();
        update_option( self::LAST_FETCH_OPTION, $map, false );
    }

    private function is_in_failure_backoff( string $type ): bool {
        $until = (int) ( self::$request_failure_map[ $type ] ?? 0 );
        if ( $until > time() ) {
            return true;
        }

        $transient_until = (int) get_transient( self::FAILURE_TRANSIENT_PREFIX . $type );
        if ( $transient_until > time() ) {
            self::$request_failure_map[ $type ] = $transient_until;
            return true;
        }

        $stored_until = (int) get_option( self::FAILURE_OPTION_PREFIX . $type, 0 );
        if ( $stored_until > time() ) {
            self::$request_failure_map[ $type ] = $stored_until;
            set_transient( self::FAILURE_TRANSIENT_PREFIX . $type, $stored_until, max( 1, $stored_until - time() ) );
            return true;
        }

        return false;
    }

    private function mark_failure_backoff( string $type ): void {
        $until = time() + self::FAILURE_BACKOFF_TTL;
        self::$request_failure_map[ $type ] = $until;
        set_transient( self::FAILURE_TRANSIENT_PREFIX . $type, $until, self::FAILURE_BACKOFF_TTL );
        update_option( self::FAILURE_OPTION_PREFIX . $type, $until, false );
    }

    private function clear_failure_backoff( string $type ): void {
        unset( self::$request_failure_map[ $type ] );
        delete_transient( self::FAILURE_TRANSIENT_PREFIX . $type );
        delete_option( self::FAILURE_OPTION_PREFIX . $type );
    }

    private function acquire_request_lock( string $type ): bool {
        $now   = time();
        $until = (int) ( self::$request_lock_map[ $type ] ?? 0 );

        if ( $until > $now ) {
            return false;
        }

        $transient_until = (int) get_transient( self::REQUEST_LOCK_TRANSIENT_PREFIX . $type );
        if ( $transient_until > $now ) {
            self::$request_lock_map[ $type ] = $transient_until;
            return false;
        }

        $option_key   = self::REQUEST_LOCK_OPTION_PREFIX . $type;
        $stored_until = (int) get_option( $option_key, 0 );
        if ( $stored_until > $now ) {
            self::$request_lock_map[ $type ] = $stored_until;
            set_transient( self::REQUEST_LOCK_TRANSIENT_PREFIX . $type, $stored_until, max( 1, $stored_until - $now ) );
            return false;
        }

        $lock_until = $now + self::REQUEST_LOCK_TTL;

        if ( ! add_option( $option_key, $lock_until, '', false ) ) {
            $stored_until = (int) get_option( $option_key, 0 );
            if ( $stored_until > $now ) {
                self::$request_lock_map[ $type ] = $stored_until;
                set_transient( self::REQUEST_LOCK_TRANSIENT_PREFIX . $type, $stored_until, max( 1, $stored_until - $now ) );
                return false;
            }

            update_option( $option_key, $lock_until, false );
        }

        self::$request_lock_map[ $type ] = $lock_until;
        set_transient( self::REQUEST_LOCK_TRANSIENT_PREFIX . $type, $lock_until, self::REQUEST_LOCK_TTL );

        return true;
    }

    private function release_request_lock( string $type ): void {
        unset( self::$request_lock_map[ $type ] );
        delete_transient( self::REQUEST_LOCK_TRANSIENT_PREFIX . $type );
        delete_option( self::REQUEST_LOCK_OPTION_PREFIX . $type );
    }

    // ── Private: helpers ───────────────────────────────────────────────────────

    /**
     * Retrieve the active license key from plugin settings.
     *
     * Returns an empty string when no key is configured, which causes
     * fetch() to skip the remote request and use local files.
     *
     * @return string
     */
    private function get_license_key(): string {
        return (string) get_option( LicenseInfo::OPTION_KEY, '' );
    }

    private function get_identity_key(): string {
        $license_key = $this->get_license_key();

        return '' !== $license_key ? $license_key : 'ultraguard-public';
    }

    private function get_signing_key(): string {
        $identity_key = $this->get_identity_key();
        $stored       = (array) get_option( LicenseInfo::OPTION_DATA, [] );
        $transport    = (string) ( $stored['transport_key'] ?? '' );
        $fingerprint  = (string) ( $stored['transport_identity_fingerprint'] ?? '' );

        if ( '' !== $transport && '' !== $identity_key && hash( 'sha256', $identity_key ) === $fingerprint ) {
            return $transport;
        }

        return $identity_key;
    }

    /**
     * @return string[]
     */
    private function endpoint_candidates( string $endpoint ): array {
        if ( class_exists( LicenseInfo::class ) ) {
            return LicenseInfo::endpointUrlCandidates( $endpoint );
        }

        $base = untrailingslashit( home_url() ) . '/wp-json/ultraguard/v2';

        return [
            $base . '/' . ltrim( $endpoint, '/' ),
        ];
    }

    private function should_verify_ssl_for_url( string $url ): bool {
        $host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) );

        if (
            '' === $host
            || 'localhost' === $host
            || '127.0.0.1' === $host
            || '::1' === $host
            || str_ends_with( $host, '.test' )
            || str_ends_with( $host, '.local' )
            || str_ends_with( $host, '.localhost' )
        ) {
            return false;
        }

        return true;
    }

    private function normalize_remote_payload( string $type, array $body, string $since ): array {
        $config      = self::DATA_TYPES[ $type ];
        $payload_key = (string) $config['payload_key'];
        $version     = (string) ( $body['version'] ?? $since );
        $up_to_date  = ! empty( $body['up_to_date'] );
        $delayed     = ! empty( $body['delayed_for_tier'] );

        if ( $up_to_date ) {
            return [
                $payload_key => [],
                '_meta'      => [
                    'version'          => $version,
                    'source'           => 'remote',
                    'up_to_date'       => true,
                    'delayed_for_tier' => $delayed,
                    'released_at'      => (string) ( $body['released_at'] ?? '' ),
                    'interval_minutes' => (int) ( $body['interval_minutes'] ?? 0 ),
                ],
            ];
        }

        return [
            $payload_key => $this->normalize_feed_entries(
                $type,
                is_array( $body[ $payload_key ] ?? null ) ? $body[ $payload_key ] : []
            ),
            '_meta'      => [
                'version'          => $version,
                'hash'             => (string) ( $body['hash'] ?? '' ),
                'released_at'      => (string) ( $body['released_at'] ?? '' ),
                'source'           => 'remote',
                'up_to_date'       => false,
                'delayed_for_tier' => $delayed,
                'interval_minutes' => (int) ( $body['interval_minutes'] ?? 0 ),
            ],
        ];
    }

    private function normalize_feed_entries( string $type, array $entries ): array {
        if ( 'attack-patterns' !== $type ) {
            return $entries;
        }

        return array_values(
            array_filter(
                array_map(
                    function ( $entry ): ?array {
                        if ( ! is_array( $entry ) ) {
                            return null;
                        }

                        $pattern_value = (string) ( $entry['pattern_value'] ?? $entry['pattern'] ?? '' );
                        $pattern_name  = (string) ( $entry['pattern_name'] ?? $entry['name'] ?? $pattern_value );

                        if ( '' === $pattern_name || '' === $pattern_value ) {
                            return null;
                        }

                        $pattern_type = sanitize_key( (string) ( $entry['pattern_type'] ?? '' ) );
                        if ( '' === $pattern_type ) {
                            $pattern_type = preg_match( '#^/.+/[a-zA-Z]*$#', $pattern_value ) ? 'regex' : 'string';
                        }

                        return [
                            'pattern_name'    => $pattern_name,
                            'pattern_type'    => $pattern_type,
                            'pattern_value'   => $pattern_value,
                            'attack_category' => sanitize_key( (string) ( $entry['attack_category'] ?? $entry['category'] ?? 'generic' ) ),
                            'severity'        => sanitize_key( (string) ( $entry['severity'] ?? 'medium' ) ),
                            'source'          => sanitize_key( (string) ( $entry['source'] ?? 'ultraguard' ) ),
                        ];
                    },
                    $entries
                )
            )
        );
    }

    /**
     * Absolute path to the bundled JSON assets directory.
     *
     * @return string  e.g. /var/www/html/wp-content/plugins/ultraguard-security-suite/assets/json/
     */
    private function json_dir(): string {
        $base = defined( 'ULTRAGUARD_PRO_PATH' )
            ? ULTRAGUARD_PRO_PATH
            : trailingslashit( dirname( __DIR__, 2 ) );
        return trailingslashit( $base . 'assets/json' );
    }

    /**
     * Write a debug-gated log entry.
     *
     * @param string $msg
     */
    private function log( string $msg ): void {
        if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
            \UltraguardPro\core\Logger::error( '[UltraGuard RemoteIntelligence] ' . $msg );
        }
    }

    /**
     * Build a standardised error result array.
     *
     * @param  string $message
     * @return array
     */
    private function error_result( string $message ): array {
        $this->log( $message );
        return [
            'data'    => [],
            'source'  => 'error',
            'version' => '',
            'up_to_date' => false,
            'error'   => $message,
        ];
    }
}
