<?php
/**
 * Invoice.php
 *
 * @since      1.0.0
 * @author     alfiopiccione <alfio.piccione@gmail.com>
 * @license    http://opensource.org/licenses/gpl-2.0.php GPL v2
 *
 * Copyright (C) 2025 POP
 *
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the GNU General Public License
 * as published by the Free Software Foundation; either version 2
 * of the License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
 */

namespace WcElectronInvoice\Functions;

if (! defined('ABSPATH')) {
    exit; // Exit if accessed directly.
}

use WcElectronInvoice\Admin\Settings\OptionPage;
use WcElectronInvoice\Providers\OrderQuery;
use WcElectronInvoice\Utils\TimeZone;

/**
 * getNextInvoiceNumber
 *
 * @param $optionKeyName
 *
 * @return false|int
 */
function getNextInvoiceNumber($optionKeyName)
{
    global $wpdb;

    $optionPage = OptionPage::init();
    $optionName = $optionPage->optionsName;

    $maxAttempts = 5; // How many times to retry in case of conflict
    $attempt     = 0;
    $scanLimit   = (int)apply_filters('wc_el_inv-next_number_scan_limit', 50, $optionKeyName);
    if ($scanLimit < 1) {
        $scanLimit = 1;
    }

    while ($attempt < $maxAttempts) {
        $attempt++;

        // 1) Read the corresponding line in the DB
        // (NOTE: if that option doesn't exist at all, we create it with a basic array)
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $row = $wpdb->get_row(
            $wpdb->prepare(
                "SELECT option_id, option_value 
                 FROM {$wpdb->options}
                 WHERE option_name = '%s'
                 LIMIT 1",
                $optionName
            )
        );

        if (! $row) {
            // There is no option: I create it with the array that already has the subkey initialized to 1
            $arr = array($optionPage::$optionKey . $optionKeyName => 1);
            // Save with add_option (or update_option), and then "continue" to reread next time
            add_option($optionName, $arr, '', 'yes');
            continue;
        }

        // 2) We found the row. $row->option_value is the serialized string of the array
        $oldSerializedValue = $row->option_value;
        $arr                = maybe_unserialize($oldSerializedValue);

        // If for some reason it is not an array, we force an empty array
        if (! is_array($arr)) {
            $arr = array();
        }

        // 3) The subkey to manage is "we_inv_number_next_invoice"
        // or with prefix self::$optionKey + $optionKeyName
        $subKey = $optionPage::$optionKey . $optionKeyName;

        // Se non esiste la sub-chiave, la inizializziamo a 1
        if (! isset($arr[$subKey])) {
            $arr[$subKey] = 1;
        }

        // Current value
        $currentValue = (int)$arr[$subKey];
        if ($currentValue < 1) {
            $currentValue = 1;
        }

        $lastAssignedCandidate = getLastAssignedInvoiceCandidateForSeries($optionKeyName);
        $lastAssigned          = (int)$lastAssignedCandidate['number'];
        if ($lastAssigned > 0 && $currentValue <= $lastAssigned) {
            $currentValue = $lastAssigned + 1;
        }

        $checked = 0;
        while (
            $checked < $scanLimit
            &&
            isInvoiceNumberAlreadyAssigned($currentValue, $optionKeyName)
        ) {
            $currentValue++;
            $checked++;
        }

        if (
            $checked >= $scanLimit
            &&
            isInvoiceNumberAlreadyAssigned($currentValue, $optionKeyName)
        ) {
            error_log("[WcElectronInvoice] Unable to align next invoice number for {$optionKeyName}: scan limit reached at {$currentValue}");

            return false;
        }

        // Let's increase by 1
        $newValue     = $currentValue + 1;
        $arr[$subKey] = $newValue;

        // 4) Serialize the new array
        $newSerializedValue = maybe_serialize($arr);

        // 5) We try the UPDATE with "optimistic concurrency":
        //// we update ONLY if option_value has not changed in the meantime.
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $updatedRows = $wpdb->query(
            $wpdb->prepare(
                "UPDATE {$wpdb->options}
                 SET option_value = '%s'
                 WHERE option_id   = '%d'
                   AND option_value = '%s'",
                $newSerializedValue,
                $row->option_id,
                $oldSerializedValue
            )
        );

        if ($updatedRows === 1) {
            // SUCCESS: means that no other process has changed it after our reading
            // We return the "old" value, i.e. $currentValue
            return $currentValue;
        }
        // Otherwise, another process has modified it in the meantime. Let's try the loop again.
    }

    // If after X attempts we have failed to save, we return false.
    return false;
}

/**
 * Return the visible custom field key used for the SdI filename progressive.
 */
function getInvoiceFileProgressiveMetaKey(): string
{
    return 'invoice_progressive_file_number';
}

/**
 * Return the option key used for the global filename progressive sequence.
 */
function getInvoiceFileProgressiveSequenceOptionKey(): string
{
    return 'wc_el_inv-invoice_file_progressive_sequence';
}

/**
 * Format the filename progressive as a 5-char uppercase base36 code.
 */
function formatInvoiceFileProgressiveCode(int $sequence): string
{
    $sequence = max(0, $sequence);
    $code     = strtoupper(base_convert((string)$sequence, 10, 36));

    if (strlen($code) > 5) {
        $code = substr($code, -5);
    }

    return str_pad($code, 5, '0', STR_PAD_LEFT);
}

/**
 * Reserve the next global filename progressive sequence.
 */
function reserveInvoiceFileProgressiveSequence(): int
{
    $lockKey = buildDbLockKey('invoice', 'file_progressive_sequence');
    $locked  = acquireDbLock($lockKey, 5);

    try {
        $optionKey = getInvoiceFileProgressiveSequenceOptionKey();
        $current   = (int)get_option($optionKey, 0);
        if ($current < 0) {
            $current = 0;
        }

        $next = $current + 1;
        update_option($optionKey, $next);

        return $next;
    } finally {
        if ($locked) {
            releaseDbLock($lockKey);
        }
    }
}

/**
 * Read or reserve the 5-char filename progressive for an order.
 *
 * When $reserveNew is true a new code is always generated and persisted, so
 * each new transmission can use a different SdI filename.
 *
 * @param mixed $order
 */
function getOrderInvoiceFileProgressiveNumber($order, bool $reserveNew = false): string
{
    if (! is_object($order) || ! method_exists($order, 'get_id') || ! method_exists($order, 'get_meta')) {
        return '';
    }

    $metaKey = getInvoiceFileProgressiveMetaKey();
    $stored  = strtoupper(trim((string)$order->get_meta($metaKey)));

    if (! $reserveNew && '' !== $stored && preg_match('/^[A-Z0-9]{5}$/', $stored)) {
        return apply_filters('wc_el_inv-invoice_progressive_file_number', $stored, $order->get_id(), 'xml');
    }

    $code = formatInvoiceFileProgressiveCode(reserveInvoiceFileProgressiveSequence());

    if (method_exists($order, 'update_meta')) {
        $order->update_meta($metaKey, $code);
    } elseif (method_exists($order, 'update_meta_data')) {
        $order->update_meta_data($metaKey, $code);
    }

    if (method_exists($order, 'save')) {
        $order->save();
    }

    return apply_filters('wc_el_inv-invoice_progressive_file_number', $code, $order->get_id(), 'xml');
}

/**
 * Check if candidate invoice number is already assigned.
 *
 * @param int    $number
 * @param string $optionKeyName
 *
 * @return bool
 */
function isInvoiceNumberAlreadyAssigned(int $number, string $optionKeyName): bool
{
    if ($number < 1) {
        return false;
    }

    global $wpdb;

    $exists = false;

    if ('number_next_invoice' === $optionKeyName || 'number_next_receipt' === $optionKeyName) {
        $lastAssignedCandidate = getLastAssignedInvoiceCandidateForSeries($optionKeyName);
        $lastAssigned          = (int)$lastAssignedCandidate['number'];
        $exists       = ($lastAssigned > 0 && $lastAssigned === $number);

        if (! $exists && isset($wpdb) && is_object($wpdb) && isset($wpdb->postmeta, $wpdb->posts) && ! function_exists('wc_get_orders')) {
            $boundary = getNextNumberSeriesResetBoundary($optionKeyName);
            $segmentMetaKey = getNextNumberSeriesSegmentMetaKey($optionKeyName);
            $typeWhere = '';
            if ('number_next_receipt' === $optionKeyName) {
                $typeWhere = " AND type_meta.meta_value = 'receipt' ";
            } else {
                $typeWhere = " AND (type_meta.meta_id IS NULL OR type_meta.meta_value = '' OR type_meta.meta_value = 'invoice') ";
            }

            $boundaryWhere = '';
            $segmentJoin = '';
            if (! empty($boundary['segment_id'])) {
                $segmentJoin = " LEFT JOIN {$wpdb->postmeta} segment_meta ON segment_meta.post_id = posts.ID AND segment_meta.meta_key = '" . esc_sql($segmentMetaKey) . "' ";
                $boundaryWhere = " AND segment_meta.meta_value = '" . esc_sql((string)$boundary['segment_id']) . "' ";
            }

            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $exists = (bool)$wpdb->get_var(
                $wpdb->prepare(
                    "SELECT inv_meta.meta_id
                     FROM {$wpdb->postmeta} inv_meta
                     INNER JOIN {$wpdb->posts} posts ON posts.ID = inv_meta.post_id
                     LEFT JOIN {$wpdb->postmeta} type_meta ON type_meta.post_id = posts.ID AND type_meta.meta_key = '_billing_choice_type'
                     {$segmentJoin}
                     WHERE inv_meta.meta_key = 'order_number_invoice'
                       AND CAST(inv_meta.meta_value AS UNSIGNED) = %d
                       AND posts.post_type = 'shop_order'
                       AND posts.post_status <> 'trash'
                       {$typeWhere}
                       {$boundaryWhere}
                     LIMIT 1",
                    $number
                )
            );
        }
    }

    /**
     * Filter duplicate-number lookup so integrations/providers can extend check.
     *
     * @param bool   $exists
     * @param int    $number
     * @param string $optionKeyName
     */
    return (bool)apply_filters('wc_el_inv-next_number_already_assigned', $exists, $number, $optionKeyName);
}

