<?php
/**
 * REST API endpoints powering Capsquery Facial Authentication.
 *
 * POST /wp-json/capsquery-facial-authentication/v1/enroll        (auth required) save current user's face descriptor
 * POST /wp-json/capsquery-facial-authentication/v1/remove-face   (auth required) delete current user's face descriptor
 * POST /wp-json/capsquery-facial-authentication/v1/save-pin      (auth required) set/replace PIN
 * POST /wp-json/capsquery-facial-authentication/v1/match         (public)        match a descriptor against enrolled users
 * POST /wp-json/capsquery-facial-authentication/v1/verify        (public)        verify PIN for a match token, logs the user in
 * GET  /wp-json/capsquery-facial-authentication/v1/models/{file} (public)        serves face-api.js model assets
 *
 * The face-api.js model weight files ("shards") are pretrained binary tensor
 * data with no meaningful file extension. To comply with the WordPress.org
 * plugin directory's file-type policy (which only permits recognizable
 * source/asset/data files such as .php, .js, .css, .json, .xml, images, etc.),
 * each shard is stored base64-encoded inside a small `{shard-name}.json`
 * wrapper file under assets/models/. This endpoint decodes that wrapper on
 * request and streams the original binary bytes back out, so the front-end
 * face-api.js code needs no changes beyond pointing `modelsUrl` here instead
 * of directly at the assets/models folder.
 */

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

class Capsq_REST {

	const NS = 'capsquery-facial-authentication/v1';

	public function __construct() {
		add_action( 'rest_api_init', array( $this, 'register_routes' ) );

		add_action( 'added_user_meta', array( $this, 'maybe_flush_descriptor_cache' ), 10, 3 );
		add_action( 'updated_user_meta', array( $this, 'maybe_flush_descriptor_cache' ), 10, 3 );
		add_action( 'deleted_user_meta', array( $this, 'maybe_flush_descriptor_cache' ), 10, 3 );
	}

	public function maybe_flush_descriptor_cache( $meta_id, $object_id, $meta_key ) {
		if ( '_capsq_face_descriptor' === $meta_key ) {
			wp_cache_delete( 'capsq_all_descriptors', 'capsq' );
			wp_cache_delete( 'capsq_enrolled_count', 'capsq' );
		}
	}

