<?php

defined('ABSPATH') || exit;

/**
 * Resolves the per-signup application-fee percent from the stripe-connect /fee
 * endpoint with a 24-hour transient cache and fail-open behavior.
 *
 * Sole entry point for plugin-side fee lookups during checkout. The service
 * issues a JWT-authed POST to MEPR_STRIPE_SERVICE_URL./api/memberpress/fee on a
 * cache miss, caches the resulting percent in a per-gateway WordPress
 * transient for DAY_IN_SECONDS, and returns 0.0 on any non-200 / malformed /
 * transport-error path. The merchant is identified server-side by the
 * site_uuid alone; the license key, site domain, and the build's edition/version
 * diagnostics travel in the body and are never policy input.
 *
 * Before the call, a gateway that is not Stripe Connect (application fees
 * only exist on Connect requests) or a connected account in an excluded
 * market (see UNSUPPORTED_COUNTRIES) short-circuits to 0.0 with no HTTP
 * request.
 *
 * A successful 200 with a zero percent shares the 24-hour TTL with positive
 * responses: zero is the legitimate steady-state for unlicensed and lapsed
 * sites, and short-caching it would hammer /fee on every checkout for those
 * sites. The accepted trade-off is that a spurious upstream zero (e.g. a stale
 * read during a deploy) pins zero for up to a day before the next fetch.
 *
 * License-state changes do not wait out the TTL: the DRM license handlers
 * call clear_cache(), so the long TTL only delays inputs the plugin cannot
 * observe locally (server-side overrides and connector-chain license state).
 *
 * Fail-open paths cache 0.0 for a short window (2 minutes) on transient errors
 * (transport, 5xx, malformed body) so a service hiccup does not produce a
 * checkout-by-checkout hammer. A 4xx response indicates a persistent config
 * error that won't self-heal, so the cache is extended (15 minutes) to avoid
 * burning the endpoint with the same broken request until the config is fixed.
 *
 * A dev URL or non-production environment short-circuits the call entirely:
 * fetch_percent() returns 0.0 so no application_fee_* field is stamped on the
 * outbound Stripe payload. The underlying checkout / resume / subscription
 * mutation still proceeds normally — staging is intentionally NOT blocked.
 */
class MeprStripeFeeService
{
    /**
     * ISO 3166-1 alpha-2 codes for the markets excluded from the application-fee program.
     *
     * Connected accounts in these countries are zeroed plugin-side before the
     * /fee call is issued. The list is a product decision, not a calculator
     * detail, and is intentionally hardcoded here rather than fetched.
     *
     * @var string[]
     */
    private const UNSUPPORTED_COUNTRIES = ['BR', 'IN', 'MX', 'MY', 'SG', 'TH'];

    /**
     * Transient key prefix for the cached fee percent; the gateway id is appended.
     *
     * The key is scoped per payment method because the server's answer is per
     * (site, connected account): two Connect gateways on one site can point at
     * different Stripe accounts with different license states, so a shared
     * value would stamp one account's percent on the other's checkouts. The
     * 24-hour TTL alone governs freshness.
     *
     * @var string
     */
    private const CACHE_KEY_PREFIX = 'mepr_stripe_fee_';