/**
 * Resolve shared last assigned invoice candidate across providers for current series.
 *
 * @param string $optionKeyName
 *
 * @return array{number:int,timestamp:int,provider:string,order_id:int}
 */
function getLastAssignedInvoiceCandidateForSeries(string $optionKeyName): array
{
    $candidate = getLastAssignedInvoiceCandidateForCurrentSeries($optionKeyName);
    $candidate = normalizeLastAssignedCandidate($candidate);
    $seriesContext = getNextNumberSeriesContext($optionKeyName);
    $resetBoundary = getNextNumberSeriesResetBoundary($optionKeyName, null, $seriesContext);

    /**
     * Allow providers/integrations to contribute last assigned candidate for the active series.
     *
     * @param array  $candidate     Candidate array ['number'=>int,'timestamp'=>int,'provider'=>string,'order_id'=>int]
     * @param string $optionKeyName
     */
    $candidate = apply_filters('wc_el_inv-next_number_last_assigned_candidate_for_series', $candidate, $optionKeyName);
    $candidate = normalizeLastAssignedCandidate($candidate);
    $candidate = applyNextNumberResetBoundaryToCandidate($candidate, $resetBoundary);

    // Backward compatibility for integrations still using the legacy integer filter.
    if ((int)$candidate['number'] < 1 && empty($resetBoundary['reset_at'])) {
        $legacyNumber = (int)apply_filters(
            'wc_el_inv-next_number_last_assigned_for_series',
            0,
            $optionKeyName,
            $candidate
        );
        if ($legacyNumber > 0) {
            $candidate['number'] = $legacyNumber;
        }
    }

    return $candidate;
}

/**
 * Resolve last assigned invoice number for the current series.
 *
 * @param string $optionKeyName
 *
 * @return int
 */
function getLastAssignedInvoiceNumberForCurrentSeries(string $optionKeyName): int
{
    $candidate = getLastAssignedInvoiceCandidateForCurrentSeries($optionKeyName);

    return (int)$candidate['number'];
}

/**
 * Resolve last assigned invoice candidate (number + timestamp) for current WooCommerce series context.
 *
 * @param string $optionKeyName
 *
 * @return array{number:int,timestamp:int,provider:string,order_id:int}
 */
function getLastAssignedInvoiceCandidateForCurrentSeries(string $optionKeyName): array
{
    if (! function_exists('wc_get_orders') || ! function_exists('wc_get_order')) {
        return [
            'number'    => 0,
            'timestamp' => 0,
            'provider'  => '',
            'order_id'  => 0,
        ];
    }

    $recentOrdersLimit = (int)apply_filters('wc_el_inv-next_number_recent_orders_limit', 200, $optionKeyName, 0);
    if ($recentOrdersLimit < 1) {
        $recentOrdersLimit = 1;
    }

    $ordersArgs = [
        'type'    => 'shop_order',
        'status'  => 'any',
        'limit'   => $recentOrdersLimit,
        'return'  => 'ids',
        'orderby' => 'date',
        'order'   => 'DESC',
    ];

    /**
     * Filter recent-orders query used to detect duplicate numbers.
     *
     * @param array  $ordersArgs
     * @param string $optionKeyName
     * @param int    $number
     */
    $ordersArgs = apply_filters('wc_el_inv-next_number_recent_orders_args', $ordersArgs, $optionKeyName, 0);

    $seriesContext = getNextNumberSeriesContext($optionKeyName);
    $resetBoundary = getNextNumberSeriesResetBoundary($optionKeyName, null, $seriesContext);
    $recentOrderIDs = wc_get_orders($ordersArgs);
    if (! is_array($recentOrderIDs)) {
        return [
            'number'    => 0,
            'timestamp' => 0,
            'provider'  => '',
            'order_id'  => 0,
        ];
    }

    foreach ($recentOrderIDs as $orderID) {
        $order = is_object($orderID) ? $orderID : wc_get_order($orderID);
        if (! is_object($order) || ! method_exists($order, 'get_meta')) {
            continue;
        }

        $invoiceNumber = (int)$order->get_meta('order_number_invoice');
        if ($invoiceNumber < 1) {
            continue;
        }

        $choiceType = (string)$order->get_meta('_billing_choice_type');
        $isReceipt  = ('receipt' === $choiceType);
        if ('number_next_receipt' === $optionKeyName && ! $isReceipt) {
            continue;
        }
        if ('number_next_invoice' === $optionKeyName && $isReceipt) {
            continue;
        }

        if (isOrderInNextNumberSeries($order, $seriesContext)) {
            $candidateTimestamp = getOrderComparableTimestamp($order);
            if (! isOrderInsideNextNumberResetBoundary($order, $optionKeyName, $resetBoundary, $candidateTimestamp)) {
                continue;
            }

            $orderID = method_exists($order, 'get_id') ? (int)$order->get_id() : (int)$orderID;
            return [
                'number'    => $invoiceNumber,
                'timestamp' => $candidateTimestamp,
                'provider'  => 'woocommerce',
                'order_id'  => $orderID,
            ];
        }
    }

    return [
        'number'    => 0,
        'timestamp' => 0,
        'provider'  => '',
        'order_id'  => 0,
    ];
}

/**
 * Normalize candidate shape and scalar types.
 *
 * @param mixed $candidate
 *
 * @return array{number:int,timestamp:int,provider:string,order_id:int}
 */
function normalizeLastAssignedCandidate($candidate): array
{
    if (! is_array($candidate)) {
        $candidate = [];
    }

    return [
        'number'    => isset($candidate['number']) ? (int)$candidate['number'] : 0,
        'timestamp' => isset($candidate['timestamp']) ? (int)$candidate['timestamp'] : 0,
        'provider'  => isset($candidate['provider']) ? (string)$candidate['provider'] : '',
        'order_id'  => isset($candidate['order_id']) ? (int)$candidate['order_id'] : 0,
    ];
}

/**
 * Pick the better candidate using chronological priority.
 *
 * @param array $current
 * @param array $incoming
 *
 * @return array{number:int,timestamp:int,provider:string,order_id:int}
 */
function pickBetterLastAssignedCandidate(array $current, array $incoming): array
{
    $current  = normalizeLastAssignedCandidate($current);
    $incoming = normalizeLastAssignedCandidate($incoming);

    if ($incoming['number'] < 1) {
        return $current;
    }

    if ($incoming['timestamp'] > $current['timestamp']) {
        return $incoming;
    }

    if ($incoming['timestamp'] === $current['timestamp']) {
        if ($incoming['number'] > $current['number']) {
            return $incoming;
        }

        if ($incoming['number'] === $current['number'] && $incoming['order_id'] > $current['order_id']) {
            return $incoming;
        }
    }

    return $current;
}

/**
 * Resolve a comparable timestamp for numeration ordering from an order object.
 *
 * @param mixed $order
 *
 * @return int
 */
function getOrderComparableTimestamp($order): int
{
    if (! is_object($order)) {
        return 0;
    }

    $date = null;
    if (method_exists($order, 'get_date_completed')) {
        $date = $order->get_date_completed();
    }

    $hasValidDate = false;
    if ($date instanceof \DateTimeInterface) {
        $hasValidDate = true;
    } elseif (is_numeric($date)) {
        $hasValidDate = true;
    } elseif (is_string($date) && '' !== trim($date)) {
        $hasValidDate = true;
    }

    if (! $hasValidDate && method_exists($order, 'get_date_created')) {
        $date = $order->get_date_created();
    }

    if ($date instanceof \DateTimeInterface) {
        return (int)$date->getTimestamp();
    }

    if (is_numeric($date)) {
        return (int)$date;
    }

    if (is_string($date) && '' !== trim($date)) {
        $timestamp = strtotime($date);
        if (false !== $timestamp) {
            return (int)$timestamp;
        }
    }

    return 0;
}

/**
 * Build current series context (type/prefix/year token) for next-number alignment.
 *
 * @param string $optionKeyName
 *
 * @return array{numeration_type:string,prefix:string,suffix_year_enabled:bool,reset_year_enabled:bool,year_scope_enabled:bool,suffix_year_format:string,year_token:string}
 */
function getNextNumberSeriesContext(string $optionKeyName): array
{
    return getNextNumberSeriesContextFromOptions($optionKeyName);
}

