<?php

declare(strict_types=1);

namespace SiteFort\Modules\Cloudflare;

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

use SiteFort\Includes\SecretManager;
use SiteFort\Includes\SettingsStore;

/**
 * Cloudflare API client.
 *
 * Handles token/zone/account scoped requests, managed list/rule lifecycle,
 * and access-rule fallback operations.
 */
class Api {

    public const BASE_URL = 'https://api.cloudflare.com/client/v4/';
    public const AUTH_MODE_API_TOKEN = 'api_token';

    /**
     * SiteFort managed rule identifier in Cloudflare rulesets.
     */
    public const MANAGED_RULE_REF = 'sitefort_managed_edge_rule';

    /**
     * Prefix used for managed access-rule notes.
     */
    public const MANAGED_ACCESS_NOTE_PREFIX = 'SiteFort Managed';

    private string $api_token;
    private string $zone_id;
    private string $account_id;

    public function __construct(
        ?string $api_token = null,
        ?string $zone_id = null,
        ?string $account_id = null
    ) {
        $this->api_token  = self::normalize_api_token( (string) ( $api_token ?? '' ) );
        $this->zone_id    = $zone_id ?? '';
        $this->account_id = $account_id ?? '';

        if ( $this->api_token === '' || $this->zone_id === '' || $this->account_id === '' ) {
            $settings = SettingsStore::get( SettingsStore::COL_CLOUDFLARE );

            if ( $this->api_token === '' ) {
                $token = SecretManager::get( 'cloudflare_api_token' );
                $this->api_token = is_string( $token ) ? self::normalize_api_token( $token ) : '';
            }

            if ( $this->zone_id === '' ) {
                $this->zone_id = sanitize_text_field( (string) ( $settings['zone_id'] ?? '' ) );
            }

            if ( $this->account_id === '' ) {
                $this->account_id = sanitize_text_field( (string) ( $settings['account_id'] ?? '' ) );
            }
        }
    }

    // ---------------------------------------------------------------------
    // Basic state
    // ---------------------------------------------------------------------

    public function is_configured(): bool {
        return $this->zone_id !== '' && $this->api_token !== '';
    }

    public function has_account_id(): bool {
        return $this->account_id !== '';
    }

    // ---------------------------------------------------------------------
    // Core requests
    // ---------------------------------------------------------------------

    /**
     * @param string               $endpoint Relative endpoint.
     * @param 'GET'|'POST'|'PUT'|'PATCH'|'DELETE' $method
     * @param array<string,mixed>  $body
     * @param array<string,mixed>  $query
     */
    private function request( string $endpoint, string $method = 'GET', array $body = [], array $query = [] ) {
        // Only the token is required to attempt a request. Endpoints that need a zone or
        // account (get_zone_details, list_lists, etc.) already guard for those individually,
        // since some calls, e.g. list_zones() for zone auto-detection, legitimately run before
        // a zone is known.
        if ( $this->api_token === '' ) {
            return new \WP_Error( 'cloudflare_missing_config', 'Cloudflare API token is missing.' );
        }

        $headers = [
            'Content-Type'  => 'application/json',
        ];

        if ( ! self::is_valid_bearer_token( $this->api_token ) ) {
            return new \WP_Error(
                'cloudflare_invalid_token_format',
                'Cloudflare API token format is invalid. Paste the API Token value only (no "Authorization:", "Bearer", spaces, or unsupported symbols).'
            );
        }

        if ( self::looks_like_cloudflare_token_identifier( $this->api_token ) ) {
            return new \WP_Error(
                'cloudflare_token_likely_id',
                'Cloudflare credential looks like a Global API Key or Token ID. This field accepts API Token value only.'
            );
        }

        $headers['Authorization'] = 'Bearer ' . $this->api_token;

        $url = self::BASE_URL . ltrim( $endpoint, '/' );
        if ( ! empty( $query ) ) {
            $url = add_query_arg( $query, $url );
        }

        $args = [
            'method'  => $method,
            'headers' => $headers,
            'timeout' => 20,
        ];

        if ( ! empty( $body ) || in_array( $method, [ 'POST', 'PUT', 'PATCH' ], true ) ) {
            $args['body'] = wp_json_encode( $body );
        }

        $response = wp_remote_request( $url, $args );

        if ( is_wp_error( $response ) ) {
            return new \WP_Error(
                'cloudflare_request_failed',
                'Cloudflare request failed: ' . $response->get_error_message(),
                [ 'endpoint' => $endpoint ]
            );
        }

        $code = (int) wp_remote_retrieve_response_code( $response );
        $raw  = (string) wp_remote_retrieve_body( $response );
        $data = json_decode( $raw, true );

        if ( ! is_array( $data ) ) {
            return new \WP_Error(
                'cloudflare_invalid_response',
                'Cloudflare returned an invalid response payload.',
                [ 'code' => $code, 'endpoint' => $endpoint ]
            );
        }

        if ( $code >= 400 || ( isset( $data['success'] ) && ! $data['success'] ) ) {
            $message = 'Cloudflare API request failed.';
            if ( ! empty( $data['errors'] ) && is_array( $data['errors'] ) ) {
                $first = $data['errors'][0] ?? [];
                if ( is_array( $first ) && ! empty( $first['message'] ) ) {
                    $message = (string) $first['message'];
                }
            }

            if (
                stripos( $message, 'invalid request header' ) !== false
                || stripos( $message, 'invalid request headers' ) !== false
                || stripos( $message, 'invalid format for authorization header' ) !== false
            ) {
                if ( self::looks_like_cloudflare_token_identifier( $this->api_token ) ) {
                    $message = 'Cloudflare rejected the authorization header. The saved credential looks like a Global API Key or Token ID. Use API Token value only.';
                } else {
                    $message = 'Cloudflare rejected the authorization header. Use the API Token value (not Global API Key / Token ID), and do not include "Authorization:", "Bearer", or extra spaces.';
                }
            }

            if (
                $code === 401
                || $code === 403
                || stripos( $message, 'unauthorized to access requested resource' ) !== false
                || stripos( $message, 'not authorized to access requested resource' ) !== false
                || trim( strtolower( $message ) ) === 'authentication error'
            ) {
                $message = $this->map_permission_error_message( $endpoint );
            }

            $resource = $this->get_permission_error_resource_context( $endpoint );

            return new \WP_Error(
                'cloudflare_api_error',
                $message,
                [
                    'code'     => $code,
                    'endpoint' => $endpoint,
                    'errors'   => $data['errors'] ?? [],
                    'resource' => $resource,
                ]
            );
        }

        return $data;
    }

