<?php

declare(strict_types=1);

namespace SecurWP\Includes;

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

use SecurWP\Modules\Cloud\SecureApi;
use SecurWP\Modules\Cloud\Service\CloudSettings;

/**
 * IntelEngine-powered IP detection probe.
 *
 * Asks IntelEngine to make an HTTP request back to this site from a known
 * external IP. The probe endpoint scans all server headers for IntelEngine's
 * IP and records which header contains it. That header becomes the recommended
 * IP source for automatic detection -- no user configuration required.
 *
 * Flow:
 *   1. Plugin calls POST /v1/ip-detect/init on IntelEngine with a one-time
 *      token and this site's probe URL.
 *   2. IntelEngine makes GET /wp-json/securwp/v1/firewall/ip-probe?token=...
 *      to this site from its known production IP.
 *   3. The probe endpoint validates the token and scans $_SERVER for
 *      IntelEngine's IP.
 *   4. Stores the matched header in a transient. IpDetector reads this
 *      recommendation in automatic mode.
 *
 * Probe is re-run:
 *   - On plugin activation
 *   - When firewall settings are saved (if probe is older than 24 hours)
 *   - When the admin requests it via the IP Detection panel
 */
class IpProbe {

    public const RECOMMENDATION_TRANSIENT = 'securwp_ip_probe_recommendation';
    public const TOKEN_TRANSIENT           = 'securwp_ip_probe_token';
    public const DISPATCH_LOCK_TRANSIENT   = 'securwp_ip_probe_dispatched';
    public const NEXT_CHECK_OPTION         = 'securwp_ip_probe_next_check';
    public const DISPATCH_STATE_OPTION     = 'securwp_ip_probe_dispatch_state';
    public const PROBE_TTL                 = DAY_IN_SECONDS;
    public const PROBE_HARD_TTL            = 2 * DAY_IN_SECONDS; // Self-destruct beyond this.
    public const TOKEN_TTL                 = 600; // 10 minutes
    public const SEND_FAILURE_RETRY_TTL    = 15 * MINUTE_IN_SECONDS;

    /**
     * Get the stored probe recommendation.
     *
     * Self-destructs any recommendation older than PROBE_HARD_TTL so the UI
     * and readiness computation never display indefinitely stale probe data
     * when IntelEngine is unreachable for an extended period.
     *
     * @return array{header: string, status: string, detected_at: int, origin_restored?: bool}|null
     */
    public static function get_recommendation(): ?array {
        $cached = get_transient( self::RECOMMENDATION_TRANSIENT );
        if ( ! is_array( $cached ) || empty( $cached['header'] ) ) {
            return null;
        }

        $detected_at = (int) ( $cached['detected_at'] ?? 0 );
        if ( $detected_at > 0 && ( time() - $detected_at ) > self::PROBE_HARD_TTL ) {
            delete_transient( self::RECOMMENDATION_TRANSIENT );
            return null;
        }

        return $cached;
    }

    /**
     * Check if the probe result is fresh enough to rely on.
     */
    public static function is_fresh(): bool {
        $rec = self::get_recommendation();
        if ( ! $rec ) {
            return false;
        }
        return ( time() - (int) ( $rec['detected_at'] ?? 0 ) ) < self::PROBE_TTL;
    }

    /**
     * Schedule a probe if none exists, the current one is stale, or readiness
     * recommends refreshing probe metadata. Safe to call on every admin_init:
     * request_probe() owns freshness, backoff, and dispatch locking.
     *
     * @param array<string,mixed>|null $readiness Latest readiness payload.
     */
    public static function maybe_schedule( ?array $readiness = null ): void {
        self::request_probe( false, self::readiness_recommends_probe_refresh( $readiness ) );
    }