/**
 * Build current series context (type/prefix/year token) for next-number alignment using a specific options snapshot.
 *
 * @param string     $optionKeyName
 * @param array|null $optionsSnapshot
 *
 * @return array{numeration_type:string,prefix:string,suffix_year_enabled:bool,reset_year_enabled:bool,year_scope_enabled:bool,suffix_year_format:string,year_token:string}
 */
function getNextNumberSeriesContextFromOptions(string $optionKeyName, ?array $optionsSnapshot = null): array
{
    $numerationType = ('number_next_receipt' === $optionKeyName) ? 'receipt' : 'invoice';

    $prefix = (string)getNextNumberSeriesOptionValue("prefix_{$numerationType}_number", '', $optionsSnapshot);
    $prefix = (string)apply_filters('wc_el_inv-next_number_series_prefix', $prefix, $optionKeyName, $numerationType);

    $suffixYearEnabled = ('on' === (string)getNextNumberSeriesOptionValue('suffix_year_invoice_number', 'off', $optionsSnapshot));
    $resetYearEnabled  = ('on' === (string)getNextNumberSeriesOptionValue('reset_numerations_year_change', 'off', $optionsSnapshot));
    $yearScopeEnabled  = $suffixYearEnabled || $resetYearEnabled;
    $suffixYearFormat  = (string)getNextNumberSeriesOptionValue('suffix_year_format_invoice_number', '', $optionsSnapshot);
    if ('' === $suffixYearFormat) {
        $suffixYearFormat = 'Y';
    }

    $yearToken = '';
    if ($yearScopeEnabled) {
        // Keep suffix-year behavior unchanged; for yearly reset-only mode we scope by current calendar year.
        $yearToken = $suffixYearEnabled ? date($suffixYearFormat) : date('Y');
    }

    return [
        'numeration_type'     => $numerationType,
        'prefix'              => $prefix,
        'suffix_year_enabled' => $suffixYearEnabled,
        'reset_year_enabled'  => $resetYearEnabled,
        'year_scope_enabled'  => $yearScopeEnabled,
        'suffix_year_format'  => $suffixYearFormat,
        'year_token'          => $yearToken,
    ];
}

/**
 * Read a numeration option either from a raw option snapshot or from persisted options.
 *
 * @param string     $key
 * @param mixed      $default
 * @param array|null $optionsSnapshot
 *
 * @return mixed
 */
function getNextNumberSeriesOptionValue(string $key, $default = '', ?array $optionsSnapshot = null)
{
    if (is_array($optionsSnapshot)) {
        $prefixedKey = OptionPage::$optionKey . $key;
        if (array_key_exists($prefixedKey, $optionsSnapshot)) {
            return $optionsSnapshot[$prefixedKey];
        }
        if (array_key_exists($key, $optionsSnapshot)) {
            return $optionsSnapshot[$key];
        }
    }

    $value = OptionPage::init()->getOptions($key);

    return false === $value ? $default : $value;
}

/**
 * Build a persistent series key for manual numeration resets.
 *
 * @param array $seriesContext
 *
 * @return string
 */
function buildNextNumberSeriesKey(array $seriesContext): string
{
    $numerationType = isset($seriesContext['numeration_type']) ? (string)$seriesContext['numeration_type'] : 'invoice';
    $prefix         = isset($seriesContext['prefix']) ? (string)$seriesContext['prefix'] : '';
    $yearToken      = isset($seriesContext['year_token']) ? (string)$seriesContext['year_token'] : '';

    return implode('|', [$numerationType, $prefix, $yearToken]);
}

/**
 * Return the manual-reset map stored in plugin options.
 *
 * @param array|null $optionsSnapshot
 *
 * @return array
 */
function getManualNumerationResetSeriesMap(?array $optionsSnapshot = null): array
{
    $resets = getNextNumberSeriesOptionValue('manual_numeration_resets', [], $optionsSnapshot);

    return is_array($resets) ? $resets : [];
}

/**
 * Return the active manual-reset boundary for the requested series.
 *
 * @param string     $optionKeyName
 * @param array|null $optionsSnapshot
 * @param array|null $seriesContext
 *
 * @return array
 */
function getNextNumberSeriesResetBoundary(string $optionKeyName, ?array $optionsSnapshot = null, ?array $seriesContext = null): array
{
    if (null === $seriesContext) {
        $seriesContext = getNextNumberSeriesContextFromOptions($optionKeyName, $optionsSnapshot);
    }

    $seriesKey = buildNextNumberSeriesKey($seriesContext);
    $resets    = getManualNumerationResetSeriesMap($optionsSnapshot);
    $boundary  = isset($resets[$seriesKey]) && is_array($resets[$seriesKey]) ? $resets[$seriesKey] : [];

    $boundary = array_merge([
        'series_key'       => $seriesKey,
        'segment_id'       => '',
        'numeration_type'  => (string)$seriesContext['numeration_type'],
        'prefix'           => (string)$seriesContext['prefix'],
        'year_token'       => (string)$seriesContext['year_token'],
        'start_from'       => 0,
        'reset_at'         => 0,
        'reset_by_user_id' => 0,
    ], $boundary);

    return (array)apply_filters('wc_el_inv-next_number_series_reset_boundary', $boundary, $optionKeyName, $seriesContext);
}

/**
 * Persist a manual numeration reset for the active series into an option snapshot.
 *
 * @param string     $optionKeyName
 * @param int        $startFrom
 * @param array      $optionsSnapshot
 * @param int|null   $timestamp
 * @param int|null   $userId
 *
 * @return array
 */
function registerManualNumerationResetForSeries(
    string $optionKeyName,
    int $startFrom,
    array $optionsSnapshot,
    ?int $timestamp = null,
    ?int $userId = null
): array {
    $seriesContext = getNextNumberSeriesContextFromOptions($optionKeyName, $optionsSnapshot);
    $seriesKey     = buildNextNumberSeriesKey($seriesContext);
    $resets        = getManualNumerationResetSeriesMap($optionsSnapshot);
    $timestamp     = $timestamp ?: time();
    $userId        = null === $userId ? (function_exists('get_current_user_id') ? (int)get_current_user_id() : 0) : $userId;

    $resets[$seriesKey] = [
        'series_key'       => $seriesKey,
        'segment_id'       => $seriesKey . '|' . $timestamp,
        'numeration_type'  => (string)$seriesContext['numeration_type'],
        'prefix'           => (string)$seriesContext['prefix'],
        'year_token'       => (string)$seriesContext['year_token'],
        'start_from'       => max(1, (int)$startFrom),
        'reset_at'         => (int)$timestamp,
        'reset_by_user_id' => (int)$userId,
    ];

    $optionsSnapshot[OptionPage::$optionKey . 'manual_numeration_resets'] = $resets;

    return $optionsSnapshot;
}

/**
 * Return the order meta key used to tag the active numeration segment.
 *
 * @param string $optionKeyName
 *
 * @return string
 */
function getNextNumberSeriesSegmentMetaKey(string $optionKeyName): string
{
    return ('number_next_receipt' === $optionKeyName) ? '_receipt_numeration_segment_id' : '_invoice_numeration_segment_id';
}

/**
 * Check whether a candidate timestamp belongs to the active reset segment.
 *
 * @param int   $timestamp
 * @param array $boundary
 *
 * @return bool
 */
function isTimestampInsideNextNumberResetBoundary(int $timestamp, array $boundary): bool
{
    $resetAt = isset($boundary['reset_at']) ? (int)$boundary['reset_at'] : 0;
    if ($resetAt < 1) {
        return true;
    }

    return $timestamp >= $resetAt;
}

/**
 * Check whether an order belongs to the currently active numeration reset segment.
 *
 * @param mixed  $order
 * @param string $optionKeyName
 * @param array  $boundary
 * @param int    $fallbackTimestamp
 *
 * @return bool
 */
function isOrderInsideNextNumberResetBoundary($order, string $optionKeyName, array $boundary, int $fallbackTimestamp = 0): bool
{
    if (empty($boundary['segment_id'])) {
        return isTimestampInsideNextNumberResetBoundary($fallbackTimestamp, $boundary);
    }

    if (! is_object($order) || ! method_exists($order, 'get_meta')) {
        return false;
    }

    $segmentMetaKey = getNextNumberSeriesSegmentMetaKey($optionKeyName);
    $segmentId      = (string)$order->get_meta($segmentMetaKey);

    return '' !== $segmentId && $segmentId === (string)$boundary['segment_id'];
}

/**
 * Remove candidates that belong to a pre-reset segment.
 *
 * @param array $candidate
 * @param array $boundary
 *
 * @return array{number:int,timestamp:int,provider:string,order_id:int}
 */
function applyNextNumberResetBoundaryToCandidate(array $candidate, array $boundary): array
{
    $candidate = normalizeLastAssignedCandidate($candidate);
    if (! empty($boundary['segment_id']) && '' === (string)$candidate['provider']) {
        return $candidate;
    }
    if (! isTimestampInsideNextNumberResetBoundary((int)$candidate['timestamp'], $boundary)) {
        return [
            'number'    => 0,
            'timestamp' => 0,
            'provider'  => '',
            'order_id'  => 0,
        ];
    }

    return $candidate;
}

/**
 * Tag an order with the active numeration segment when a manual reset is in effect.
 *
 * @param mixed  $order
 * @param string $optionKeyName
 *
 * @return void
 */
