<?php
/**
 * IP Management aggregator and admin helpers (display + manual rule CRUD).
 *
 * @package WPSecurityNinja\Plugin
 */

namespace WPSecurityNinja\Plugin;

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

/**
 * Aggregates firewall IP rules and temporary blocks for the IP Management admin UI.
 */
class Wf_Sn_Cf_Ip_Management {

	const SOURCE_MANUAL_BLACKLIST = 'manual_blacklist';
	const SOURCE_MANUAL_WHITELIST = 'manual_whitelist';
	const SOURCE_LOCAL_BAN        = 'local_ban';
	const SOURCE_404_GUARD        = '404_guard';

	/**
	 * Maximum length for an IP rule note/label.
	 */
	const NOTE_MAX_LENGTH = 150;

	/**
	 * Sanitize a free-text note for an IP rule.
	 *
	 * @param mixed $note Raw note.
	 * @return string Sanitized note (may be empty).
	 */
	public static function sanitize_note( $note ) {
		$note = sanitize_text_field( (string) $note );
		if ( '' === $note ) {
			return '';
		}
		if ( function_exists( 'mb_substr' ) ) {
			return mb_substr( $note, 0, self::NOTE_MAX_LENGTH );
		}
		return substr( $note, 0, self::NOTE_MAX_LENGTH );
	}

	/**
	 * Notes option key for a list type.
	 *
	 * @param string $list_type blacklist|whitelist.
	 * @return string|null
	 */
	private static function notes_option_key( $list_type ) {
		if ( 'blacklist' === $list_type ) {
			return 'blacklist_notes';
		}
		if ( 'whitelist' === $list_type ) {
			return 'whitelist_notes';
		}
		return null;
	}

	/**
	 * Get note for an IP on a list.
	 *
	 * @param array  $options   Firewall options.
	 * @param string $list_type blacklist|whitelist.
	 * @param string $ip        IP or CIDR.
	 * @return string
	 */
	public static function get_note( $options, $list_type, $ip ) {
		$key = self::notes_option_key( $list_type );
		if ( null === $key || ! is_array( $options ) ) {
			return '';
		}
		$ip = trim( (string) $ip );
		if ( '' === $ip || empty( $options[ $key ] ) || ! is_array( $options[ $key ] ) ) {
			return '';
		}
		if ( ! isset( $options[ $key ][ $ip ] ) ) {
			return '';
		}
		return self::sanitize_note( $options[ $key ][ $ip ] );
	}

	/**
	 * Set or clear a note for an IP on a list.
	 *
	 * @param array  $options   Firewall options (by reference).
	 * @param string $list_type blacklist|whitelist.
	 * @param string $ip        IP or CIDR.
	 * @param string $note      Note text; empty clears.
	 * @return void
	 */
	public static function set_note( &$options, $list_type, $ip, $note ) {
		$key = self::notes_option_key( $list_type );
		if ( null === $key || ! is_array( $options ) ) {
			return;
		}
		$ip = trim( (string) $ip );
		if ( '' === $ip ) {
			return;
		}
		if ( ! isset( $options[ $key ] ) || ! is_array( $options[ $key ] ) ) {
			$options[ $key ] = array();
		}
		$note = self::sanitize_note( $note );
		if ( '' === $note ) {
			unset( $options[ $key ][ $ip ] );
			return;
		}
		$options[ $key ][ $ip ] = $note;
	}

	/**
	 * Clear note for an IP on a list.
	 *
	 * @param array  $options   Firewall options (by reference).
	 * @param string $list_type blacklist|whitelist.
	 * @param string $ip        IP or CIDR.
	 * @return void
	 */
	public static function clear_note( &$options, $list_type, $ip ) {
		self::set_note( $options, $list_type, $ip, '' );
	}

	/**
	 * Move or replace a note when editing an IP rule.
	 *
	 * @param array  $options   Firewall options (by reference).
	 * @param string $from_list Source list type.
	 * @param string $from_ip   Previous IP or CIDR.
	 * @param string $to_list   Target list type.
	 * @param string $to_ip     New IP or CIDR.
	 * @param string $note      New note (sanitized); if null, preserve previous note when possible.
	 * @return void
	 */
	public static function move_note( &$options, $from_list, $from_ip, $to_list, $to_ip, $note = null ) {
		$from_ip = trim( (string) $from_ip );
		$to_ip   = trim( (string) $to_ip );
		$prev    = self::get_note( $options, $from_list, $from_ip );

		self::clear_note( $options, 'blacklist', $from_ip );
		self::clear_note( $options, 'whitelist', $from_ip );
		if ( $from_ip !== $to_ip ) {
			self::clear_note( $options, 'blacklist', $to_ip );
			self::clear_note( $options, 'whitelist', $to_ip );
		}

		if ( null === $note ) {
			$note = $prev;
		}
		self::set_note( $options, $to_list, $to_ip, $note );
	}

