<?php
/**
 * Magic Link URL Generator and Verifier
 *
 * Generates HMAC-signed URLs for public transcript viewing without login.
 * Business owners receive these links in email notifications.
 *
 * @link       https://github.com/sitestaffr/sitestaffr-plugin
 * @since      1.2.0
 *
 * @package    SiteStaffr
 * @subpackage SiteStaffr/includes
 */

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

/**
 * Magic Link class for generating and verifying signed transcript URLs.
 *
 * Uses HMAC-SHA256 to sign call IDs, allowing secure public access
 * to conversation transcripts without WordPress authentication.
 *
 * @since      1.2.0
 * @package    SiteStaffr
 * @subpackage SiteStaffr/includes
 * @author     SiteStaffr
 */
class SiteStaffr_Magic_Link {

	/**
	 * Point at which the legacy non-expiring link shape stops being honoured.
	 *
	 * 2026-09-30 23:59:59 UTC. Owner-notification emails sent before 1.40.0 carry
	 * bare `?sitestaffr_transcript={id}&sig={hmac(id)}` URLs with no expiry — a
	 * permanent public view of a transcript. They are honoured until this moment so
	 * links already in customers' inboxes do not break the day the update lands,
	 * then refused with a distinct 410 explaining why.
	 *
	 * Deliberately a fixed timestamp rather than a rolling window from activation:
	 * auditable, stores no extra state, and fails closed.
	 *
	 * @since 1.40.0
	 * @var int
	 */
	const LEGACY_LINK_CUTOFF = 1790812799;

	/**
	 * Default lifetime of an owner-facing transcript link.
	 *
	 * @since 1.40.0
	 * @var int
	 */
	const TRANSCRIPT_TTL = 2592000; // 30 days.

	/**
	 * Option holding the link revocation epoch.
	 *
	 * @since 1.40.0
	 * @var string
	 */
	const EPOCH_OPTION = 'sitestaffr_link_revocation_epoch';

	/**
	 * Verify a legacy, non-expiring HMAC signature for a call ID.
	 *
	 * Uses timing-safe comparison to prevent timing attacks.
	 *
	 * LEGACY — retained only to honour links already sitting in customers' inboxes
	 * until LEGACY_LINK_CUTOFF. Nothing mints this shape any more; the generator was
	 * removed in 1.40.0 so a permanent transcript URL cannot be reintroduced by
	 * accident. Callers must check legacy_links_expired() first. Delete this method
	 * once the cutoff has passed.
	 *
	 * @since  1.2.0
	 * @param  int    $call_id    The call record ID.
	 * @param  string $signature  The HMAC signature to verify.
	 * @return bool               True if signature is valid, false otherwise.
	 */
	public static function verify( $call_id, $signature ) {
		$call_id = absint( $call_id );

		if ( ! $call_id || empty( $signature ) ) {
			return false;
		}

		$secret = self::get_secret();

		if ( ! $secret ) {
			return false;
		}

		$expected = hash_hmac( 'sha256', (string) $call_id, $secret );

		return hash_equals( $expected, (string) $signature );
	}

	/**
	 * Retrieve the HMAC signing secret from WordPress options.
	 *
	 * Checks per-site secret first, falls back to global secret.
	 * Does NOT auto-generate a secret if none exists.
	 *
	 * @since  1.2.0
	 * @return string  The secret key, or empty string if not configured.
	 */
	private static function get_secret() {
		$secret = get_option( 'sitestaffr_site_api_secret', '' );

		if ( ! empty( $secret ) ) {
			return $secret;
		}

		$secret = get_option( 'sitestaffr_api_secret', '' );

		if ( ! empty( $secret ) ) {
			return $secret;
		}

		return '';
	}

	/**
	 * Build a scoped, expiring signed URL for a mutating no-login surface.
	 *
	 * Binds the signature to an action scope and an expiry so a link cannot be
	 * replayed against a different surface or used indefinitely. Since 1.40.0 this
	 * is the only shape the plugin mints, transcripts included.
	 *
	 * @since 1.28.0
	 * @param  string $scope     Action scope, e.g. 'blog-review'.
	 * @param  int    $id        Record id (e.g. WP post ID).
	 * @param  string $query_key Query var carrying the id, e.g. 'sitestaffr_blog_review'.
	 * @param  int    $ttl       Lifetime in seconds.
	 * @return string            Signed URL, or '' if no secret is configured.
	 */
	public static function generate_scoped_url( $scope, $id, $query_key, $ttl = MONTH_IN_SECONDS ) {
		$id = absint( $id );

		if ( ! $id || '' === (string) $scope || '' === (string) $query_key ) {
			return '';
		}

		if ( self::scope_is_unsafe( $scope ) ) {
			return '';
		}

		$secret = self::get_secret();

		if ( ! $secret ) {
			return '';
		}

		$exp       = time() + max( 60, (int) $ttl );
		$signature = hash_hmac( 'sha256', self::scoped_message( $scope, $id, $exp ), $secret );

		return add_query_arg(
			array(
				$query_key => $id,
				'exp'      => $exp,
				'sig'      => $signature,
			),
			home_url( '/' )
		);
	}