function assignOrderToActiveNumerationSegment($order, string $optionKeyName): void
{
    if (! is_object($order) || ! method_exists($order, 'update_meta_data')) {
        return;
    }

    $boundary = getNextNumberSeriesResetBoundary($optionKeyName);
    if (empty($boundary['segment_id'])) {
        return;
    }

    $order->update_meta_data(getNextNumberSeriesSegmentMetaKey($optionKeyName), (string)$boundary['segment_id']);
}

/**
 * Check whether an order belongs to the active next-number series.
 *
 * @param mixed $order
 * @param array $seriesContext
 *
 * @return bool
 */
function isOrderInNextNumberSeries($order, array $seriesContext): bool
{
    if (! is_object($order)) {
        return false;
    }

    $formattedPrefix = '';
    if (
        method_exists($order, 'get_meta')
        &&
        method_exists($order, 'get_type')
        &&
        method_exists($order, 'get_id')
    ) {
        $formatted = (string)$order->get_meta("{$order->get_type()}_formatted_number_{$order->get_id()}");
        if ('' === $formatted) {
            $formatted = (string)$order->get_meta("shop_order_formatted_number_{$order->get_id()}");
        }
        if (preg_match('/^\D*/u', $formatted, $matches)) {
            $formattedPrefix = (string)$matches[0];
        }
    }

    if ((string)$seriesContext['prefix'] !== $formattedPrefix) {
        return false;
    }

    if (! empty($seriesContext['year_scope_enabled'])) {
        $seriesYear = '';
        $date       = null;
        if (method_exists($order, 'get_date_completed')) {
            $date = $order->get_date_completed();
        }
        if (! $date && method_exists($order, 'get_date_created')) {
            $date = $order->get_date_created();
        }
        if ($date instanceof \DateTimeInterface) {
            $seriesYear = ! empty($seriesContext['suffix_year_enabled']) ?
                $date->format((string)$seriesContext['suffix_year_format']) :
                $date->format('Y');
        }
        if ($seriesYear !== (string)$seriesContext['year_token']) {
            return false;
        }
    }

    return true;
}

/**
 * removeDuplicateMeta
 *
 * remove duplicate invoice meta key
 *
 * @param $orderID
 *
 * @return array|false
 */
function removeDuplicateMeta($orderID)
{
    $order = wc_get_order($orderID);
    if (! $order) {
        return false;
    }

    $numberInvoice = $order->get_meta('order_number_invoice');

    if (! empty($numberInvoice)) {
        return false;
    }

    $meta_keys = [
        'nature_rc',
        'ref_norm_rc',
        'order_number_invoice',
        "shop_order_formatted_number_{$orderID}",
    ];

    $removed = [];

    $all_meta = $order->get_meta_data();
    foreach ($meta_keys as $meta_key) {
        $values = [];
        foreach ($all_meta as $meta) {
            if ($meta->key === $meta_key) {
                $values[] = $meta->value;
            }
        }

        if (count($values) > 1) {
            $to_keep = array_filter($values);
            $to_keep = end($to_keep);

            foreach ($all_meta as $meta) {
                if ($meta->key === $meta_key) {
                    $removed[] = $meta_key;
                    $order->delete_meta_data($meta_key);
                }
            }

            if ($to_keep) {
                $order->update_meta_data($meta_key, $to_keep);
            }
        }
    }

    $order->save();
    if (! empty($removed)) {
        error_log('removeDuplicateMeta: ' . print_r($removed, true));

        return $removed;
    }

    return false;
}

/**
 * Set invoice number on order completed
 *
 * @param $orderID
 * @param $from
 * @param $to
 *
 * @since 1.0.0
 *
 */
function setInvoiceNumberOnOrderCompleted($orderID, $from, $to)
{
    // Check if order exist.
    $order        = wc_get_order($orderID);
    $wcOrderClass = \WcElectronInvoice\Functions\wcOrderClassName($order, '\WC_Order');
    if (! $order instanceof $wcOrderClass) {
        return;
    }

    /**
     * Filters hook status check
     */
    $status1Check = apply_filters('wc_el_inv-set_invoice_number_status1_check', 'completed');
    $status2Check = apply_filters('wc_el_inv-set_invoice_number_status2_check', 'processing');

    if (($status1Check === $from)
        ||
        (
            $status1Check !== $to
            &&
            $status2Check !== $to
        )
    ) {
        return;
    }

    // Disable invoice order whit total zero
    if (disableInvoiceOnOrderTotalZero($order)) {
        return;
    }

    if (true === apply_filters('wc_el_inv-disabled_set_invoice_number', false, $order, $order->get_payment_method())) {
        return;
    }

    $checkSent = \WcElectronInvoice\Functions\getPostMeta('_invoice_sent', null, $orderID);
    // Numeration type
    $numerationType = $order->get_meta('_billing_choice_type') ?: 'invoice';

    // Hook for change next numeration type option
    $numerationType = apply_filters('wc_el_inv-invoice_number_numeration_type', $numerationType, $order);

    if (($from !== $to)) {
        // Order invoice number
        $orderInvoiceNumber = $order->get_meta('order_number_invoice') ?? null;
        if (! $orderInvoiceNumber) {
            $recoveredNumber = recoverOrderInvoiceNumberFromFormatted($order, $numerationType);
            if ($recoveredNumber > 0) {
                $order->update_meta_data('order_number_invoice', $recoveredNumber);
                setFormattedInvoiceNumber($order, $numerationType, true);
                $order->save();
                $orderInvoiceNumber = $recoveredNumber;
            }
        }
        if (! $orderInvoiceNumber) {
            // Get next invoice number option.
            $invoiceNumber = (int)getNextInvoiceNumber("number_next_{$numerationType}");
        } else {
            $invoiceNumber = (int)$orderInvoiceNumber;
        }

        if (! $invoiceNumber) {
            error_log("[WcElectronInvoice] Unable to assign invoice number for order #{$orderID} with numeration type {$numerationType}");

            return;
        }

        // Hook for change next numeration type option
        $numerationType = apply_filters('wc_el_inv-invoice_number_next_numeration_type', $numerationType, $order);

        if (! $orderInvoiceNumber) {
            // Set invoice number for order.
            $order->update_meta_data('order_number_invoice', $invoiceNumber);
            assignOrderToActiveNumerationSegment($order, "number_next_{$numerationType}");
            // Set formatted number
            setFormattedInvoiceNumber($order, $numerationType, true);
        }

        // Fix formatted number (contain order number)
        $numberFormatted = $order->get_meta("{$order->get_type()}_formatted_number_{$order->get_id()}") ?: null;
        if ($numberFormatted && ! containsNumber($numberFormatted, $invoiceNumber)) {
            // Set invoice number for order.
            $order->update_meta_data('order_number_invoice', $invoiceNumber);
            assignOrderToActiveNumerationSegment($order, "number_next_{$numerationType}");
            // Set formatted number
            setFormattedInvoiceNumber($order, $numerationType, true);
        }

        // If order/invoice sent on processing status, update completed date with created date
        $processingDate = $order->get_meta('_date_processing_set');
        if ('sent' === $checkSent && 'processing' === $from && 'completed' === $to) {
            if (is_null($processingDate) || $processingDate === '') {
                $processingDate = current_time('mysql');
                $order->update_meta_data('_date_processing_set', $processingDate);
            }
            if ($processingDate) {
                $order->set_date_completed($processingDate);
            }
        }

        // Save
        $order->save();

        try {
            $timeZone = new TimeZone();
            $timeZone = new \DateTimeZone($timeZone->getTimeZone()->getName());
            $date     = new \DateTime('now');
            $date->setTimezone($timeZone);

            if (! $processingDate) {
                $wcDate = new \WC_DateTime($date->format('Y-m-d H:i:s'), $timeZone);
                $order->set_date_completed($wcDate);
                $order->save();
            }

            if (isset($checkSent) && '' === $checkSent) {
                // Set invoice sent data
                $order->update_meta_data('_invoice_sent', 'no_sent');
                $order->update_meta_data('_invoice_sent_timestamp', $date->getTimestamp());
            }

            // Save
            $order->save();
        } catch (\Exception $data_Exception) {
            echo esc_html('setInvoiceNumberOnOrderCompleted: ' . $data_Exception->getMessage()) . "\n";
            die();
        }
    }
}

/**
 * Set invoice number on order auto completed
 *
 * @param $orderID
 *
 * @since 1.0.0
 *
 */
