<?php

declare(strict_types=1);

namespace SecurWP\Modules\TwoFactor;

use SecurWP\Core\Database\SchemaInspector;

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

/**
 * Database-backed login challenge sessions for pre-auth 2FA flows.
 *
 * Keeps pending 2FA state out of transients so challenge handling remains
 * reliable on hosts with persistent object caches or external object-cache
 * backends.
 */
final class LoginSessionStore {

	public const TABLE_NAME = 'securwp_two_factor_sessions';
	private const SESSION_TTL = 300;

	/**
	 * Create a new pending login challenge and return its public token.
	 */
	public static function create( int $user_id, string $redirect_to, bool $remember, string $method, string $notice_code = '' ): string {
		global $wpdb;

		if ( ! self::ensure_storage_ready() ) {
			return '';
		}

		$token      = bin2hex( random_bytes( 32 ) );
		$token_hash = self::hash_token( $token );
		$now        = time();
		$table      = SchemaInspector::table_identifier( $wpdb->prefix . self::TABLE_NAME );

		self::purge_expired( $now );

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
		$inserted = $wpdb->insert(
			trim( $table, '`' ),
			[
				'token_hash'   => $token_hash,
				'user_id'      => $user_id,
				'method'       => self::normalize_method( $method ),
				'notice_code'  => self::normalize_notice_code( $notice_code ),
				'redirect_to'  => $redirect_to,
				'remember'     => $remember ? 1 : 0,
				'fail_count'   => 0,
				'expires_at'   => $now + self::SESSION_TTL,
				'created_at'   => $now,
				'updated_at'   => $now,
			],
			[ '%s', '%d', '%s', '%s', '%s', '%d', '%d', '%d', '%d', '%d' ]
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter

		if ( false === $inserted ) {
			return '';
		}

		return $token;
	}

	/**
	 * Load an active session by its public token.
	 *
	 * @return array<string,mixed>|null
	 */
	public static function get( string $token ): ?array {
		global $wpdb;

		if ( ! self::ensure_storage_ready() ) {
			return null;
		}

		$token_hash = self::hash_token( $token );
		$table      = SchemaInspector::table_identifier( $wpdb->prefix . self::TABLE_NAME );

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
		$row = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT * FROM {$table} WHERE token_hash = %s LIMIT 1",
				$token_hash
			),
			ARRAY_A
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter

		if ( ! is_array( $row ) ) {
			return null;
		}

		$expires_at = (int) ( $row['expires_at'] ?? 0 );
		if ( $expires_at > 0 && $expires_at <= time() ) {
			self::delete_by_hash( $token_hash );
			return null;
		}

		$row['user_id']         = (int) ( $row['user_id'] ?? 0 );
		$row['remember']        = ! empty( $row['remember'] );
		$row['fail_count']      = (int) ( $row['fail_count'] ?? 0 );
		$row['email_attempts']  = (int) ( $row['email_attempts'] ?? 0 );
		$row['email_delivered'] = ! empty( $row['email_delivered'] );
		$row['method']          = self::normalize_method( (string) ( $row['method'] ?? 'totp' ) );
		$row['notice_code']     = self::normalize_notice_code( (string) ( $row['notice_code'] ?? '' ) );

		return $row;
	}

	/**
	 * Delete a pending login challenge by public token.
	 */
	public static function delete( string $token ): void {
		self::delete_by_hash( self::hash_token( $token ) );
	}