    /**
     * Resolves the application-fee percent for a connected Stripe payment method.
     *
     * Returns the cached percent on hit, performs a JIT POST to /fee on miss,
     * and returns 0.0 on any failure (transport error, non-200, malformed or
     * out-of-range body, dev/staging environment, missing site_uuid /
     * gateway_id, a gateway that is not Stripe Connect, or an
     * unsupported-market account). The percent is rounded to 2 decimal places before caching and
     * returning so callers can stamp it directly on Stripe's `application_fee_percent`
     * field (which accepts at most 2dp) and so the one-time `application_fee_amount`
     * math stays symmetric with the subscription path.
     *
     * @param string $gateway_id The plugin-side gateway integration ID (site_payment_method_id on the server's Connection row).
     *
     * @return float The fee percent rounded to 2 decimal places (0.0 on any failure or short-circuit).
     */
    public function fetch_percent(string $gateway_id): float
    {
        // A dev URL or non-production environment skips the fee entirely so
        // staging clones and local installs are never charged.
        $is_staging_or_dev = MeprUtils::is_dev_url()
            || (function_exists('wp_get_environment_type') && wp_get_environment_type() !== 'production');

        if ($is_staging_or_dev) {
            return 0.0;
        }

        $site_uuid = (string) get_option('mepr_authenticator_site_uuid');

        // The site_uuid is the server's sole identification handle (carried in
        // the JWT payload), and gateway_id drives the connected-account country
        // pre-check below; both are required. The mothership license key is not
        // a gate — identification is by site_uuid, so an unlicensed but
        // identifiable site still resolves a fee.
        if ('' === $site_uuid || '' === $gateway_id) {
            return 0.0;
        }

        // Application fees only exist on Stripe Connect requests: stamping
        // application_fee_* on a gateway that authenticates with its own API
        // keys makes Stripe reject the request outright, blocking the
        // checkout. The guard sits before the cache read so a gateway that is
        // no longer connected can never serve a percent cached while it was.
        if ('connected' !== MeprStripeGateway::stripe_connect_status($gateway_id)) {
            return 0.0;
        }

        $key    = self::CACHE_KEY_PREFIX . $gateway_id;
        $cached = get_transient($key);
        if (false !== $cached) {
            return (float) $cached;
        }

        // The fee program excludes connected accounts in certain markets, so
        // the plugin short-circuits here before issuing the call. The check
        // runs only on a cache miss: a Stripe account's country is immutable,
        // so a cached percent was already vetted at fetch time, and skipping
        // the check on hits avoids a live Stripe account lookup on every
        // checkout when the country is not stored locally. The result is not
        // cached here because nothing was fetched.
        if ($this->is_unsupported_country($gateway_id)) {
            return 0.0;
        }

        $mepr_options = MeprOptions::fetch();
        $license_key  = $mepr_options->mothership_license;
        $site_domain  = MeprUtils::site_domain();

        $jwt = MeprAuthenticatorCtrl::generate_jwt(['site_uuid' => $site_uuid]);

        // The gateway_id is the connected payment method; it travels in the URL path so the service
        // resolves the specific connection (scoped to the JWT site_uuid). The body is diagnostic only.
        $url = MEPR_STRIPE_SERVICE_URL . '/api/memberpress/fee/' . rawurlencode($gateway_id);

        $response = wp_remote_post($url, [
            'timeout' => 5,
            'headers' => MeprUtils::jwt_header($jwt, MEPR_STRIPE_SERVICE_DOMAIN),
            'body'    => wp_json_encode([
                'license_key'    => $license_key,
                'site_domain'    => $site_domain,
                'plugin_edition' => MEPR_EDITION,
                'plugin_version' => MEPR_VERSION,
                'wp_version'     => get_bloginfo('version'),
            ]),
        ]);

        if (is_wp_error($response)) {
            MeprUtils::debug_log('mepr_stripe_fee: HTTP transport error: ' . $response->get_error_message());
            $this->cache_failure($key);
            return 0.0;
        }

        $code = (int) wp_remote_retrieve_response_code($response);
        if (200 !== $code) {
            if ($code >= 400 && $code < 500) {
                MeprUtils::debug_log('mepr_stripe_fee: config error (HTTP ' . $code . ').');
            } else {
                MeprUtils::debug_log('mepr_stripe_fee: non-200 response (' . $code . ').');
            }
            $this->cache_failure($key, $code);
            return 0.0;
        }

        $body = json_decode(wp_remote_retrieve_body($response), true);
        if (!is_array($body) || !isset($body['percent']) || !is_numeric($body['percent'])) {
            MeprUtils::debug_log('mepr_stripe_fee: malformed response body.');
            $this->cache_failure($key);
            return 0.0;
        }

        $percent = round((float) $body['percent'], 2);

        // Stripe rejects application_fee_percent outside [0, 100], so an
        // out-of-range value (a server-side config typo) would hard-block
        // every checkout if stamped. Treat it as malformed and fail open.
        if ($percent < 0.0 || $percent > 100.0) {
            MeprUtils::debug_log('mepr_stripe_fee: out-of-range percent (' . $percent . ').');
            $this->cache_failure($key);
            return 0.0;
        }

        set_transient($key, $percent, DAY_IN_SECONDS);

        return $percent;
    }

    /**
     * Deletes every per-gateway cached fee percent.
     *
     * Called when the license state changes: the server resolves the percent
     * from the license, so a value cached under the previous state — e.g. the
     * unlicensed rate after the customer just activated a license — must not
     * be served for up to another day. The next checkout re-fetches.
     *
     * @return void
     */
    public static function clear_cache(): void
    {
        $mepr_options = MeprOptions::fetch();

        foreach ((array) $mepr_options->integrations as $gateway_id => $integration) {
            if ('MeprStripeGateway' === ($integration['gateway'] ?? '')) {
                delete_transient(self::CACHE_KEY_PREFIX . $gateway_id);
            }
        }
    }

    /**
     * Caches the fail-open 0.0 result so a service outage does not produce a
     * checkout-by-checkout hammer.
     *
     * On a 4xx the cache is extended to 15 minutes because the failure is a
     * persistent config error (wrong site_uuid, missing connection, host
     * mismatch, etc.) that retrying at the 2-minute transient cadence would
     * just hammer with the same broken request. Other failures (transport,
     * 5xx, malformed body) stay at 2 minutes since they're typically transient.
     *
     * @param string  $key  The transient key.
     * @param integer $code The HTTP status from the upstream response, or 0
     *                      for non-HTTP failures (transport, malformed body).
     *
     * @return void
     */
    private function cache_failure(string $key, int $code = 0): void
    {
        $ttl = ($code >= 400 && $code < 500) ? 15 * MINUTE_IN_SECONDS : 2 * MINUTE_IN_SECONDS;
        set_transient($key, 0.0, $ttl);
    }

    /**
     * Returns true when the connected account's country is excluded from the fee program.
     *
     * The country is resolved from the connected Stripe account behind the
     * gateway and matched (case-insensitively) against the excluded-market list.
     * The check is fail-open: an unknown or unresolved country proceeds to the
     * fee call rather than zeroing a potentially valid merchant.
     *
     * @param string $gateway_id The plugin-side gateway integration ID.
     *
     * @return boolean
     */
    private function is_unsupported_country(string $gateway_id): bool
    {
        $country = MeprStripeGateway::get_account_country($gateway_id);

        if (!is_string($country) || '' === $country) {
            return false;
        }

        return in_array(strtoupper($country), self::UNSUPPORTED_COUNTRIES, true);
    }
}