function setInvoiceNumberOnOrderAutoCompleted($orderID)
{
    $order        = wc_get_order($orderID);
    $wcOrderClass = \WcElectronInvoice\Functions\wcOrderClassName($order, '\WC_Order');
    // Check if order exist.
    if (! $order instanceof $wcOrderClass) {
        return;
    }

    // Disable invoice order whit total zero
    if (disableInvoiceOnOrderTotalZero($order)) {
        return;
    }

    if (true === apply_filters('wc_el_inv-disabled_set_invoice_number', false, $order, $order->get_payment_method())) {
        return;
    }

    /**
     * Filters hook status check
     */
    $status1Check = apply_filters('wc_el_inv-set_invoice_number_status1_check', 'completed');
    $status2Check = apply_filters('wc_el_inv-set_invoice_number_status2_check', 'processing');

    // Check status.
    if ($status1Check === $order->get_status() || $status2Check === $order->get_status()) {
        $checkSent = \WcElectronInvoice\Functions\getPostMeta('_invoice_sent', null, $order->get_id());
        // Numeration type
        $numerationType = $order->get_meta('_billing_choice_type') ?: 'invoice';

        // Hook for change next numeration type option
        $numerationType = apply_filters('wc_el_inv-invoice_number_numeration_type', $numerationType, $order);

        // Order invoice number
        $orderInvoiceNumber = $order->get_meta('order_number_invoice');
        if (! $orderInvoiceNumber) {
            $recoveredNumber = recoverOrderInvoiceNumberFromFormatted($order, $numerationType);
            if ($recoveredNumber > 0) {
                $order->update_meta_data('order_number_invoice', $recoveredNumber);
                setFormattedInvoiceNumber($order, $numerationType, true);
                $order->save();
                $orderInvoiceNumber = $recoveredNumber;
            }
        }
        if (! $orderInvoiceNumber) {
            // Get next invoice number option.
            $invoiceNumber = (int)getNextInvoiceNumber("number_next_{$numerationType}");
        } else {
            $invoiceNumber = (int)$orderInvoiceNumber;
        }
        if ($invoiceNumber <= 0) {
            error_log("[WcElectronInvoice] Unable to assign invoice number for order #{$orderID} with numeration type {$numerationType}");

            return;
        }

        // Hook for change next numeration type option
        $numerationType = apply_filters('wc_el_inv-invoice_number_next_numeration_type', $numerationType, $order);

        if (! $orderInvoiceNumber) {
            // Set invoice number for order.
            $order->update_meta_data('order_number_invoice', $invoiceNumber);
            assignOrderToActiveNumerationSegment($order, "number_next_{$numerationType}");
            // Set formatted number
            setFormattedInvoiceNumber($order, $numerationType, true);

            // Save
            $order->save();
        }

        try {
            $timeZone = new TimeZone();
            $timeZone = new \DateTimeZone($timeZone->getTimeZone()->getName());
            $date     = new \DateTime('now');
            $date->setTimezone($timeZone);

            if (isset($checkSent) && '' === $checkSent) {
                // Set invoice sent data
                $order->update_meta_data('_invoice_sent', 'no_sent');
                $order->update_meta_data('_invoice_sent_timestamp', $date->getTimestamp());
            }

            // If order/invoice sent on processing status, update completed date with created date
            if ('sent' === $checkSent && 'processing' === $order->get_status()) {
                $processingDate = $order->get_meta('_date_processing_set');
                if (is_null($processingDate) || $processingDate === '') {
                    $processingDate = current_time('mysql');
                    $order->update_meta_data('_date_processing_set', $processingDate);
                    $order->save();
                }
                if ($processingDate) {
                    $order->set_date_completed($processingDate);
                }
            } else {
                // Save date completed
                $wcDate = new \WC_DateTime($date->format('Y-m-d H:i:s'), $timeZone);
                $order->set_date_completed($wcDate);
            }

            // Save
            $order->save();
        } catch (\Exception $data_Exception) {

        }
    }
}

/**
 * Set invoice number on order refund
 *
 * NB: For credit notes, no competition conflict can occur as they are generated manually from within the order.
 *
 * @param $refundID
 *
 * @since 1.0.0
 *
 */
function setInvoiceNumberOnOrderRefund($refundID)
{
    $refund = wc_get_order($refundID);
    $order  = wc_get_order($refund->get_parent_id());

    // Disable invoice order whit total zero
    if (disableInvoiceOnOrderTotalZero($order)) {
        return;
    }

    if (true === apply_filters('wc_el_inv-disabled_set_refund_number_invoice', false, $order,
            $order->get_payment_method())) {
        return;
    }

    $checkOrderSent     = \WcElectronInvoice\Functions\getPostMeta('_invoice_sent', null, $order->get_id());
    $wcOrderRefundClass = \WcElectronInvoice\Functions\wcOrderClassName($refund, '\WC_Order_Refund');

    if ($refund instanceof $wcOrderRefundClass) {
        // Get next invoice number option.
        $options = OptionPage::init();
        // Type Credit note invoice
        // Hook for change next numeration type option
        $numerationType = apply_filters('wc_el_inv-credit_note_numeration_type', 'credit_note', $order);

        $defaultCreditNoteNumber    = $options->getOptions("number_next_{$numerationType}");
        $numerationCreditNoteActive = $options->getOptions("numeration_{$numerationType}_active");

        // Check parent order
        $parentNumerationType = $order->get_meta('_billing_choice_type');
        if ('receipt' === $parentNumerationType) {
            // Hook for change next numeration type option
            $numerationType = apply_filters('wc_el_inv-credit_note_receipt_numeration_type', 'credit_note_receipt',
                $order);
        }

        // Type Credit note receipt
        $numeration                        = $options->getOptions("number_next_{$numerationType}");
        $numerationCreditNoteReceiptActive = $options->getOptions("numeration_{$numerationType}_active");
        $invoiceNumberFallBack             = get_option("number_next_{$numerationType}");

        // Numeration credit note receipt
        if ('on' === $numerationCreditNoteReceiptActive &&
            (int)$numeration > 0 &&
            (int)$invoiceNumberFallBack > (int)$numeration
        ) {
            $numeration = $invoiceNumberFallBack;
        }

        // Numeration credit note invoice
        if ('on' === $numerationCreditNoteActive &&
            ! $numeration && (int)$defaultCreditNoteNumber > 0
        ) {
            $numeration = get_option("number_next_credit_note");
        }

        // Numeration invoice - backwards compatibility
        $usesSharedNumeration = false;
        if (! $numeration ||
            ('on' !== $numerationCreditNoteReceiptActive && 'on' !== $numerationCreditNoteActive)
        ) {
            if ('receipt' === $parentNumerationType) {
                $numeration     = (int)getNextInvoiceNumber('number_next_receipt');
                $numerationType = 'receipt';
            } else {
                $numeration     = (int)getNextInvoiceNumber('number_next_invoice');
                $numerationType = 'invoice';
            }
            $usesSharedNumeration = true;
        }

        // Hook for change next numeration type option
        $numerationType = apply_filters('wc_el_inv-credit_note_number_next_numeration_type', $numerationType, $order);

        if ($numeration <= 0) {
            error_log("[WcElectronInvoice] Unable to assign refund number for refund #{$refundID} with numeration type {$numerationType}");

            return;
        }

        try {
            $timeZone = new TimeZone();
            $timeZone = new \DateTimeZone($timeZone->getTimeZone()->getName());
            $date     = new \DateTime('now');
            $date->setTimezone($timeZone);

            // Increment invoice number only not total refund
            if (('' === $checkOrderSent || 'sent' === $checkOrderSent) &&
                0.00 <= (abs($order->get_total()) - abs($refund->get_total())) &&
                ! $usesSharedNumeration) {
                // Set next invoice number.
                $number = ($numeration + 1);
                $options->setOption("number_next_{$numerationType}", $number);
                // Set Fallback number is setOption not save (issue)
                update_option("number_next_{$numerationType}", $number);
            }

            // Set invoice number for order.
            $refund->update_meta_data("refund_number_invoice-{$refundID}", $numeration);
            // Set formatted number
            setFormattedInvoiceNumber($refund, $numerationType);

            $refund->update_meta_data("_invoice_sent-{$refundID}", 'no_sent');
            $refund->update_meta_data("_invoice_sent_timestamp", $date->getTimestamp());
            // Save
            $refund->save();
        } catch (\Exception $e) {

        }
    }
}

/**
 * setFormattedInvoiceNumber
 *
 * @param $order
 * @param $numerationType
 *
 * @return false|void
 */
function setFormattedInvoiceNumber($order, $numerationType, $updateNumber = false)
{
    $wcOrderClass       = \WcElectronInvoice\Functions\wcOrderClassName($order, '\WC_Order');
    $wcOrderRefundClass = \WcElectronInvoice\Functions\wcOrderClassName($order, '\WC_Order_Refund');

    if (! is_subclass_of($order, '\WcElectronInvoice\Providers\Order') &&
        ! is_subclass_of($order, '\WcElectronInvoice\Providers\OrderRefund') &&
        ! $order instanceof $wcOrderClass &&
        ! $order instanceof $wcOrderRefundClass
    ) {
        // not extending provider orders nor a woocommerce order
        return false;
    }

    if (! is_subclass_of($order, '\WcElectronInvoice\Providers\Order') &&
        ! is_subclass_of($order, '\WcElectronInvoice\Providers\OrderRefund')
    ) {
        // not extending provider orders, assume this is a woocommerce order
        $order = OrderQuery::instance()->getProviderOrder($order->get_id(), 'woocommerce');
    }

    $formattedNumber = $order->get_meta("{$order->get_type()}_formatted_number_{$order->get_id()}") ?? null;
    if ($formattedNumber && ! $updateNumber) {
        return apply_filters('wc_el_inv-normalize_formatted_number', $formattedNumber, $order);
    }

    $number = getStoredInvoiceNumberValue($order, (string)$numerationType);
    $resolvedNumerationType = resolveStoredInvoiceNumerationType($order, (string)$numerationType);

    $options = OptionPage::init();
    $digits  = $options->getOptions('number_digits_in_invoice');
    $digits  = isset($digits) && '' !== $digits ? $digits : 2;

    $prefix = $options->getOptions("prefix_{$resolvedNumerationType}_number");
    $prefix = apply_filters("wc_el_inv-formatted_number_prefix_{$resolvedNumerationType}", $prefix, $order);

    $suffix           = $options->getOptions('suffix_invoice_number');
    $suffixYear       = $options->getOptions('suffix_year_invoice_number');
    $suffixYearFormat = $options->getOptions('suffix_year_format_invoice_number') ?: 'Y';
    if ('on' === $suffixYear) {
        if ('shop_order' === $order->get_type()) {
            $created = $order->has_status('completed') ? $order->get_date_completed() : $order->get_date_created();
        } else {
            $created = $order->get_date_created();
        }

        if (! $created instanceof \DateTimeInterface) {
            $created = new \DateTime();
        }

        $suffix = "/" . $created->format($suffixYearFormat);
    } else {
        $suffix = isset($suffix) && '' !== $suffix ? $suffix : '';
    }

    $suffix           = apply_filters('wc_el_inv-formatted_number_suffix_number', $suffix, $order);
    $invNumber        = str_pad((string)max(0, (int)$number), (int)$digits, '0', STR_PAD_LEFT);
    $numberFormatted  = buildFormattedInvoiceNumberValue($order, (string)$numerationType, (int)$number);

    $numberFormatted = apply_filters('wc_el_inv-formatted_number_before_save', $numberFormatted, $invNumber, $prefix,
        $suffix, $order);

    $order->update_meta("{$order->get_type()}_formatted_number_{$order->get_id()}", $numberFormatted);
    $order->save();
}

