<?php
/**
 * Sensor Transport — Pfad B (Threat-Intelligence Network), Phase 1.
 *
 * The only component that talks to the central API
 * (https://shieldforge-intel.brainwerk.at). Three jobs (RFC §3.1 Transport.php):
 *
 *   1. register()    — one-time: exchange the bootstrap register-token for a
 *                      pseudonymous site_id + site_secret (stored via Privacy).
 *   2. fetch_salt()   — pull the current network epoch key (/v1/salt/current)
 *                      so this sensor's g1$ indicators match every other site's.
 *   3. flush()        — sign + POST a batch of queued SensorEvents to /v1/events,
 *                      then drop the sent items from the local Batcher queue.
 *
 * All requests are HMAC-signed: signature = hex(HMAC-SHA256(secret,
 * "<timestamp>\n<raw_body>")), mirroring the server's ingestion.auth. Nothing
 * is sent unless the admin opted in (sensor_enabled) AND the site is registered.
 *
 * @package Brainwerk Security Suite
 */

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

class Shieldforge_Sensor_Transport {

    const DEFAULT_API_BASE = 'https://shieldforge-intel.brainwerk.at';
    const BACKOFF_OPTION   = 'shieldforge_sensor_backoff_until'; // unix ts
    const SALT_TTL         = 6 * HOUR_IN_SECONDS;
    const FLUSH_BATCH      = 200;

    /* ------------------------------------------------------------------
     * config
     * ------------------------------------------------------------------ */

    public static function api_base() {
        $base = (string) Shieldforge_Settings::get( 'sensor_api_base', self::DEFAULT_API_BASE );
        $base = trim( $base );
        return '' !== $base ? untrailingslashit( $base ) : self::DEFAULT_API_BASE;
    }

    private static function register_token() {
        return (string) Shieldforge_Settings::get( 'sensor_register_token', '' );
    }

    private static function sign( $secret, $ts, $body ) {
        return hash_hmac( 'sha256', $ts . "\n" . $body, $secret );
    }

    /* ------------------------------------------------------------------
     * 1. registration
     * ------------------------------------------------------------------ */

    /**
     * Register this site with the central API. Idempotent: returns early if
     * already registered. Returns array( ok => bool, message => string ).
     */
    public static function register( $force = false ) {
        if ( ! $force && Shieldforge_Sensor_Privacy::is_registered() ) {
            return array( 'ok' => true, 'message' => 'already_registered' );
        }
        $token = self::register_token();
        if ( '' === $token ) {
            return array( 'ok' => false, 'message' => 'missing_register_token' );
        }

        $resp = wp_remote_post(
            self::api_base() . '/v1/sites/register',
            array(
                'timeout' => 15,
                'headers' => array(
                    'Content-Type'        => 'application/json',
                    'X-SF-Register-Token' => $token,
                ),
                'body'    => wp_json_encode( array( 'label' => home_url() ) ),
            )
        );
        if ( is_wp_error( $resp ) ) {
            return array( 'ok' => false, 'message' => $resp->get_error_message() );
        }
        $code = wp_remote_retrieve_response_code( $resp );
        $data = json_decode( wp_remote_retrieve_body( $resp ), true );
        if ( 200 !== (int) $code || empty( $data['site_id'] ) || empty( $data['site_secret'] ) ) {
            $err = is_array( $data ) && isset( $data['error'] ) ? $data['error'] : ( 'http_' . $code );
            return array( 'ok' => false, 'message' => $err );
        }

        Shieldforge_Sensor_Privacy::set_registration( $data['site_id'], $data['site_secret'], self::api_base() );
        // Pull the network salt immediately so the first events already correlate.
        self::fetch_salt();
        return array( 'ok' => true, 'message' => 'registered' );
    }

    /* ------------------------------------------------------------------
     * 2. salt fetch
     * ------------------------------------------------------------------ */

