<?php

/**
 * Copyright 2024-2025 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-2025 Simply.IN Sp. z o.o.
 * @license   https://joinup.ec.europa.eu/software/page/eupl
 */

declare(strict_types=1);

namespace SimplyIn\BackOffice;

use Context;
use OrderState;
use Simplyin;
use SimplyIn\Api\ConnectionOutcome;
use SimplyIn\Form\DataProvider\SettingsDataProvider;
use SimplyIn\Form\Type\SimplyInSettingsType;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Twig\Environment as TwigEnvironment;

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

/**
 * Renders the BO configuration screen through Symfony Form + Twig.
 *
 * Replaces the HelperForm-driven flow that used to live on ConfigurationFormBuilder.
 * The entry point stays `Simplyin::getContent()` — the module resolves
 * FormFactory/Twig from the PS container and hands them in.
 *
 * @phpstan-type SubmitResult array{confirmations: array<int, string>, errors: array<int, string>}
 */
final class ConfigurationController
{
    private const TEMPLATE = '@Modules/simplyin/views/templates/admin/configure.html.twig';
    private const SUBMIT_FIELD = 'submitSimplyinModule';

    public function __construct(
        private readonly Simplyin $module,
        private readonly ConfigurationRepository $config,
        private readonly SettingsDataProvider $dataProvider,
        private readonly FormFactoryInterface $formFactory,
        private readonly TwigEnvironment $twig,
    ) {
    }

    /**
     * Processes the admin request (submit + render) in one pass.
     *
     * @return array{html: string, confirmations: array<int, string>, errors: array<int, string>}
     */
    public function process(?Request $request = null): array
    {
        $request ??= Request::createFromGlobals();
        $form = $this->buildForm();

        $confirmations = [];
        $errors = [];

        if ($request->isMethod('POST') && $request->request->has(self::SUBMIT_FIELD)) {
            $form->handleRequest($request);
            if ($form->isSubmitted() && $form->isValid()) {
                /** @var array<string, mixed> $data */
                $data = $form->getData();
                $result = $this->persistAndSync($data);
                $confirmations = $result['confirmations'];
                $errors = $result['errors'];
                $form = $this->buildForm();
            }
        }

        $html = $this->twig->render(self::TEMPLATE, [
            'form' => $form->createView(),
            'cron_link' => $this->cronLink(),
            // PasswordType blanks its own view value on every unsubmitted render, so the
            // stored key for the currently selected environment is injected at render time
            // instead; the eye button reveals it.
            'secret_key_value' => $this->config->getApiKey($this->config->getEnvironment()),
            // Both panel URLs, keyed by the same enum value the environment <select> uses — lets
            // the "generate your key in the merchant panel" link (in help.tpl, rendered separately
            // before this form) follow the LIVE dropdown selection instead of only the persisted
            // environment, which would otherwise still point at Production while a merchant is
            // switching to Test to obtain its first key.
            'merchant_panel_urls' => array_combine(
                array_map(static fn (Environment $environment): string => $environment->value, Environment::cases()),
                array_map(static fn (Environment $environment): string => $environment->merchantPanelUrl(), Environment::cases()),
            ),
        ]);

        return ['html' => $html, 'confirmations' => $confirmations, 'errors' => $errors];
    }

    private function buildForm(): FormInterface
    {
        return $this->formFactory->create(
            SimplyInSettingsType::class,
            $this->dataProvider->getData(),
            [
                'carrier_choices' => $this->buildCarrierChoices(),
                'order_state_choices' => $this->buildOrderStateChoices(),
                'save_checkbox_forced' => $this->config->getBool(ConfigurationKey::CHECKBOX_FORCED_DISABLED),
            ],
        );
    }