    private function map_permission_error_message( string $endpoint ): string {
        $endpoint = ltrim( $endpoint, '/' );
        $zone_id = $this->extract_zone_id_from_endpoint( $endpoint );

        if ( $endpoint === 'user/tokens/verify' ) {
            return 'Cloudflare rejected this API token. Confirm you pasted the token value, not the Token ID, and verify that the token is still active.';
        }

        if ( $zone_id !== '' && preg_match( '#^zones/[^/]+$#', $endpoint ) ) {
            return $this->build_zone_permission_error_message( $zone_id, 'Zone - Zone - Read' );
        }

        if ( preg_match( '#^zones/[^/]+/firewall/access_rules/rules(?:/.*)?$#', $endpoint ) ) {
            if ( $zone_id !== '' ) {
                return $this->build_zone_permission_error_message( $zone_id, 'Zone - Firewall Services - Edit' );
            }

            return 'Cloudflare accepted the token, but fallback IP Access Rules are unavailable for this zone. Add Zone - Firewall Services - Edit and confirm Zone Resources includes this zone.';
        }

        if ( preg_match( '#^accounts/[^/]+/rules/lists(?:/.*)?$#', $endpoint ) ) {
            return 'Cloudflare accepted the token, but it cannot access Cloudflare Lists for the owning account. Add Account - Filter Lists - Edit and confirm Account Resources includes the account that owns this zone.';
        }

        if (
            preg_match( '#^zones/[^/]+/rulesets/phases/http_request_firewall_custom/entrypoint$#', $endpoint )
            || preg_match( '#^zones/[^/]+/rulesets/[^/]+/rules(?:/.*)?$#', $endpoint )
        ) {
            if ( $zone_id !== '' ) {
                return $this->build_zone_permission_error_message( $zone_id, 'Zone - WAF - Edit' );
            }

            return 'Cloudflare accepted the token, but it cannot manage edge WAF rules for the configured site zone. Add Zone - WAF - Edit and confirm Zone Resources includes this zone.';
        }

        return 'Cloudflare accepted the credential, but it does not have permission to access this resource. Review the token scopes, zone access, and account access, then verify again.';
    }

    private function build_zone_permission_error_message( string $zone_id, string $required_permission ): string {
        $zone_id = $this->sanitize_resource_id_for_message( $zone_id );

        return sprintf(
            'API token cannot access Zone ID %1$s. Add this site to token Zone Resources and enable %2$s.',
            $zone_id,
            $required_permission
        );
    }

    /**
     * @return array{type:string,id:string}|null
     */
    private function get_permission_error_resource_context( string $endpoint ): ?array {
        $endpoint = ltrim( $endpoint, '/' );
        $zone_id = $this->extract_zone_id_from_endpoint( $endpoint );

        if ( $zone_id !== '' ) {
            return [
                'type' => 'zone',
                'id'   => $this->sanitize_resource_id_for_message( $zone_id ),
            ];
        }

        if ( preg_match( '#^accounts/([^/]+)(?:/|$)#', $endpoint, $matches ) ) {
            return [
                'type' => 'account',
                'id'   => $this->sanitize_resource_id_for_message( rawurldecode( $matches[1] ) ),
            ];
        }

        return null;
    }

    private function extract_zone_id_from_endpoint( string $endpoint ): string {
        if ( ! preg_match( '#(?:^|/)zones/([^/]+)(?:/|$)#', $endpoint, $matches ) ) {
            return '';
        }

        return $this->sanitize_resource_id_for_message( rawurldecode( $matches[1] ) );
    }

    private function sanitize_resource_id_for_message( string $value ): string {
        return sanitize_text_field( preg_replace( '/[^A-Za-z0-9_-]/', '', $value ) ?? '' );
    }

    // ---------------------------------------------------------------------
    // Account and zone
    // ---------------------------------------------------------------------

