<?php

/**
 * Copyright 2024-2026 Simply.IN Sp. z o.o.
 *
 * NOTICE OF LICENSE
 *
 * Licensed under the EUPL-1.2 or later.
 * You may not use this work except in compliance with the Licence.
 *
 * Copy of the Licence is available at:
 * https://joinup.ec.europa.eu/software/page/eupl
 * It is bundled with this package in the file LICENSE.txt
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the Licence is distributed on an as is basis,
 * without warranties or conditions of any kind, either express or implied.
 * Check the Licence for the specific language governing permissions
 * and limitations under the License.
 *
 * @author   Simply.IN Sp. z o.o.
 * @copyright 2024-2026 Simply.IN Sp. z o.o.
 * @license   https://joinup.ec.europa.eu/software/page/eupl
 */

declare(strict_types=1);

namespace SimplyIn\Install;

use Closure;
use SimplyIn\Api\ApiException;
use SimplyIn\Api\ConnectionOutcome;
use SimplyIn\Api\SimplyInApiClient;
use SimplyIn\Api\SimplyInApiClientFactory;
use SimplyIn\Auth\RsaSigner;
use SimplyIn\BackOffice\ConfigurationKey;
use SimplyIn\BackOffice\ConfigurationRepository;
use SimplyIn\BackOffice\Environment;

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

/**
 * One-off migration for a shop upgrading from a build with the backend URL pinned in code (the
 * old two-package model) to the single-package runtime environment selector. Such a shop has a
 * key stored under the legacy flat slot and no environment setting yet, and this code cannot know
 * which build (preprod or prod) it is replacing — the old URL lived in code, not config. Guessing
 * would silently break every shop currently pointed at preprod, so instead: try the stored key
 * against Production, then Test, and adopt whichever accepts it. If both DEFINITIVELY reject it,
 * settle on Production (the safe default) — the actionable rejection message then surfaces the
 * next time the merchant opens the settings screen, the same way it would for any other rejected
 * key.
 *
 * If a probe could not get a straight answer at all, {@see adopt()} returns false instead of
 * committing anything. The caller must NOT read that as "PrestaShop will retry this upgrade step
 * later" — it will not: {@see \Simplyin::attemptEnvironmentAdoption()} is the actual retry, a
 * throttled admin-only backstop independent of the upgrade mechanism entirely (see that method's
 * docblock, and upgrade/upgrade-8.1.24.php's, for why relying on the upgrade function's own return
 * value does not work here).
 *
 * Idempotent and side-effect-free beyond the config writes: it never touches
 * {@see \SimplyIn\Service\MerchantSettingsSync}'s own state (webhook secret, docs, etc.) — the very
 * next normal bootstrap picks the resolved environment/key up and performs that sync itself.
 */
final class EnvironmentAdopter
{
    /**
     * Throttle timestamp for the admin-only retry ({@see \Simplyin::attemptEnvironmentAdoption()}).
     * The one-off upgrade step's own attempt does not use this — retrying via PrestaShop's
     * upgrade-function return value does not work (it disables the module on false and does not
     * re-run the step regardless), so the admin hook is the only real retry path.
     */
    public const LAST_ATTEMPT_KEY = 'SIMPLYIN_ENVIRONMENT_ADOPTION_LAST_ATTEMPT';

    /** @var Closure(Environment, string, string): SimplyInApiClient */
    private readonly Closure $clientFactory;

    /**
     * @param null|Closure(Environment, string, string): SimplyInApiClient $clientFactory called
     *                                                                     with (environment, apiKey, origin); defaults to a real client against that environment's backend
     */
    public function __construct(
        private readonly ConfigurationRepository $config,
        ?Closure $clientFactory = null,
    ) {
        $this->clientFactory = $clientFactory ?? static fn (Environment $environment, string $apiKey, string $origin): SimplyInApiClient => SimplyInApiClientFactory::create(
            baseUrl: $environment->backendUrl(),
            merchantApiKey: $apiKey,
            origin: $origin,
        );
    }