	/**
	 * Delete all pending sessions for a user.
	 */
	public static function delete_for_user( int $user_id ): void {
		global $wpdb;

		if ( ! self::ensure_storage_ready() ) {
			return;
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Deletes pending sessions for one user through wpdb's structured delete helper.
		$wpdb->delete(
			$wpdb->prefix . self::TABLE_NAME,
			[ 'user_id' => $user_id ],
			[ '%d' ]
		);
	}

	/**
	 * Increment the failed-attempt counter for a challenge session.
	 */
	public static function increment_fail_count( string $token ): int {
		global $wpdb;

		if ( ! self::ensure_storage_ready() ) {
			return 0;
		}

		$token_hash = self::hash_token( $token );
		$table      = SchemaInspector::table_identifier( $wpdb->prefix . self::TABLE_NAME );
		$now        = time();

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->query(
			$wpdb->prepare(
				"UPDATE {$table}
				 SET fail_count = fail_count + 1,
				     updated_at = %d
				 WHERE token_hash = %s",
				$now,
				$token_hash
			)
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter

		$session = self::get( $token );
		return (int) ( $session['fail_count'] ?? 0 );
	}

	/**
	 * Send an email verification code for a pending login session.
	 */
	public static function send_email_code( string $token, int $user_id ): bool {
		global $wpdb;

		if ( ! self::ensure_storage_ready() ) {
			return false;
		}

		$token_hash = self::hash_token( $token );
		$lock_name  = 'securwp_2fa_session_' . $token_hash;
		$table      = SchemaInspector::table_identifier( $wpdb->prefix . self::TABLE_NAME );

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
		$lock = $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, 2)', $lock_name ) );
		if ( '1' !== (string) $lock ) {
			return false;
		}

		try {
			$session = self::get( $token );
			if ( ! is_array( $session ) || (int) ( $session['user_id'] ?? 0 ) !== $user_id ) {
				return false;
			}

			$sent_at = (int) ( $session['email_sent_at'] ?? 0 );
			if ( ! empty( $session['email_delivered'] ) && $sent_at > 0 && ( time() - $sent_at ) < EmailOTPProvider::cooldown_seconds() ) {
				return true;
			}

			$code = EmailOTPProvider::generate_plaintext_code();
			$sent = EmailOTPProvider::send_challenge_code( $user_id, $code, 'login' );
			if ( ! $sent ) {
				return false;
			}

			$now = time();
			$code_hash = EmailOTPProvider::hash_code_for_storage( $code );
			if ( '' === $code_hash ) {
				return false;
			}

			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
			$updated = $wpdb->query(
				$wpdb->prepare(
					"UPDATE {$table}
					 SET email_code_hash = %s,
					     email_attempts = 0,
					     email_sent_at = %d,
					     email_delivered = 1,
					     updated_at = %d
					 WHERE token_hash = %s",
					$code_hash,
					$now,
					$now,
					$token_hash
				)
			);
			// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter

			return false !== $updated;
		} finally {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock_name ) );
		}
	}

	/**
	 * Verify an email verification code for a pending login session.
	 */
	public static function verify_email_code( string $token, string $code ): bool {
		global $wpdb;

		if ( ! self::ensure_storage_ready() ) {
			return false;
		}

		$token_hash = self::hash_token( $token );
		$lock_name  = 'securwp_2fa_session_' . $token_hash;
		$table      = SchemaInspector::table_identifier( $wpdb->prefix . self::TABLE_NAME );

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
		$lock = $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, 2)', $lock_name ) );
		if ( '1' !== (string) $lock ) {
			return false;
		}

		try {
			$session = self::get( $token );
			if ( ! is_array( $session ) ) {
				return false;
			}

			$stored_hash = (string) ( $session['email_code_hash'] ?? '' );
			if ( '' === $stored_hash ) {
				return false;
			}

			$attempts = (int) ( $session['email_attempts'] ?? 0 );
			if ( $attempts >= EmailOTPProvider::max_attempts() ) {
				return false;
			}

			if ( EmailOTPProvider::verify_stored_code( $code, $stored_hash ) ) {
				$now = time();
				// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
				$wpdb->query(
					$wpdb->prepare(
						"UPDATE {$table}
						 SET email_code_hash = NULL,
						     email_attempts = 0,
						     updated_at = %d
						 WHERE token_hash = %s",
						$now,
						$token_hash
					)
				);
				// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
				return true;
			}

			$now = time();
			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->query(
				$wpdb->prepare(
					"UPDATE {$table}
					 SET email_attempts = email_attempts + 1,
					     updated_at = %d
					 WHERE token_hash = %s",
					$now,
					$token_hash
				)
			);
			// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter

			return false;
		} finally {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock_name ) );
		}
	}

	/**
	 * Delete expired session rows.
	 */
	public static function purge_expired( ?int $now = null ): int {
		global $wpdb;

		if ( ! self::ensure_storage_ready() ) {
			return 0;
		}

		$cutoff = max( 0, (int) ( $now ?? time() ) );
		$table  = SchemaInspector::table_identifier( $wpdb->prefix . self::TABLE_NAME );

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
		$deleted = $wpdb->query(
			$wpdb->prepare(
				"DELETE FROM {$table} WHERE expires_at <= %d",
				$cutoff
			)
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter

		return max( 0, (int) $deleted );
	}

	public static function is_schema_current(): bool {
		global $wpdb;

		$table = $wpdb->prefix . self::TABLE_NAME;
		if ( ! SchemaInspector::table_exists( $table ) ) {
			return false;
		}

		return SchemaInspector::has_columns(
			$table,
			[
				'id',
				'token_hash',
				'user_id',
				'method',
				'notice_code',
				'redirect_to',
				'remember',
				'fail_count',
				'email_code_hash',
				'email_attempts',
				'email_sent_at',
				'email_delivered',
				'expires_at',
				'created_at',
				'updated_at',
			]
		) && SchemaInspector::has_indexes( $table, [ 'uk_token_hash', 'idx_expires_at', 'idx_user_id' ] );
	}

	public static function is_hash_schema_current(): bool {
		global $wpdb;

		$table = $wpdb->prefix . self::TABLE_NAME;

		return self::is_schema_current()
			&& EmailOTPProvider::column_supports_password_hash_storage( $table, 'email_code_hash' );
	}

	/**
	 * Delete every pending login challenge session.
	 */
	public static function delete_all(): void {
		if ( ! self::ensure_storage_ready() ) {
			return;
		}

		self::delete_rows_by_ids( self::list_ids() );
	}

	/**
	 * Remove sessions whose interactive method is no longer allowed by policy.
	 *
	 * Recovery-code sessions remain valid because recovery codes are not part of
	 * the administrator-managed primary method toggle.
	 *
	 * @param array<int,string> $allowed_methods Sanitized allowed method list.
	 */
	public static function delete_disallowed_methods( array $allowed_methods ): void {
		global $wpdb;

		if ( ! self::ensure_storage_ready() ) {
			return;
		}

		$allowed = array_values(
			array_intersect(
				array_map(
					static fn( $method ): string => strtolower( trim( (string) $method ) ),
					$allowed_methods
				),
				[ 'totp', 'email' ]
			)
		);

		if ( count( $allowed ) >= 2 ) {
			return;
		}

		if ( empty( $allowed ) ) {
			self::delete_rows_by_ids( self::list_ids_by_methods( [ 'totp', 'email' ] ) );
			return;
		}

		self::delete_rows_by_ids( self::list_ids_by_methods( [ 'totp', 'email' ], (string) reset( $allowed ) ) );
	}

	private static function delete_by_hash( string $token_hash ): void {
		global $wpdb;

		if ( ! self::ensure_storage_ready() ) {
			return;
		}

		$session_id = self::get_id_by_hash( $token_hash );
		if ( $session_id <= 0 ) {
			return;
		}

		self::delete_rows_by_ids( [ $session_id ] );
	}

	private static function hash_token( string $token ): string {
		return hash( 'sha256', $token );
	}

	private static function normalize_method( string $method ): string {
		return in_array( $method, [ 'totp', 'email', 'recovery' ], true ) ? $method : 'totp';
	}

	private static function normalize_notice_code( string $notice_code ): string {
		$allowed = [
			'',
			'totp_unavailable_email',
			'totp_disabled_email',
			'totp_unavailable_recovery',
			'totp_disabled_recovery',
			'email_unavailable_recovery',
			'email_disabled_recovery',
		];

		return in_array( $notice_code, $allowed, true ) ? $notice_code : '';
	}

	/**
	 * Ensure the DB-backed challenge store is available before use.
	 */
	private static function ensure_storage_ready(): bool {
		return self::is_hash_schema_current();
	}

	private static function get_raw_table_name(): string {
		global $wpdb;

		return $wpdb->prefix . self::TABLE_NAME;
	}

	private static function get_table_identifier(): string {
		return SchemaInspector::table_identifier( self::get_raw_table_name() );
	}

	private static function get_id_by_hash( string $token_hash ): int {
		global $wpdb;
		$table = self::get_table_identifier();

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Resolves the primary key for a validated plugin-owned session table before structured deletion.
		$session_id = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT id FROM {$table} WHERE token_hash = %s LIMIT 1",
				$token_hash
			)
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter

		return max( 0, (int) $session_id );
	}

	/**
	 * @param array<int,string> $methods
	 * @return array<int,int>
	 */
	private static function list_ids_by_methods( array $methods, string $exclude_method = '' ): array {
		global $wpdb;

		$normalized_methods = array_values(
			array_filter(
				array_map(
					static fn( $method ): string => self::normalize_method( (string) $method ),
					$methods
				),
				static fn( string $method ): bool => $method !== 'recovery'
			)
		);

		if ( empty( $normalized_methods ) ) {
			return [];
		}

		$placeholders = implode( ',', array_fill( 0, count( $normalized_methods ), '%s' ) );
		$sql          = "SELECT id FROM " . self::get_table_identifier() . " WHERE method IN ({$placeholders})";
		$args         = $normalized_methods;

		if ( $exclude_method !== '' ) {
			$sql    .= ' AND method <> %s';
			$args[] = self::normalize_method( $exclude_method );
		}

		$sql .= ' ORDER BY id ASC';

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Reads primary keys from a validated plugin-owned session table before structured deletion.
		$ids = $wpdb->get_col( $wpdb->prepare( $sql, ...$args ) );
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter

		return self::normalize_ids( $ids );
	}

	/**
	 * @return array<int,int>
	 */
	private static function list_ids(): array {
		global $wpdb;
		$table = self::get_table_identifier();

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Reads primary keys from a validated plugin-owned session table before structured deletion.
		$ids = $wpdb->get_col( "SELECT id FROM {$table} ORDER BY id ASC" );
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter

		return self::normalize_ids( $ids );
	}

	/**
	 * @param array<int,int|string>|null $ids
	 */
	private static function normalize_ids( ?array $ids ): array {
		if ( ! is_array( $ids ) ) {
			return [];
		}

		return array_values(
			array_filter(
				array_map( 'intval', $ids ),
				static fn( int $id ): bool => $id > 0
			)
		);
	}

	/**
	 * @param array<int,int> $ids
	 */
	private static function delete_rows_by_ids( array $ids ): void {
		global $wpdb;

		$table_name = self::get_raw_table_name();

		foreach ( $ids as $id ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Deletes a single plugin-owned session row by primary key after selecting matching ids.
			$wpdb->delete(
				$table_name,
				[ 'id' => $id ],
				[ '%d' ]
			);
		}
	}
}
