<?php
/**
 * Shared content field catalog — compose named presets into field_schema.
 *
 * @package JOCBM
 *
 * Copyright (C) 2026 JayOnCode
 * License: GPL-2.0-or-later
 */

namespace JOCBM\Services;

defined( 'ABSPATH' ) || exit;

/**
 * Resolves `{ "$field": "header" }` entries into full field definitions.
 *
 * Ref options: `overrides`, `omit` (drop keys after expand), and peer props
 * (e.g. `styleOnly`) merged onto the primary field.
 *
 * Presets live under `templates/_shared/fields/{id}.json` (and extension roots).
 * Pro / extensions may call {@see register()} to add or override presets.
 */
final class FieldCatalog {

	/**
	 * Runtime / filter-registered presets (id => list of field defs).
	 *
	 * @var array<string, array<int, array<string, mixed>>>
	 */
	private static $registered = array();

	/**
	 * Disk cache (id => list of field defs|null).
	 *
	 * @var array<string, array<int, array<string, mixed>>|null>
	 */
	private static $disk_cache = array();

	/**
	 * Register or override a field preset (Pro / extensions).
	 *
	 * `$definition` may be:
	 * - a single field object
	 * - a list of field objects
	 * - `{ "fields": [ ... ] }`
	 *
	 * @param string               $id          Preset id (e.g. `header`, `buttons.plain`).
	 * @param array<string, mixed> $definition  Preset payload.
	 */
	public static function register( string $id, array $definition ): void {
		$id = self::normalize_id( $id );
		if ( '' === $id ) {
			return;
		}
		$fields = self::normalize_preset_payload( $definition );
		if ( empty( $fields ) ) {
			return;
		}
		self::$registered[ $id ] = $fields;

		/**
		 * Fires after a field preset is registered at runtime.
		 *
		 * @param string                         $id     Preset id.
		 * @param array<int, array<string, mixed>> $fields Normalized field list.
		 */
		do_action( 'jocbm_field_registered', $id, $fields );
	}

	/**
	 * Whether a preset exists (registered or on disk).
	 *
	 * @param string $id Preset id.
	 */
	public static function has( string $id ): bool {
		return null !== self::get( $id );
	}

	/**
	 * Get a preset as a list of field definitions (unexpanded $field refs inside).
	 *
	 * @param string $id Preset id.
	 * @return array<int, array<string, mixed>>|null
	 */
	public static function get( string $id ): ?array {
		$id = self::normalize_id( $id );
		if ( '' === $id ) {
			return null;
		}

		if ( isset( self::$registered[ $id ] ) ) {
			return self::$registered[ $id ];
		}

		$from_disk = self::load_disk_preset( $id );
		if ( null !== $from_disk ) {
			return $from_disk;
		}

		/**
		 * Filter a missing field catalog lookup (return a field list to provide it).
		 *
		 * @param array<int, array<string, mixed>>|null $fields Null when not found.
		 * @param string                                $id     Preset id.
		 */
		$filtered = apply_filters( 'jocbm_field_catalog_get', null, $id );
		if ( is_array( $filtered ) && ! empty( $filtered ) ) {
			return self::normalize_preset_payload( $filtered );
		}

		return null;
	}

	/**
	 * Expand a field_schema that may contain `$field` composition entries.
	 *
	 * @param mixed $schema Raw field_schema from block.json.
	 * @return array<int, array<string, mixed>> Fully expanded field list.
	 */
	public static function compose( $schema ): array {
		if ( ! is_array( $schema ) ) {
			return array();
		}

		$out = array();
		foreach ( $schema as $entry ) {
			foreach ( self::resolve_entry( $entry ) as $field ) {
				$out[] = self::compose_nested_fields( $field );
			}
		}
		return $out;
	}

	/**
	 * List known disk + registered preset ids (for docs / admin UI).
	 *
	 * @return array<int, string>
	 */
	public static function ids(): array {
		$ids = array_keys( self::$registered );
		foreach ( self::field_dirs() as $dir ) {
			$files = glob( trailingslashit( $dir ) . '*.json' );
			if ( ! is_array( $files ) ) {
				continue;
			}
			foreach ( $files as $file ) {
				$ids[] = self::normalize_id( basename( $file, '.json' ) );
			}
		}
		$ids = array_values( array_unique( array_filter( $ids ) ) );
		sort( $ids );
		return $ids;
	}