	public function register_routes() {

		register_rest_route(
			self::NS,
			'/enroll',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'enroll' ),
				'permission_callback' => array( 'Capsq_Helpers', 'rest_can_manage_own_face' ),
				'args'                => array(
					'descriptor' => array(
						'required'          => true,
						'validate_callback' => array( $this, 'validate_descriptor' ),
					),
				),
			)
		);

		register_rest_route(
			self::NS,
			'/remove-face',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'remove_face' ),
				'permission_callback' => array( 'Capsq_Helpers', 'rest_can_manage_own_face' ),
			)
		);

		register_rest_route(
			self::NS,
			'/save-pin',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'save_pin' ),
				'permission_callback' => array( 'Capsq_Helpers', 'rest_can_manage_own_face' ),
				'args'                => array(
					'pin' => array(
						'required'          => true,
						'sanitize_callback' => array( $this, 'sanitize_pin' ),
					),
				),
			)
		);

		register_rest_route(
			self::NS,
			'/match',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'match_face' ),
				'permission_callback' => array( 'Capsq_Helpers', 'rest_can_attempt_login' ),
				'args'                => array(
					'descriptor' => array(
						'required'          => true,
						'validate_callback' => array( $this, 'validate_descriptor' ),
					),
				),
			)
		);

		register_rest_route(
			self::NS,
			'/verify',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'verify_pin' ),
				'permission_callback' => array( 'Capsq_Helpers', 'rest_can_attempt_login' ),
				'args'                => array(
					'token' => array(
						'required'          => true,
						'sanitize_callback' => 'sanitize_text_field',
					),
					'pin'   => array(
						'required'          => true,
						'sanitize_callback' => array( $this, 'sanitize_pin' ),
					),
				),
			)
		);

		register_rest_route(
			self::NS,
			'/models/(?P<file>[A-Za-z0-9_\-\.]+)',
			array(
				'methods'             => 'GET',
				'callback'            => array( $this, 'serve_model_asset' ),
				// Public: the face scan (and therefore model loading) happens
				// before the user is authenticated.
				'permission_callback' => '__return_true',
				'args'                => array(
					'file' => array(
						'required'          => true,
						'sanitize_callback' => 'sanitize_file_name',
					),
				),
			)
		);
	}

	/* ---------------------------------------------------------------- */
	/* Model asset proxy                                                  */
	/* ---------------------------------------------------------------- */

	/**
	 * Serves face-api.js model files (manifests and base64-wrapped shards)
	 * from assets/models/. See the class docblock for why this proxy exists.
	 */
	public function serve_model_asset( WP_REST_Request $req ) {
		$file = $req->get_param( 'file' );
		$file = sanitize_file_name( $file );

		$models_dir = trailingslashit( CAPSQ_PLUGIN_DIR ) . 'assets/models/';
		$real_dir   = realpath( $models_dir );

		// Manifest files are already plain, valid JSON — serve them as-is.
		if ( '.json' === substr( $file, -5 ) && ! preg_match( '/-shard[0-9]+\.json$/', $file ) ) {
			$path = realpath( $models_dir . $file );
			if ( ! $path || 0 !== strpos( $path, $real_dir ) || ! file_exists( $path ) ) {
				return new WP_Error( 'capsq_model_not_found', __( 'Model asset not found.', 'capsquery-facial-authentication' ), array( 'status' => 404 ) );
			}

			$body = file_get_contents( $path );
			nocache_headers();
			header( 'Content-Type: application/json; charset=utf-8' );
			header( 'Content-Length: ' . strlen( $body ) );
			header( 'Cache-Control: public, max-age=31536000, immutable' );
			// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Raw pass-through of a static, plugin-bundled face-api.js manifest file (not user input, not HTML); escaping would corrupt the JSON payload.
			echo $body;
			exit;
		}

		// Otherwise this is a request for a weight shard by its bare
		// face-api.js path (e.g. "face_recognition_model-shard1"), which is
		// stored base64-encoded in "{file}.json".
		$wrapper_path = realpath( $models_dir . $file . '.json' );
		if ( ! $wrapper_path || 0 !== strpos( $wrapper_path, $real_dir ) || ! file_exists( $wrapper_path ) ) {
			return new WP_Error( 'capsq_model_not_found', __( 'Model asset not found.', 'capsquery-facial-authentication' ), array( 'status' => 404 ) );
		}

		$wrapper = json_decode( file_get_contents( $wrapper_path ), true );
		if ( empty( $wrapper['data'] ) ) {
			return new WP_Error( 'capsq_model_corrupt', __( 'Model asset is corrupt.', 'capsquery-facial-authentication' ), array( 'status' => 500 ) );
		}

		$binary = base64_decode( $wrapper['data'] );
		if ( false === $binary ) {
			return new WP_Error( 'capsq_model_corrupt', __( 'Model asset is corrupt.', 'capsquery-facial-authentication' ), array( 'status' => 500 ) );
		}

		nocache_headers();
		header( 'Content-Type: application/octet-stream' );
		header( 'Content-Length: ' . strlen( $binary ) );
		header( 'Cache-Control: public, max-age=31536000, immutable' );
		// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Raw binary tensor data decoded from a plugin-bundled model file (not user input, not HTML); escaping functions would corrupt the bytes.
		echo $binary;
		exit;
	}

	/* ---------------------------------------------------------------- */
	/* Enrollment (logged-in, own account)              */
	/* ---------------------------------------------------------------- */

	public function enroll( WP_REST_Request $req ) {
		$rate = $this->rate_limit_check( 'capsq_enroll_rl_' . $this->client_ip(), 10, MINUTE_IN_SECONDS );
		if ( is_wp_error( $rate ) ) {
			return $rate;
		}

		$descriptor = $req->get_param( 'descriptor' );
		$user_id    = get_current_user_id();

		$json      = wp_json_encode( array_map( 'floatval', $descriptor ) );
		$encrypted = Capsq_Crypto::encrypt( $json );

		if ( false === $encrypted ) {
			return new WP_Error( 'capsq_encrypt_failed', __( 'Could not store face data.', 'capsquery-facial-authentication' ), array( 'status' => 500 ) );
		}

		update_user_meta( $user_id, '_capsq_face_descriptor', $encrypted );

		return array( 'success' => true );
	}

	public function remove_face( WP_REST_Request $req ) {
		$user_id = get_current_user_id();
		delete_user_meta( $user_id, '_capsq_face_descriptor' );
		return array( 'success' => true );
	}

	public function save_pin( WP_REST_Request $req ) {
		$pin = $req->get_param( 'pin' );

		if ( strlen( $pin ) < 4 || strlen( $pin ) > 8 ) {
			return new WP_Error( 'capsq_bad_pin', __( 'PIN must be 4-8 digits.', 'capsquery-facial-authentication' ), array( 'status' => 400 ) );
		}

		$user_id = get_current_user_id();
		update_user_meta( $user_id, '_capsq_pin_hash', wp_hash_password( $pin ) );

		return array( 'success' => true );
	}

	/* ---------------------------------------------------------------- */
	/* Public matching flow                                              */
	/* ---------------------------------------------------------------- */

	/**
	 * Compares the submitted descriptor against enrolled users
	 * and returns a short-lived opaque match token if found.
	 */
	public function match_face( WP_REST_Request $req ) {
		$rate = $this->rate_limit_check( 'capsq_match_rl_' . $this->client_ip(), 20, MINUTE_IN_SECONDS );
		if ( is_wp_error( $rate ) ) {
			return $rate;
		}

		$descriptor = array_map( 'floatval', $req->get_param( 'descriptor' ) );
		
		global $wpdb;

		$cache_key = 'capsq_all_descriptors';
		$rows = wp_cache_get( $cache_key, 'capsq' );

		if ( false === $rows ) {
			$rows = $wpdb->get_results(
			    $wpdb->prepare("SELECT user_id, meta_value FROM {$wpdb->usermeta} WHERE meta_key = %s", '_capsq_face_descriptor')
			);
			wp_cache_set( $cache_key, $rows, 'capsq', HOUR_IN_SECONDS ); // long TTL now, just a safety net
		}

		$best_user_id = 0;
		$best_dist    = PHP_FLOAT_MAX;
		$threshold    = Capsq_Helpers::get_threshold();

		foreach ( $rows as $row ) {
			$plain = Capsq_Crypto::decrypt( $row->meta_value );
			if ( ! $plain ) {
				continue;
			}
			$stored = json_decode( $plain, true );
			if ( ! is_array( $stored ) || count( $stored ) !== 128 ) {
				continue;
			}

			$dist = $this->euclidean_distance( $descriptor, $stored );
			if ( $dist < $best_dist ) {
				$best_dist    = $dist;
				$best_user_id = (int) $row->user_id;
			}
		}

		if ( ! $best_user_id || $best_dist > $threshold ) {
			return new WP_Error(
				'capsq_no_match',
				__( 'Face not recognized. Please try again or use your password.', 'capsquery-facial-authentication' ),
				array( 'status' => 401 )
			);
		}


		if ( ! get_user_meta( $best_user_id, '_capsq_pin_hash', true ) ) {
			return new WP_Error( 'capsq_no_pin', __( 'Account is not fully configured for Capsquery Facial Authentication.', 'capsquery-facial-authentication' ), array( 'status' => 400 ) );
		}

		$token = wp_generate_password( 32, false );
		set_transient(
			'capsq_match_' . $token,
			array(
				'user_id'  => $best_user_id,
				'attempts' => 0,
			),
			2 * MINUTE_IN_SECONDS
		);

		$user = get_userdata( $best_user_id );

		return array(
			'success' => true,
			'token'   => $token,
			'user'    => $user ? $user->display_name : '',
		);
	}

	/**
	 * Verifies the PIN tied to a match token. On success, logs the user in.
	 */
	public function verify_pin( WP_REST_Request $req ) {
		$rate = $this->rate_limit_check( 'capsq_verify_rl_' . $this->client_ip(), 30, MINUTE_IN_SECONDS );
		if ( is_wp_error( $rate ) ) {
			return $rate;
		}

		$token = $req->get_param( 'token' );
		$pin   = $req->get_param( 'pin' );

		$data = get_transient( 'capsq_match_' . $token );
		if ( ! $data ) {
			return new WP_Error( 'capsq_expired', __( 'Face match expired, please try again.', 'capsquery-facial-authentication' ), array( 'status' => 401 ) );
		}

		$user_id = (int) $data['user_id'];
		$hash    = get_user_meta( $user_id, '_capsq_pin_hash', true );

		if ( ! $hash || ! wp_check_password( $pin, $hash ) ) {
			do_action( 'wp_login_failed', $user_login_for_logging, new WP_Error( 'capsq_wrong_pin', 'Incorrect PIN' ) );
			$data['attempts']++;
			if ( $data['attempts'] >= CAPSQ_MAX_PIN_ATTEMPTS ) {
				delete_transient( 'capsq_match_' . $token );
				return new WP_Error( 'capsq_locked', __( 'Too many incorrect attempts. Please scan your face again.', 'capsquery-facial-authentication' ), array( 'status' => 401 ) );
			}
			set_transient( 'capsq_match_' . $token, $data, 2 * MINUTE_IN_SECONDS );
			return new WP_Error(
				'capsq_wrong_pin',
				__( 'Incorrect PIN.', 'capsquery-facial-authentication' ),
				array(
					'status'        => 401,
					'attempts_left' => CAPSQ_MAX_PIN_ATTEMPTS - $data['attempts'],
				)
			);
		}

		delete_transient( 'capsq_match_' . $token );

		$user = get_user_by( 'id', $user_id );
		if ( ! $user ) {
			return new WP_Error( 'capsq_no_user', __( 'User not found.', 'capsquery-facial-authentication' ), array( 'status' => 404 ) );
		}

		$authenticate_cb = function ( $wp_user, $username, $password ) use ( $user ) {
		    return $user;
		};
		add_filter( 'authenticate', $authenticate_cb, 5, 3 );

		$signon = wp_signon( array(), is_ssl() );

		remove_filter( 'authenticate', $authenticate_cb, 5 );

		if ( is_wp_error( $signon ) ) {
		    // A 2FA plugin (or anything else on 'authenticate') rejected it.
		    return new WP_Error( 'capsq_login_blocked', $signon->get_error_message(), array( 'status' => 401 ) );
		}

		$redirect = apply_filters( 'capsq_login_redirect', admin_url(), $signon );

		return array(
		    'success'  => true,
		    'redirect' => esc_url_raw( $redirect ),
		);
	}

	/* ---------------------------------------------------------------- */
	/* Validation & helpers                                               */
	/* ---------------------------------------------------------------- */

	public function validate_descriptor( $descriptor ) {
		if ( ! is_array( $descriptor ) || count( $descriptor ) !== 128 ) {
			return false;
		}
		foreach ( $descriptor as $v ) {
			if ( ! is_numeric( $v ) ) {
				return false;
			}
		}
		return true;
	}

	public function sanitize_pin( $pin ) {
		return preg_replace( '/[^0-9]/', '', (string) $pin );
	}

	private function euclidean_distance( $a, $b ) {
		$sum = 0.0;
		$n   = count( $a );
		for ( $i = 0; $i < $n; $i++ ) {
			$diff = $a[ $i ] - $b[ $i ];
			$sum += $diff * $diff;
		}
		return sqrt( $sum );
	}

	private function client_ip() {
		return isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : 'unknown';
	}

	/**
	 * Transient-based rate limiter.
	 *
	 * @return true|WP_Error
	 */
	private function rate_limit_check( $key, $max_attempts, $window_seconds ) {
		$count = (int) get_transient( $key );
		if ( $count >= $max_attempts ) {
			return new WP_Error(
				'capsq_rate_limited',
				__( 'Too many requests, please slow down.', 'capsquery-facial-authentication' ),
				array( 'status' => 429 )
			);
		}
		set_transient( $key, $count + 1, $window_seconds );
		return true;
	}
}