    /**
     * Verify API token validity and status.
     *
     * @return array<string,mixed>|\WP_Error
     */
    public function verify_credentials() {
        return $this->request( 'user/tokens/verify', 'GET' );
    }

    public static function normalize_api_token( string $token ): string {
        $token = trim( $token );
        if ( $token === '' ) {
            return '';
        }

        // Handle pasted header forms like "Authorization: Bearer <token>".
        $token = preg_replace( '/^authorization\\s*:\\s*/i', '', $token ) ?? $token;
        $token = preg_replace( '/^bearer\\s+/i', '', $token ) ?? $token;

        // Remove wrapping quotes/backticks from clipboard copies.
        $token = trim( $token, "\"'`" );

        $token = preg_replace( '/[\\x00-\\x1F\\x7F]/u', '', $token ) ?? $token;
        $token = preg_replace( '/\\s+/u', '', $token ) ?? $token;

        return trim( $token );
    }

    public static function is_valid_bearer_token( string $token ): bool {
        return (bool) preg_match( '/^[A-Za-z0-9\\-._~+\\/]+=*$/', $token );
    }

    public static function looks_like_cloudflare_token_identifier( string $token ): bool {
        if ( $token === '' ) {
            return false;
        }

        return strlen( $token ) < 40 && ctype_xdigit( $token );
    }

    /**
     * @return array<string,mixed>|\WP_Error
     */
    public function get_zone_details() {
        if ( $this->zone_id === '' ) {
            return new \WP_Error( 'cloudflare_missing_zone', 'Cloudflare Zone ID is missing.' );
        }

        return $this->request( 'zones/' . rawurlencode( $this->zone_id ), 'GET' );
    }

    /**
     * Resolve and cache account_id from zone details.
     *
     * @return string|\WP_Error
     */
    public function discover_account_id() {
        $zone = $this->get_zone_details();
        if ( is_wp_error( $zone ) ) {
            return $zone;
        }

        $account_id = sanitize_text_field( (string) ( $zone['result']['account']['id'] ?? '' ) );
        if ( $account_id === '' ) {
            return new \WP_Error( 'cloudflare_missing_account', 'Cloudflare account ID could not be resolved for this zone.' );
        }

        $this->account_id = $account_id;
        return $account_id;
    }

    /**
     * List zones the token can access, optionally filtered by exact name.
     *
     * Only needs the API token (no zone or account required), so this is safe
     * to call during first-time setup before a zone is known.
     *
     * @return array{zones:array<int,array<string,mixed>>,total_pages:int}|\WP_Error
     */
    public function list_zones( string $name = '', int $per_page = 50, int $page = 1 ) {
        $query = [
            'page'     => max( 1, $page ),
            'per_page' => max( 5, min( 50, $per_page ) ),
        ];
        if ( $name !== '' ) {
            $query['name'] = $name;
        }

        $response = $this->request( 'zones', 'GET', [], $query );
        if ( is_wp_error( $response ) ) {
            return $response;
        }

        $result = is_array( $response['result'] ?? null ) ? $response['result'] : [];

        return [
            'zones'       => array_values( array_filter( $result, 'is_array' ) ),
            'total_pages' => max( 1, (int) ( $response['result_info']['total_pages'] ?? 1 ) ),
        ];
    }

    /**
     * Resolve the zone that owns the given site host, matching the most
     * specific zone name that the host falls under (handles subdomains).
     * Exact-name lookups per parent domain first, so multi-hundred-zone
     * accounts resolve without scanning; paginated scan as last resort.
     *
     * @return array{id:string,name:string,account_id:string}|null|\WP_Error
     */
    public function find_zone_for_host( string $host ) {
        $host = strtolower( trim( $host ) );
        $host = preg_replace( '/^www\./', '', $host ) ?? $host;
        if ( $host === '' ) {
            return null;
        }

        $labels = explode( '.', $host );
        $last_error = null;
        for ( $i = 0; $i < count( $labels ) - 1; $i++ ) {
            $candidate = implode( '.', array_slice( $labels, $i ) );
            $result = $this->list_zones( $candidate, 5 );
            if ( is_wp_error( $result ) ) {
                $last_error = $result;
                break;
            }

            $match = $this->pick_zone_match( $result['zones'], $host );
            if ( $match !== null ) {
                return $match;
            }
        }

        $best = null;
        $best_len = -1;
        $page = 1;
        do {
            $result = $this->list_zones( '', 50, $page );
            if ( is_wp_error( $result ) ) {
                return $last_error ?? $result;
            }

            foreach ( $result['zones'] as $zone ) {
                $zone_name = strtolower( sanitize_text_field( (string) ( $zone['name'] ?? '' ) ) );
                $zone_id   = sanitize_text_field( (string) ( $zone['id'] ?? '' ) );
                if ( $zone_name === '' || $zone_id === '' ) {
                    continue;
                }

                $matches = ( $host === $zone_name ) || str_ends_with( $host, '.' . $zone_name );
                if ( $matches && strlen( $zone_name ) > $best_len ) {
                    $best_len = strlen( $zone_name );
                    $best = [
                        'id'         => $zone_id,
                        'name'       => $zone_name,
                        'account_id' => sanitize_text_field( (string) ( $zone['account']['id'] ?? '' ) ),
                    ];
                }
            }

            $total_pages = min( $result['total_pages'], 40 );
            $page++;
        } while ( $page <= $total_pages );

        return $best;
    }

