<?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
 */

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

class SimplyInPayments extends ObjectModel
{
    use SimplyInInstallSqlTrait;

    public $id;

    public $cart_id;

    public $type;

    public $date_add;

    public $date_upd;

    public $status;

    public $uuid;

    public $payment_provider;

    public $card_provider;

    public $provider_logo;

    public $logo;

    /**
     * The normalized decline reason SIN.CORE reports for this attempt (a {@see
     * \SimplyIn\Payment\PaymentProviderErrorReason} member), when it reported one. Only ever set by
     * {@see applyStatusByUuid()}/{@see markAsRejected()} with an already-sanitized value - never
     * written raw from an external payload.
     */
    public $reject_reason;

    public static $definition = [
        'table' => 'simplyin_payments',
        'primary' => 'id_simplyin_payments',
        'multilang' => false,
        'multilang_shop' => false,
        'fields' => [
            'cart_id' => [
                'type' => self::TYPE_INT,
                'index' => true,
            ],
            'type' => [
                'type' => self::TYPE_STRING,
                'size' => 36,
            ],
            'date_add' => [
                'type' => self::TYPE_DATE,
            ],
            'date_upd' => [
                'type' => self::TYPE_DATE,
            ],
            'status' => [
                'type' => self::TYPE_STRING,
                'size' => 50,
            ],
            'uuid' => [
                'type' => self::TYPE_STRING,
                'size' => 36,
            ],
            'payment_provider' => [
                'type' => self::TYPE_STRING,
                'size' => 36,
            ],
            'card_provider' => [
                'type' => self::TYPE_STRING,
                'size' => 36,
            ],
            'provider_logo' => [
                'type' => self::TYPE_STRING,
                'size' => 255,
            ],
            'logo' => [
                'type' => self::TYPE_STRING,
                'size' => 255,
            ],
            'reject_reason' => [
                'type' => self::TYPE_STRING,
                'size' => 40,
            ],
        ],
    ];

    /**
     * Random RFC-4122 v4 payment id.
     *
     * Historically this was a deterministic md5 slice of order id + reference, which is
     * guessable — and the backend's unauthenticated `payments/getPaymentStatus` endpoint
     * returns the payment status and amount for a given paymentId, so a predictable id
     * leaks order data. Nothing relies on the determinism: the id is persisted on the
     * payment row at creation (addByCart) and every later read (status polling,
     * reconcile, webhooks) takes it from the database, never recomputes it.
     *
     * The $order parameter is kept for signature compatibility.
     *
     * @param object $order unused
     *
     * @return string
     */
    public static function getUUID($order)
    {
        $bytes = random_bytes(16);
        $bytes[6] = chr((ord($bytes[6]) & 0x0F) | 0x40);
        $bytes[8] = chr((ord($bytes[8]) & 0x3F) | 0x80);

        return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
    }

    /**
     * Values are cut to their column widths before the row is built.
     *
     * Not cosmetic: ObjectModel::save() THROWS on a field-size violation, and both of these fields
     * come from outside. The payment type is capped far above this column's width on its way into
     * the session, and the status is whatever the payment service reported, passed through
     * verbatim. A throw here would abort the request AFTER a payment may already exist, leaving it
     * unrecorded - and an unrecorded payment is invisible to every guard that reads these rows.
     */
    private static function fitColumn($value, $field)
    {
        $size = self::$definition['fields'][$field]['size'] ?? null;

        return $size === null ? (string) $value : substr((string) $value, 0, (int) $size);
    }

    public static function addByCart($cart, $type, $status, $uuid, $payment_provider, $card_provider, $provider_logo, $image)
    {

        self::ensureSchema();
        $simplyin_payment = new self();
        $simplyin_payment->cart_id = (int) $cart->id;
        $simplyin_payment->type = pSQL(self::fitColumn($type, 'type'));
        $simplyin_payment->status = pSQL(self::fitColumn($status, 'status'));
        $simplyin_payment->uuid = pSQL($uuid);
        $simplyin_payment->date_add = date('Y-m-d H:i:s');
        $simplyin_payment->payment_provider = pSQL($payment_provider);
        $simplyin_payment->card_provider = pSQL($card_provider);
        $simplyin_payment->provider_logo = pSQL($provider_logo);
        $simplyin_payment->logo = pSQL($image);

        return $simplyin_payment->save();
    }

