<?php
/**
 * Enhanced Reviews module.
 *
 * @package Kramar
 * @since   1.0.0
 */

declare( strict_types=1 );

namespace Kramar\Modules\Products;

// Prevent direct access.
defined( 'ABSPATH' ) || exit;

/**
 * Enhanced Reviews module.
 *
 * Adds verified-purchase badges, helpful voting, a star-rating breakdown widget,
 * sort controls, and a "disable reviews entirely" toggle to WooCommerce product
 * reviews — replacing the need for several standalone plugins.
 *
 * @since 1.0.0
 */
final class Enhanced_Reviews extends \Kramar\Module {

	// -------------------------------------------------------------------------
	// Meta / transient keys
	// -------------------------------------------------------------------------

	/** Comment meta: helpful vote count (up). */
	private const META_VOTES_UP = '_kramar_review_votes_up';

	/** Comment meta: helpful vote count (down). */
	private const META_VOTES_DOWN = '_kramar_review_votes_down';

	/** Transient prefix for per-product rating breakdown cache. */
	private const TRANSIENT_PREFIX = 'kramar_rev_breakdown_';

	/** Cookie name for dedup (visitor has voted). */
	private const COOKIE_PREFIX = 'kramar_rvote_';

	// -------------------------------------------------------------------------
	// Module identity
	// -------------------------------------------------------------------------

	/**
	 * {@inheritDoc}
	 */
	public function get_slug(): string {
		return 'enhanced-reviews';
	}

	/**
	 * {@inheritDoc}
	 */
	public function get_category(): string {
		return 'products';
	}

	/**
	 * {@inheritDoc}
	 */
	public function get_name(): string {
		return __( 'Enhanced Reviews', 'kramar' );
	}

	/**
	 * {@inheritDoc}
	 */
	public function get_description(): string {
		return __( 'Upgrade WooCommerce product reviews with a verified-purchase badge, helpful voting, a star-distribution breakdown, sort controls, and an option to disable reviews site-wide.', 'kramar' );
	}

	// -------------------------------------------------------------------------
	// Settings
	// -------------------------------------------------------------------------

	/**
	 * {@inheritDoc}
	 */
	public function get_default_settings(): array {
		return [
			'enabled'            => false,
			'verified_badge'     => true,
			'helpful_voting'     => true,
			'rating_breakdown'   => true,
			'sort_controls'      => true,
			'disable_reviews'    => false,
		];
	}

	/**
	 * {@inheritDoc}
	 */
	public function sanitize_settings( array $input ): array {
		return [
			'enabled'            => ! empty( $input['enabled'] ),
			'verified_badge'     => ! empty( $input['verified_badge'] ),
			'helpful_voting'     => ! empty( $input['helpful_voting'] ),
			'rating_breakdown'   => ! empty( $input['rating_breakdown'] ),
			'sort_controls'      => ! empty( $input['sort_controls'] ),
			'disable_reviews'    => ! empty( $input['disable_reviews'] ),
		];
	}

	/**
	 * {@inheritDoc}
	 */
	/**
	 * Whether this module's display hooks can actually fire on this site.
	 *
	 * Every feature here hangs off the classic product-tab and review-loop
	 * hooks. A block-based Single Product template — the default in current
	 * themes — never invokes them, so the module reports itself enabled and
	 * loads its assets while rendering nothing at all.
	 *
	 * @since 1.0.2
	 */
	private function classic_product_template_active(): bool {
		if ( ! function_exists( 'wp_is_block_theme' ) || ! wp_is_block_theme() ) {
			return true;
		}

		// A block theme can still fall back to the classic template when it
		// ships no single-product block template of its own.
		if ( ! function_exists( 'get_block_templates' ) ) {
			return true;
		}

		// Any woocommerce/product-* block used to trigger the warning. That is
		// far too broad: WooCommerce's own fallback single-product template —
		// what a block theme without its own override gets, i.e. most sites —
		// is built from those blocks and still renders the classic review
		// output this module hooks into. Everything worked while the settings
		// screen said it would not, which is worse than saying nothing.
		//
		// What matters is whether classic review output runs at all. Both
		// legacy-template and product-details render the classic tabs; only
		// product-reviews replaces reviews with its own block.
		foreach ( (array) get_block_templates( [ 'slug__in' => [ 'single-product' ] ] ) as $template ) {
			$content = (string) ( $template->content ?? '' );
			if ( '' === $content ) {
				continue;
			}

			if (
				str_contains( $content, 'woocommerce/legacy-template' )
				|| str_contains( $content, 'woocommerce/product-details' )
			) {
				continue;
			}

			if ( str_contains( $content, 'woocommerce/product-' ) ) {
				return false;
			}
		}

		return true;
	}

