<?php
namespace TitanCart\Core;

defined( 'ABSPATH' ) || exit;

/**
 * Edition resolver + Lite/Pro capability gate.
 *
 * TitanCart ships as a single codebase that produces TWO packages from one tree
 * (see docs/TITANCART-LITE-SCOPING.md):
 *
 *   - titancart-lite (WordPress.org)  — free discovery funnel. The build pins
 *       the TITANCART_EDITION constant to 'lite' and sets TITANCART_NO_PHONEHOME, excludes the
 *       paid Extensions/ and Pro-only dirs, never consults a license server.
 *
 *       ⚠️ Do NOT spell that constant here as a define() call, not even inside
 *       backticks. The Pro build asserts that a define() of TITANCART_EDITION appears
 *       NOWHERE in the staged tree, so a Pro package can never ship pinned to
 *       Lite. That assertion scans every staged .php/.js/.txt/.md file —
 *       comments included — so a docblock that writes the call out verbatim
 *       fails the Pro build. A load-bearing token must never be spelled in
 *       prose. See sessions/CURRENT.md S355 (the same trap, with the strip
 *       markers) and build/manifest.php → pro.absent_text for the pattern.
 *   - titancart (Pro / direct + marketplace) — full product. TITANCART_EDITION unset;
 *       edition resolves from the core license (and today, from entitlement).
 *
 * This class is the single source of truth for:
 *   1. Which EDITION this install is running   → resolve() / is_pro()
 *   2. Which core CAPABILITIES are Pro-only     → the LOCKED §4 map
 *   3. The reusable enforcement PRIMITIVES the three seams use (REST
 *      permission verdicts, the admin-SPA summary feed, and the storefront
 *      render wrapper — see the section banners below for each).
 *
 * ── Resolution order (first definitive answer wins) ──────────────────────────
 *   1. Build constant TITANCART_EDITION ('lite'|'pro')  — baked per package, OVERRIDES.
 *      Lets the free WP.org package hard-pin 'lite' with no license server at all.
 *   2. Core-license `edition` field — authoritative once the marketplace/license
 *      server returns it on the 'titancart' license status object.
 *   3. Backward-compatible entitlement fallback — for installs that predate the
 *      `edition` field (every store live today: vendor, licensed, or on the
 *      grandfathered beta/trial). If titancart_core_license_active() is true → 'pro',
 *      else 'lite'. This is what makes deploying the gate INERT on existing
 *      installs: nothing licensed/trialing gets accidentally downgraded to Lite.
 *   4. Default → 'lite' (fail-closed-for-Pro: unknown / unlicensed never grants Pro).
 *
 * Once the license server ships the `edition` field, step 2 governs and step 3
 * only matters for genuinely-unlicensed installs (which correctly get Lite).
 *
 * ── Leak-free discipline (§3.4) ──────────────────────────────────────────────
 *   - Enforce at the DATA/WRITE layer (REST permission callbacks), never UI-only.
 *     A hidden menu with a live REST route is a leak.
 *   - Fail closed for Pro, fail open for Lite. A capability NOT in the Pro map is
 *     a Lite feature and is always available (we never accidentally lock a Lite
 *     feature); Pro features are explicitly enumerated below.
 *   - The Lite package additionally omits the Pro module files entirely — the
 *     strongest guard — but the shared Pro tree still needs this runtime gate.
 *
 * Portable: pure PHP resolution + a plain-array capability map. The only host
 * touch-points are the guarded global helpers (titancart_core_license_active) and the
 * WP_Error the REST primitive returns.
 *
 * PHP 8.0-safe (TITANCART_MIN_PHP = 8.0): no enums, no readonly, no `true`/`never`
 * return types, no union types.
 *
 * @package TitanCart\Core
 */

class Edition {

	/** Edition identifiers. */
	public const LITE = 'lite';
	public const PRO  = 'pro';


	/** @var string|null Per-request memo of the resolved edition. */
	private static ?string $memo = null;

	/* ══════════════════════════════════════════════════════════════════════
	 * EDITION RESOLUTION
	 * ══════════════════════════════════════════════════════════════════════ */