    public static function fetch_salt() {
        $reg = Shieldforge_Sensor_Privacy::get_registration();
        if ( empty( $reg['site_id'] ) || empty( $reg['secret'] ) ) {
            return array( 'ok' => false, 'message' => 'not_registered' );
        }
        $ts  = (string) time();
        $sig = self::sign( $reg['secret'], $ts, '' ); // empty body for GET

        $resp = wp_remote_get(
            self::api_base() . '/v1/salt/current',
            array(
                'timeout' => 15,
                'headers' => array(
                    'X-SF-Site'      => $reg['site_id'],
                    'X-SF-Timestamp' => $ts,
                    'X-SF-Signature' => $sig,
                ),
            )
        );
        if ( is_wp_error( $resp ) ) {
            return array( 'ok' => false, 'message' => $resp->get_error_message() );
        }
        $code = wp_remote_retrieve_response_code( $resp );
        $data = json_decode( wp_remote_retrieve_body( $resp ), true );
        if ( 200 !== (int) $code || empty( $data['epoch'] ) || empty( $data['epoch_key'] ) ) {
            $err = is_array( $data ) && isset( $data['error'] ) ? $data['error'] : ( 'http_' . $code );
            return array( 'ok' => false, 'message' => $err );
        }
        Shieldforge_Sensor_Privacy::set_network_salt( $data );
        return array( 'ok' => true, 'message' => 'salt_updated', 'epoch' => $data['epoch'] );
    }

    private static function salt_is_stale() {
        $net = Shieldforge_Sensor_Privacy::get_network_salt();
        if ( empty( $net['epoch'] ) || empty( $net['fetched_at'] ) ) {
            return true;
        }
        // Stale if epoch rolled over or older than SALT_TTL.
        if ( (string) $net['epoch'] !== Shieldforge_Sensor_Privacy::current_epoch() ) {
            return true;
        }
        $age = time() - strtotime( $net['fetched_at'] . ' UTC' );
        return $age > self::SALT_TTL;
    }

    /* ------------------------------------------------------------------
     * 3. flush
     * ------------------------------------------------------------------ */

    /**
     * Send queued events to the central API. Returns array( ok, sent, message ).
     * Honours a 429 Retry-After backoff and leaves unsent items in the queue.
     */
    public static function flush() {
        if ( ! Shieldforge_Sensor_Privacy::consent_given() ) {
            return array( 'ok' => false, 'sent' => 0, 'message' => 'not_opted_in' );
        }
        if ( ! Shieldforge_Sensor_Privacy::is_registered() ) {
            return array( 'ok' => false, 'sent' => 0, 'message' => 'not_registered' );
        }
        $backoff = (int) get_option( self::BACKOFF_OPTION, 0 );
        if ( $backoff > time() ) {
            return array( 'ok' => false, 'sent' => 0, 'message' => 'backoff' );
        }

        $items = Shieldforge_Sensor_Batcher::peek( self::FLUSH_BATCH );
        if ( empty( $items ) ) {
            return array( 'ok' => true, 'sent' => 0, 'message' => 'queue_empty' );
        }

        $reg    = Shieldforge_Sensor_Privacy::get_registration();
        $events = array();
        $ids    = array();
        foreach ( $items as $item ) {
            if ( ! empty( $item['event'] ) && is_array( $item['event'] ) ) {
                $events[] = $item['event'];
                $ids[]    = $item['id'];
            }
        }
        if ( empty( $events ) ) {
            return array( 'ok' => true, 'sent' => 0, 'message' => 'nothing_to_send' );
        }

        $body = wp_json_encode( array( 'events' => $events ) );
        $ts   = (string) time();
        $sig  = self::sign( $reg['secret'], $ts, $body );

        $resp = wp_remote_post(
            self::api_base() . '/v1/events',
            array(
                'timeout' => 20,
                'headers' => array(
                    'Content-Type'   => 'application/json',
                    'X-SF-Site'      => $reg['site_id'],
                    'X-SF-Timestamp' => $ts,
                    'X-SF-Signature' => $sig,
                ),
                'body'    => $body,
            )
        );
        if ( is_wp_error( $resp ) ) {
            return array( 'ok' => false, 'sent' => 0, 'message' => $resp->get_error_message() );
        }
        $code = (int) wp_remote_retrieve_response_code( $resp );

        if ( 429 === $code ) {
            $retry = (int) wp_remote_retrieve_header( $resp, 'retry-after' );
            update_option( self::BACKOFF_OPTION, time() + max( 5, $retry ), false );
            return array( 'ok' => false, 'sent' => 0, 'message' => 'rate_limited' );
        }
        if ( 200 !== $code ) {
            $data = json_decode( wp_remote_retrieve_body( $resp ), true );
            $err  = is_array( $data ) && isset( $data['error'] ) ? $data['error'] : ( 'http_' . $code );
            // 401/403 likely means stale registration — clear so the admin re-registers.
            if ( in_array( $code, array( 401, 403 ), true ) && 'rate_limited' !== $err ) {
                Shieldforge_Sensor_Privacy::clear_registration();
            }
            return array( 'ok' => false, 'sent' => 0, 'message' => $err );
        }

        // Success: drop the sent items from the queue.
        Shieldforge_Sensor_Batcher::remove( $ids );
        delete_option( self::BACKOFF_OPTION );
        return array( 'ok' => true, 'sent' => count( $events ), 'message' => 'sent' );
    }

