<?php


namespace Fliegerlogin\WpPublic;


use Fliegerlogin\Api;
use Fliegerlogin\Db\Users;
use Fliegerlogin\Debug;
use Fliegerlogin\Error\ApiError;
use Fliegerlogin\Error\InvalidLoginCredentialsError;
use Fliegerlogin\Error\TwoFactorAuthenticationRequiredError;
use Fliegerlogin\Options;
use Fliegerlogin\Guard;
use Fliegerlogin\RateLimiter;
use Fliegerlogin\VereinsfliegerApi\Model\IUserDto;

class Authentication {

	/** @var Api */
	protected $api = null;

	private $username = '';
	private $hashedPassword = '';

	/**
	 * A Vereinsflieger login is either the member's email address or their
	 * numeric member number ("Mitgliedsnr"). Everything else can never succeed
	 * against the API, so it is not worth an API request. The member-number
	 * branch is only allowed when $allowMemberId is set; it is kept deliberately
	 * strict (pure digits, not any alphanumeric token) so common brute-force
	 * names like "admin" are still rejected.
	 * @param string $username
	 * @param bool   $allowMemberId Also treat a purely numeric member number as plausible.
	 * @return bool
	 */
	protected function getApi(): Api {
		if ($this->api) {
			return $this->api;
		}

		$this->api = new Api();
		$this->api->init();
		return $this->api;
	}

	/**
	 * @param \WP_User|\WP_Error|null $user
	 * @param string $username
	 * @param string $password
	 * @return \WP_User|null The authenticated user or null if the authentication failed
	 */
	public function authenticationRequest($user, string $username, string $password) {
		Debug::log('auth.filter_called', [
			'username'      => $username,
			'has_password'  => $password !== '',
			'incoming_user' => $user instanceof \WP_User ? 'WP_User#' . $user->ID
				: ($user instanceof \WP_Error ? 'WP_Error(' . implode(',', $user->get_error_codes()) . ')' : 'null'),
		]);

		// A captcha / anti-bot plugin has already rejected this request — don't
		// waste Vereinsflieger API requests (and daily quota) verifying a bot.
		if ($user instanceof \WP_Error && Guard::captchaBlockedCodes($user)) {
			return $user;
		}

		// stop when no credentials were provided
		if (!$username || !$password) {
			Debug::log('auth.no_credentials');
			return $user;
		}

		if (RateLimiter::appliesToAllLogins() && RateLimiter::isBlocked()) {
			return $this->rateLimitError($user);
		}

		// If is authenticated, allow the user to login
		if ($user instanceof \WP_User) {

			// check if user is linked to an Vereinsflieger account
			if (!Options::getAllowDefaultLogin() && Users::isVereinsfliegerUser($user)) {
				Debug::log('auth.block_default_login_for_linked_user', ['user_id' => $user->ID]);
				return null;
			}

			if (RateLimiter::appliesToAllLogins()) {
				RateLimiter::clearFailures();
			}

			return $user;
		}

		// skip Vereinsflieger lookup for existing WP users that are not VF-linked,
		// otherwise every mistyped WP password triggers 4 unnecessary VF API requests
		$existingWpUser = get_user_by('login', $username);
		if ($existingWpUser && !Users::isVereinsfliegerUser($existingWpUser)) {
			Debug::log('auth.skip_existing_wp_user', [
				'reason'      => 'wp_user_with_this_login_exists_and_is_not_vf_linked',
				'wp_user_id'  => $existingWpUser->ID,
				'wp_login'    => $existingWpUser->user_login,
				'wp_email'    => $existingWpUser->user_email,
			]);
			if (RateLimiter::appliesToAllLogins() && $user instanceof \WP_Error) {
				RateLimiter::recordFailure();
			}
			return $user;
		}
		Debug::log('auth.proceed_to_vf', [
			'existing_wp_user' => $existingWpUser ? 'WP_User#' . $existingWpUser->ID . ' (vf-linked)' : 'none',
		]);

		// A Vereinsflieger login is always an email address (some clubs also
		// allow the numeric member number). Anything else — "admin", "root",
		// "test", … the classic brute-force names — can never authenticate
		// against the API, so reject it before spending a request on it.
		if (Guard::rejectsUsername($username)) {
			return $user;
		}

		// xmlrpc.php is a classic brute-force vector. Skip the Vereinsflieger
		// lookup for XML-RPC requests so bots hammering it never reach the API.
		if (Guard::blocksXmlRpc()) {
			return $user;
		}

		if (RateLimiter::isBlocked()) {
			return $this->rateLimitError($user);
		}

		// Reject visitors from disallowed countries before touching the API.
		if (Guard::isGeoBlocked()) {
			Debug::log('auth.geo_blocked', ['country' => Guard::geoCountry()]);
			return $this->geoBlockError($user);
		}

		// Global circuit breaker: if the API traffic looks like an attack (or the
		// daily quota is exhausted), stop before making any Vereinsflieger call.
		if (Guard::isCircuitOpen()) {
			Debug::log('auth.circuit_open', [
				'reason'      => Guard::circuitReason(),
				'retry_after' => Guard::circuitRetryAfterSeconds(),
			]);
			return $this->circuitBreakerError($user);
		}

		$this->username = $username;
		$this->hashedPassword = md5($password);

		// phpcs:disable WordPress.Security.NonceVerification.Missing -- Das Anmeldeformular von WordPress traegt keinen Nonce, und darf keinen tragen: wp-login.php muss auch von Lesezeichen und externen Formularen aus erreichbar bleiben. Der Wert wird stattdessen gegen ein festes Muster geprueft, entschraegt und maskiert.
		if (
			array_key_exists('fliegerlogin_hashed_password', $_POST) &&
			is_string($_POST['fliegerlogin_hashed_password']) &&
			preg_match('/^[a-f0-9]{32}$/', sanitize_text_field(wp_unslash($_POST['fliegerlogin_hashed_password'])))
		) {
			$this->hashedPassword = sanitize_text_field(wp_unslash($_POST['fliegerlogin_hashed_password']));
		}
		// phpcs:enable WordPress.Security.NonceVerification.Missing

		return $this->authenticateVereinsfliegerUser($user);
	}

