<?php

declare(strict_types=1);

namespace SecurWP\Modules\Firewall;

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

use SecurWP\Includes\SettingsStore;

/**
 * Structured IP lockout manager.
 */
class LockoutManager {

    /**
     * Option key for lockout metadata.
     */
    private const META_OPTION = 'securwp_ip_lockout_meta';

    /**
     * Maximum metadata entries to prevent unbounded growth.
     */
    private const MAX_META_ENTRIES = 1000;

    private static bool $expiry_check_ran = false;
    private static bool $protected_ban_cleanup_ran = false;

    /**
     * Get all lockout entries as a structured array.
     *
     * Parses both banned_ips and allowlist from firewall settings,
     * merges with stored metadata, and returns a unified list.
     *
     * @param string|null $filter_type Filter by 'ban' or 'allow'. Null returns all.
     * @return array List of lockout entry arrays.
     */
    public function get_all( ?string $filter_type = null ): array {
        $this->purge_protected_bans_if_present();

        $settings = FirewallSettings::get();
        $meta     = $this->get_meta();
        $entries  = [];
        $now      = time();

        if ( $filter_type === null || $filter_type === 'ban' ) {
            $banned = $this->parse_list( $settings['banned_ips'] ?? '' );
            foreach ( $banned as $ip ) {
                $m = $meta[ $ip ] ?? [];
                $expires_at = $m['expires_at'] ?? null;

                // Determine status based on expiry.
                $status = 'active';
                if ( $expires_at !== null && $now > (int) $expires_at ) {
                    $status = 'expired';
                }

                $entries[] = [
                    'ip'         => $ip,
                    'type'       => 'ban',
                    'source'     => $m['source'] ?? 'manual',
                    'reason'     => $m['reason'] ?? '',
                    'created_at' => $m['created_at'] ?? null,
                    'expires_at' => $expires_at,
                    'status'     => $status,
                ];
            }
        }

        if ( $filter_type === null || $filter_type === 'allow' ) {
            $allowed = $this->parse_list( $settings['allowlist'] ?? '' );
            foreach ( $allowed as $ip ) {
                $m = $meta[ $ip ] ?? [];
                $entries[] = [
                    'ip'         => $ip,
                    'type'       => 'allow',
                    'source'     => $m['source'] ?? 'manual',
                    'reason'     => $m['reason'] ?? '',
                    'created_at' => $m['created_at'] ?? null,
                    'expires_at' => null,
                    'status'     => 'active',
                ];
            }
        }

        // Sort newest first.
        usort( $entries, function ( $a, $b ) {
            return ( $b['created_at'] ?? 0 ) - ( $a['created_at'] ?? 0 );
        } );

        return $entries;
    }

    /**
     * Count entries by type.
     *
     * @return array{ban: int, allow: int, total: int}
     */
    public function get_counts(): array {
        $this->purge_protected_bans_if_present();

        $settings = FirewallSettings::get();
        $ban_count   = count( $this->parse_list( $settings['banned_ips'] ?? '' ) );
        $allow_count = count( $this->parse_list( $settings['allowlist'] ?? '' ) );

        return [
            'ban'   => $ban_count,
            'allow' => $allow_count,
            'total' => $ban_count + $allow_count,
        ];
    }

