<?php
/**
 * Trait registry — PHP counterpart to `src/shared/traits/registry.js`.
 *
 * Two responsibilities, both wired into a single `block_type_metadata`
 * filter (runs once per block at registration):
 *
 *   1. **Capture** each block's `blocksifySupports` declaration so
 *      downstream consumers (CSS generator, lookup helpers) can introspect
 *      which traits a block opted into.
 *
 *   2. **Inject schema** — each declared trait's `Blocksify_X_Trait::
 *      attributes()` map merges into `$metadata['attributes']` so blocks
 *      no longer enumerate trait attrs in their `block.json` (Phase 7.1).
 *      Block.json wins on key collision — lets blocks override trait
 *      defaults (e.g. Button's `padding`, Icon's `size`).
 *
 * Lookup helpers:
 *   - `block_has_trait( $block_name, $trait_name )` → bool
 *   - `block_traits( $block_name )` → array (declaration-order trait names)
 *   - `block_trait_supports( $block_name, $trait_name )` → bool|array
 *     (subset config; true means "all sub-fields enabled")
 *   - `trait_class( $trait_name )` → string|null (canonical PHP class name)
 *   - `block_parts( $block_name )` → array (part-name → CSS selector suffix;
 *     root excluded — implicit). See SPEC-trait-parts for parts mechanism.
 *   - `block_trait_parts( $block_name, $trait_name )` → string[] (parts the
 *     given trait opts in to; may include 'root' if author listed it).
 *
 * @package Blocksify
 */

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

class Blocksify_Traits {

	/**
	 * Map: trait-name → PHP class. Mirrors the JS `traits` lookup. Add an
	 * entry here whenever a new trait class lands.
	 *
	 * @var array<string,string>
	 */
	protected static $classes = array(
		'background' => 'Blocksify_Background_Trait',
		'color'      => 'Blocksify_Color_Trait',
		'spacing'    => 'Blocksify_Spacing_Trait',
		'border'     => 'Blocksify_Border_Trait',
		'size'       => 'Blocksify_Size_Trait',
		'overflow'   => 'Blocksify_Overflow_Trait',
		'position'   => 'Blocksify_Position_Trait',
		'zindex'     => 'Blocksify_ZIndex_Trait',
		'visibility' => 'Blocksify_Visibility_Trait',
		'typography' => 'Blocksify_Typography_Trait',
	);

	/**
	 * Map: block-name → array{ names: string[], supports: array }.
	 * Populated lazily — first access for a given block reads its
	 * block.json and caches the result.
	 *
	 * @var array<string,array>
	 */
	protected static $declared = array();

	/**
	 * Map: block-name → array{ part-name: selector-suffix }. Populated
	 * alongside `$declared` from `blocksifyParts` declaration in block.json.
	 * `root` is implicit and never stored here.
	 *
	 * @var array<string,array<string,string>>
	 */
	protected static $parts = array();

	/**
	 * Boot the registry. Hooks `block_type_metadata` (raw block.json view
	 * with all keys present, including custom ones like `blocksifySupports`
	 * that WP's `register_block_type_from_metadata` would otherwise drop
	 * from `$args`).
	 */
	public static function boot() {
		add_filter( 'block_type_metadata', array( __CLASS__, 'capture_supports' ) );
	}

