<?php
/**
 * File-based library templates (editable HTML + block.json).
 *
 * Extension: Pro (or other add-ons) can register extra template roots via
 * `jocbm_template_dirs`. Each root uses the same folder contract as
 * the free plugin's `templates/` directory.
 *
 * @package JOCBM
 *
 * Copyright (C) 2026 JayOnCode
 * License: GPL-2.0-or-later
 */

namespace JOCBM\Services;

use JOCBM\Database\CategoryTaxonomy;
use JOCBM\Services\BlockCategories;

defined( 'ABSPATH' ) || exit;

/**
 * Class TemplateLibrary
 */
final class TemplateLibrary {

	/**
	 * Absolute paths to template directories (existing dirs only).
	 *
	 * Core / Pro / extension packs under registered template dirs.
	 * Drafts use `publisher_status: draft` (seeded inactive). Legacy
	 * `templates/_creator` is excluded — Owner Library lives in Pro.
	 *
	 * @return array<int, string>
	 */
	public static function dirs(): array {
		$default = trailingslashit( JOCBM_PATH ) . 'templates';

		/**
		 * Filter template directories scanned for library blocks.
		 *
		 * Pro example:
		 * `add_filter( 'jocbm_template_dirs', function ( $dirs ) {
		 *     $dirs[] = trailingslashit( JOCBM_PRO_PATH ) . 'templates';
		 *     return $dirs;
		 * } );`
		 *
		 * Later directories override earlier ones when slug+variant collide.
		 * Do not add legacy `templates/_creator` — Owner packs use free/Pro `templates/`.
		 *
		 * @param array<int, string> $dirs Absolute directory paths.
		 */
		$dirs = apply_filters( 'jocbm_template_dirs', array( $default ) );

		$normalized = array();
		$creator    = wp_normalize_path( self::creator_dir() );
		foreach ( (array) $dirs as $dir ) {
			$dir = trailingslashit( wp_normalize_path( (string) $dir ) );
			if ( '' === $dir || ! is_dir( $dir ) ) {
				continue;
			}
			$untrail = untrailingslashit( $dir );
			// Never seed owner creator packs through the main template dirs.
			if ( $untrail === $creator ) {
				continue;
			}
			$normalized[ $untrail ] = $untrail;
		}

		return array_values( $normalized );
	}

	/**
	 * Absolute path to legacy Owner Library packs (`templates/_creator`).
	 */
	public static function creator_dir(): string {
		return untrailingslashit(
			trailingslashit( JOCBM_PATH ) . 'templates/_creator'
		);
	}

	/**
	 * Whether raw block.json marks an Owner-authored pack (file-first).
	 *
	 * Shipping core templates never set `publisher_status` / `owner`.
	 *
	 * @param array<string,mixed> $meta Decoded block.json.
	 */
	public static function meta_is_owner_pack( array $meta ): bool {
		if ( ! empty( $meta['owner'] ) ) {
			return true;
		}
		if ( array_key_exists( 'publisher_status', $meta ) ) {
			return true;
		}
		return 'creator' === sanitize_key( (string) ( $meta['source'] ?? '' ) );
	}

	/**
	 * Published Owner packs from free + Pro template dirs (disk only).
	 *
	 * @return array<int, array<string, mixed>>
	 */
	public static function ready_owner_blocks(): array {
		$blocks = array();
		foreach ( self::all() as $block ) {
			if ( empty( $block['is_owner'] ) ) {
				continue;
			}
			if ( 'ready' !== sanitize_key( (string) ( $block['publisher_status'] ?? '' ) ) ) {
				continue;
			}
			if ( ! empty( $block['is_hidden'] ) ) {
				continue;
			}
			$blocks[] = $block;
		}

		usort(
			$blocks,
			static function ( $a, $b ) {
				return strcmp( (string) ( $a['name'] ?? '' ), (string) ( $b['name'] ?? '' ) );
			}
		);

		return $blocks;
	}

