<?php
/**
 * Forge License Resolver — single source of truth for licensing UI + gating.
 *
 * FC-63: unifies the licensing model across Forge Suite and the 4 Forge
 * plugins. Every UI surface (per-plugin License page, Forge Suite dashboard
 * status pills, the credits widget, Pro lock screens) reads ONE object from
 * here instead of independently inferring Pro-state from a saved key, a
 * credits balance, a "connected" flag, or raw Freemius flags — the exact
 * source of the "magic mixed states" the ticket lists (Free + credits widget,
 * Connected + inactive license, credits from the wrong account, etc.).
 *
 * Design (keystone): entitlement (free|pro|trial) is NOT recomputed here.
 * Each plugin already centralises that in {PREFIX}_License::effective_plan,
 * and those four ladders differ (RankForge sandbox path, FormForge /
 * FieldForge local_license_data_plan + {product}_license_status, the LF
 * grace window). Re-implementing them would risk flipping a Pro gate. So the
 * resolver DELEGATES entitlement to {PREFIX}_License::get_plan and layers
 * the NEW shared concepts on top: connection state, license state, license
 * type (bundle vs single), product scope, entitlement source, the Avakode
 * account binding, credits visibility, and the manage URL. Because the
 * resolver reads the same get_plan the gates use, the resolver object and
 * is_pro/can can never disagree (AC16/AC25).
 *
 * Shared Free-side class (loaded by every Forge plugin; first one wins via
 * the class_exists wrap, like Forge_Crypto / Forge_Suite_Dashboard). Lives in
 * the Free core so effective_plan-adjacent code can consult it even on a
 * Free-only site, where it resolves to disconnected / Free. It must NOT
 * hard-require any -pro class.
 *
 * @package ForgeSuite
 */

if (!defined('ABSPATH')) {
    exit;
}

