<?php
/**
 * Unified Ban Manager
 *
 * Handles all ban operations for phone numbers, WebRTC visitors, and IP addresses.
 * Replaces the old SiteStaffr_Banned_Numbers_Manager with a unified system.
 *
 * @link       https://github.com/sitestaffr/sitestaffr-plugin
 * @since      0.11.75
 *
 * @package    SiteStaffr
 * @subpackage SiteStaffr/includes
 */

/**
 * Unified Ban Manager class.
 *
 * Manages CRUD operations for all ban types (phone, visitor, IP) in a single system.
 * Uses prefixed identifiers for storage: phone:+17863337300, visitor:abc123, ip:192.168.1.1
 *
 * @since      0.11.75
 * @package    SiteStaffr
 * @subpackage SiteStaffr/includes
 * @author     SiteStaffr
 */
class SiteStaffr_Ban_Manager {

	/**
	 * WordPress option name for storing banned users (unified system).
	 *
	 * @since    0.11.75
	 * @var      string
	 */
	const OPTION_NAME = 'sitestaffr_banned_users';

	/**
	 * Legacy option name for phone-only bans (for migration).
	 *
	 * @since    0.11.75
	 * @var      string
	 */
	const LEGACY_OPTION_NAME = 'sitestaffr_banned_numbers';

	/**
	 * Migration marker option for legacy ban import.
	 *
	 * Prevents repeated re-migration when unified list becomes empty.
	 *
	 * @since    0.14.69
	 * @var      string
	 */
	const LEGACY_MIGRATION_FLAG = 'sitestaffr_bans_legacy_migrated';

	/**
	 * Ban a user (phone, visitor, or IP).
	 *
	 * Adds an identifier to the unified ban list with reason and metadata.
	 * Supports automatic migration from old phone-only ban system.
	 *
	 * @since    0.11.75
	 * @param    string    $type         Ban type ('phone', 'visitor', 'ip').
	 * @param    string    $identifier   Identifier to ban (phone number, visitor hash, IP address).
	 * @param    string    $reason       Ban reason (optional).
	 * @param    array     $metadata     Additional metadata (optional).
	 * @return   bool|WP_Error           True on success, WP_Error on failure.
	 */
	public static function ban( $type, $identifier, $reason = '', $metadata = array() ) {
		// Sanitize inputs
		$type       = sanitize_text_field( $type );
		$identifier = sanitize_text_field( $identifier );
		$reason     = sanitize_text_field( $reason );

		// Validate type
		$valid_types = array( 'phone', 'visitor', 'ip' );
		if ( ! in_array( $type, $valid_types, true ) ) {
			SiteStaffr_Logger::legacy( "SiteStaffr Ban: Invalid ban type '{$type}'" );
			return new WP_Error(
				'invalid_type',
				sprintf( 'Invalid ban type. Must be one of: %s', implode( ', ', $valid_types ) )
			);
		}

		// Type-specific validation
		if ( 'phone' === $type ) {
			// Format phone number to E.164
			$identifier = self::format_phone_number( $identifier );
			if ( ! self::is_valid_phone_number( $identifier ) ) {
				SiteStaffr_Logger::legacy( "SiteStaffr Ban: Invalid phone number '{$identifier}'" );
				return new WP_Error(
					'invalid_phone',
					'Invalid phone number format. Must be a valid US/Canada number.'
				);
			}
		} elseif ( 'ip' === $type ) {
			// Validate IP address
			if ( ! filter_var( $identifier, FILTER_VALIDATE_IP ) ) {
				SiteStaffr_Logger::legacy( "SiteStaffr Ban: Invalid IP address '{$identifier}'" );
				return new WP_Error(
					'invalid_ip',
					'Invalid IP address format.'
				);
			}

			$identifier = self::normalize_ip_identifier( $identifier );
		}

		// Build prefixed key
		$key = $type . ':' . $identifier;

		// Get current banned users (auto-migrates legacy data if needed)
		$banned_users = self::get_all_bans_raw();

		// Check if already banned
		if ( isset( $banned_users[ $key ] ) ) {
			SiteStaffr_Logger::legacy( "SiteStaffr Ban: Already banned - {$key}" );
			return new WP_Error(
				'already_banned',
				'This identifier is already banned.'
			);
		}

		// Get current user ID for banned_by field
		$user_id = get_current_user_id();

		// Set default reason if not provided
		if ( empty( $reason ) ) {
			$reason = 'Manual ban by admin';
		}

		// Build display name based on type
		$display = self::build_display_name( $type, $identifier, $metadata );

		// Create ban entry
		$banned_users[ $key ] = array(
			'type'       => $type,
			'identifier' => $identifier,
			'display'    => $display,
			'flagged_at' => current_time( 'mysql' ),
			'reason'     => $reason,
			'banned_by'  => $user_id,
			'ban_type'   => ! empty( $metadata['auto'] ) ? 'auto' : 'manual',
			'source'     => ( 'phone' === $type ) ? 'phone' : 'webrtc',
			'metadata'   => $metadata,
		);

		// Update option in database
		$updated = update_option( self::OPTION_NAME, $banned_users, false );

		if ( ! $updated && get_option( self::OPTION_NAME ) !== $banned_users ) {
			SiteStaffr_Logger::legacy( "SiteStaffr Ban: Failed to update banned users option for {$key}" );
			return new WP_Error(
				'update_failed',
				'Failed to save banned user to database.'
			);
		}

		// Log success
		SiteStaffr_Logger::legacy( "SiteStaffr Ban: Banned {$key} - {$reason}" );

		return true;
	}