	/**
	 * @return array<string, true> Map of `slug::variant` for every Owner pack on disk.
	 */
	public static function owner_pack_keys(): array {
		$keys = array();
		foreach ( self::all() as $block ) {
			if ( empty( $block['is_owner'] ) ) {
				continue;
			}
			$slug    = sanitize_key( (string) ( $block['slug'] ?? '' ) );
			$variant = sanitize_key( (string) ( $block['variant'] ?? 'default' ) ) ?: 'default';
			if ( '' === $slug ) {
				continue;
			}
			$keys[ $slug . '::' . $variant ] = true;
		}
		return $keys;
	}

	/**
	 * Find one template variant from disk (core or Owner).
	 *
	 * @param string $slug    Slug.
	 * @param string $variant Variant.
	 * @return array<string,mixed>|null
	 */
	public static function find( string $slug, string $variant = 'default' ): ?array {
		$slug    = sanitize_key( $slug );
		$variant = sanitize_key( $variant ) ?: 'default';
		if ( '' === $slug ) {
			return null;
		}
		foreach ( self::all() as $block ) {
			if (
				$slug === sanitize_key( (string) ( $block['slug'] ?? '' ) )
				&& $variant === ( sanitize_key( (string) ( $block['variant'] ?? 'default' ) ) ?: 'default' )
			) {
				return $block;
			}
		}
		return null;
	}

	/**
	 * Published (ready) legacy `_creator` packs — deprecated alias.
	 *
	 * @return array<int, array<string, mixed>>
	 */
	public static function ready_creator_blocks(): array {
		return array_values(
			array_filter(
				self::ready_owner_blocks(),
				static function ( $block ) {
					return 'creator' === sanitize_key( (string) ( $block['source'] ?? '' ) );
				}
			)
		);
	}

	/**
	 * Primary (first) templates directory — back-compat helper.
	 */
	public static function dir(): string {
		$dirs = self::dirs();
		return $dirs[0] ?? untrailingslashit( trailingslashit( JOCBM_PATH ) . 'templates' );
	}

	/**
	 * Fingerprint of all template files across registered directories.
	 */
	public static function fingerprint(): string {
		$parts = array();

		foreach ( self::dirs() as $root ) {
			$iterator = new \RecursiveIteratorIterator(
				new \RecursiveDirectoryIterator( $root, \FilesystemIterator::SKIP_DOTS )
			);

			foreach ( $iterator as $file ) {
				if ( ! $file->isFile() ) {
					continue;
				}
				$ext = strtolower( $file->getExtension() );
				if ( ! in_array( $ext, array( 'html', 'json' ), true ) ) {
					continue;
				}
				$parts[] = $file->getPathname() . '|' . $file->getMTime() . '|' . $file->getSize();
			}
		}

		if ( ! $parts ) {
			return '';
		}

		sort( $parts );
		return hash( 'sha256', implode( "\n", $parts ) );
	}

	/**
	 * Load all library block definitions from disk.
	 *
	 * @return array<int, array<string, mixed>>
	 */
	public static function all(): array {
		$shared_style = self::load_shared_style_schema();
		$by_key       = array();

		foreach ( self::dirs() as $root ) {
			$slug_dirs = glob( $root . '/*', GLOB_ONLYDIR ) ?: array();
			foreach ( $slug_dirs as $slug_dir ) {
				$slug_base = basename( $slug_dir );
				if ( 0 === strpos( $slug_base, '_' ) ) {
					continue;
				}

				$variant_dirs = glob( $slug_dir . '/*', GLOB_ONLYDIR ) ?: array();
				foreach ( $variant_dirs as $variant_dir ) {
					$block = self::load_variant( $variant_dir, $root, $shared_style );
					if ( ! $block ) {
						continue;
					}
					$key            = $block['slug'] . '::' . $block['variant'];
					$by_key[ $key ] = $block;
				}
			}
		}

		$blocks = array_values( $by_key );

		/**
		 * Filter loaded library block definitions before they are seeded to the DB.
		 *
		 * @param array<int, array<string, mixed>> $blocks Block definitions.
		 */
		$blocks = apply_filters( 'jocbm_template_blocks', $blocks );

		usort(
			$blocks,
			static function ( $a, $b ) {
				$ao = (int) ( $a['sort_order'] ?? 0 );
				$bo = (int) ( $b['sort_order'] ?? 0 );
				if ( $ao === $bo ) {
					return strcmp( (string) $a['name'], (string) $b['name'] );
				}
				return $ao <=> $bo;
			}
		);

		return is_array( $blocks ) ? $blocks : array();
	}