	protected function rateLimitError($user): \WP_Error {
		RateLimiter::refreshBlock();
		RateLimiter::recordRateLimitEvent();
		Debug::log('auth.rate_limited', [
			'ip'       => RateLimiter::getIp(),
			'attempts' => RateLimiter::getAttempts(),
		]);
		$error = $user instanceof \WP_Error ? $user : new \WP_Error();
		$error->add(
			'vf_rate_limited',
			'<strong>' . __('Too many failed login attempts', 'sso-for-vereinsflieger') . '</strong><br>'
			. sprintf(
				/* translators: 1: number of failed attempts, 2: lockout duration in minutes */
				__('Your IP has been temporarily blocked after %1$d failed attempts. Please try again in %2$d minutes.', 'sso-for-vereinsflieger'),
				RateLimiter::getMaxAttempts(),
				(int) ceil(RateLimiter::getLockoutSeconds() / 60)
			)
		);
		return $error;
	}

	protected function privilegedAccountError($user): \WP_Error {
		$error = $user instanceof \WP_Error ? $user : new \WP_Error();
		$error->add(
			'vf_privileged_account',
			'<strong>' . __('This account cannot be linked automatically', 'sso-for-vereinsflieger') . '</strong><br>'
			. __('A WordPress account with administrative rights already uses this email address. For safety it is not linked to Vereinsflieger automatically. Please ask an administrator to link it.', 'sso-for-vereinsflieger')
		);

		return $error;
	}

	protected function geoBlockError($user): \WP_Error {
		$error = $user instanceof \WP_Error ? $user : new \WP_Error();
		$error->add(
			'vf_geo_blocked',
			'<strong>' . __('Login not available from your region', 'sso-for-vereinsflieger') . '</strong><br>'
			. __('Logins to this site are restricted to specific countries. Please contact the administrator if you believe this is an error.', 'sso-for-vereinsflieger')
		);
		return $error;
	}

	protected function circuitBreakerError($user): \WP_Error {
		$error = $user instanceof \WP_Error ? $user : new \WP_Error();
		$minutes = max(1, (int) ceil(Guard::circuitRetryAfterSeconds() / 60));
		$error->add(
			'vf_circuit_open',
			'<strong>' . __('Login temporarily unavailable', 'sso-for-vereinsflieger') . '</strong><br>'
			. sprintf(
				/* translators: %d: minutes until the login is available again */
				__('The Vereinsflieger login is paused to protect the service. Please try again in about %d minutes.', 'sso-for-vereinsflieger'),
				$minutes
			)
		);
		return $error;
	}