	/**
	 * Resolve the effective edition for this install: 'lite' | 'pro'.
	 * Memoised per request (edition cannot change mid-request).
	 *
	 * @return string
	 */
	public static function resolve(): string {
		if ( self::$memo !== null ) {
			return self::$memo;
		}

		// 0. LITE BUILD — fail closed, before anything can promote us.
		//
		//    titancart_is_lite_build() is true when EITHER pinned build constant is set
		//    (TITANCART_EDITION='lite' or TITANCART_NO_PHONEHOME). Without this step, deleting
		//    just the TITANCART_EDITION line from a Lite package would fall through to
		//    the entitlement fallback at step 3 — and that fallback calls
		//    titancart_core_license_active(), whose OWN step-0 short-circuit returns
		//    true on a Lite build (the day-38 anti-brick fix). Net effect:
		//    removing one line would silently PROMOTE a free install to full,
		//    permanent Pro with no trial clock. Removing a line must never be an
		//    upgrade. Fail closed here.
		//
		//    Safe from recursion: titancart_is_lite_build() reads constants only and
		//    never touches the resolver or the license chain.
		if ( function_exists( 'titancart_is_lite_build' ) && titancart_is_lite_build() ) {
			return self::$memo = self::LITE;
		}

		// 1. Build constant — baked per package, overrides everything.
		if ( defined( 'TITANCART_EDITION' ) ) {
			$const = self::normalize( (string) constant( 'TITANCART_EDITION' ) );
			if ( $const !== '' ) {
				return self::$memo = $const;
			}
		}

		// 2. Core-license `edition` field (authoritative once the server ships it).
		$licensed = self::edition_from_license();
		if ( $licensed !== '' ) {
			return self::$memo = $licensed;
		}

		// 3. Backward-compatible entitlement fallback — keeps every currently-live
		//    install (vendor / licensed / grandfathered beta / active trial) on Pro
		//    so deploying the gate is INERT on existing stores.
		if ( function_exists( 'titancart_core_license_active' ) && titancart_core_license_active() ) {
			return self::$memo = self::PRO;
		}

		// 4. Fail closed for Pro: unknown / unlicensed → Lite.
		return self::$memo = self::LITE;
	}

	/**
	 * Read the `edition` field off the core license status object, if present.
	 * Returns '' when the field is absent (server hasn't shipped it yet) or the
	 * value is not a recognised edition.
	 *
	 * @return string '' | 'lite' | 'pro'
	 */
	private static function edition_from_license(): string {
		if ( ! class_exists( __NAMESPACE__ . '\\LicenseManager' ) ) {
			return '';
		}

		$status = LicenseManager::get_status( 'titancart' );
		if ( ! is_array( $status ) || ! isset( $status['edition'] ) ) {
			return '';
		}

		return self::normalize( (string) $status['edition'] );
	}

	/**
	 * Coerce an arbitrary string to a recognised edition, or '' if unrecognised.
	 *
	 * @param string $value
	 * @return string '' | 'lite' | 'pro'
	 */
	private static function normalize( string $value ): string {
		$value = strtolower( trim( $value ) );
		if ( $value === self::PRO || $value === self::LITE ) {
			return $value;
		}
		return '';
	}

	/**
	 * @return bool True when this install is running the Pro edition.
	 */
	public static function is_pro(): bool {
		return self::resolve() === self::PRO;
	}

	/**
	 * Clear the per-request memo. For tests / long-running CLI only.
	 */
	public static function reset(): void {
		self::$memo = null;
	}