	/**
	 * Drop note keys whose IP is not on the matching list.
	 *
	 * @param array $options Firewall options (by reference).
	 * @return void
	 */
	public static function prune_notes( &$options ) {
		if ( ! is_array( $options ) ) {
			return;
		}
		foreach ( array( 'blacklist', 'whitelist' ) as $list_type ) {
			$notes_key = self::notes_option_key( $list_type );
			if ( null === $notes_key ) {
				continue;
			}
			if ( ! isset( $options[ $notes_key ] ) || ! is_array( $options[ $notes_key ] ) ) {
				$options[ $notes_key ] = array();
				continue;
			}
			$list = isset( $options[ $list_type ] ) && is_array( $options[ $list_type ] ) ? $options[ $list_type ] : array();
			$list = array_map(
				function ( $v ) {
					return trim( (string) $v );
				},
				$list
			);
			$kept = array();
			foreach ( $options[ $notes_key ] as $ip => $note ) {
				$ip = trim( (string) $ip );
				if ( '' === $ip || ! in_array( $ip, $list, true ) ) {
					continue;
				}
				$note = self::sanitize_note( $note );
				if ( '' === $note ) {
					continue;
				}
				$kept[ $ip ] = $note;
			}
			$options[ $notes_key ] = $kept;
		}
	}

	/**
	 * Sanitize an associative notes map from import or remote sync.
	 *
	 * @param mixed $map Raw map.
	 * @return array<string, string>
	 */
	public static function sanitize_notes_map( $map ) {
		if ( ! is_array( $map ) ) {
			return array();
		}
		$out = array();
		foreach ( $map as $ip => $note ) {
			$ip = sanitize_text_field( trim( (string) $ip ) );
			if ( '' === $ip ) {
				continue;
			}
			$note = self::sanitize_note( $note );
			if ( '' === $note ) {
				continue;
			}
			$out[ $ip ] = $note;
		}
		return $out;
	}

	/**
	 * Ensure notes map keys exist on options.
	 *
	 * @param array $options Firewall options (by reference).
	 * @return void
	 */
	private static function ensure_notes_maps( &$options ) {
		if ( ! isset( $options['blacklist_notes'] ) || ! is_array( $options['blacklist_notes'] ) ) {
			$options['blacklist_notes'] = array();
		}
		if ( ! isset( $options['whitelist_notes'] ) || ! is_array( $options['whitelist_notes'] ) ) {
			$options['whitelist_notes'] = array();
		}
	}

	/**
	 * Build unified IP entries for the admin table.
	 *
	 * @param array|null $options Firewall options; loads via Wf_sn_cf::get_options() if null.
	 * @return array<int, array<string, mixed>>
	 */
	public static function get_ip_management_entries( $options = null ) {
		if ( null === $options ) {
			$options = Wf_sn_cf::get_options();
		}

		$raw = array();

		$blacklist = isset( $options['blacklist'] ) && is_array( $options['blacklist'] ) ? $options['blacklist'] : array();
		foreach ( $blacklist as $ip ) {
			$ip = trim( (string) $ip );
			if ( '' === $ip ) {
				continue;
			}
			$raw[] = self::make_entry( $ip, 'blacklisted', self::SOURCE_MANUAL_BLACKLIST, null, true, false, self::get_note( $options, 'blacklist', $ip ) );
		}

		$whitelist = isset( $options['whitelist'] ) && is_array( $options['whitelist'] ) ? $options['whitelist'] : array();
		foreach ( $whitelist as $ip ) {
			$ip = trim( (string) $ip );
			if ( '' === $ip ) {
				continue;
			}
			$raw[] = self::make_entry( $ip, 'whitelisted', self::SOURCE_MANUAL_WHITELIST, null, true, false, self::get_note( $options, 'whitelist', $ip ) );
		}

		$now         = current_time( 'timestamp' );
		$banned_ips  = Wf_sn_cf::get_banned_ips();
		if ( is_array( $banned_ips ) ) {
			foreach ( $banned_ips as $ip => $expiry ) {
				if ( $expiry > $now ) {
					$raw[] = self::make_entry( (string) $ip, 'blacklisted', self::SOURCE_LOCAL_BAN, (int) $expiry, false, true );
				}
			}
		}

		if ( ! class_exists( __NAMESPACE__ . '\SN_404_Guard' ) ) {
			$guard_file = WF_SN_PLUGIN_DIR . 'modules/cloud-firewall/class-sn-404-guard.php';
			if ( file_exists( $guard_file ) ) {
				require_once $guard_file;
			}
		}

		if ( class_exists( __NAMESPACE__ . '\SN_404_Guard' ) ) {
			$guard_banned = SN_404_Guard::get_currently_banned_ips();
			if ( is_array( $guard_banned ) ) {
				foreach ( $guard_banned as $ip => $expiry ) {
					if ( $expiry > time() ) {
						$raw[] = self::make_entry( (string) $ip, 'blacklisted', self::SOURCE_404_GUARD, (int) $expiry, false, true );
					}
				}
			}
		}

		$merged = self::merge_entries( $raw );

		$exact_ips = array();
		foreach ( $merged as $entry ) {
			if ( self::is_exact_ip( $entry['ip'] ) ) {
				$exact_ips[] = $entry['ip'];
			}
		}

		$last_visits = self::get_last_visits_for_ips( $exact_ips, $options );
		foreach ( $merged as $idx => $entry ) {
			$ip = $entry['ip'];
			if ( isset( $last_visits[ $ip ] ) ) {
				$merged[ $idx ]['last_visit'] = $last_visits[ $ip ];
			} else {
				$merged[ $idx ]['last_visit'] = null;
			}
		}

		usort(
			$merged,
			function ( $a, $b ) {
				return strcasecmp( $a['ip'], $b['ip'] );
			}
		);

		return $merged;
	}