	/**
	 * Unban a user (phone, visitor, or IP).
	 *
	 * Removes an identifier from the unified ban list.
	 *
	 * @since    0.11.75
	 * @param    string    $type         Ban type ('phone', 'visitor', 'ip').
	 * @param    string    $identifier   Identifier to unban.
	 * @return   bool|WP_Error           True on success, WP_Error on failure.
	 */
	public static function unban( $type, $identifier ) {
		// Sanitize inputs
		$type       = sanitize_text_field( $type );
		$identifier = sanitize_text_field( $identifier );

		// Format phone numbers
		if ( 'phone' === $type ) {
			$identifier = self::format_phone_number( $identifier );
		} elseif ( 'ip' === $type ) {
			$identifier = self::normalize_ip_identifier( $identifier );
		}

		// Build prefixed key
		$key = $type . ':' . $identifier;

		// Get current banned users
		$banned_users = self::get_all_bans_raw();

		// Check if actually banned
		if ( ! isset( $banned_users[ $key ] ) ) {
			SiteStaffr_Logger::legacy( "SiteStaffr Ban: Attempted to unban non-banned identifier: {$key}" );
			return new WP_Error(
				'not_banned',
				'This identifier is not currently banned.'
			);
		}

		// Remove from array
		unset( $banned_users[ $key ] );

		// Update option in database
		$updated = update_option( self::OPTION_NAME, $banned_users, false );

		if ( ! $updated && get_option( self::OPTION_NAME ) !== $banned_users ) {
			SiteStaffr_Logger::legacy( "SiteStaffr Ban: Failed to update banned users option when unbanning: {$key}" );
			return new WP_Error(
				'update_failed',
				'Failed to remove banned user from database.'
			);
		}

		// Log success
		SiteStaffr_Logger::legacy( "SiteStaffr Ban: Unbanned {$key}" );

		// Keep legacy storage in sync so removed entries do not reappear.
		if ( 'phone' === $type ) {
			self::purge_legacy_phone_ban( $identifier );
		}

		return true;
	}

	/**
	 * Unban a user by exact stored key.
	 *
	 * Useful for legacy/migrated data where identifier normalization may not
	 * reconstruct the original key perfectly.
	 *
	 * @since    0.14.68
	 * @param    string    $key    Prefixed key (example: phone:+17863337300).
	 * @return   bool|WP_Error     True on success, WP_Error on failure.
	 */
	public static function unban_by_key( $key ) {
		$key = sanitize_text_field( $key );
		if ( '' === $key ) {
			return new WP_Error( 'invalid_key', 'Invalid ban key.' );
		}

		$banned_users = self::get_all_bans_raw();
		if ( ! isset( $banned_users[ $key ] ) ) {
			SiteStaffr_Logger::legacy( "SiteStaffr Ban: Attempted to unban non-banned key: {$key}" );
			return new WP_Error( 'not_banned', 'This identifier is not currently banned.' );
		}

		$removed_entry = $banned_users[ $key ];
		unset( $banned_users[ $key ] );
		$updated = update_option( self::OPTION_NAME, $banned_users, false );
		if ( ! $updated && get_option( self::OPTION_NAME ) !== $banned_users ) {
			SiteStaffr_Logger::legacy( "SiteStaffr Ban: Failed to update banned users option when unbanning key: {$key}" );
			return new WP_Error( 'update_failed', 'Failed to remove banned user from database.' );
		}

		SiteStaffr_Logger::legacy( "SiteStaffr Ban: Unbanned by key {$key}" );

		// Keep legacy storage in sync for phone bans.
		if ( isset( $removed_entry['type'] ) && 'phone' === $removed_entry['type'] && isset( $removed_entry['identifier'] ) ) {
			self::purge_legacy_phone_ban( (string) $removed_entry['identifier'] );
		}

		return true;
	}