	/* ══════════════════════════════════════════════════════════════════════
	 * PRO EXTENSION MAP  (built-in Extensions admin — the "4th seam")
	 *
	 * The built-in extensions registered in ExtensionsController::definitions()
	 * are gated by SLUG rather than by a capability key. This is the single
	 * source of truth for which built-in extensions are Pro-only, so ALL of
	 * TitanCart's Pro-ness lives in this one class. ExtensionsController
	 * consults it in three places:
	 *   (a) update_status()  → blocks ACTIVATING a Pro extension on Lite with
	 *                          the standard 403 verdict. Deactivating stays
	 *                          allowed so a downgraded store can turn off a
	 *                          leftover.
	 *   (b) is_active()      → returns false for a Pro extension on Lite
	 *                          (fail-closed → the feature stops functioning in
	 *                          BOTH storefront and admin = the storefront seam
	 *                          for these; also neutralises a Pro extension left
	 *                          'active' from a lapsed Pro period).
	 *   (c) get_extensions() → marks each `pro` so the Extensions admin badges
	 *                          + locks the toggle (seam #1 feed).
	 *
	 * Split LOCKED per docs/TITANCART-LITE-SCOPING.md (Sean, S351; seo corrected
	 * to Lite S352; advanced-import Lite S352 → re-gated Pro S415 once it gained
	 * a real Pro feature; bestsellers/recaptcha/price-per-unit/social-buttons
	 * UNLOCKED to Lite S451 per the WP.org R1 trialware remediation —
	 * docs/WPORG-R1-TRIALWARE-STRIP-SPEC.md §1, Sean's approved split):
	 *   Pro  (9):  social-login, catalog-mode, access-restriction       [3 new]
	 *              + advanced-import                                     [re-gated S415]
	 *              + gift-cards, newsletters, bundles, rma, rewards
	 *              [5 already covered by the core capability map above; repeated
	 *               here so the admin badges them correctly AND the extension
	 *               gate is a second, independent guard (belt-and-suspenders)].
	 *   Lite (11): variations, reviews, wishlist, sitemap, gdpr, age-verification,
	 *              seo, bestsellers, recaptcha, price-per-unit, social-buttons.
	 *
	 * Lite rationale: `variations` is effectively core (a Lite store must be
	 * able to sell size/colour products); `reviews` + `wishlist` are table-stakes
	 * (paid wishlist-plus is the upsell); `sitemap` is standalone; `gdpr` +
	 * `age-verification` are legal/compliance and ship in Lite on the same
	 * principle as tax. `advanced-import` was a PHANTOM toggle at S352 (Lite),
	 * but S415 (KI-34) built its advertised Pro feature — SAVED IMPORT PRESETS —
	 * and wired the toggle to gate it (is_active('advanced-import') at the
	 * /import/presets REST routes + the admin UI). The BASIC importer, including
	 * manual CSV-column→field mapping (the Lite "migration weapon",
	 * ImportService::getFieldMap / column_map), stays Lite and un-gated; only the
	 * presets tier is Pro. So the toggle is no longer a lie → re-gated Pro S415.
	 * `seo` (S352, KI-35): SEO is table-stakes — a free WP.org
	 * discovery-funnel product must be indexable, so meta tags / canonical / OG /
	 * structured data ship in Lite; the `seo` toggle now genuinely gates those
	 * outputs (StorefrontModule + single.php wired to is_active('seo') S352).
	 * ────────────────────────────────────────────────────────────────────── */

	/**
	 * The LOCKED set of Pro-only built-in extension slugs.
	 * Slugs match ExtensionsController::definitions() keys exactly.
	 *
	 * @return array<int, string>
	 */
	public static function pro_extensions(): array {
		return [
		];
	}

	/**
	 * True when the built-in extension $slug is gated to the Pro edition.
	 * A slug not in the Pro list is a Lite extension → false.
	 *
	 * @param string $slug
	 * @return bool
	 */
	public static function is_pro_extension( string $slug ): bool {
		return in_array( $slug, self::pro_extensions(), true );
	}

	/**
	 * True when the CURRENT edition may activate/use the built-in extension.
	 *   - a Lite extension → always true
	 *   - a Pro extension  → true only when is_pro()
	 *
	 * @param string $slug
	 * @return bool
	 */
	public static function can_use_extension( string $slug ): bool {
		if ( ! self::is_pro_extension( $slug ) ) {
			return true;
		}
		return self::is_pro();
	}