	public function render_extra_settings( string $option_name ): void {
		if ( ! $this->classic_product_template_active() ) {
			echo '<div class="kramar-module__extra"><p class="kramar-legal-warning">'
				. esc_html__( 'Your theme renders products with blocks, and these features attach to the classic product tabs and review template — which that layout never calls. The verified-buyer badge, helpful voting, rating breakdown and sorting will not appear on your product pages until the single-product template includes the classic Product Details / Reviews block.', 'kramar' )
				. '</p></div>';
		}

		$cat  = $this->get_category();
		$slug = $this->get_slug();

		$verified   = (bool) $this->get_setting( 'verified_badge' );
		$voting     = (bool) $this->get_setting( 'helpful_voting' );
		$breakdown  = (bool) $this->get_setting( 'rating_breakdown' );
		$sort       = (bool) $this->get_setting( 'sort_controls' );
		$disable    = (bool) $this->get_setting( 'disable_reviews' );

		$base = esc_attr( $option_name ) . '[' . esc_attr( $cat ) . '][' . esc_attr( $slug ) . ']';
		?>
		<div class="kramar-module__extra">
			<div class="kramar-settings-stack">
				<p>
					<label>
						<input type="checkbox" name="<?php echo $base; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $base built from esc_attr ?>[verified_badge]" value="1" <?php checked( $verified ); ?> />
						<?php esc_html_e( 'Verified-purchase badge', 'kramar' ); ?>
					</label>
					<span class="description"><?php esc_html_e( 'Show "Geverifieerde aankoop" badge on reviews where the reviewer actually bought the product.', 'kramar' ); ?></span>
				</p>
				<p>
					<label>
						<input type="checkbox" name="<?php echo $base; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $base built from esc_attr ?>[helpful_voting]" value="1" <?php checked( $voting ); ?> />
						<?php esc_html_e( 'Helpful voting', 'kramar' ); ?>
					</label>
					<span class="description"><?php esc_html_e( 'Let visitors vote "Was deze review nuttig? Ja / Nee" under each review. One vote per visitor (cookie guard).', 'kramar' ); ?></span>
				</p>
				<p>
					<label>
						<input type="checkbox" name="<?php echo $base; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $base built from esc_attr ?>[rating_breakdown]" value="1" <?php checked( $breakdown ); ?> />
						<?php esc_html_e( 'Rating breakdown', 'kramar' ); ?>
					</label>
					<span class="description"><?php esc_html_e( 'Show a 5→1 star distribution bar chart with average above the reviews list.', 'kramar' ); ?></span>
				</p>
				<p>
					<label>
						<input type="checkbox" name="<?php echo $base; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $base built from esc_attr ?>[sort_controls]" value="1" <?php checked( $sort ); ?> />
						<?php esc_html_e( 'Sort controls', 'kramar' ); ?>
					</label>
					<span class="description"><?php esc_html_e( 'Let shoppers sort reviews by Newest, Highest rating, Lowest rating, or Most helpful.', 'kramar' ); ?></span>
				</p>
				<p>
					<label>
						<input type="checkbox" name="<?php echo $base; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $base built from esc_attr ?>[disable_reviews]" value="1" <?php checked( $disable ); ?> />
						<?php esc_html_e( 'Disable product reviews entirely', 'kramar' ); ?>
					</label>
					<span class="description"><?php esc_html_e( 'Closes comments on all products, removes the Reviews tab and the dashboard widget. When enabled, the display enhancements above are inactive.', 'kramar' ); ?></span>
				</p>
			</div>
		</div>
		<?php
	}

	// -------------------------------------------------------------------------
	// Convenience setting getter
	// -------------------------------------------------------------------------

	/**
	 * Get a single setting value for this module.
	 *
	 * @param string $key Setting key.
	 * @return mixed
	 */
	private function get_setting( string $key ): mixed {
		return $this->plugin->get_setting( $this->get_category(), $this->get_slug(), $key );
	}

	// -------------------------------------------------------------------------
	// Boot
	// -------------------------------------------------------------------------