    /**
     * Initiate a probe: generate token, call IntelEngine.
     *
     * Returns a status string: 'dispatched' on success, or a reason why
     * the probe was not sent. Callers that need to report the outcome
     * (e.g. REST trigger endpoint) should check this value.
     *
     * @return string 'dispatched' | 'locked' | 'fresh' | 'backoff' | 'not_connected' | 'no_credentials' | 'send_failed'
     */
    public static function request_probe( bool $force = false, bool $refresh_recommended = false ): string {
        if ( get_transient( self::DISPATCH_LOCK_TRANSIENT ) ) {
            return 'locked';
        }

        if ( ! $force && ! $refresh_recommended && self::is_fresh() ) {
            return 'fresh';
        }

        $now = time();
        $next_check = (int) get_option( self::NEXT_CHECK_OPTION, 0 );
        if ( ! $force && $next_check > 0 && $now < $next_check ) {
            return 'backoff';
        }

        $settings = CloudSettings::get_all();
        if ( empty( $settings['console_enabled'] ) ) {
            return 'not_connected';
        }
        if ( empty( $settings['site_token'] ) || empty( $settings['site_secret'] ) ) {
            return 'no_credentials';
        }

        $token = wp_generate_password( 32, false );
        set_transient( self::TOKEN_TRANSIENT, $token, self::TOKEN_TTL );
        set_transient( self::DISPATCH_LOCK_TRANSIENT, 1, self::TOKEN_TTL );

        $probe_url = rest_url( 'securwp/v1/firewall/ip-probe' );
        $probe_url = add_query_arg( 'token', $token, $probe_url );

        $api = new SecureApi(
            $settings['license_key'] ?? '',
            $settings['site_token'],
            $settings['site_secret']
        );

        $sent = $api->post_background( 'services/ip-detect/init', [
            'probe_url'    => $probe_url,
            'site_url'     => get_site_url(),
            'callback_ttl' => self::TOKEN_TTL,
        ] );

        if ( ! $sent ) {
            delete_transient( self::TOKEN_TRANSIENT );
            delete_transient( self::DISPATCH_LOCK_TRANSIENT );
            self::record_dispatch_state( 'send_failed', $now, $now + self::SEND_FAILURE_RETRY_TTL, $force, 'send_failed' );
            return 'send_failed';
        }

        self::record_dispatch_state( 'dispatched', $now, $now + self::PROBE_TTL, $force );

        return 'dispatched';
    }

    /**
     * Handle the probe callback from IntelEngine.
     *
     * Called from the public REST endpoint. Validates the one-time token,
     * scans $_SERVER for the IntelEngine IP passed in the query string,
     * and stores the matched header as the recommendation.
     *
     * @param string $token   The one-time token from the query string.
     * @param string $ie_ip   IntelEngine's IP, passed in the query string so
     *                        we know what to look for in the headers.
     * @return array{ok: bool, header?: string, status?: string, error?: string}
     */
    public static function handle_callback( string $token, string $ie_ip ): array {
        $stored = get_transient( self::TOKEN_TRANSIENT );
        if ( ! is_string( $stored ) || ! hash_equals( $stored, $token ) ) {
            return [ 'ok' => false, 'error' => 'invalid_token' ];
        }

        delete_transient( self::TOKEN_TRANSIENT );
        delete_transient( self::DISPATCH_LOCK_TRANSIENT );

        if ( ! filter_var( $ie_ip, FILTER_VALIDATE_IP ) ) {
            return [ 'ok' => false, 'error' => 'invalid_ip' ];
        }

        $matched_header  = self::find_header_for_ip( $ie_ip );
        $remote_addr     = IpDetector::get_server_value( 'REMOTE_ADDR' );
        $origin_restored = $matched_header
            && self::normalize_for_compare( $remote_addr ) === self::normalize_for_compare( $ie_ip );

        $recommendation = [
            'header'          => $matched_header ?? 'REMOTE_ADDR',
            'status'          => $matched_header ? 'proxy' : 'direct',
            'origin_restored' => (bool) $origin_restored,
            'ie_ip'           => $ie_ip,
            'remote_addr'     => $remote_addr,
            'detected_at'     => time(),
        ];

        set_transient( self::RECOMMENDATION_TRANSIENT, $recommendation, self::PROBE_TTL * 2 );

        $now = time();
        $state = self::get_dispatch_state();
        self::record_dispatch_state(
            'completed',
            (int) ( $state['attempted_at'] ?? $now ),
            $now + self::PROBE_TTL,
            ! empty( $state['forced'] )
        );

        return [
            'ok'              => true,
            'header'          => $recommendation['header'],
            'status'          => $recommendation['status'],
            'origin_restored' => $recommendation['origin_restored'],
        ];
    }

