<?php
/**
 * Click tracking REST controller.
 *
 * @package Affilun\Presentation\Rest\Controller
 */

declare(strict_types=1);

namespace Affilun\Presentation\Rest\Controller;

use Affilun\Application\Analytics\ClickToken;
use Affilun\Infrastructure\Persistence\ClickEventRepository;
use Affilun\Infrastructure\Persistence\OptionStore;
use Affilun\Presentation\Rest\Permission\CapabilityPolicy;
use Affilun\Presentation\Rest\RequestContext;
use Affilun\Presentation\Rest\Response\ErrorEnvelope;
use Affilun\Presentation\Rest\Response\SuccessEnvelope;

/**
 * Records privacy-light click events from rendered product buttons.
 */
final class ClicksController {

	/**
	 * Stable route token for `POST /affilun/v1/clicks`.
	 */
	public const ROUTE_TOKEN_WRITE = 'clicks.write';

	/**
	 * Synthetic envelope key carrying the desired HTTP status.
	 */
	public const META_HTTP_STATUS = '_http_status';

	/**
	 * Option key for recent click events.
	 */
	private const OPTION_EVENTS = 'affilun_click_events';

	/**
	 * Option key for aggregate click counts.
	 */
	private const OPTION_COUNTS = 'affilun_click_counts';

	/**
	 * Maximum recent event rows retained in the option store.
	 */
	private const MAX_RECENT_EVENTS = 500;

	/**
	 * Option key for lightweight public endpoint rate-limit buckets.
	 */
	private const OPTION_RATE_LIMIT = 'affilun_click_rate_limits';

	/**
	 * Maximum accepted clicks per coarse client bucket per minute.
	 */
	private const RATE_LIMIT_PER_MINUTE = 60;

	/**
	 * Maximum aggregate-count dimensions retained in the option store.
	 *
	 * The click endpoint is intentionally public, so aggregate key cardinality
	 * must be bounded until the analytics store moves to a dedicated table.
	 */
	private const MAX_AGGREGATE_KEYS = 1000;

	/**
	 * Option storage.
	 */
	private OptionStore $options;

	/**
	 * Capability policy.
	 */
	private CapabilityPolicy $capabilityPolicy;

	/**
	 * Request adapter.
	 */
	private RestRequestAdapter $requestAdapter;

	/**
	 * Optional custom-table click repository.
	 */
	private ?ClickEventRepository $clickEvents;

	/**
	 * Current-user id resolver.
	 *
	 * @var callable(): ?int
	 */
	private $userIdProvider;

	/**
	 * Capability resolver.
	 *
	 * @var callable(string): bool
	 */
	private $capabilityProvider;

	/**
	 * @param OptionStore               $options            Option storage.
	 * @param CapabilityPolicy          $capabilityPolicy   Capability policy.
	 * @param RestRequestAdapter        $requestAdapter     Request adapter.
	 * @param callable|null             $userIdProvider     User id resolver.
	 * @param callable|null             $capabilityProvider Capability resolver.
	 * @param ClickEventRepository|null $clickEvents      Custom-table click repository.
	 *
	 * @phpstan-param (callable(): ?int)|null $userIdProvider
	 * @phpstan-param (callable(string): bool)|null $capabilityProvider
	 */
	public function __construct(
		OptionStore $options,
		CapabilityPolicy $capabilityPolicy,
		RestRequestAdapter $requestAdapter,
		?callable $userIdProvider = null,
		?callable $capabilityProvider = null,
		?ClickEventRepository $clickEvents = null
	) {
		$this->options            = $options;
		$this->capabilityPolicy   = $capabilityPolicy;
		$this->requestAdapter     = $requestAdapter;
		$this->userIdProvider     = $userIdProvider ?? self::defaultUserIdProvider();
		$this->capabilityProvider = $capabilityProvider ?? self::defaultCapabilityProvider();
		$this->clickEvents        = $clickEvents;
	}