if (!class_exists('Forge_License_Resolver')) {

class Forge_License_Resolver {

    /** The 4 Forge products the resolver knows about. */
    const PRODUCTS = ['langforge', 'rankforge', 'formforge', 'fieldforge'];

    /** Site-level option holding the single Avakode account binding (FC-63 §2). */
    const BINDING_OPTION = 'forge_account_binding';

    /** Per-request memo, keyed by product. Flushed after Connect/Disconnect/activate. */
    private static $cache = [];


    // Product → storage maps (must mirror Forge_Connect::PRODUCTS)


    /**
 * wp_option key that holds a product's license data.
 * LangForge predates the {slug}_license_data convention and stores under
 * the lf_ prefix (lf_license_data) — see langforge_migrate_license_option.
 */
    public static function option_key($product) {
        return $product === 'langforge' ? 'langforge_license_data' : $product . '_license_data';
    }

    /** Admin slug of a plugin's own License page (admin.php?page=...). */
    public static function license_page_slug($product) {
        return $product . '-license';
    }


    // Public API


    /**
 * The unified license object for a product (FC-63 §12 shape).
 *
 * @return array{
 * connection_state:string, account_id:?string, account_email:?string,
 * license_state:string, license_type:?string, product_scope:?string,
 * site_limit_tier:?string, entitlement_state:string,
 * entitlement_source:string, credits_visible:bool, manage_url:?string,
 * plan:string
 * }
 */
    public static function resolve($product) {
        if (!in_array($product, self::PRODUCTS, true)) {
            $product = 'langforge';
        }
        if (isset(self::$cache[$product])) {
            return self::$cache[$product];
        }

        $data = get_option(self::option_key($product), []);
        if (!is_array($data)) {
            $data = [];
        }
        $binding = self::account_binding();

        // Entitlement — delegated to the plugin's own (well-tested) ladder.
        $plan        = self::plan_for($product);                       // free|pro|trial
        $entitlement = in_array($plan, ['pro', 'trial'], true) ? 'pro' : 'free';

        $has_key     = !empty($data['key']);
        $is_inactive = !empty($data['inactive']);

        // Connection state — "is the site bound to an Avakode account?". This is
        // independent of entitlement: connected does NOT mean pro.
        $connected = (!empty($binding['account_hash']));
        if (!$connected && $entitlement === 'pro') {
            // Legacy "connected-but-unbound": rows written before FC-63 added the
            // binding, plus E2E/local Pro (forge_e2e_plan) which has no binding.
            // Don't suddenly show these disconnected.
            if (($has_key && !$is_inactive) || self::is_e2e_pro()) {
                $connected = true;
            }
        }
        $connection_state = $connected ? 'connected' : 'disconnected';

        // Entitlement source / license type / product scope (FC-63 §3.4, §4).
        $is_bundle = ($entitlement === 'pro') ? self::detect_is_bundle($product, $data) : false;
        if ($entitlement !== 'pro') {
            $entitlement_source = 'none';
            $license_type       = null;
            $product_scope      = null;
        } elseif ($is_bundle) {
            $entitlement_source = 'bundle';
            $license_type       = 'bundle';
            $product_scope      = 'bundle';
        } else {
            $entitlement_source = 'single_plugin';
            $license_type       = 'single';
            $product_scope      = $product;
        }

        $license_state = self::compute_license_state($product, $entitlement, $has_key, $is_inactive);

        $account_email = self::first_nonempty([
            isset($binding['account_email']) ? $binding['account_email'] : null,
            isset($data['account_email']) ? $data['account_email'] : null,
        ]);
        $account_id = self::first_nonempty([
            isset($binding['account_hash']) ? $binding['account_hash'] : null,
            isset($data['account_hash']) ? $data['account_hash'] : null,
        ]);

        // Credits widget visibility (FC-63 §9, AC9/10/12): connected + active +
        // pro + credits belong to the bound account.
        $credits_visible = ($connection_state === 'connected'
            && $license_state === 'active'
            && $entitlement === 'pro');
        if ($credits_visible
            && !empty($binding['account_hash'])
            && !empty($data['account_hash'])
            && $binding['account_hash'] !== $data['account_hash']) {
            // Stored product key belongs to a different account than the bound
            // one — never light up another account's credits.
            $credits_visible = false;
        }

        if ($entitlement_source === 'bundle') {
            $manage_url = admin_url('admin.php?page=forge-suite');
        } elseif ($entitlement_source === 'single_plugin') {
            $manage_url = admin_url('admin.php?page=' . self::license_page_slug($product));
        } else {
            $manage_url = null;
        }

        $resolved = [
            'connection_state'   => $connection_state,
            'account_id'         => $account_id,
            'account_email'      => $account_email,
            'license_state'      => $license_state,
            'license_type'       => $license_type,
            'product_scope'      => $product_scope,
            // Worker does not expose a tier (ticket §14 forbids changing it);
            // best-effort from persisted data, else null (render degrades).
            'site_limit_tier'    => isset($data['site_limit_tier']) ? $data['site_limit_tier'] : null,
            'entitlement_state'  => $entitlement,
            'entitlement_source' => $entitlement_source,
            'credits_visible'    => $credits_visible,
            'manage_url'         => $manage_url,
            // Convenience: callers that still need the trial/pro/free distinction.
            'plan'               => $plan,
        ];

        self::$cache[$product] = $resolved;
        return $resolved;
    }

    /** Drop the per-request memo after a state change (Connect/Disconnect/activate). */
    public static function flush($product = null) {
        if ($product === null) {
            self::$cache = [];
        } else {
            unset(self::$cache[$product]);
        }
    }

    public static function is_connected($product) {
        $r = self::resolve($product);
        return $r['connection_state'] === 'connected';
    }

    public static function credits_visible($product) {
        $r = self::resolve($product);
        return !empty($r['credits_visible']);
    }

    public static function is_pro($product) {
        $r = self::resolve($product);
        return $r['entitlement_state'] === 'pro';
    }

    public static function entitlement_source($product) {
        $r = self::resolve($product);
        return $r['entitlement_source'];
    }


    // Account binding (FC-63 §2 — one site = one Avakode account)


    /** @return array The current site→account binding, or when disconnected. */
    public static function account_binding() {
        $b = get_option(self::BINDING_OPTION, []);
        return is_array($b) ? $b : [];
    }

    /** Stable, non-reversible account id derived from the Connect email. */
    public static function account_hash($email) {
        $email = strtolower(trim((string) $email));
        return $email === '' ? '' : hash('sha256', $email);
    }


    // Internals


    /** Entitlement plan from the plugin's own License class (static vs instance API). */
    private static function plan_for($product) {
        switch ($product) {
            case 'langforge':
                return class_exists('LANGFORGE_License') ? LANGFORGE_License::get_plan() : 'free';
            case 'rankforge':
                return class_exists('RANKFORGE_License') ? RANKFORGE_License::get_plan() : 'free';
            case 'formforge':
                return class_exists('FORMFORGE_License') ? FORMFORGE_License::instance()->get_plan() : 'free';
            case 'fieldforge':
                return class_exists('FIELDFORGE_License') ? FIELDFORGE_License::instance()->get_plan() : 'free';
        }
        return 'free';
    }

    /**
 * Bundle vs single. Prefer the value persisted at Connect/activate
 * (entitlement_source on the product's license_data, or the account
 * binding's license_type); fall back to the shared Freemius/E2E detector
 * for rows written before FC-63 (lazy backfill happens on next Connect).
 */
    private static function detect_is_bundle($product, $data) {
        $src = isset($data['entitlement_source']) ? $data['entitlement_source'] : '';
        if ($src === 'bundle') {
            return true;
        }
        if ($src === 'single_plugin') {
            return false;
        }
        $binding = self::account_binding();
        if (!empty($binding['license_type'])) {
            return $binding['license_type'] === 'bundle';
        }
        if (class_exists('Forge_Suite_Dashboard_Bundle_CTA')) {
            return Forge_Suite_Dashboard_Bundle_CTA::user_has_bundle();
        }
        return false;
    }

    private static function compute_license_state($product, $entitlement, $has_key, $is_inactive) {
        if ($entitlement === 'pro') {
            return 'active';
        }
        if ($is_inactive && $has_key) {
            return 'saved_but_inactive';
        }
        if ($has_key) {
            // Key present but not entitled. Distinguish expired/cancelled/invalid
            // where the plugin exposes a status (FF/FDF). The Worker's
            // /licenses/validate only says valid/invalid, so this is best-effort;
            // everything else collapses to saved_but_inactive.
            $status = self::license_status($product);
            if ($status === 'expired')   return 'expired';
            if ($status === 'cancelled') return 'cancelled';
            if ($status === 'invalid')   return 'invalid';
            return 'saved_but_inactive';
        }
        return 'missing';
    }

    /** FormForge/FieldForge persist a {product}_license_status; LF/SEO do not. */
    private static function license_status($product) {
        if ($product !== 'formforge' && $product !== 'fieldforge') {
            return '';
        }
        $opt    = $product . '_license_status';
        $status = get_transient($opt);
        if ($status === false) {
            $status = get_option($opt, '');
        }
        return is_string($status) ? $status : '';
    }

    private static function is_e2e_pro() {
        return defined('FORGE_SKIP_LICENSE_REVALIDATION')
            && FORGE_SKIP_LICENSE_REVALIDATION
            && in_array(get_option('forge_e2e_plan', ''), ['pro', 'trial'], true);
    }

    private static function first_nonempty(array $candidates) {
        foreach ($candidates as $c) {
            if (!empty($c)) {
                return $c;
            }
        }
        return null;
    }
}

} // end: if (!class_exists('Forge_License_Resolver'))