	/**
	 * {@inheritDoc}
	 */
	protected function run(): void {
		$disable = (bool) $this->get_setting( 'disable_reviews' );

		// --- Feature 5: Disable reviews ---
		if ( $disable ) {
			add_filter( 'comments_open', [ $this, 'close_comments_on_products' ], 20, 2 );
			add_filter( 'woocommerce_product_tabs', [ $this, 'remove_reviews_tab' ], 99 );
			add_action( 'admin_init', [ $this, 'remove_reviews_dashboard_widget' ] );
			// When reviews disabled, skip display enhancements.
			return;
		}

		// --- Feature 1: Verified-purchase badge ---
		if ( (bool) $this->get_setting( 'verified_badge' ) ) {
			add_action( 'woocommerce_review_after_comment_text', [ $this, 'render_verified_badge' ], 10, 1 );
			// De-dupe: our badge replaces WooCommerce's native "(verified owner)"
			// label on the front end, so suppress WC's to avoid two indicators for
			// the same fact. Admin (WC settings toggle) reads the real value.
			add_filter( 'option_woocommerce_review_rating_verification_label', [ $this, 'suppress_wc_verified_label' ] );
		}

		// --- Feature 2: Helpful voting ---
		if ( (bool) $this->get_setting( 'helpful_voting' ) ) {
			add_action( 'woocommerce_review_after_comment_text', [ $this, 'render_helpful_buttons' ], 20, 1 );
			add_action( 'wp_ajax_kramar_review_vote', [ $this, 'handle_vote' ] );
			add_action( 'wp_ajax_nopriv_kramar_review_vote', [ $this, 'handle_vote' ] );
		}

		// --- Feature 3: Rating breakdown ---
		if ( (bool) $this->get_setting( 'rating_breakdown' ) ) {
			add_filter( 'woocommerce_product_tabs', [ $this, 'inject_breakdown_into_reviews_tab' ], 30 );
		}

		// --- Feature 4: Sort controls ---
		if ( (bool) $this->get_setting( 'sort_controls' ) ) {
			add_filter( 'woocommerce_product_tabs', [ $this, 'inject_sort_into_reviews_tab' ], 31 );
			add_filter( 'pre_get_comments', [ $this, 'apply_sort_order' ] );
		}

		// Enqueue frontend assets if any display feature is active.
		$needs_assets = (bool) $this->get_setting( 'helpful_voting' )
			|| (bool) $this->get_setting( 'sort_controls' );
		if ( $needs_assets ) {
			add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_frontend_assets' ] );
		}

		// Bust breakdown transient when a new approved review is posted.
		add_action( 'comment_approved_review', [ $this, 'bust_breakdown_transient' ], 10, 1 );
		// Also bust on comment status change (covers pending → approved).
		add_action( 'transition_comment_status', [ $this, 'bust_breakdown_on_status_change' ], 10, 3 );
		// Also bust when a review is inserted ALREADY approved — admin-authored
		// reviews and trusted-customer auto-approve never fire the two hooks above.
		add_action( 'wp_insert_comment', [ $this, 'bust_breakdown_on_insert' ], 10, 2 );
	}

	// -------------------------------------------------------------------------
	// Feature 5: Disable reviews
	// -------------------------------------------------------------------------

	/**
	 * Close comments on product posts.
	 *
	 * @since 1.0.0
	 *
	 * @param bool $open    Whether comments are open.
	 * @param int  $post_id Post ID.
	 * @return bool
	 */
	public function close_comments_on_products( bool $open, int $post_id ): bool {
		if ( 'product' === get_post_type( $post_id ) ) {
			return false;
		}
		return $open;
	}

	/**
	 * Remove the Reviews tab from product pages.
	 *
	 * @since 1.0.0
	 *
	 * @param array<string, mixed> $tabs Existing tabs.
	 * @return array<string, mixed>
	 */
	public function remove_reviews_tab( array $tabs ): array {
		unset( $tabs['reviews'] );
		return $tabs;
	}

	/**
	 * Remove the Recent Reviews dashboard widget.
	 *
	 * @since 1.0.0
	 */
	public function remove_reviews_dashboard_widget(): void {
		remove_meta_box( 'woocommerce_dashboard_recent_reviews', 'dashboard', 'normal' );
	}

	// -------------------------------------------------------------------------
	// Feature 1: Verified-purchase badge
	// -------------------------------------------------------------------------

	/**
	 * Render the verified-purchase badge after the comment text.
	 *
	 * WooCommerce stores `verified` in comment meta (set by WC core when the
	 * reviewer's email matches an order containing the product).
	 *
	 * @since 1.0.0
	 *
	 * @param \WP_Comment $comment Current comment object.
	 */
	public function render_verified_badge( \WP_Comment $comment ): void {
		$verified = get_comment_meta( $comment->comment_ID, 'verified', true );
		if ( ! $verified || '0' === (string) $verified ) {
			return;
		}
		echo '<span class="kramar-verified-badge">' . esc_html__( 'Geverifieerde aankoop', 'kramar' ) . '</span>';
	}