    public static function getPaymentInfo($id_cart)
    {
        // ORDER BY picks the newest of several attempts for one cart. No explicit LIMIT:
        // getValue()/getRow() append " LIMIT 1" themselves ("LIMIT 1 LIMIT 1" = SQL syntax error).
        //
        // Every read and every markAs* helper resolves to this newest row, so an answer about an
        // OLDER attempt lands on the newest one. Each attempt does have its own row and its own
        // payment id, so the data to attribute correctly is there - what is missing is an attempt
        // identifier on the answering side: the webhook is matched by order reference alone and
        // carries nothing to tell one attempt from another. Fixing attribution therefore needs the
        // answer to name the attempt, not a change here, which is why this is left as it stands.
        $sql = '
            SELECT      id_simplyin_payments
            FROM        ' . _DB_PREFIX_ . 'simplyin_payments
            WHERE       cart_id = ' . (int) $id_cart . '
            ORDER BY    id_simplyin_payments DESC';
        $id_simplyin_payment = Db::getInstance()->getValue($sql);
        if ($id_simplyin_payment) {
            return new self((int) $id_simplyin_payment);
        }

        return false;
    }

    /**
     * The stored status of every attempt recorded for one cart, not just the newest.
     *
     * Reading them all is what lets a caller ask "is anything still open here?" rather than only
     * "what happened last?" — the two differ once an order carries more than one attempt, and the
     * difference is what keeps a second payment off an order whose first one is still running.
     *
     * A read failure returns an empty list, indistinguishable from "no attempts". Both mean the
     * same thing to every caller: we know nothing about this order's payments, so nothing may be
     * decided in favour of acting on it.
     *
     * @param int $id_cart
     *
     * @return array<int, string>
     */
    public static function getAttemptStatuses($id_cart)
    {
        $rows = Db::getInstance()->executeS(
            'SELECT status FROM ' . _DB_PREFIX_ . 'simplyin_payments WHERE cart_id = ' . (int) $id_cart,
        );
        if (!is_array($rows)) {
            return [];
        }

        $statuses = [];
        foreach ($rows as $row) {
            // A row with no readable status is kept as an empty string rather than skipped: it is
            // an attempt that exists, and an unrecognized status counts as still open.
            $statuses[] = (string) ($row['status'] ?? '');
        }

        return $statuses;
    }

    /**
     * Attempts still open — not just {@see \SimplyIn\Payment\PaymentStatus::AWAITING_CONFIRMATION},
     * but any status that has not reached a terminal one, including a locally-stored `PENDING` a
     * retry can be left stranded in (see {@see \SimplyIn\Payment\PaymentReconciliationSweep} for why
     * that widening matters: a retry redirected off the confirmation page used to leave exactly such
     * a row with nothing left to ever come back to it). Oldest first, bounded to a moving window and
     * a batch size rather than the whole table.
     *
     * The exclusion is applied IN this SQL, not filtered afterwards in PHP: the batch LIMIT must
     * bound the OPEN candidates, not the raw rows, or a window full of already-settled attempts
     * would crowd every real candidate out of the batch. It is still sourced from one place -
     * {@see \SimplyIn\Payment\PaymentStatus::terminalStatuses()}, the exact list {@see
     * \SimplyIn\Payment\PaymentStatus::isTerminal()} reads - so this query and the retry policy can
     * never drift onto two different ideas of "terminal". A read failure returns an empty list, same
     * as every other read helper here, so a DB hiccup skips this cron tick instead of acting on a
     * partial result.
     *
     * @param int $maxAgeDays how many days back the sweep still bothers looking; an attempt older
     *                        than this has already cleared every grace period
     *                        {@see \SimplyIn\Payment\PaymentReconciliationPolicy} defines
     * @param int $limit      caps the batch one cron tick processes
     *
     * @return list<array{cart_id: int, uuid: string, type: string, date_add: string}>
     */
    public static function getStaleOpenAttempts($maxAgeDays, $limit)
    {
        $maxAgeDays = max(0, (int) $maxAgeDays);
        $limit = max(1, (int) $limit);

        $terminal = "'" . implode("', '", array_map('pSQL', \SimplyIn\Payment\PaymentStatus::terminalStatuses())) . "'";

        $rows = Db::getInstance()->executeS(
            'SELECT cart_id, uuid, type, date_add '
            . 'FROM ' . _DB_PREFIX_ . 'simplyin_payments '
            . "WHERE uuid != '' "
            // NULL is spelled out because SQL would otherwise drop those rows silently: `NULL NOT IN
            // (...)` is NULL, not true, so a row with no status would never be a candidate - the
            // exact opposite of widening this to "anything that has not reached a terminal status".
            // getAttemptStatuses() already reads a missing status as open (it maps it to ''), so
            // excluding it here would have the model contradicting itself on legacy rows.
            . 'AND (status IS NULL OR status NOT IN (' . $terminal . ')) '
            . 'AND date_add >= DATE_SUB(NOW(), INTERVAL ' . $maxAgeDays . ' DAY) '
            . 'ORDER BY date_add ASC '
            . 'LIMIT ' . $limit,
        );

        if (!is_array($rows)) {
            return [];
        }

        $result = [];
        foreach ($rows as $row) {
            $result[] = [
                'cart_id' => (int) ($row['cart_id'] ?? 0),
                'uuid' => (string) ($row['uuid'] ?? ''),
                'type' => (string) ($row['type'] ?? ''),
                'date_add' => (string) ($row['date_add'] ?? ''),
            ];
        }

        return $result;
    }