	/* ──────────────────────────────────────────────────────────────────────
	 * PRO-BUNDLED STANDALONE EXTENSIONS (distribution model, S354)
	 *
	 * ⚠️ DO NOT CONFUSE WITH pro_extensions() ABOVE. Two different things that
	 * both use the word "extension":
	 *
	 *   pro_extensions()          → BUILT-IN feature toggles living inside core
	 *                               (slugs like 'bundles', 'rma', 'gift-cards').
	 *                               Gated by ExtensionsController (seam #4).
	 *
	 *   pro_bundled_extensions()  → STANDALONE WordPress PLUGINS that ship with
	 *                               their own bootstrap file and are folded into
	 *                               the Pro subscription (slugs like
	 *                               'titancart-wishlist-plus'). Gated inside each
	 *                               plugin's own tcXX_check_license_active().
	 *
	 * The two slug namespaces are disjoint (every slug here is `titancart-`
	 * prefixed; none of the built-in slugs are), so a mix-up cannot silently
	 * grant entitlement — it simply returns false.
	 *
	 * PER docs/DISTRIBUTION-MODEL.md §1 (amended KI-45): membership here is a
	 * COMMERCIAL decision about what the Pro subscription includes. It is NOT a
	 * technical taxonomy, and it is NOT "store function vs third-party".
	 *
	 * `titancart-printful` WAS deliberately absent (S354). KI-45 folded it in.
	 * Read this carefully before "correcting" it back:
	 *
	 *   §1's original wording implied "third-party integration ⇒ addon". That
	 *   made absence-from-this-list coextensive with third-party-ness, which
	 *   read like a taxonomy. It is not. **Bundling is a COMMERCIAL decision.**
	 *   Printful is a third-party integration AND it is bundled into Pro.
	 *
	 * The enforcement mechanism is unchanged and still needs no exclusion list:
	 * a future add-on is an add-on because it does NOT appear here. That test
	 * simply no longer happens to line up with "does it call a third-party API".
	 * See KI-45 and docs/DISTRIBUTION-MODEL.md §1 (amended).
	 *
	 * ⚠️ NEVER enumerate by exclusion. `software-licensing` is a PluginPress
	 * product that legitimately sells; a rule shaped "everything except the core
	 * plugin" would stop it selling. Enumerate by the POSITIVE fact — this list.
	 *
	 * `titancart-essentials` is already free/unlicensed. `tc-helpdesk` and
	 * `pluginpress` are internal vendor tooling. All three are out of scope.
	 *
	 * Source of truth mirrored from build/manifest.php ($extensions → tier ===
	 * 'pro_bundled'). If you add a Pro-bundled extension, update BOTH.
	 * ────────────────────────────────────────────────────────────────────── */

	/**
	 * The LOCKED set of standalone extension plugin slugs bundled into Pro.
	 * Entitlement for these comes from the core subscription — they require no
	 * per-extension license key, because Pro *is* the key.
	 *
	 * @return array<int, string>
	 */
	public static function pro_bundled_extensions(): array {
		return [
			'titancart-abandoned-cart',
			'titancart-tip-donation',
			'titancart-sms-notifications',
			'titancart-product-feeds',
			'titancart-wishlist-plus',
			'titancart-marketplace',
			'titancart-affiliate-marketing',
			'titancart-subscriptions',
			'titancart-printful', // KI-45. Third-party integration, bundled anyway.
		];
	}

	/**
	 * True when the standalone extension plugin $slug is bundled into Pro.
	 *
	 * NOTE: this reports CLASSIFICATION only, independent of the current
	 * edition — exactly like is_pro_extension(). The edition check lives in
	 * the titancart_extension_pro_bundled() bootstrap global, which is what the
	 * extensions actually call.
	 *
	 * @param string $slug Full plugin slug, e.g. 'titancart-wishlist-plus'.
	 * @return bool
	 */
	public static function is_pro_bundled_extension( string $slug ): bool {
		return in_array( $slug, self::pro_bundled_extensions(), true );
	}


	/* ══════════════════════════════════════════════════════════════════════
	 * CLIENT SUMMARY (seam #1 feed)
	 * ══════════════════════════════════════════════════════════════════════ */

	/**
	 * One-shot snapshot for the admin SPA Config and the License REST response.
	 * `proCapabilities` lets the SPA lock+badge the matching nav nodes when
	 * !isPro without hard-coding the split in JS.
	 *
	 * @return array{edition:string, isPro:bool, proCapabilities:array<int,string>, proExtensions:array<int,string>}
	 */
	public static function summary(): array {
		return [
			'edition'         => self::resolve(),
			'isPro'           => self::is_pro(),
			'proExtensions'   => self::pro_extensions(),
		];
	}
}