	/**
	 * @param \WP_User|\WP_Error|null $user
	 * @return \WP_User|\WP_Error|null The authenticated user or null if the authentication failed
	 */
	protected function authenticateVereinsfliegerUser($user) {
		// Try to login with vereinsflieger
		$otp = null;
		try {
			// phpcs:disable WordPress.Security.NonceVerification.Missing -- Das Anmeldeformular von WordPress traegt keinen Nonce, und darf keinen tragen: wp-login.php muss auch von Lesezeichen und externen Formularen aus erreichbar bleiben. Der Wert wird stattdessen gegen ein festes Muster geprueft, entschraegt und maskiert.
			if (array_key_exists('fliegerlogin_otp', $_POST) && is_string($_POST['fliegerlogin_otp'])) {
				$candidate = str_replace(' ', '', sanitize_text_field(wp_unslash($_POST['fliegerlogin_otp'])));
				if (preg_match('/^[A-Za-z0-9]{6,10}$/', $candidate)) {
					$otp = $candidate;
				}
			}
			// phpcs:enable WordPress.Security.NonceVerification.Missing

			// Recently rejected credentials (no 2FA in play): fail instantly
			// instead of asking Vereinsflieger the same question again.
			if ($otp === null && Guard::isKnownBadCredential($this->username, $this->hashedPassword)) {
				Debug::log('auth.negative_cache_hit', ['username' => $this->username]);
				RateLimiter::recordFailure();
				return $user;
			}

			$loginSuccessful = $this->tryLogin($this->username, $this->hashedPassword, $otp, $user);

			if (!$loginSuccessful) {
				return $user;
			}
		} catch (TwoFactorAuthenticationRequiredError $e) {
			if ($otp !== null) {
				return null;
			}

			// str_contains rather than !strpos(): strpos returns 0 for a match at
			// the very start, which the old test read as "not found".
			$script = isset($_SERVER['SCRIPT_NAME'])
				? sanitize_text_field(wp_unslash($_SERVER['SCRIPT_NAME']))
				: '';
			if (!str_contains($script, 'wp-login.php')) {
				$requestUri = isset($_SERVER['REQUEST_URI'])
					? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI']))
					: '';
				wp_safe_redirect(wp_login_url($requestUri));
			}

			return $this->twoFactorAuthenticationError();
		}

		try {
			// get user details
			$userDetails = $this->tryGetUser();
			Debug::log('auth.vf_login_success', [
				'vf_uid'   => $userDetails->getUid(),
				'vf_email' => $userDetails->getEmail(),
			]);

			// try to find user in users table
			$wpUser = Users::getUserByVereinsfliegerId($userDetails->getUid());

			// try to find existing user with same email
			if (!$wpUser) {
				$wpUser = Users::getUserByEmail($userDetails->getEmail());
				Debug::log('auth.lookup_by_email', [
					'matched' => $wpUser ? 'user#' . $wpUser->ID : 'none',
				]);

				// Adopting an account found only by email address is how an
				// unlinked member keeps their existing site account. It is also
				// how someone who manages to register that address at
				// Vereinsflieger would take the account over, so accounts that
				// can administer the site are never adopted this way. Linking
				// those stays a manual, deliberate act.
				if ($wpUser && Users::hasElevatedCapabilities((int) $wpUser->ID)) {
					Debug::log('auth.refuse_privileged_email_match', [
						'wp_user_id' => $wpUser->ID,
					]);

					return $this->privilegedAccountError($user);
				}
			} else {
				Debug::log('auth.lookup_by_vf_uid', ['matched' => 'user#' . $wpUser->ID]);
			}

			// add new user
			if (!$wpUser) {
				Debug::log('auth.create_new_wp_user');
				$userId = Users::addVereinsfliegerUser($userDetails);
				if (!$userId) {
					Debug::log('auth.create_wp_user_failed');
					return null;
				}

				$wpUser = Users::getUserByVereinsfliegerId($userDetails->getUid());
			}

			// update user meta data
			Users::updateMetaData($wpUser->ID, $wpUser, $userDetails);

			// release the Vereinsflieger session — we have what we need
			try {
				$this->tryLogout();
			} catch (ApiError $e) {
				// logout best-effort: a stale token expires server-side anyway
			}

			Debug::log('auth.return_wp_user', ['wp_user_id' => $wpUser->ID]);
			RateLimiter::clearFailures();
			Guard::forgetBadCredential($this->username, $this->hashedPassword);

			// return user to login
			return new \WP_User($wpUser->ID);
		} catch (ApiError $e) {
			Debug::log('auth.api_error_in_post_login', [
				'code'    => $e->getCode(),
				'message' => $e->getMessage(),
			]);
			try {
				$this->tryLogout();
			} catch (ApiError $logoutError) {
				// best-effort cleanup
			}
			return null;
		}
	}