	/**
	 * Check if an identifier is banned.
	 *
	 * @since    0.11.75
	 * @param    string    $type         Ban type ('phone', 'visitor', 'ip').
	 * @param    string    $identifier   Identifier to check.
	 * @return   bool                    True if banned, false otherwise.
	 */
	public static function is_banned( $type, $identifier ) {
		// Sanitize inputs
		$type       = sanitize_text_field( $type );
		$identifier = sanitize_text_field( $identifier );

		// Format phone numbers
		if ( 'phone' === $type ) {
			$identifier = self::format_phone_number( $identifier );
		} elseif ( 'ip' === $type ) {
			$identifier = self::normalize_ip_identifier( $identifier );
		}

		// Build prefixed key
		$key = $type . ':' . $identifier;

		// Get banned users list
		$banned_users = self::get_all_bans_raw();

		// Check if key exists in banned list
		if ( isset( $banned_users[ $key ] ) ) {
			return true;
		}

		// Backward-compatibility for pre-normalization IP keys.
		if ( 'ip' === $type && ! empty( $banned_users ) ) {
			$target_ip_normalized = self::normalize_ip_identifier( $identifier );

			foreach ( $banned_users as $row ) {
				if ( ! is_array( $row ) || empty( $row['type'] ) || 'ip' !== $row['type'] || empty( $row['identifier'] ) ) {
					continue;
				}

				$row_ip_normalized = self::normalize_ip_identifier( (string) $row['identifier'] );
				if ( $row_ip_normalized === $target_ip_normalized ) {
					return true;
				}
			}
		}

		return false;
	}

	/**
	 * Get all bans (filterable).
	 *
	 * Returns formatted array of all banned users with optional filters.
	 *
	 * @since    0.11.75
	 * @param    array    $filters    Optional filters (type, source, ban_type).
	 * @return   array                Formatted array of banned users.
	 */
	public static function get_bans( $filters = array() ) {
		// Get all bans
		$banned_users = self::get_all_bans_raw();

		// If no bans, return empty array
		if ( empty( $banned_users ) ) {
			return array();
		}

		// Format for display
		$formatted = array();
		foreach ( $banned_users as $key => $data ) {
			// Apply filters if provided
			if ( ! empty( $filters ) ) {
				// Filter by type
				if ( isset( $filters['type'] ) && $data['type'] !== $filters['type'] ) {
					continue;
				}

				// Filter by source
				if ( isset( $filters['source'] ) && $data['source'] !== $filters['source'] ) {
					continue;
				}

				// Filter by ban_type
				if ( isset( $filters['ban_type'] ) && $data['ban_type'] !== $filters['ban_type'] ) {
					continue;
				}
			}

			// Add to formatted list
			$formatted[] = array(
				'key'         => $key,
				'type'        => $data['type'],
				'identifier'  => $data['identifier'],
				'display'     => $data['display'],
				'date'        => self::format_date( $data['flagged_at'] ),
				'reason'      => stripslashes( $data['reason'] ),
				'ban_type'    => $data['ban_type'],
				'source'      => $data['source'],
				'banned_by'   => $data['banned_by'],
				'metadata'    => isset( $data['metadata'] ) ? $data['metadata'] : array(),
				'flagged_at'  => $data['flagged_at'],
			);
		}

		// Sort by most recent first (flagged_at DESC)
		usort( $formatted, function( $a, $b ) {
			return strcmp( $b['flagged_at'], $a['flagged_at'] );
		});

		return $formatted;
	}

