<?php

namespace Brandy\ThemeJson\CustomSelectors;

use Brandy\Traits\SingletonTrait;

defined( 'ABSPATH' ) || exit;

class Caller {
	use SingletonTrait;

	const SUPPORTED_STYLE_GROUPS = array( 'typography', 'spacing', 'color', 'border', 'shadow', 'dimensions' );
	const PSEUDO_STATE_KEYS      = array( ':hover', ':focus', ':active' );

	// AbstractNicheSetup and VariationHoverCompat both register on this filter at the
	// default priority (10); run after them so appended css lands after niche/variation merges.
	const HOOK_PRIORITY = 20;

	private $compiled_css_by_block;
	private $compiled_css_by_variation;

	protected function __construct() {
		add_filter( 'wp_theme_json_data_theme', array( $this, 'apply_compiled_css' ), self::HOOK_PRIORITY );
	}

	/**
	 * Writes compiled selector CSS onto styles.blocks.<block>.css and
	 * styles.blocks.<block>.variations.<name>.css.
	 *
	 * `.selectors` itself is unreadable here — WP_Theme_JSON_Data's constructor
	 * strips unknown schema keys before this filter fires — so the read happens
	 * separately, straight off disk. See get_compiled_css_by_block() /
	 * get_compiled_css_by_variation().
	 *
	 * This filter fires AFTER WP_Theme_JSON_Resolver::inject_variations_from_block_style_variation_files()
	 * has already merged block style variation files (styles/blocks/*.json)
	 * into $data['styles']['blocks'][$block]['variations'][$name] — confirmed
	 * via class-wp-theme-json-resolver.php:266-292 — so that path already
	 * exists by the time we append to it here.
	 */
	public function apply_compiled_css( $theme_json ) {
		$compiled           = $this->get_compiled_css_by_block();
		$compiled_variation = $this->get_compiled_css_by_variation();

		if ( empty( $compiled ) && empty( $compiled_variation ) ) {
			return $theme_json;
		}

		$data = $theme_json->get_data();

		foreach ( $compiled as $block_name => $css ) {
			if ( '' === $css ) {
				continue;
			}

			$existing_css = $data['styles']['blocks'][ $block_name ]['css'] ?? '';

			$data['styles']['blocks'][ $block_name ]['css'] = $existing_css . $css;

			unset( $data['styles']['blocks'][ $block_name ]['selectors'] );
		}

		foreach ( $compiled_variation as $block_name => $variations ) {
			foreach ( $variations as $variation_name => $css ) {
				if ( '' === $css ) {
					continue;
				}

				$existing_css = $data['styles']['blocks'][ $block_name ]['variations'][ $variation_name ]['css'] ?? '';

				$data['styles']['blocks'][ $block_name ]['variations'][ $variation_name ]['css'] = $existing_css . $css;
			}
		}

		return $theme_json->update_with( $data );
	}

	/**
	 * Reads theme.json + the currently active niche's niche.json (if any) off
	 * disk and compiles `.selectors` per block, memoized for the rest of the
	 * request. A block declared in both sources gets both compiled outputs
	 * concatenated (theme.json first, niche second).
	 */
	private function get_compiled_css_by_block() {
		if ( null !== $this->compiled_css_by_block ) {
			return $this->compiled_css_by_block;
		}

		$this->compiled_css_by_block = array();

		$sources   = array( $this->read_theme_json_from_disk() );
		$niche_data = $this->read_current_niche_json_from_disk();

		if ( ! empty( $niche_data ) ) {
			$sources[] = $niche_data;
		}

		foreach ( $sources as $source ) {
			$blocks = $source['styles']['blocks'] ?? array();

			if ( ! is_array( $blocks ) ) {
				continue;
			}

			foreach ( $blocks as $block_name => $block_style_node ) {
				if ( ! is_array( $block_style_node ) || empty( $block_style_node['selectors'] ) || ! is_array( $block_style_node['selectors'] ) ) {
					continue;
				}

				$css = $this->compile_selectors_for_block( $block_style_node['selectors'] );

				if ( '' === $css ) {
					continue;
				}

				$existing                                     = $this->compiled_css_by_block[ $block_name ] ?? '';
				$this->compiled_css_by_block[ $block_name ] = $existing . $css;
			}
		}

		return $this->compiled_css_by_block;
	}