	/**
	 * Clear caches (tests / resync).
	 */
	public static function flush(): void {
		self::$disk_cache = array();
	}

	/**
	 * Resolve one schema entry to zero or more field objects.
	 *
	 * @param mixed $entry Field object or `{ "$field": "id", ... }`.
	 * @return array<int, array<string, mixed>>
	 */
	private static function resolve_entry( $entry ): array {
		if ( ! is_array( $entry ) ) {
			return array();
		}

		// Already a concrete field.
		if ( empty( $entry['$field'] ) ) {
			return array( $entry );
		}

		$id = self::normalize_id( (string) $entry['$field'] );
		$preset = self::get( $id );
		if ( null === $preset ) {
			// Soft-fail: keep a marker field so authors notice in UI.
			return array(
				array(
					'key'   => sanitize_key( str_replace( array( '.', '/' ), '-', $id ) ) ?: 'missing_field',
					'label' => sprintf(
						/* translators: %s: field catalog id */
						__( 'Missing field preset: %s', 'joc-blockora-morph' ),
						$id
					),
					'type'  => 'text',
				),
			);
		}

		// Peer props on the ref (except control keys).
		$peer = $entry;
		unset( $peer['$field'], $peer['overrides'], $peer['omit'] );

		$overrides = isset( $entry['overrides'] ) && is_array( $entry['overrides'] )
			? $entry['overrides']
			: array();

		$omit = array();
		if ( isset( $entry['omit'] ) && is_array( $entry['omit'] ) ) {
			foreach ( $entry['omit'] as $omit_key ) {
				if ( is_string( $omit_key ) && '' !== $omit_key ) {
					$omit[] = $omit_key;
				}
			}
		}

		$preset_keys = array();
		foreach ( $preset as $field ) {
			if ( is_array( $field ) && ! empty( $field['key'] ) ) {
				$preset_keys[] = (string) $field['key'];
			}
		}

		$keyed = array();
		$flat  = array();
		foreach ( $overrides as $okey => $ovalue ) {
			if ( in_array( (string) $okey, $preset_keys, true ) && is_array( $ovalue ) ) {
				$keyed[ (string) $okey ] = $ovalue;
			} else {
				$flat[ $okey ] = $ovalue;
			}
		}

		$expanded = array();
		foreach ( $preset as $field ) {
			if ( ! is_array( $field ) ) {
				continue;
			}
			// Resolve nested $field refs inside the preset itself.
			foreach ( self::resolve_entry( $field ) as $resolved ) {
				$expanded[] = $resolved;
			}
		}

		$count = count( $expanded );
		foreach ( $expanded as $i => $merged ) {
			// Peer + flat overrides apply to the primary (first) field, or all when single.
			if ( 0 === $i || 1 === $count ) {
				if ( ! empty( $peer ) ) {
					$merged = self::deep_merge( $merged, $peer );
				}
				if ( ! empty( $flat ) ) {
					$merged = self::deep_merge( $merged, $flat );
				}
			}
			$key = (string) ( $merged['key'] ?? '' );
			if ( $key && isset( $keyed[ $key ] ) ) {
				$merged = self::deep_merge( $merged, $keyed[ $key ] );
			}
			$expanded[ $i ] = $merged;
		}

		if ( ! empty( $omit ) ) {
			$expanded = array_values(
				array_filter(
					$expanded,
					static function ( $field ) use ( $omit ) {
						$key = (string) ( $field['key'] ?? '' );
						return '' === $key || ! in_array( $key, $omit, true );
					}
				)
			);
		}

		return $expanded;
	}

	/**
	 * Recurse into `fields` on repeaters so nested `$field` refs resolve.
	 *
	 * @param array<string, mixed> $field Field definition.
	 * @return array<string, mixed>
	 */
	private static function compose_nested_fields( array $field ): array {
		if ( isset( $field['fields'] ) && is_array( $field['fields'] ) ) {
			$field['fields'] = self::compose( $field['fields'] );
		}
		return $field;
	}