	/**
	 * Blocks the "Lost your password?" flow for Vereinsflieger-linked accounts
	 * before the reset email is sent.
	 * @param \WP_Error      $errors
	 * @param \WP_User|false $userData
	 */
	public function lostPasswordRequest(\WP_Error $errors, $userData = null): void {
		if ($userData instanceof \WP_User && Users::isVereinsfliegerUser($userData)) {
			$errors->add(
				'vf_no_password_reset',
				'<strong>' . __('Password reset disabled', 'sso-for-vereinsflieger') . '</strong><br>'
				. __('This account is managed via Vereinsflieger. Please change your password directly in your Vereinsflieger account.', 'sso-for-vereinsflieger')
			);
		}
	}

	/**
	 * Blocks the actual password reset for Vereinsflieger-linked accounts,
	 * in case someone reaches the reset form via a stale link.
	 * @param bool|\WP_Error $allow
	 * @param int            $userId
	 * @return bool|\WP_Error
	 */
	public function allowPasswordReset($allow, $userId) {
		$wpUser = get_userdata((int) $userId);
		if ($wpUser instanceof \WP_User && Users::isVereinsfliegerUser($wpUser)) {
			return new \WP_Error(
				'vf_no_password_reset',
				__('Password reset is disabled for accounts linked to Vereinsflieger.', 'sso-for-vereinsflieger')
			);
		}
		return $allow;
	}

	protected function twoFactorAuthenticationError(): \WP_Error {
		add_action('login_form', [$this, 'printTwoFactorForm'], 10, 0);

		$error = new \WP_Error();
		$error->add('2fa_needed', '<strong>' . __('Two factor authentication', 'sso-for-vereinsflieger') . '</strong><br>' . __('Please enter the current one time password.', 'sso-for-vereinsflieger'));

		return $error;
	}

	/**
	 * @param string                  $username
	 * @param string                  $password
	 * @param string|null             $otp
	 * @param \WP_User|\WP_Error|null $user
	 * @return bool
	 * @throws TwoFactorAuthenticationRequiredError
	 */
	protected function tryLogin(string $username, string $password, ?string $otp = null, $user = null): bool {
		Debug::log('auth.try_login', [
			'username' => $username,
			'has_otp'  => $otp !== null,
		]);
		try {
			$this->getApi()->login($username, $password, $otp);
			return true;
		} catch (InvalidLoginCredentialsError $e) {
			Debug::log('auth.invalid_credentials', [
				'api_message' => $e->getApiErrorMessage(),
			]);
			RateLimiter::recordFailure();
			if ($otp === null) {
				Guard::rememberBadCredential($username, $password);
			}
			if ($user instanceof \WP_Error) {
				$apiMessage = $e->getApiErrorMessage();
				if ($apiMessage !== null && $apiMessage !== '') {
					$user->add(
						'vf_login_failed',
						sprintf(
							'<strong>' . __('Vereinsflieger login failed', 'sso-for-vereinsflieger') . '</strong><br>'
							/* translators: %s: error message returned by the Vereinsflieger API */
						. __('The Vereinsflieger API rejected the request: %s', 'sso-for-vereinsflieger'),
							esc_html($apiMessage)
						)
					);
				}
			}
			return false;
		} catch (TwoFactorAuthenticationRequiredError $e) {
			Debug::log('auth.2fa_required');
			throw $e;
		} catch (ApiError $e) {
			Debug::log('auth.api_error', [
				'code'    => $e->getCode(),
				'message' => $e->getMessage(),
			]);
			if ($e->getCode() >= 500 && $e->getCode() < 600 && $user instanceof \WP_Error) {
                $user->add(
						'vf_offline',
						sprintf(
								'<strong>' . __('Vereinsflieger is offline', 'sso-for-vereinsflieger') . '</strong><br>'
								/* translators: 1: HTTP status code, 2: error message from the Vereinsflieger API */
								. __('We\'re unable to verify your login credentials with Vereinsflieger. The API returned with error <i>%1$d %2$s</i>.', 'sso-for-vereinsflieger')
								. '<br>'
								. __('If this problem continues to exist, please contact the administrator.', 'sso-for-vereinsflieger'),
								$e->getCode(),
								$e->getMessage()
						)
                );
			}

			return false;
		}
	}