    /**
     * @param array<int,array<string,mixed>> $zones
     * @return array{id:string,name:string,account_id:string}|null
     */
    private function pick_zone_match( array $zones, string $host ): ?array {
        foreach ( $zones as $zone ) {
            $zone_name = strtolower( sanitize_text_field( (string) ( $zone['name'] ?? '' ) ) );
            $zone_id   = sanitize_text_field( (string) ( $zone['id'] ?? '' ) );
            if ( $zone_name === '' || $zone_id === '' ) {
                continue;
            }

            if ( $host === $zone_name || str_ends_with( $host, '.' . $zone_name ) ) {
                return [
                    'id'         => $zone_id,
                    'name'       => $zone_name,
                    'account_id' => sanitize_text_field( (string) ( $zone['account']['id'] ?? '' ) ),
                ];
            }
        }

        return null;
    }

    // ---------------------------------------------------------------------
    // Lists API
    // ---------------------------------------------------------------------

    /**
     * @return array<string,mixed>|\WP_Error
     */
    public function list_lists( int $page = 1, int $per_page = 100 ) {
        if ( ! $this->has_account_id() ) {
            return new \WP_Error( 'cloudflare_missing_account', 'Cloudflare account ID is required for list operations.' );
        }

        return $this->request(
            'accounts/' . rawurlencode( $this->account_id ) . '/rules/lists',
            'GET',
            [],
            [
                'page'     => max( 1, $page ),
                'per_page' => max( 5, min( 100, $per_page ) ),
            ]
        );
    }

    /**
     * @return array<string,mixed>|\WP_Error
     */
    public function ensure_list( string $name, string $kind = 'ip', string $description = '' ) {
        if ( ! $this->has_account_id() ) {
            return new \WP_Error( 'cloudflare_missing_account', 'Cloudflare account ID is required for list operations.' );
        }

        $safe_name = self::sanitize_list_name( $name );
        if ( $safe_name === '' ) {
            return new \WP_Error( 'cloudflare_invalid_list_name', 'Cloudflare list name is invalid.' );
        }

        // Lookup existing list first.
        $page = 1;
        do {
            $lists = $this->list_lists( $page, 100 );
            if ( is_wp_error( $lists ) ) {
                return $lists;
            }

            $result = is_array( $lists['result'] ?? null ) ? $lists['result'] : [];
            foreach ( $result as $item ) {
                $list_name = sanitize_text_field( (string) ( $item['name'] ?? '' ) );
                if ( $list_name === $safe_name ) {
                    return [
                        'id'   => sanitize_text_field( (string) ( $item['id'] ?? '' ) ),
                        'name' => $list_name,
                    ];
                }
            }

            $total_pages = (int) ( $lists['result_info']['total_pages'] ?? 1 );
            $page++;
        } while ( $page <= max( 1, $total_pages ) );

        // Create list when not found.
        $create = $this->request(
            'accounts/' . rawurlencode( $this->account_id ) . '/rules/lists',
            'POST',
            [
                'name'        => $safe_name,
                'kind'        => $kind,
                'description' => sanitize_text_field( $description ),
            ]
        );

        if ( is_wp_error( $create ) ) {
            return $create;
        }

        return [
            'id'   => sanitize_text_field( (string) ( $create['result']['id'] ?? '' ) ),
            'name' => sanitize_text_field( (string) ( $create['result']['name'] ?? $safe_name ) ),
        ];
    }

    /**
     * Replace all list items. Entries the Lists API cannot represent are
     * skipped and reported, since one rejected item fails the whole replace.
     *
     * Returns operation metadata when Cloudflare performs async bulk writes.
     *
     * @param string[] $entries
     * @return array<string,mixed>|\WP_Error
     */
    public function replace_ip_list_items( string $list_id, array $entries ) {
        if ( ! $this->has_account_id() ) {
            return new \WP_Error( 'cloudflare_missing_account', 'Cloudflare account ID is required for list operations.' );
        }

        $payload = [];
        $skipped = [];
        foreach ( array_values( array_unique( $entries ) ) as $entry ) {
            $entry = trim( (string) $entry );
            if ( $entry === '' ) {
                continue;
            }

            if ( filter_var( $entry, FILTER_VALIDATE_IP ) === false && ! $this->is_cidr( $entry ) ) {
                continue;
            }

            if ( ! $this->is_list_compatible_entry( $entry ) ) {
                $skipped[] = $entry;
                continue;
            }

            $payload[] = [
                'ip'      => $entry,
                'comment' => 'Managed by SiteFort',
            ];
        }

        $response = $this->request(
            'accounts/' . rawurlencode( $this->account_id ) . '/rules/lists/' . rawurlencode( $list_id ) . '/items',
            'PUT',
            $payload
        );

        if ( is_wp_error( $response ) ) {
            return $response;
        }

        return [
            'success'      => true,
            'queued'       => ! empty( $response['result']['operation_id'] ),
            'operation_id' => sanitize_text_field( (string) ( $response['result']['operation_id'] ?? '' ) ),
            'skipped'      => $skipped,
        ];
    }