	/**
	 * Verify a scoped, expiring signature.
	 *
	 * Uses timing-safe comparison and rejects expired links. The scope string is
	 * part of the signed message, so a signature minted for one surface cannot be
	 * replayed against another.
	 *
	 * @since 1.28.0
	 * @param  string $scope Action scope used when signing.
	 * @param  int    $id    Record id.
	 * @param  int    $exp   Expiry timestamp from the URL.
	 * @param  string $sig   Signature from the URL.
	 * @return bool
	 */
	public static function verify_scoped( $scope, $id, $exp, $sig ) {
		$id  = absint( $id );
		$exp = (int) $exp;

		if ( ! $id || '' === (string) $scope || empty( $sig ) ) {
			return false;
		}

		if ( self::scope_is_unsafe( $scope ) ) {
			return false;
		}

		if ( $exp < time() ) {
			return false;
		}

		$secret = self::get_secret();

		if ( ! $secret ) {
			return false;
		}

		$expected = hash_hmac( 'sha256', self::scoped_message( $scope, $id, $exp ), $secret );

		return hash_equals( $expected, (string) $sig );
	}

	/**
	 * Build a scoped, expiring URL for viewing a call transcript.
	 *
	 * The replacement for generate_url(). That signed the bare call id with no
	 * scope and no expiry, producing a permanent public transcript URL; these are
	 * health-data conversations, so an owner link must age out.
	 *
	 * @since  1.40.0
	 * @param  int $call_id The call record ID.
	 * @param  int $ttl     Lifetime in seconds. Defaults to 30 days.
	 * @return string       Signed URL, or '' if no secret is configured.
	 */
	public static function generate_transcript_url( $call_id, $ttl = self::TRANSCRIPT_TTL ) {
		return self::generate_scoped_url( 'transcript-view', $call_id, 'sitestaffr_transcript', $ttl );
	}

	/**
	 * Whether the legacy non-expiring link shape has passed its sunset.
	 *
	 * Revoking also ends the grace immediately. A legacy signature covers only the
	 * bare call id, so bumping the epoch cannot invalidate it the way it invalidates
	 * a scoped link — and legacy links are the permanent ones, the exact population a
	 * revocation is called for. Ending the grace is what makes revocation cover them.
	 *
	 * @since  1.40.0
	 * @param  int|null $now Timestamp to evaluate against. Defaults to now.
	 * @return bool
	 */
	public static function legacy_links_expired( $now = null ) {
		if ( self::current_epoch() > 0 ) {
			return true;
		}

		$now = ( null === $now ) ? time() : (int) $now;

		return $now > self::LEGACY_LINK_CUTOFF;
	}

	/**
	 * Invalidate every outstanding no-login link at once.
	 *
	 * Expiry bounds how long a link lives; this revokes links that have not yet
	 * expired — the incident-response tool for a transcript link that has been
	 * forwarded or leaked. Bumping the epoch changes the signed message, so every
	 * signature minted under a previous epoch stops verifying immediately.
	 *
	 * Also rotates the leads-page bearer token. The leads page re-mints fresh
	 * transcript links on every render, so leaving that token alone would let a
	 * holder of the leads URL keep minting after a revocation — and since the URL
	 * rides in every notification email footer, it is the most-forwarded credential
	 * we issue and the likeliest thing to leak in bulk. Revoking without it would be
	 * revoking in name only.
	 *
	 * The owner's leads URL therefore changes. They pick the new one up from their
	 * next lead-notification email; support can read it immediately with
	 * `wp eval 'echo SiteStaffr_Leads::get_page_url();'`.
	 *
	 * @since  1.40.0
	 * @return int The new epoch.
	 */
	public static function revoke_all_links() {
		$epoch = self::current_epoch() + 1;
		update_option( self::EPOCH_OPTION, $epoch );

		if ( class_exists( 'SiteStaffr_Leads' ) ) {
			SiteStaffr_Leads::reset_token();
		}

		return $epoch;
	}

	/**
	 * Current link revocation epoch.
	 *
	 * @since  1.40.0
	 * @return int
	 */
	private static function current_epoch() {
		return absint( get_option( self::EPOCH_OPTION, 0 ) );
	}

	/**
	 * Whether a scope would corrupt the signed message.
	 *
	 * The signed message is pipe-delimited, so a scope containing a pipe could make
	 * two different tuples hash identically and let a signature cross surfaces. Every
	 * scope in the plugin is a hardcoded literal, so this is unreachable today — it is
	 * here so it stays unreachable if a scope is ever derived from data.
	 *
	 * @since  1.40.0
	 * @param  string $scope Action scope.
	 * @return bool
	 */
	private static function scope_is_unsafe( $scope ) {
		return false !== strpos( (string) $scope, '|' );
	}

	/**
	 * Build the message a scoped signature covers.
	 *
	 * At epoch 0 (nothing has ever been revoked) the message is exactly the
	 * historical `scope|id|exp`, so every link already in an inbox keeps working.
	 * The epoch is appended only once it has been bumped.
	 *
	 * @since  1.40.0
	 * @param  string $scope Action scope.
	 * @param  int    $id    Record id.
	 * @param  int    $exp   Expiry timestamp.
	 * @return string
	 */
	private static function scoped_message( $scope, $id, $exp ) {
		$message = $scope . '|' . $id . '|' . $exp;
		$epoch   = self::current_epoch();

		return ( $epoch > 0 ) ? $message . '|' . $epoch : $message;
	}
}