	protected function tryLogout(): void {
		$this->getApi()->logout();
	}

	/**
	 * @return IUserDto
	 * @throws ApiError
	 */
	protected function tryGetUser(): IUserDto {
		return $this->getApi()->getUser();
	}

	public function printTwoFactorForm(): void {
		add_filter('enable_login_autofocus', function () { return false; });

		wp_enqueue_style('fliegerlogin-otp');
		wp_enqueue_script('fliegerlogin-otp');
		wp_add_inline_script('fliegerlogin-otp', $this->buildTwoFactorScript());
		?>
		<p id="fliegerlogin_otp_form">
			<label for="fliegerlogin_otp"><?php esc_html_e('One time password', 'sso-for-vereinsflieger'); ?></label>
			<input type="number" name="fliegerlogin_otp" id="fliegerlogin_otp" class="input" size="6" autocapitalize="off" />
		</p>
		<?php
	}

	/**
	 * Registers the handles the one time password screen attaches its inline
	 * assets to. Both are src-less: there is no file to fetch, WordPress just
	 * needs something to hang the inline code on so it lands in the footer
	 * instead of in the middle of the form.
	 */
	public static function registerLoginAssets(): void {
		wp_register_style('fliegerlogin-otp', false, [], FLIEGERLOGIN_VERSION);
		wp_add_inline_style('fliegerlogin-otp', '#fliegerlogin_otp::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}#fliegerlogin_otp{-moz-appearance:textfield}');

		wp_register_script('fliegerlogin-otp', false, [], FLIEGERLOGIN_VERSION, true);
	}

	/**
	 * The one time password screen replaces the username and password fields
	 * with hidden ones, so the second step can resubmit the credentials the
	 * member already entered without asking for them again.
	 */
	private function buildTwoFactorScript(): string {
		$fields = [
			['log', $this->username],
			['pwd', 'user_pass'],
			['fliegerlogin_hashed_password', $this->hashedPassword],
		];

		// phpcs:disable WordPress.Security.NonceVerification.Missing -- Das Anmeldeformular von WordPress traegt keinen Nonce, und darf keinen tragen: wp-login.php muss auch von Lesezeichen und externen Formularen aus erreichbar bleiben. Der Wert wird stattdessen gegen ein festes Muster geprueft, entschraegt und maskiert.
		if (array_key_exists('rememberme', $_POST)) {
			$fields[] = ['rememberme', sanitize_text_field(wp_unslash($_POST['rememberme']))];
		}
		// phpcs:enable WordPress.Security.NonceVerification.Missing

		$hidden = '';
		foreach ($fields as $i => [$name, $value]) {
			$hidden .= sprintf(
				"var f%1$d=document.createElement('input');f%1$d.type='hidden';f%1$d.name=%2$s;f%1$d.value=%3$s;form.appendChild(f%1$d);",
				$i,
				wp_json_encode($name),
				wp_json_encode($value)
			);
		}

		return "window.setTimeout(function(){"
			. "var user=document.getElementById('user_login'),"
			. "pass=document.getElementById('user_pass'),"
			. "remember=document.getElementById('rememberme'),"
			. "form=document.getElementById('fliegerlogin_otp_form'),"
			. "otp=document.getElementById('fliegerlogin_otp');"
			// If a WordPress update ever changes these ids, leave the form alone;
			// the member then simply enters the credentials a second time.
			. "if(!user||!pass||!form){return;}"
			. "user.parentElement.remove();"
			. "pass.parentElement.parentElement.remove();"
			. "if(remember){remember.parentElement.remove();}"
			. $hidden
			. "if(otp){otp.focus();}"
			. "},200);";
	}

}
