<?php
/**
 * The paid tier gate.
 *
 * @package MenuVerse
 */

namespace WPXero\MenuVerse;

defined( 'ABSPATH' ) || exit;

/**
 * Decides which settings take effect, and enforces that decision in one place.
 *
 * The rule this class exists to keep: **a gate never touches stored settings.**
 * A licence that lapses, or a site cloned to staging without one, must not cost
 * anybody the configuration they built — the same promise `COMING_SOON` already
 * makes for a module that has not shipped. So nothing here runs on save, on
 * read, or anywhere near the option. It runs on the render path only, replacing
 * gated values with their schema defaults for the length of one render, and the
 * stored value comes back the moment the licence does.
 *
 * That also settles what the dashboard shows: the real stored values, because
 * the dashboard reads settings rather than rendering them. Someone whose licence
 * lapsed sees their own configuration with a lock on it, not a form silently
 * reset to defaults — and pressing Save writes back what they were already
 * looking at rather than quietly flattening it.
 *
 * A property opts in with `ui.pro` in its module's schema, so gating is declared
 * beside the setting it gates and needs no list to be kept in step here.
 */
final class Pro {

	/**
	 * Whether the paid tier is active for this request.
	 *
	 * Resolved once. A licensing SDK answers over the network or out of a
	 * transient, and the render path asks per module per render.
	 *
	 * @var bool|null
	 */
	private static $active = null;

	/**
	 * Gated keys per schema, keyed by module id and locale.
	 *
	 * Reading a schema builds a large translated array and the render path
	 * would otherwise walk it once per render.
	 *
	 * @var array<string,string[]>
	 */
	private static $gated = array();

	/**
	 * Registers the render time gate.
	 *
	 * @return void
	 */
	public function boot() {
		// `menuverse_render_settings` is the documented place for server side
		// gating: it fires inside `Abstract_Module::output()` before any markup
		// exists, and every render path goes through it. Doing this anywhere
		// else would mean four gates instead of one.
		add_filter( 'menuverse_render_settings', array( $this, 'apply' ), 5, 2 );
	}

	/**
	 * Reports whether the paid tier is active.
	 *
	 * @return bool
	 */
	public static function is_active() {
		if ( null === self::$active ) {
			/**
			 * Filters whether MenuVerse Pro is active.
			 *
			 * This is the single seam a licensing SDK hooks. Nothing else in
			 * the plugin knows which vendor sells the licence, which is what
			 * lets the free plugin ship without one.
			 *
			 * @since 1.0.0
			 *
			 * @param bool $active Whether the paid tier is active.
			 */
			self::$active = (bool) apply_filters( 'menuverse_is_pro', false );
		}

		return self::$active;
	}

	/**
	 * Forgets the resolved answer.
	 *
	 * A licence can be activated without reloading the page, and the tests
	 * switch tiers within one process.
	 *
	 * @return void
	 */
	public static function flush() {
		self::$active = null;
		self::$gated  = array();
	}

