<?php
/**
 * Single source of truth for the Free / Pro / Agency / Unlimited capability
 * matrix.
 *
 * Every gated feature in the plugin goes through this class. To open a
 * feature up to a different tier (or to free), edit the FEATURES array
 * below — that is the *only* place to change. The runtime checks
 * (`can()`, `cap()`) read this matrix once per request, ask Freemius
 * what plan the buyer is on, and answer yes/no.
 *
 * Every value is filterable, so a buyer who wants to extend or override
 * a gate from their own code can do so without forking the plugin.
 *
 * @package FindAstra
 */

defined( 'ABSPATH' ) || exit;

final class FindAstra_License {

	/**
	 * Plan-tier identifiers — these correspond to the slugs configured in the
	 * Freemius dashboard. "free" is the implicit base tier (no license active).
	 */
	public const PLAN_FREE      = 'free';
	public const PLAN_PRO       = 'pro';
	public const PLAN_AGENCY    = 'agency';
	public const PLAN_UNLIMITED = 'unlimited';

	/**
	 * Ordered list of plans, lowest to highest. Used by `meets_tier()` to
	 * answer "is the current plan at least Pro?" without hard-coding plan
	 * comparisons all over the codebase.
	 */
	private const PLAN_RANK = array(
		self::PLAN_FREE      => 0,
		self::PLAN_PRO       => 1,
		self::PLAN_AGENCY    => 2,
		self::PLAN_UNLIMITED => 3,
	);

	/**
	 * THE CAPABILITY MATRIX.
	 *
	 * Each feature key maps to the *minimum* plan that unlocks it. Anything
	 * at or above that plan tier passes the check. Capabilities here are
	 * binary (yes/no) — for numeric or string thresholds (product caps,
	 * analytics windows) see the LIMITS array below.
	 *
	 * To change the free/paid line for a feature, edit one cell here. Code
	 * sites that gate on this feature never need to change.
	 */
	private const FEATURES = array(
		// --- Search engines ----------------------------------------------------
		// The free build ships only the Local (in-browser) engine. The OpenAI and
		// Hugging Face providers live in `__premium_only` files that are absent
		// from the free build entirely, so these keys gate the engines in the
		// PAID build (where an unlicensed install resolves to "free"); in the
		// free build the provider classes simply do not exist. Either way, the
		// free build never locks a feature it actually contains.
		'engine.local'           => self::PLAN_FREE,
		'engine.openai'          => self::PLAN_PRO,
		'engine.huggingface'     => self::PLAN_PRO,
	);

	/**
	 * Numeric / string thresholds, plan-keyed. Empty today: the free build is
	 * fully functional for everything it ships — no product cap, no analytics
	 * window, no quota of any kind. Paid-only capabilities (extra engines,
	 * multilingual indexing) are delivered as code that is absent from the free
	 * build, not as limits applied to shared code.
	 */
	private const LIMITS = array();

	/**
	 * Per-request memoisation. findastra_fs() does a fair amount of work the first
	 * time it's called.
	 */
	private static ?string $current_plan_cache = null;

	/**
	 * Resolve the currently active plan slug. Falls back to PLAN_FREE when
	 * Freemius isn't loaded (e.g. during PHPUnit) or the buyer has no
	 * active license.
	 *
	 * The result is filterable so buyer code (and tests) can force a plan
	 * without going through Freemius — useful for staging environments.
	 */
	public static function current_plan(): string {
		if ( null !== self::$current_plan_cache ) {
			return self::$current_plan_cache;
		}

		$plan = self::PLAN_FREE;

		if ( function_exists( 'findastra_fs' ) ) {
			$fs = findastra_fs();
			if ( is_object( $fs ) && method_exists( $fs, 'can_use_premium_code' ) && $fs->can_use_premium_code() ) {
				// Walk plans high-to-low so we report the strongest match. Freemius'
				// is_plan() with the "exact" flag avoids inheriting up the chain.
				foreach ( array( self::PLAN_UNLIMITED, self::PLAN_AGENCY, self::PLAN_PRO ) as $candidate ) {
					if ( method_exists( $fs, 'is_plan' ) && $fs->is_plan( $candidate, true ) ) {
						$plan = $candidate;
						break;
					}
				}
				// If we couldn't pin a specific slug but a valid license is active,
				// treat it as Pro (the lowest paid plan) — protects against a typo
				// in the dashboard plan slugs masking a real subscription.
				if ( self::PLAN_FREE === $plan ) {
					$plan = self::PLAN_PRO;
				}
			}
		}

		$plan = (string) apply_filters( 'findastra_current_plan', $plan );
		if ( ! isset( self::PLAN_RANK[ $plan ] ) ) {
			$plan = self::PLAN_FREE;
		}

		self::$current_plan_cache = $plan;
		return $plan;
	}