    /**
     * Lists accept bare IPs, IPv4 CIDRs /8-/32, IPv6 CIDRs /12-/128.
     */
    private function is_list_compatible_entry( string $entry ): bool {
        if ( strpos( $entry, '/' ) === false ) {
            return filter_var( $entry, FILTER_VALIDATE_IP ) !== false;
        }

        [ $subnet, $bits ] = explode( '/', $entry, 2 );
        if ( ! ctype_digit( $bits ) ) {
            return false;
        }

        $bits_int = (int) $bits;
        if ( filter_var( $subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
            return $bits_int >= 8 && $bits_int <= 32;
        }

        if ( filter_var( $subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
            return $bits_int >= 12 && $bits_int <= 128;
        }

        return false;
    }

    /**
     * Delete a SiteFort-managed list.
     *
     * @return array<string,mixed>|\WP_Error
     */
    public function delete_list( string $list_id ) {
        if ( ! $this->has_account_id() ) {
            return new \WP_Error( 'cloudflare_missing_account', 'Cloudflare account ID is required for list operations.' );
        }

        return $this->request(
            'accounts/' . rawurlencode( $this->account_id ) . '/rules/lists/' . rawurlencode( $list_id ),
            'DELETE'
        );
    }

    /**
     * @return array<string,mixed>|\WP_Error
     */
    public function get_list_bulk_operation( string $operation_id ) {
        if ( ! $this->has_account_id() ) {
            return new \WP_Error( 'cloudflare_missing_account', 'Cloudflare account ID is required for list operations.' );
        }

        return $this->request(
            'accounts/' . rawurlencode( $this->account_id ) . '/rules/lists/bulk_operations/' . rawurlencode( $operation_id ),
            'GET'
        );
    }

    // ---------------------------------------------------------------------
    // Rulesets API
    // ---------------------------------------------------------------------

    /**
     * @return array<string,mixed>|\WP_Error
     */
    public function get_custom_firewall_entrypoint() {
        if ( $this->zone_id === '' ) {
            return new \WP_Error( 'cloudflare_missing_zone', 'Cloudflare Zone ID is required for ruleset operations.' );
        }

        return $this->request(
            'zones/' . rawurlencode( $this->zone_id ) . '/rulesets/phases/http_request_firewall_custom/entrypoint',
            'GET'
        );
    }

    /**
     * Cloudflare 404s the entrypoint GET until a zone's first custom rule exists.
     */
    public static function is_missing_entrypoint_error( \WP_Error $error ): bool {
        if ( $error->get_error_code() !== 'cloudflare_api_error' ) {
            return false;
        }

        $data = $error->get_error_data();
        if ( ! is_array( $data ) || (int) ( $data['code'] ?? 0 ) !== 404 ) {
            return false;
        }

        return (bool) preg_match(
            '#/rulesets/phases/http_request_firewall_custom/entrypoint$#',
            (string) ( $data['endpoint'] ?? '' )
        );
    }

    /**
     * Create or update the SiteFort managed custom firewall rule.
     *
     * The action is always block: SiteFort owns the edge enforcement decision,
     * and block behaves identically to the access-rule fallback.
     *
     * @return array<string,mixed>|\WP_Error
     */
    public function ensure_managed_custom_rule( string $expression, bool $enabled = true ) {
        $payload = [
            'action'      => 'block',
            'expression'  => $expression,
            'description' => 'SiteFort managed edge firewall policy',
            'enabled'     => $enabled,
            'ref'         => self::MANAGED_RULE_REF,
        ];

        $entrypoint = $this->get_custom_firewall_entrypoint();
        if ( is_wp_error( $entrypoint ) ) {
            if ( self::is_missing_entrypoint_error( $entrypoint ) ) {
                return $this->create_entrypoint_with_managed_rule( $payload );
            }
            return $entrypoint;
        }

        $ruleset_id = sanitize_text_field( (string) ( $entrypoint['result']['id'] ?? '' ) );
        if ( $ruleset_id === '' ) {
            return new \WP_Error( 'cloudflare_missing_ruleset', 'Cloudflare custom firewall entrypoint ruleset is unavailable.' );
        }

        $existing_rule_id = '';
        $rules = is_array( $entrypoint['result']['rules'] ?? null ) ? $entrypoint['result']['rules'] : [];
        foreach ( $rules as $rule ) {
            $ref = sanitize_text_field( (string) ( $rule['ref'] ?? '' ) );
            if ( $ref === self::MANAGED_RULE_REF ) {
                $existing_rule_id = sanitize_text_field( (string) ( $rule['id'] ?? '' ) );
                break;
            }
        }

        if ( $existing_rule_id !== '' ) {
            $update = $this->request(
                'zones/' . rawurlencode( $this->zone_id )
                . '/rulesets/' . rawurlencode( $ruleset_id )
                . '/rules/' . rawurlencode( $existing_rule_id ),
                'PATCH',
                $payload
            );

            if ( is_wp_error( $update ) ) {
                return $update;
            }

            return [
                'ruleset_id' => $ruleset_id,
                'rule_id'    => $existing_rule_id,
                'updated'    => true,
            ];
        }

        $create = $this->request(
            'zones/' . rawurlencode( $this->zone_id ) . '/rulesets/' . rawurlencode( $ruleset_id ) . '/rules',
            'POST',
            $payload
        );

        if ( is_wp_error( $create ) ) {
            return $create;
        }

        return [
            'ruleset_id' => $ruleset_id,
            'rule_id'    => sanitize_text_field( (string) ( $create['result']['id'] ?? '' ) ),
            'updated'    => false,
        ];
    }

    /**
     * PUT provisions the phase entrypoint on zones that lack one. Only called
     * after the entrypoint GET 404ed, so it can never replace user rules.
     *
     * @param array<string,mixed> $payload
     * @return array<string,mixed>|\WP_Error
     */
    private function create_entrypoint_with_managed_rule( array $payload ) {
        $created = $this->request(
            'zones/' . rawurlencode( $this->zone_id ) . '/rulesets/phases/http_request_firewall_custom/entrypoint',
            'PUT',
            [ 'rules' => [ $payload ] ]
        );

        if ( is_wp_error( $created ) ) {
            return $created;
        }

        $ruleset_id = sanitize_text_field( (string) ( $created['result']['id'] ?? '' ) );
        $rule_id = '';
        $rules = is_array( $created['result']['rules'] ?? null ) ? $created['result']['rules'] : [];
        foreach ( $rules as $rule ) {
            if ( sanitize_text_field( (string) ( $rule['ref'] ?? '' ) ) === self::MANAGED_RULE_REF ) {
                $rule_id = sanitize_text_field( (string) ( $rule['id'] ?? '' ) );
                break;
            }
        }

        return [
            'ruleset_id' => $ruleset_id,
            'rule_id'    => $rule_id,
            'updated'    => false,
        ];
    }

    /**
     * Delete managed custom rule if present.
     *
     * @return array<string,mixed>|\WP_Error
     */
    public function delete_managed_custom_rule() {
        $entrypoint = $this->get_custom_firewall_entrypoint();
        if ( is_wp_error( $entrypoint ) ) {
            if ( self::is_missing_entrypoint_error( $entrypoint ) ) {
                return [ 'deleted' => false ];
            }
            return $entrypoint;
        }

        $ruleset_id = sanitize_text_field( (string) ( $entrypoint['result']['id'] ?? '' ) );
        if ( $ruleset_id === '' ) {
            return [ 'deleted' => false ];
        }

        $rules = is_array( $entrypoint['result']['rules'] ?? null ) ? $entrypoint['result']['rules'] : [];
        $rule_id = '';
        foreach ( $rules as $rule ) {
            $ref = sanitize_text_field( (string) ( $rule['ref'] ?? '' ) );
            if ( $ref === self::MANAGED_RULE_REF ) {
                $rule_id = sanitize_text_field( (string) ( $rule['id'] ?? '' ) );
                break;
            }
        }

        if ( $rule_id === '' ) {
            return [ 'deleted' => false ];
        }

        $delete = $this->request(
            'zones/' . rawurlencode( $this->zone_id )
            . '/rulesets/' . rawurlencode( $ruleset_id )
            . '/rules/' . rawurlencode( $rule_id ),
            'DELETE'
        );

        if ( is_wp_error( $delete ) ) {
            return $delete;
        }

        return [
            'deleted'    => true,
            'ruleset_id' => $ruleset_id,
            'rule_id'    => $rule_id,
        ];
    }

    // ---------------------------------------------------------------------
    // IP Access Rules fallback
    // ---------------------------------------------------------------------

    /**
     * Zone scope only; account-level rules would hit every zone in the account.
     */
    private function get_access_rules_base_path(): string {
        return 'zones/' . rawurlencode( $this->zone_id ) . '/firewall/access_rules/rules';
    }

    /**
     * @return array<string,mixed>|\WP_Error
     */
    public function get_access_rules( array $params = [] ) {
        if ( $this->zone_id === '' ) {
            return new \WP_Error( 'cloudflare_missing_zone', 'Cloudflare Zone ID is required for access-rule operations.' );
        }

        return $this->request(
            $this->get_access_rules_base_path(),
            'GET',
            [],
            $params
        );
    }

    /**
     * @return array<string,mixed>|\WP_Error
     */
    public function create_access_rule( string $value, string $mode = 'block', string $note = '' ) {
        if ( $this->zone_id === '' ) {
            return new \WP_Error( 'cloudflare_missing_zone', 'Cloudflare Zone ID is required for access-rule operations.' );
        }

        $value = trim( $value );

        $target = 'ip';
        if ( $this->is_cidr( $value ) ) {
            [ $subnet, $bits ] = explode( '/', $value, 2 );
            $is_v4 = filter_var( $subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) !== false;
            // Host-sized CIDRs are rejected as ranges; send them as plain IPs.
            if ( ( $is_v4 && (int) $bits === 32 ) || ( ! $is_v4 && (int) $bits === 128 ) ) {
                $value = $subnet;
            } else {
                $target = 'ip_range';
            }
        }

        $payload = [
            'mode'          => $mode,
            'configuration' => [
                'target' => $target,
                'value'  => $value,
            ],
            'notes'         => sanitize_text_field( $note ),
        ];

        return $this->request(
            $this->get_access_rules_base_path(),
            'POST',
            $payload
        );
    }

    /**
     * Access rules accept bare IPs, IPv4 ranges /16 and /24, IPv6 ranges
     * /32, /48, /64; host-sized CIDRs pass because create_access_rule()
     * sends them as plain IPs.
     */
    public function is_access_rule_compatible_entry( string $entry ): bool {
        if ( strpos( $entry, '/' ) === false ) {
            return filter_var( $entry, FILTER_VALIDATE_IP ) !== false;
        }

        [ $subnet, $bits ] = explode( '/', $entry, 2 );
        if ( ! ctype_digit( $bits ) ) {
            return false;
        }

        $bits_int = (int) $bits;
        if ( filter_var( $subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
            return in_array( $bits_int, [ 16, 24, 32 ], true );
        }

        if ( filter_var( $subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
            return in_array( $bits_int, [ 32, 48, 64, 128 ], true );
        }

        return false;
    }

    /**
     * @return array<string,mixed>|\WP_Error
     */
    public function delete_access_rule( string $rule_id ) {
        return $this->request(
            $this->get_access_rules_base_path() . '/' . rawurlencode( $rule_id ),
            'DELETE'
        );
    }

    /**
     * Sync a managed set of access rules, used when list API is unavailable.
     *
     * @param string[] $block_entries
     * @param string[] $allow_entries
     * @return array<string,mixed>|\WP_Error
     */
    public function sync_managed_access_rules( array $block_entries, array $allow_entries, string $owner_tag = '' ) {
        $existing_owned = [];
        $existing_external = [];
        $conflicting_targets = [];
        $skipped_targets = [];
        $owner_tag = $this->sanitize_owner_tag( $owner_tag );

        $block_entries = $this->partition_access_rule_entries( $block_entries, $skipped_targets );
        $allow_entries = $this->partition_access_rule_entries( $allow_entries, $skipped_targets );

        $page = 1;
        do {
            $response = $this->get_access_rules([
                'page'     => $page,
                'per_page' => 100,
            ]);

            if ( is_wp_error( $response ) ) {
                return $response;
            }

            $results = is_array( $response['result'] ?? null ) ? $response['result'] : [];
            foreach ( $results as $rule ) {
                $notes = (string) ( $rule['notes'] ?? '' );
                $mode  = sanitize_text_field( (string) ( $rule['mode'] ?? '' ) );
                $value = sanitize_text_field( (string) ( $rule['configuration']['value'] ?? '' ) );
                $id    = sanitize_text_field( (string) ( $rule['id'] ?? '' ) );
                if ( $mode === '' || $value === '' || $id === '' ) {
                    continue;
                }

                $key = $mode . ':' . $value;
                if ( $this->is_owned_managed_access_rule( $notes, $owner_tag ) ) {
                    if ( ! isset( $existing_owned[ $key ] ) ) {
                        $existing_owned[ $key ] = [];
                    }
                    $existing_owned[ $key ][] = $id;
                    continue;
                }

                $existing_external[ $key ] = true;
            }

            $total_pages = (int) ( $response['result_info']['total_pages'] ?? 1 );
            $page++;
        } while ( $page <= max( 1, $total_pages ) );

        $desired = [];
        $keep_existing = [];

        foreach ( array_unique( $allow_entries ) as $entry ) {
            $entry = trim( $entry );
            if ( $entry === '' ) {
                continue;
            }
            $key = 'whitelist:' . $entry;
            $desired[ $key ] = [
                'value' => $entry,
                'mode'  => 'whitelist',
                'note'  => $this->build_managed_access_note( $owner_tag, 'allow' ),
            ];

            if ( ! empty( $existing_owned[ $key ] ) ) {
                $keep_existing[ $key ] = array_shift( $existing_owned[ $key ] );
                continue;
            }

            if ( ! empty( $existing_external[ $key ] ) ) {
                $keep_existing[ $key ] = 'external';
                continue;
            }

            if ( ! empty( $existing_external[ 'block:' . $entry ] ) || ! empty( $existing_owned[ 'block:' . $entry ] ) ) {
                $conflicting_targets[] = $entry;
                $keep_existing[ $key ] = 'conflict';
            }
        }

        foreach ( array_unique( $block_entries ) as $entry ) {
            $entry = trim( $entry );
            if ( $entry === '' ) {
                continue;
            }
            $key = 'block:' . $entry;
            $desired[ $key ] = [
                'value' => $entry,
                'mode'  => 'block',
                'note'  => $this->build_managed_access_note( $owner_tag, 'block' ),
            ];

            if ( ! empty( $existing_owned[ $key ] ) ) {
                $keep_existing[ $key ] = array_shift( $existing_owned[ $key ] );
                continue;
            }

            if ( ! empty( $existing_external[ $key ] ) ) {
                $keep_existing[ $key ] = 'external';
                continue;
            }

            if ( ! empty( $existing_external[ 'whitelist:' . $entry ] ) || ! empty( $existing_owned[ 'whitelist:' . $entry ] ) ) {
                $conflicting_targets[] = $entry;
                $keep_existing[ $key ] = 'conflict';
            }
        }

        foreach ( $existing_owned as $rule_ids ) {
            foreach ( $rule_ids as $rule_id ) {
                $delete = $this->delete_access_rule( $rule_id );
                if ( is_wp_error( $delete ) ) {
                    return $delete;
                }
            }
        }

        foreach ( $desired as $key => $rule ) {
            if ( isset( $keep_existing[ $key ] ) ) {
                continue;
            }

            $create = $this->create_access_rule(
                (string) $rule['value'],
                (string) $rule['mode'],
                (string) $rule['note']
            );
            if ( is_wp_error( $create ) ) {
                $error_data = $create->get_error_data();
                $error_code = (string) $create->get_error_code();
                $error_texts = [ strtolower( $create->get_error_message() ) ];

                if ( is_array( $error_data ) ) {
                    $error_texts[] = strtolower( (string) ( $error_data['error'] ?? '' ) );

                    if ( ! empty( $error_data['errors'] ) && is_array( $error_data['errors'] ) ) {
                        foreach ( $error_data['errors'] as $api_error ) {
                            if ( ! is_array( $api_error ) ) {
                                continue;
                            }

                            $error_texts[] = strtolower( (string) ( $api_error['message'] ?? '' ) );
                            $error_texts[] = strtolower( (string) ( $api_error['code'] ?? '' ) );
                        }
                    }
                }

                if ( in_array( $error_code, [ 'cloudflare_request_failed', 'cloudflare_api_error' ], true ) ) {
                    foreach ( $error_texts as $error_text ) {
                        if ( $error_text !== '' && str_contains( $error_text, 'duplicate_of_existing' ) ) {
                            $keep_existing[ $key ] = 'external';
                            continue 2;
                        }
                    }
                }

                return $create;
            }
        }

        $conflicting_targets = array_values( array_unique( array_map( 'sanitize_text_field', $conflicting_targets ) ) );
        $skipped_targets = array_values( array_unique( array_map( 'sanitize_text_field', $skipped_targets ) ) );

        return [
            'success'           => true,
            'mode'              => 'access_rules_fallback',
            'managed'           => max( 0, count( $desired ) - count( $conflicting_targets ) ),
            'warning_message'   => $this->build_conflicting_access_rule_message( $conflicting_targets ),
            'warning_targets'   => $conflicting_targets,
            'skipped_targets'   => $skipped_targets,
        ];
    }

    /**
     * @param string[] $entries
     * @param string[] $skipped
     * @return string[] Compatible entries; the rest are appended to $skipped.
     */
    private function partition_access_rule_entries( array $entries, array &$skipped ): array {
        $compatible = [];
        foreach ( $entries as $entry ) {
            $entry = trim( (string) $entry );
            if ( $entry === '' ) {
                continue;
            }

            if ( $this->is_access_rule_compatible_entry( $entry ) ) {
                $compatible[] = $entry;
            } else {
                $skipped[] = $entry;
            }
        }

        return $compatible;
    }

    // ---------------------------------------------------------------------
    // Helpers
    // ---------------------------------------------------------------------

    public static function sanitize_list_name( string $name ): string {
        $name = strtolower( $name );
        $name = preg_replace( '/[^a-z0-9_]/', '_', $name ) ?: '';
        $name = trim( $name, '_' );
        if ( $name === '' ) {
            return '';
        }

        // Cloudflare list names have a practical limit; keep it bounded.
        return substr( $name, 0, 50 );
    }

    private function is_cidr( string $value ): bool {
        if ( strpos( $value, '/' ) === false ) {
            return false;
        }

        [ $subnet, $bits ] = explode( '/', $value, 2 );
        if ( filter_var( $subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
            return ctype_digit( $bits ) && (int) $bits >= 0 && (int) $bits <= 32;
        }

        if ( filter_var( $subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
            return ctype_digit( $bits ) && (int) $bits >= 0 && (int) $bits <= 128;
        }

        return false;
    }

    /**
     * @param string[] $targets
     */
    private function build_conflicting_access_rule_message( array $targets ): string {
        $targets = array_values(
            array_filter(
                array_map( 'sanitize_text_field', $targets ),
                static fn( string $target ): bool => $target !== ''
            )
        );

        if ( $targets === [] ) {
            return '';
        }

        if ( count( $targets ) === 1 ) {
            return sprintf(
                'An existing Cloudflare rule for %s uses the opposite action. Remove or update the conflicting Cloudflare rule, then retry.',
                $targets[0]
            );
        }

        return sprintf(
            'Existing Cloudflare rules with the opposite action were found for %d IPs or networks. Remove or update the conflicting Cloudflare rules, then retry.',
            count( $targets )
        );
    }

    private function sanitize_owner_tag( string $owner_tag ): string {
        $owner_tag = strtolower( sanitize_key( $owner_tag ) );
        return substr( $owner_tag, 0, 24 );
    }

    private function build_managed_access_note( string $owner_tag, string $mode ): string {
        $mode = $mode === 'allow' ? 'allow' : 'block';
        if ( $owner_tag === '' ) {
            return self::MANAGED_ACCESS_NOTE_PREFIX . ' ' . $mode;
        }

        return sprintf( '%s [%s] %s', self::MANAGED_ACCESS_NOTE_PREFIX, $owner_tag, $mode );
    }

    private function is_owned_managed_access_rule( string $notes, string $owner_tag ): bool {
        if ( $owner_tag === '' ) {
            return false;
        }

        return str_starts_with( $notes, self::MANAGED_ACCESS_NOTE_PREFIX . ' [' . $owner_tag . '] ' );
    }
}