    /**
     * Add an IP to the ban or allow list.
     *
     * @param string $ip      IP address, CIDR, or wildcard pattern.
     * @param string $type    'ban' or 'allow'.
     * @param array  $options {
     *     @type string $source   Origin: 'manual', 'waf', 'rate_limit', 'login'. Default 'manual'.
     *     @type string $reason   Human-readable reason. Default ''.
     *     @type int    $duration Duration in days. 0 = permanent. Default 0.
     * }
     * @return array{success: bool, message: string, code?: string}
     */
    public function add( string $ip, string $type = 'ban', array $options = [] ): array {
        $ip = $this->normalize_entry( $ip );
        if ( $ip === null ) {
            return [ 'success' => false, 'message' => 'Invalid IP address, CIDR, or wildcard.', 'code' => 'invalid_entry' ];
        }

        $this->purge_protected_bans_if_present();

        if ( $type === 'ban' ) {
            $protected_rejection = ProtectedNetworkGuard::describe_block_rejection( $ip );
            if ( $protected_rejection !== null ) {
                return [
                    'success' => false,
                    'message' => $protected_rejection['message'],
                    'code'    => $protected_rejection['code'],
                ];
            }
        }

        $settings = FirewallSettings::get();
        $original_settings = $settings;
        $key      = $type === 'allow' ? 'allowlist' : 'banned_ips';
        $list     = $this->parse_list( $settings[ $key ] ?? '' );

        if ( in_array( $ip, $list, true ) ) {
            return [ 'success' => false, 'message' => 'This IP is already in the list.', 'code' => 'already_present' ];
        }

        // Prevent adding the same IP to both lists.
        $other_key  = $type === 'allow' ? 'banned_ips' : 'allowlist';
        $other_list = $this->parse_list( $settings[ $other_key ] ?? '' );
        $replace_conflict = ! empty( $options['replace_conflict'] );
        $other_type = $type === 'allow' ? 'ban' : 'allow';
        $other_label = $type === 'allow' ? 'blocklist' : 'allowlist';
        $same_entry_conflict = in_array( $ip, $other_list, true );

        if ( $same_entry_conflict && ! $replace_conflict ) {
            return [
                'success' => false,
                'message' => 'This IP is already in the ' . $other_label . '. Remove it first.',
                'code'    => 'opposite_list_conflict',
            ];
        }

        $overlap_candidates = $same_entry_conflict
            ? array_values( array_filter( $other_list, static fn( string $entry ): bool => $entry !== $ip ) )
            : $other_list;

        $overlap_entry = $this->find_overlapping_entry( $ip, $overlap_candidates );
        if ( $overlap_entry !== null ) {
            return [
                'success' => false,
                'message' => sprintf(
                    'This entry conflicts with %s rule %s. Remove or narrow that rule first.',
                    $other_label,
                    $overlap_entry
                ),
                'code'    => 'overlap_conflict',
            ];
        }

        $list[]           = $ip;
        $settings[ $key ] = implode( "\n", $list );

        if ( $same_entry_conflict ) {
            $other_list = array_values( array_filter( $other_list, static fn( string $entry ): bool => $entry !== $ip ) );
            $settings[ $other_key ] = implode( "\n", $other_list );
        }

        // Store metadata.
        $duration   = (int) ( $options['duration'] ?? 0 );
        $expires_at = $duration > 0 ? time() + $duration * DAY_IN_SECONDS : null;

        $original_meta = $this->get_meta();
        $meta = $original_meta;
        $meta[ $ip ] = [
            'source'     => sanitize_key( $options['source'] ?? 'manual' ),
            'reason'     => sanitize_text_field( substr( $options['reason'] ?? '', 0, 500 ) ),
            'created_at' => time(),
            'expires_at' => $expires_at,
        ];

        $persist_result = $this->persist_lockout_state( $original_settings, $settings, $original_meta, $meta );
        if ( ! $persist_result['success'] ) {
            return $persist_result;
        }

        if ( $same_entry_conflict ) {
            do_action( 'securwp_firewall_lockout_changed', [
                'event' => 'removed',
                'ip'    => $ip,
                'type'  => $other_type,
            ] );
        }

        /**
         * Notify lockout consumers (e.g. Cloudflare sync) of lockout mutation.
         */
        do_action( 'securwp_firewall_lockout_changed', [
            'event'      => 'added',
            'ip'         => $ip,
            'type'       => $type,
            'source'     => $meta[ $ip ]['source'],
            'reason'     => $meta[ $ip ]['reason'],
            'expires_at' => $expires_at,
        ] );

        if ( $same_entry_conflict ) {
            return [
                'success' => true,
                'message' => sprintf(
                    'Moved %s from the %s to the %s.',
                    $ip,
                    $other_label,
                    $type === 'allow' ? 'allowlist' : 'blocklist'
                ),
                'code'    => 'moved',
            ];
        }

        return [ 'success' => true, 'message' => 'IP added successfully.', 'code' => 'added' ];
    }