	/**
	 * Suppress WooCommerce's native "(verified owner)" label on the front end.
	 *
	 * Our own "Geverifieerde aankoop" badge covers the same verified-purchase
	 * fact, so showing both duplicates the indicator. Only filtered on the front
	 * end — in wp-admin the real option value is returned so the WooCommerce
	 * settings toggle still reflects the stored choice.
	 *
	 * @since 1.0.0
	 *
	 * @param mixed $value Stored option value ('yes'/'no').
	 * @return mixed 'no' on the front end, otherwise the original value.
	 */
	public function suppress_wc_verified_label( mixed $value ): mixed {
		return is_admin() ? $value : 'no';
	}

	// -------------------------------------------------------------------------
	// Feature 2: Helpful voting
	// -------------------------------------------------------------------------

	/**
	 * Render "Was deze review nuttig? Ja / Nee" buttons after the comment text.
	 *
	 * @since 1.0.0
	 *
	 * @param \WP_Comment $comment Current comment object.
	 */
	public function render_helpful_buttons( \WP_Comment $comment ): void {
		$id      = (int) $comment->comment_ID;
		$up      = (int) get_comment_meta( $id, self::META_VOTES_UP, true );
		$down    = (int) get_comment_meta( $id, self::META_VOTES_DOWN, true );
		$total   = $up + $down;
		$percent = $total > 0 ? (int) round( $up / $total * 100 ) : 0;

		// Determine if visitor already voted (cookie dedup).
		$cookie_name = self::COOKIE_PREFIX . $id;
		$voted       = ! empty( $_COOKIE[ $cookie_name ] );

		printf(
			'<div class="kramar-helpful" data-id="%d">
				<span class="kramar-helpful__label">%s</span>
				<button type="button" class="kramar-helpful__btn kramar-helpful__btn--yes%s" data-vote="up" data-comment="%d" aria-label="%s">%s</button>
				<button type="button" class="kramar-helpful__btn kramar-helpful__btn--no%s" data-vote="down" data-comment="%d" aria-label="%s">%s</button>
				<span class="kramar-helpful__count" aria-live="polite">%s</span>
			</div>',
			esc_attr( (string) $id ),
			esc_html__( 'Was deze review nuttig?', 'kramar' ),
			$voted ? ' kramar-helpful__btn--voted' : '',
			esc_attr( (string) $id ),
			esc_attr__( 'Ja, deze review was nuttig', 'kramar' ),
			esc_html__( 'Ja', 'kramar' ),
			$voted ? ' kramar-helpful__btn--voted' : '',
			esc_attr( (string) $id ),
			esc_attr__( 'Nee, deze review was niet nuttig', 'kramar' ),
			esc_html__( 'Nee', 'kramar' ),
			$total > 0
				/* translators: 1: percentage 0-100, 2: total vote count */
				? sprintf( esc_html__( '%1$d%% van %2$d bezoekers vond dit nuttig', 'kramar' ), absint( $percent ), absint( $total ) )
				: ''
		);
	}

