<?php
/**
 * @package Hafenstudios\Kurato
 */

namespace Hafenstudios\Kurato\Rest;

use Hafenstudios\Kurato\Campaign\LinkRegistry;
use Hafenstudios\Kurato\Db\CampaignRepository;
use Hafenstudios\Kurato\Db\ContactRepository;
use Hafenstudios\Kurato\Db\MailEventRepository;
use Hafenstudios\Kurato\Mail\TrackingToken;

defined( 'ABSPATH' ) || exit;

/**
 * Oeffentliche Tracking-Endpoints hinter dem doppelten Consent-Gate (K4-Konzept
 * Abschnitt 7, K4e-2):
 *
 * - `GET kurato/v1/t/open`  liefert immer ein 1x1-GIF (no-cache) und schreibt -
 *   nur bei Site-Schalter AN und noch vorliegendem Kontakt-Consent - genau EIN
 *   `open`-Ereignis je Kampagne+Kontakt (Dedup). Ein ungueltiger Token liefert
 *   trotzdem das GIF (kein Fehler-Leak).
 * - `GET kurato/v1/t/click` loest den Token ueber die Link-Registry der Kampagne
 *   zu einer hinterlegten URL auf und macht darauf ein 302 (kein Open-Redirect).
 *   Ein `click`-Ereignis wird - nur bei Site-Schalter AN und Consent - je
 *   Kampagne+Kontakt+Link einmal geschrieben (Dedup). Ungueltiger Token oder
 *   unbekannter Link: 302 auf `home_url('/')`.
 *
 * Kein Login (Token ist die Absicherung), grosszuegiges Rate-Limit pro IP (Muster
 * Webhook), KEINE IP-Speicherung im Ereignis (BAUPLAN 28).
 *
 * Die reine Wirkungslogik (`record_open`, `resolve_click`) ist frei von HTTP/GIF/
 * Redirect, damit sie ohne WP-Installation testbar bleibt.
 */
final class TrackingController {

	public const REST_NAMESPACE = 'kurato/v1';

	/**
	 * Rate-Limit pro IP je Endpunkt (grosszuegig; E-Mail-Clients buendeln Aufrufe).
	 */
	private const RATE_LIMIT = 300;

	/**
	 * 1x1 transparentes GIF (base64), fuer den Open-Pixel.
	 */
	private const PIXEL_GIF = 'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';

	private TrackingToken $tokens;

	private CampaignRepository $campaigns;

	private ContactRepository $contacts;

	private MailEventRepository $events;

	private LinkRegistry $registry;

	private bool $tracking_enabled;

	/**
	 * @param TrackingToken       $tokens           Tracking-Signierer.
	 * @param CampaignRepository  $campaigns        Kampagnen-Repository (Post-Aufloesung).
	 * @param ContactRepository   $contacts         Kontakt-Repository (Consent-Pruefung).
	 * @param MailEventRepository $events           Ereignis-Log (Dedup + Schreiben).
	 * @param LinkRegistry        $registry         Link-Registry (Redirect-Aufloesung).
	 * @param bool                $tracking_enabled Site-Schalter Tracking.
	 */
	public function __construct(
		TrackingToken $tokens,
		CampaignRepository $campaigns,
		ContactRepository $contacts,
		MailEventRepository $events,
		LinkRegistry $registry,
		bool $tracking_enabled
	) {
		$this->tokens           = $tokens;
		$this->campaigns        = $campaigns;
		$this->contacts         = $contacts;
		$this->events           = $events;
		$this->registry         = $registry;
		$this->tracking_enabled = $tracking_enabled;
	}

	/**
	 * Registriert die Routen.
	 *
	 * @return void
	 */
	public function register_routes(): void {
		register_rest_route(
			self::REST_NAMESPACE,
			'/t/open',
			array(
				'methods'             => \WP_REST_Server::READABLE,
				'callback'            => array( $this, 'open' ),
				'permission_callback' => '__return_true',
				'args'                => array(
					'token' => array( 'type' => 'string', 'required' => false ),
				),
			)
		);
		register_rest_route(
			self::REST_NAMESPACE,
			'/t/click',
			array(
				'methods'             => \WP_REST_Server::READABLE,
				'callback'            => array( $this, 'click' ),
				'permission_callback' => '__return_true',
				'args'                => array(
					'token' => array( 'type' => 'string', 'required' => false ),
				),
			)
		);
	}

	// --- HTTP-Handler ------------------------------------------------------

	/**
	 * Open-Pixel: schreibt (falls erlaubt) ein Ereignis und liefert immer das GIF.
	 *
	 * @param \WP_REST_Request $request Request.
	 * @return void
	 */
	public function open( \WP_REST_Request $request ): void {
		if ( ! $this->rate_limited( 'open' ) ) {
			$this->record_open( (string) $request->get_param( 'token' ) );
		}
		$this->serve_gif();
	}

