<?php
/**
 * Login attempt limiter with rate limiting and auto-blocking.
 *
 * @package ZA_Creative_Login_Shield
 */

defined( 'ABSPATH' ) || exit;

/**
 * Limits failed login attempts per IP with configurable thresholds and lockout.
 */
class ZACLS_Login_Limiter {

	/**
	 * Constructor.
	 */
	public function __construct() {
		$this->register_hooks();
	}

	/**
	 * Register hooks.
	 */
	private function register_hooks() {
		add_filter( 'authenticate', array( $this, 'check_login_limits' ), 9, 3 );
		add_action( 'wp_login_failed', array( $this, 'on_login_failed' ) );
		add_action( 'wp_login', array( $this, 'on_login_success' ), 10, 2 );
	}

	/**
	 * Check login limits before authentication.
	 *
	 * @param mixed  $user     User object or WP_Error.
	 * @param string $username Username.
	 * @param string $password Password.
	 * @return mixed
	 */
	public function check_login_limits( $user, $username, $password ) {
		if ( is_wp_error( $user ) ) {
			return $user;
		}

		$ip     = ZACLS_Main::get_ip();
		$limits = get_option(
			'zacls_login_limits',
			array(
				'max_attempts' => 5,
				'time_window'  => 15,
				'lockout_time' => 15,
			)
		);

		$attempts = self::get_attempts_in_window( $ip, $limits['time_window'] );

		if ( $attempts >= $limits['max_attempts'] ) {
			$lockout_end = $this->get_lockout_end( $ip, $limits['lockout_time'] );
			if ( $lockout_end > time() ) {
				$minutes_left = ceil( ( $lockout_end - time() ) / 60 );
				return new WP_Error(
					'too_many_attempts',
					sprintf(
						/* translators: %d: minutes remaining */
						esc_html__( 'Too many failed login attempts. Please try again in %d minute(s).', 'za-creative-login-shield' ),
						$minutes_left
					)
				);
			}
		}

		return $user;
	}

	/**
	 * Handle failed login attempt.
	 *
	 * @param string $username Username.
	 */
	public function on_login_failed( $username ) {
		// Prevent duplicate insert: WordPress fires wp_login_failed twice
		// per failed attempt (once in wp_authenticate_username_password and
		// once in wp_authenticate). The static flag resets per request.
		static $did = false;
		if ( $did ) {
			return;
		}
		$did = true;

		$ip = ZACLS_Main::get_ip();

		if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
			error_log(
				'[ZACLS] wp_login_failed fired | username: ' . $username . ' | ip: ' . $ip
			);
		}

		$this->record_failed_attempt( $username, $ip );

		$limits    = get_option(
			'zacls_login_limits',
			array(
				'max_attempts' => 5,
				'time_window'  => 15,
				'lockout_time' => 15,
			)
		);
		$attempts  = self::get_attempts_in_window( $ip, $limits['time_window'] );
		$threshold = (int) get_option( 'zacls_auto_block_threshold', 10 );

		if ( $attempts >= $threshold ) {
			do_action( 'zacls_auto_block_ip', $ip );
		}