	/**
	 * Hash structural template parts (markup + schemas).
	 *
	 * @param array<string, mixed> $block Block definition or DB row-like array.
	 */
	public static function hash( array $block ): string {
		$payload = array(
			'markup'       => (string) ( $block['markup'] ?? '' ),
			'field_schema' => self::normalize_json( $block['field_schema'] ?? array() ),
			'style_schema' => self::normalize_json( $block['style_schema'] ?? array() ),
		);

		return hash( 'sha256', wp_json_encode( $payload ) );
	}

	/**
	 * Copy compatible field metadata from the library schema onto a user snapshot.
	 * Keeps user field keys/order; upgrades type (e.g. textarea → wysiwyg) and tag defaults.
	 *
	 * @param array<int, array<string, mixed>> $current User / coalesced field schema.
	 * @param array<int, array<string, mixed>> $library Library field schema.
	 * @return array<int, array<string, mixed>>
	 */
	public static function apply_library_field_types( array $current, array $library ): array {
		if ( ! $current || ! $library ) {
			return $current;
		}

		$by_key = array();
		foreach ( $library as $field ) {
			$key = (string) ( $field['key'] ?? '' );
			if ( '' !== $key ) {
				$by_key[ $key ] = $field;
			}
		}

		$out      = array();
		$seen_keys = array();
		foreach ( $current as $field ) {
			if ( ! is_array( $field ) ) {
				continue;
			}
			$key = (string) ( $field['key'] ?? '' );
			if ( '' !== $key ) {
				$seen_keys[ $key ] = true;
			}
			if ( '' !== $key && isset( $by_key[ $key ] ) ) {
				$lib = $by_key[ $key ];
				if ( ! empty( $lib['type'] ) ) {
					$field['type'] = $lib['type'];
				}
				if ( array_key_exists( 'default', $lib ) ) {
					$field['default'] = $lib['default'];
				}
				if ( isset( $lib['options'] ) && is_array( $lib['options'] ) ) {
					$field['options'] = $lib['options'];
				}
				if ( isset( $lib['mediaTypes'] ) && is_array( $lib['mediaTypes'] ) ) {
					$field['mediaTypes'] = $lib['mediaTypes'];
				}
				if ( array_key_exists( 'mediaType', $lib ) ) {
					$field['mediaType'] = $lib['mediaType'];
				}
				if ( array_key_exists( 'label', $lib ) ) {
					$field['label'] = $lib['label'];
				}
				if ( array_key_exists( 'allowedCommand', $lib ) ) {
					$field['allowedCommand'] = $lib['allowedCommand'];
				} elseif ( array_key_exists( 'allowedCommands', $lib ) ) {
					$field['allowedCommands'] = $lib['allowedCommands'];
				}
				if ( array_key_exists( 'disableAdvanceEditor', $lib ) ) {
					$field['disableAdvanceEditor'] = $lib['disableAdvanceEditor'];
				} elseif ( array_key_exists( 'disableAdvancedEditor', $lib ) ) {
					$field['disableAdvanceEditor'] = $lib['disableAdvancedEditor'];
				}
				if ( array_key_exists( 'inlineEdit', $lib ) ) {
					$field['inlineEdit'] = $lib['inlineEdit'];
				} elseif ( array_key_exists( 'inline_edit', $lib ) ) {
					$field['inlineEdit'] = $lib['inline_edit'];
				}
				if (
					isset( $lib['fields'] ) &&
					is_array( $lib['fields'] ) &&
					isset( $field['fields'] ) &&
					is_array( $field['fields'] )
				) {
					$field['fields'] = self::apply_library_field_types(
						$field['fields'],
						$lib['fields']
					);
				}
				if ( ( $lib['type'] ?? '' ) === 'elementsOrder' ) {
					if ( isset( $lib['labels'] ) && is_array( $lib['labels'] ) ) {
						$field['labels'] = $lib['labels'];
					}
					if ( isset( $lib['visibilityKeys'] ) && is_array( $lib['visibilityKeys'] ) ) {
						$field['visibilityKeys'] = $lib['visibilityKeys'];
					}
				}
				// Inject per-slide elementsOrder into slides repeater fields.
				if ( ( $lib['itemType'] ?? '' ) === 'slide' || ( $field['itemType'] ?? '' ) === 'slide' ) {
					$field = self::merge_slide_elements_order_field( $field, $lib );
				}
			}
			$out[] = $field;
		}

		// Inject library-only top-level elementsOrder (non-slider heroes).
		foreach ( $library as $lib_field ) {
			if ( ! is_array( $lib_field ) ) {
				continue;
			}
			if ( ( $lib_field['type'] ?? '' ) !== 'elementsOrder' ) {
				continue;
			}
			$key = (string) ( $lib_field['key'] ?? '' );
			if ( '' === $key || isset( $seen_keys[ $key ] ) ) {
				continue;
			}
			array_unshift( $out, $lib_field );
			$seen_keys[ $key ] = true;
		}

		return $out;
	}

