<?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\Webhook;

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

/**
 * Reads the two things the plugin needs out of a received payment notification: which attempt it
 * concerns, and the key it should be deduplicated by.
 *
 * ## The identifier, and why it is not assumed
 *
 * The plugin mints a payment id per attempt and sends it with the payment request, so an answer
 * that echoes it back names the exact attempt it belongs to. What this code cannot establish from
 * its own side is the FIELD NAME the answer uses. So the candidates below are tried in order and
 * the value is handed to the caller as nothing more than a candidate - the caller confirms it by
 * looking it up among the attempts this plugin actually recorded, and ignores it otherwise.
 *
 * That is the difference between a guess that fails loudly and one that fails silently. If none of
 * the candidates is the real name, the lookup simply never matches, the caller falls back to its
 * previous behaviour and says so in the log - rather than this returning a confident-looking key
 * built from a field that was never there.
 *
 * ## The deduplication key
 *
 * The payload carries no event identifier of its own, so the key is built from what is there, and
 * what it is built from decides what counts as "the same event": too coarse and distinct events are
 * silently swallowed, too fine and a redelivery is applied twice.
 *
 * The per-attempt payment id is what makes this work. The order reference does not: every attempt
 * on one order shares it, so keying on it collapsed two separate refusals of one order into a
 * single event and dropped the second without trace - which stops being a rare edge and becomes the
 * normal case once an order can carry several attempts.
 *
 * The status stays in the key beside it, so the sequence a single payment legitimately produces -
 * pending, then its outcome - remains distinct events rather than one event redelivered. The amount
 * is deliberately NOT in the key: it cannot change for a given payment, so it adds nothing to
 * identity, and including it would make the key sensitive to how the number happens to be
 * serialized between two deliveries of the same event.
 */
final class WebhookEventKey
{
    /**
     * Field names a per-attempt payment id may arrive under, most specific first. Tried in order;
     * none of them is trusted without the caller's own lookup.
     *
     * @var list<string>
     */
    private const PAYMENT_ID_FIELDS = ['merchantPaymentId', 'paymentId'];

    /**
     * A candidate per-attempt payment id, or '' when the payload offers none.
     *
     * @param array<string, mixed> $payload the decoded notification body
     */
    public static function attemptPaymentId(array $payload): string
    {
        foreach (self::PAYMENT_ID_FIELDS as $field) {
            $value = self::readString($payload, $field);
            if ($value !== '') {
                return $value;
            }
        }

        return '';
    }

    /**
     * @param array<string, mixed> $payload the decoded notification body
     *
     * @return string a non-empty key, so the caller never has to handle "no key available"
     */
    public static function derive(array $payload): string
    {
        // An identifier chosen by the sender is authoritative and needs no derivation. Kept for
        // both spellings, and only when non-empty: a present-but-blank field is not an identifier.
        foreach (['event_id', 'eventId'] as $key) {
            $value = self::readString($payload, $key);
            if ($value !== '') {
                return $value;
            }
        }

        $paymentId = self::attemptPaymentId($payload);
        if ($paymentId !== '') {
            return hash('sha256', $paymentId . '|' . self::readString($payload, 'status'));
        }

        // No per-payment identifier under any name we recognise. The order reference is then all
        // there is to key on, and it cannot tell two attempts on one order apart - so a second
        // notification differing only by attempt is treated as a redelivery of the first. That is
        // the known cost of a payload we cannot identify, not a choice between equals.
        return hash('sha256', self::readString($payload, 'title') . '|' . self::readString($payload, 'status'));
    }

    /**
     * @param array<string, mixed> $payload
     */
    private static function readString(array $payload, string $key): string
    {
        $value = $payload[$key] ?? null;

        return is_scalar($value) ? (string) $value : '';
    }
}