	/**
	 * Record a click event.
	 *
	 * @param mixed $request WP_REST_Request, or array shim.
	 *
	 * @return array<string, mixed>
	 */
	public function record( mixed $request ): array {
		$required = $this->capabilityPolicy::requiredForWrite( self::ROUTE_TOKEN_WRITE );
		$ctx      = $this->buildContext( $request, $required );

		if ( ! $this->capabilityPolicy::isAuthorized( $ctx, $required ) ) {
			return $this->errorResponse(
				ErrorEnvelope::CODE_FORBIDDEN,
				'Insufficient capabilities.',
				ErrorEnvelope::STATUS_FORBIDDEN,
				$ctx
			);
		}

		$body  = $this->requestAdapter->jsonBody( $request );
		$event = $this->buildEvent( $body, $request );
		if ( $event === null ) {
			return $this->errorResponse(
				ErrorEnvelope::CODE_VALIDATION_FAILED,
				'Invalid click payload.',
				ErrorEnvelope::STATUS_UNPROCESSABLE_ENTITY,
				$ctx
			);
		}
		if ( ! $this->passesSameOriginGuard( $request ) ) {
			return $this->errorResponse(
				ErrorEnvelope::CODE_FORBIDDEN,
				'Click requests must originate from this site.',
				ErrorEnvelope::STATUS_FORBIDDEN,
				$ctx
			);
		}
		if ( ! $this->passesRateLimit( $request, $event ) ) {
			return $this->errorResponse(
				'affilun_rate_limited',
				'Too many click requests.',
				429,
				$ctx,
				array( 'retry_after_seconds' => 60 ),
				true
			);
		}
		if ( ! $this->isValidClickToken( $body, $event ) ) {
			return $this->errorResponse(
				ErrorEnvelope::CODE_FORBIDDEN,
				'Invalid click token.',
				ErrorEnvelope::STATUS_FORBIDDEN,
				$ctx
			);
		}

		if ( $this->clickEvents !== null ) {
			$this->clickEvents->record( $event );
		}

		$events = $this->options->get( self::OPTION_EVENTS, array() );
		if ( ! is_array( $events ) ) {
			$events = array();
		}
		$events[] = $event;
		if ( count( $events ) > self::MAX_RECENT_EVENTS ) {
			$events = array_slice( $events, - self::MAX_RECENT_EVENTS );
		}
		$this->options->update( self::OPTION_EVENTS, array_values( $events ) );

		$counts = $this->options->get( self::OPTION_COUNTS, array() );
		if ( ! is_array( $counts ) ) {
			$counts = array();
		}
		$counts = $this->incrementAggregateCounts( $counts, $event );
		$this->options->update( self::OPTION_COUNTS, $counts );

		return $this->successResponse(
			SuccessEnvelope::fromContext(
				array( 'recorded' => true ),
				$ctx
			),
			201
		);
	}

	/**
	 * Build a sanitized click event.
	 *
	 * @param array<mixed> $body    JSON body.
	 * @param mixed        $request Request.
	 *
	 * @return array<string, mixed>|null
	 */
	private function buildEvent( array $body, mixed $request ): ?array {
		$provider  = $this->readToken( $body, 'provider', 'unknown' );
		$buttonKey = $this->readToken( $body, 'button_key', 'unknown' );
		$targetUrl = $this->readString( $body, 'target_url' );
		if ( $targetUrl === null || ! $this->isSafeHttpUrl( $targetUrl ) ) {
			return null;
		}

		$event = array(
			'provider'        => $provider,
			'button_key'      => $buttonKey,
			'target_url_hash' => hash( 'sha256', $targetUrl ),
			'product_id'      => $this->readPositiveInt( $body, 'product_id' ),
			'post_id'         => $this->readPositiveInt( $body, 'post_id' ),
			'created_at'      => gmdate( 'c' ),
			'metadata'        => $this->buildMetadata( $request ),
		);

		$variant = $this->readToken( $body, 'variant', '' );
		if ( $variant !== '' ) {
			$event['variant'] = $variant;
		}

		return $event;
	}

	/**
	 * Apply Origin/Referer same-site checks when site host is available.
	 *
	 * @param mixed $request Request.
	 */
	private function passesSameOriginGuard( mixed $request ): bool {
		$siteHost = self::siteHost();
		if ( $siteHost === '' ) {
			return true;
		}
		$origin = $this->requestAdapter->header( $request, 'Origin' );
		if ( is_string( $origin ) && trim( $origin ) !== '' ) {
			return self::hostFromUrl( $origin ) === $siteHost;
		}
		$referer = $this->requestAdapter->header( $request, 'Referer' );
		if ( is_string( $referer ) && trim( $referer ) !== '' ) {
			return self::hostFromUrl( $referer ) === $siteHost;
		}
		return true;
	}