	/**
	 * Get all banned users (raw data with auto-migration).
	 *
	 * Automatically migrates legacy phone-only bans to unified system on first access.
	 *
	 * @since    0.11.75
	 * @return   array    Raw banned users array.
	 */
	private static function get_all_bans_raw() {
		// Get unified bans
		$banned_users = get_option( self::OPTION_NAME, array() );
		if ( ! is_array( $banned_users ) ) {
			$banned_users = array();
		}

		$legacy_migrated = (bool) get_option( self::LEGACY_MIGRATION_FLAG, false );

		// Unified list exists but migration flag missing from older versions.
		// Mark migrated so we don't re-import legacy rows later.
		if ( ! $legacy_migrated && ! empty( $banned_users ) ) {
			update_option( self::LEGACY_MIGRATION_FLAG, true, false );
			$legacy_migrated = true;
		}

		// Check if migration needed (legacy option exists and unified is empty)
		$legacy_bans = get_option( self::LEGACY_OPTION_NAME, array() );
		if ( ! empty( $legacy_bans ) && is_array( $legacy_bans ) && empty( $banned_users ) && ! $legacy_migrated ) {
			SiteStaffr_Logger::legacy( 'SiteStaffr Ban: Migrating ' . count( $legacy_bans ) . ' legacy phone bans to unified system' );

			// Migrate each legacy phone ban
			foreach ( $legacy_bans as $phone => $data ) {
				// Build prefixed key
				$key = 'phone:' . $phone;

				// Build display name
				$display = self::format_phone_display( $phone );

				// Migrate to new format
				$banned_users[ $key ] = array(
					'type'       => 'phone',
					'identifier' => $phone,
					'display'    => $display,
					'flagged_at' => isset( $data['flagged_at'] ) ? $data['flagged_at'] : current_time( 'mysql' ),
					'reason'     => isset( $data['reason'] ) ? $data['reason'] : 'Migrated from legacy system',
					'banned_by'  => isset( $data['banned_by'] ) ? $data['banned_by'] : 0,
					'ban_type'   => isset( $data['ban_type'] ) ? $data['ban_type'] : 'manual',
					'source'     => 'phone',
					'metadata'   => array(
						'migrated'        => true,
						'legacy_call_id'  => isset( $data['call_id'] ) ? $data['call_id'] : null,
					),
				);
			}

			// Save migrated data
			update_option( self::OPTION_NAME, $banned_users, false );
			update_option( self::LEGACY_OPTION_NAME, array(), false );
			update_option( self::LEGACY_MIGRATION_FLAG, true, false );

			// Log migration success
			SiteStaffr_Logger::legacy( 'SiteStaffr Ban: Migration complete - ' . count( $banned_users ) . ' phone bans migrated' );
		}

		return $banned_users;
	}

	/**
	 * Remove a phone ban from legacy option storage.
	 *
	 * Some older installs still contain rows in sitestaffr_banned_numbers.
	 * If these aren't cleaned, an empty unified list can be repopulated.
	 *
	 * @since    0.14.69
	 * @param    string $identifier Phone identifier to purge.
	 * @return   void
	 */
	private static function purge_legacy_phone_ban( $identifier ) {
		$legacy_bans = get_option( self::LEGACY_OPTION_NAME, array() );
		if ( ! is_array( $legacy_bans ) || empty( $legacy_bans ) ) {
			return;
		}

		$identifier = sanitize_text_field( $identifier );
		$id_digits = preg_replace( '/\D+/', '', $identifier );
		$id_digits_10 = ( strlen( $id_digits ) >= 10 ) ? substr( $id_digits, -10 ) : $id_digits;
		$changed = false;

		foreach ( array_keys( $legacy_bans ) as $legacy_key ) {
			$legacy_digits = preg_replace( '/\D+/', '', (string) $legacy_key );
			$legacy_digits_10 = ( strlen( $legacy_digits ) >= 10 ) ? substr( $legacy_digits, -10 ) : $legacy_digits;

			if ( (string) $legacy_key === $identifier || ( '' !== $id_digits_10 && $legacy_digits_10 === $id_digits_10 ) ) {
				unset( $legacy_bans[ $legacy_key ] );
				$changed = true;
			}
		}

		if ( $changed ) {
			update_option( self::LEGACY_OPTION_NAME, $legacy_bans, false );
			if ( empty( $legacy_bans ) ) {
				update_option( self::LEGACY_MIGRATION_FLAG, true, false );
			}
		}
	}

	/**
	 * Normalize IP address to a canonical representation.
	 *
	 * Keeps IPv4 stable and compresses IPv6 consistently so different textual
	 * forms of the same address map to one ban key.
	 *
	 * @since    0.14.73
	 * @param    string $identifier Raw IP identifier.
	 * @return   string             Normalized IP identifier.
	 */
	private static function normalize_ip_identifier( $identifier ) {
		$identifier = sanitize_text_field( $identifier );
		if ( ! filter_var( $identifier, FILTER_VALIDATE_IP ) ) {
			return $identifier;
		}

		$packed = @inet_pton( $identifier );
		if ( false === $packed ) {
			return $identifier;
		}

		$normalized = @inet_ntop( $packed );
		if ( false === $normalized ) {
			return $identifier;
		}

		return $normalized;
	}