	/**
	 * Last visit timestamps for exact IPs from visitor log.
	 *
	 * @param array      $ips     List of IP strings.
	 * @param array|null $options Firewall options.
	 * @return array<string, string> ip => mysql datetime.
	 */
	public static function get_last_visits_for_ips( array $ips, $options = null ) {
		if ( empty( $ips ) ) {
			return array();
		}

		if ( null === $options ) {
			$options = Wf_sn_cf::get_options();
		}

		if ( empty( $options['trackvisits'] ) ) {
			return array();
		}

		global $wpdb;
		$table_name = $wpdb->prefix . 'wf_sn_cf_vl';

		if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) ) !== $table_name ) {
			return array();
		}

		$valid_ips = array();
		foreach ( $ips as $ip ) {
			if ( self::is_exact_ip( $ip ) ) {
				$valid_ips[] = $ip;
			}
		}

		if ( empty( $valid_ips ) ) {
			return array();
		}

		$placeholders = implode( ',', array_fill( 0, count( $valid_ips ), '%s' ) );
		$query        = $wpdb->prepare(
			"SELECT ip, MAX(timestamp) AS last_visit FROM {$table_name} WHERE ip IN ({$placeholders}) GROUP BY ip",
			$valid_ips
		);

		$results = $wpdb->get_results( $query, ARRAY_A );
		$out     = array();
		if ( is_array( $results ) ) {
			foreach ( $results as $row ) {
				if ( ! empty( $row['ip'] ) && ! empty( $row['last_visit'] ) ) {
					$out[ $row['ip'] ] = $row['last_visit'];
				}
			}
		}

		return $out;
	}

	/**
	 * Validate and sanitize an IP or CIDR string.
	 *
	 * @param string $value Raw input.
	 * @return string|null Sanitized value or null if invalid.
	 */
	public static function sanitize_ip_or_cidr( $value ) {
		$sanitized = sanitize_text_field( trim( (string) $value ) );
		if ( '' === $sanitized ) {
			return null;
		}

		if (
			filter_var( $sanitized, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ||
			filter_var( $sanitized, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 )
		) {
			return $sanitized;
		}

		if ( strpos( $sanitized, '/' ) !== false && self::is_valid_cidr( $sanitized ) ) {
			return $sanitized;
		}

		return null;
	}

	/**
	 * Add IP to manual blacklist or whitelist.
	 *
	 * @param string $ip        IP or CIDR.
	 * @param string $list_type blacklist|whitelist.
	 * @param string $note      Optional note/label.
	 * @return array|\WP_Error Entries payload or error.
	 */
	public static function add_manual_rule( $ip, $list_type, $note = '' ) {
		$ip = self::sanitize_ip_or_cidr( $ip );
		if ( null === $ip ) {
			return new \WP_Error( 'invalid_ip', __( 'Please enter a valid IP address or CIDR range.', 'security-ninja' ) );
		}

		$list_type = sanitize_key( $list_type );
		if ( ! in_array( $list_type, array( 'blacklist', 'whitelist' ), true ) ) {
			return new \WP_Error( 'invalid_list', __( 'Invalid list type.', 'security-ninja' ) );
		}

		$options = Wf_sn_cf::get_options();
		if ( ! is_array( $options['blacklist'] ) ) {
			$options['blacklist'] = array();
		}
		if ( ! is_array( $options['whitelist'] ) ) {
			$options['whitelist'] = array();
		}
		self::ensure_notes_maps( $options );

		$target_key   = 'blacklist' === $list_type ? 'blacklist' : 'whitelist';
		$opposite_key = 'blacklist' === $list_type ? 'whitelist' : 'blacklist';

		if ( ! in_array( $ip, $options[ $target_key ], true ) ) {
			$options[ $target_key ][] = $ip;
		}

		$options[ $opposite_key ] = array_values(
			array_filter(
				$options[ $opposite_key ],
				function ( $existing ) use ( $ip ) {
					return trim( (string) $existing ) !== $ip;
				}
			)
		);
		self::clear_note( $options, $opposite_key, $ip );

		$options[ $target_key ] = array_values( array_unique( $options[ $target_key ] ) );
		self::set_note( $options, $list_type, $ip, $note );
		self::prune_notes( $options );

		update_option( WF_SN_CF_OPTIONS_KEY, $options, false );
		Wf_sn_cf::$options = $options;

		return self::build_ajax_payload( $options );
	}

	/**
	 * Add multiple IPs from newline-separated text to manual blacklist or whitelist.
	 *
	 * @param string $raw       One IP or CIDR per line.
	 * @param string $list_type blacklist|whitelist.
	 * @param string $note      Optional note applied to each newly added IP.
	 * @return array|\WP_Error Payload with entries and summary counts.
	 */
	public static function add_manual_rules_bulk( $raw, $list_type, $note = '' ) {
		$list_type = sanitize_key( $list_type );
		if ( ! in_array( $list_type, array( 'blacklist', 'whitelist' ), true ) ) {
			return new \WP_Error( 'invalid_list', __( 'Invalid list type.', 'security-ninja' ) );
		}

		$lines = preg_split( '/\r\n|\r|\n/', (string) $raw );
		$valid_ips           = array();
		$invalid_count       = 0;
		$paste_duplicate_cnt = 0;

		if ( is_array( $lines ) ) {
			foreach ( $lines as $line ) {
				$line = trim( (string) $line );
				if ( '' === $line ) {
					continue;
				}

				$ip = self::sanitize_ip_or_cidr( $line );
				if ( null === $ip ) {
					++$invalid_count;
					continue;
				}

				if ( in_array( $ip, $valid_ips, true ) ) {
					++$paste_duplicate_cnt;
					continue;
				}

				$valid_ips[] = $ip;
			}
		}

		if ( empty( $valid_ips ) ) {
			if ( $invalid_count > 0 ) {
				return new \WP_Error(
					'invalid_ip',
					__( 'No valid IP addresses or CIDR ranges found.', 'security-ninja' )
				);
			}

			return new \WP_Error(
				'invalid_ip',
				__( 'Please enter at least one IP address or CIDR range.', 'security-ninja' )
			);
		}

		$options = Wf_sn_cf::get_options();
		if ( ! is_array( $options['blacklist'] ) ) {
			$options['blacklist'] = array();
		}
		if ( ! is_array( $options['whitelist'] ) ) {
			$options['whitelist'] = array();
		}
		self::ensure_notes_maps( $options );

		$target_key   = 'blacklist' === $list_type ? 'blacklist' : 'whitelist';
		$opposite_key = 'blacklist' === $list_type ? 'whitelist' : 'blacklist';
		$note         = self::sanitize_note( $note );

		$added              = 0;
		$list_duplicate_cnt = 0;
		$newly_added        = array();

		foreach ( $valid_ips as $ip ) {
			if ( in_array( $ip, $options[ $target_key ], true ) ) {
				++$list_duplicate_cnt;
				continue;
			}

			$options[ $target_key ][] = $ip;
			$newly_added[]            = $ip;
			++$added;
		}

		$options[ $opposite_key ] = array_values(
			array_filter(
				$options[ $opposite_key ],
				function ( $existing ) use ( $valid_ips ) {
					return ! in_array( trim( (string) $existing ), $valid_ips, true );
				}
			)
		);
		foreach ( $valid_ips as $ip ) {
			self::clear_note( $options, $opposite_key, $ip );
		}

		$options[ $target_key ] = array_values( array_unique( $options[ $target_key ] ) );

		if ( '' !== $note ) {
			foreach ( $newly_added as $ip ) {
				self::set_note( $options, $list_type, $ip, $note );
			}
		}
		self::prune_notes( $options );

		update_option( WF_SN_CF_OPTIONS_KEY, $options, false );
		Wf_sn_cf::$options = $options;

		$payload            = self::build_ajax_payload( $options );
		$payload['summary'] = array(
			'added'             => $added,
			'invalid'           => $invalid_count,
			'skipped_duplicate' => $paste_duplicate_cnt + $list_duplicate_cnt,
		);

		return $payload;
	}

	/**
	 * Edit a manual blacklist or whitelist rule (change IP/CIDR and/or list type).
	 *
	 * @param string $old_ip    Current IP or CIDR in the list.
	 * @param string $new_ip    New IP or CIDR.
	 * @param string $list_type blacklist|whitelist.
	 * @param string $note      Optional note/label for the updated rule.
	 * @return array|\WP_Error
	 */
	public static function edit_manual_rule( $old_ip, $new_ip, $list_type, $note = '' ) {
		$old_ip = sanitize_text_field( trim( (string) $old_ip ) );
		if ( '' === $old_ip ) {
			return new \WP_Error( 'invalid_ip', __( 'Invalid IP address.', 'security-ninja' ) );
		}

		$new_ip = self::sanitize_ip_or_cidr( $new_ip );
		if ( null === $new_ip ) {
			return new \WP_Error( 'invalid_ip', __( 'Please enter a valid IP address or CIDR range.', 'security-ninja' ) );
		}

		$list_type = sanitize_key( $list_type );
		if ( ! in_array( $list_type, array( 'blacklist', 'whitelist' ), true ) ) {
			return new \WP_Error( 'invalid_list', __( 'Invalid list type.', 'security-ninja' ) );
		}

		$options = Wf_sn_cf::get_options();
		if ( ! is_array( $options['blacklist'] ) ) {
			$options['blacklist'] = array();
		}
		if ( ! is_array( $options['whitelist'] ) ) {
			$options['whitelist'] = array();
		}
		self::ensure_notes_maps( $options );

		$from_list = 'whitelist';
		if ( in_array( $old_ip, array_map( 'strval', $options['blacklist'] ), true ) ) {
			$from_list = 'blacklist';
		} elseif ( in_array( $old_ip, array_map( 'strval', $options['whitelist'] ), true ) ) {
			$from_list = 'whitelist';
		}

		foreach ( array( 'blacklist', 'whitelist' ) as $key ) {
			$options[ $key ] = array_values(
				array_filter(
					$options[ $key ],
					function ( $existing ) use ( $old_ip ) {
						return trim( (string) $existing ) !== $old_ip;
					}
				)
			);
		}

		self::move_note( $options, $from_list, $old_ip, $list_type, $new_ip, $note );

		update_option( WF_SN_CF_OPTIONS_KEY, $options, false );
		Wf_sn_cf::$options = $options;

		return self::add_manual_rule( $new_ip, $list_type, $note );
	}

	/**
	 * Remove IP from manual blacklist or whitelist.
	 *
	 * @param string $ip        IP or CIDR.
	 * @param string $list_type blacklist|whitelist.
	 * @return array|\WP_Error
	 */
	public static function remove_manual_rule( $ip, $list_type ) {
		$ip = sanitize_text_field( trim( (string) $ip ) );
		if ( '' === $ip ) {
			return new \WP_Error( 'invalid_ip', __( 'Invalid IP address.', 'security-ninja' ) );
		}

		$list_type = sanitize_key( $list_type );
		if ( ! in_array( $list_type, array( 'blacklist', 'whitelist' ), true ) ) {
			return new \WP_Error( 'invalid_list', __( 'Invalid list type.', 'security-ninja' ) );
		}

		$options    = Wf_sn_cf::get_options();
		$target_key = 'blacklist' === $list_type ? 'blacklist' : 'whitelist';

		if ( ! is_array( $options[ $target_key ] ) ) {
			$options[ $target_key ] = array();
		}
		self::ensure_notes_maps( $options );

		$options[ $target_key ] = array_values(
			array_filter(
				$options[ $target_key ],
				function ( $existing ) use ( $ip ) {
					return trim( (string) $existing ) !== $ip;
				}
			)
		);
		self::clear_note( $options, $list_type, $ip );
		self::prune_notes( $options );

		update_option( WF_SN_CF_OPTIONS_KEY, $options, false );
		Wf_sn_cf::$options = $options;

		return self::build_ajax_payload( $options );
	}

	/**
	 * Remove a temporary local ban (wf_sn_banned_ips).
	 *
	 * @param string $ip IP address.
	 * @return array|\WP_Error
	 */
	public static function lift_local_ban( $ip ) {
		$ip = sanitize_text_field( trim( (string) $ip ) );
		if ( '' === $ip || ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
			return new \WP_Error( 'invalid_ip', __( 'Invalid IP address.', 'security-ninja' ) );
		}

		$banned_ips = Wf_sn_cf::get_banned_ips();
		if ( ! is_array( $banned_ips ) ) {
			$banned_ips = array();
		}

		if ( isset( $banned_ips[ $ip ] ) ) {
			unset( $banned_ips[ $ip ] );
			Wf_sn_cf::update_banned_ips( $banned_ips );
		}

		return self::build_ajax_payload( Wf_sn_cf::get_options() );
	}

	/**
	 * Lift 404 Guard ban transients for an IP.
	 *
	 * @param string $ip IP address.
	 * @return array|\WP_Error
	 */
	public static function lift_404_guard_ban( $ip ) {
		$ip = sanitize_text_field( trim( (string) $ip ) );
		if ( '' === $ip || ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
			return new \WP_Error( 'invalid_ip', __( 'Invalid IP address.', 'security-ninja' ) );
		}

		if ( ! class_exists( __NAMESPACE__ . '\SN_404_Guard' ) ) {
			$guard_file = WF_SN_PLUGIN_DIR . 'modules/cloud-firewall/class-sn-404-guard.php';
			if ( file_exists( $guard_file ) ) {
				require_once $guard_file;
			}
		}

		if ( class_exists( __NAMESPACE__ . '\SN_404_Guard' ) ) {
			SN_404_Guard::lift_ban_for_ip( $ip );
		}

		return self::build_ajax_payload( Wf_sn_cf::get_options() );
	}

	/**
	 * IP entries for MainWP sync (capped).
	 *
	 * @param int $max_entries Maximum rows.
	 * @return array{entries: array, truncated: bool}
	 */
	public static function get_ip_entries_for_mainwp_sync( $max_entries = 200 ) {
		$max_entries = max( 1, min( 500, (int) $max_entries ) );
		$entries     = self::get_ip_management_entries();
		$truncated   = count( $entries ) > $max_entries;
		if ( $truncated ) {
			$entries = array_slice( $entries, 0, $max_entries );
		}

		return array(
			'entries'   => self::entries_for_js( $entries ),
			'truncated' => $truncated,
		);
	}

	/**
	 * Handle MainWP manage_ip remote action.
	 *
	 * @param array $post_data Request payload from MainWP.
	 * @return array
	 */
	public static function handle_mainwp_manage_ip_request( $post_data ) {
		$response = array(
			'success' => false,
			'message' => __( 'IP management is not available.', 'security-ninja' ),
		);

		$sub_action = isset( $post_data['sub_action'] ) ? sanitize_key( $post_data['sub_action'] ) : '';
		$ip         = isset( $post_data['ip'] ) ? sanitize_text_field( wp_unslash( $post_data['ip'] ) ) : '';
		$note       = isset( $post_data['note'] ) ? self::sanitize_note( wp_unslash( $post_data['note'] ) ) : '';

		if ( '' === $sub_action || '' === $ip ) {
			$response['message'] = __( 'Missing required fields.', 'security-ninja' );
			return $response;
		}

		$result = null;
		switch ( $sub_action ) {
			case 'add_whitelist':
				$result = self::add_manual_rule( $ip, 'whitelist', $note );
				break;
			case 'add_blacklist':
				$result = self::add_manual_rule( $ip, 'blacklist', $note );
				break;
			case 'remove_whitelist':
				$result = self::remove_manual_rule( $ip, 'whitelist' );
				break;
			case 'remove_blacklist':
				$result = self::remove_manual_rule( $ip, 'blacklist' );
				break;
			case 'lift_local_ban':
				$result = self::lift_local_ban( $ip );
				break;
			case 'lift_404_guard_ban':
				$result = self::lift_404_guard_ban( $ip );
				break;
			default:
				$response['message'] = __( 'Unknown IP action.', 'security-ninja' );
				return $response;
		}

		if ( is_wp_error( $result ) ) {
			$response['message'] = $result->get_error_message();
			return $response;
		}

		$ip_sync = self::get_ip_entries_for_mainwp_sync( 200 );
		$options = Wf_sn_cf::get_options();

		wf_sn_el_modules::log_event(
			'security_ninja',
			'mainwp_ip_change',
			sprintf(
				/* translators: 1: action, 2: IP */
				__( 'MainWP changed IP rule: %1$s for %2$s', 'security-ninja' ),
				$sub_action,
				$ip
			),
			array(
				'source'     => 'mainwp',
				'sub_action' => $sub_action,
				'ip'         => $ip,
			)
		);

		$response['success']    = true;
		$response['message']    = __( 'IP rule updated.', 'security-ninja' );
		$response['ip_entries'] = $ip_sync['entries'];
		$response['truncated']  = $ip_sync['truncated'];
		$response['firewall']   = array(
			'whitelist'       => isset( $options['whitelist'] ) && is_array( $options['whitelist'] ) ? $options['whitelist'] : array(),
			'blacklist'       => isset( $options['blacklist'] ) && is_array( $options['blacklist'] ) ? $options['blacklist'] : array(),
			'whitelist_notes' => isset( $options['whitelist_notes'] ) && is_array( $options['whitelist_notes'] ) ? $options['whitelist_notes'] : array(),
			'blacklist_notes' => isset( $options['blacklist_notes'] ) && is_array( $options['blacklist_notes'] ) ? $options['blacklist_notes'] : array(),
		);

		return $response;
	}

	/**
	 * Collect block/allow reasons for Test IP.
	 *
	 * @param string $ip Validated IP.
	 * @return array{blocked: bool, whitelisted: bool, reasons: string[]}
	 */
	public static function get_block_reasons_for_ip( $ip ) {
		$reasons     = array();
		$whitelisted = false;

		$options = Wf_sn_cf::get_options();
		$wl      = isset( $options['whitelist'] ) && is_array( $options['whitelist'] ) ? $options['whitelist'] : array();
		if ( Wf_sn_cf_Utils::is_whitelisted( $ip, $wl ) ) {
			$whitelisted = true;
		}

		if ( ! class_exists( __NAMESPACE__ . '\SN_404_Guard' ) ) {
			$guard_file = WF_SN_PLUGIN_DIR . 'modules/cloud-firewall/class-sn-404-guard.php';
			if ( file_exists( $guard_file ) ) {
				require_once $guard_file;
			}
		}

		if ( class_exists( __NAMESPACE__ . '\SN_404_Guard' ) && SN_404_Guard::is_ip_blocked( $ip ) ) {
			$reasons[] = __( '404 Guard (excessive 404s)', 'security-ninja' );
		}

		$ban_reason = Wf_sn_cf::is_banned_ip( $ip );
		if ( $ban_reason ) {
			$reasons[] = $ban_reason;
		}

		$blocked = ! empty( $reasons ) && ! $whitelisted;

		return array(
			'blocked'     => $blocked,
			'whitelisted' => $whitelisted,
			'reasons'     => array_unique( $reasons ),
		);
	}

	/**
	 * Human-readable source label.
	 *
	 * @param string $source Internal source key.
	 * @return string
	 */
	public static function source_label( $source ) {
		$labels = array(
			self::SOURCE_MANUAL_BLACKLIST => __( 'Manual', 'security-ninja' ),
			self::SOURCE_MANUAL_WHITELIST => __( 'Manual', 'security-ninja' ),
			self::SOURCE_LOCAL_BAN        => __( 'Brute force / Firewall', 'security-ninja' ),
			self::SOURCE_404_GUARD        => __( '404 Guard', 'security-ninja' ),
		);

		return isset( $labels[ $source ] ) ? $labels[ $source ] : $source;
	}

	/**
	 * Format expiry for display.
	 *
	 * @param int|null $expiry Unix timestamp or null for never.
	 * @return string
	 */
	public static function format_expires_display( $expiry ) {
		if ( null === $expiry || 0 === $expiry ) {
			return __( 'Never', 'security-ninja' );
		}

		$now = current_time( 'timestamp' );
		if ( $expiry <= $now ) {
			return __( 'Expired', 'security-ninja' );
		}

		return human_time_diff( $now, $expiry );
	}

	/**
	 * Format last visit for display.
	 *
	 * @param string|null $mysql_datetime MySQL datetime or null.
	 * @return string
	 */
	public static function format_last_visit_display( $mysql_datetime ) {
		if ( empty( $mysql_datetime ) ) {
			return '-';
		}

		$ts = strtotime( $mysql_datetime );
		if ( ! $ts ) {
			return '-';
		}

		return human_time_diff( $ts, current_time( 'timestamp' ) ) . ' ' . __( 'ago', 'security-ninja' );
	}

	/**
	 * Entries formatted for JS localization.
	 *
	 * @param array $entries Raw entries from get_ip_management_entries.
	 * @return array
	 */
	public static function entries_for_js( array $entries ) {
		$out = array();
		foreach ( $entries as $entry ) {
			$sources = array();
			foreach ( $entry['sources'] as $src ) {
				$sources[] = self::source_label( $src );
			}

			$out[] = array(
				'ip'           => $entry['ip'],
				'status'       => $entry['status'],
				'sources'      => $sources,
				'sources_raw'  => $entry['sources'],
				'expires'      => $entry['expires'],
				'expires_text' => self::format_expires_display( $entry['expires'] ),
				'last_visit'   => $entry['last_visit'],
				'last_visit_text' => self::format_last_visit_display( $entry['last_visit'] ),
				'editable'     => ! empty( $entry['editable'] ),
				'temporary'    => ! empty( $entry['temporary'] ),
				'list_type'    => isset( $entry['list_type'] ) ? $entry['list_type'] : '',
				'note'         => isset( $entry['note'] ) ? (string) $entry['note'] : '',
			);
		}
		return $out;
	}

	/**
	 * AJAX response payload with entries and manual list text.
	 *
	 * @param array $options Firewall options.
	 * @return array
	 */
	public static function build_ajax_payload( $options ) {
		$entries = self::get_ip_management_entries( $options );

		return array(
			'entries' => self::entries_for_js( $entries ),
		);
	}

	/**
	 * @param string   $ip        IP or CIDR.
	 * @param string   $status    whitelisted|blacklisted.
	 * @param string   $source    Source constant.
	 * @param int|null $expires   Unix expiry or null.
	 * @param bool     $editable  Manual rule row.
	 * @param bool     $temporary Temp block row.
	 * @param string   $note      Optional note/label.
	 * @return array
	 */
	private static function make_entry( $ip, $status, $source, $expires, $editable, $temporary, $note = '' ) {
		$list_type = '';
		if ( self::SOURCE_MANUAL_BLACKLIST === $source ) {
			$list_type = 'blacklist';
		} elseif ( self::SOURCE_MANUAL_WHITELIST === $source ) {
			$list_type = 'whitelist';
		}

		return array(
			'ip'         => $ip,
			'status'     => $status,
			'sources'    => array( $source ),
			'expires'    => $expires,
			'editable'   => $editable,
			'temporary'  => $temporary,
			'list_type'  => $list_type,
			'last_visit' => null,
			'note'       => self::sanitize_note( $note ),
		);
	}

	/**
	 * Merge rows by IP with dedupe rules.
	 *
	 * @param array $raw Raw entries.
	 * @return array
	 */
	private static function merge_entries( array $raw ) {
		$by_ip = array();

		foreach ( $raw as $entry ) {
			$ip = $entry['ip'];
			if ( ! isset( $by_ip[ $ip ] ) ) {
				$by_ip[ $ip ] = $entry;
				continue;
			}

			$existing = $by_ip[ $ip ];

			foreach ( $entry['sources'] as $src ) {
				if ( ! in_array( $src, $existing['sources'], true ) ) {
					$existing['sources'][] = $src;
				}
			}

			if ( $entry['editable'] ) {
				$existing['editable']  = true;
				$existing['list_type'] = $entry['list_type'];
			}

			if ( ! empty( $entry['note'] ) ) {
				$existing['note'] = $entry['note'];
			} elseif ( ! isset( $existing['note'] ) ) {
				$existing['note'] = '';
			}

			if ( $entry['temporary'] ) {
				$existing['temporary'] = true;
			}

			if ( null !== $entry['expires'] ) {
				if ( null === $existing['expires'] || $entry['expires'] < $existing['expires'] ) {
					$existing['expires'] = $entry['expires'];
				}
			}

			$has_manual_whitelist = in_array( self::SOURCE_MANUAL_WHITELIST, $existing['sources'], true );
			if ( $has_manual_whitelist ) {
				$existing['status'] = 'whitelisted';
			} elseif ( in_array( self::SOURCE_MANUAL_BLACKLIST, $existing['sources'], true ) ||
				in_array( self::SOURCE_LOCAL_BAN, $existing['sources'], true ) ||
				in_array( self::SOURCE_404_GUARD, $existing['sources'], true ) ) {
				$existing['status'] = 'blacklisted';
			}

			$by_ip[ $ip ] = $existing;
		}

		return array_values( $by_ip );
	}

	/**
	 * @param string $ip IP string.
	 * @return bool
	 */
	private static function is_exact_ip( $ip ) {
		return (bool) filter_var( $ip, FILTER_VALIDATE_IP );
	}

	/**
	 * @param string $cidr CIDR notation.
	 * @return bool
	 */
	private static function is_valid_cidr( $cidr ) {
		$parts = explode( '/', $cidr );
		if ( count( $parts ) !== 2 ) {
			return false;
		}

		$subnet = $parts[0];
		$mask   = (int) $parts[1];

		if ( filter_var( $subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
			return $mask >= 0 && $mask <= 32;
		}
		if ( filter_var( $subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
			return $mask >= 0 && $mask <= 128;
		}

		return false;
	}
}