/**
 * Resolve the raw numeric invoice number stored on the order/refund.
 *
 * @param mixed       $order
 * @param string|null $numerationType
 *
 * @return int
 */
function getStoredInvoiceNumberValue($order, ?string $numerationType = null): int
{
    if (! is_object($order) || ! method_exists($order, 'get_type') || ! method_exists($order, 'get_meta')) {
        return 0;
    }

    $numerationType = sanitize_key((string)$numerationType);

    if (in_array($numerationType, array('credit_note', 'credit_note_receipt'), true) &&
        method_exists($order, 'get_id')) {
        return (int)$order->get_meta("refund_number_invoice-{$order->get_id()}");
    }

    if ('shop_order' === $order->get_type()) {
        return (int)$order->get_meta('order_number_invoice');
    }

    if ('shop_order_refund' === $order->get_type() && method_exists($order, 'get_id')) {
        return (int)$order->get_meta("refund_number_invoice-{$order->get_id()}");
    }

    return 0;
}

/**
 * Normalize numeration type used for stored numbers / formatted prefixes.
 *
 * Credit note prefixes must only be used when the dedicated numeration is
 * enabled and initialized with a positive next number. Otherwise refunds fall
 * back to the base invoice/receipt series.
 *
 * @param mixed  $order
 * @param string $numerationType
 *
 * @return string
 */
function resolveStoredInvoiceNumerationType($order, string $numerationType = ''): string
{
    $numerationType = sanitize_key($numerationType);
    if (! in_array($numerationType, array('credit_note', 'credit_note_receipt'), true)) {
        return $numerationType;
    }

    $options = OptionPage::init();
    $active  = $options->getOptions("numeration_{$numerationType}_active");
    $next    = (int)$options->getOptions("number_next_{$numerationType}");
    if ('on' === $active && $next > 0) {
        return $numerationType;
    }

    if ('credit_note_receipt' === $numerationType) {
        return 'receipt';
    }

    return 'invoice';
}

/**
 * Build the formatted invoice number without persisting it.
 *
 * Returns an empty string when the raw numeric invoice number is missing or invalid.
 *
 * @param mixed    $order
 * @param string   $numerationType
 * @param int|null $number
 *
 * @return string
 */
function buildFormattedInvoiceNumberValue($order, string $numerationType, ?int $number = null): string
{
    if (! is_object($order) || ! method_exists($order, 'get_type') || ! method_exists($order, 'get_meta')) {
        return '';
    }

    $resolvedNumerationType = resolveStoredInvoiceNumerationType($order, $numerationType);

    if (null === $number) {
        $number = getStoredInvoiceNumberValue($order, $numerationType);
    }
    $number = (int)$number;
    if ($number < 1) {
        return '';
    }

    $options = OptionPage::init();

    $digits = $options->getOptions('number_digits_in_invoice');
    $digits = isset($digits) && '' !== $digits ? $digits : 2;

    $prefix = $options->getOptions("prefix_{$resolvedNumerationType}_number");
    $prefix = apply_filters("wc_el_inv-formatted_number_prefix_{$resolvedNumerationType}", $prefix, $order);

    $suffix           = $options->getOptions('suffix_invoice_number');
    $suffixYear       = $options->getOptions('suffix_year_invoice_number');
    $suffixYearFormat = $options->getOptions('suffix_year_format_invoice_number') ?: 'Y';
    if ('on' === $suffixYear) {
        if ('shop_order' === $order->get_type()) {
            $created = $order->has_status('completed') ? $order->get_date_completed() : $order->get_date_created();
        } else {
            $created = $order->get_date_created();
        }

        if (! $created instanceof \DateTimeInterface) {
            $created = new \DateTime();
        }

        $suffix = "/" . $created->format($suffixYearFormat);
    } else {
        $suffix = isset($suffix) && '' !== $suffix ? $suffix : '';
    }

    $suffix    = apply_filters('wc_el_inv-formatted_number_suffix_number', $suffix, $order);
    $invNumber = str_pad((string)$number, (int)$digits, '0', STR_PAD_LEFT);

    return "{$prefix}{$invNumber}{$suffix}";
}

/**
 * Try to recover the raw invoice number from an existing formatted number.
 *
 * @param \WC_Order $order
 * @param string    $numerationType
 *
 * @return int
 */
function recoverOrderInvoiceNumberFromFormatted($order, $numerationType): int
{
    $formattedNumber = $order->get_meta("{$order->get_type()}_formatted_number_{$order->get_id()}") ?? null;
    if (! $formattedNumber) {
        return 0;
    }

    $normalized = apply_filters('wc_el_inv-normalize_formatted_number', $formattedNumber, $order);
    if (is_numeric($normalized) && (int)$normalized > 0) {
        return (int)$normalized;
    }

    $candidate = (string)$formattedNumber;
    $options   = OptionPage::init();

    $prefix = $options->getOptions("prefix_{$numerationType}_number");
    $prefix = apply_filters("wc_el_inv-formatted_number_prefix_{$numerationType}", $prefix, $order);
    if (is_string($prefix) && '' !== $prefix && 0 === strpos($candidate, $prefix)) {
        $candidate = substr($candidate, strlen($prefix));
    }

    $suffix           = $options->getOptions('suffix_invoice_number');
    $suffixYear       = $options->getOptions('suffix_year_invoice_number');
    $suffixYearFormat = $options->getOptions('suffix_year_format_invoice_number') ?: 'Y';
    if ('on' === $suffixYear) {
        $created = $order->has_status('completed') ? $order->get_date_completed() : $order->get_date_created();
        if (! $created instanceof \DateTimeInterface) {
            $created = new \DateTime();
        }
        $suffix = "/" . $created->format($suffixYearFormat);
    } else {
        $suffix = isset($suffix) && '' !== $suffix ? $suffix : '';
    }
    $suffix = apply_filters('wc_el_inv-formatted_number_suffix_number', $suffix, $order);
    if (is_string($suffix) && '' !== $suffix && substr($candidate, -strlen($suffix)) === $suffix) {
        $candidate = substr($candidate, 0, -strlen($suffix));
    }

    $candidate = trim($candidate);

    if (ctype_digit($candidate) && (int)$candidate > 0) {
        return (int)$candidate;
    }

    return 0;
}

/**
 * setCreditNoteFallBackNumeration
 *
 * @return void
 */
function setCreditNoteFallBackNumeration()
{
    $options                         = \WcElectronInvoice\Admin\Settings\OptionPage::init();
    $creditNoteNumber                = $options->getOptions('number_next_credit_note');
    $creditNoteNumberFallBack        = get_option("number_next_credit_note");
    $creditNoteReceiptNumber         = $options->getOptions('number_next_credit_note_receipt');
    $creditNoteReceiptNumberFallBack = get_option("number_next_credit_note_receipt");

    if (isset($creditNoteNumberFallBack) &&
        isset($creditNoteNumber) &&
        (int)$creditNoteNumber > 0 &&
        (int)$creditNoteNumberFallBack > (int)$creditNoteNumber) {
        $options->setOption("number_next_credit_note", $creditNoteNumberFallBack);
        update_option("number_next_credit_note", $creditNoteNumberFallBack);
    }
    if (isset($creditNoteReceiptNumberFallBack) &&
        isset($creditNoteReceiptNumber) &&
        (int)$creditNoteReceiptNumber > 0 &&
        (int)$creditNoteReceiptNumberFallBack > (int)$creditNoteReceiptNumber) {
        $options->setOption("number_next_credit_note_receipt", $creditNoteReceiptNumberFallBack);
        update_option("number_next_credit_note_receipt", $creditNoteReceiptNumberFallBack);
    }
}

/**
 * Disable invoice on order total zero
 *
 * @param $order
 *
 * @return bool
 */