	/**
	 * `block_type_metadata` filter: capture each Blocksify block's
	 * `blocksifySupports` declaration AND inject the trait attribute
	 * schemas (Phase 7.1 schema merge). Runs once per block at registration
	 * — fast lookup downstream + zero block.json duplication.
	 *
	 * Schema merge semantic (FOUNDATION §7.4): inject FULL trait attrs
	 * regardless of subset config. Subset config gates UI only — schema
	 * registration is full so unused sub-fields can be enabled later by
	 * editing `blocksifySupports` without an attribute migration.
	 *
	 * Block.json wins on key collision: pre-existing `attributes[$key]`
	 * is preserved (lets blocks override trait shape with custom defaults
	 * — Button's `padding`, Icon's `size`, etc).
	 *
	 * @param array $metadata Raw decoded block.json (all keys preserved).
	 * @return array Metadata with `attributes` enriched by declared traits.
	 */
	public static function capture_supports( $metadata ) {
		if ( ! is_array( $metadata ) || ! isset( $metadata['name'] ) ) {
			return $metadata;
		}
		$block_name = $metadata['name'];
		if ( strpos( $block_name, 'blocksify/' ) !== 0 ) {
			return $metadata;
		}
		$raw       = isset( $metadata['blocksifySupports'] )
			? $metadata['blocksifySupports']
			: null;
		$declared = self::normalize( $raw );
		self::$declared[ $block_name ] = $declared;

		// Capture `blocksifyParts` declaration (SPEC-trait-parts §1). Map of
		// part-name → CSS selector suffix. `root` is implicit (= per-instance
		// class) and silently ignored if redeclared.
		self::$parts[ $block_name ] = self::normalize_parts(
			isset( $metadata['blocksifyParts'] ) ? $metadata['blocksifyParts'] : null
		);

		// Inject trait attribute schemas. Block.json's `attributes` map
		// (when present) wins on collision — trait fragment fills in keys
		// the block doesn't enumerate. Trait names from `$declared['names']`
		// are guaranteed to exist in `self::$classes` (filtered by `normalize`).
		if ( ! empty( $declared['names'] ) ) {
			if ( ! isset( $metadata['attributes'] ) || ! is_array( $metadata['attributes'] ) ) {
				$metadata['attributes'] = array();
			}
			foreach ( $declared['names'] as $trait_name ) {
				$class = self::$classes[ $trait_name ];
				if ( ! method_exists( $class, 'attributes' ) ) {
					continue;
				}
				$trait_attrs = $class::attributes();
				if ( ! is_array( $trait_attrs ) ) {
					continue;
				}

				// Determine which parts the schema injects for. Trait
				// without a `parts` subset key (legacy form: `true` or
				// `{ field: true, ... }`) injects root attrs only —
				// preserves the existing 6-block behavior byte-for-byte.
				// Trait WITH `parts` injects per-part-prefixed keys per
				// SPEC-trait-parts §2; root included only when listed.
				$trait_parts = self::block_trait_parts( $block_name, $trait_name );
				$parts_to_inject = empty( $trait_parts ) ? array( 'root' ) : $trait_parts;

				foreach ( $parts_to_inject as $part_name ) {
					if ( 'root' === $part_name ) {
						$prefix = '';
					} else {
						// Skip parts not declared on this block (typo /
						// drift defense). Author meant to list a part
						// that doesn't exist in `blocksifyParts`.
						if ( ! isset( self::$parts[ $block_name ][ $part_name ] ) ) {
							continue;
						}
						$prefix = $part_name;
					}
					foreach ( $trait_attrs as $attr_key => $attr_def ) {
						$key = '' === $prefix ? $attr_key : $prefix . ucfirst( $attr_key );
						if ( ! array_key_exists( $key, $metadata['attributes'] ) ) {
							$metadata['attributes'][ $key ] = $attr_def;
						}
					}
				}
			}
		}
		return $metadata;
	}

	/**
	 * Normalize the raw `blocksifyParts` declaration into a
	 * part-name → selector-suffix map. `root` is rejected (implicit —
	 * always = per-instance class). Empty / non-string entries dropped.
	 *
	 * @param mixed $raw Raw value from block.json (object/assoc array).
	 * @return array<string,string>
	 */
	protected static function normalize_parts( $raw ) {
		if ( ! is_array( $raw ) ) {
			return array();
		}
		$out = array();
		foreach ( $raw as $part_name => $part_def ) {
			if ( ! is_string( $part_name ) || '' === $part_name ) {
				continue;
			}
			if ( 'root' === $part_name ) {
				continue;
			}
			$selector = '';
			if ( is_array( $part_def ) && isset( $part_def['selector'] ) && is_string( $part_def['selector'] ) ) {
				$selector = $part_def['selector'];
			}
			$out[ $part_name ] = $selector;
		}
		return $out;
	}