	/**
	 * Ensure slides repeater schema includes nested elementsOrder from the library.
	 *
	 * @param array<string, mixed> $field Current slides field.
	 * @param array<string, mixed> $lib   Library slides field.
	 * @return array<string, mixed>
	 */
	private static function merge_slide_elements_order_field( array $field, array $lib ): array {
		$lib_fields = isset( $lib['fields'] ) && is_array( $lib['fields'] ) ? $lib['fields'] : array();
		$lib_order  = null;
		foreach ( $lib_fields as $sub ) {
			if ( is_array( $sub ) && ( $sub['type'] ?? '' ) === 'elementsOrder' ) {
				$lib_order = $sub;
				break;
			}
		}
		if ( ! is_array( $lib_order ) ) {
			return $field;
		}

		$current_fields = isset( $field['fields'] ) && is_array( $field['fields'] ) ? $field['fields'] : array();
		foreach ( $current_fields as $sub ) {
			if ( is_array( $sub ) && ( $sub['type'] ?? '' ) === 'elementsOrder' ) {
				return $field;
			}
		}

		$merged   = array();
		$inserted = false;
		foreach ( $current_fields as $sub ) {
			$merged[] = $sub;
			if ( ! $inserted && is_array( $sub ) && ( $sub['key'] ?? '' ) === 'layout' ) {
				$merged[] = $lib_order;
				$inserted = true;
			}
		}
		if ( ! $inserted ) {
			array_unshift( $merged, $lib_order );
		}
		$field['fields'] = $merged;
		return $field;
	}

	/**
	 * Decode JSON field schema helper.
	 *
	 * @param mixed $raw JSON string or array.
	 * @return array<int, array<string, mixed>>
	 */
	public static function decode_field_schema( $raw ): array {
		return JsonHelper::decode_array( $raw, array() );
	}