	/**
	 * Same file WP_Theme_JSON_Resolver::get_theme_data() reads (child-theme aware).
	 */
	private function read_theme_json_from_disk() {
		$theme_json_path = wp_get_theme()->get_file_path( 'theme.json' );

		if ( ! $theme_json_path ) {
			return array();
		}

		return $this->read_json_file( $theme_json_path );
	}

	/**
	 * Reads the CURRENTLY ACTIVE niche's own JSON file directly off disk
	 * (same reason as read_theme_json_from_disk — AbstractNicheSetup::update_theme_json()
	 * merges niche.json via WP_Theme_JSON::merge(), which sanitizes it, stripping
	 * `.selectors` before we could ever read it that way).
	 *
	 * Niches are mutually exclusive (brandy_is_current_niche()), so only the
	 * active one's file is read. Resolved via the shared NicheLoader registry
	 * (inc/Niches/NicheLoader.php), which both the theme's own niches and
	 * Brandy-Starter-Sites' 17 niches register into via the `brandy_sites_onload`
	 * hook (confirmed: SitesLoader.php calls $loader_instance->register_niche()
	 * for each of its niches).
	 *
	 * JSON_FILE is `protected` on AbstractNicheSetup, inaccessible here — but
	 * every niche class (verified across all 19: 2 theme + 17 plugin) defines
	 * it as `ROOT_PATH . '/niche.json'`, and ROOT_PATH is `public`, so we
	 * derive the same path from the public constant instead.
	 */
	private function read_current_niche_json_from_disk() {
		if ( ! function_exists( 'brandy_current_niche' ) || ! class_exists( '\Brandy\Niches\NicheLoader' ) ) {
			return array();
		}

		$niche_id = brandy_current_niche();

		if ( empty( $niche_id ) || ! is_string( $niche_id ) ) {
			return array();
		}

		$niche_instance = \Brandy\Niches\NicheLoader::get_instance()->get_niche_instance( $niche_id );

		if ( ! $niche_instance ) {
			return array();
		}

		$niche_class = get_class( $niche_instance );

		if ( ! defined( "{$niche_class}::ROOT_PATH" ) || empty( $niche_class::ROOT_PATH ) ) {
			return array();
		}

		return $this->read_json_file( $niche_class::ROOT_PATH . '/niche.json' );
	}

	/**
	 * Compiles `.selectors` from block-style-variation JSON partials
	 * (styles/*.json, incl. styles/blocks/*.json — same directories
	 * WP_Theme_JSON_Resolver::get_style_variations('block') scans), memoized
	 * for the rest of the request. Returns [ blockType => [ variationName => css ] ].
	 */
	private function get_compiled_css_by_variation() {
		if ( null !== $this->compiled_css_by_variation ) {
			return $this->compiled_css_by_variation;
		}

		$this->compiled_css_by_variation = array();

		foreach ( $this->read_block_style_variation_files() as $variation_file ) {
			$css = $this->compile_selectors_for_block( $variation_file['selectors'] );

			if ( '' === $css ) {
				continue;
			}

			foreach ( $variation_file['block_types'] as $block_type ) {
				if ( ! is_string( $block_type ) ) {
					continue;
				}

				$this->compiled_css_by_variation[ $block_type ][ $variation_file['variation_name'] ] = $css;
			}
		}

		return $this->compiled_css_by_variation;
	}