	/**
	 * Replaces gated values with their schema defaults for one render.
	 *
	 * @param array  $settings  Resolved settings.
	 * @param string $module_id Module identifier.
	 * @return array
	 */
	public function apply( $settings, $module_id ) {
		if ( ! is_array( $settings ) || self::is_active() ) {
			return $settings;
		}

		$schema = Plugin::instance()->modules()->schema( $module_id );

		if ( empty( $schema['properties'] ) ) {
			return $settings;
		}

		$gated = self::gated_keys( $module_id, $schema );

		foreach ( $gated['own'] as $key ) {
			if ( ! array_key_exists( $key, $settings ) ) {
				continue;
			}

			$settings[ $key ] = self::fallback( (array) $schema['properties'][ $key ] );
		}

		// A setting can be free while some of its answers are not — one
		// colour is free, a picture behind the bar is not. Gating the whole
		// setting would take the free answer away with the paid one, so only
		// the value is replaced, and only when it is one of the named ones.
		foreach ( $gated['options'] as $key => $options ) {
			if ( ! isset( $settings[ $key ] ) || ! in_array( $settings[ $key ], $options, true ) ) {
				continue;
			}

			$settings[ $key ] = self::fallback( (array) $schema['properties'][ $key ] );
		}

		// How many rows of a list the free tier renders. The rest stay in the
		// settings untouched — the same promise every other gate makes — and
		// come back the moment a licence does.
		foreach ( $gated['caps'] as $key => $cap ) {
			if ( empty( $settings[ $key ] ) || ! is_array( $settings[ $key ] ) ) {
				continue;
			}

			$settings[ $key ] = array_slice( $settings[ $key ], 0, $cap );
		}

		// Repeater rows carry settings of their own, and per item styling is
		// exactly the sort of thing worth paying for — so a gate that only
		// walked the top level could not express it. Runs after the cap
		// above, so the rows it walks are the ones that will actually render.
		$lists = array_unique(
			array_merge( array_keys( $gated['rows'] ), array_keys( $gated['row_options'] ) )
		);

		foreach ( $lists as $list ) {
			if ( empty( $settings[ $list ] ) || ! is_array( $settings[ $list ] ) ) {
				continue;
			}

			$keys       = isset( $gated['rows'][ $list ] ) ? $gated['rows'][ $list ] : array();
			$row_schema = $schema['properties'][ $list ]['items']['properties'];

			$row_options = isset( $gated['row_options'][ $list ] )
				? $gated['row_options'][ $list ]
				: array();

			foreach ( $settings[ $list ] as $index => $row ) {
				if ( ! is_array( $row ) ) {
					continue;
				}

				foreach ( $keys as $key ) {
					if ( array_key_exists( $key, $row ) ) {
						$row[ $key ] = self::fallback( (array) $row_schema[ $key ] );
					}
				}

				// The same "free question, paid answer" rule as above, one row
				// at a time: an item may always choose what it does, and only
				// some of the things it can do need a licence.
				foreach ( $row_options as $key => $options ) {
					if ( isset( $row[ $key ] ) && in_array( $row[ $key ], $options, true ) ) {
						$row[ $key ] = self::fallback( (array) $row_schema[ $key ] );
					}
				}

				$settings[ $list ][ $index ] = $row;
			}
		}

		return $settings;
	}

	/**
	 * Returns the value a gated property falls back to.
	 *
	 * The schema default rather than nothing at all: every module reads its
	 * settings as though the schema had been applied, so a missing key would
	 * be a notice rather than a feature that is switched off.
	 *
	 * @param array $property Schema property.
	 * @return mixed
	 */
	private static function fallback( array $property ) {
		return array_key_exists( 'default', $property ) ? $property['default'] : null;
	}

	/**
	 * Lists the properties a schema gates.
	 *
	 * @param string $module_id Module identifier.
	 * @param array  $schema    Module schema.
	 * @return array{own:string[],rows:array<string,string[]>}
	 */
	private static function gated_keys( $module_id, array $schema ) {
		$cache_key = $module_id . '|' . determine_locale();

		if ( isset( self::$gated[ $cache_key ] ) ) {
			return self::$gated[ $cache_key ];
		}

		$found = array(
			'own'         => array(),
			'rows'        => array(),
			'options'     => array(),
			'row_options' => array(),
			'caps'        => array(),
		);

		foreach ( $schema['properties'] as $key => $property ) {
			$property = (array) $property;

			if ( self::gates( $property ) ) {
				$found['own'][] = $key;
			}

			if ( ! empty( $property['ui']['proOptions'] ) && is_array( $property['ui']['proOptions'] ) ) {
				$found['options'][ $key ] = array_values( $property['ui']['proOptions'] );
			}

			if ( isset( $property['ui']['proMaxItems'] ) ) {
				$found['caps'][ $key ] = max( 0, (int) $property['ui']['proMaxItems'] );
			}

			if ( empty( $property['items']['properties'] ) ) {
				continue;
			}

			foreach ( $property['items']['properties'] as $row_key => $row_property ) {
				$row_property = (array) $row_property;

				if ( self::gates( $row_property ) ) {
					$found['rows'][ $key ][] = $row_key;
				}

				if ( ! empty( $row_property['ui']['proOptions'] ) && is_array( $row_property['ui']['proOptions'] ) ) {
					$found['row_options'][ $key ][ $row_key ] = array_values( $row_property['ui']['proOptions'] );
				}
			}
		}

		self::$gated[ $cache_key ] = $found;

		return $found;
	}

	/**
	 * Reports whether a schema property is gated.
	 *
	 * Read by the REST layer and the dashboard, which describe a setting
	 * without rendering it. Answers only for the property as a whole: a
	 * setting whose *answers* are partly paid is free to change, so it is not
	 * reported here — see `ui.proOptions`.
	 *
	 * @param array $property Schema property.
	 * @return bool
	 */
	public static function gates( array $property ) {
		return ! empty( $property['ui']['pro'] );
	}
}