	/**
	 * Resolve pack_type for an Owner pack meta / resolved block.
	 *
	 * Missing pack_type: standalone (preserves existing catalog cards).
	 * New forks default to variant in Publisher.
	 *
	 * @param array<string,mixed> $meta Pack meta or resolved block.
	 * @return string standalone|variant
	 */
	public static function resolve_pack_type( array $meta ): string {
		$raw = sanitize_key(
			(string) ( $meta['pack_type'] ?? $meta['packType'] ?? '' )
		);
		if ( 'variant' === $raw || 'standalone' === $raw ) {
			return $raw;
		}
		return 'standalone';
	}

	/**
	 * Parse `extends` from pack meta (`{ slug, variant }` or `"slug/variant"`).
	 *
	 * @param array<string,mixed> $meta Decoded block.json.
	 * @return array{slug: string, variant: string}|null
	 */
	public static function parse_extends( array $meta ): ?array {
		$raw = $meta['extends'] ?? null;
		if ( is_string( $raw ) ) {
			$raw = trim( $raw );
			if ( '' === $raw ) {
				return null;
			}
			$parts = array_values( array_filter( explode( '/', $raw ) ) );
			$slug  = sanitize_key( (string) ( $parts[0] ?? '' ) );
			$var   = sanitize_key( (string) ( $parts[1] ?? 'default' ) ) ?: 'default';
			return '' !== $slug ? array( 'slug' => $slug, 'variant' => $var ) : null;
		}
		if ( ! is_array( $raw ) ) {
			return null;
		}
		$slug = sanitize_key( (string) ( $raw['slug'] ?? '' ) );
		$var  = sanitize_key( (string) ( $raw['variant'] ?? 'default' ) ) ?: 'default';
		return '' !== $slug ? array( 'slug' => $slug, 'variant' => $var ) : null;
	}

	/**
	 * Resolve an Owner overlay pack against its non-owner base template.
	 *
	 * Markup + schemas always come from the base. css_vars / default_attrs merge
	 * with pack winning. Identity flags stay on the pack.
	 *
	 * @param array<string,mixed> $pack_meta   Pack block.json.
	 * @param string|null         $pack_markup Ignored when extending (base markup wins).
	 * @return array<string,mixed>|null Resolved library block shape, or null if base missing.
	 */
	public static function resolve_owner_overlay( array $pack_meta, ?string $pack_markup = null ): ?array {
		$extends = self::parse_extends( $pack_meta );
		if ( ! $extends ) {
			return null;
		}

		$shared_style = self::load_shared_style_schema();
		$base         = self::load_non_owner_variant(
			$extends['slug'],
			$extends['variant'],
			$shared_style
		);
		if ( ! $base ) {
			return null;
		}

		$pack_css = array_key_exists( 'css_vars', $pack_meta ) && is_array( $pack_meta['css_vars'] )
			? $pack_meta['css_vars']
			: array();
		$pack_attrs = array_key_exists( 'default_attrs', $pack_meta ) && is_array( $pack_meta['default_attrs'] )
			? $pack_meta['default_attrs']
			: array();

		$source = sanitize_key( (string) ( $pack_meta['source'] ?? '' ) );
		if ( '' === $source ) {
			$source = ! empty( $pack_meta['is_pro'] ) ? 'pro' : 'core';
		}

		$publisher_status = sanitize_key( (string) ( $pack_meta['publisher_status'] ?? '' ) );
		if ( '' === $publisher_status ) {
			$publisher_status = 'draft';
		}

		$is_hidden = ! empty( $pack_meta['is_hidden'] );
		if ( 'ready' !== $publisher_status ) {
			$is_hidden = true;
		}

		$category = CategoryTaxonomy::resolve(
			(string) ( $pack_meta['category'] ?? $base['category'] ?? '' )
		);
		list( $category, $categories ) = BlockCategories::normalize(
			$category ?: (string) ( $base['category'] ?? '' ),
			$pack_meta['categories'] ?? ( $base['categories'] ?? null )
		);

		$is_pro = ! empty( $pack_meta['is_pro'] );
		if ( ! $is_pro && in_array( $source, array( 'pro', 'premium' ), true ) ) {
			$is_pro = true;
		}

		$block = array(
			'category'         => $category,
			'categories'       => $categories,
			'slug'             => sanitize_key(
				(string) ( $pack_meta['slug'] ?? '' )
			),
			'variant'          => sanitize_key(
				(string) ( $pack_meta['variant'] ?? 'default' )
			) ?: 'default',
			'name'             => sanitize_text_field(
				(string) ( $pack_meta['name'] ?? $base['name'] ?? '' )
			),
			'description'      => sanitize_text_field(
				(string) ( $pack_meta['description'] ?? $base['description'] ?? '' )
			),
			'sort_order'       => (int) ( $pack_meta['sort_order'] ?? $base['sort_order'] ?? 0 ),
			'markup'           => (string) ( $base['markup'] ?? '' ),
			'css_vars'         => CssVars::merge( $base['css_vars'] ?? array(), $pack_css ),
			'default_attrs'    => self::merge_default_attrs_overlay(
				is_array( $base['default_attrs'] ?? null ) ? $base['default_attrs'] : array(),
				$pack_attrs
			),
			'field_schema'     => is_array( $base['field_schema'] ?? null ) ? $base['field_schema'] : array(),
			'style_schema'     => is_array( $base['style_schema'] ?? null ) ? $base['style_schema'] : array(),
			'is_pro'           => $is_pro ? 1 : 0,
			'is_featured'      => ! empty( $pack_meta['is_featured'] ) ? 1 : 0,
			'is_hidden'        => $is_hidden ? 1 : 0,
			'is_owner'         => 1,
			'publisher_status' => $publisher_status,
			'source'           => $source ?: 'core',
			'extends'          => $extends,
			'pack_type'        => self::resolve_pack_type( $pack_meta ),
		);

		// Silence unused arg (API keeps markup for legacy callers).
		unset( $pack_markup );

		$block['template_hash'] = self::hash( $block );

		return $block['category'] && $block['slug'] && $block['name'] ? $block : null;
	}