    /**
     * Remove an IP from the ban or allow list.
     *
     * @param string $ip   IP address to remove.
     * @param string $type 'ban' or 'allow'.
     * @return array{success: bool, message: string}
     */
    public function remove( string $ip, string $type = 'ban' ): array {
        $ip = $this->normalize_entry( $ip );
        if ( $ip === null ) {
            return [ 'success' => false, 'message' => 'Invalid IP address, CIDR, or wildcard.' ];
        }
        $settings = FirewallSettings::get();
        $original_settings = $settings;
        $key      = $type === 'allow' ? 'allowlist' : 'banned_ips';
        $list     = $this->parse_list( $settings[ $key ] ?? '' );

        $original_count = count( $list );
        $list           = array_values( array_filter( $list, fn( $item ) => $item !== $ip ) );

        if ( count( $list ) === $original_count ) {
            return [ 'success' => false, 'message' => 'IP not found in the list.' ];
        }

        $settings[ $key ] = implode( "\n", $list );

        // Clean up metadata.
        $original_meta = $this->get_meta();
        $meta = $original_meta;
        unset( $meta[ $ip ] );

        $persist_result = $this->persist_lockout_state( $original_settings, $settings, $original_meta, $meta );
        if ( ! $persist_result['success'] ) {
            return [ 'success' => false, 'message' => $persist_result['message'] ];
        }

        do_action( 'securwp_firewall_lockout_changed', [
            'event' => 'removed',
            'ip'    => $ip,
            'type'  => $type,
        ] );

        return [ 'success' => true, 'message' => 'IP removed successfully.' ];
    }

    /**
     * Replace the full manual allowlist with a canonical set of entries.
     *
     * @param string|array<int,string> $entries
     * @return array{success: bool, message: string, code?: string, changed?: bool}
     */
    public function replace_allowlist( $entries ): array {
        $settings = FirewallSettings::get();
        $original_settings = $settings;
        $current_allowlist = $this->parse_list( $settings['allowlist'] ?? '' );
        $next_allowlist    = RuleEngine::normalize_ip_entries( $entries, true );

        if ( $current_allowlist === $next_allowlist ) {
            return [
                'success' => true,
                'message' => 'Allowlist already matches the requested entries.',
                'code'    => 'unchanged',
                'changed' => false,
            ];
        }

        $blocked_entries = $this->parse_list( $settings['banned_ips'] ?? '' );
        foreach ( $next_allowlist as $entry ) {
            $overlap_entry = $this->find_overlapping_entry( $entry, $blocked_entries );
            if ( $overlap_entry !== null ) {
                return [
                    'success' => false,
                    'message' => sprintf(
                        'This allowlist entry conflicts with blocklist rule %s. Remove or narrow that rule first.',
                        $overlap_entry
                    ),
                    'code'    => 'overlap_conflict',
                ];
            }
        }

        $settings['allowlist'] = implode( "\n", $next_allowlist );

        $original_meta = $this->get_meta();
        $meta          = $original_meta;
        $now           = time();
        $removed       = array_values( array_diff( $current_allowlist, $next_allowlist ) );
        $added         = array_values( array_diff( $next_allowlist, $current_allowlist ) );

        foreach ( $removed as $entry ) {
            unset( $meta[ $entry ] );
        }

        foreach ( $added as $entry ) {
            $meta[ $entry ] = [
                'source'     => 'manual',
                'reason'     => '',
                'created_at' => $now,
                'expires_at' => null,
            ];
        }

        $persist_result = $this->persist_lockout_state( $original_settings, $settings, $original_meta, $meta );
        if ( ! $persist_result['success'] ) {
            return [
                'success' => false,
                'message' => $persist_result['message'],
                'code'    => $persist_result['code'] ?? 'save_failed',
            ];
        }

        foreach ( $removed as $entry ) {
            do_action( 'securwp_firewall_lockout_changed', [
                'event' => 'removed',
                'ip'    => $entry,
                'type'  => 'allow',
            ] );
        }

        foreach ( $added as $entry ) {
            do_action( 'securwp_firewall_lockout_changed', [
                'event'      => 'added',
                'ip'         => $entry,
                'type'       => 'allow',
                'source'     => 'manual',
                'reason'     => '',
                'expires_at' => null,
            ] );
        }

        return [
            'success' => true,
            'message' => 'Allowlist updated successfully.',
            'code'    => 'updated',
            'changed' => true,
        ];
    }