	/**
	 * Reads block-style-variation JSON partials directly off disk (same
	 * child/parent directories + basename dedup as
	 * WP_Theme_JSON_Resolver::get_style_variations(), verified against
	 * class-wp-theme-json-resolver.php:802-833) and returns only the ones
	 * with a non-empty `styles.selectors` — bypasses WP_Theme_JSON's schema
	 * sanitize, same reason as read_theme_json_from_disk().
	 *
	 * @return array[] Each: [ 'block_types' => string[], 'variation_name' => string, 'selectors' => array ].
	 */
	private function read_block_style_variation_files() {
		$files = array();

		$base_directory = get_stylesheet_directory() . '/styles';
		if ( is_dir( $base_directory ) ) {
			foreach ( $this->find_json_files( $base_directory ) as $path ) {
				$files[ basename( $path ) ] = $path;
			}
		}

		$template_directory = get_template_directory() . '/styles';
		if ( is_dir( $template_directory ) && $template_directory !== $base_directory ) {
			foreach ( $this->find_json_files( $template_directory ) as $path ) {
				// Child theme file with the same basename wins — matches core's dedup rule.
				if ( ! isset( $files[ basename( $path ) ] ) ) {
					$files[ basename( $path ) ] = $path;
				}
			}
		}

		$variation_files = array();

		foreach ( $files as $path ) {
			$decoded = $this->read_json_file( $path );

			if ( ! is_array( $decoded ) || empty( $decoded['blockTypes'] ) || ! is_array( $decoded['blockTypes'] ) || empty( $decoded['styles']['selectors'] ) || ! is_array( $decoded['styles']['selectors'] ) ) {
				continue;
			}

			$title          = ! empty( $decoded['title'] ) ? $decoded['title'] : basename( $path, '.json' );
			$variation_name = ! empty( $decoded['slug'] ) ? $decoded['slug'] : ( function_exists( '_wp_to_kebab_case' ) ? _wp_to_kebab_case( $title ) : sanitize_title( $title ) );

			$variation_files[] = array(
				'block_types'    => $decoded['blockTypes'],
				'variation_name' => $variation_name,
				'selectors'      => $decoded['styles']['selectors'],
			);
		}

		return $variation_files;
	}