	/**
	 * Shallow merge: pack keys replace base (arrays/objects replace whole).
	 *
	 * @param array<string,mixed> $base Base attrs.
	 * @param array<string,mixed> $pack Pack overlay attrs.
	 * @return array<string,mixed>
	 */
	public static function merge_default_attrs_overlay( array $base, array $pack ): array {
		if ( ! $pack ) {
			return $base;
		}
		if ( ! $base ) {
			return $pack;
		}
		return array_merge( $base, $pack );
	}

	/**
	 * Load a non-owner base variant by slug/variant (direct disk scan — no recursion).
	 *
	 * @param string            $slug         Base slug.
	 * @param string            $variant      Base variant.
	 * @param array<int, mixed> $shared_style Shared style schema.
	 * @return array<string,mixed>|null
	 */
	private static function load_non_owner_variant( string $slug, string $variant, array $shared_style ): ?array {
		$slug    = sanitize_key( $slug );
		$variant = sanitize_key( $variant ) ?: 'default';
		if ( '' === $slug ) {
			return null;
		}

		$found = null;
		foreach ( self::dirs() as $root ) {
			$dir = trailingslashit( $root ) . $slug . '/' . $variant;
			if ( ! is_dir( $dir ) ) {
				continue;
			}
			$block = self::load_variant_raw( $dir, $root, $shared_style, false );
			if ( ! $block ) {
				continue;
			}
			// Later template dirs win (same as all()).
			$found = $block;
		}

		return $found;
	}

	/**
	 * Load one variant folder.
	 *
	 * @param string            $dir          Variant directory.
	 * @param string            $root         Template root that contains this variant.
	 * @param array<int, mixed> $shared_style Shared style schema.
	 * @return array<string, mixed>|null
	 */
	private static function load_variant( string $dir, string $root, array $shared_style ): ?array {
		return self::load_variant_raw( $dir, $root, $shared_style, true );
	}