	/**
	 * Deep-merge associative arrays; numeric lists in override replace base.
	 *
	 * @param array<string, mixed> $base Base.
	 * @param array<string, mixed> $over Override.
	 * @return array<string, mixed>
	 */
	private static function deep_merge( array $base, array $over ): array {
		foreach ( $over as $key => $value ) {
			if ( is_array( $value ) && isset( $base[ $key ] ) && is_array( $base[ $key ] ) && self::is_assoc( $value ) && self::is_assoc( $base[ $key ] ) ) {
				$base[ $key ] = self::deep_merge( $base[ $key ], $value );
			} else {
				$base[ $key ] = $value;
			}
		}
		return $base;
	}

	/**
	 * @param array<mixed> $arr Array.
	 */
	private static function is_assoc( array $arr ): bool {
		if ( array() === $arr ) {
			return false;
		}
		return array_keys( $arr ) !== range( 0, count( $arr ) - 1 );
	}

	/**
	 * @param string $id Raw id.
	 */
	private static function normalize_id( string $id ): string {
		$id = strtolower( trim( $id ) );
		$id = str_replace( array( '\\', ' ' ), array( '/', '-' ), $id );
		// Allow dots for plain variants: header.plain
		$id = preg_replace( '/[^a-z0-9._\\/-]/', '', $id ) ?? '';
		return trim( $id, '/' );
	}

	/**
	 * @param array<string, mixed>|array<int, mixed> $definition Payload.
	 * @return array<int, array<string, mixed>>
	 */
	private static function normalize_preset_payload( array $definition ): array {
		if ( isset( $definition['fields'] ) && is_array( $definition['fields'] ) ) {
			$definition = $definition['fields'];
		}

		// Single field object.
		if ( isset( $definition['type'] ) || isset( $definition['key'] ) || isset( $definition['$field'] ) ) {
			return array( $definition );
		}

		// List of fields.
		$out = array();
		foreach ( $definition as $item ) {
			if ( is_array( $item ) ) {
				$out[] = $item;
			}
		}
		return $out;
	}

	/**
	 * @param string $id Preset id.
	 * @return array<int, array<string, mixed>>|null
	 */
	private static function load_disk_preset( string $id ): ?array {
		if ( array_key_exists( $id, self::$disk_cache ) ) {
			return self::$disk_cache[ $id ];
		}

		$basename = $id . '.json';
		foreach ( self::field_dirs() as $dir ) {
			$path = trailingslashit( $dir ) . $basename;
			if ( ! is_readable( $path ) ) {
				continue;
			}
			$data = json_decode( (string) file_get_contents( $path ), true );
			if ( ! is_array( $data ) ) {
				continue;
			}
			$fields = self::normalize_preset_payload( $data );
			/**
			 * Filter a field preset loaded from disk.
			 *
			 * @param array<int, array<string, mixed>> $fields Fields.
			 * @param string                            $id     Preset id.
			 * @param string                            $path   File path.
			 */
			$filtered = apply_filters( 'jocbm_field_preset', $fields, $id, $path );
			$fields   = is_array( $filtered ) ? self::normalize_preset_payload( $filtered ) : $fields;
			self::$disk_cache[ $id ] = $fields;
			return $fields;
		}

		self::$disk_cache[ $id ] = null;
		return null;
	}

	/**
	 * Absolute directories that may contain `_shared/fields/*.json`.
	 *
	 * @return array<int, string>
	 */
	private static function field_dirs(): array {
		$dirs = array();
		foreach ( TemplateLibrary::dirs() as $root ) {
			$dirs[] = trailingslashit( $root ) . '_shared/fields';
		}
		/**
		 * Extra field-catalog directories (absolute paths).
		 *
		 * @param array<int, string> $dirs Directories.
		 */
		$extra = apply_filters( 'jocbm_field_catalog_dirs', array() );
		if ( is_array( $extra ) ) {
			foreach ( $extra as $dir ) {
				if ( is_string( $dir ) && '' !== $dir ) {
					$dirs[] = untrailingslashit( $dir );
				}
			}
		}
		return array_values( array_unique( $dirs ) );
	}
}