    /**
     * @param array<string, mixed> $data
     *
     * @return SubmitResult
     */
    private function persistAndSync(array $data): array
    {
        $confirmations = [];
        $errors = [];

        $storedEnvironment = $this->config->getEnvironment();
        $environment = Environment::fromStoredValue((string) ($data['environment'] ?? ''));

        // An untouched field means "keep the stored key", not "clear it" nor "reuse whatever the
        // form was pre-filled with" — and the stored key is this environment's own slot, never the
        // other one's. The pre-filled value is the RENDERED environment's key (see the template's
        // secret_key_value), which is what an unedited field posts back.
        $storedKey = $this->config->getApiKey($environment);
        $effectiveKey = EffectiveApiKey::toAttempt(
            (string) ($data['secret_key'] ?? ''),
            $storedKey,
            $this->config->getApiKey($storedEnvironment),
        );

        // Point THIS REQUEST at the candidate environment so the validation call below targets it.
        // Nothing is persisted yet on purpose: an environment whose key the backend rejects must
        // not take effect, or a failed save would repoint a live shop (checkout, order sync,
        // tracking) at the other environment while only showing one transient error.
        $this->module->setEnvironment($environment);

        // Validate BEFORE persisting, so a rejected save never replaces a working setup.
        $syncOutcome = $this->module->simplyInUpdateMerchantSettings($effectiveKey, true);
        $connected = $syncOutcome === ConnectionOutcome::SUCCESS;

        if ($connected) {
            // A rejected save is all-or-nothing: CONNECTED gates checkout assets, order sync and
            // tracking, so flipping it false here would disable a still-working previous setup
            // (environment/key are reverted below) over a failed *attempt* at a different one.
            $this->config->set(ConfigurationKey::CONNECTED, true);
            $data['secret_key'] = $effectiveKey;
        } else {
            // Leave BOTH the environment and every key slot exactly as they were, and put this
            // request back on the environment that is actually still in effect.
            unset($data['environment'], $data['secret_key']);
            $this->module->setEnvironment($storedEnvironment);
        }
        $this->dataProvider->saveData($data);

        if ($connected) {
            // setEnvironment() above only repointed the signer for the validation call — the rest
            // of the request-scoped object graph (apiClient, orderSyncService, rsaRegistrar,
            // trackingNumberSync, ...) was still built at bootstrap against the OLD environment.
            // ConfigurationScreen::render() runs a pending-order sync right after a successful
            // save, so a stale apiClient would hit the wrong backend for it. Config now reflects
            // the newly accepted environment/key, so a full reload rebuilds everything from it —
            // cheap: the merchant-settings sync below is cached from the call above, not repeated.
            $this->module->loadObjects();

            $confirmations[] = $this->module->l('Successfully connected to SimplyIN.');
            // Register-on-save: bring RSA request signing up immediately for the just-saved key
            // instead of waiting for the throttled back-office/cron backstop. Best-effort; it never
            // blocks saving the configuration, but a failure is now surfaced so the merchant isn't
            // left guessing why checkout request signing didn't activate.
            $rsaOutcome = $this->module->registerRsaForApiKey($effectiveKey);
            if ($rsaOutcome === ConnectionOutcome::ALREADY_REGISTERED) {
                $errors[] = sprintf(
                    $this->module->l('Simply.IN: An RSA key has already been generated for this store. To create a new one, revoke the current key using the "Revoke key" button in the <a href="%s" target="_blank" rel="noopener noreferrer">Simply.IN merchant panel</a>, then click "Save" again.'),
                    $environment->merchantPanelUrl(),
                );
            } elseif ($rsaOutcome === ConnectionOutcome::AUTH_REJECTED) {
                $errors[] = $this->apiKeyRejectedMessage($environment);
            } elseif ($rsaOutcome === ConnectionOutcome::TECHNICAL_FAILURE) {
                $errors[] = $this->module->l('There was a problem completing setup, please click Save again to retry.');
            }
        } else {
            $keyRejected = $syncOutcome === ConnectionOutcome::AUTH_REJECTED;
            $environmentChanged = $environment !== $storedEnvironment;

            // Keep the auth-rejection vs technical-failure distinction all the way through: a
            // TECHNICAL_FAILURE means validation never got a straight answer, not that the key was
            // wrong, so neither message here may say the key was rejected when it was not.
            if ($keyRejected) {
                $errors[] = $this->apiKeyRejectedMessage($environment);
            } else {
                $errors[] = $this->module->l('There was a problem completing setup, please click Save again to retry (re-enter your API key first if you just changed it, since it is not carried over automatically).');
            }

            // Say what is still in effect, instead of leaving the merchant to guess why the form
            // came back showing the old values.
            if ($environmentChanged && $keyRejected) {
                $errors[] = sprintf(
                    $this->module->l('The environment was NOT switched to %1$s — this shop stays on %2$s, because the API key for %1$s was not accepted. Enter a valid %1$s API key and save again to switch.'),
                    $environment->label(),
                    $storedEnvironment->label(),
                );
            } elseif ($environmentChanged) {
                $errors[] = sprintf(
                    $this->module->l('The environment was NOT switched to %1$s — this shop stays on %2$s, because validation could not be completed (a technical problem, not necessarily the API key). Please click Save again to retry.'),
                    $environment->label(),
                    $storedEnvironment->label(),
                );
            } elseif ($keyRejected && $this->config->getApiKey($storedEnvironment) !== '') {
                $errors[] = $this->module->l('The submitted API key was rejected — the previously saved key remains active.');
            }
        }

        $confirmations[] = $this->module->l('Configuration updated.');

        return ['confirmations' => $confirmations, 'errors' => $errors];
    }

    /**
     * Actionable copy for an auth-rejected API key: names the merchant panel matching the
     * selected environment and the exact shop domain sent as Origin — both things the plugin
     * already knows locally, no backend response text involved.
     */
    private function apiKeyRejectedMessage(Environment $environment): string
    {
        return ApiKeyRejectionMessage::build(
            $this->module->l('Your API key was rejected. Check that it was generated in the %1$s environment\'s Simply.IN merchant panel (%2$s), and that it is bound to the exact domain this shop sends as its Origin: "%3$s".'),
            $environment,
            (string) Context::getContext()->shop->getBaseURL(true),
        );
    }

    /**
     * @return array<string, int>
     */
    private function buildCarrierChoices(): array
    {
        $choices = ['--' => 0];
        foreach ($this->module->getAllAvailableShippingMethods() as $carrier) {
            if (!is_array($carrier) || !isset($carrier['id_carrier'], $carrier['name'])) {
                continue;
            }
            $choices[(string) $carrier['name']] = (int) $carrier['id_carrier'];
        }

        return $choices;
    }

    /**
     * @return array<string, int>
     */
    private function buildOrderStateChoices(): array
    {
        $idLang = (int) Context::getContext()->language->id;
        $choices = [];
        foreach (OrderState::getOrderStates($idLang) as $state) {
            if (!isset($state['id_order_state'], $state['name'])) {
                continue;
            }
            $choices[(string) $state['name']] = (int) $state['id_order_state'];
        }

        return $choices;
    }

    private function cronLink(): string
    {
        return Context::getContext()->link->getModuleLink(
            $this->module->name,
            'cron',
            ['secure_key' => $this->module->getCronSecureKey()],
            true,
        );
    }
}