	/**
	 * Load one variant folder; optionally resolve Owner `extends`.
	 *
	 * @param string            $dir             Variant directory.
	 * @param string            $root            Template root.
	 * @param array<int, mixed> $shared_style    Shared style schema.
	 * @param bool              $allow_extends   Resolve owner overlays.
	 * @return array<string, mixed>|null
	 */
	private static function load_variant_raw(
		string $dir,
		string $root,
		array $shared_style,
		bool $allow_extends
	): ?array {
		$json_file = trailingslashit( $dir ) . 'block.json';
		if ( ! is_readable( $json_file ) ) {
			return null;
		}

		$meta = json_decode( (string) file_get_contents( $json_file ), true );
		if ( ! is_array( $meta ) ) {
			return null;
		}

		$extends  = self::parse_extends( $meta );
		$is_owner = self::meta_is_owner_pack( $meta );

		// Extending Owner packs: no local markup required.
		if ( $allow_extends && $is_owner && $extends ) {
			if ( empty( $meta['slug'] ) ) {
				$meta['slug'] = basename( dirname( $dir ) );
			}
			if ( empty( $meta['variant'] ) ) {
				$meta['variant'] = basename( $dir );
			}
			return self::resolve_owner_overlay( $meta, null );
		}

		// When resolving a base, never follow another extends chain / Owner pack.
		if ( ! $allow_extends && ( $is_owner || $extends ) ) {
			return null;
		}

		$html_file = trailingslashit( $dir ) . 'markup.html';
		if ( ! is_readable( $html_file ) ) {
			return null;
		}

		$markup = trim( (string) file_get_contents( $html_file ) );
		if ( '' === $markup ) {
			return null;
		}

		$style_schema = $meta['style_schema'] ?? 'shared';
		if ( 'shared' === $style_schema || null === $style_schema ) {
			$style_schema = $shared_style;
		} elseif ( is_string( $style_schema ) ) {
			$style_schema = self::resolve_style_schema_path( $style_schema, $root, $shared_style );
		}

		$source = sanitize_key( (string) ( $meta['source'] ?? '' ) );
		if ( '' === $source ) {
			$source = self::source_for_path( $root );
		}

		$is_pro = ! empty( $meta['is_pro'] );
		if ( ! $is_pro && in_array( $source, array( 'pro', 'premium' ), true ) ) {
			$is_pro = true;
		}

		$publisher_status = sanitize_key( (string) ( $meta['publisher_status'] ?? '' ) );
		if ( $is_owner && '' === $publisher_status ) {
			$publisher_status = 'draft';
		}
		if ( '' === $publisher_status ) {
			$publisher_status = 'ready';
		}

		$is_hidden = ! empty( $meta['is_hidden'] );
		if ( $is_owner && 'ready' !== $publisher_status ) {
			$is_hidden = true;
		}

		$category = CategoryTaxonomy::resolve(
			(string) ( $meta['category'] ?? '' )
		);
		list( $category, $categories ) = BlockCategories::normalize(
			$category,
			$meta['categories'] ?? null
		);

		$block = array(
			'category'         => $category,
			'categories'       => $categories,
			'slug'             => sanitize_key( (string) ( $meta['slug'] ?? basename( dirname( $dir ) ) ) ),
			'variant'          => sanitize_key( (string) ( $meta['variant'] ?? basename( $dir ) ) ),
			'name'             => sanitize_text_field( (string) ( $meta['name'] ?? '' ) ),
			'description'      => sanitize_text_field( (string) ( $meta['description'] ?? '' ) ),
			'sort_order'       => (int) ( $meta['sort_order'] ?? 0 ),
			'markup'           => $markup,
			'css_vars'         => CssVars::normalize( is_array( $meta['css_vars'] ?? null ) ? $meta['css_vars'] : array() ),
			'default_attrs'    => is_array( $meta['default_attrs'] ?? null ) ? $meta['default_attrs'] : array(),
			'field_schema'     => FieldCatalog::compose(
				is_array( $meta['field_schema'] ?? null ) ? $meta['field_schema'] : array()
			),
			'style_schema'     => is_array( $style_schema ) ? $style_schema : array(),
			'is_pro'           => $is_pro ? 1 : 0,
			'is_featured'      => ! empty( $meta['is_featured'] ) ? 1 : 0,
			'is_hidden'        => $is_hidden ? 1 : 0,
			'is_owner'         => $is_owner ? 1 : 0,
			'publisher_status' => $publisher_status,
			'source'           => $source ?: 'core',
		);

		$block['template_hash'] = self::hash( $block );

		return $block['category'] && $block['slug'] && $block['name'] ? $block : null;
	}