    /**
     * Purge all expired bans.
     *
     * Removes IPs whose ban has expired from the banned_ips list and metadata.
     *
     * @return int Number of entries purged.
     */
    public function purge_expired(): int {
        $settings = FirewallSettings::get();
        $original_settings = $settings;
        $banned   = $this->parse_list( $settings['banned_ips'] ?? '' );
        $original_meta = $this->get_meta();
        $meta     = $original_meta;
        $now      = time();
        $purged   = 0;
        $purged_entries = [];

        $remaining = [];
        foreach ( $banned as $ip ) {
            $m = $meta[ $ip ] ?? [];
            if ( ! empty( $m['expires_at'] ) && $now > (int) $m['expires_at'] ) {
                unset( $meta[ $ip ] );
                $purged++;
                $purged_entries[] = $ip;
            } else {
                $remaining[] = $ip;
            }
        }

        if ( $purged > 0 ) {
            $settings['banned_ips'] = implode( "\n", $remaining );
            $persist_result = $this->persist_lockout_state( $original_settings, $settings, $original_meta, $meta );
            if ( ! $persist_result['success'] ) {
                return 0;
            }

            foreach ( $purged_entries as $ip ) {
                do_action( 'securwp_firewall_lockout_changed', [
                    'event' => 'expired',
                    'ip'    => $ip,
                    'type'  => 'ban',
                ] );
            }
        }

        return $purged;
    }

    public function purge_expired_if_due(): int {
        $protected_purged = $this->purge_protected_bans_if_present();

        if ( self::$expiry_check_ran ) {
            return $protected_purged;
        }

        self::$expiry_check_ran = true;
        $meta = $this->get_meta();
        if ( empty( $meta ) ) {
            return $protected_purged;
        }

        $now = time();
        foreach ( $meta as $entry ) {
            if ( ! is_array( $entry ) ) {
                continue;
            }

            $expires_at = isset( $entry['expires_at'] ) ? (int) $entry['expires_at'] : 0;
            if ( $expires_at > 0 && $now > $expires_at ) {
                return $protected_purged + $this->purge_expired();
            }
        }

        return $protected_purged;
    }

    // ------------------------------------------------------------------
    // Internal helpers
    // ------------------------------------------------------------------

    /**
     * Validate an IP, CIDR notation, or wildcard pattern.
     */
    private function is_valid_entry( string $ip ): bool {
        return $this->normalize_entry( $ip ) !== null;
    }

    /**
     * Parse a newline-separated list into a trimmed, non-empty array.
     *
     * @param string|array $input
     * @return string[]
     */
    private function parse_list( $input ): array {
        return RuleEngine::normalize_ip_entries( $input, true );
    }

    private function normalize_entry( string $ip ): ?string {
        $entries = RuleEngine::normalize_ip_entries( [ $ip ], true );
        return ! empty( $entries ) ? (string) $entries[0] : null;
    }

    private function find_overlapping_entry( string $ip, array $entries ): ?string {
        foreach ( $entries as $entry ) {
            $candidate = (string) $entry;
            if ( RuleEngine::ip_entries_overlap( $ip, $candidate ) ) {
                return $candidate;
            }
        }

        return null;
    }