	private function find_json_files( $directory ) {
		$iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $directory ) );
		$matches  = new \RegexIterator( $iterator, '/^.+\.json$/i', \RecursiveRegexIterator::GET_MATCH );

		$paths = array();

		foreach ( $matches as $match ) {
			$paths[] = $match[0];
		}

		return $paths;
	}

	private function read_json_file( $path ) {
		if ( ! file_exists( $path ) ) {
			return array();
		}

		$raw = file_get_contents( $path );

		if ( false === $raw ) {
			return array();
		}

		$decoded = json_decode( $raw, true );

		if ( ! is_array( $decoded ) || JSON_ERROR_NONE !== json_last_error() ) {
			return array();
		}

		return $decoded;
	}

	private function compile_selectors_for_block( $selectors ) {
		if ( ! function_exists( 'wp_style_engine_get_styles' ) ) {
			return '';
		}

		$css = '';

		foreach ( $selectors as $selector_fragment => $style_object ) {
			if ( ! is_string( $selector_fragment ) || ! is_array( $style_object ) ) {
				continue;
			}

			if ( ! $this->is_valid_selector_fragment( $selector_fragment ) ) {
				$this->log_invalid_selector( $selector_fragment );
				continue;
			}

			$css .= $this->compile_style_object( $style_object, $selector_fragment, true );
		}

		return $css;
	}

	/**
	 * Compiles base style groups, raw `css`, pseudo-states (recursively, so
	 * they get the same `css`/group handling), and (fragment-level only)
	 * nested `elements`. Element style objects don't recurse into `elements`
	 * again — core theme.json never nests elements within elements either.
	 */
	private function compile_style_object( $style_object, $selector, $allow_elements = false ) {
		$base_styles   = array();
		$pseudo_states = array();
		$elements      = array();
		$raw_css       = '';

		foreach ( $style_object as $key => $value ) {
			if ( in_array( $key, self::PSEUDO_STATE_KEYS, true ) ) {
				if ( is_array( $value ) ) {
					$pseudo_states[ $key ] = $value;
				}
				continue;
			}

			if ( 'css' === $key && is_string( $value ) ) {
				$raw_css = $value;
				continue;
			}

			if ( $allow_elements && 'elements' === $key && is_array( $value ) ) {
				$elements = $value;
				continue;
			}

			if ( in_array( $key, self::SUPPORTED_STYLE_GROUPS, true ) ) {
				$base_styles[ $key ] = $value;
			}

			// Any other key (outline, filter, unrecognized) is silently dropped.
		}

		$css = '';

		if ( ! empty( $base_styles ) ) {
			$css .= $this->compile_declarations( $base_styles, $selector );
		}

		// Same `&`-means-"this selector" convention as the existing top-level
		// `css` property; core resolves the outer `&` later when it processes
		// the block's final compiled css string.
		if ( '' !== $raw_css ) {
			$css .= str_replace( '&', $selector, $raw_css );
		}

		foreach ( $pseudo_states as $pseudo_key => $pseudo_style_object ) {
			$css .= $this->compile_style_object( $pseudo_style_object, $selector . $pseudo_key, false );
		}

		if ( $allow_elements && ! empty( $elements ) && class_exists( 'WP_Theme_JSON' ) ) {
			foreach ( $elements as $element_name => $element_style_object ) {
				if ( ! is_string( $element_name ) || ! is_array( $element_style_object ) || ! isset( \WP_Theme_JSON::ELEMENTS[ $element_name ] ) ) {
					continue;
				}

				$element_selector = $this->combine_selector_with_element( $selector, \WP_Theme_JSON::ELEMENTS[ $element_name ] );

				$css .= $this->compile_style_object( $element_style_object, $element_selector, false );
			}
		}

		return $css;
	}

	/**
	 * Prepends $selector as a descendant combinator to EACH comma-separated
	 * part of $element_selector — WP_Theme_JSON::ELEMENTS selectors are
	 * sometimes comma lists (button/heading/caption); naive concatenation
	 * would leave only the first part correctly scoped.
	 */
	private function combine_selector_with_element( $selector, $element_selector ) {
		$parts = array_map(
			function ( $part ) use ( $selector ) {
				return $selector . ' ' . trim( $part );
			},
			explode( ',', $element_selector )
		);

		return implode( ', ', $parts );
	}

	private function compile_declarations( $style_object, $selector ) {
		$result = wp_style_engine_get_styles( $style_object, array( 'selector' => $selector ) );

		if ( empty( $result['css'] ) || ! is_string( $result['css'] ) ) {
			return '';
		}

		return $result['css'];
	}

	/**
	 * Defense-in-depth, not a full CSS parser: blocks the selector fragments most
	 * likely to break scope or corrupt output. theme.json remains a
	 * developer-trusted, filesystem-authored input.
	 */
	private function is_valid_selector_fragment( $fragment ) {
		$fragment = trim( $fragment );

		if ( '' === $fragment || '&' !== $fragment[0] ) {
			return false;
		}

		if ( false !== strpbrk( $fragment, '<>,' ) ) {
			return false;
		}

		$quote_count = substr_count( $fragment, '"' ) + substr_count( $fragment, "'" );

		if ( 0 !== $quote_count % 2 ) {
			return false;
		}

		return true;
	}

	private function log_invalid_selector( $fragment ) {
		if ( ! defined( 'WP_DEBUG' ) || ! WP_DEBUG ) {
			return;
		}

		error_log( sprintf( 'Brandy CustomSelectors: skipped invalid theme.json selector fragment "%s" (must start with "&", no "<", ">", ",", or unbalanced quotes).', $fragment ) );
	}
}

Caller::get_instance();