function disableInvoiceOnOrderTotalZero($order)
{
    $options              = OptionPage::init();
    $disableInvoiceNumber = $options->getOptions('disable_invoice_number_order_zero');

    if ('on' === $disableInvoiceNumber &&
        (is_subclass_of($order, '\WcElectronInvoice\Providers\Order') || $order instanceof \WC_Abstract_Order)
    ) {
        $total = floatval($order->get_total());
        if ($total === 0.00 && 'refunded' !== $order->get_status()) {
            return true;
        }
    }

    return false;
}


/**
 * Evaluate behavior when a DB lock is required for an action.
 *
 * @param bool $lockAcquired
 * @param bool $isBackgroundJob
 *
 * @return array{continue:bool,respond_error:bool,reason:string}
 */
function evaluateLockBusyDecision(bool $lockAcquired, bool $isBackgroundJob): array
{
    if ($lockAcquired) {
        return [
            'continue'      => true,
            'respond_error' => false,
            'reason'        => 'ok',
        ];
    }

    if ($isBackgroundJob) {
        return [
            'continue'      => false,
            'respond_error' => false,
            'reason'        => 'lock_busy_background_skip',
        ];
    }

    return [
        'continue'      => false,
        'respond_error' => true,
        'reason'        => 'lock_busy_foreground_error',
    ];
}

/**
 * Map lock-busy context to normalized error metadata.
 *
 * @param string $channel
 * @param string $action
 *
 * @return array{code:int,message_key:string}
 */
function mapLockBusyError(string $channel, string $action): array
{
    $channel = strtolower(trim($channel));
    $action  = strtolower(trim($action));
    $key     = "{$channel}:{$action}";

    $map = [
        'fic:create_invoice'      => ['code' => 409, 'message_key' => 'action_skipped_create'],
        'fic:send_invoice'        => ['code' => 409, 'message_key' => 'action_skipped_send'],
        'sdi:create_and_send'     => ['code' => 409, 'message_key' => 'action_skipped_send_create'],
        'peppol:create_and_send'  => ['code' => 409, 'message_key' => 'action_skipped_send_create'],
        'ksef:create_and_send'    => ['code' => 409, 'message_key' => 'action_skipped_send_create'],
        'zoho:create_and_send'    => ['code' => 409, 'message_key' => 'action_skipped_send_create'],
        'aruba:upload_invoice'    => ['code' => 409, 'message_key' => 'action_skipped_upload'],
        'sdi_pec:upload_invoice'  => ['code' => 409, 'message_key' => 'action_skipped_upload'],
    ];

    if (isset($map[$key])) {
        return $map[$key];
    }

    return ['code' => 409, 'message_key' => 'action_skipped'];
}

/**
 * Map SDI/PEPPOL native create/send precondition reason to normalized metadata.
 *
 * @param string $channel
 * @param string $reason
 *
 * @return array{code:int,message_key:string}
 */
function mapNativeCreateAndSendPreconditionError(string $channel, string $reason): array
{
    $channel = strtolower(trim($channel));
    $reason  = strtolower(trim($reason));

    $map = [
        'channel_mismatch'     => ['code' => 422, 'message_key' => 'action_not_allowed_channel'],
        'integration_inactive' => ['code' => 422, 'message_key' => 'action_not_allowed'],
        'action_invalid'       => ['code' => 422, 'message_key' => 'action_not_valid'],
        'uuid_already_exists'  => ['code' => 422, 'message_key' => 'uuid_exists'],
        'response_uuid_missing' => ['code' => 422, 'message_key' => 'response_uuid_missing'],
    ];

    if ('sdi' !== $channel && 'peppol' !== $channel && 'ksef' !== $channel && 'zoho' !== $channel) {
        return ['code' => 422, 'message_key' => 'action_not_allowed'];
    }

    if (isset($map[$reason])) {
        return $map[$reason];
    }

    return ['code' => 422, 'message_key' => 'action_not_allowed'];
}

/**
 * Map Aruba/SDI-PEC upload precondition reason to normalized metadata.
 *
 * @param string $channel
 * @param string $reason
 *
 * @return array{code:int,message_key:string}
 */
function mapUploadPreconditionError(string $channel, string $reason): array
{
    $channel = strtolower(trim($channel));
    $reason  = strtolower(trim($reason));

    if ('aruba' !== $channel && 'sdi_pec' !== $channel) {
        return ['code' => 501, 'message_key' => 'upload_error'];
    }

    $map = [
        'xml_invalid'             => ['code' => 501, 'message_key' => 'xml_invalid'],
        'invalid_email_recipient' => ['code' => 501, 'message_key' => 'invalid_email_recipient'],
        'upload_failed'           => ['code' => 501, 'message_key' => 'upload_failed'],
        'empty_upload_filename'   => ['code' => 501, 'message_key' => 'empty_upload_filename'],
    ];

    if (isset($map[$reason])) {
        return $map[$reason];
    }

    return ['code' => 501, 'message_key' => 'upload_error'];
}

/**
 * Build Aruba upload payload in a deterministic format.
 *
 * @param string $xmlBase64
 *
 * @return array<string,mixed>
 */
function buildArubaUploadPayload(string $xmlBase64): array
{
    return [
        'dataFile'        => $xmlBase64,
        'credential'      => '',
        'domain'          => '',
        'senderPIVA'      => '',
        'skipExtraSchema' => false,
    ];
}

/**
 * Build integration metadata for native create/send XML generation.
 *
 * @param string $channel
 *
 * @return array<string,mixed>
 */
function buildNativeCreateIntegrationMeta(string $channel): array
{
    $channel = strtolower(trim($channel));

    if ('sdi' === $channel) {
        return [
            'integration' => [
                'use'    => 'sdi-via-pop',
                'action' => 'create',
            ],
        ];
    }

    if ('peppol' === $channel) {
        return [
            'integration' => [
                'use'    => 'peppol-via-pop',
                'action' => 'create',
            ],
        ];
    }

    if ('ksef' === $channel) {
        return [
            'integration' => [
                'use'    => 'ksef-via-pop',
                'action' => 'create',
            ],
        ];
    }

    if ('zoho' === $channel) {
        return [
            'integration' => [
                'use'    => 'zoho-via-pop',
                'action' => 'create',
            ],
        ];
    }

    return [];
}

/**
 * Check if current execution mode is a background one.
 *
 * @param string|null $mode
 * @param array       $backgroundModes
 *
 * @return bool
 */
function isBackgroundExecutionMode(?string $mode, array $backgroundModes = ['cronjob', 'feCronJobUploadRequest']): bool
{
    if (! is_string($mode) || '' === $mode) {
        return false;
    }

    return in_array($mode, $backgroundModes, true);
}

/**
 * Normalize meta/option values to boolean consistently.
 *
 * @param mixed $value
 *
 * @return bool
 */
function normalizeBooleanMeta($value): bool
{
    return filter_var($value, FILTER_VALIDATE_BOOLEAN);
}

/**
 * Resolve integration document type from order type.
 *
 * @param string $orderType
 *
 * @return string
 */
function resolveDocumentTypeFromOrderType(string $orderType): string
{
    return 'shop_order' === $orderType ? 'invoice' : 'credit_note';
}

/**
 * Check if current execution should return immediate (foreground) errors.
 *
 * @param string|null $mode
 * @param array       $backgroundModes
 *
 * @return bool
 */
function shouldRespondWithImmediateError(
    ?string $mode,
    array $backgroundModes = ['cronjob', 'feCronJobUploadRequest']
): bool {
    return ! isBackgroundExecutionMode($mode, $backgroundModes);
}

/**
 * Build UUID meta key for native integrations.
 *
 * @param string $channelPrefix
 * @param string $documentType
 *
 * @return string
 */
function buildUuidMetaKey(string $channelPrefix, string $documentType): string
{
    $channelPrefix = trim($channelPrefix);
    $documentType  = trim($documentType);

    return "{$channelPrefix}-{$documentType}_uuid";
}

/**
 * Build notifications meta key for native integrations.
 *
 * @param string $channelPrefix
 * @param string $documentType
 *
 * @return string
 */
function buildNotificationsMetaKey(string $channelPrefix, string $documentType): string
{
    $channelPrefix = trim($channelPrefix);
    $documentType  = trim($documentType);

    return "{$channelPrefix}-{$documentType}_notifications";
}

/**
 * Build KSeF reference number meta key for native integrations.
 *
 * @param string $channelPrefix
 * @param string $documentType
 *
 * @return string
 */
function buildKsefReferenceMetaKey(string $channelPrefix, string $documentType): string
{
    $channelPrefix = trim($channelPrefix);
    $documentType  = trim($documentType);

    return "{$channelPrefix}-{$documentType}_ksef_reference";
}

/**
 * Build meta key used to mark native timeout pending verification.
 *
 * @return string
 */
function buildNativeTimeoutPendingMetaKey(): string
{
    return '_native_timeout_pending_verification';
}

/**
 * Build meta key for native timeout pending channel.
 *
 * @return string
 */
function buildNativeTimeoutPendingChannelMetaKey(): string
{
    return '_native_timeout_pending_channel';
}

/**
 * Build meta key for native timeout pending timestamp.
 *
 * @return string
 */
function buildNativeTimeoutPendingTimestampMetaKey(): string
{
    return '_native_timeout_pending_at';
}

/**
 * Build meta key for native timeout pending document type.
 *
 * @return string
 */