    /* ------------------------------------------------------------------
     * 4. read-only network views (Kommandozentrale threat map / state-of-wp)
     * ------------------------------------------------------------------ */

    const THREATMAP_CACHE_TTL = 10 * MINUTE_IN_SECONDS;

    /**
     * Aggregated cross-network attacker/victim corridors ("Netz-Gesamtbild").
     * Signed GET to /v1/threatmap, short timeout, 10-minute transient cache.
     * Returns null (never throws/fatals) when not registered/opted-in, or on
     * any request failure — callers must render a friendly empty state.
     *
     * @return array{corridors:array,attacker_totals:array,victim_totals:array,generated_at:string}|null
     */
    public static function get_threatmap( $window = '24h' ) {
        return self::signed_get( '/v1/threatmap', array( 'window' => (string) $window ), 'shieldforge_threatmap_' );
    }

    /** Aggregated network-wide "state of WordPress" indicators (same transport contract as get_threatmap). */
    public static function get_stateofwp( $window = '24h' ) {
        return self::signed_get( '/v1/stateofwp', array( 'window' => (string) $window ), 'shieldforge_stateofwp_' );
    }

    /** Shared signed-GET + transient-cache plumbing for the read-only network views. */
    private static function signed_get( $path, array $query, $cache_prefix ) {
        if ( ! Shieldforge_Sensor_Privacy::consent_given() || ! Shieldforge_Sensor_Privacy::is_registered() ) {
            return null;
        }
        $cache_key = $cache_prefix . md5( $path . wp_json_encode( $query ) );
        $cached    = get_transient( $cache_key );
        if ( false !== $cached ) {
            return ( is_array( $cached ) && empty( $cached['__error'] ) ) ? $cached : null;
        }

        $reg = Shieldforge_Sensor_Privacy::get_registration();
        if ( empty( $reg['site_id'] ) || empty( $reg['secret'] ) ) {
            return null;
        }
        $ts  = (string) time();
        $sig = self::sign( $reg['secret'], $ts, '' );
        $url = add_query_arg( $query, self::api_base() . $path );

        $resp = wp_remote_get(
            $url,
            array(
                'timeout' => 6, // short — this feeds an admin page render, never block on it
                'headers' => array(
                    'X-SF-Site'      => $reg['site_id'],
                    'X-SF-Timestamp' => $ts,
                    'X-SF-Signature' => $sig,
                ),
            )
        );
        if ( is_wp_error( $resp ) || 200 !== (int) wp_remote_retrieve_response_code( $resp ) ) {
            set_transient( $cache_key, array( '__error' => true ), MINUTE_IN_SECONDS ); // short negative cache, avoid hammering on failure
            return null;
        }
        $data = json_decode( (string) wp_remote_retrieve_body( $resp ), true );
        if ( ! is_array( $data ) ) {
            set_transient( $cache_key, array(), MINUTE_IN_SECONDS );
            return null;
        }
        set_transient( $cache_key, $data, self::THREATMAP_CACHE_TTL );
        return $data;
    }

    /* ------------------------------------------------------------------
     * cron tick
     * ------------------------------------------------------------------ */

    /** Periodic worker: refresh the salt if stale, then flush the queue. */
    public static function tick() {
        if ( ! Shieldforge_Sensor_Privacy::consent_given() || ! Shieldforge_Sensor_Privacy::is_registered() ) {
            return;
        }
        if ( self::salt_is_stale() ) {
            self::fetch_salt();
        }
        self::flush();
    }
}
