<?php
/**
 * Block_Allowlist — L3 enforcement of the admin-configured block allowlist.
 *
 * Mode 'registered' (default): any block name present in the active
 * WP_Block_Type_Registry is permitted — covers core/* plus every custom
 * block registered by an active theme or plugin (ACF, Yoast, Jetpack, etc.)
 * without per-deploy maintenance.
 *
 * Mode 'curated': only the names listed in block_curated_allowlist are
 * permitted. Tightens the surface to a hand-picked subset for high-risk
 * sites where "any registered block" is too permissive.
 *
 * Classic-editor freeform markup (blocks with blockName === null) is always
 * permitted — it carries no opcodes the registry could vet, and rejecting
 * it would prevent any post containing legacy content from being edited.
 *
 * See .context/project.json → architecture.scoped_writes_layers.L3.
 *
 * @package Ppok\AIToolkit
 * @license GPL-2.0-or-later
 */

namespace Ppok\AIToolkit\Abilities;

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

final class Block_Allowlist {

	public const MODE_REGISTERED = 'registered';
	public const MODE_CURATED    = 'curated';

	/**
	 * Walk the block tree (depth-first, inner blocks included) and reject
	 * if any block name falls outside the active allowlist.
	 *
	 * @param array<int, mixed> $blocks parse_blocks()-shape tree.
	 * @return true|\WP_Error
	 */
	public static function check( array $blocks ) {
		$mode    = self::mode();
		$allowed = self::allowed_set( $mode );

		$unknown = array();
		self::walk(
			$blocks,
			static function ( $name ) use ( $allowed, &$unknown ) {
				if ( null === $name || '' === $name ) {
					return;
				}
				if ( ! in_array( $name, $allowed, true ) ) {
					$unknown[] = $name;
				}
			}
		);

		$unknown = array_values( array_unique( $unknown ) );
		if ( ! $unknown ) {
			return true;
		}

		return new \WP_Error(
			'unknown_block',
			sprintf(
				/* translators: 1: mode, 2: comma-separated rejected block names */
				__( 'Block tree contains names not in the %1$s allowlist: %2$s.', 'ppok-ai-toolkit' ),
				$mode,
				implode( ', ', $unknown )
			),
			array(
				'status'              => 422,
				'mode'                => $mode,
				'unknown_block_names' => $unknown,
				'allowed_count'       => count( $allowed ),
			)
		);
	}

	public static function mode(): string {
		$settings = Scope_Gate::settings();
		$mode     = isset( $settings['block_allowlist_mode'] ) ? (string) $settings['block_allowlist_mode'] : self::MODE_REGISTERED;
		return in_array( $mode, array( self::MODE_REGISTERED, self::MODE_CURATED ), true ) ? $mode : self::MODE_REGISTERED;
	}

	/**
	 * @return array<int, string>
	 */
	public static function allowed_set( string $mode ): array {
		if ( self::MODE_CURATED === $mode ) {
			$settings = Scope_Gate::settings();
			$curated  = isset( $settings['block_curated_allowlist'] ) ? (array) $settings['block_curated_allowlist'] : array();
			return array_values( array_filter( array_map( 'strval', $curated ), 'strlen' ) );
		}

		if ( ! class_exists( '\WP_Block_Type_Registry' ) ) {
			return array();
		}
		return array_keys( \WP_Block_Type_Registry::get_instance()->get_all_registered() );
	}

	/**
	 * Walk a parse_blocks() tree depth-first, invoking $visit with each
	 * block's blockName (which may be string or null for freeform markup).
	 *
	 * @param array<int, mixed> $blocks
	 */
	private static function walk( array $blocks, callable $visit ): void {
		foreach ( $blocks as $block ) {
			if ( ! is_array( $block ) ) {
				continue;
			}
			$visit( $block['blockName'] ?? null );
			if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
				self::walk( $block['innerBlocks'], $visit );
			}
		}
	}
}
