<?php
/**
 * Magic-link request handler and early link-click verification for the portal.
 *
 * This is the plugin's SECOND anonymous (nopriv) write surface, after the visit
 * form. It reuses the visit-form anti-abuse gauntlet verbatim: verify the nonce
 * first, drop honeypot and time-trap hits silently, then rate-limit per IP and
 * per email. Its defining property is email-enumeration neutrality: whether or
 * not the submitted email matches a customer, the handler follows the same code
 * path and returns one identical confirmation. An email is dispatched only on a
 * match, but the response never reveals that.
 *
 * The link-click verification runs on template_redirect (before any theme
 * output), claims the single-use token, sets the signed session cookie via
 * WrenchPress_Portal_Session::issue_cookie() while headers can still be sent,
 * and wp_safe_redirect()s to a clean portal URL so the token never lingers in
 * the address bar or browser history. The cookie is NEVER set from a shortcode
 * render path.
 *
 * @package WrenchPress
 */

defined( 'ABSPATH' ) || exit;

/**
 * Class WrenchPress_Portal_Request
 */
class WrenchPress_Portal_Request {

	/**
	 * The admin-post action slug for the magic-link request POST.
	 */
	const ACTION = 'wrenchpress_portal_request';

	/**
	 * Nonce action guarding the magic-link request POST.
	 */
	const NONCE_ACTION = 'wrenchpress_portal_request';

	/**
	 * Query arg carrying the single-use token on the emailed sign-in link.
	 */
	const LOGIN_QUERY_ARG = 'wrenchpress_login';

	/**
	 * Shortcode tag whose host page is the portal (and redirect) target.
	 */
	const PORTAL_SHORTCODE = 'wrenchpress_portal';

	/**
	 * Register the nopriv/priv request handlers and the early link verifier.
	 *
	 * Both admin_post_nopriv_ and admin_post_ are registered so a logged-in
	 * staffer testing the form works too, mirroring the visit-form registration.
	 * maybe_verify_login() runs on template_redirect, before any output, so the
	 * session cookie is set while headers can still be sent (Pitfall 6).
	 *
	 * @return void
	 */
	public static function register() {
		add_action( 'admin_post_nopriv_' . self::ACTION, array( __CLASS__, 'handle' ) );
		add_action( 'admin_post_' . self::ACTION, array( __CLASS__, 'handle' ) );
		add_action( 'template_redirect', array( __CLASS__, 'maybe_verify_login' ) );
	}

	/**
	 * Handle a magic-link request POST: run the gauntlet, then redirect neutrally.
	 *
	 * The whole decision is delegated to process(), which returns the one neutral
	 * confirmation URL used for every outcome. This wrapper only performs the safe
	 * redirect and exits (the Post/Redirect/Get tail), keeping process() a
	 * testable, exit-free seam.
	 *
	 * @return void
	 */
	public static function handle() {
		wp_safe_redirect( self::process() );
		exit;
	}