    /**
     * @param array<string,mixed>|null $readiness
     */
    private static function readiness_recommends_probe_refresh( ?array $readiness = null ): bool {
        if ( ! is_array( $readiness ) ) {
            $cached = get_transient( IpDetector::READINESS_TRANSIENT );
            $readiness = is_array( $cached ) ? $cached : null;
        }

        return is_array( $readiness ) && ! empty( $readiness['probe_refresh_recommended'] );
    }

    /**
     * @return array<string,mixed>
     */
    private static function get_dispatch_state(): array {
        $state = get_option( self::DISPATCH_STATE_OPTION, [] );

        return is_array( $state ) ? $state : [];
    }

    private static function record_dispatch_state( string $status, int $attempted_at, int $next_check, bool $forced, string $error = '' ): void {
        update_option( self::NEXT_CHECK_OPTION, $next_check, false );
        update_option(
            self::DISPATCH_STATE_OPTION,
            [
                'status' => sanitize_key( $status ),
                'attempted_at' => max( 0, $attempted_at ),
                'next_check' => max( 0, $next_check ),
                'forced' => $forced,
                'error' => sanitize_key( $error ),
            ],
            false
        );
    }

    /**
     * Scan all known proxy headers for a specific IP address.
     *
     * Returns the header name that contains the IP, or null if none found
     * (meaning the site is directly connected with no proxy).
     *
     * @param string $ip IP address to find.
     * @return string|null Header key (e.g. 'HTTP_CF_CONNECTING_IP') or null.
     */
    private static function find_header_for_ip( string $ip ): ?string {
        $normalized = self::normalize_for_compare( $ip );

        foreach ( IpDetector::PROXY_HEADERS as $header ) {
            $raw = IpDetector::get_server_value( $header );
            if ( '' === $raw ) {
                continue;
            }

            $candidates = self::extract_ips_from_header( $header, (string) $raw );
            foreach ( $candidates as $candidate ) {
                if ( self::normalize_for_compare( $candidate ) === $normalized ) {
                    return $header;
                }
            }
        }

        if ( self::normalize_for_compare( IpDetector::get_server_value( 'REMOTE_ADDR' ) ) === $normalized ) {
            return null;
        }

        return null;
    }

    /**
     * Extract IP addresses from a header value, handling both comma-separated
     * lists (X-Forwarded-For) and RFC 7239 structured syntax (Forwarded).
     *
     * @param string $header Header key (e.g. 'HTTP_FORWARDED').
     * @param string $raw    Raw header value.
     * @return string[] List of IP addresses found.
     */
    private static function extract_ips_from_header( string $header, string $raw ): array {
        if ( 'HTTP_FORWARDED' === $header ) {
            // RFC 7239: for="[2001:db8::1]", for=198.51.100.178
            if ( preg_match_all( '/for=["\']?\[?([a-f0-9.:]+)\]?["\']?/i', $raw, $m ) ) {
                return array_map( [ self::class, 'strip_port' ], $m[1] );
            }
            return [];
        }

        // Standard comma-separated list (X-Forwarded-For, etc.).
        $parts = array_map( 'trim', explode( ',', $raw ) );
        return array_map( [ self::class, 'strip_port' ], $parts );
    }

    /**
     * Strip port suffix from an IP string (handles IPv4:port and [IPv6]:port).
     */
    private static function strip_port( string $ip ): string {
        $ip = trim( $ip );
        if ( str_starts_with( $ip, '[' ) ) {
            $close = strpos( $ip, ']' );
            if ( $close !== false ) {
                return substr( $ip, 1, $close - 1 );
            }
        }
        if ( strpos( $ip, '.' ) !== false && substr_count( $ip, ':' ) === 1 ) {
            return explode( ':', $ip, 2 )[0];
        }
        return $ip;
    }

    /**
     * Normalize an IP for comparison: lowercase, expand IPv6-mapped-IPv4.
     */
    private static function normalize_for_compare( string $ip ): string {
        if ( '' === $ip ) {
            return '';
        }
        $packed = @inet_pton( $ip );
        if ( false !== $packed ) {
            $normalized = @inet_ntop( $packed );
            if ( $normalized ) {
                return strtolower( $normalized );
            }
        }
        return strtolower( $ip );
    }
}
