<?php
/**
 * Passwordless magic-link crypto and the custom signed portal session cookie.
 *
 * This service is the single source of truth for "who is the logged-in
 * customer." A customer is identified WITHOUT any WordPress user account: a
 * short-lived, single-use magic-link token establishes a longer-lived session
 * row, and a self-signed cookie references that row. current_customer_id()
 * derives the authenticated customer_id ONLY from a live active session row and
 * NEVER from request input (the PORT-04 / IDOR invariant).
 *
 * Security primitives are PHP core, not WordPress auth: random_bytes for the
 * token, hash('sha256', ...) for the at-rest token hash, hash_hmac keyed by
 * wp_salt('auth') for the cookie signature, and hash_equals for every secret
 * comparison. A WordPress auth login cookie is deliberately never issued: a
 * customer must never receive a real WordPress login (D-05, per the project auth policy).
 *
 * @package WrenchPress
 */

defined( 'ABSPATH' ) || exit;

/**
 * Class WrenchPress_Portal_Session
 */
class WrenchPress_Portal_Session {

	/**
	 * Name of the self-signed portal session cookie.
	 */
	const COOKIE_NAME = 'wrenchpress_portal';

	/**
	 * Lifetime of a pending magic-link token (short, single-use).
	 */
	const TOKEN_TTL = 30 * MINUTE_IN_SECONDS;

	/**
	 * Lifetime of an established portal session (long-lived remember-me, D-04).
	 */
	const SESSION_TTL = 30 * DAY_IN_SECONDS;

	/**
	 * Row id of the session most recently activated by claim_token().
	 *
	 * The claim_token() call returns the customer_id; the caller passes this id
	 * to issue_cookie() to mint the signed cookie for the freshly established
	 * session. Reset to 0 until a successful claim.
	 *
	 * @var int
	 */
	protected static $last_session_id = 0;

	/**
	 * Mint a magic-link token for a customer.
	 *
	 * Generates a CSPRNG plaintext token, stores ONLY its SHA-256 hash (with the
	 * REMOTE_ADDR/UA fingerprint and a short expiry) via the sessions
	 * repository, and returns the plaintext for the emailed link. The plaintext
	 * never reaches the database.
	 *
	 * @param int $customer_id Customer the token belongs to.
	 * @return string Plaintext token for the emailed link, or '' on failure.
	 */
	public static function mint_token( $customer_id ) {
		$customer_id = absint( $customer_id );

		if ( 0 === $customer_id ) {
			return '';
		}

		$token      = bin2hex( random_bytes( 32 ) );
		$token_hash = hash( 'sha256', $token );

		$inserted = WrenchPress_Customer_Sessions_Repository::insert_pending_token(
			$customer_id,
			$token_hash,
			self::client_ip(),
			self::user_agent(),
			self::future_mysql( self::TOKEN_TTL )
		);

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

		return $token;
	}

	/**
	 * Claim a presented magic-link token and establish a session.
	 *
	 * Hashes the plaintext, looks up the guarded pending row (unused,
	 * unexpired), confirms the stored hash in constant time, then single-use
	 * activates it into a SESSION_TTL session. On success the activated row id
	 * is stashed in last_session_id() so the caller can issue the cookie.
	 * Returns 0 for any expired, already-used, forged, or unknown token.
	 *
	 * @param string $plaintext Plaintext token from the magic-link URL.
	 * @return int Customer id on success, 0 otherwise.
	 */
	public static function claim_token( $plaintext ) {
		self::$last_session_id = 0;

		$plaintext = (string) $plaintext;

		if ( '' === $plaintext ) {
			return 0;
		}

		$token_hash = hash( 'sha256', $plaintext );

		$row = WrenchPress_Customer_Sessions_Repository::find_pending_by_hash( $token_hash );

		if ( null === $row ) {
			return 0;
		}

		// Constant-time confirmation the looked-up hash matches (defence in depth
		// alongside the repository's own WHERE guard; never == / ===).
		if ( ! hash_equals( (string) $row->token_hash, $token_hash ) ) {
			return 0;
		}

		$activated = WrenchPress_Customer_Sessions_Repository::activate(
			(int) $row->id,
			self::future_mysql( self::SESSION_TTL )
		);

		if ( ! $activated ) {
			return 0;
		}

		self::$last_session_id = (int) $row->id;

		return (int) $row->customer_id;
	}

	/**
	 * Row id of the session established by the most recent claim_token() call.
	 *
	 * @return int Session id, or 0 when no session was established.
	 */
	public static function last_session_id() {
		return self::$last_session_id;
	}

	/**
	 * Issue the self-signed session cookie for an established session.
	 *
	 * Builds "session_id|expiry|HMAC(session_id|expiry, wp_salt('auth'))" and
	 * sets it HttpOnly, Secure on SSL, SameSite=Lax. MUST be called early
	 * (before any output) by the magic-link verification path in 08-03; this is
	 * the only place the cookie is ever set.
	 *
	 * @param int $session_id Session row id to bind the cookie to.
	 * @return void
	 */
	public static function issue_cookie( $session_id ) {
		$session_id = absint( $session_id );

		if ( 0 === $session_id ) {
			return;
		}

		$expiry  = time() + self::SESSION_TTL;
		$payload = $session_id . '|' . $expiry;
		$sig     = hash_hmac( 'sha256', $payload, wp_salt( 'auth' ) );

		self::write_cookie( $payload . '|' . $sig, $expiry );
	}