	/**
	 * Run the anti-abuse gauntlet and (only on a match) send the sign-in link.
	 *
	 * Fixed order, identical to the visit form: nonce first (a failure returns the
	 * neutral confirmation, never wp_die, so a cached page can resubmit), honeypot
	 * silent drop, time-trap silent drop, then per-IP and per-email rate limit.
	 * Regardless of match state or rate-limit state the SAME neutral confirmation
	 * URL is returned; wp_mail() fires only inside the matched-customer branch.
	 * This single return point is what makes a known and an unknown email
	 * indistinguishable from the response (T-08-03-01).
	 *
	 * Returns the neutral confirmation URL; it does not redirect or exit, so tests
	 * can assert both the returned URL and whether an email was sent.
	 *
	 * @return string Neutral confirmation redirect URL.
	 */
	public static function process() {
		// 1. Nonce (CSRF) first. Generic, retryable: return the neutral response
		// rather than wp_die so a legitimate visitor on a cached page can resubmit
		// and an attacker learns nothing (T-08-03-02).
		$nonce = isset( $_POST['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ) : '';

		if ( ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) {
			return self::neutral_url();
		}

		// Raw POST snapshot for the trap checks; the persisted email is sanitized
		// separately below.
		$raw = wp_unslash( $_POST ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- trap inspection only; the email is sanitized before use below.

		// 2. Honeypot: a filled hidden field means a bot. Drop silently.
		if ( WrenchPress_Anti_Abuse::honeypot_tripped( $raw ) ) {
			return self::neutral_url();
		}

		// 3. Time-trap: an implausibly fast submission is automated. Drop silently.
		if ( WrenchPress_Anti_Abuse::too_fast( $raw ) ) {
			return self::neutral_url();
		}

		// Sanitize on input before the rate limit so the email key is normalized.
		$email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '';

		// 4. Rate limit: per-IP AND per-email. When tripped, return the neutral
		// response WITHOUT sending an email (T-08-03-03).
		$ip = WrenchPress_Anti_Abuse::client_ip();

		if ( WrenchPress_Anti_Abuse::is_rate_limited( $ip, $email ) ) {
			return self::neutral_url();
		}

		// 5. Match branch: send the link ONLY when a valid email resolves to at
		// least one active customer. No match sends nothing but falls through to
		// the identical neutral response below.
		if ( '' !== $email && is_email( $email ) ) {
			$customers = WrenchPress_Customers_Repository::find_by_email( $email );

			if ( ! empty( $customers ) ) {
				self::send_login_link( (int) $customers[0]->id, $email );
			}
		}

		return self::neutral_url();
	}

	/**
	 * Verify a clicked magic link early, set the cookie, and strip the token.
	 *
	 * Runs on template_redirect (before any theme output). When the login query
	 * arg is present it claims the single-use token; on success it issues the
	 * signed session cookie while headers can still be sent and redirects to the
	 * same URL with the token removed, so the token never lingers in the address
	 * bar or history (T-08-03-04 / T-08-03-05). On any failure (empty, expired,
	 * used, or forged token) it redirects to the clean URL with an expired flag
	 * and sets no cookie. The token itself is the single-use bearer secret, so the
	 * emailed GET carries no nonce.
	 *
	 * @return void
	 */
	public static function maybe_verify_login() {
		// phpcs:disable WordPress.Security.NonceVerification.Recommended -- The magic-link GET is authenticated by the single-use token itself, not a nonce; an emailed cross-session link cannot carry one.
		if ( ! isset( $_GET[ self::LOGIN_QUERY_ARG ] ) ) {
			return;
		}

		$token = sanitize_text_field( wp_unslash( $_GET[ self::LOGIN_QUERY_ARG ] ) );
		// phpcs:enable WordPress.Security.NonceVerification.Recommended

		$clean = remove_query_arg( self::LOGIN_QUERY_ARG );

		if ( '' === $token ) {
			wp_safe_redirect( add_query_arg( array( 'expired' => 1 ), $clean ) );
			exit;
		}

		$customer_id = WrenchPress_Portal_Session::claim_token( $token );

		if ( $customer_id > 0 ) {
			// Set the cookie EARLY (before output) then redirect to the token-free
			// URL. issue_cookie is called here and NEVER from a shortcode render.
			WrenchPress_Portal_Session::issue_cookie( WrenchPress_Portal_Session::last_session_id() );

			wp_safe_redirect( $clean );
			exit;
		}

		wp_safe_redirect( add_query_arg( array( 'expired' => 1 ), $clean ) );
		exit;
	}

	/**
	 * Mint a token for the matched customer and email the sign-in link.
	 *
	 * The token is single-use and expires in 30 minutes. A wp_mail() failure is
	 * surfaced server-side via an action hook but never changes the caller's
	 * neutral response (UI-SPEC error-states row): the customer must not learn
	 * that delivery failed any more than an attacker learns the email exists.
	 *
	 * @param int    $customer_id Matched active customer id.
	 * @param string $email       Destination email (already sanitized).
	 * @return void
	 */
	private static function send_login_link( $customer_id, $email ) {
		$token = WrenchPress_Portal_Session::mint_token( $customer_id );

		if ( '' === $token ) {
			return;
		}

		$link = add_query_arg(
			array( self::LOGIN_QUERY_ARG => $token ),
			self::portal_url()
		);

		$subject = __( 'Your sign-in link', 'pitdesk-repair-crm' );

		$message = sprintf(
			/* translators: %s: single-use sign-in URL. */
			__( "Use the link below to sign in to your account. It expires in 30 minutes and can be used once.\n\n%s\n\nIf you did not request this, you can safely ignore this email.", 'pitdesk-repair-crm' ),
			$link
		);

		$sent = wp_mail( $email, $subject, $message );

		if ( ! $sent ) {
			/**
			 * Fires when a portal sign-in email could not be dispatched.
			 *
			 * Lets a site log the failure server-side without altering the
			 * enumeration-neutral response returned to the visitor.
			 *
			 * @param string $email Intended recipient.
			 */
			do_action( 'wrenchpress_portal_login_email_failed', $email );
		}
	}

	/**
	 * Build the single neutral confirmation redirect used for every outcome.
	 *
	 * Match, no-match, rate-limited, honeypot, time-trap, and nonce-failure paths
	 * all return this exact URL, so the response cannot be used to enumerate which
	 * emails are on file (T-08-03-01). The copy on the portal page reads "If that
	 * email is on file, we have sent a sign-in link."
	 *
	 * @return string
	 */
	private static function neutral_url() {
		return add_query_arg( array( 'wrenchpress_portal' => 'sent' ), self::portal_url() );
	}

	/**
	 * Resolve the front-end URL of the page hosting the portal shortcode.
	 *
	 * Scans published pages for the [wrenchpress_portal] shortcode and returns the
	 * first match's permalink. When no such page exists the target falls back
	 * through wp_validate_redirect() to home_url('/'), so the value is always a
	 * validated, same-host front-end URL and never an admin URL (T-08-03-06).
	 *
	 * @return string
	 */
	private static function portal_url() {
		$url = '';

		$page_ids = get_posts(
			array(
				'post_type'        => 'page',
				'post_status'      => 'publish',
				'numberposts'      => 100,
				'fields'           => 'ids',
				'orderby'          => 'ID',
				'order'            => 'ASC',
				'suppress_filters' => true,
			)
		);

		foreach ( $page_ids as $page_id ) {
			$content = (string) get_post_field( 'post_content', $page_id );

			if ( has_shortcode( $content, self::PORTAL_SHORTCODE ) ) {
				$url = (string) get_permalink( $page_id );
				break;
			}
		}

		if ( '' === $url ) {
			$url = home_url( '/' );
		}

		return wp_validate_redirect( $url, home_url( '/' ) );
	}
}