function buildNativeTimeoutPendingDocumentTypeMetaKey(): string
{
    return '_native_timeout_pending_document_type';
}

/**
 * Resolve native channel meta prefix from logical channel name.
 *
 * @param string $channel
 *
 * @return string
 */
function resolveNativeChannelPrefix(string $channel): string
{
    $channel = strtolower(trim($channel));
    if ('sdi' === $channel) {
        return 'sdi_pop';
    }
    if ('peppol' === $channel) {
        return 'peppol_pop';
    }

    if ('ksef' === $channel) {
        return 'ksef_pop';
    }

    if ('zoho' === $channel) {
        return 'zoho_pop';
    }

    return '';
}

/**
 * Detect timeout-like failures in mixed error payloads (string/array/object).
 *
 * @param mixed $value
 *
 * @return bool
 */
function isLikelyTimeoutError($value): bool
{
    if (is_string($value)) {
        $haystack = strtolower($value);
        $needles  = [
            'timed out',
            'timeout',
            'curl error 28',
            'operation timed out',
            'connection timed out',
            'http request failed',
        ];

        foreach ($needles as $needle) {
            if (false !== strpos($haystack, $needle)) {
                return true;
            }
        }

        return false;
    }

    if (is_array($value) || is_object($value)) {
        $encoded = function_exists('wp_json_encode') ? wp_json_encode($value) : json_encode($value);
        if (is_string($encoded) && '' !== $encoded) {
            return isLikelyTimeoutError($encoded);
        }
    }

    return false;
}

/**
 * Extract readable native integration error detail from mixed raw payloads.
 *
 * @param mixed $raw
 *
 * @return string
 */
function extractNativeErrorDetail($raw): string
{
    $status = '';
    $detail = '';
    $title  = '';
    $msg    = '';

    if (is_object($raw)) {
        $status = isset($raw->code) ? (string)$raw->code : '';
        $msg    = isset($raw->message) && is_string($raw->message) ? trim($raw->message) : '';

        if (isset($raw->data) && is_object($raw->data)) {
            $status = '' !== $status ? $status : (isset($raw->data->status) ? (string)$raw->data->status : '');
            $detail = isset($raw->data->detail) && is_string($raw->data->detail) ? trim($raw->data->detail) : '';
            $title  = isset($raw->data->title) && is_string($raw->data->title) ? trim($raw->data->title) : '';
        }
    } elseif (is_array($raw)) {
        $status = isset($raw['code']) ? (string)$raw['code'] : '';
        $msg    = isset($raw['message']) && is_string($raw['message']) ? trim($raw['message']) : '';

        if (isset($raw['data']) && is_array($raw['data'])) {
            $status = '' !== $status ? $status : (isset($raw['data']['status']) ? (string)$raw['data']['status'] : '');
            $detail = isset($raw['data']['detail']) && is_string($raw['data']['detail']) ? trim($raw['data']['detail']) : '';
            $title  = isset($raw['data']['title']) && is_string($raw['data']['title']) ? trim($raw['data']['title']) : '';
        }
    }

    $genericMessages = [
        'risposta non valida',
        'invalid response',
        'an error occurred',
    ];

    $msgLower   = strtolower($msg);
    $titleLower = strtolower($title);
    foreach ($genericMessages as $genericMessage) {
        if ($msgLower === $genericMessage) {
            $msg = '';
        }
        if ($titleLower === $genericMessage) {
            $title = '';
        }
    }

    $bestText = $detail ?: ($msg ?: $title);
    if ('' === $bestText) {
        return '';
    }

    return '' !== $status ? "[{$status}] {$bestText}" : $bestText;
}

/**
 * Safely decode JSON from mixed payloads.
 *
 * @param mixed $raw
 * @param bool  $assoc
 *
 * @return array{ok:bool,data:mixed,error:string}
 */
function safeJsonDecode($raw, bool $assoc = false): array
{
    if (! is_string($raw)) {
        return [
            'ok'    => false,
            'data'  => null,
            'error' => 'json_decode_input_not_string',
        ];
    }

    $trimmed = trim($raw);
    if ('' === $trimmed) {
        return [
            'ok'    => false,
            'data'  => null,
            'error' => 'json_decode_input_empty',
        ];
    }

    $decoded = json_decode($raw, $assoc);
    if (JSON_ERROR_NONE !== json_last_error()) {
        return [
            'ok'    => false,
            'data'  => null,
            'error' => json_last_error_msg(),
        ];
    }

    return [
        'ok'    => true,
        'data'  => $decoded,
        'error' => '',
    ];
}

/**
 * Prefix native integration messages with channel label.
 *
 * @param string $channel
 * @param string $message
 *
 * @return string
 */
function prefixNativeFlowMessage(string $channel, string $message): string
{
    $channel = strtolower(trim($channel));
    $prefix  = 'NATIVE';
    if ('sdi' === $channel) {
        $prefix = 'SDI';
    } elseif ('peppol' === $channel) {
        $prefix = 'PEPPOL';
    } elseif ('zoho' === $channel) {
        $prefix = 'ZOHO';
    } elseif ('ksef' === $channel) {
        $prefix = 'KSEF';
    }

    return sprintf('%s: %s', $prefix, trim($message));
}

/**
 * Minimal consistency check for marking invoice as sent using a UUID.
 *
 * @param mixed $uuid
 *
 * @return bool
 */
function canMarkInvoiceSentWithUuid($uuid): bool
{
    return is_string($uuid) && '' !== trim($uuid);
}

/**
 * Minimal consistency check for marking invoice as sent using an upload filename.
 *
 * @param mixed $uploadFileName
 *
 * @return bool
 */
function canMarkInvoiceSentWithUploadFileName($uploadFileName): bool
{
    return is_string($uploadFileName) && '' !== trim($uploadFileName);
}

/**
 * resetNumerationsOnYearChange
 *
 * @return void
 */
function resetNumerationsOnYearChange()
{
    $resetNumber = OptionPage::init()->getOptions("reset_numerations_year_change");
    if ($resetNumber && 'on' !== $resetNumber) {
        return;
    }

    try {
        $timeZone = new TimeZone();
        $timeZone = new \DateTimeZone($timeZone->getTimeZone()->getName());
        $now      = new \DateTime('now');
        $now->setTimezone($timeZone);

        $currentYear = get_option('wc_el_inv-current_year');
        if (! $currentYear) {
            update_option('wc_el_inv-current_year', $now->format('Y'));

            return;
        }

        if ((int)$currentYear < (int)$now->format('Y')) {
            $receiptNumberOption           = (int)OptionPage::init()->getOptions("number_next_receipt");
            $creditNoteNumberOption        = (int)OptionPage::init()->getOptions("number_next_credit_note");
            $creditNoteReceiptNumberOption = (int)OptionPage::init()->getOptions("number_next_credit_note_receipt");
            // Reset invoice
            OptionPage::init()->setOption('number_next_invoice', 1);
            // Reset receipt
            if (0 < $receiptNumberOption) {
                OptionPage::init()->setOption('number_next_receipt', 1);
            }
            // Reset credit note invoice
            if (0 < $creditNoteNumberOption) {
                OptionPage::init()->setOption('number_next_credit_note', 1);
                update_option('number_next_credit_note', 1);
            }
            // Reset credit note receipt
            if (0 < $creditNoteReceiptNumberOption) {
                OptionPage::init()->setOption('number_next_credit_note_receipt', 1);
                update_option('number_next_credit_note_receipt', 1);
            }
            // Set new current year
            update_option('wc_el_inv-current_year', $now->format('Y'));
        }
    } catch (\Exception $e) {
        log('Error to resetNumerationsOnYearChange: ' . $e->getMessage());
    }
}

/**
 * setNextNumber
 * Check and set the next number
 *
 * @return void
 */
function setNextNumber()
{
    $options    = \WcElectronInvoice\Admin\Settings\OptionPage::init();
    $nextNumber = $options->getOptions('number_next_invoice');
    if (! $nextNumber || (int)$nextNumber < 1) {
        $options->setOption('number_next_invoice', 1);
    }
}

/**
 * checkAndUpdateFormattedNumber
 *
 * @param $order
 *
 * @return void
 */
function checkAndUpdateFormattedNumber($order)
{
    try {
        // Numeration type
        $numerationType  = $order->get_billing_choice_type() ?: 'invoice';
        $formattedNumber = $order->get_meta("{$order->get_type()}_formatted_number_{$order->get_id()}") ?? null;
        $invoiceNumber   = $order->get_order_number_invoice();

        if ('shop_order_refund' === $order->get_type()) {
            $parentOrder    = OrderQuery::instance()->getProviderOrder($order->get_parent_id(), $order->get_provider());
            $numerationType = $parentOrder->get_billing_choice_type();
            $invoiceNumber  = $order->get_meta("refund_number_invoice-{$order->get_id()}");
            if ('receipt' === $numerationType) {
                $numerationType = 'credit_note_receipt';
            } else {
                $numerationType = 'credit_note';
            }
        }

        // Check if the invoice number has been saved correctly in the formatted number.
        if (! containsNumber($formattedNumber, $invoiceNumber)) {
            // Update formatted
            setFormattedInvoiceNumber($order, $numerationType, true);
        }
    } catch (\Exception $e) {
        /* --- */
    }
}
