<?php
namespace Pay4All\Pro\Frontend\Newsletter;

use Pay4All\Pro\Domain\NewsletterSendLog;

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

/**
 * Public tracking endpoints hit by newsletter recipients :
 *
 *   /?p4all_nl_open=<track_token>     → serves a 1×1 transparent GIF,
 *                                       records an open on the send row.
 *   /?p4all_nl_click=<track_token>&u=…→ records a click, then 302 to the
 *                                       original URL (decoded from base64).
 *
 * Both routes short-circuit WP's template rendering so the response stays
 * lightweight (no theme boot, no headers WP would set for HTML pages).
 *
 * Kept OUT of the LicenseManager gate : a merchant whose Pro licence
 * expires must still get accurate stats for the campaigns they sent
 * BEFORE the expiry, and blocking the tracking would break the live
 * links their recipients hold in their inbox.
 */
final class TrackingEndpoint {

	public const OPEN_KEY  = 'p4all_nl_open';
	public const CLICK_KEY = 'p4all_nl_click';
	public const URL_KEY   = 'u';

	public static function register() : void {
		add_action( 'init', [ self::class, 'maybe_handle' ] );
	}

	public static function maybe_handle() : void {
		// Public tracking endpoints hit by e-mail clients loading the open
		// pixel or by recipients clicking a rewritten link. No nonce
		// possible (mail is out-of-band from WP sessions) — the auth is
		// the opaque per-send token embedded in the URL, validated in
		// handle_open()/handle_click() via a DB lookup on `track_token`.
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- public pixel/redirect endpoint, token-authenticated below.
		if ( isset( $_GET[ self::OPEN_KEY ] ) ) {
			// phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitize_token() strips to [a-f0-9]{40} below (whitelist stricter than sanitize_text_field).
			$token = self::sanitize_token( (string) wp_unslash( $_GET[ self::OPEN_KEY ] ) );
			self::handle_open( $token );
			exit;
		}
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- see above.
		if ( isset( $_GET[ self::CLICK_KEY ] ) ) {
			// phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitize_token() below whitelists to [a-f0-9]{40}.
			$token = self::sanitize_token( (string) wp_unslash( $_GET[ self::CLICK_KEY ] ) );
			// phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- $raw is base64url and re-validated as an http/https URL in decode_url() before redirect.
			$raw   = isset( $_GET[ self::URL_KEY ] ) ? (string) wp_unslash( $_GET[ self::URL_KEY ] ) : '';
			self::handle_click( $token, $raw );
			exit;
		}
	}

	// ----------------------------------------------------------------
	// Open pixel — records + returns a 1×1 transparent GIF
	// ----------------------------------------------------------------
	private static function handle_open( string $token ) : void {
		if ( $token !== '' ) {
			$row = NewsletterSendLog::find_by_track_token( $token );
			if ( $row && (string) $row['status'] !== NewsletterSendLog::STATUS_TEST ) {
				NewsletterSendLog::record_open( (int) $row['id'] );
			}
		}
		self::serve_pixel();
	}

	/**
	 * Sends a 43-byte GIF89a transparent pixel with cache-busting
	 * headers so intermediate proxies (corporate MTA scanners, Apple MPP,
	 * Gmail image cache) don't dedupe repeat opens against the same URL.
	 *
	 * We use `nocache_headers()` for Cache-Control + Pragma + Expires so
	 * we stay aligned with the rest of WP's no-cache stack. Content-Type
	 * is set manually to image/gif.
	 */
	private static function serve_pixel() : void {
		nocache_headers();
		header( 'Content-Type: image/gif' );
		header( 'Content-Length: 43' );
		// GIF89a 1×1 transparent — hex-encoded so the file is text-safe.
		$gif = base64_decode( 'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' );
		// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- binary GIF payload.
		echo $gif;
	}

	// ----------------------------------------------------------------
	// Click — records + 302 to decoded destination
	// ----------------------------------------------------------------
	private static function handle_click( string $token, string $encoded_url ) : void {
		$destination = home_url( '/' );
		if ( $token !== '' ) {
			$row = NewsletterSendLog::find_by_track_token( $token );
			if ( $row && (string) $row['status'] !== NewsletterSendLog::STATUS_TEST ) {
				NewsletterSendLog::record_click( (int) $row['id'] );
			}
		}
		if ( $encoded_url !== '' ) {
			$decoded = self::decode_url( $encoded_url );
			if ( $decoded !== '' ) { $destination = $decoded; }
		}
		wp_safe_redirect( $destination, 302 );
	}

	private static function sanitize_token( string $raw ) : string {
		$t = preg_replace( '/[^a-f0-9]/i', '', trim( $raw ) );
		return strlen( (string) $t ) === 40 ? strtolower( (string) $t ) : '';
	}

	/**
	 * Reverses the base64-urlsafe encoding used by
	 * NewsletterSpooler::rewrite_url_for_tracking(). Falls back to an
	 * empty string when the payload isn't a well-formed http(s) URL —
	 * we then redirect to the home URL rather than to a possibly
	 * attacker-controlled destination.
	 */
	private static function decode_url( string $encoded ) : string {
		$padded = strtr( $encoded, '-_', '+/' );
		$pad    = strlen( $padded ) % 4;
		if ( $pad > 0 ) { $padded .= str_repeat( '=', 4 - $pad ); }
		$raw = base64_decode( $padded, true );
		if ( $raw === false || $raw === '' ) { return ''; }
		$parsed = wp_parse_url( $raw );
		if ( ! is_array( $parsed ) || empty( $parsed['scheme'] ) ) { return ''; }
		if ( ! in_array( strtolower( (string) $parsed['scheme'] ), [ 'http', 'https' ], true ) ) { return ''; }
		return esc_url_raw( $raw, [ 'http', 'https' ] );
	}
}