	/**
	 * Resolve a relative style schema path against the variant root, then all dirs.
	 *
	 * @param string            $relative     Relative path from block.json.
	 * @param string            $root         Current template root.
	 * @param array<int, mixed> $shared_style Fallback shared schema.
	 * @return array<int, mixed>
	 */
	private static function resolve_style_schema_path( string $relative, string $root, array $shared_style ): array {
		$candidates = array(
			trailingslashit( $root ) . ltrim( $relative, '/' ),
		);
		foreach ( self::dirs() as $dir ) {
			$candidates[] = trailingslashit( $dir ) . ltrim( $relative, '/' );
		}

		foreach ( $candidates as $path ) {
			if ( is_readable( $path ) ) {
				$data = json_decode( (string) file_get_contents( $path ), true );
				return is_array( $data ) ? $data : $shared_style;
			}
		}

		return $shared_style;
	}

	/**
	 * Infer source label from template root path.
	 *
	 * @param string $root Template root directory.
	 */
	private static function source_for_path( string $root ): string {
		$free    = wp_normalize_path( untrailingslashit( trailingslashit( JOCBM_PATH ) . 'templates' ) );
		$creator = wp_normalize_path( untrailingslashit( trailingslashit( JOCBM_PATH ) . 'templates/_creator' ) );
		$path    = wp_normalize_path( untrailingslashit( $root ) );
		if ( $path === $creator ) {
			return 'creator';
		}
		if ( $path === $free || 0 === strpos( $path, $free . '/' ) ) {
			// Nested `_creator` is registered as its own root — never treat as core.
			if ( $path === $creator || 0 === strpos( $path, $creator . '/' ) ) {
				return 'creator';
			}
			return 'core';
		}

		/**
		 * Filter inferred template source for a non-core template directory.
		 *
		 * @param string $source Default `extension`.
		 * @param string $root   Absolute template root.
		 */
		return sanitize_key( (string) apply_filters( 'jocbm_template_source', 'extension', $root ) ) ?: 'extension';
	}

	/**
	 * Shared style schema — first readable `_shared/style-schema.json` across dirs.
	 *
	 * @return array<int, mixed>
	 */
	private static function load_shared_style_schema(): array {
		foreach ( self::dirs() as $root ) {
			$path = trailingslashit( $root ) . '_shared/style-schema.json';
			if ( ! is_readable( $path ) ) {
				continue;
			}
			$data = json_decode( (string) file_get_contents( $path ), true );
			if ( is_array( $data ) ) {
				/**
				 * Filter the shared style schema loaded from disk.
				 *
				 * @param array<int, mixed> $data Schema fields.
				 * @param string            $path File path used.
				 */
				$filtered = apply_filters( 'jocbm_shared_style_schema', $data, $path );
				return is_array( $filtered ) ? $filtered : $data;
			}
		}

		return array();
	}

	/**
	 * Normalize schema values that may be JSON strings or arrays.
	 *
	 * @param mixed $value Value.
	 * @return mixed
	 */
	private static function normalize_json( $value ) {
		if ( is_string( $value ) ) {
			$decoded = json_decode( $value, true );
			return null !== $decoded ? $decoded : $value;
		}
		return $value;
	}
}