	/**
	 * AJAX handler for helpful votes.
	 *
	 * Stores up/down count in comment meta; one vote per visitor via cookie
	 * + IP-hash guard.
	 *
	 * @since 1.0.0
	 */
	public function handle_vote(): void {
		// Verify nonce.
		$nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['nonce'] ) ) : '';
		if ( ! wp_verify_nonce( $nonce, 'kramar_review_vote' ) ) {
			wp_send_json_error( [ 'message' => __( 'Beveiligingscontrole mislukt.', 'kramar' ) ], 403 );
		}

		$comment_id = isset( $_POST['comment_id'] ) ? absint( wp_unslash( $_POST['comment_id'] ) ) : 0;
		$direction  = isset( $_POST['direction'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['direction'] ) ) : '';

		if ( $comment_id <= 0 || ! in_array( $direction, [ 'up', 'down' ], true ) ) {
			wp_send_json_error( [ 'message' => __( 'Ongeldig verzoek.', 'kramar' ) ], 400 );
		}

		// Ensure this is an approved WooCommerce review comment.
		$comment = get_comment( $comment_id );
		if ( ! $comment instanceof \WP_Comment || '1' !== $comment->comment_approved ) {
			wp_send_json_error( [ 'message' => __( 'Ongeldig verzoek.', 'kramar' ) ], 400 );
		}

		// Dedup: cookie presence (set after first vote).
		$cookie_name = self::COOKIE_PREFIX . $comment_id;
		if ( ! empty( $_COOKIE[ $cookie_name ] ) ) {
			wp_send_json_error( [ 'message' => __( 'Je hebt al gestemd.', 'kramar' ) ], 409 );
		}

		// Secondary dedup: IP hash stored in comment meta.
		$ip_raw      = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( (string) $_SERVER['REMOTE_ADDR'] ) ) : '';
		$ip_hash     = hash( 'sha256', $ip_raw . wp_salt( 'auth' ) );
		$voted_ips   = get_comment_meta( $comment_id, '_kramar_vote_ips', true );
		$voted_ips   = is_array( $voted_ips ) ? $voted_ips : [];

		if ( in_array( $ip_hash, $voted_ips, true ) ) {
			wp_send_json_error( [ 'message' => __( 'Je hebt al gestemd.', 'kramar' ) ], 409 );
		}

		// Record vote.
		$meta_key = 'up' === $direction ? self::META_VOTES_UP : self::META_VOTES_DOWN;
		$current  = (int) get_comment_meta( $comment_id, $meta_key, true );
		update_comment_meta( $comment_id, $meta_key, $current + 1 );

		// Store IP hash to block repeat votes.
		$voted_ips[] = $ip_hash;
		update_comment_meta( $comment_id, '_kramar_vote_ips', $voted_ips );

		// Set cookie (1 year, httponly). Client JS also checks the cookie.
		$secure = is_ssl();
		setcookie( $cookie_name, '1', time() + YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN, $secure, true );

		$up   = (int) get_comment_meta( $comment_id, self::META_VOTES_UP, true );
		$down = (int) get_comment_meta( $comment_id, self::META_VOTES_DOWN, true );

		wp_send_json_success(
			[
				'up'   => $up,
				'down' => $down,
			]
		);
	}

	// -------------------------------------------------------------------------
	// Feature 3: Rating breakdown
	// -------------------------------------------------------------------------

	/**
	 * Inject the rating breakdown summary before the reviews list by wrapping
	 * the reviews tab callback.
	 *
	 * @since 1.0.0
	 *
	 * @param array<string, mixed> $tabs Existing product tabs.
	 * @return array<string, mixed>
	 */
	public function inject_breakdown_into_reviews_tab( array $tabs ): array {
		if ( empty( $tabs['reviews']['callback'] ) ) {
			return $tabs;
		}

		$original_callback = $tabs['reviews']['callback'];
		$tabs['reviews']['callback'] = function () use ( $original_callback ): void {
			global $product;
			if ( $product instanceof \WC_Product ) {
				$this->render_rating_breakdown( $product );
			}
			call_user_func( $original_callback );
		};

		return $tabs;
	}

	/**
	 * Render the star-distribution breakdown widget for a product.
	 *
	 * Results are cached in a transient per product and busted on new reviews.
	 *
	 * @since 1.0.0
	 *
	 * @param \WC_Product $product Product object.
	 */
	/**
	 * Split a rating distribution into whole percentages that total 100.
	 *
	 * Rounding each row independently leaves the column visibly failing to add
	 * up — three reviews one star apart each round to 33%, showing 99% on a
	 * widget whose entire job is presenting exact statistics. Largest-remainder
	 * apportionment gives the leftover points to the rows that lost the most to
	 * rounding.
	 *
	 * @since 1.0.2
	 *
	 * @param array<int, int> $distribution Count of reviews keyed by star rating.
	 * @param int             $total        Total number of reviews.
	 * @return array<int, int> Whole percentages keyed by star rating.
	 */
	private static function distribute_percentages( array $distribution, int $total ): array {
		$percentages = [];

		if ( $total <= 0 ) {
			for ( $stars = 1; $stars <= 5; $stars++ ) {
				$percentages[ $stars ] = 0;
			}
			return $percentages;
		}

		$remainders = [];
		$assigned   = 0;

		for ( $stars = 1; $stars <= 5; $stars++ ) {
			$exact                 = ( ( $distribution[ $stars ] ?? 0 ) / $total ) * 100;
			$floor                 = (int) floor( $exact );
			$percentages[ $stars ] = $floor;
			$remainders[ $stars ]  = $exact - $floor;
			$assigned             += $floor;
		}

		arsort( $remainders );

		foreach ( array_keys( $remainders ) as $stars ) {
			if ( $assigned >= 100 ) {
				break;
			}
			++$percentages[ $stars ];
			++$assigned;
		}

		return $percentages;
	}

	private function render_rating_breakdown( \WC_Product $product ): void {
		$distribution = $this->get_rating_distribution( $product->get_id() );

		$total   = array_sum( $distribution );
		$average = 0.0;
		if ( $total > 0 ) {
			$weighted = 0;
			foreach ( $distribution as $stars => $count ) {
				$weighted += (int) $stars * $count;
			}
			$average = round( $weighted / $total, 1 );
		}

		if ( 0 === $total ) {
			return;
		}
		?>
		<div class="kramar-rating-breakdown" aria-label="<?php esc_attr_e( 'Beoordelingsverdeling', 'kramar' ); ?>">
			<div class="kramar-rating-breakdown__average">
				<span class="kramar-rating-breakdown__score"><?php echo esc_html( number_format_i18n( $average, 1 ) ); ?></span>
				<span class="kramar-rating-breakdown__out-of"><?php echo esc_html__( '/ 5', 'kramar' ); ?></span>
				<span class="kramar-rating-breakdown__count">
					<?php
					printf(
						/* translators: %d: number of reviews */
						esc_html( _n( '(%d beoordeling)', '(%d beoordelingen)', $total, 'kramar' ) ),
						(int) $total
					);
					?>
				</span>
			</div>
			<?php $percentages = self::distribute_percentages( $distribution, (int) $total ); ?>
			<div class="kramar-rating-breakdown__bars">
			<?php for ( $stars = 5; $stars >= 1; $stars-- ) : ?>
				<?php
				$count   = $distribution[ $stars ] ?? 0;
				$percent = $percentages[ $stars ] ?? 0;
				?>
				<div class="kramar-rating-breakdown__row">
					<span class="kramar-rating-breakdown__label">
						<?php echo esc_html( (string) $stars ); ?> ★
					</span>
					<div class="kramar-rating-breakdown__bar-wrap" role="progressbar" aria-valuenow="<?php echo esc_attr( (string) $percent ); ?>" aria-valuemin="0" aria-valuemax="100">
						<div class="kramar-rating-breakdown__bar" style="width:<?php echo esc_attr( (string) $percent ); ?>%"></div>
					</div>
					<span class="kramar-rating-breakdown__pct"><?php echo esc_html( (string) $percent ); ?>%</span>
				</div>
			<?php endfor; ?>
			</div>
		</div>
		<?php
	}

	/**
	 * Get the star distribution for a product, with transient caching.
	 *
	 * @since 1.0.0
	 *
	 * @param int $product_id Product post ID.
	 * @return array<int, int> Map of star rating (1–5) => count.
	 */
	private function get_rating_distribution( int $product_id ): array {
		$transient_key = self::TRANSIENT_PREFIX . $product_id;
		$cached        = get_transient( $transient_key );

		if ( is_array( $cached ) ) {
			/** @var array<int, int> $cached */
			return $cached;
		}

		// Aggregate via WP_Comment_Query — safe, cached results.
		$distribution = [ 1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 0 ];

		$comments = new \WP_Comment_Query(
			[
				'post_id' => $product_id,
				'status'  => 'approve',
				'type'    => 'review',
				'number'  => 0, // all
				'fields'  => 'ids',
			]
		);

		$comment_ids = $comments->get_comments();
		if ( ! empty( $comment_ids ) && is_array( $comment_ids ) ) {
			foreach ( $comment_ids as $comment_id ) {
				$rating = (int) get_comment_meta( (int) $comment_id, 'rating', true );
				if ( $rating >= 1 && $rating <= 5 ) {
					$distribution[ $rating ]++;
				}
			}
		}

		set_transient( $transient_key, $distribution, DAY_IN_SECONDS );

		return $distribution;
	}

	/**
	 * Bust the rating breakdown transient when a review is approved.
	 *
	 * @since 1.0.0
	 *
	 * @param int $comment_id Comment ID.
	 */
	public function bust_breakdown_transient( int $comment_id ): void {
		$comment = get_comment( $comment_id );
		if ( ! $comment instanceof \WP_Comment ) {
			return;
		}
		$post_id = (int) $comment->comment_post_ID;
		if ( $post_id > 0 ) {
			delete_transient( self::TRANSIENT_PREFIX . $post_id );
		}
	}

	/**
	 * Bust breakdown transient when a comment is inserted already approved.
	 *
	 * Covers admin-authored reviews and trusted-customer auto-approve, which
	 * skip both `comment_approved_review` and `transition_comment_status`.
	 *
	 * @since 1.0.0
	 *
	 * @param int         $comment_id Inserted comment ID.
	 * @param \WP_Comment $comment    Comment object.
	 */
	public function bust_breakdown_on_insert( int $comment_id, \WP_Comment $comment ): void {
		// Only approved product reviews affect the breakdown.
		if ( '1' !== (string) $comment->comment_approved ) {
			return;
		}
		$post_id = (int) $comment->comment_post_ID;
		if ( $post_id > 0 && 'product' === get_post_type( $post_id ) ) {
			delete_transient( self::TRANSIENT_PREFIX . $post_id );
		}
	}

	/**
	 * Bust breakdown transient on comment status change (e.g. pending → approved).
	 *
	 * @since 1.0.0
	 *
	 * @param string      $new_status New comment status.
	 * @param string      $old_status Old comment status.
	 * @param \WP_Comment $comment    Comment object.
	 */
	public function bust_breakdown_on_status_change( string $new_status, string $old_status, \WP_Comment $comment ): void {
		if ( $new_status === $old_status ) {
			return;
		}
		$post_id = (int) $comment->comment_post_ID;
		if ( $post_id > 0 && 'product' === get_post_type( $post_id ) ) {
			delete_transient( self::TRANSIENT_PREFIX . $post_id );
		}
	}

	// -------------------------------------------------------------------------
	// Feature 4: Sort controls
	// -------------------------------------------------------------------------

	/**
	 * Inject a sort selector above the reviews list by wrapping the tab callback.
	 *
	 * Runs after inject_breakdown_into_reviews_tab (priority 31 vs 30), so the
	 * breakdown renders first, then the sort control, then the WC reviews list.
	 *
	 * @since 1.0.0
	 *
	 * @param array<string, mixed> $tabs Existing product tabs.
	 * @return array<string, mixed>
	 */
	public function inject_sort_into_reviews_tab( array $tabs ): array {
		if ( empty( $tabs['reviews']['callback'] ) ) {
			return $tabs;
		}

		$original_callback = $tabs['reviews']['callback'];
		$tabs['reviews']['callback'] = function () use ( $original_callback ): void {
			$this->render_sort_controls();
			call_user_func( $original_callback );
		};

		return $tabs;
	}

	/**
	 * Render sort <select> above the reviews list.
	 *
	 * @since 1.0.0
	 */
	private function render_sort_controls(): void {
		$current = $this->get_current_sort();
		$options = [
			'newest'          => __( 'Nieuwste', 'kramar' ),
			'highest_rating'  => __( 'Hoogste beoordeling', 'kramar' ),
			'lowest_rating'   => __( 'Laagste beoordeling', 'kramar' ),
			'most_helpful'    => __( 'Meest nuttig', 'kramar' ),
		];
		?>
		<div class="kramar-review-sort">
			<label for="kramar-review-sort-select"><?php esc_html_e( 'Sorteer op:', 'kramar' ); ?></label>
			<select id="kramar-review-sort-select" class="kramar-review-sort__select" data-product-url="<?php echo esc_url( get_permalink() ); ?>">
				<?php foreach ( $options as $value => $label ) : ?>
					<option value="<?php echo esc_attr( $value ); ?>" <?php selected( $current, $value ); ?>>
						<?php echo esc_html( $label ); ?>
					</option>
				<?php endforeach; ?>
			</select>
		</div>
		<?php
	}

	/**
	 * Get and sanitize the current sort parameter from the query string.
	 *
	 * @since 1.0.0
	 *
	 * @return string One of: newest, highest_rating, lowest_rating, most_helpful.
	 */
	private function get_current_sort(): string {
		$allowed = [ 'newest', 'highest_rating', 'lowest_rating', 'most_helpful' ];
		$raw     = isset( $_GET['review_sort'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['review_sort'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only sort param, no state change
		return in_array( $raw, $allowed, true ) ? $raw : 'newest';
	}

	/**
	 * Apply the sort parameter to the WP_Comment_Query for product reviews.
	 *
	 * @since 1.0.0
	 *
	 * @param \WP_Comment_Query $query The comment query.
	 */
	public function apply_sort_order( \WP_Comment_Query $query ): void {
		// Only apply on front-end single product pages.
		if ( is_admin() || ! is_product() ) {
			return;
		}

		$sort = $this->get_current_sort();

		switch ( $sort ) {
			case 'highest_rating':
				$query->query_vars['meta_key'] = 'rating'; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- intentional sort by rating
				$query->query_vars['orderby']  = 'meta_value_num';
				$query->query_vars['order']    = 'DESC';
				break;

			case 'lowest_rating':
				$query->query_vars['meta_key'] = 'rating'; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- intentional sort by rating
				$query->query_vars['orderby']  = 'meta_value_num';
				$query->query_vars['order']    = 'ASC';
				break;

			case 'most_helpful':
				$query->query_vars['meta_key'] = self::META_VOTES_UP; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- intentional sort by helpful votes
				$query->query_vars['orderby']  = 'meta_value_num';
				$query->query_vars['order']    = 'DESC';
				break;

			case 'newest':
			default:
				$query->query_vars['orderby'] = 'comment_date';
				$query->query_vars['order']   = 'DESC';
				break;
		}
	}

	// -------------------------------------------------------------------------
	// Frontend assets
	// -------------------------------------------------------------------------

	/**
	 * Enqueue frontend JS and CSS on single product pages.
	 *
	 * @since 1.0.0
	 */
	public function enqueue_frontend_assets(): void {
		if ( ! is_product() ) {
			return;
		}

		wp_enqueue_script(
			'kramar-enhanced-reviews',
			KRAMAR_URL . 'assets/js/enhanced-reviews.js',
			[],
			KRAMAR_VERSION,
			true
		);

		wp_localize_script(
			'kramar-enhanced-reviews',
			'kramarEnhancedReviews',
			[
				'ajaxUrl'      => admin_url( 'admin-ajax.php' ),
				'nonce'        => wp_create_nonce( 'kramar_review_vote' ),
				'sortEnabled'  => (bool) $this->get_setting( 'sort_controls' ),
				'votingEnabled' => (bool) $this->get_setting( 'helpful_voting' ),
				'i18nAlready'  => __( 'Je hebt al gestemd.', 'kramar' ),
				'i18nError'    => __( 'Er is een fout opgetreden. Probeer het opnieuw.', 'kramar' ),
				/* translators: 1: percentage 0-100, 2: total vote count */
				'i18nHelpful'  => __( '%1$d%% van %2$d bezoekers vond dit nuttig', 'kramar' ),
			]
		);

		// Inline CSS via wp_add_inline_style (hooked to an existing WC handle).
		wp_add_inline_style(
			'woocommerce-general',
			$this->get_frontend_css()
		);
	}

	/**
	 * Return the frontend CSS string.
	 *
	 * @since 1.0.0
	 *
	 * @return string
	 */
	private function get_frontend_css(): string {
		return '
/* Kramar: Enhanced Reviews */
.kramar-verified-badge{display:inline-block;margin-top:6px;padding:2px 8px;font-size:12px;font-weight:600;color:#1a7f37;background:#e6f4ea;border:1px solid #b7dfbe;border-radius:3px;}

.kramar-helpful{display:flex;align-items:center;gap:8px;margin-top:10px;font-size:13px;color:#555;}
.kramar-helpful__label{margin-right:2px;}
.kramar-helpful__btn{cursor:pointer;padding:3px 12px;border:1px solid #ddd;border-radius:3px;background:#f9f9f9;font-size:13px;line-height:1.4;transition:background .15s,border-color .15s;}
.kramar-helpful__btn:hover{background:#f0f0f0;border-color:#bbb;}
.kramar-helpful__btn--voted{opacity:.55;cursor:default;}
.kramar-helpful__count{color:#888;font-size:12px;}

.kramar-rating-breakdown{margin-bottom:24px;padding:16px;background:#fafafa;border:1px solid #eee;border-radius:4px;}
.kramar-rating-breakdown__average{display:flex;align-items:baseline;gap:6px;margin-bottom:12px;}
.kramar-rating-breakdown__score{font-size:32px;font-weight:700;line-height:1;}
.kramar-rating-breakdown__out-of{font-size:18px;color:#888;}
.kramar-rating-breakdown__count{font-size:13px;color:#888;}
.kramar-rating-breakdown__bars{display:flex;flex-direction:column;gap:5px;}
.kramar-rating-breakdown__row{display:flex;align-items:center;gap:8px;font-size:13px;}
.kramar-rating-breakdown__label{flex:0 0 34px;text-align:right;white-space:nowrap;}
.kramar-rating-breakdown__bar-wrap{flex:1;height:10px;background:#e0e0e0;border-radius:5px;overflow:hidden;}
.kramar-rating-breakdown__bar{height:100%;background:#f0b429;border-radius:5px;transition:width .4s;}
.kramar-rating-breakdown__pct{flex:0 0 36px;color:#888;}

.kramar-review-sort{display:flex;align-items:center;gap:8px;margin-bottom:16px;font-size:14px;}
.kramar-review-sort__select{padding:4px 8px;border:1px solid #ddd;border-radius:3px;font-size:13px;}
';
	}
}