	/**
	 * Click-Redirect: loest die Ziel-URL aus der Registry auf und leitet weiter.
	 *
	 * @param \WP_REST_Request $request Request.
	 * @return void
	 */
	public function click( \WP_REST_Request $request ): void {
		$url = '';
		if ( ! $this->rate_limited( 'click' ) ) {
			$url = $this->resolve_click( (string) $request->get_param( 'token' ) );
		}
		if ( '' === $url ) {
			$url = home_url( '/' );
		}
		// Ziel stammt ausschliesslich aus der Link-Registry (kein Open-Redirect);
		// darum bewusst wp_redirect (externe Ziele sind nicht in der same-site-
		// Whitelist von wp_safe_redirect).
		wp_redirect( $url ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect
		exit;
	}

	// --- Reine Wirkungslogik (testbar) -------------------------------------

	/**
	 * Schreibt ein Open-Ereignis, wenn Site-Schalter an, Token gueltig, Kontakt-
	 * Consent noch vorliegt und noch kein Open fuer Kampagne+Kontakt vorliegt.
	 *
	 * @param string $token Open-Token.
	 * @return bool true, wenn ein Ereignis geschrieben wurde.
	 */
	public function record_open( string $token ): bool {
		if ( ! $this->tracking_enabled ) {
			return false;
		}
		$claim = $this->tokens->verify_open( $token );
		if ( is_wp_error( $claim ) ) {
			return false;
		}
		$campaign_id = (int) $claim['campaign_id'];
		$contact_id  = (int) $claim['contact_id'];

		if ( ! $this->contact_consents( $contact_id ) ) {
			return false;
		}
		// Dedup: je Kampagne+Kontakt nur das erste Open.
		if ( $this->events->exists_for( $campaign_id, $contact_id, 'open' ) ) {
			return false;
		}
		return $this->events->record( $campaign_id, $contact_id, 'open' ) > 0;
	}

	/**
	 * Loest den Click-Token zu einer hinterlegten Ziel-URL auf (kein
	 * Open-Redirect) und schreibt - nur bei Site-Schalter an und noch vorliegendem
	 * Consent - ein `click`-Ereignis (Dedup je Kampagne+Kontakt+Link). Die
	 * Aufloesung des Ziels ist vom Consent unabhaengig: Links in bereits
	 * versendeten Mails bleiben klickbar, auch wenn spaeter das Tracking
	 * deaktiviert oder widerrufen wird.
	 *
	 * @param string $token Click-Token.
	 * @return string Ziel-URL oder '' (Aufrufer faellt auf home_url zurueck).
	 */
	public function resolve_click( string $token ): string {
		$claim = $this->tokens->verify_click( $token );
		if ( is_wp_error( $claim ) ) {
			return '';
		}
		$campaign_id = (int) $claim['campaign_id'];
		$contact_id  = (int) $claim['contact_id'];
		$link_index  = (int) $claim['link_index'];

		$campaign = $this->campaigns->get( $campaign_id );
		if ( null === $campaign ) {
			return '';
		}
		$post_id = (int) ( $campaign['post_id'] ?? 0 );
		$url     = $this->registry->url_for( $post_id, $link_index );
		if ( '' === $url ) {
			return '';
		}

		// Messen nur bei Site-Schalter an UND noch vorliegendem Consent.
		if ( $this->tracking_enabled && $this->contact_consents( $contact_id )
			&& ! $this->events->exists_for( $campaign_id, $contact_id, 'click', 'link:' . $link_index ) ) {
			$this->events->record( $campaign_id, $contact_id, 'click', array( 'detail' => 'link:' . $link_index ) );
		}

		return $url;
	}

	// --- Helfer ------------------------------------------------------------

	/**
	 * Liegt fuer den Kontakt aktuell Tracking-Consent vor?
	 *
	 * @param int $contact_id Kontakt.
	 * @return bool
	 */
	private function contact_consents( int $contact_id ): bool {
		$contact = $this->contacts->get( $contact_id );
		return null !== $contact && 1 === (int) ( $contact['tracking_consent'] ?? 0 );
	}

	/**
	 * Prueft und erhoeht das IP-Rate-Limit je Endpunkt.
	 *
	 * @param string $bucket Endpunkt-Kennung (open|click).
	 * @return bool true, wenn blockiert.
	 */
	private function rate_limited( string $bucket ): bool {
		$ip  = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
		$key = 'kurato_track_' . $bucket . '_' . md5( $ip );

		$count = (int) get_transient( $key );
		if ( $count >= self::RATE_LIMIT ) {
			return true;
		}
		set_transient( $key, $count + 1, 10 * MINUTE_IN_SECONDS );
		return false;
	}

	/**
	 * Gibt das 1x1-GIF mit no-cache-Headern aus und beendet den Request.
	 *
	 * @return void
	 */
	private function serve_gif(): void {
		if ( ! headers_sent() ) {
			header( 'Content-Type: image/gif' );
			header( 'Content-Disposition: inline' );
			nocache_headers();
		}
		echo base64_decode( self::PIXEL_GIF ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Binaeres GIF, kein HTML.
		exit;
	}
}