	/**
	 * Build display name for banned identifier.
	 *
	 * @since    0.11.75
	 * @param    string    $type         Ban type.
	 * @param    string    $identifier   Identifier.
	 * @param    array     $metadata     Additional metadata.
	 * @return   string                  Display name.
	 */
	private static function build_display_name( $type, $identifier, $metadata ) {
		switch ( $type ) {
			case 'phone':
				return self::format_phone_display( $identifier );

			case 'visitor':
				// Show IP if available, otherwise show hash
				if ( ! empty( $metadata['ip_address'] ) ) {
					// Mask IP for privacy
					$ip_parts = explode( '.', $metadata['ip_address'] );
					if ( count( $ip_parts ) === 4 ) {
						return sprintf( 'Web Visitor (%s.%s.x.x)', $ip_parts[0], $ip_parts[1] );
					}
				}
				// Fallback to hash
				return sprintf( 'Web Visitor (%s)', substr( $identifier, 0, 8 ) );

			case 'ip':
				return sprintf( 'IP: %s', $identifier );

			default:
				return $identifier;
		}
	}

	/**
	 * Format phone number to E.164 standard.
	 *
	 * Converts various phone number formats to E.164 (+17863337300).
	 * Supports US/Canada numbers only (+1 prefix).
	 *
	 * @since    0.11.75
	 * @param    string    $phone_number    Raw phone number input.
	 * @return   string                     Formatted phone number or original if invalid.
	 */
	private static function format_phone_number( $phone_number ) {
		// Remove all non-numeric characters except leading +
		$cleaned = preg_replace( '/[^0-9+]/', '', $phone_number );

		// If it already starts with +1, validate and return
		if ( strpos( $cleaned, '+1' ) === 0 ) {
			return $cleaned;
		}

		// If it starts with +, but not +1, return original (invalid for US/Canada)
		if ( strpos( $cleaned, '+' ) === 0 ) {
			return $phone_number;
		}

		// If it starts with 1 and has 11 digits, add +
		if ( strpos( $cleaned, '1' ) === 0 && strlen( $cleaned ) === 11 ) {
			return '+' . $cleaned;
		}

		// If it's 10 digits, add +1
		if ( strlen( $cleaned ) === 10 ) {
			return '+1' . $cleaned;
		}

		// Return original if format is unclear
		return $phone_number;
	}

	/**
	 * Validate phone number format.
	 *
	 * Ensures phone number is in valid E.164 format for US/Canada.
	 *
	 * @since    0.11.75
	 * @param    string    $phone_number    Phone number to validate.
	 * @return   bool                       True if valid, false otherwise.
	 */
	private static function is_valid_phone_number( $phone_number ) {
		// Must start with +1
		if ( strpos( $phone_number, '+1' ) !== 0 ) {
			return false;
		}

		// Remove +1 and check remaining digits
		$digits = substr( $phone_number, 2 );

		// Must have exactly 10 digits after +1
		if ( strlen( $digits ) !== 10 || ! ctype_digit( $digits ) ) {
			return false;
		}

		return true;
	}

	/**
	 * Format phone number for display.
	 *
	 * Converts E.164 format to human-readable format.
	 * Example: +17863337300 => (786) 333-7300
	 *
	 * @since    0.11.75
	 * @param    string    $phone_number    Phone number in E.164 format.
	 * @return   string                     Formatted phone number for display.
	 */
	private static function format_phone_display( $phone_number ) {
		// Remove +1 prefix
		$digits = preg_replace( '/[^0-9]/', '', $phone_number );

		// If not enough digits, return original
		if ( strlen( $digits ) < 10 ) {
			return $phone_number;
		}

		// Take last 10 digits
		$digits = substr( $digits, -10 );

		// Format as (XXX) XXX-XXXX
		return sprintf(
			'(%s) %s-%s',
			substr( $digits, 0, 3 ),
			substr( $digits, 3, 3 ),
			substr( $digits, 6, 4 )
		);
	}

	/**
	 * Format date for display.
	 *
	 * Converts MySQL datetime to human-readable format.
	 * Example: 2026-01-22 14:30:00 => Jan 22, 2026 2:30 PM
	 *
	 * @since    0.11.75
	 * @param    string    $datetime    MySQL datetime string.
	 * @return   string                 Formatted date string.
	 */
	private static function format_date( $datetime ) {
		// Stored via current_time('mysql') — a site-local wall-clock string, so
		// parse in the site timezone before converting to Eastern Time (the
		// site-wide display convention).
		try {
			$date = new DateTime( $datetime, wp_timezone() );
		} catch ( Exception $e ) {
			return '';
		}

		return wp_date( 'M j, Y g:i A', $date->getTimestamp(), new DateTimeZone( 'America/New_York' ) );
	}
}