    /**
     * @param string $legacyApiKey the value stored under the old flat {@see ConfigurationKey::SECRET_KEY}
     *
     * @return bool true once the environment is actually resolved (or there was nothing to
     *              resolve); false when a probe was inconclusive and the caller must retry later
     */
    public function adopt(string $legacyApiKey, string $origin, string $pluginVersion, string $shopName): bool
    {
        // Already migrated — a previous run already settled this, or this is a fresh install
        // (which never reaches this class in the first place, see upgrade/upgrade-8.1.24.php).
        if ($this->config->getString(ConfigurationKey::ENVIRONMENT) !== '') {
            return true;
        }

        if ($legacyApiKey === '') {
            $this->config->setEnvironment(Environment::default());

            return true;
        }

        $accepted = null;
        $allAnswersDefinitive = true;
        foreach (Environment::cases() as $candidate) {
            $verdict = $this->probe($candidate, $legacyApiKey, $origin, $pluginVersion, $shopName);
            if ($verdict === true) {
                $accepted = $candidate;
                break;
            }
            $allAnswersDefinitive = $allAnswersDefinitive && $verdict === false;
        }

        if ($accepted === null && !$allAnswersDefinitive) {
            // At least one probe could not get a straight answer (timeout, 5xx, unreachable) — so
            // "neither accepted it" is not established, and committing an environment here would
            // be a coin flip that a network blip decides. Seed both slots with the key (nothing is
            // lost — whichever environment the merchant ends up on has it pre-filled) but leave the
            // environment genuinely unresolved by reporting failure — see this class's own docblock
            // for why the caller must retry independently rather than relying on this return value
            // reaching PrestaShop's upgrade mechanism.
            foreach (Environment::cases() as $candidate) {
                $this->config->setApiKey($candidate, $legacyApiKey);
            }

            return false;
        }

        // Either one environment accepted the key, or both definitively rejected it — in which
        // case settle on the safe default and let the actionable rejection message surface in
        // admin, rather than dropping the merchant's key.
        $environment = $accepted ?? Environment::default();
        $this->config->setEnvironment($environment);
        $this->config->setApiKey($environment, $legacyApiKey);
        // The pre-existing request-signing credential was registered against whichever backend the
        // old build pointed at — which is the environment just adopted. Carry it into that
        // environment's slot so an already-registered shop keeps signing instead of trying (and
        // being refused) a fresh registration for a credential the backend already holds.
        RsaSigner::moveSlots(Environment::default()->rsaSlotSuffix(), $environment->rsaSlotSuffix());

        return true;
    }

    /**
     * A bare auth probe against the candidate environment's backend — deliberately not a full
     * {@see \SimplyIn\Service\MerchantSettingsSync::sync()} (no config side effects for a
     * candidate that turns out to be the wrong one); the winning environment gets a real sync on
     * the very next normal bootstrap once its key is in place.
     *
     * @return bool|null true = this environment accepts the key; false = it definitively rejected
     *                   it (401/403); null = no straight answer (transport failure, non-2xx status
     *                   even with a body that superficially looks like success, 5xx, malformed
     *                   body), which must NOT be read as a rejection — the same technical-failure
     *                   vs auth-rejection distinction {@see ConnectionOutcome} draws elsewhere
     */
    private function probe(Environment $environment, string $apiKey, string $origin, string $pluginVersion, string $shopName): ?bool
    {
        $client = ($this->clientFactory)($environment, $apiKey, $origin);

        try {
            $response = $client->request('GET', 'plugin-sync/info', params: [
                'platform' => 'PrestaShop',
                'pluginVersion' => $pluginVersion,
                'shopName' => $shopName,
            ]);
        } catch (ApiException) {
            return null;
        }

        // A non-2xx status with a body that happens to decode to an array without a SCREAMING_SNAKE
        // `code` (e.g. a plain-text 500 wrapped as {"message": "..."}) would otherwise read as
        // acceptance via isFailure() alone — require the status to actually say success too.
        if ($response->statusCode >= 200 && $response->statusCode < 300 && !SimplyInApiClient::isFailure($response->body)) {
            return true;
        }

        return ConnectionOutcome::fromStatusCode($response->statusCode) === ConnectionOutcome::AUTH_REJECTED
            ? false
            : null;
    }
}