	/**
	 * Normalize a raw `blocksifySupports` value (array or object/assoc) into
	 * the canonical `{ names, supports }` shape. Mirrors JS `normalize()` in
	 * `src/shared/traits/registry.js`.
	 *
	 * @param mixed $raw
	 * @return array{ names: string[], supports: array }
	 */
	protected static function normalize( $raw ) {
		if ( ! $raw ) {
			return array(
				'names'    => array(),
				'supports' => array(),
			);
		}
		// JSON arrays decode to indexed PHP arrays — same semantic as JS Array.
		if ( is_array( $raw ) && self::is_indexed_array( $raw ) ) {
			$names    = array();
			$supports = array();
			foreach ( $raw as $name ) {
				if ( is_string( $name ) && isset( self::$classes[ $name ] ) ) {
					$names[]            = $name;
					$supports[ $name ] = true;
				}
			}
			return array(
				'names'    => $names,
				'supports' => $supports,
			);
		}
		if ( is_array( $raw ) ) {
			$names    = array();
			$supports = array();
			foreach ( $raw as $name => $cfg ) {
				if ( ! isset( self::$classes[ $name ] ) ) {
					continue;
				}
				if ( $cfg === false ) {
					continue;
				}
				$names[]            = $name;
				$supports[ $name ] = $cfg;
			}
			return array(
				'names'    => $names,
				'supports' => $supports,
			);
		}
		return array(
			'names'    => array(),
			'supports' => array(),
		);
	}

	/**
	 * Detect indexed arrays (JSON array style). Empty array counts as indexed.
	 */
	protected static function is_indexed_array( $arr ) {
		if ( empty( $arr ) ) {
			return true;
		}
		return array_keys( $arr ) === range( 0, count( $arr ) - 1 );
	}

	/**
	 * Return the declared trait names for a block (declaration-order array).
	 */
	public static function block_traits( $block_name ) {
		if ( ! isset( self::$declared[ $block_name ] ) ) {
			return array();
		}
		return self::$declared[ $block_name ]['names'];
	}

	/**
	 * Whether a block declares a given trait.
	 */
	public static function block_has_trait( $block_name, $trait_name ) {
		if ( ! isset( self::$declared[ $block_name ] ) ) {
			return false;
		}
		return in_array(
			$trait_name,
			self::$declared[ $block_name ]['names'],
			true
		);
	}

	/**
	 * Return the subset config for a trait on a block. `true` = all
	 * sub-fields enabled (shorthand from indexed-array form). Otherwise
	 * an associative array `{ field: true, … }`.
	 */
	public static function block_trait_supports( $block_name, $trait_name ) {
		if ( ! isset( self::$declared[ $block_name ] ) ) {
			return false;
		}
		$supports = self::$declared[ $block_name ]['supports'];
		return isset( $supports[ $trait_name ] ) ? $supports[ $trait_name ] : false;
	}

	/**
	 * Return the registered PHP class for a trait, or null.
	 */
	public static function trait_class( $trait_name ) {
		return isset( self::$classes[ $trait_name ] )
			? self::$classes[ $trait_name ]
			: null;
	}

	/**
	 * Return the parts declared on a block (SPEC-trait-parts §1). Map of
	 * part-name → CSS selector suffix relative to per-instance class.
	 * `root` is NOT included (always implicit). Empty array when the
	 * block has no `blocksifyParts` declaration.
	 *
	 * @param string $block_name
	 * @return array<string,string>
	 */
	public static function block_parts( $block_name ) {
		if ( ! isset( self::$parts[ $block_name ] ) ) {
			return array();
		}
		return self::$parts[ $block_name ];
	}

	/**
	 * Return the parts a trait opts in to on a given block. Reads
	 * `blocksifySupports.{trait}.parts` (SPEC-trait-parts §1). Returns
	 * empty array when the trait has no `parts` config — caller should
	 * treat that as root-only (legacy form).
	 *
	 * @param string $block_name
	 * @param string $trait_name
	 * @return string[] Array of part names (may include 'root').
	 */
	public static function block_trait_parts( $block_name, $trait_name ) {
		if ( ! isset( self::$declared[ $block_name ] ) ) {
			return array();
		}
		$supports = self::$declared[ $block_name ]['supports'];
		if ( ! isset( $supports[ $trait_name ] ) ) {
			return array();
		}
		$cfg = $supports[ $trait_name ];
		if ( ! is_array( $cfg ) ) {
			return array();
		}
		if ( ! isset( $cfg['parts'] ) || ! is_array( $cfg['parts'] ) ) {
			return array();
		}
		$out = array();
		foreach ( $cfg['parts'] as $part_name ) {
			if ( is_string( $part_name ) && '' !== $part_name ) {
				$out[] = $part_name;
			}
		}
		return $out;
	}
}