	/**
	 * Apply a coarse per-minute limit for public click requests.
	 *
	 * @param mixed               $request Request.
	 * @param array<string,mixed> $event   Sanitized event.
	 */
	private function passesRateLimit( mixed $request, array $event ): bool {
		if ( $this->clickEvents !== null ) {
			$metadata = isset( $event['metadata'] ) && is_array( $event['metadata'] ) ? $event['metadata'] : array();
			$since    = new \DateTimeImmutable( '-60 seconds', new \DateTimeZone( 'UTC' ) );
			$count    = $this->clickEvents->countRecentMatching(
				self::metadataString( $metadata, 'user_agent_hash' ),
				self::metadataString( $metadata, 'referer_host' ),
				$since
			);
			return $count < self::RATE_LIMIT_PER_MINUTE;
		}
		$bucket = gmdate( 'YmdHi' ) . ':' . $this->clientBucket( $request );
		$stored = $this->options->get( self::OPTION_RATE_LIMIT, array() );
		if ( ! is_array( $stored ) ) {
			$stored = array();
		}

		$normalized = array();
		foreach ( $stored as $key => $value ) {
			if ( is_string( $key ) && is_int( $value ) && str_starts_with( $key, gmdate( 'YmdHi' ) . ':' ) ) {
				$normalized[ $key ] = $value;
			}
		}

		$count = $normalized[ $bucket ] ?? 0;
		if ( $count >= self::RATE_LIMIT_PER_MINUTE ) {
			$this->options->update( self::OPTION_RATE_LIMIT, $normalized );
			return false;
		}
		$normalized[ $bucket ] = $count + 1;
		$this->options->update( self::OPTION_RATE_LIMIT, $normalized );
		return true;
	}

	/**
	 * Read a metadata string.
	 *
	 * @param array<mixed> $metadata Metadata.
	 * @param string       $key      Key.
	 */
	private static function metadataString( array $metadata, string $key ): string {
		$value = $metadata[ $key ] ?? null;
		return is_string( $value ) ? $value : '';
	}

	/**
	 * Build a privacy-light client bucket for rate limiting.
	 *
	 * @param mixed $request Request.
	 */
	private function clientBucket( mixed $request ): string {
		$userAgent = $this->requestAdapter->header( $request, 'User-Agent' ) ?? '';
		$referer   = $this->requestAdapter->header( $request, 'Referer' ) ?? '';
		return hash( 'sha256', trim( $userAgent ) . '|' . trim( $referer ) );
	}

	/**
	 * Site host for same-origin checks.
	 */
	private static function siteHost(): string {
		$url = function_exists( 'home_url' ) ? (string) home_url() : '';
		return self::hostFromUrl( $url );
	}

	/**
	 * Extract a normalized host from a URL.
	 *
	 * @param string $url URL.
	 */
	private static function hostFromUrl( string $url ): string {
		$host = function_exists( 'wp_parse_url' )
			? wp_parse_url( $url, PHP_URL_HOST )
			// phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- Pure-unit fallback when WordPress is unavailable.
			: parse_url( $url, PHP_URL_HOST );
		return is_string( $host ) ? strtolower( rtrim( $host, '.' ) ) : '';
	}

	/**
	 * Verify that the click payload came from a rendered product card.
	 *
	 * @param array<mixed>         $body  Payload.
	 * @param array<string, mixed> $event Sanitized event.
	 */
	private function isValidClickToken( array $body, array $event ): bool {
		if ( ! isset( $event['product_id'] ) || ! is_int( $event['product_id'] ) ) {
			return false;
		}
		$token     = $this->readString( $body, 'click_token' );
		$targetUrl = $this->readString( $body, 'target_url' );
		if ( $token === null || $targetUrl === null ) {
			return false;
		}
		$postId = isset( $event['post_id'] ) && is_int( $event['post_id'] ) ? $event['post_id'] : null;
		return ClickToken::verify( $token, $event['product_id'], $targetUrl, $postId );
	}

	/**
	 * Build privacy-light request metadata.
	 *
	 * @param mixed $request Request.
	 *
	 * @return array<string, string>
	 */
	private function buildMetadata( mixed $request ): array {
		$metadata  = array();
		$userAgent = $this->requestAdapter->header( $request, 'User-Agent' );
		if ( is_string( $userAgent ) && trim( $userAgent ) !== '' ) {
			$metadata['user_agent_hash'] = hash( 'sha256', trim( $userAgent ) );
		}

		$referer = $this->requestAdapter->header( $request, 'Referer' );
		if ( is_string( $referer ) && preg_match( '~\Ahttps?://([^/:?#]+)~i', trim( $referer ), $matches ) === 1 ) {
			$metadata['referer_host'] = strtolower( $matches[1] );
		}
		return $metadata;
	}

	/**
	 * Increment aggregate count dimensions.
	 *
	 * @param array<mixed>         $counts Counts.
	 * @param array<string, mixed> $event  Event.
	 *
	 * @return array<string, int>
	 */
	private function incrementAggregateCounts( array $counts, array $event ): array {
		$normalized = array();
		foreach ( $counts as $key => $value ) {
			if ( is_string( $key ) && is_int( $value ) ) {
				$normalized[ $key ] = $value;
			}
		}

		$keys = array(
			$event['provider'] . '|' . $event['button_key'],
			'provider:' . $event['provider'],
			'button:' . $event['button_key'],
		);

		if ( isset( $event['product_id'] ) && is_int( $event['product_id'] ) ) {
			$keys[] = 'product:' . $event['product_id'];
		}
		if ( isset( $event['post_id'] ) && is_int( $event['post_id'] ) ) {
			$keys[] = 'post:' . $event['post_id'];
		}
		if ( isset( $event['variant'] ) && is_string( $event['variant'] ) ) {
			$keys[] = 'variant:' . $event['variant'];
		}

		foreach ( $keys as $key ) {
			if ( array_key_exists( $key, $normalized ) ) {
				++$normalized[ $key ];
				continue;
			}
			if ( count( $normalized ) >= self::MAX_AGGREGATE_KEYS ) {
				continue;
			}
			$normalized[ $key ] = 1;
		}
		return $normalized;
	}