    private function purge_protected_bans_if_present(): int {
        if ( self::$protected_ban_cleanup_ran ) {
            return 0;
        }

        self::$protected_ban_cleanup_ran = true;

        $settings = FirewallSettings::get();
        $original_settings = $settings;
        $banned   = $this->parse_list( $settings['banned_ips'] ?? '' );
        if ( empty( $banned ) ) {
            return 0;
        }

        $original_meta = $this->get_meta();
        $meta = $original_meta;
        $remaining = [];
        $purged_entries = [];

        foreach ( $banned as $entry ) {
            if ( ProtectedNetworkGuard::describe_block_rejection( $entry ) !== null ) {
                unset( $meta[ $entry ] );
                $purged_entries[] = $entry;
                continue;
            }

            $remaining[] = $entry;
        }

        if ( empty( $purged_entries ) ) {
            return 0;
        }

        $settings['banned_ips'] = implode( "\n", $remaining );
        $persist_result = $this->persist_lockout_state( $original_settings, $settings, $original_meta, $meta );
        if ( ! $persist_result['success'] ) {
            return 0;
        }

        foreach ( $purged_entries as $entry ) {
            do_action( 'securwp_firewall_lockout_changed', [
                'event'  => 'removed',
                'ip'     => $entry,
                'type'   => 'ban',
                'reason' => 'protected_network_cleanup',
            ] );
        }

        return count( $purged_entries );
    }

    /**
     * Load metadata from wp_options.
     *
     * @return array<string, array>
     */
    private function get_meta(): array {
        $meta = get_option( self::META_OPTION, [] );
        return is_array( $meta ) ? $meta : [];
    }

    private function save_firewall_settings( array $settings ): bool {
        return SettingsStore::save( SettingsStore::COL_FIREWALL, FirewallSettings::sanitize( $settings ) );
    }

    /**
     * Persist the firewall rules and lockout metadata as one logical unit.
     *
     * If the metadata write fails after the firewall rules were saved, the
     * firewall settings are rolled back so callers never receive a failure
     * while the rule change remains live locally.
     *
     * @param array $original_settings
     * @param array $next_settings
     * @param array $original_meta
     * @param array $next_meta
     * @return array{success: bool, message: string, code: string}
     */
    private function persist_lockout_state( array $original_settings, array $next_settings, array $original_meta, array $next_meta ): array {
        try {
            if ( ! $this->save_firewall_settings( $next_settings ) ) {
                return [ 'success' => false, 'message' => 'Failed to save firewall rules.', 'code' => 'save_failed' ];
            }
        } catch ( \InvalidArgumentException $exception ) {
            return [ 'success' => false, 'message' => sanitize_text_field( $exception->getMessage() ), 'code' => 'settings_conflict' ];
        }

        if ( $this->save_meta( $next_meta ) ) {
            return [ 'success' => true, 'message' => '', 'code' => 'saved' ];
        }

        $settings_restored = false;
        $meta_restored     = false;

        try {
            $settings_restored = $this->save_firewall_settings( $original_settings );
        } catch ( \InvalidArgumentException $exception ) {
            $settings_restored = false;
        }

        $meta_restored = $this->save_meta( $original_meta );

        if ( ! $settings_restored || ! $meta_restored ) {
            securwp_log(
                sprintf(
                    'SecurWP Firewall: lockout state rollback was incomplete after metadata save failure (settings_restored=%s, meta_restored=%s).',
                    $settings_restored ? 'yes' : 'no',
                    $meta_restored ? 'yes' : 'no'
                )
            );
        }

        return [ 'success' => false, 'message' => 'Failed to save firewall rules.', 'code' => 'save_failed' ];
    }

    /**
     * Save metadata, pruning if over the limit.
     *
     * @param array $meta
     */
    private function save_meta( array $meta ): bool {
        $meta = $this->normalize_meta_for_save( $meta );

        $current = get_option( self::META_OPTION, [] );
        if ( ! is_array( $current ) ) {
            $current = [];
        }
        $current = $this->normalize_meta_for_save( $current );

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

        return update_option( self::META_OPTION, $meta, false );
    }

    /**
     * Normalize metadata before persistence and equality checks.
     *
     * @param array $meta
     * @return array
     */
    private function normalize_meta_for_save( array $meta ): array {
        if ( count( $meta ) <= self::MAX_META_ENTRIES ) {
            return $meta;
        }

        // Keep only the most recent entries by created_at.
        uasort( $meta, function ( $a, $b ) {
            return ( $b['created_at'] ?? 0 ) - ( $a['created_at'] ?? 0 );
        } );

        return array_slice( $meta, 0, self::MAX_META_ENTRIES, true );
    }
}