	/**
	 * Is the currently active plan at or above the named tier?
	 *
	 *   FindAstra_License::meets_tier( FindAstra_License::PLAN_PRO )
	 */
	public static function meets_tier( string $required_plan ): bool {
		$current = self::current_plan();
		$req     = self::PLAN_RANK[ $required_plan ] ?? PHP_INT_MAX;
		$cur     = self::PLAN_RANK[ $current ]       ?? 0;
		return $cur >= $req;
	}

	/**
	 * Capability check. Returns true when the buyer's plan is at or above
	 * the tier required for the named feature. Unknown feature keys default
	 * to "free" (visible to everyone) — fail-open by design so a typo in a
	 * feature key never *breaks* free-tier search.
	 *
	 * Filter `findastra_can_{$feature}` to override per feature, or
	 * `findastra_can` for a single coarse hook receiving (feature, allowed).
	 */
	public static function can( string $feature ): bool {
		$required = self::FEATURES[ $feature ] ?? self::PLAN_FREE;
		$allowed  = self::meets_tier( $required );

		$slug    = str_replace( '.', '_', $feature );
		$allowed = (bool) apply_filters( "findastra_can_{$slug}", $allowed, $feature );
		$allowed = (bool) apply_filters( 'findastra_can', $allowed, $feature );

		return $allowed;
	}

	/**
	 * Limit lookup. Returns the value configured for the buyer's current
	 * plan. Falls back to the free-tier value when a plan column is
	 * missing. Filterable via `findastra_limit_{$limit}`.
	 *
	 * @param string $limit   Limit key (e.g. 'index.product_cap').
	 * @param mixed  $default Returned when the limit isn't defined.
	 * @return mixed
	 */
	public static function cap( string $limit, mixed $default = null ): mixed {
		if ( ! isset( self::LIMITS[ $limit ] ) ) {
			return $default;
		}

		$plan   = self::current_plan();
		$values = self::LIMITS[ $limit ];
		$value  = array_key_exists( $plan, $values )
			? $values[ $plan ]
			: ( $values[ self::PLAN_FREE ] ?? $default );

		$slug  = str_replace( '.', '_', $limit );
		$value = apply_filters( "findastra_limit_{$slug}", $value, $plan );

		return $value;
	}

	/**
	 * Convenience: is the buyer on the free tier? Useful for sprinkling an
	 * upsell into admin UI without naming a specific paid plan.
	 */
	public static function is_free(): bool {
		return self::current_plan() === self::PLAN_FREE;
	}

	/**
	 * The Freemius-generated checkout URL for upgrading the current install.
	 * Returns an empty string when Freemius isn't available (CI, tests).
	 */
	public static function upgrade_url( ?string $plan = null ): string {
		if ( ! function_exists( 'findastra_fs' ) ) {
			return '';
		}
		$fs = findastra_fs();
		if ( ! is_object( $fs ) || ! method_exists( $fs, 'get_upgrade_url' ) ) {
			return '';
		}
		// findastra_fs()->get_upgrade_url() handles the buyer's site + nonce + UTM tags.
		// It also handles the case where the buyer is already on a plan and the
		// link should point at the pricing page rather than the activation form.
		return (string) $fs->get_upgrade_url();
	}

	/**
	 * Snapshot of the licence state for serialising into window.FindAstraConfig
	 * so frontend code can decide whether to show "Pro feature" overlays.
	 *
	 * @return array{plan:string,is_free:bool,upgrade_url:string,can:array<string,bool>}
	 */
	public static function js_snapshot(): array {
		$capabilities = array();
		foreach ( array_keys( self::FEATURES ) as $feature ) {
			$capabilities[ $feature ] = self::can( $feature );
		}
		return array(
			'plan'        => self::current_plan(),
			'is_free'     => self::is_free(),
			'upgrade_url' => self::upgrade_url(),
			'can'         => $capabilities,
		);
	}

	/**
	 * Reset the memoised plan. Tests reach for this; production code should
	 * not — the plan is stable for the lifetime of a request.
	 *
	 * @internal
	 */
	public static function flush_cache(): void {
		self::$current_plan_cache = null;
	}
}