    /**
     * Apply a status to ONE attempt, identified by the payment id the plugin itself minted for it.
     *
     * This is the difference between "an answer arrived for this order" and "an answer arrived for
     * this attempt". Every other writer here resolves to the newest row for the cart, so an answer
     * about an earlier attempt lands on a later - possibly still live - one, and a later attempt
     * wrongly stamped as failed is what re-opens a retry that must stay closed.
     *
     * Deliberately keyed on a value WE generated and stored, never on a field name assumed to exist
     * in someone else's payload: the caller offers a candidate identifier and this reports whether
     * it actually matched a stored attempt. A false return means "not identified" - the caller must
     * then fall back to its previous behaviour rather than assume the write happened.
     *
     * The column carries no uniqueness constraint, so the first matching row wins rather than the
     * match being provably singular. Nothing rests on that: the ids are minted per attempt from
     * random bytes, and a collision would mean an answer landing on the wrong row of one cart -
     * the same failure the caller's fallback already has to survive.
     *
     * @param string $uuid         the stored attempt's payment id
     * @param string $status       the status to record
     * @param string $rejectReason an already-sanitized {@see
     *                             \SimplyIn\Payment\PaymentProviderErrorReason} member, or '' to
     *                             leave the attempt's stored reason untouched - never wiped by a
     *                             later write that carries none of its own (e.g. a status re-applied
     *                             with no reason available at that call site)
     *
     * @return bool true when a stored attempt was matched and updated
     */
    public static function applyStatusByUuid($uuid, $status, $rejectReason = '')
    {
        $uuid = (string) $uuid;
        if ($uuid === '') {
            return false;
        }

        self::ensureSchema();
        $table = _DB_PREFIX_ . self::$definition['table'];
        $id = Db::getInstance()->getValue(
            'SELECT id_simplyin_payments FROM ' . $table . " WHERE uuid = '" . pSQL($uuid) . "'",
        );
        if (!$id) {
            return false;
        }

        $object = new self((int) $id);
        if (!$object->id) {
            return false;
        }
        $object->status = pSQL(self::fitColumn($status, 'status'));
        self::applyRejectReason($object, $rejectReason);

        return (bool) $object->save();
    }

    public static function markAsPaid($id_cart)
    {
        $object = self::getPaymentInfo($id_cart);
        if ($object && $object->id) {

            self::ensureSchema();
            $object->status = 'PAID';
            $object->save();
        }
    }

    /**
     * @param string $rejectReason an already-sanitized {@see
     *                             \SimplyIn\Payment\PaymentProviderErrorReason} member, or '' to
     *                             leave the attempt's stored reason as it is
     */
    public static function markAsRejected($id_cart, $rejectReason = '')
    {
        $object = self::getPaymentInfo($id_cart);
        if ($object && $object->id) {
            self::ensureSchema();
            $object->status = 'REJECTED';
            self::applyRejectReason($object, $rejectReason);
            $object->save();
        }
    }

    /**
     * Shared by every writer that can carry a reject reason - never wipes a reason already stored
     * on the row with a blank one, so a later write that has none of its own leaves an earlier
     * capture in place.
     *
     * @param string $rejectReason an already-sanitized {@see
     *                             \SimplyIn\Payment\PaymentProviderErrorReason} member, or '' to
     *                             leave the row's stored reason as it is
     */
    private static function applyRejectReason(self $object, $rejectReason)
    {
        $rejectReason = (string) $rejectReason;
        if ($rejectReason !== '') {
            $object->reject_reason = pSQL(self::fitColumn($rejectReason, 'reject_reason'));
        }
    }

    public static function markAsCancelled($id_cart)
    {
        $object = self::getPaymentInfo($id_cart);
        if ($object && $object->id) {
            self::ensureSchema();
            $object->status = 'CANCELLED';
            $object->save();
        }
    }

    public static function markAsRefunded($id_cart)
    {
        $object = self::getPaymentInfo($id_cart);
        if ($object && $object->id) {
            self::ensureSchema();
            $object->status = 'REFUNDED';
            $object->save();
        }
    }

    // ensureSchema() is inherited from SimplyInInstallSqlTrait (used above) - this model no longer
    // carries its own copy. SimplyInOrder's auth_token column added the same generic method to that
    // trait; keeping a second, byte-identical copy here would have let the two drift on the next
    // field-type addition.

    public static function upgrade202()
    {
        $sql = '
            ALTER TABLE `' . _DB_PREFIX_ . self::$definition['table'] . '`
            ADD COLUMN `provaider_logo` VARCHAR(255) NULL,
            ADD COLUMN `logo` VARCHAR(255) NULL
        ';

        return Db::getInstance()->execute($sql);
    }
}