	/**
	 * Read a non-empty string field.
	 *
	 * @param array<mixed> $body Payload.
	 * @param string       $key  Field key.
	 */
	private function readString( array $body, string $key ): ?string {
		if ( ! isset( $body[ $key ] ) || ! is_string( $body[ $key ] ) ) {
			return null;
		}
		$trimmed = trim( $body[ $key ] );
		return $trimmed === '' ? null : $trimmed;
	}

	/**
	 * Read and validate a token field.
	 *
	 * @param array<mixed> $body     Payload.
	 * @param string       $key      Field key.
	 * @param string       $fallback Fallback token.
	 */
	private function readToken( array $body, string $key, string $fallback ): string {
		$value = $this->readString( $body, $key );
		if ( $value === null || preg_match( '/\A[a-z][a-z0-9_-]{0,63}\z/', $value ) !== 1 ) {
			return $fallback;
		}
		return $value;
	}

	/**
	 * Read a positive integer field.
	 *
	 * @param array<mixed> $body Payload.
	 * @param string       $key  Field key.
	 */
	private function readPositiveInt( array $body, string $key ): ?int {
		$value = $body[ $key ] ?? null;
		if ( is_int( $value ) && $value > 0 ) {
			return $value;
		}
		if ( is_string( $value ) && preg_match( '/\A\d+\z/', $value ) === 1 ) {
			$parsed = (int) $value;
			return $parsed > 0 ? $parsed : null;
		}
		return null;
	}

	/**
	 * Check whether a URL is an absolute HTTP(S) URL.
	 *
	 * @param string $url URL candidate.
	 */
	private function isSafeHttpUrl( string $url ): bool {
		return preg_match( '#\Ahttps?://#i', $url ) === 1;
	}

	/**
	 * Build context.
	 *
	 * @param mixed        $request       Request.
	 * @param list<string> $candidateCaps Capabilities.
	 */
	private function buildContext( mixed $request, array $candidateCaps ): RequestContext {
		$userResolver = $this->userIdProvider;
		$capResolver  = $this->capabilityProvider;

		$caps = array();
		foreach ( $candidateCaps as $cap ) {
			if ( $capResolver( $cap ) === true ) {
				$caps[ $cap ] = 'true';
			}
		}

		return $this->requestAdapter->buildRequestContext( $request, $userResolver(), $caps );
	}

	/**
	 * Build success response.
	 *
	 * @param SuccessEnvelope $envelope Success envelope.
	 * @param int             $status   HTTP status.
	 *
	 * @return array<string, mixed>
	 */
	private function successResponse( SuccessEnvelope $envelope, int $status ): array {
		return $envelope->toArray() + array( self::META_HTTP_STATUS => $status );
	}

	/**
	 * Build error response.
	 *
	 * @param string               $code      Error code.
	 * @param string               $message   Error message.
	 * @param int                  $status    HTTP status.
	 * @param RequestContext       $ctx       Request context.
	 * @param array<string, mixed> $details   Details.
	 * @param bool                 $retryable Retryable flag.
	 *
	 * @return array<string, mixed>
	 */
	private function errorResponse(
		string $code,
		string $message,
		int $status,
		RequestContext $ctx,
		array $details = array(),
		bool $retryable = false
	): array {
		return ( new ErrorEnvelope(
			$code,
			$message,
			$status,
			$details,
			$retryable,
			$ctx->requestId(),
			$ctx->correlationId(),
			gmdate( 'c' )
		) )->toArray() + array( self::META_HTTP_STATUS => $status );
	}

	/**
	 * @phpstan-return callable(): ?int
	 */
	private static function defaultUserIdProvider(): callable {
		return static function (): ?int {
			if ( function_exists( 'get_current_user_id' ) ) {
				$userId = (int) get_current_user_id();
				return $userId > 0 ? $userId : null;
			}
			return null;
		};
	}

	/**
	 * @phpstan-return callable(string): bool
	 */
	private static function defaultCapabilityProvider(): callable {
		return static function ( string $capability ): bool {
			return function_exists( 'current_user_can' ) && current_user_can( $capability );
		};
	}
}
