<?php
/**
 * One-time passcode issue and verification.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Otp;

use TextTango\Support\Clock;
use TextTango\Support\Settings;
use WP_Error;

defined( 'ABSPATH' ) || exit;

/**
 * Issues and verifies SMS one-time passcodes (spec §5, §13).
 *
 * The challenge lives in a transient keyed by a hash of the phone number, so the
 * number itself never appears in an option name. The code is stored only as an
 * HMAC — a database read cannot reveal it — and comparison is constant-time so
 * timing cannot leak a partial match.
 *
 * Codes are single-use: a successful verification deletes the challenge before
 * returning, so a replayed code fails even within its TTL.
 *
 * Nothing here reveals whether a phone number belongs to a user. That decision
 * belongs to the caller, and {@see LoginController} keeps it uniform.
 */
final class OtpService {

	/**
	 * Prefix for challenge transients.
	 */
	private const CHALLENGE_PREFIX = 'texttango_otp_';

	/**
	 * Number of digits in a code.
	 */
	public const CODE_LENGTH = 6;

	/**
	 * How long a challenge stays valid, in seconds.
	 */
	public const TTL = 300;

	/**
	 * Failed verifications allowed before the challenge is destroyed.
	 */
	public const MAX_ATTEMPTS = 5;

	/**
	 * Create a challenge for a phone number and return the plaintext code.
	 *
	 * The caller sends the code; it is never persisted in readable form and never
	 * returned again.
	 *
	 * @param string $phone   Recipient in E.164.
	 * @param string $purpose What the code authorises, e.g. 'login' or '2fa'.
	 * @return string The plaintext code to send.
	 */
	public static function issue( string $phone, string $purpose = 'login' ): string {
		$code = self::generate_code();

		set_transient(
			self::challenge_key( $phone, $purpose ),
			array(
				'hash'       => self::hash( $code, $phone ),
				'attempts'   => 0,
				'issued_at'  => Clock::now(),
				'expires_at' => Clock::now() + self::ttl(),
			),
			self::ttl()
		);

		return $code;
	}

	/**
	 * Check a submitted code.
	 *
	 * @param string $phone   Recipient in E.164.
	 * @param string $code    Code as submitted.
	 * @param string $purpose Must match the purpose used at issue time.
	 * @return true|WP_Error True on success; an error naming the failure otherwise.
	 */
	public static function verify( string $phone, string $code, string $purpose = 'login' ) {
		$key       = self::challenge_key( $phone, $purpose );
		$challenge = get_transient( $key );

		if ( ! is_array( $challenge ) || empty( $challenge['hash'] ) ) {
			return new WP_Error(
				'texttango_otp_expired',
				__( 'That code has expired. Request a new one.', 'texttango' )
			);
		}

		$code = self::normalize_code( $code );

		if ( '' === $code ) {
			return self::register_failure( $key, $challenge );
		}

		if ( ! hash_equals( (string) $challenge['hash'], self::hash( $code, $phone ) ) ) {
			return self::register_failure( $key, $challenge );
		}

		// Single use: destroy the challenge before reporting success, so a replay
		// of the same code cannot succeed even inside the TTL.
		delete_transient( $key );

		return true;
	}

	/**
	 * Discard a challenge without verifying it.
	 *
	 * @param string $phone   Recipient in E.164.
	 * @param string $purpose Purpose used at issue time.
	 */
	public static function forget( string $phone, string $purpose = 'login' ): void {
		delete_transient( self::challenge_key( $phone, $purpose ) );
	}

	/**
	 * Whether an unexpired challenge exists.
	 *
	 * @param string $phone   Recipient in E.164.
	 * @param string $purpose Purpose used at issue time.
	 */
	public static function has_challenge( string $phone, string $purpose = 'login' ): bool {
		return is_array( get_transient( self::challenge_key( $phone, $purpose ) ) );
	}

	/**
	 * Record a failed attempt, destroying the challenge once the limit is reached.
	 *
	 * Burning the challenge on the last attempt is what makes brute force
	 * pointless: a six-digit code has a million values, and only a handful of
	 * guesses are ever allowed against any one of them.
	 *
	 * @param string               $key       Transient key.
	 * @param array<string, mixed> $challenge Stored challenge.
	 */
	private static function register_failure( string $key, array $challenge ): WP_Error {
		$attempts = (int) ( $challenge['attempts'] ?? 0 ) + 1;

		if ( $attempts >= self::max_attempts() ) {
			delete_transient( $key );

			return new WP_Error(
				'texttango_otp_locked',
				__( 'Too many incorrect codes. Request a new one.', 'texttango' )
			);
		}

		$challenge['attempts'] = $attempts;
		$remaining             = max( 1, (int) ( $challenge['expires_at'] ?? 0 ) - Clock::now() );

		set_transient( $key, $challenge, $remaining );

		return new WP_Error(
			'texttango_otp_invalid',
			__( 'That code is not correct.', 'texttango' ),
			array( 'attempts_left' => self::max_attempts() - $attempts )
		);
	}

	/**
	 * Generate a zero-padded numeric code using a CSPRNG.
	 */
	private static function generate_code(): string {
		$length = self::CODE_LENGTH;
		$max    = ( 10 ** $length ) - 1;

		return str_pad( (string) wp_rand( 0, $max ), $length, '0', STR_PAD_LEFT );
	}

	/**
	 * Keyed hash of a code.
	 *
	 * Salted with the phone number as well as the site key, so an identical code
	 * issued to two numbers does not produce an identical stored hash.
	 *
	 * @param string $code  Plaintext code.
	 * @param string $phone Recipient in E.164.
	 */
	private static function hash( string $code, string $phone ): string {
		return hash_hmac( 'sha256', $phone . '|' . $code, wp_salt( 'secure_auth' ) );
	}

	/**
	 * Transient key for a challenge.
	 *
	 * The phone number is hashed rather than embedded, so the options table never
	 * carries a list of everyone who tried to sign in.
	 *
	 * @param string $phone   Recipient in E.164.
	 * @param string $purpose Purpose of the challenge.
	 */
	private static function challenge_key( string $phone, string $purpose ): string {
		return self::CHALLENGE_PREFIX . hash_hmac( 'sha256', $purpose . '|' . $phone, wp_salt() );
	}

	/**
	 * Strip formatting a user may have pasted along with the digits.
	 *
	 * @param string $code Code as submitted.
	 */
	private static function normalize_code( string $code ): string {
		return (string) preg_replace( '/\D/', '', $code );
	}

	/**
	 * Configured challenge lifetime, clamped to something sane.
	 */
	public static function ttl(): int {
		$ttl = (int) Settings::get( 'otp_ttl', self::TTL );

		return max( 60, min( 900, $ttl ) );
	}

	/**
	 * Configured attempt limit, clamped to something sane.
	 */
	public static function max_attempts(): int {
		$attempts = (int) Settings::get( 'otp_max_attempts', self::MAX_ATTEMPTS );

		return max( 3, min( 10, $attempts ) );
	}
}