	/**
	 * Resolve the authenticated customer id for the current request.
	 *
	 * Reads the cookie, recomputes the HMAC and rejects on any mismatch
	 * (constant-time), loads the referenced active-and-unexpired session row,
	 * and returns its customer_id. The id is taken ONLY from the row, never from
	 * the cookie payload (PORT-04 / IDOR invariant). Returns 0 on any failure.
	 *
	 * @return int Customer id, or 0 when there is no valid session.
	 */
	public static function current_customer_id() {
		$session_id = self::verified_session_id();

		if ( 0 === $session_id ) {
			return 0;
		}

		$row = WrenchPress_Customer_Sessions_Repository::get_active( $session_id );

		if ( null === $row ) {
			return 0;
		}

		return (int) $row->customer_id;
	}

	/**
	 * Sign the current customer out: destroy the session row and clear the cookie.
	 *
	 * @return void
	 */
	public static function destroy_current() {
		$session_id = self::verified_session_id();

		if ( $session_id > 0 ) {
			WrenchPress_Customer_Sessions_Repository::destroy( $session_id );
		}

		self::clear_cookie();
	}

	/**
	 * Verify the session cookie signature and return the referenced session id.
	 *
	 * Splits the cookie into session_id|expiry|signature, recomputes the HMAC
	 * over session_id|expiry keyed by wp_salt('auth'), and compares it in
	 * constant time. Only the session id is extracted here; the customer_id is
	 * always re-derived from the live session row by the caller.
	 *
	 * Fingerprint policy: the REMOTE_ADDR/UA stored on the row is kept for audit
	 * and future tightening but is deliberately NOT enforced at read time. A
	 * hard IP/UA match would sign out legitimate customers whose mobile network
	 * IP changes mid-session; session validity rests on the signed cookie plus
	 * the live active row, both of which an attacker cannot forge.
	 *
	 * @return int Session id when the signature verifies, 0 otherwise.
	 */
	protected static function verified_session_id() {
		$raw = isset( $_COOKIE[ self::COOKIE_NAME ] )
			? sanitize_text_field( wp_unslash( $_COOKIE[ self::COOKIE_NAME ] ) )
			: '';

		if ( '' === $raw ) {
			return 0;
		}

		$parts = explode( '|', $raw, 3 );

		if ( 3 !== count( $parts ) ) {
			return 0;
		}

		list( $session_id, $expiry, $signature ) = $parts;

		$want = hash_hmac( 'sha256', $session_id . '|' . $expiry, wp_salt( 'auth' ) );

		if ( ! hash_equals( $want, (string) $signature ) ) {
			return 0;
		}

		return absint( $session_id );
	}

	/**
	 * Set the session cookie value (and mirror it into $_COOKIE for this request).
	 *
	 * The $_COOKIE mirror lets current_customer_id() resolve within the same
	 * request that established the session. The setcookie() call is skipped when
	 * headers are already sent (e.g. under the PHPUnit CLI) so callers never
	 * emit a "headers already sent" warning.
	 *
	 * @param string $value   Cookie value to set.
	 * @param int    $expires Absolute expiry as a Unix timestamp.
	 * @return void
	 */
	private static function write_cookie( $value, $expires ) {
		$_COOKIE[ self::COOKIE_NAME ] = $value;

		if ( headers_sent() ) {
			return;
		}

		setcookie(
			self::COOKIE_NAME,
			$value,
			array(
				'expires'  => (int) $expires,
				'path'     => COOKIEPATH,
				'domain'   => COOKIE_DOMAIN,
				'secure'   => is_ssl(),
				'httponly' => true,
				'samesite' => 'Lax',
			)
		);
	}

	/**
	 * Clear the session cookie (both the browser cookie and the request mirror).
	 *
	 * @return void
	 */
	private static function clear_cookie() {
		unset( $_COOKIE[ self::COOKIE_NAME ] );

		if ( headers_sent() ) {
			return;
		}

		setcookie(
			self::COOKIE_NAME,
			'',
			array(
				'expires'  => time() - YEAR_IN_SECONDS,
				'path'     => COOKIEPATH,
				'domain'   => COOKIE_DOMAIN,
				'secure'   => is_ssl(),
				'httponly' => true,
				'samesite' => 'Lax',
			)
		);
	}

	/**
	 * Request IP fingerprint (REMOTE_ADDR only, via the shared anti-abuse helper).
	 *
	 * @return string
	 */
	private static function client_ip() {
		return WrenchPress_Anti_Abuse::client_ip();
	}

	/**
	 * Request user-agent fingerprint, sanitized.
	 *
	 * @return string
	 */
	private static function user_agent() {
		if ( ! isset( $_SERVER['HTTP_USER_AGENT'] ) ) {
			return '';
		}

		return sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) );
	}

	/**
	 * A MySQL datetime the given number of seconds in the future.
	 *
	 * Expressed in the site's local wall-clock so it lines up with the
	 * repository's current_time('mysql') expiry comparisons regardless of the
	 * site timezone.
	 *
	 * @param int $seconds Seconds from now.
	 * @return string MySQL datetime (Y-m-d H:i:s).
	 */
	private static function future_mysql( $seconds ) {
		$seconds = absint( $seconds );
		$now     = current_datetime();

		return gmdate( 'Y-m-d H:i:s', $now->getTimestamp() + $now->getOffset() + $seconds );
	}
}