		if ( $attempts >= (int) $limits['max_attempts'] ) {
			$notifier = new ZACLS_Email_Notifications();
			$notifier->notify_multiple_failed_logins( $username );
		}
	}

	/**
	 * Handle successful login.
	 *
	 * @param string  $user_login Username.
	 * @param WP_User $user       User object.
	 */
	public function on_login_success( $user_login, $user ) {
		do_action( 'zacls_successful_login', $user );
	}

	/**
	 * Ensure the login_attempts database table exists.
	 * Creates it if missing — useful after plugin updates when
	 * activation hooks may not have run.
	 */
	public static function ensure_table_exists() {
		global $wpdb;

		$table_name = $wpdb->prefix . 'zacls_login_attempts';
		// Escape LIKE wildcards in case the table prefix contains _ or %.
		$like_name  = str_replace( array( '%', '_' ), array( '\%', '\_' ), $table_name );
		$table_check = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->prepare( 'SHOW TABLES LIKE %s', $like_name )
		);

		if ( $table_check !== $table_name ) {
			ZACLS_Install::create_tables();
			error_log( '[ZACLS] Auto-created missing table: zacls_login_attempts' );
		}
	}

	/**
	 * Record a failed login attempt in the database.
	 *
	 * @param string $username Username.
	 * @param string $ip       IP address.
	 */
	public function record_failed_attempt( $username = '', $ip = '' ) {
		global $wpdb;

		self::ensure_table_exists();

		if ( empty( $ip ) ) {
			$ip = ZACLS_Main::get_ip();
		}

		$user    = get_user_by( 'login', $username );
		$user_id = $user ? $user->ID : 0;
		$country = ZACLS_Main::lookup_country( $ip );

		$data = array(
			'ip'           => $ip,
			'username'     => sanitize_user( $username ),
			'user_id'      => $user_id,
			'status'       => 'failed',
			'user_agent'   => isset( $_SERVER['HTTP_USER_AGENT'] )
				? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) )
				: '',
			'attempt_time' => current_time( 'mysql' ),
		);
		$format = array( '%s', '%s', '%d', '%s', '%s', '%s' );

		if ( self::column_exists( 'country_code' ) ) {
			$data['country_code'] = $country;
			$format[]             = '%s';
		}
		if ( self::column_exists( 'device_id' ) ) {
			$data['device_id'] = substr( ZACLS_Device::get_device_id(), 0, 64 );
			$format[]          = '%s';
		}
		if ( self::column_exists( 'session_id' ) ) {
			$data['session_id'] = substr( ZACLS_Session::get_session_id_from_cookie(), 0, 64 );
			$format[]           = '%s';
		}

		$result = $wpdb->insert( $wpdb->prefix . 'zacls_login_attempts', $data, $format ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching

		if ( false === $result ) {
			error_log(
				'[ZACLS] record_failed_attempt insert failed: '
				. $wpdb->last_error
				. ' | table: ' . $wpdb->prefix . 'zacls_login_attempts'
				. ' | ip: ' . $ip
				. ' | username: ' . $username
			);
		}
	}

	/**
	 * Check if a column exists in the login_attempts table.
	 *
	 * @param string $column Column name.
	 * @return bool
	 */
	private static function column_exists( $column ) {
		global $wpdb;

		$cache_key = 'zacls_col_' . $column;
		$cached    = wp_cache_get( $cache_key, 'zacls' );
		if ( false !== $cached ) {
			return (bool) $cached;
		}

		$exists = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->prepare(
				"SHOW COLUMNS FROM {$wpdb->prefix}zacls_login_attempts LIKE %s",
				$column
			)
		);

		$result = (bool) $exists;
		wp_cache_set( $cache_key, (int) $result, 'zacls', 3600 );

		return $result;
	}

	/**
	 * Get number of failed attempts within a time window.
	 *
	 * @param string $ip              IP address.
	 * @param int    $window_minutes Time window in minutes.
	 * @return int
	 */
	public static function get_attempts_in_window( $ip, $window_minutes = 15 ) {
		global $wpdb;

		$count = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->prepare(
				"SELECT COUNT(*) FROM {$wpdb->prefix}zacls_login_attempts
				WHERE ip = %s AND status = 'failed'
				AND attempt_time >= %s - INTERVAL %d MINUTE",
				$ip,
				current_time( 'mysql' ),
				$window_minutes
			)
		);

		return (int) $count;
	}

	/**
	 * Get the timestamp when lockout ends.
	 *
	 * @param string $ip              IP address.
	 * @param int    $lockout_minutes Lockout duration in minutes.
	 * @return int Unix timestamp.
	 */
	private function get_lockout_end( $ip, $lockout_minutes ) {
		global $wpdb;

		$first = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->prepare(
				"SELECT MIN(attempt_time) FROM {$wpdb->prefix}zacls_login_attempts
				WHERE ip = %s AND status = 'failed'
				AND attempt_time >= %s - INTERVAL %d MINUTE",
				$ip,
				current_time( 'mysql' ),
				$lockout_minutes
			)
		);

		if ( $first ) {
			return strtotime( $first ) + ( $lockout_minutes * 60 );
		}

		return 0;
	}

	/**
	 * Get total attempts for a given status and time period.
	 *
	 * @param string $status Status filter (failed, success).
	 * @param int    $days   Number of days.
	 * @return int
	 */
	public static function get_total_attempts( $status = 'failed', $days = 7 ) {
		global $wpdb;

		$count = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->prepare(
				"SELECT COUNT(*) FROM {$wpdb->prefix}zacls_login_attempts
				WHERE status = %s AND attempt_time >= %s - INTERVAL %d DAY",
				$status,
				current_time( 'mysql' ),
				$days
			)
		);

		return (int) $count;
	}

	/**
	 * Get recent login attempts with pagination.
	 *
	 * @param int $limit  Number of records.
	 * @param int $offset Offset.
	 * @return array
	 */
	public static function get_recent_attempts( $limit = 50, $offset = 0 ) {
		global $wpdb;

		return $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->prepare(
				"SELECT * FROM {$wpdb->prefix}zacls_login_attempts
				ORDER BY attempt_time DESC LIMIT %d OFFSET %d",
				$limit,
				$offset
			),
			ARRAY_A
		);
	}

	/**
	 * Clear all login attempt logs.
	 *
	 * @return bool
	 */
	public static function clear_logs() {
		global $wpdb;

		$result = $wpdb->query( "TRUNCATE TABLE {$wpdb->prefix}zacls_login_attempts" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
		if ( function_exists( 'wp_cache_flush_group' ) ) {
			wp_cache_flush_group( 'zacls' );
		}

		return false !== $result;
	}
}
