<?php
if (!defined("ROOT_PATH")) {
    header("HTTP/1.1 403 Forbidden");
    exit;
}
class pjAppController extends pjBaseAppController
{
    const GALLERY_MODEL_PRODUCT = 'pjProduct';
    const GALLERY_MODEL_MODEL_IMAGE = 'pjProductModelImage';

    public $models = array();

    public function pjActionCheckInstall()
    {
        $this->setLayout('pjActionEmpty');

        $result = array('status' => 'OK', 'code' => 200, 'text' => 'Operation succeeded', 'info' => array());
        $folders = array(
            'app/web/upload/digital',
            'app/web/upload/large',
            'app/web/upload/locale',
            'app/web/upload/medium',
            'app/web/upload/small',
            'app/web/upload/source'
        );
        foreach ($folders as $dir) {
            if (!is_writable($dir)) {
                $result['status'] = 'ERR';
                $result['code'] = 101;
                $result['text'] = 'Permission requirement';
                $result['info'][] = sprintf('Folder \'<span class="bold">%1$s</span>\' is not writable. You need to set write permissions (chmod 777) to directory located at \'<span class="bold">%1$s</span>\'', $dir);
            }
        }

        return $result;
    }

    /**
     * Sets some predefined role permissions and grants full permissions to Admin.
     */
    public function pjActionAfterInstall()
    {
        $this->setLayout('pjActionEmpty');

        $result = array('status' => 'OK', 'code' => 200, 'text' => 'Operation succeeded', 'info' => array());

        $pjAuthRolePermissionModel = pjAuthRolePermissionModel::factory();
        $pjAuthUserPermissionModel = pjAuthUserPermissionModel::factory();

        $permissions = pjAuthPermissionModel::factory()->findAll()->getDataPair('key', 'id');

        $roles = array(1 => 'admin', 2 => 'editor');
        foreach ($roles as $role_id => $role) {
            if (
                isset($GLOBALS['CONFIG'], $GLOBALS['CONFIG']["role_permissions_{$role}"])
                && is_array($GLOBALS['CONFIG']["role_permissions_{$role}"])
                && !empty($GLOBALS['CONFIG']["role_permissions_{$role}"])
            ) {
                $pjAuthRolePermissionModel->reset()->where('role_id', $role_id)->eraseAll();

                foreach ($GLOBALS['CONFIG']["role_permissions_{$role}"] as $role_permission) {
                    if ($role_permission == '*') {
                        // Grant full permissions for the role
                        foreach ($permissions as $key => $permission_id) {
                            $pjAuthRolePermissionModel->setAttributes(compact('role_id', 'permission_id'))->insert();
                        }
                        break;
                    } else {
                        $hasAsterix = strpos($role_permission, '*') !== false;
                        if ($hasAsterix) {
                            $role_permission = str_replace('*', '', $role_permission);
                        }

                        foreach ($permissions as $key => $permission_id) {
                            if ($role_permission == $key || ($hasAsterix && strpos($key, $role_permission) !== false)) {
                                $pjAuthRolePermissionModel->setAttributes(compact('role_id', 'permission_id'))->insert();
                            }
                        }
                    }
                }
            }
        }

        pjOptionModel::factory()
            ->where('`key`', 'o_install_url')
            ->modifyAll(array(
                'value' => PJ_INSTALL_URL . 'preview.php'
            ));

        // Grant full permissions to Admin
        $user_id = 1; // Admin ID
        $pjAuthUserPermissionModel->reset()->where('user_id', $user_id)->eraseAll();
        foreach ($permissions as $key => $permission_id) {
            $pjAuthUserPermissionModel->setAttributes(compact('user_id', 'permission_id'))->insert();
        }

        return $result;
    }

    public function isEditor()
    {
        return $this->getRoleId() == 2;
    }

    public function getForeignId()
    {
        return 1;
    }

    public function beforeFilter()
    {
        parent::beforeFilter();

        if (!in_array($this->_get->toString('controller'), array('pjFront', 'pjInstaller'))) {
            $this->appendJs('pjAdminCore.js');
            /* admin.css / admin-datagrid-listings.css / admin-ui-global.css are appended
               from pjBaseAppController::afterFilter (after plugin CSS) for all backend controllers. */

            if (in_array($this->_get->toString('controller'), array('pjAdminProducts')) && in_array($this->_get->toString('action'), array('pjActionUpdate'))) {
                $this->appendCss('pj-all.css', PJ_FRAMEWORK_LIBS_PATH . 'pj/css/');
                $this->appendJs('jquery-ui.min.js', PJ_THIRD_PARTY_PATH . 'jquery_ui/');
                $this->appendCss('jquery-ui.min.css', PJ_THIRD_PARTY_PATH . 'jquery_ui/');
            }
        }
        return true;
    }

    public static function jsonDecode($str)
    {
        $Services_JSON = new pjServices_JSON();
        return $Services_JSON->decode($str);
    }

    public static function jsonEncode($arr)
    {
        $Services_JSON = new pjServices_JSON();
        return $Services_JSON->encode($arr);
    }

    public static function getCsrfToken()
    {
        if (empty($_SESSION['csrf_token']))
        {
            $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
        }
        return $_SESSION['csrf_token'];
    }

    public static function validateCsrfToken()
    {
        $token = '';
        $reg = pjRegistry::getInstance();
        $post = $reg->get('_post');
        if (is_object($post) && $post->check('csrf_token')) {
            $token = (string) $post->toString('csrf_token');
        } elseif (!empty($_POST['csrf_token'])) {
            $token = $_POST['csrf_token'];
        } elseif (!empty($_SERVER['HTTP_X_CSRF_TOKEN'])) {
            $token = $_SERVER['HTTP_X_CSRF_TOKEN'];
        }
        if (empty($_SESSION['csrf_token']) || !hash_equals($_SESSION['csrf_token'], (string) $token))
        {
            header('HTTP/1.1 403 Forbidden');
            if (!empty($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest') {
                header('Content-Type: application/json');
                exit(json_encode(array('code' => 403, 'message' => 'Invalid CSRF token.')));
            }
            exit('Invalid CSRF token.');
        }
    }

    public static function jsonResponse($arr)
    {
        while (ob_get_level() > 0) {
            ob_end_clean();
        }

        $prevDisplayErrors = ini_get('display_errors');
        ini_set('display_errors', '0');

        if (!headers_sent()) {
            header("Content-Type: application/json; charset=utf-8");
        }

        $json = pjAppController::jsonEncode($arr);
        if ($json === false || $json === null || $json === '') {
            $fallback = array(
                'status' => 'ERR',
                'code' => 500,
                'text' => 'Could not encode server response as JSON.'
            );
            if (isset($arr['text']) && is_string($arr['text'])) {
                $fallback['text'] = mb_substr(strip_tags($arr['text']), 0, 2000);
            }
            $json = function_exists('json_encode')
                ? json_encode($fallback)
                : '{"status":"ERR","code":500,"text":"Could not encode server response as JSON."}';
        }

        echo $json;

        if ($prevDisplayErrors !== false) {
            ini_set('display_errors', $prevDisplayErrors);
        }
        exit;
    }

    public function getLocaleId()
    {
        return 1;
    }

    public function setLocaleId($locale_id)
    {
        $_SESSION[$this->defaultLocale] = 1;
    }

    static public function getFromEmail()
    {
        $arr = pjAuthUserModel::factory()
            ->findAll()
            ->orderBy("t1.id ASC")
            ->limit(1)
            ->getData();
        return !empty($arr) ? $arr[0]['email'] : null;
    }

    static public function getAdminEmail()
    {
        $arr = pjAuthUserModel::factory()
            ->where('t1.role_id', '1')
            ->where('t1.status', 'T')
            ->findAll()
            ->getDataPair('id', 'email');
        return $arr;
    }

    static public function getAdminPhone()
    {
        $arr = pjAuthUserModel::factory()
            ->where('t1.role_id', '1')
            ->where('t1.status', 'T')
            ->findAll()
            ->getDataPair('id', 'phone');
        return $arr;
    }

    public static function friendlyURL($str, $divider = '-')
    {
        $str = mb_strtolower($str, mb_detect_encoding($str));
        $str = trim($str);
        $str = preg_replace('/[_|\s]+/', $divider, $str);
        $str = preg_replace('/\x{00C5}/u', 'AA', $str);
        $str = preg_replace('/\x{00C6}/u', 'AE', $str);
        $str = preg_replace('/\x{00D8}/u', 'OE', $str);
        $str = preg_replace('/\x{00E5}/u', 'aa', $str);
        $str = preg_replace('/\x{00E6}/u', 'ae', $str);
        $str = preg_replace('/\x{00F8}/u', 'oe', $str);
        $str = preg_replace('/[^a-z\x{0400}-\x{04FF}0-9-]+/u', '', $str);
        $str = preg_replace('/[-]+/', $divider, $str);
        $str = preg_replace('/^-+|-+$/', '', $str);
        return $str;
    }

    public static function getSubjectMessage($notification, $locale_id)
    {
        $variant = $notification['variant'] == 'confirmation' ? 'confirm' : $notification['variant'];
        $field = $variant . '_tokens_' . $notification['recipient'];
        $pjMultiLangModel = pjMultiLangModel::factory();
        $lang_message = $pjMultiLangModel
            ->reset()
            ->select('t1.*')
            ->where('t1.foreign_id', $notification['id'])
            ->where('t1.model', 'pjNotification')
            ->where('t1.locale', $locale_id)
            ->where('t1.field', $field)
            ->limit(0, 1)
            ->findAll()
            ->getData();
        $field = $variant . '_subject_' . $notification['recipient'];
        $lang_subject = $pjMultiLangModel
            ->reset()
            ->select('t1.*')
            ->where('t1.foreign_id',  $notification['id'])
            ->where('t1.model', 'pjNotification')
            ->where('t1.locale', $locale_id)
            ->where('t1.field', $field)
            ->limit(0, 1)
            ->findAll()
            ->getData();
        return compact('lang_message', 'lang_subject');
    }

    public static function getSmsMessage($notification, $locale_id)
    {
        $variant = $notification['variant'] == 'confirmation' ? 'confirm' : $notification['variant'];
        $field = $variant . '_sms_' . $notification['recipient'];
        $pjMultiLangModel = pjMultiLangModel::factory();
        $lang_message = $pjMultiLangModel
            ->reset()
            ->select('t1.*')
            ->where('t1.foreign_id', $notification['id'])
            ->where('t1.model', 'pjNotification')
            ->where('t1.locale', $locale_id)
            ->where('t1.field', $field)
            ->limit(0, 1)
            ->findAll()
            ->getData();
        return compact('lang_message');
    }

    public static function getDiscount($data, $option_arr)
    {
        if (!isset($data['code']) || empty($data['code'])) {
            // Missing params
            return array('status' => 'ERR', 'code' => 100, 'text' => __('system_134', true));
        }
        $arr = pjVoucherModel::factory()
            ->select(sprintf("t1.*, (SELECT GROUP_CONCAT(`product_id`) FROM `%s` WHERE `voucher_id` = `t1`.`id` LIMIT 1) AS `products`", pjVoucherProductModel::factory()->getTable()))
            ->where('t1.code', $data['code'])
            ->limit(1)
            ->findAll()
            ->toArray('products', ',')
            ->getData();

        if (empty($arr)) {
            // Not found
            return array('status' => 'ERR', 'code' => 101, 'text' => __('system_133', true));
        }
        $arr = $arr[0];

        $date = $data['date'];
        if (isset($data['hour']) && isset($data['minute'])) {
            $time = $data['hour'] . ":" . $data['minute'] . ":00";
        }
        if (!isset($time)) {
            $time = "00:00:00";
        }
        if (empty($date)) {
            // Empty date
            return array('status' => 'ERR', 'code' => 103, 'text' => __('system_135', true));
        }
        $d = strtotime($date);
        $dt = strtotime($date . " " . $time);

        $valid = false;
        switch ($arr['valid']) {
            case 'fixed':
                $time_from = strtotime($arr['date_from'] . " " . $arr['time_from']);
                $time_to = strtotime($arr['date_to'] . " " . $arr['time_to']);
                if ($time_from <= $dt && $time_to >= $dt) {
                    // Valid
                    $valid = true;
                }
                break;
            case 'period':
                $d_from = strtotime($arr['date_from']);
                $d_to = strtotime($arr['date_to']);
                $t_from = strtotime($arr['date_from'] . " " . $arr['time_from']);
                $t_to = strtotime($arr['date_to'] . " " . $arr['time_to']);
                if ($d_from <= $d && $d_to >= $d && $t_from <= $dt && $t_to >= $dt) {
                    // Valid
                    $valid = true;
                }
                break;
            case 'recurring':
                $t_from = strtotime($date . " " . $arr['time_from']);
                $t_to = strtotime($date . " " . $arr['time_to']);
                if ($arr['every'] == strtolower(date("l", $dt)) && $t_from <= $dt && $t_to >= $dt) {
                    // Valid
                    $valid = true;
                }
                break;
        }

        if (!$valid) {
            // Out of date
            return array('status' => 'ERR', 'code' => 102, 'text' => __('system_136', true));
        }

        // Valid
        return array(
            'status' => 'OK',
            'code' => 200,
            'text' => __('system_137', true),
            'voucher_code' => $arr['code'],
            'voucher_type' => $arr['type'],
            'voucher_apply' => $arr['apply'],
            'voucher_discount' => $arr['discount'],
            'voucher_products' => $arr['products']
        );
    }

    public static function addToHistory($record_id, $user_id, $table, $before, $after)
    {
        return pjHistoryModel::factory()->setAttributes(array(
            'record_id' => $record_id,
            'user_id' => $user_id,
            'table_name' => $table,
            'before' => base64_encode(serialize($before)),
            'after' => base64_encode(serialize($after)),
            'ip' => $_SERVER['REMOTE_ADDR']
        ))->insert()->getInsertId();
    }

    public static function getCompanyTokenMap($locale_id = 1)
    {
        $config = array();
        try {
            $config = pjInvoiceConfigModel::factory()->getConfigData((int) $locale_id);
        } catch (Exception $e) {
        }

        $logo_path = !empty($config['y_email_logo']) ? $config['y_email_logo'] : (!empty($config['y_logo']) ? $config['y_logo'] : '');
        $company_logo = '';
        if (!empty($logo_path)) {
            $logo_full_path = PJ_INSTALL_PATH . $logo_path;
            if (is_file($logo_full_path) && ($size = @getimagesize($logo_full_path))) {
                $logo_width = min(600, $size[0]);
                $company_logo = '<img src="' . PJ_INSTALL_URL . $logo_path . '" width="' . $logo_width . '" style="max-width:100%;height:auto;display:block;" />';
            }
        }

        return array(
            '{CompanyName}'    => !empty($config['y_company']) ? $config['y_company'] : (!empty($config['y_name']) ? $config['y_name'] : ''),
            '{CompanyLogo}'    => $company_logo,
            '{CompanyAddress}' => !empty($config['y_street_address']) ? $config['y_street_address'] : '',
            '{CompanyZip}'     => !empty($config['y_zip']) ? $config['y_zip'] : '',
            '{CompanyCity}'    => !empty($config['y_city']) ? $config['y_city'] : '',
            '{CompanyCountry}' => !empty($config['y_country_title']) ? $config['y_country_title'] : (!empty($config['y_country']) ? $config['y_country'] : ''),
            '{CompanyState}'   => !empty($config['y_state']) ? $config['y_state'] : '',
            '{CompanyPhone}'   => !empty($config['y_phone']) ? $config['y_phone'] : '',
            '{CompanyEmail}'   => !empty($config['y_email']) ? $config['y_email'] : '',
            '{CompanyUrl}'     => !empty($config['y_url']) ? $config['y_url'] : '',
            '{CompanyTaxNumber}' => !empty($config['y_vat_number']) ? $config['y_vat_number'] : '',
            '{CompanyRegistrationNumber}' => !empty($config['y_coc_number']) ? $config['y_coc_number'] : '',
            '{CompanyBankName}' => !empty($config['y_bank_name']) ? $config['y_bank_name'] : '',
            '{CompanyBankAccount}' => !empty($config['y_bank_iban']) ? $config['y_bank_iban'] : '',
        );
    }

    public static function getInvoiceTemplateTokenGroups()
    {
        return array(
            'invoice' => array(
                'label_key' => 'invoice_tokens_group_invoice',
                'fallback'  => 'Invoice',
                'tokens'    => array(
                    '{InvoiceNumber}' => array('label_key' => 'invoice_token_invoice_number', 'fallback' => 'invoice number'),
                    '{OrderNumber}'   => array('label_key' => 'invoice_token_order_number', 'fallback' => 'order number'),
                    '{IssueDate}'     => array('label_key' => 'invoice_token_issue_date', 'fallback' => 'issue date'),
                    '{DueDate}'       => array('label_key' => 'invoice_token_due_date', 'fallback' => 'due date'),
                    '{PaymentStatus}' => array('label_key' => 'invoice_token_payment_status', 'fallback' => 'invoice status'),
                    '{PaymentMethod}' => array('label_key' => 'invoice_token_payment_method', 'fallback' => 'payment method'),
                    '{TransactionID}' => array('label_key' => 'invoice_token_transaction_id', 'fallback' => 'transaction ID'),
                ),
            ),
            'company' => array(
                'label_key' => 'invoice_tokens_group_company',
                'fallback'  => 'Company',
                'tokens'    => array(
                    '{CompanyLogo}'               => array('label_key' => 'invoice_token_company_logo', 'fallback' => 'logo'),
                    '{CompanyName}'               => array('label_key' => 'invoice_token_company_name', 'fallback' => 'company name'),
                    '{CompanyAddress}'            => array('label_key' => 'invoice_token_company_address', 'fallback' => 'street address'),
                    '{CompanyZip}'                => array('label_key' => 'invoice_token_company_zip', 'fallback' => 'ZIP / postal code'),
                    '{CompanyCity}'               => array('label_key' => 'invoice_token_company_city', 'fallback' => 'city'),
                    '{CompanyCountry}'            => array('label_key' => 'invoice_token_company_country', 'fallback' => 'country'),
                    '{CompanyState}'              => array('label_key' => 'invoice_token_company_state', 'fallback' => 'state'),
                    '{CompanyPhone}'              => array('label_key' => 'invoice_token_company_phone', 'fallback' => 'phone'),
                    '{CompanyEmail}'              => array('label_key' => 'invoice_token_company_email', 'fallback' => 'email'),
                    '{CompanyUrl}'                => array('label_key' => 'invoice_token_company_url', 'fallback' => 'website'),
                    '{CompanyTaxNumber}'          => array('label_key' => 'invoice_token_company_tax_number', 'fallback' => 'tax number'),
                    '{CompanyRegistrationNumber}' => array('label_key' => 'invoice_token_company_registration_number', 'fallback' => 'registration / COC number'),
                    '{CompanyBankName}'           => array('label_key' => 'invoice_token_company_bank_name', 'fallback' => 'bank account name'),
                    '{CompanyBankAccount}'        => array('label_key' => 'invoice_token_company_bank_account', 'fallback' => 'bank account / IBAN'),
                ),
            ),
            'customer' => array(
                'label_key' => 'invoice_tokens_group_customer',
                'fallback'  => 'Customer',
                'tokens'    => array(
                    '{ClientName}'       => array('label_key' => 'invoice_token_client_name', 'fallback' => 'customer name'),
                    '{ClientEmail}'      => array('label_key' => 'invoice_token_client_email', 'fallback' => 'customer email'),
                    '{BillingName}'      => array('label_key' => 'invoice_token_billing_name', 'fallback' => 'billing name'),
                    '{BillingAddress1}'  => array('label_key' => 'invoice_token_billing_address_1', 'fallback' => 'billing address line 1'),
                    '{BillingAddress2}'  => array('label_key' => 'invoice_token_billing_address_2', 'fallback' => 'billing address line 2'),
                    '{BillingZip}'       => array('label_key' => 'invoice_token_billing_zip', 'fallback' => 'billing ZIP / postal code'),
                    '{BillingCity}'      => array('label_key' => 'invoice_token_billing_city', 'fallback' => 'billing city'),
                    '{BillingState}'     => array('label_key' => 'invoice_token_billing_state', 'fallback' => 'billing state'),
                    '{BillingCountry}'   => array('label_key' => 'invoice_token_billing_country', 'fallback' => 'billing country'),
                    '{ShippingName}'     => array('label_key' => 'invoice_token_shipping_name', 'fallback' => 'shipping name'),
                    '{ShippingAddress1}' => array('label_key' => 'invoice_token_shipping_address_1', 'fallback' => 'shipping address line 1'),
                    '{ShippingAddress2}' => array('label_key' => 'invoice_token_shipping_address_2', 'fallback' => 'shipping address line 2'),
                    '{ShippingZip}'      => array('label_key' => 'invoice_token_shipping_zip', 'fallback' => 'shipping ZIP / postal code'),
                    '{ShippingCity}'     => array('label_key' => 'invoice_token_shipping_city', 'fallback' => 'shipping city'),
                    '{ShippingState}'    => array('label_key' => 'invoice_token_shipping_state', 'fallback' => 'shipping state'),
                    '{ShippingCountry}'  => array('label_key' => 'invoice_token_shipping_country', 'fallback' => 'shipping country'),
                ),
            ),
            'totals' => array(
                'label_key' => 'invoice_tokens_group_totals',
                'fallback'  => 'Totals',
                'tokens'    => array(
                    '{Items}'     => array('label_key' => 'invoice_token_items', 'fallback' => 'invoice items table'),
                    '{Subtotal}'  => array('label_key' => 'invoice_token_subtotal', 'fallback' => 'subtotal'),
                    '{Discount}'  => array('label_key' => 'invoice_token_discount', 'fallback' => 'discount'),
                    '{Shipping}'  => array('label_key' => 'invoice_token_shipping', 'fallback' => 'shipping amount'),
                    '{Tax}'       => array('label_key' => 'invoice_token_tax', 'fallback' => 'tax amount'),
                    '{Insurance}' => array('label_key' => 'invoice_token_insurance', 'fallback' => 'insurance amount'),
                    '{Total}'     => array('label_key' => 'invoice_token_total', 'fallback' => 'total amount'),
                    '{Currency}'  => array('label_key' => 'invoice_token_currency', 'fallback' => 'currency'),
                    '{Notes}'     => array('label_key' => 'invoice_token_notes', 'fallback' => 'invoice notes'),
                ),
            ),
        );
    }

    public static function getTokens($order_arr, $option_arr)
    {
        $date = new DateTime($order_arr['created']);
        $order_date = $date->format($option_arr['o_date_format']);
        $locale_id = ! empty($order_arr['locale_id']) ? $order_arr['locale_id'] : 1;

        $search = [
            '{ShippingName}',
            '{ShippingFirstName}',
            '{ShippingLastName}',
            '{ShippingCountry}',
            '{ShippingCity}',
            '{ShippingState}',
            '{ShippingZip}',
            '{ShippingAddress1}',
            '{ShippingAddress2}',
            '{BillingName}',
            '{BillingFirstName}',
            '{BillingLastName}',
            '{BillingCountry}',
            '{BillingCity}',
            '{BillingState}',
            '{BillingZip}',
            '{BillingAddress1}',
            '{BillingAddress2}',
            '{ClientName}',
            '{ClientEmail}',
            '{ClientPassword}',
            '{ClientPhone}',
            '{ClientURL}',
            '{PaymentMethod}',
            '{Price}',
            '{Discount}',
            '{Insurance}',
            '{Shipping}',
            '{Tax}',
            '{Total}',
            '{Voucher}',
            '{Notes}',
            '{OrderID}',
            '{OrderUUID}',
            '{OrderDate}',
            '{Products}',
            '{StoreName}',
            '{InvoiceNumber}',
            '{InvoicePdfLink}',
            '{PaymentRetryUrl}'
        ];

        $digital_download = __('front_na', true);
        $email = isset($order_arr['email']) ? $order_arr['email'] : (isset($order_arr['client_email']) ? $order_arr['client_email'] : NULL);
        $phone = isset($order_arr['phone']) ? $order_arr['phone'] : (isset($order_arr['client_phone']) ? $order_arr['client_phone'] : NULL);
        $url = isset($order_arr['url']) ? $order_arr['url'] : (isset($order_arr['client_url']) ? $order_arr['client_url'] : NULL);

        $payment_method_display = self::enrichPaymentMethod($order_arr);

        $replace = [
            $order_arr['s_name'],
            '',
            '',
            @$order_arr['s_country'],
            $order_arr['s_city'],
            $order_arr['s_state'],
            $order_arr['s_zip'],
            $order_arr['s_address_1'],
            $order_arr['s_address_2'],
            $order_arr['b_name'],
            '',
            '',
            @$order_arr['b_country'],
            $order_arr['b_city'],
            $order_arr['b_state'],
            $order_arr['b_zip'],
            $order_arr['b_address_1'],
            $order_arr['b_address_2'],
            $order_arr['client_name'],
            $email,
            '********',
            $phone,
            $url,
            $payment_method_display,
            pjCurrency::formatPrice($order_arr['price']),
            pjCurrency::formatPrice($order_arr['discount']),
            pjCurrency::formatPrice($order_arr['insurance']),
            pjCurrency::formatPrice($order_arr['shipping']),
            pjCurrency::formatPrice($order_arr['tax']),
            pjCurrency::formatPrice($order_arr['total']),
            $order_arr['voucher'],
            $order_arr['notes'],
            $order_arr['id'],
            $order_arr['uuid'],
            $order_date,
            @$order_arr['products'],
            __('lblStoreName', true),
            isset($order_arr['invoice_uuid']) ? $order_arr['invoice_uuid'] : '',
            isset($order_arr['invoice_pdf_link']) ? $order_arr['invoice_pdf_link'] : '',
            isset($order_arr['payment_retry_url']) ? $order_arr['payment_retry_url'] : ''
        ];

        $company_tokens = self::getCompanyTokenMap($locale_id);
        $search = array_merge($search, array_keys($company_tokens));
        $replace = array_merge($replace, array_values($company_tokens));

        return compact('search', 'replace');
    }

    public static function enrichPaymentMethod($order_arr)
    {
        $method = isset($order_arr['payment_method']) ? (string) $order_arr['payment_method'] : '';
        if (pjObject::getPlugin('pjPayments') !== NULL && class_exists('pjPayments')) {
            $foreign_id = isset($order_arr['foreign_id']) ? $order_arr['foreign_id'] : 1;
            $locale_id = isset($order_arr['locale_id']) ? $order_arr['locale_id'] : null;
            if (empty($locale_id)) {
                $locale = pjLocaleModel::factory()->where('is_default', 1)->limit(1)->findAll()->getDataIndex(0);
                $locale_id = !empty($locale['id']) ? $locale['id'] : 1;
            }
            $titles = pjPayments::getPaymentTitles($foreign_id, $locale_id);
        } else {
            $titles = __('payment_methods', true);
        }
        if (!is_array($titles)) {
            $titles = array();
        }
        return isset($titles[$method]) ? $titles[$method] : $method;
    }

    public static function getActivePaymentMethods($foreign_id = null, $locale_id = null)
    {
        $methods = array();
        if (pjObject::getPlugin('pjPayments') !== NULL && class_exists('pjPayments')) {
            $foreign_id = $foreign_id === null ? 1 : $foreign_id;
            $locale_id = $locale_id === null ? 1 : $locale_id;
            $payment_titles = pjPayments::getPaymentTitles($foreign_id, $locale_id);
            $payment_options = pjPaymentOptionModel::factory()->getOptions($foreign_id);
            $default_payment_options = pjPaymentOptionModel::factory()->getOptions(null);
            foreach ((array) $payment_titles as $method => $title) {
                $option = isset($payment_options[$method]) ? $payment_options[$method] : (isset($default_payment_options[$method]) ? $default_payment_options[$method] : null);
                if ($option !== null && (int) $option['is_active'] === 1) {
                    $methods[$method] = $title;
                }
            }
        } else {
            $methods = __('payment_methods', true);
            if (!is_array($methods)) {
                $methods = array();
            }
        }

        return $methods;
    }

    public static function isOnlinePaymentMethod($payment_method, $foreign_id = null)
    {
        $payment_method = (string) $payment_method;
        if ($payment_method === '' || pjObject::getPlugin('pjPayments') === NULL || !class_exists('pjPayments')) {
            return false;
        }

        $plugin_methods = pjPayments::getPaymentMethods();
        if (!isset($plugin_methods[$payment_method])) {
            return false;
        }

        $foreign_id = $foreign_id === null ? 1 : $foreign_id;
        $payment_options = pjPaymentOptionModel::factory()->getOptions($foreign_id);
        $default_payment_options = pjPaymentOptionModel::factory()->getOptions(null);
        $option = isset($payment_options[$payment_method]) ? $payment_options[$payment_method] : (isset($default_payment_options[$payment_method]) ? $default_payment_options[$payment_method] : null);
        if ($option === null || (int) $option['is_active'] !== 1) {
            return false;
        }

        $plugin = pjPayments::getPluginName($payment_method);
        return pjObject::getPlugin($plugin) !== NULL;
    }

    public static function getPaymentRetryUrl($order_arr)
    {
        if (empty($order_arr['id']) || empty($order_arr['payment_method'])) {
            return '';
        }
        if (!self::isOnlinePaymentMethod($order_arr['payment_method'], isset($order_arr['foreign_id']) ? $order_arr['foreign_id'] : 1)) {
            return '';
        }

        return PJ_INSTALL_URL . 'index.php?controller=pjFrontCart&action=pjActionGetPaymentForm'
            . '&order_id=' . urlencode($order_arr['id'])
            . '&payment_method=' . urlencode($order_arr['payment_method']);
    }

    public static function pjActionCheckDigital($order_id)
    {
        $has_digital = false;
        $digitals = array();
        $os_arr = pjOrderStockModel::factory()
            ->select('t2.digital_file, t2.digital_name, t2.is_digital')
            ->join('pjProduct', "t2.id=t1.product_id AND t2.is_digital='1'", 'inner')
            ->where('t1.order_id', $order_id)
            ->findAll()
            ->getData();
        foreach ($os_arr as $item) {
            $digitals[] = $item;
        }
        foreach ($digitals as $file) {
            if (!empty($file['digital_file']) && is_file($file['digital_file'])) {
                $has_digital = true;
                break;
            }
        }
        return $has_digital;
    }

    public static function pjActionGetExtrasList($product_id, $locale_id)
    {
        $pjExtraItemModel = pjExtraItemModel::factory();
        $pjExtraModel = pjExtraModel::factory();
        if (!empty($product_id)) {
            $pjExtraModel->whereIn('t1.product_id', $product_id);
        }
        $extra_arr = $pjExtraModel
            ->select('t1.*, t2.content AS name, t3.content AS title')
            ->join('pjMultiLang', "t2.model='pjExtra' AND t2.foreign_id=t1.id AND t2.locale='$locale_id' AND t2.field='extra_name'", 'left outer')
            ->join('pjMultiLang', "t3.model='pjExtra' AND t3.foreign_id=t1.id AND t3.locale='$locale_id' AND t3.field='extra_title'", 'left outer')
            ->orderBy('`title` ASC, `name` ASC')
            ->findAll()
            ->getData();


        foreach ($extra_arr as $k => $extra) {
            $extra_arr[$k]['extra_items'] = $pjExtraItemModel
                ->reset()
                ->select('t1.*, t2.content AS name')
                ->join('pjMultiLang', "t2.model='pjExtraItem' AND t2.foreign_id=t1.id AND t2.locale='$locale_id' AND t2.field='extra_name'", 'left outer')
                ->where('t1.extra_id', $extra['id'])
                ->orderBy('t1.price ASC')
                ->findAll()
                ->getData();
        }
        return $extra_arr;
    }

    public static function pjActionGetAttr($product_id, $locale_id)
    {
        $attr_arr = $a_arr = array();
        if (!empty($product_id)) {
            // Do not change col_name, direction
            $a_arr = pjAttributeModel::factory()
                ->select('t1.id, t1.product_id, t1.parent_id, t2.content AS name')
                ->join('pjMultiLang', "t2.model='pjAttribute' AND t2.foreign_id=t1.id AND t2.field='name' AND t2.locale='$locale_id'", 'left outer')
                ->whereIn('t1.product_id', $product_id)
                ->orderBy('t1.parent_id ASC, `name` ASC')
                ->findAll()
                ->getData();
        }
        foreach ($a_arr as $attr) {
            if ((int) $attr['parent_id'] === 0) {
                $attr_arr[$attr['id']] = $attr;
            } else {
                if (!isset($attr_arr[$attr['parent_id']]['child'])) {
                    $attr_arr[$attr['parent_id']]['child'] = array();
                }
                $attr_arr[$attr['parent_id']]['child'][] = $attr;
            }
        }
        $attr_arr = array_values($attr_arr);

        return $attr_arr;
    }

    public static function pjActionGetOrderStock($order_id, $locale_id)
    {
        $os_arr = pjOrderStockModel::factory()
            ->select("t1.*, t2.sku, t3.content AS name,
				(SELECT GROUP_CONCAT(CONCAT_WS('_', `attribute_id`, `attribute_parent_id`))
					FROM `" . pjStockAttributeModel::factory()->getTable() . "`
					WHERE `stock_id` = `t1`.`stock_id`
					LIMIT 1) AS `attr`,
				(SELECT GROUP_CONCAT(CONCAT_WS('.', `extra_id`, `extra_item_id`))
					FROM `" . pjOrderExtraModel::factory()->getTable() . "`
					WHERE `order_stock_id` = `t1`.`id`
					LIMIT 1) AS `extra`,
				(SELECT `small_path` FROM `" . pjGalleryModel::factory()->getTable() . "` WHERE `foreign_id` = t1.product_id ORDER BY `id` ASC LIMIT 1) AS `product_image`")
            ->join('pjProduct', 't2.id=t1.product_id', 'left outer')
            ->join('pjMultiLang', "t3.model='pjProduct' AND t3.foreign_id=t2.id AND t3.field='name' AND t3.locale='$locale_id'", 'left outer')
            ->where('t1.order_id', $order_id)
            ->findAll()
            ->getData();

        $product_id = array();
        $stock_ids = array();
        foreach ($os_arr as $item) {
            $product_id[] = $item['product_id'];
            if (!empty($item['stock_id'])) {
                $stock_ids[] = (int) $item['stock_id'];
            }
        }
        $stock_ids = array_values(array_unique($stock_ids));

        $image_arr = array();
        if (!empty($stock_ids)) {
            $image_arr = pjStockModel::factory()
                ->select('t1.id, t2.small_path')
                ->join('pjGallery', 't2.id=t1.image_id', 'left outer')
                // ->where('t1.status', 'T')
                ->whereIn('t1.id', $stock_ids)
                ->findAll()
                ->getDataPair('id', 'small_path');
        }

        $extra_arr = pjAppController::pjActionGetExtrasList($product_id, $locale_id);
        $attr_arr = pjAppController::pjActionGetAttr($product_id, $locale_id);
        // echo "<pre>"; var_dump($image_arr); echo "</pre>"; die;
        return compact('os_arr', 'extra_arr', 'attr_arr', 'image_arr');
    }


    public static function pjActionGetProductsString($order_id, $locale_id)
    {
        $result = pjAppController::pjActionGetOrderStock($order_id, $locale_id);
        if (!isset($result['os_arr']) || empty($result['os_arr'])) {
            return '';
        }

        $stack = array();
        foreach ($result['os_arr'] as $item) {
            $stack[] = sprintf("%s x %u", $item['name'], (int) $item['qty']);
            $attrs = array();
            if (isset($item['attr']) && !empty($item['attr'])) {
                $at = array();
                $a = explode(",", $item['attr']);
                foreach ($a as $v) {
                    $t = explode("_", $v);
                    $at[$t[1]] = $t[0];
                }
                foreach ($at as $attr_parent_id => $attr_id) {
                    foreach ($result['attr_arr'] as $attr) {
                        if ($attr['id'] == $attr_parent_id) {
                            foreach ($attr['child'] as $child) {
                                if ($child['id'] == $attr_id) {
                                    $attrs[] = sprintf('%s: %s', $attr['name'], $child['name']);
                                    break;
                                }
                            }
                        }
                    }
                }
            }
            if (!empty($attrs)) {
                $stack[] = join("; ", $attrs);
            }
            //Extras
            $extras = array();
            if (isset($item['extra']) && !empty($item['extra'])) {
                $a = explode(",", $item['extra']);
                foreach ($a as $eid) {
                    if (strpos($eid, ".") === FALSE) {
                        //single
                        foreach ($result['extra_arr'] as $extra) {
                            if ($extra['id'] == $eid) {
                                $extras[] = $extra['name'];
                                break;
                            }
                        }
                    } else {
                        //multi
                        list($e_id, $ei_id) = explode(".", $eid);
                        foreach ($result['extra_arr'] as $extra) {
                            if ($extra['id'] == $e_id && isset($extra['extra_items']) && !empty($extra['extra_items'])) {
                                foreach ($extra['extra_items'] as $extra_item) {
                                    if ($extra_item['id'] == $ei_id) {
                                        $extras[] = $extra_item['name'];
                                        break;
                                    }
                                }
                                break;
                            }
                        }
                    }
                }
            }
            if (!empty($extras)) {
                $stack[] = join("; ", $extras);
            }
        }

        return join("\n", $stack);
    }

    public static function pjActionCalcPrices($product_id, $extra_arr, $cart_arr, $stocks, $voucher, $option_arr, $tax_id, $from)
    {
        $price = $discount = $tax = $shipping = $insurance = $total = 0;

        foreach ($cart_arr as $cart_item) {
            if ($from == 'front') {
                $product = unserialize($cart_item['key_data']);
                if ($cart_item['qty'] > @$stocks[$cart_item['stock_id']]['qty'] && $product['is_digital'] == 0) {
                    return false;
                    break;
                }

                $amount = @$stocks[$cart_item['stock_id']]['price'] * $cart_item['qty'];

                $item = unserialize($cart_item['key_data']);
                if (isset($item['extra']) && is_array($item['extra'])) {
                    $extras = array();
                    foreach ($item['extra'] as $extra) {
                        if (strpos($extra, ".") !== FALSE) {
                            list($extras['extra_id'], $extras['extra_item_id']) = explode(".", $extra);
                            $amount += @$extra_arr[$extras['extra_id']]['extra_items'][$extras['extra_item_id']] * $cart_item['qty'];
                        } else {
                            $amount += @$extra_arr[$extra]['price'] * $cart_item['qty'];
                        }
                    }
                }
            } else {
                $oe_arr = pjOrderExtraModel::factory()->where('t1.order_id', $product_id)->findAll()->getData();
                $amount = $cart_item['qty'] * $cart_item['price'];
                foreach ($oe_arr as $oe_item) {
                    if ($cart_item['id'] == $oe_item['order_stock_id']) {
                        $amount += $oe_item['price'] * $cart_item['qty'];
                    }
                }
            }

            $price += $amount;
            if ($voucher && $voucher['voucher_apply'] == 'each') {
                $discount += pjUtil::getDiscount($amount, $cart_item['product_id'], @$voucher);
            }
        }

        if ($voucher && $voucher['voucher_apply'] == 'total') {
            switch ($voucher['voucher_type']) {
                case 'percent':
                    $discount = ($price * $voucher['voucher_discount']) / 100;
                    break;
                case 'amount':
                    $discount = $voucher['voucher_discount'];
                    break;
            }
        }

        if ($tax_id != null && (int) $tax_id > 0) {
            $tax_arr = pjTaxModel::factory()->find($tax_id)->getData();
            if (!empty($tax_arr)) {
                $shipping = (float) $tax_arr['shipping'];
                if ((float) $tax_arr['free'] > 0 && (float) $price >= (float) $tax_arr['free']) {
                    $shipping = 0;
                }
                if ((float) $tax_arr['tax'] > 0) {
                    $tax = (($price) * (float) $tax_arr['tax']) / 100;
                }
            }
        }

        switch ($option_arr['o_insurance_type']) {
            case 'percent':
                $insurance = (($price) * (float) $option_arr['o_insurance']) / 100;
                break;
            case 'amount':
                $insurance = (float) $option_arr['o_insurance'];
                break;
            default:
                $insurance = 0;
        }
        $total = $price + $shipping + $tax + $insurance - $discount;

        return compact('price', 'tax', 'shipping', 'insurance', 'discount', 'total');
    }

    /**
     * Generates a responsive HTML table with products for emails.
     * Hybrid approach: table on desktop, cards on mobile.
     */
    public static function pjActionGetProductsHtml($order_id, $locale_id, $option_arr = [])
    {
        $result = pjAppController::pjActionGetOrderStock($order_id, $locale_id);
        if (! isset($result['os_arr']) || empty($result['os_arr'])) {
            return '';
        }

        // Determine whether prices are shown incl. or excl. VAT
        $vat_include = isset($option_arr['o_vat_rate_include_exclude']) ? (int) $option_arr['o_vat_rate_include_exclude'] : 1;

        // Start HTML with inline styles for email compatibility
        $html = '
<style type="text/css">
    @media only screen and (max-width: 600px) {
        .products-table { width: 100% !important; }
        .products-table thead { display: none !important; }
        .products-table tr { display: block !important; margin-bottom: 15px !important; border: 1px solid #e0e0e0 !important; border-radius: 8px !important; padding: 15px !important; background: #fafafa !important; }
        .products-table td { display: block !important; text-align: left !important; padding: 5px 0 !important; border: none !important; }
        .products-table td:before { content: attr(data-label); font-weight: bold; display: block; margin-bottom: 3px; color: #444; font-size: 12px; }
        .products-table .product-name-cell { font-size: 16px !important; font-weight: bold !important; border-bottom: 1px solid #e0e0e0 !important; padding-bottom: 10px !important; margin-bottom: 10px !important; }
        .products-table .product-details { padding-left: 0 !important; }
        .products-table .product-price-cell { font-weight: bold !important; color: #1a1a1a !important; font-size: 16px !important; text-align: right !important; margin-top: 10px !important; padding-top: 10px !important; border-top: 1px solid #e0e0e0 !important; }
    }
</style>
<table class="products-table" cellpadding="0" cellspacing="0" border="0" style="width: 100%; border-collapse: collapse; font-family: Arial, sans-serif; font-size: 14px;">
    <thead>
        <tr style="background-color: #f0f0f0;">
            <th style="padding: 12px 15px; text-align: left; border-bottom: 2px solid #ddd; font-weight: 600; color: #1a1a1a;">Product</th>
            <th style="padding: 12px 15px; text-align: center; border-bottom: 2px solid #ddd; font-weight: 600; color: #1a1a1a; width: 80px;">Quantity</th>
            <th style="padding: 12px 15px; text-align: right; border-bottom: 2px solid #ddd; font-weight: 600; color: #1a1a1a; width: 100px;">Price</th>
        </tr>
    </thead>
    <tbody>';

        foreach ($result['os_arr'] as $item) {
            $productName = htmlspecialchars($item['name'] ?? '');
            $qty = (int) $item['qty'];
            $price = floatval($item['price'] ?? 0);
            $lineTotal = $price * $qty;

            // Verzamel attributen
            $attrs = [];
            if (isset($item['attr']) && ! empty($item['attr'])) {
                $at = [];
                $a = explode(",", $item['attr']);
                foreach ($a as $v) {
                    $t = explode("_", $v);
                    if (count($t) >= 2) {
                        $at[$t[1]] = $t[0];
                    }
                }
                foreach ($at as $attr_parent_id => $attr_id) {
                    foreach ($result['attr_arr'] as $attr) {
                        if ($attr['id'] == $attr_parent_id && isset($attr['child'])) {
                            foreach ($attr['child'] as $child) {
                                if ($child['id'] == $attr_id) {
                                    $attrs[] = sprintf(
                                        '<span style="color: #444;">%s:</span> <span style="color: #1a1a1a;">%s</span>',
                                        htmlspecialchars($attr['name']),
                                        htmlspecialchars($child['name'])
                                    );
                                    break;
                                }
                            }
                        }
                    }
                }
            }

            // Collect extras with prices
            $extras = [];
            if (isset($item['extra']) && ! empty($item['extra'])) {
                $a = explode(",", $item['extra']);
                foreach ($a as $eid) {
                    if (strpos($eid, ".") === false) {
                        // Single extra
                        foreach ($result['extra_arr'] as $extra) {
                            if ($extra['id'] == $eid) {
                                $extraPrice = $vat_include ? floatval($extra['price_inc_vat'] ?? $extra['price']) : floatval($extra['price']);
                                $extraPriceFormatted = $extraPrice > 0 ? ' (+' . pjCurrency::formatPrice($extraPrice) . ')' : '';
                                $extras[] = htmlspecialchars($extra['name']) . $extraPriceFormatted;
                                $lineTotal += $extraPrice * $qty;
                                break;
                            }
                        }
                    } else {
                        // Multi extra item
                        list($e_id, $ei_id) = explode(".", $eid);
                        foreach ($result['extra_arr'] as $extra) {
                            if ($extra['id'] == $e_id && isset($extra['extra_items'])) {
                                foreach ($extra['extra_items'] as $extra_item) {
                                    if ($extra_item['id'] == $ei_id) {
                                        $extraPrice = $vat_include ? floatval($extra_item['price_inc_vat'] ?? $extra_item['price']) : floatval($extra_item['price']);
                                        $extraPriceFormatted = $extraPrice > 0 ? ' (+' . pjCurrency::formatPrice($extraPrice) . ')' : '';
                                        $extras[] = htmlspecialchars($extra_item['name']) . $extraPriceFormatted;
                                        $lineTotal += $extraPrice * $qty;
                                        break;
                                    }
                                }
                                break;
                            }
                        }
                    }
                }
            }

            // Build product details.
            $details = '';
            if (!empty($attrs)) {
                $details .= '<div class="product-details" style="font-size: 13px; color: #444; margin-top: 5px; padding-left: 10px;">' . implode(' &bull; ', $attrs) . '</div>';
            }
            if (!empty($extras)) {
                $extrasList = '';
                foreach ($extras as $extraItem) {
                    $extrasList .= '<div style="padding: 2px 0;">+ ' . $extraItem . '</div>';
                }
                $details .= '<div class="product-details" style="font-size: 13px; color: #444; margin-top: 5px; padding-left: 10px;">' . $extrasList . '</div>';
            }

            $html .= '
        <tr style="border-bottom: 1px solid #eee;">
            <td class="product-name-cell" data-label="Product" style="padding: 15px; vertical-align: top;">
                <div style="font-weight: 600; color: #1a1a1a;">' . $productName . '</div>
                ' . $details . '
            </td>
            <td class="product-qty-cell" data-label="Quantity" style="padding: 15px; text-align: center; vertical-align: top; color: #444;">' . $qty . 'x</td>
            <td class="product-price-cell" data-label="Total" style="padding: 15px; text-align: right; vertical-align: top; font-weight: 600; color: #1a1a1a;">' . pjCurrency::formatPrice($lineTotal) . '</td>
        </tr>';
        }

        $html .= '
    </tbody>
</table>';

        return $html;
    }

    /**
     * Decode notification.settings JSON and migrate to normalized structure.
     */
    public static function getNotificationSettingsRaw($notification)
    {
        $saved = array();
        if (!empty($notification['settings'])) {
            $decoded = json_decode($notification['settings'], true);
            if (is_array($decoded)) {
                $saved = $decoded;
            }
        }
        $shared = array();
        $sharedKeys = array('order_fields', 'bcc_emails', 'attach_invoice', 'show_order');
        foreach ($sharedKeys as $k) {
            if (array_key_exists($k, $saved)) {
                $shared[$k] = $saved[$k];
            }
        }
        $shared = array_merge(array(
            'order_fields'   => array(),
            'bcc_emails'     => '',
            'attach_invoice' => 0,
        ), $shared);
        $shared['show_order'] = !empty($shared['order_fields']);

        return array(
            'shared' => $shared,
            'i18n'   => isset($saved['i18n']) ? $saved['i18n'] : array(),
        );
    }

    /**
     * Get structured settings for a notification, with sensible defaults.
     */
    public static function getNotificationSettings($notification, $locale_id = null)
    {
        $raw = self::getNotificationSettingsRaw($notification);
        $shared = $raw['shared'];
        $i18n = $raw['i18n'];

        $targetLocale = (int) ($locale_id ?: self::resolveDefaultLocaleId());

        $perLocale = array(
            'greeting'   => '',
            'sign_off'   => '',
            'cta_label'  => '',
            'cta_url'    => '',
            'preheader'  => '',
        );

        if (isset($i18n[$targetLocale]) && is_array($i18n[$targetLocale])) {
            foreach ($perLocale as $k => $v) {
                if (!empty($i18n[$targetLocale][$k])) {
                    $perLocale[$k] = $i18n[$targetLocale][$k];
                }
            }
        }

        return array_merge($perLocale, $shared);
    }

    /**
     * Build a complete email from the universal template.
     */
    public static function buildEmailFromTemplate($params, $option_arr = array())
    {
        if (!class_exists('pjColorHelper')) {
            require_once PJ_APP_PATH . 'classes/pjColorHelper.class.php';
        }

        $recipient   = isset($params['recipient']) ? $params['recipient'] : 'client';
        $body        = isset($params['body']) ? $params['body'] : '';
        $showOrder   = !empty($params['show_order']);
        $orderData   = isset($params['order_data']) ? $params['order_data'] : array();
        $orderFields = isset($params['order_fields']) && is_array($params['order_fields']) ? $params['order_fields'] : null;
        $ctaUrl      = isset($params['cta_url']) ? $params['cta_url'] : '';
        $ctaLabel    = isset($params['cta_label']) ? $params['cta_label'] : '';
        $cta2Url     = isset($params['cta2_url']) ? $params['cta2_url'] : '';
        $cta2Label   = isset($params['cta2_label']) ? $params['cta2_label'] : '';
        $preheader   = isset($params['preheader']) ? trim($params['preheader']) : '';

        // Strip residual document tags from TinyMCE content
        $stripDocTags = function ($html) {
            if (empty($html)) return $html;
            $html = preg_replace('/<!DOCTYPE[^>]*>/i', '', $html);
            $html = preg_replace('/<\/?html[^>]*>/i', '', $html);
            $html = preg_replace('/<head[^>]*>.*?<\/head>/is', '', $html);
            $html = preg_replace('/<\/?body[^>]*>/i', '', $html);
            return trim($html);
        };
        $body = $stripDocTags($body);

        // Resolve primary color for helpers
        $primary = '#8438df';
        if ($recipient === 'client') {
            if (!empty($option_arr['o_brand_color']) && pjColorHelper::isValidHex($option_arr['o_brand_color'])) {
                $primary = $option_arr['o_brand_color'];
            }
        } else {
            if (!empty($option_arr['o_admin_primary']) && pjColorHelper::isValidHex($option_arr['o_admin_primary'])) {
                $primary = $option_arr['o_admin_primary'];
            }
        }

        // Greeting & Sign-off
        $greeting = isset($params['greeting']) ? $stripDocTags($params['greeting']) : '';
        $signOff  = isset($params['sign_off']) ? $stripDocTags($params['sign_off']) : '';

        // Token replacement on settings fields
        $tokens = isset($params['tokens']) ? $params['tokens'] : null;
        if ($tokens && isset($tokens['search']) && isset($tokens['replace'])) {
            $greeting  = str_replace($tokens['search'], $tokens['replace'], $greeting);
            $signOff   = str_replace($tokens['search'], $tokens['replace'], $signOff);
            $preheader = str_replace($tokens['search'], $tokens['replace'], $preheader);
            $ctaUrl    = str_replace($tokens['search'], $tokens['replace'], $ctaUrl);
            $ctaLabel  = str_replace($tokens['search'], $tokens['replace'], $ctaLabel);
            $cta2Url   = str_replace($tokens['search'], $tokens['replace'], $cta2Url);
            $cta2Label = str_replace($tokens['search'], $tokens['replace'], $cta2Label);
        }

        // Assemble the email body
        $assembled = '';
        if (!empty($preheader)) {
            $assembled .= '<div style="display:none;font-size:1px;color:#f4f4f7;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">' . htmlspecialchars($preheader, ENT_QUOTES, 'UTF-8') . '</div>';
        }
        if (!empty($greeting)) {
            $assembled .= '<p style="margin:0 0 16px 0;">' . $greeting . '</p>';
        }
        $assembled .= $body;

        // Order details block
        if ($showOrder && !empty($orderData)) {
            $assembled .= self::buildOrderDetailsBlock($orderData, $option_arr, $primary, $orderFields);
        }

        // CTA buttons
        $hasCta1 = !empty($ctaUrl) && !empty($ctaLabel);
        $hasCta2 = !empty($cta2Url) && !empty($cta2Label);
        if ($hasCta1 || $hasCta2) {
            $secondary = '#666666';
            if ($hasCta1 && $hasCta2) {
                $assembled .= '<table role="presentation" cellpadding="0" cellspacing="0" style="margin:24px auto 8px auto;"><tr><td style="padding-right:8px;">' . self::buildCtaButtonInline($ctaUrl, $ctaLabel, $primary) . '</td><td style="padding-left:8px;">' . self::buildCtaButtonInline($cta2Url, $cta2Label, $secondary) . '</td></tr></table>';
            } elseif ($hasCta1) {
                $assembled .= self::buildCtaButton($ctaUrl, $ctaLabel, $primary);
            } else {
                $assembled .= self::buildCtaButton($cta2Url, $cta2Label, $secondary);
            }
        }

        if (!empty($signOff)) {
            $assembled .= '<div style="margin:24px 0 0 0;">' . $signOff . '</div>';
        }

        return self::wrapEmailTemplate($assembled, $option_arr, $recipient);
    }

    public static function wrapEmailTemplate($body, $option_arr = array(), $recipient = 'client')
    {
        if (!class_exists('pjColorHelper')) {
            require_once PJ_APP_PATH . 'classes/pjColorHelper.class.php';
        }
        $primary = ($recipient === 'client') ? ($option_arr['o_brand_color'] ?? '#8438df') : ($option_arr['o_admin_primary'] ?? '#8438df');
        $primaryLight = pjColorHelper::ultraLight($primary);

        $profile = array();
        try {
            $profile = pjInvoiceConfigModel::factory()->getConfigData(1);
        } catch (Exception $e) {
        }

        $companyName = htmlspecialchars($profile['y_company'] ?? '');
        $logoHtml = '';
        if (!empty($profile['y_logo']) && is_file(PJ_INSTALL_PATH . $profile['y_logo'])) {
            $logoHtml = '<img src="' . PJ_INSTALL_URL . $profile['y_logo'] . '" width="180" alt="' . $companyName . '" style="display:block;max-width:200px;height:auto;margin:0 auto;" />';
        }

        $html = '<!DOCTYPE html><html><body style="margin: 0; background-color: ' . $primary . '; padding: 30px;">';
        $html .= '<table align="center" border="0" cellpadding="0" cellspacing="0" width="600" style="background-color: #fff; border-radius: 20px; overflow: hidden;">';
        $html .= '<tr><td style="padding: 30px; text-align: center;">' . ($logoHtml ?: '<h2>' . $companyName . '</h2>') . '</td></tr>';
        $html .= '<tr><td style="padding: 30px; font-family: sans-serif; font-size: 15px; line-height: 1.6; color: #333;">' . $body . '</td></tr>';
        $html .= '<tr><td style="padding: 30px; background-color: #f1f1f1; text-align: center; font-family: sans-serif; font-size: 12px; color: #666;">';
        $html .= $companyName . '<br>' . ($profile['y_street_address'] ?? '') . ', ' . ($profile['y_zip'] ?? '') . ' ' . ($profile['y_city'] ?? '');
        $html .= '</td></tr></table></body></html>';

        return $html;
    }

    public static function buildCtaButton($url, $label, $color = '#8438df')
    {
        return '<table role="presentation" cellpadding="0" cellspacing="0" style="margin:24px auto 8px auto;"><tr><td style="border-radius:50px;background-color:' . $color . ';">'
            . '<a href="' . htmlspecialchars($url) . '" target="_blank" style="display:inline-block;padding:12px 32px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;font-family:sans-serif;">' . htmlspecialchars($label) . '</a>'
            . '</td></tr></table>';
    }

    public static function buildCtaButtonInline($url, $label, $color = '#8438df')
    {
        return '<a href="' . htmlspecialchars($url) . '" target="_blank" style="display:inline-block;padding:12px 28px;font-size:14px;font-weight:600;color:#ffffff;background-color:' . $color . ';text-decoration:none;border-radius:50px;font-family:sans-serif;">' . htmlspecialchars($label) . '</a>';
    }

    public static function buildOrderDetailsBlock($order_data, $option_arr = array(), $primary = '#8438df', $activeFields = null)
    {
        $html = '<table width="100%" cellpadding="0" cellspacing="0" style="margin:20px 0;border:1px solid #eee;border-left:3px solid ' . $primary . ';background-color:#fafafa;">';
        $html .= '<tr><td style="padding:15px;font-family:sans-serif;font-size:13px;">';
        $html .= '<strong>Order number:</strong> ' . htmlspecialchars($order_data['uuid']) . '<br>';
        $html .= '<strong>Total:</strong> ' . pjCurrency::formatPrice($order_data['total']) . '<br>';
        $html .= '</td></tr></table>';
        if (in_array('order_products', (array)$activeFields)) {
            $html .= self::pjActionGetProductsHtml($order_data['id'], $order_data['locale_id'], $option_arr);
        }
        return $html;
    }

    protected function pjActionGenerateInvoice($order_id)
    {
        if (!isset($order_id) || (int) $order_id <= 0) {
            return array('status' => 'ERR', 'code' => 400, 'text' => 'ID is not set ot invalid.');
        }
        $arr = pjOrderModel::factory()
            ->select('t1.*, t2.email, t2.phone, t2.url')
            ->join('pjClient', 't2.id=t1.client_id', 'left outer')
            ->find($order_id)->getData();
        if (empty($arr)) {
            return array('status' => 'ERR', 'code' => 404, 'text' => 'Order not found.');
        }

        $stack = pjAppController::pjActionGetOrderStock($arr['id'], $arr['locale_id']);

        $items = array();
        if (isset($stack['os_arr']) && !empty($stack['os_arr'])) {
            $total = 0;
            foreach ($stack['os_arr'] as $i => $item) {
                $desc = array();
                $extra_price = 0;
                if (isset($item['attr']) && !empty($item['attr'])) {
                    $at = array();
                    $a = explode(",", $item['attr']);
                    foreach ($a as $v) {
                        $t = explode("_", $v);
                        if (isset($t[0], $t[1])) {
                            $at[$t[1]] = $t[0];
                        }
                    }
                    foreach ($at as $attr_parent_id => $attr_id) {
                        foreach ($stack['attr_arr'] as $attr) {
                            if ($attr['id'] == $attr_parent_id) {
                                foreach ($attr['child'] as $child) {
                                    if ($child['id'] == $attr_id) {
                                        $desc[] = sprintf('%s: %s', $attr['name'], pjSanitize::html($child['name']));
                                        break;
                                    }
                                }
                            }
                        }
                    }
                }
                //Extras
                if (isset($item['extra']) && !empty($item['extra'])) {
                    $a = explode(",", $item['extra']);
                    foreach ($a as $eid) {
                        if (strpos($eid, ".") === FALSE) {
                            //single
                            foreach ($stack['extra_arr'] as $extra) {
                                if ($extra['id'] == $eid) {
                                    $desc[] = sprintf('Extra: %s (%s)', $extra['name'], pjCurrency::formatPrice($extra['price']));
                                    $extra_price += $extra['price'];
                                    break;
                                }
                            }
                        } else {
                            //multi
                            list($e_id, $ei_id) = explode(".", $eid);
                            foreach ($stack['extra_arr'] as $extra) {
                                if ($extra['id'] == $e_id && isset($extra['extra_items']) && !empty($extra['extra_items'])) {
                                    foreach ($extra['extra_items'] as $extra_item) {
                                        if ($extra_item['id'] == $ei_id) {
                                            $desc[] = sprintf('Extra: %s (%s)', $extra_item['name'], pjCurrency::formatPrice($extra_item['price']));
                                            $extra_price += $extra_item['price'];
                                            break;
                                        }
                                    }
                                    break;
                                }
                            }
                        }
                    }
                }
                $price = $item['price'] + $extra_price;
                $subtotal = $price * (int) $item['qty'];
                $total += $subtotal;

                $items[] = array(
                    'name' => $item['name'],
                    'description' => join("; ", $desc),
                    'qty' => (int) $item['qty'],
                    'unit_price' => number_format($price, 2, ".", ""),
                    'amount' => number_format($subtotal, 2, ".", "")
                );
            }
        } else {
            $items[] = array(
                'name' => 'Order payment',
                'description' => "",
                'qty' => 1,
                'unit_price' => $arr['total'],
                'amount' => $arr['total']
            );
        }

        $map = array(
            'completed' => 'paid',
            'cancelled' => 'cancelled',
            'new' => 'not_paid',
            'pending' => 'not_paid'
        );

        $invoice_arr = pjInvoiceConfigModel::factory()->find(1)->getData();
        $invoice_number = self::generateInvoiceNumber($invoice_arr);

        $response = $this->requestAction(
            array(
                'controller' => 'pjInvoice',
                'action' => 'pjActionCreate',
                'params' => array(
                    'key' => md5($this->option_arr['private_key'] . PJ_SALT),
                    'uuid' => $invoice_number,
                    'order_id' => $arr['uuid'],
                    'payment_method' => $arr['payment_method'],
                    'txn_id' => $arr['txn_id'],
                    'processed_on' => $arr['processed_on'],
                    'foreign_id' => $this->getForeignId(),
                    'locale_id' => $arr['locale_id'],
                    'issue_date' => ':CURDATE()',
                    'due_date' => ':CURDATE()',
                    'created' => ':NOW()',
                    'status' => @$map[$arr['status']],
                    'price' => $arr['price'],
                    'discount' => $arr['discount'],
                    'insurance' => $arr['insurance'],
                    'shipping' => $arr['shipping'],
                    'tax' => $arr['tax'],
                    'total' => $arr['total'],
                    'currency' => $this->option_arr['o_currency'],
                    'notes' => $arr['notes'],
                    'b_name' => $arr['b_name'],
                    'b_country_id' => $arr['b_country_id'],
                    'b_state' => $arr['b_state'],
                    'b_city' => $arr['b_city'],
                    'b_zip' => $arr['b_zip'],
                    'b_address_1' => $arr['b_address_1'],
                    'b_address_2' => $arr['b_address_2'],
                    's_name' => $arr['s_name'],
                    's_country_id' => $arr['s_country_id'],
                    's_state' => $arr['s_state'],
                    's_city' => $arr['s_city'],
                    's_zip' => $arr['s_zip'],
                    's_address_1' => $arr['s_address_1'],
                    's_address_2' => $arr['s_address_2'],
                    'items' => $items
                )
            ),
            array('return')
        );

        return $response;
    }

    public function getModel($key)
    {
        if (array_key_exists($key, $this->models)) {
            return $this->models[$key];
        }

        return false;
    }

    public function setModel($key, $value)
    {
        $this->models[$key] = $value;

        return true;
    }

    public function getLayoutRange()
    {
        return $this->layoutRange;
    }

    public static function pjActionGetSubjectMessage($notification, $locale_id)
    {
        $variant = $notification['variant'] == 'confirmation' ? 'confirm' : $notification['variant'];
        $field = $variant . '_tokens_' . $notification['recipient'];
        $pjMultiLangModel = pjMultiLangModel::factory();
        $lang_message = $pjMultiLangModel
            ->reset()
            ->select('t1.*')
            ->where('t1.foreign_id', $notification['id'])
            ->where('t1.model', 'pjNotification')
            ->where('t1.locale', $locale_id)
            ->where('t1.field', $field)
            ->limit(0, 1)
            ->findAll()
            ->getData();
        $field = $variant . '_subject_' . $notification['recipient'];
        $lang_subject = $pjMultiLangModel
            ->reset()
            ->select('t1.*')
            ->where('t1.foreign_id',  $notification['id'])
            ->where('t1.model', 'pjNotification')
            ->where('t1.locale', $locale_id)
            ->where('t1.field', $field)
            ->limit(0, 1)
            ->findAll()
            ->getData();
        return compact('lang_message', 'lang_subject');
    }

    public static function getDefaultInvoiceTemplate()
    {
        return self::getDefaultInvoiceYTemplate();
    }

    /**
     * Generate footer HTML for the PDF. TCPDF places it at the bottom of each page.
     */
    public static function getInvoiceFooterHtml($option_arr = array(), $locale_id = null)
    {
        $config = array();
        try {
            $config = pjInvoiceConfigModel::factory()->getConfigData((int) $locale_id ?: 1);
        } catch (Exception $e) {
        }

        $companyName    = !empty($config['y_company']) ? $config['y_company'] : '';
        $companyAddress = !empty($config['y_street_address']) ? $config['y_street_address'] : '';
        $companyZip     = !empty($config['y_zip']) ? $config['y_zip'] : '';
        $companyCity    = !empty($config['y_city']) ? $config['y_city'] : '';
        $companyPhone   = !empty($config['y_phone']) ? $config['y_phone'] : '';
        $companyEmail   = !empty($config['y_email']) ? $config['y_email'] : '';
        $companyUrl     = !empty($config['y_url']) ? $config['y_url'] : '';
        $companyTax     = !empty($config['y_vat_number']) ? $config['y_vat_number'] : '';
        $companyCoc     = !empty($config['y_coc_number']) ? $config['y_coc_number'] : '';
        $companyIban    = !empty($config['y_bank_iban']) ? $config['y_bank_iban'] : '';
        $companyBank    = !empty($config['y_bank_name']) ? $config['y_bank_name'] : '';
        $zipCity        = trim($companyZip . ' ' . $companyCity);

        $line1Parts = array_filter(array(
            $companyName,
            $companyAddress . ($zipCity ? ', ' . $zipCity : ''),
            $companyTax ? 'Tax No. ' . $companyTax : '',
            $companyCoc ? 'Company No. ' . $companyCoc : '',
            $companyIban ? trim(($companyBank ? $companyBank . ' ' : '') . $companyIban) : '',
        ));
        $line2Parts = array_filter(array($companyPhone, $companyEmail, $companyUrl));

        $html  = '<hr style="border:none; border-top:0.5pt solid #e5e7eb; margin:0 0 4pt 0;">';
        $html .= '<div style="font-family: dejavusans, Helvetica, Arial, sans-serif; font-size:8pt; color:#9ca3af; text-align:center; line-height:1.5;">';
        if (!empty($line1Parts)) {
            $html .= htmlspecialchars(implode(' · ', $line1Parts)) . '<br>';
        }
        if (!empty($line2Parts)) {
            $html .= htmlspecialchars(implode(' · ', $line2Parts));
        }
        $html .= '</div>';

        return $html;
    }

    /**
     * Return the active invoice template: custom HTML from options or the
     * original PHPJabbers single-brace invoice token template.
     */
    public static function getInvoiceTemplate()
    {
        try {
            $custom = pjOptionModel::factory()
                ->where('t1.foreign_id', 1)
                ->where('t1.`key`', 'o_invoice_template')
                ->limit(1)
                ->findAll()
                ->getDataIndex(0);
            if (!empty($custom['value']) && strlen(trim($custom['value'])) > 50) {
                $value = $custom['value'];
                if (strpos($value, '{{') === false) {
                    return $value;
                }
            }
        } catch (Exception $e) {
            // Use the default template if the database is not available.
        }
        return self::getDefaultInvoiceYTemplate();
    }

    /**
     * Get invoice template settings (JSON) from DB, with defaults for
     * missing keys. Always use the same structure to prevent rendering errors
     * if the admin hasn't saved preferences yet.
     */
    public static function getInvoiceTemplateSettings()
    {
        $defaults = array(
            'header_logo_position' => 'left',
            'show_metadata'        => array(
                'number'       => 1,
                'issue_date'   => 1,
                'due_date'     => 1,
                'status'       => 1,
                'order_number' => 1,
            ),
            'show_customer'        => array(
                'billing'    => 1,
                'shipping'   => 1,
                'coc_number' => 0,
            ),
            'show_items_columns'   => array(
                'image'       => 0,
                'description' => 1,
                'qty'         => 1,
                'unit_price'  => 1,
                'line_total'  => 1,
            ),
            'show_totals'          => array(
                'subtotal' => 1,
                'discount' => 1,
                'shipping' => 1,
                'tax'      => 1,
                'total'    => 1,
            ),
            'show_payment_instructions' => 1,
            'show_footer_notes'         => 1,
            'raw_html_mode'             => 0,
        );

        try {
            $row = pjOptionModel::factory()
                ->where('t1.foreign_id', 1)
                ->where('t1.`key`', 'o_invoice_template_settings')
                ->limit(1)
                ->findAll()
                ->getDataIndex(0);
            if (!empty($row['value'])) {
                $saved = json_decode($row['value'], true);
                if (is_array($saved)) {
                    if (isset($saved['show_totals']) && is_array($saved['show_totals']) && !isset($saved['show_totals']['tax'])) {
                        $saved['show_totals']['tax'] = !empty($saved['show_totals']['tax']) || !empty($saved['show_totals']['vat_high']) || !empty($saved['show_totals']['vat_low']) ? 1 : 0;
                    }
                    // Merge deep: save existing sub-keys, defaults for missing ones
                    foreach ($defaults as $k => $v) {
                        if (is_array($v) && isset($saved[$k]) && is_array($saved[$k])) {
                            $defaults[$k] = array_merge($v, $saved[$k]);
                        } elseif (isset($saved[$k])) {
                            $defaults[$k] = $saved[$k];
                        }
                    }
                }
            }
        } catch (Exception $e) {
            // Gebruik defaults
        }
        return $defaults;
    }

    public static function getInvoiceVariantDefaults($langIso = 'en')
    {
        return array(
            'title'                => 'Invoice',
            'label_invoice_number' => 'Invoice number',
            'label_issue_date'     => 'Issue date',
            'label_due_date'       => 'Due date',
            'label_order_number'   => 'Order number',
            'label_status'         => 'Status',
            'label_billing'        => 'Billing address',
            'label_shipping'       => 'Shipping address',
            'label_coc_number'     => 'COC number',
            'col_description'      => 'Description',
            'col_qty'              => 'Qty',
            'col_unit_price'       => 'Price',
            'col_line_total'       => 'Total',
            'label_subtotal'       => 'Subtotal',
            'label_discount'       => 'Discount',
            'label_insurance'      => 'Insurance',
            'label_shipping_cost'  => 'Shipping',
            'label_tax'            => 'Tax',
            'label_total'          => 'Total',
            'payment_title'        => 'Payment instructions',
            'payment_text'         => 'Please include invoice number {InvoiceNumber} when making payment.',
            'footer_note'          => 'Thank you for your order!',
            'status_paid'          => 'PAID',
            'status_not_paid'      => 'UNPAID',
            'status_cancelled'     => 'CANCELLED',
        );
    }

    /**
     * Get the multilingual texts entered by the admin in the editor.
     * Saved via pjMultiLangModel with model='pjInvoiceTemplate'.
     * Fallback chain: requested locale -> default locale -> hardcoded defaults.
     */
    public static function getInvoiceLabels($locale_id = null)
    {
        $langIso = 'en';
        $default_iso = 'en-GB';

        try {
            if ($locale_id) {
                $loc = pjLocaleModel::factory()->find($locale_id)->getData();
                if (!empty($loc['language_iso'])) {
                    $langIso = $loc['language_iso'];
                }
            }
            $def = pjLocaleModel::factory()->where('is_default', 1)->limit(1)->findAll()->getDataIndex(0);
            if (!empty($def['language_iso'])) {
                $default_iso = $def['language_iso'];
            }
        } catch (Exception $e) {
        }

        $labels = self::getInvoiceVariantDefaults($langIso);

        // Load custom labels from the database (model: pjInvoiceTemplate).
        try {
            $rows = pjMultiLangModel::factory()
                ->where('t1.model', 'pjInvoiceTemplate')
                ->where('t1.foreign_id', 1)
                ->where('t1.locale', (int) $locale_id)
                ->findAll()
                ->getData();
            foreach ($rows as $row) {
                if (!empty($row['content'])) {
                    $labels[$row['field']] = $row['content'];
                }
            }
        } catch (Exception $e) {
        }

        return $labels;
    }

    /**
     * Wrap the generated invoice body in TCPDF-friendly base HTML.
     * The editable template uses the built-in single-brace invoice tokens only;
     * this wrapper intentionally does not introduce custom double-brace tokens.
     */
    public static function wrapInvoiceTemplate($body, $option_arr = array(), $locale_id = null, $title = 'Invoice')
    {
        if (!class_exists('pjColorHelper')) {
            require_once PJ_APP_PATH . 'classes/pjColorHelper.class.php';
        }

        $primary = '#8438df';
        $accent  = '#f7148b';
        if (!empty($option_arr['o_brand_color']) && pjColorHelper::isValidHex($option_arr['o_brand_color'])) {
            $primary = $option_arr['o_brand_color'];
        }
        if (!empty($option_arr['o_accent_color']) && pjColorHelper::isValidHex($option_arr['o_accent_color'])) {
            $accent = $option_arr['o_accent_color'];
        }
        $primaryLight = pjColorHelper::ultraLight($primary);

        // Raw HTML mode: body begint met <!DOCTYPE — dan skip wrap helemaal
        if (stripos(trim($body), '<!DOCTYPE') === 0 || stripos(trim($body), '<html') === 0) {
            return $body;
        }

        return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>' . htmlspecialchars($title) . '</title>'
            . '<style>'
            . 'body{font-family:dejavusans,Helvetica,Arial,sans-serif;font-size:9pt;color:#1f2937;line-height:1.45;}'
            . 'a{color:' . $primary . ';}.muted{color:#6b7280;}.brand-bg{background-color:' . $primaryLight . ';}.accent{color:' . $accent . ';}'
            . 'table{page-break-inside:auto;}tr{page-break-inside:avoid;page-break-after:auto;}'
            . '</style></head><body>' . $body . '</body></html>';
    }

    /**
     * Compose invoice body from sections based on settings JSON.
     * Returns the full PDF HTML (already wrapped by wrapInvoiceTemplate).
     *
     * @param array $invoice_arr  Row from plugin_invoice (incl. joined country names)
     * @param array $items        Rows from plugin_invoice_items
     * @param array $option_arr   Options (for colors/branding)
     * @param int   $locale_id    Locale for labels
     */
    public static function buildInvoiceFromTemplate($invoice_arr, $items, $option_arr = array(), $locale_id = null)
    {
        if (empty($locale_id) && !empty($invoice_arr['locale_id'])) {
            $locale_id = (int) $invoice_arr['locale_id'];
        }
        if (empty($locale_id)) {
            $locale_id = 1;
        }

        $settings = self::getInvoiceTemplateSettings();
        $labels   = self::getInvoiceLabels($locale_id);

        // Raw HTML mode: laat admin volledige HTML bepalen
        if (!empty($settings['raw_html_mode'])) {
            $raw = self::getInvoiceTemplate();
            return self::applyInvoiceTokens($raw, $invoice_arr, $items, $labels, $option_arr);
        }

        // Valuta-formatter
        $fmt = function ($v) {
            if (!is_numeric($v)) return $v;
            return pjCurrency::formatPrice($v);
        };

        $primary      = !empty($option_arr['o_brand_color']) ? $option_arr['o_brand_color'] : '#8438df';
        $primaryLight = class_exists('pjColorHelper') ? pjColorHelper::ultraLight($primary) : '#f5f0ff';

        // Business details from Profile (for header + footer)
        $config = array();
        try {
            $config = pjInvoiceConfigModel::factory()->getConfigData((int) $locale_id ?: 1);
        } catch (Exception $e) {
        }
        $companyName    = !empty($config['y_company']) ? $config['y_company'] : '';
        $companyAddress = !empty($config['y_street_address']) ? $config['y_street_address'] : '';
        $companyZip     = !empty($config['y_zip']) ? $config['y_zip'] : '';
        $companyCity    = !empty($config['y_city']) ? $config['y_city'] : '';
        $companyCountry = !empty($config['y_country_title']) ? $config['y_country_title'] : '';
        $companyPhone   = !empty($config['y_phone']) ? $config['y_phone'] : '';
        $companyEmail   = !empty($config['y_email']) ? $config['y_email'] : '';
        $companyTax     = !empty($config['y_vat_number']) ? $config['y_vat_number'] : '';
        $companyCoc     = !empty($config['y_coc_number']) ? $config['y_coc_number'] : '';
        $companyIban    = !empty($config['y_bank_iban']) ? $config['y_bank_iban'] : '';
        $companyBank    = !empty($config['y_bank_name']) ? $config['y_bank_name'] : '';

        // Logo (separate img — not rendered via {{LOGO}} placeholder in this layout)
        $logoHtml = '';
        if (!empty($config['y_logo']) && is_file(PJ_INSTALL_PATH . $config['y_logo'])) {
            $logoHtml = '<img src="' . PJ_INSTALL_URL . $config['y_logo'] . '" height="50" alt="' . htmlspecialchars($companyName) . '" />';
        }

        $dateFormat = $option_arr['o_date_format'] ?? 'd-m-Y';

        $body = '';

        // ============================================================
        // Header: logo position follows the invoice-template setting.
        // ============================================================
        $logoPosition = isset($settings['header_logo_position']) ? (string) $settings['header_logo_position'] : 'left';
        if (!in_array($logoPosition, array('left', 'center', 'right'), true)) {
            $logoPosition = 'left';
        }
        $body .= '<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-bottom:14pt;">';
        $body .= '<tr>';
        if ($logoPosition === 'right') {
            $body .= '<td width="50%" align="left" valign="middle"><span style="font-size:36pt; font-weight:300; color:#1f2937; letter-spacing:4pt;">' . strtoupper(htmlspecialchars($labels['title'])) . '</span></td>';
            $body .= '<td width="50%" align="right" valign="middle">' . ($logoHtml ?: '&nbsp;') . '</td>';
        } elseif ($logoPosition === 'center') {
            $body .= '<td width="100%" align="center" valign="middle">' . ($logoHtml ?: '') . '<br><span style="font-size:36pt; font-weight:300; color:#1f2937; letter-spacing:4pt;">' . strtoupper(htmlspecialchars($labels['title'])) . '</span></td>';
        } else {
            $body .= '<td width="50%" align="left" valign="middle">' . ($logoHtml ?: '&nbsp;') . '</td>';
            $body .= '<td width="50%" align="right" valign="middle"><span style="font-size:36pt; font-weight:300; color:#1f2937; letter-spacing:4pt;">' . strtoupper(htmlspecialchars($labels['title'])) . '</span></td>';
        }
        $body .= '</tr></table>';

        // ============================================================
        // Info bar: invoice number on the left, date on the right.
        // ============================================================
        $meta = $settings['show_metadata'];
        // Top separator.
        $body .= '<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-bottom:8pt;"><tr><td bgcolor="#e5e7eb" height="1" style="font-size:1px; line-height:1px;">&nbsp;</td></tr></table>';
        // Info bar with bold values and muted labels.
        $body .= '<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-bottom:8pt;"><tr>';
        $body .= '<td width="50%" align="left" style="color:#6b7280;">';
        if (!empty($meta['number'])) {
            $body .= htmlspecialchars($labels['label_invoice_number']) . ': <strong style="color:#1f2937;">#' . htmlspecialchars((string) $invoice_arr['uuid']) . '</strong>';
        }
        $body .= '</td>';
        $body .= '<td width="50%" align="right" style="color:#6b7280;">';
        if (!empty($meta['issue_date']) && !empty($invoice_arr['issue_date']) && $invoice_arr['issue_date'] !== '0000-00-00') {
            $body .= htmlspecialchars($labels['label_issue_date']) . ': <strong style="color:#1f2937;">' . date($dateFormat, strtotime($invoice_arr['issue_date'])) . '</strong>';
        }
        $body .= '</td>';
        $body .= '</tr></table>';
        // Onderste separator
        $body .= '<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-bottom:20pt;"><tr><td bgcolor="#e5e7eb" height="1" style="font-size:1px; line-height:1px;">&nbsp;</td></tr></table>';

        // ============================================================
        // EXTRA META: due date, order number, status
        // ============================================================
        $extraMeta = '';
        if (!empty($meta['due_date']) && !empty($invoice_arr['due_date']) && $invoice_arr['due_date'] !== '0000-00-00') {
            $extraMeta .= '<span style="color:#6b7280;">' . htmlspecialchars($labels['label_due_date']) . ':</span> <strong style="color:#1f2937;">' . date($dateFormat, strtotime($invoice_arr['due_date'])) . '</strong> &nbsp;&nbsp; ';
        }
        if (!empty($meta['order_number']) && !empty($invoice_arr['order_id'])) {
            $extraMeta .= '<span style="color:#6b7280;">' . htmlspecialchars($labels['label_order_number']) . ':</span> <strong style="color:#1f2937;">' . htmlspecialchars((string) $invoice_arr['order_id']) . '</strong> &nbsp;&nbsp; ';
        }
        if (!empty($meta['status'])) {
            $statusKey = 'status_' . $invoice_arr['status'];
            $statusLabel = isset($labels[$statusKey]) ? $labels[$statusKey] : strtoupper((string) $invoice_arr['status']);
            $statusColor = $invoice_arr['status'] === 'paid' ? '#16a34a' : ($invoice_arr['status'] === 'cancelled' ? '#dc2626' : '#d97706');
            $extraMeta .= '<span style="color:#6b7280;">' . htmlspecialchars($labels['label_status']) . ':</span> <span style="background-color:' . $statusColor . '; color:#ffffff; font-weight:bold; font-size:9pt; padding:2pt 8pt;">&nbsp;' . htmlspecialchars($statusLabel) . '&nbsp;</span> &nbsp;&nbsp; ';
        }
        if ($extraMeta !== '') {
            $body .= '<div style="margin-bottom:20pt;">' . $extraMeta . '</div>';
        }

        // ============================================================
        // Address block: billing address and company payment details.
        // ============================================================
        $cust = $settings['show_customer'];
        $showShipping = !empty($cust['shipping']) && !empty($invoice_arr['s_name']);

        $body .= '<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-bottom:24pt;"><tr>';
        if (!empty($cust['billing'])) {
            $body .= '<td width="50%" valign="top" style="padding-right:12pt;">';
            $body .= '<p style="margin:0 0 4pt 0;"><strong style="color:#1f2937;">' . htmlspecialchars($labels['label_billing']) . ':</strong></p>';
            $body .= '<div style="color:#4b5563;">' . self::_renderInvoiceAddress($invoice_arr, 'b_', $cust, $labels) . '</div>';
            $body .= '</td>';
        }
        $body .= '<td width="50%" valign="top" align="right">';
        $body .= '<p style="margin:0 0 4pt 0;"><strong style="color:#1f2937;">Pay To:</strong></p>';
        $body .= '<div style="color:#4b5563;">';
        if ($companyName)    $body .= htmlspecialchars($companyName) . '<br>';
        if ($companyAddress) $body .= htmlspecialchars($companyAddress) . '<br>';
        $zipCity = trim(($companyZip ?? '') . ' ' . ($companyCity ?? ''));
        if ($zipCity !== '') $body .= htmlspecialchars($zipCity) . '<br>';
        if ($companyCountry) $body .= htmlspecialchars($companyCountry) . '<br>';
        if (!empty($companyEmail)) $body .= htmlspecialchars($companyEmail) . '<br>';
        if (!empty($companyTax)) $body .= 'Tax No. ' . htmlspecialchars($companyTax) . '<br>';
        if (!empty($companyCoc)) $body .= 'Company No. ' . htmlspecialchars($companyCoc) . '<br>';
        if (!empty($companyBank)) $body .= htmlspecialchars($companyBank) . '<br>';
        if (!empty($companyIban)) $body .= 'Bank account ' . htmlspecialchars($companyIban);
        $body .= '</div>';
        $body .= '</td>';
        $body .= '</tr></table>';

        // ============================================================
        if ($showShipping) {
            $body .= '<div style="margin-bottom:18pt;">';
            $body .= '<p style="margin:0 0 4pt 0;"><strong style="color:#1f2937;">' . htmlspecialchars($labels['label_shipping']) . ':</strong></p>';
            $body .= '<div style="color:#4b5563;">' . self::_renderInvoiceAddress($invoice_arr, 's_', $cust, $labels) . '</div>';
            $body .= '</div>';
        }

        // ============================================================
        // Items table.
        // Header with gray background + brand-color text, thin borders between rows.
        // ============================================================
        $cols = $settings['show_items_columns'];

        $visibleItemColumns = array();
        foreach (array('image', 'description', 'unit_price', 'qty', 'line_total') as $columnKey) {
            if (!empty($cols[$columnKey])) {
                $visibleItemColumns[] = $columnKey;
            }
        }

        if (!empty($items) && !empty($visibleItemColumns)) {
            // Table header: light gray background + dark gray text (NOT purple).
            // No # column — more room for item name.
            $thStyle = 'font-weight:bold; color:#1f2937; padding:9pt 8pt;';
            $tdStyle = 'border-top:0.5pt solid #e5e7eb; padding:10pt 8pt; vertical-align:top; color:#1f2937;';

            // Column widths MUST be identical on the <th> header cells and the
            // <td> body cells, otherwise TCPDF sizes each row independently and
            // the header labels no longer line up over their values. Description
            // absorbs whatever width the numeric columns don't use.
            $wImg  = 16;
            $wUnit = 18;
            $wQty  = 12;
            $wTot  = 18;
            $wDesc = 100;
            if (!empty($cols['image']))      { $wDesc -= $wImg; }
            if (!empty($cols['unit_price'])) { $wDesc -= $wUnit; }
            if (!empty($cols['qty']))        { $wDesc -= $wQty; }
            if (!empty($cols['line_total'])) { $wDesc -= $wTot; }
            if (empty($cols['description'])) {
                $activeWidths = array();
                if (!empty($cols['image']))      { $activeWidths[] = 'image'; }
                if (!empty($cols['unit_price'])) { $activeWidths[] = 'unit_price'; }
                if (!empty($cols['qty']))        { $activeWidths[] = 'qty'; }
                if (!empty($cols['line_total'])) { $activeWidths[] = 'line_total'; }
                $share = !empty($activeWidths) ? floor(100 / count($activeWidths)) : 100;
                $wImg = $wUnit = $wQty = $wTot = $share;
                if (!empty($activeWidths)) {
                    $last = end($activeWidths);
                    ${'w' . ($last === 'image' ? 'Img' : ($last === 'unit_price' ? 'Unit' : ($last === 'qty' ? 'Qty' : 'Tot')))} += 100 - ($share * count($activeWidths));
                }
                $wDesc = 0;
            }

            $body .= '<table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">';
            $body .= '<thead><tr>';
            if (!empty($cols['image'])) {
                $body .= '<th width="' . $wImg . '%" align="left" bgcolor="#f5f5f5" style="' . $thStyle . '">&nbsp;</th>';
            }
            if (!empty($cols['description'])) {
                $body .= '<th width="' . $wDesc . '%" align="left" bgcolor="#f5f5f5" style="' . $thStyle . '">' . htmlspecialchars($labels['col_description']) . '</th>';
            }
            if (!empty($cols['unit_price'])) {
                $body .= '<th width="' . $wUnit . '%" align="right" bgcolor="#f5f5f5" style="' . $thStyle . '">' . htmlspecialchars($labels['col_unit_price']) . '</th>';
            }
            if (!empty($cols['qty'])) {
                $body .= '<th width="' . $wQty . '%" align="center" bgcolor="#f5f5f5" style="' . $thStyle . '">' . htmlspecialchars($labels['col_qty']) . '</th>';
            }
            if (!empty($cols['line_total'])) {
                $body .= '<th width="' . $wTot . '%" align="right" bgcolor="#f5f5f5" style="' . $thStyle . '">' . htmlspecialchars($labels['col_line_total']) . '</th>';
            }
            $body .= '</tr></thead><tbody>';

            foreach ($items as $it) {
                $desc = htmlspecialchars($it['name']);
                if (!empty($it['description'])) {
                    $rawDesc = html_entity_decode((string) $it['description'], ENT_QUOTES | ENT_HTML5, 'UTF-8');
                    $desc .= '<br><span style="color:#6b7280; font-size:9pt;">' . nl2br(htmlspecialchars($rawDesc)) . '</span>';
                }
                $qtyVal = is_numeric($it['qty']) ? (floor($it['qty']) == $it['qty'] ? (string) (int) $it['qty'] : rtrim(rtrim(number_format($it['qty'], 2, '.', ''), '0'), '.')) : $it['qty'];

                $body .= '<tr>';
                if (!empty($cols['image'])) {
                    $imageHtml = '&nbsp;';
                    if (!empty($it['image'])) {
                        $imagePath = (string) $it['image'];
                        $imageFullPath = PJ_INSTALL_PATH . ltrim($imagePath, '/');
                        if (is_file($imageFullPath)) {
                            $imageHtml = '<img src="' . htmlspecialchars($imageFullPath) . '" width="42" />';
                        }
                    }
                    $body .= '<td width="' . $wImg . '%" align="left" style="' . $tdStyle . '">' . $imageHtml . '</td>';
                }
                if (!empty($cols['description'])) {
                    $body .= '<td width="' . $wDesc . '%" align="left" style="' . $tdStyle . '">' . $desc . '</td>';
                }
                if (!empty($cols['unit_price'])) {
                    $body .= '<td width="' . $wUnit . '%" align="right" style="' . $tdStyle . '">' . $fmt($it['unit_price']) . '</td>';
                }
                if (!empty($cols['qty'])) {
                    $body .= '<td width="' . $wQty . '%" align="center" style="' . $tdStyle . '">' . htmlspecialchars($qtyVal) . '</td>';
                }
                if (!empty($cols['line_total'])) {
                    $body .= '<td width="' . $wTot . '%" align="right" style="' . $tdStyle . '">' . $fmt($it['amount']) . '</td>';
                }
                $body .= '</tr>';
            }
            $body .= '</tbody></table>';
            // Closing bottom line of the table
            $body .= '<table width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td bgcolor="#e5e7eb" height="1" style="font-size:1px; line-height:1px;">&nbsp;</td></tr></table>';
        }

        // Totals block aligned with current order pricing.
        $t = $settings['show_totals'];

        $price    = (float) ($invoice_arr['price'] ?? 0);
        $discount = (float) ($invoice_arr['discount'] ?? 0);
        $insurance = (float) ($invoice_arr['insurance'] ?? 0);
        $shipping = (float) ($invoice_arr['shipping'] ?? 0);
        $tax      = (float) ($invoice_arr['tax'] ?? 0);
        $total    = (float) ($invoice_arr['total'] ?? 0);

        // Build rows.
        $totRows = array();
        $GREEN  = '#16a34a';

        if (!empty($t['subtotal'])) {
            $totRows[] = array($labels['label_subtotal'], $fmt($price), false, false);
        }
        if (!empty($t['discount']) && $discount > 0) {
            $totRows[] = array($labels['label_discount'], '&minus; ' . $fmt($discount), false, true);
        }
        if ($insurance > 0) {
            $totRows[] = array($labels['label_insurance'] ?? 'Insurance', $fmt($insurance), false, false);
        }
        if ($shipping > 0) {
            $totRows[] = array($labels['label_shipping_cost'], $fmt($shipping), false, false);
        }
        if (!empty($t['tax']) && $tax > 0) {
            $totRows[] = array($labels['label_tax'], $fmt($tax), false, false);
        }

        // Total.
        if (!empty($t['total'])) {
            $totRows[] = array($labels['label_total'], $fmt($total), true, false);
        }

        // ============================================================
        // FOOTER OF ITEMS CARD: "Payment info" left + totals table right
        // ============================================================
        if (!empty($totRows)) {
            // Prepare payment info (left): payment instructions or an empty block
            $paymentInfoHtml = '';
            if (!empty($settings['show_payment_instructions']) && ($invoice_arr['status'] ?? '') === 'not_paid' && !empty($labels['payment_text'])) {
                $paymentText = self::applyInvoiceTokens((string) $labels['payment_text'], $invoice_arr, $items, $labels, $option_arr);
                $paymentInfoHtml .= '<p style="margin:0 0 4pt 0;"><strong style="color:#1f2937;">' . htmlspecialchars($labels['payment_title']) . ':</strong></p>';
                $paymentInfoHtml .= '<div style="color:#4b5563; font-size:9pt;">' . $paymentText . '</div>';
            }

            // Outer table with payment info left + totals right
            $body .= '<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-top:14pt; margin-bottom:18pt;">';
            $body .= '<tr>';
            // Payment info (left, 50%).
            $body .= '<td width="50%" valign="top" style="padding-right:20pt;">';
            $body .= $paymentInfoHtml;
            $body .= '</td>';
            // Totals table (right, 50%).
            $body .= '<td width="50%" valign="top">';
            $body .= '<table width="100%" cellpadding="0" cellspacing="0" border="0">';
            foreach ($totRows as $r) {
                $lbl     = $r[0];
                $val     = $r[1];
                $isTotal = !empty($r[2]);
                $isMin   = !empty($r[3]);
                $isInfo  = !empty($r[4]);

                if ($isTotal) {
                    // Spacer with thin line above the total
                    $body .= '<tr><td colspan="2" style="border-top:0.5pt solid #d1d5db; font-size:1pt; line-height:1pt; padding:0;">&nbsp;</td></tr>';
                    // Total: large, bold, BLACK
                    $labelStyle = 'font-weight:bold; font-size:13pt; color:#1f2937; padding:8pt 0;';
                    $valStyle   = 'font-weight:bold; font-size:13pt; color:#1f2937; padding:8pt 0;';
                } elseif ($isInfo) {
                    $labelStyle = 'color:#6b7280; font-size:8pt; padding:2pt 0;';
                    $valStyle   = 'color:#6b7280; font-size:8pt; padding:2pt 0;';
                } else {
                    $labelStyle = 'color:#333; padding:4pt 0;';
                    $valStyle   = 'color:' . ($isMin ? $GREEN : '#333') . '; padding:4pt 0;';
                }
                $body .= '<tr>'
                    . '<td align="left" style="' . $labelStyle . '">' . $lbl . '</td>'
                    . '<td align="right" style="' . $valStyle . '">' . $val . '</td>'
                    . '</tr>';
            }
            // Bottom line after the total
            $body .= '<tr><td colspan="2" style="border-bottom:0.5pt solid #d1d5db; font-size:1pt; line-height:1pt; padding:0;">&nbsp;</td></tr>';
            $body .= '</table>';
            $body .= '</td>';
            $body .= '</tr></table>';
        }

        // ============================================================
        // NOTES — minimal, label in black bold
        // ============================================================
        if (!empty($settings['show_footer_notes'])) {
            if (!empty($invoice_arr['notes'])) {
                $body .= '<div style="margin-top:24pt; color:#4b5563;">';
                $body .= '<p style="margin:0 0 6pt 0;"><strong style="color:#1f2937;">Notes:</strong></p>';
                $body .= nl2br(htmlspecialchars((string) $invoice_arr['notes']));
                $body .= '</div>';
            } elseif (!empty($labels['footer_note'])) {
                $body .= '<div style="margin-top:24pt; text-align:center; color:#6b7280; font-style:italic;">' . htmlspecialchars((string) $labels['footer_note']) . '</div>';
            }
        }

        // The sender and bank footer is rendered by TCPDF on each page.

        // Wrap in template and replace company tokens.
        $html = self::wrapInvoiceTemplate($body, $option_arr, $locale_id, $labels['title']);

        // Replace invoice-specific tokens.
        return self::applyInvoiceTokens($html, $invoice_arr, $items, $labels, $option_arr);
    }

    /**
     * Render an address block (billing or shipping address) based on prefix.
     */
    protected static function _renderInvoiceAddress($arr, $prefix, $opts, $labels)
    {
        $lines = array();
        $name     = trim(($arr[$prefix . 'name'] ?? '') . '');
        $company  = trim(($arr[$prefix . 'company'] ?? '') . '');
        $addr1    = trim(($arr[$prefix . 'address_1'] ?? '') . '');
        $houseNum = trim(($arr[$prefix . 'house_number'] ?? '') . '');
        $addr2    = trim(($arr[$prefix . 'address_2'] ?? '') . '');
        $zip      = trim(($arr[$prefix . 'zip'] ?? '') . '');
        $city     = trim(($arr[$prefix . 'city'] ?? '') . '');
        $state    = trim(($arr[$prefix . 'state'] ?? '') . '');
        $country  = trim(($arr[$prefix . 'country_title'] ?? '') . '');

        // Combine street and house number on one line.
        $streetLine = trim($addr1 . ($houseNum !== '' ? ' ' . $houseNum : ''));

        if ($company)    $lines[] = htmlspecialchars($company);
        if ($name)       $lines[] = htmlspecialchars($name);
        if ($streetLine) $lines[] = htmlspecialchars($streetLine);
        if ($addr2)      $lines[] = htmlspecialchars($addr2);
        if ($zip || $city) $lines[] = htmlspecialchars(trim($zip . ' ' . $city));
        if ($state)      $lines[] = htmlspecialchars($state);
        if ($country)    $lines[] = htmlspecialchars($country);

        if (!empty($opts['coc_number']) && !empty($arr[$prefix . 'coc_number'])) {
            $lines[] = htmlspecialchars($labels['label_coc_number']) . ': ' . htmlspecialchars($arr[$prefix . 'coc_number']);
        }

        return implode('<br>', $lines);
    }

    /**
     * Vervang invoice-specifieke tokens in een stuk HTML.
     * Gebruikt door buildInvoiceFromTemplate na wrap en in raw HTML mode.
     */
    protected static function applyInvoiceTokens($html, $invoice_arr, $items, $labels, $option_arr = array())
    {
        $dateFormat = $option_arr['o_date_format'] ?? 'd-m-Y';
        $formatDate = function ($date) use ($dateFormat) {
            return !empty($date) && $date !== '0000-00-00' ? date($dateFormat, strtotime($date)) : '';
        };

        $itemsHtml = '';
        if (!empty($items)) {
            $itemsHtml .= '<table style="width:100%;" border="0" cellpadding="4" cellspacing="0">';
            $itemsHtml .= '<thead><tr>'
                . '<th style="text-align:left;border-bottom:1px solid #111;">Item</th>'
                . '<th style="text-align:right;border-bottom:1px solid #111;">Qty</th>'
                . '<th style="text-align:right;border-bottom:1px solid #111;">Unit price</th>'
                . '<th style="text-align:right;border-bottom:1px solid #111;">Amount</th>'
                . '</tr></thead><tbody>';
            foreach ($items as $item) {
                $itemsHtml .= '<tr>'
                    . '<td>' . htmlspecialchars((string) ($item['name'] ?? '')) . (!empty($item['description']) ? '<br><small>' . htmlspecialchars((string) $item['description']) . '</small>' : '') . '</td>'
                    . '<td style="text-align:right;">' . htmlspecialchars((string) ($item['qty'] ?? '')) . '</td>'
                    . '<td style="text-align:right;">' . pjCurrency::formatPrice((float) ($item['unit_price'] ?? 0)) . '</td>'
                    . '<td style="text-align:right;">' . pjCurrency::formatPrice((float) ($item['amount'] ?? 0)) . '</td>'
                    . '</tr>';
            }
            $itemsHtml .= '</tbody></table>';
        }

        $config = array();
        try {
            $config = pjInvoiceConfigModel::factory()->getConfigData((int) ($invoice_arr['locale_id'] ?? 1));
        } catch (Exception $e) {
        }

        $logoHtml = '';
        if (!empty($config['y_logo']) && is_file(PJ_INSTALL_PATH . $config['y_logo'])) {
            $logoHtml = '<img src="' . PJ_INSTALL_URL . $config['y_logo'] . '" height="60" alt="' . htmlspecialchars((string) ($config['y_company'] ?? '')) . '" />';
        }

        $tokens = array(
            '{InvoiceNumber}'   => htmlspecialchars((string) ($invoice_arr['uuid'] ?? '')),
            '{OrderNumber}'     => htmlspecialchars((string) ($invoice_arr['order_id'] ?? '')),
            '{OrderID}'         => htmlspecialchars((string) ($invoice_arr['order_id'] ?? '')),
            '{OrderUUID}'       => htmlspecialchars((string) ($invoice_arr['order_id'] ?? '')),
            '{IssueDate}'       => $formatDate($invoice_arr['issue_date'] ?? ''),
            '{DueDate}'         => $formatDate($invoice_arr['due_date'] ?? ''),
            '{CreatedDate}'     => $formatDate($invoice_arr['created'] ?? ''),
            '{ModifiedDate}'    => $formatDate($invoice_arr['modified'] ?? ''),
            '{PaymentStatus}'   => isset($labels['status_' . ($invoice_arr['status'] ?? '')]) ? $labels['status_' . $invoice_arr['status']] : htmlspecialchars((string) ($invoice_arr['status'] ?? '')),
            '{PaymentMethod}'   => htmlspecialchars((string) ($invoice_arr['payment_method'] ?? '')),
            '{TransactionID}'   => htmlspecialchars((string) ($invoice_arr['txn_id'] ?? '')),
            '{ClientName}'      => htmlspecialchars((string) ($invoice_arr['b_name'] ?? '')),
            '{ClientEmail}'     => htmlspecialchars((string) ($invoice_arr['b_email'] ?? '')),
            '{BillingName}'     => htmlspecialchars((string) ($invoice_arr['b_name'] ?? '')),
            '{BillingAddress1}' => htmlspecialchars((string) ($invoice_arr['b_address_1'] ?? '')),
            '{BillingAddress2}' => htmlspecialchars((string) ($invoice_arr['b_address_2'] ?? '')),
            '{BillingCountry}'  => htmlspecialchars((string) ($invoice_arr['b_country_title'] ?? '')),
            '{BillingCity}'     => htmlspecialchars((string) ($invoice_arr['b_city'] ?? '')),
            '{BillingState}'    => htmlspecialchars((string) ($invoice_arr['b_state'] ?? '')),
            '{BillingZip}'      => htmlspecialchars((string) ($invoice_arr['b_zip'] ?? '')),
            '{ShippingName}'    => htmlspecialchars((string) ($invoice_arr['s_name'] ?? '')),
            '{ShippingAddress1}' => htmlspecialchars((string) ($invoice_arr['s_address_1'] ?? '')),
            '{ShippingAddress2}' => htmlspecialchars((string) ($invoice_arr['s_address_2'] ?? '')),
            '{ShippingCountry}' => htmlspecialchars((string) ($invoice_arr['s_country_title'] ?? '')),
            '{ShippingCity}'    => htmlspecialchars((string) ($invoice_arr['s_city'] ?? '')),
            '{ShippingState}'   => htmlspecialchars((string) ($invoice_arr['s_state'] ?? '')),
            '{ShippingZip}'     => htmlspecialchars((string) ($invoice_arr['s_zip'] ?? '')),
            '{Total}'           => pjCurrency::formatPrice((float) ($invoice_arr['total'] ?? 0)),
            '{Subtotal}'        => pjCurrency::formatPrice((float) ($invoice_arr['price'] ?? 0)),
            '{Price}'           => pjCurrency::formatPrice((float) ($invoice_arr['price'] ?? 0)),
            '{Discount}'        => pjCurrency::formatPrice((float) ($invoice_arr['discount'] ?? 0)),
            '{Shipping}'        => pjCurrency::formatPrice((float) ($invoice_arr['shipping'] ?? 0)),
            '{Tax}'             => pjCurrency::formatPrice((float) ($invoice_arr['tax'] ?? 0)),
            '{Insurance}'       => pjCurrency::formatPrice((float) ($invoice_arr['insurance'] ?? 0)),
            '{Currency}'        => htmlspecialchars((string) ($invoice_arr['currency'] ?? '')),
            '{Notes}'           => nl2br(htmlspecialchars((string) ($invoice_arr['notes'] ?? ''))),
            '{Items}'           => $itemsHtml,
            '{Status}'          => isset($labels['status_' . $invoice_arr['status']]) ? $labels['status_' . $invoice_arr['status']] : (string) ($invoice_arr['status'] ?? ''),
            '{CompanyLogo}'     => $logoHtml,
            '{CompanyName}'     => htmlspecialchars((string) ($config['y_company'] ?? ($config['y_name'] ?? ''))),
            '{CompanyAddress}'  => htmlspecialchars((string) ($config['y_street_address'] ?? '')),
            '{CompanyCountry}'  => htmlspecialchars((string) ($config['y_country_title'] ?? ($config['y_country'] ?? ''))),
            '{CompanyCity}'     => htmlspecialchars((string) ($config['y_city'] ?? '')),
            '{CompanyState}'    => htmlspecialchars((string) ($config['y_state'] ?? '')),
            '{CompanyZip}'      => htmlspecialchars((string) ($config['y_zip'] ?? '')),
            '{CompanyPhone}'    => htmlspecialchars((string) ($config['y_phone'] ?? '')),
            '{CompanyFax}'      => htmlspecialchars((string) ($config['y_fax'] ?? '')),
            '{CompanyEmail}'    => htmlspecialchars((string) ($config['y_email'] ?? '')),
            '{CompanyUrl}'      => htmlspecialchars((string) ($config['y_url'] ?? '')),
            '{CompanyTaxNumber}' => htmlspecialchars((string) ($config['y_vat_number'] ?? '')),
            '{CompanyRegistrationNumber}' => htmlspecialchars((string) ($config['y_coc_number'] ?? '')),
            '{CompanyBankName}' => htmlspecialchars((string) ($config['y_bank_name'] ?? '')),
            '{CompanyBankAccount}' => htmlspecialchars((string) ($config['y_bank_iban'] ?? '')),
            '{uuid}'            => htmlspecialchars((string) ($invoice_arr['uuid'] ?? '')),
            '{order_id}'        => htmlspecialchars((string) ($invoice_arr['order_id'] ?? '')),
            '{issue_date}'      => $formatDate($invoice_arr['issue_date'] ?? ''),
            '{due_date}'        => $formatDate($invoice_arr['due_date'] ?? ''),
            '{status}'          => isset($labels['status_' . ($invoice_arr['status'] ?? '')]) ? $labels['status_' . $invoice_arr['status']] : htmlspecialchars((string) ($invoice_arr['status'] ?? '')),
            '{price}'           => pjCurrency::formatPrice((float) ($invoice_arr['price'] ?? 0)),
            '{discount}'        => pjCurrency::formatPrice((float) ($invoice_arr['discount'] ?? 0)),
            '{insurance}'       => pjCurrency::formatPrice((float) ($invoice_arr['insurance'] ?? 0)),
            '{shipping}'        => pjCurrency::formatPrice((float) ($invoice_arr['shipping'] ?? 0)),
            '{tax}'             => pjCurrency::formatPrice((float) ($invoice_arr['tax'] ?? 0)),
            '{total}'           => pjCurrency::formatPrice((float) ($invoice_arr['total'] ?? 0)),
            '{currency}'        => htmlspecialchars((string) ($invoice_arr['currency'] ?? '')),
            '{notes}'           => nl2br(htmlspecialchars((string) ($invoice_arr['notes'] ?? ''))),
            '{items}'           => $itemsHtml,
            '{y_logo}'          => $logoHtml,
            '{y_company}'       => htmlspecialchars((string) ($config['y_company'] ?? '')),
            '{y_name}'          => htmlspecialchars((string) ($config['y_name'] ?? '')),
            '{y_street_address}' => htmlspecialchars((string) ($config['y_street_address'] ?? '')),
            '{y_country}'       => htmlspecialchars((string) ($config['y_country_title'] ?? '')),
            '{y_city}'          => htmlspecialchars((string) ($config['y_city'] ?? '')),
            '{y_state}'         => htmlspecialchars((string) ($config['y_state'] ?? '')),
            '{y_zip}'           => htmlspecialchars((string) ($config['y_zip'] ?? '')),
            '{y_phone}'         => htmlspecialchars((string) ($config['y_phone'] ?? '')),
            '{y_fax}'           => htmlspecialchars((string) ($config['y_fax'] ?? '')),
            '{y_email}'         => htmlspecialchars((string) ($config['y_email'] ?? '')),
            '{y_url}'           => htmlspecialchars((string) ($config['y_url'] ?? '')),
            '{y_tax_number}'    => htmlspecialchars((string) ($config['y_vat_number'] ?? '')),
            '{y_vat_number}'    => htmlspecialchars((string) ($config['y_vat_number'] ?? '')),
            '{y_coc_number}'    => htmlspecialchars((string) ($config['y_coc_number'] ?? '')),
            '{y_bank_name}'     => htmlspecialchars((string) ($config['y_bank_name'] ?? '')),
            '{y_bank_iban}'     => htmlspecialchars((string) ($config['y_bank_iban'] ?? '')),
            '{b_name}'          => htmlspecialchars((string) ($invoice_arr['b_name'] ?? '')),
            '{b_address_1}'     => htmlspecialchars((string) ($invoice_arr['b_address_1'] ?? '')),
            '{b_address_2}'     => htmlspecialchars((string) ($invoice_arr['b_address_2'] ?? '')),
            '{b_country}'       => htmlspecialchars((string) ($invoice_arr['b_country_title'] ?? '')),
            '{b_city}'          => htmlspecialchars((string) ($invoice_arr['b_city'] ?? '')),
            '{b_state}'         => htmlspecialchars((string) ($invoice_arr['b_state'] ?? '')),
            '{b_zip}'           => htmlspecialchars((string) ($invoice_arr['b_zip'] ?? '')),
            '{s_name}'          => htmlspecialchars((string) ($invoice_arr['s_name'] ?? '')),
            '{s_address_1}'     => htmlspecialchars((string) ($invoice_arr['s_address_1'] ?? '')),
            '{s_address_2}'     => htmlspecialchars((string) ($invoice_arr['s_address_2'] ?? '')),
            '{s_country}'       => htmlspecialchars((string) ($invoice_arr['s_country_title'] ?? '')),
            '{s_city}'          => htmlspecialchars((string) ($invoice_arr['s_city'] ?? '')),
            '{s_state}'         => htmlspecialchars((string) ($invoice_arr['s_state'] ?? '')),
            '{s_zip}'           => htmlspecialchars((string) ($invoice_arr['s_zip'] ?? '')),
        );
        foreach (self::getCompanyTokenMap((int) ($invoice_arr['locale_id'] ?? 1)) as $key => $value) {
            $tokens[$key] = $value;
        }
        return str_replace(array_keys($tokens), array_values($tokens), $html);
    }

    /**
     * Adds BCC recipients to an outgoing email from the notification settings.
     * Parses the (comma/semicolon/newline separated) bcc_emails field, validates
     * each address and sets them as a single Bcc header (the mailer overwrites
     * same-named headers, so all addresses must be passed at once).
     *
     * @param pjEmail $pjEmail Email instance (PHPMailer wrapper)
     * @param array   $ns      Notification settings (must contain 'bcc_emails')
     * @return array           List of valid BCC addresses that were applied
     */
    public static function applyNotificationBcc($pjEmail, $ns)
    {
        $raw = isset($ns['bcc_emails']) ? (string) $ns['bcc_emails'] : '';
        if ($raw === '') {
            return array();
        }

        // Split on comma, semicolon, or any whitespace (incl. newlines).
        $parts = preg_split('/[\s,;]+/', $raw, -1, PREG_SPLIT_NO_EMPTY);
        $valid = array();
        foreach ($parts as $email) {
            $email = trim($email);
            if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
                // De-duplicate (case-insensitive).
                if (!in_array(strtolower($email), array_map('strtolower', $valid), true)) {
                    $valid[] = $email;
                }
            }
        }

        if (!empty($valid) && is_object($pjEmail) && method_exists($pjEmail, 'setBcc')) {
            $pjEmail->setBcc(implode(', ', $valid));
        }

        return $valid;
    }

    /**
     * Best-effort logging of BCC activity for an outgoing notification email.
     * Never throws: logging must not break email delivery.
     *
     * @param int    $order_id  Order row id
     * @param string $label     Human label for the email (e.g. 'Payment confirmation')
     * @param array  $bccResult Return value of applyNotificationBcc()
     * @param int    $user_id   Admin user id triggering the send (0 if none)
     * @return void
     */
    public static function logBccActivity($order_id, $label, $bccResult, $user_id = 0)
    {
        try {
            $emails = is_array($bccResult) ? $bccResult : array();
            if (empty($emails)) {
                return;
            }
            error_log(sprintf(
                '[shopping_cart][bcc] order_id=%d user_id=%d label="%s" bcc=%s',
                (int) $order_id,
                (int) $user_id,
                (string) $label,
                implode(',', $emails)
            ));
        } catch (Exception $e) {
            // Swallow: logging is non-critical.
        }
    }

    /**
     * Adds the PDF invoice as an attachment to the email when the notification
     * is configured for it. Returns the tempfile path (or null), so that
     * the caller can clean it up after sending.
     *
     * @param pjEmail $pjEmail        Email instance (PHPMailer wrapper)
     * @param array   $ns             Notification settings (from getNotificationSettings)
     * @param string  $order_uuid     Order number (shopping_cart_orders.uuid)
     * @return string|null            Path to tempfile, or null if nothing added
     */
    public static function attachInvoiceIfConfigured($pjEmail, $ns, $order_uuid)
    {
        if (empty($ns['attach_invoice'])) {
            return null;
        }
        if (empty($order_uuid) || !class_exists('pjInvoiceModel')) {
            return null;
        }

        // Find the invoice for this order.
        $invoice = pjInvoiceModel::factory()
            ->where('t1.order_id', $order_uuid)
            ->limit(1)
            ->findAll()
            ->getDataIndex(0);
        if (empty($invoice)) {
            return null;
        }

        // Load the pjInvoice controller to call renderInvoicePdf().
        if (!class_exists('pjInvoice')) {
            $ctrlPath = PJ_INSTALL_PATH . 'plugins/pjInvoice/controllers/pjInvoice.controller.php';
            if (!is_file($ctrlPath)) {
                return null;
            }
            require_once $ctrlPath;
        }
        if (!class_exists('pjInvoice')) {
            return null;
        }

        try {
            $invoiceCtrl = new pjInvoice();
            $pdfBytes = $invoiceCtrl->renderInvoicePdf($invoice['uuid'], $invoice['order_id']);
        } catch (Exception $e) {
            return null;
        }
        if (empty($pdfBytes)) {
            return null;
        }

        $tmpFile = tempnam(sys_get_temp_dir(), 'pjinv_mail_');
        if ($tmpFile === false) {
            return null;
        }
        if (file_put_contents($tmpFile, $pdfBytes) === false) {
            @unlink($tmpFile);
            return null;
        }

        $safeName = preg_replace('/[^A-Za-z0-9._-]/', '_', $invoice['uuid']);
        $pjEmail->attach($tmpFile, 'invoice_' . $safeName . '.pdf', 'application/pdf');

        return $tmpFile;
    }

    /**
     * Generate the next invoice number based on the prefix template, number of digits
     * and reset-per-year setting from plugin_invoice_config.
     *
     * @param array $invoice_arr Config-row from plugin_invoice_config
     * @return string E.g. "2026-00042"
     */
    public static function generateInvoiceNumber($invoice_arr)
    {
        $prefixTemplate = isset($invoice_arr['invoice_number']) ? (string) $invoice_arr['invoice_number'] : '';
        $digits         = isset($invoice_arr['invoice_number_digits']) ? (int) $invoice_arr['invoice_number_digits'] : 5;
        $resetYearly    = !empty($invoice_arr['invoice_number_reset_yearly']);

        if ($digits < 2) {
            $digits = 2;
        }
        if ($digits > 10) {
            $digits = 10;
        }

        // Expand tokens
        $tokens = [
            '{Year}'      => date('Y'),
            '{YearShort}' => date('y'),
            '{Month}'     => date('m'),
            '{Day}'       => date('d'),
        ];
        $fullPrefix = strtr($prefixTemplate, $tokens);

        // Find next sequence
        $whereLike = $fullPrefix . '%';

        $query = pjInvoiceModel::factory()
            ->where("uuid LIKE", $whereLike)
            ->orderBy("id DESC")
            ->limit(1);

        if ($resetYearly) {
            $query->where('YEAR(created) =', (int) date('Y'));
        }

        $last = $query->findAll()->getData();

        if (!empty($last)) {
            $suffix = substr($last[0]['uuid'], strlen($fullPrefix));
            // Keep only the trailing digits (handles legacy double-hyphen format "2025--01361",
            // and tolerates any scheidingsteken/letters between prefix en sequence)
            if (preg_match('/(\d+)\s*$/', $suffix, $m)) {
                $next_seq = (int) ltrim($m[1], '0') + 1;
                if ($next_seq <= 0) {
                    $next_seq = 1;
                }
            } else {
                $next_seq = 1;
            }
        } else {
            $next_seq = 1;
        }

        return $fullPrefix . sprintf('%0' . $digits . 'd', $next_seq);
    }
    protected function ensureInvoiceExists($order_id)
    {
        $order_id = (int) $order_id;
        if ($order_id <= 0) {
            return ['status' => 'ERR', 'created' => false, 'invoice' => null, 'text' => 'Invalid order ID'];
        }

        $order = pjOrderModel::factory()->find($order_id)->getData();
        if (empty($order) || empty($order['uuid'])) {
            return ['status' => 'ERR', 'created' => false, 'invoice' => null, 'text' => 'Order not found'];
        }

        $existing = pjInvoiceModel::factory()
            ->where('t1.order_id', $order['uuid'])
            ->limit(1)
            ->findAll()
            ->getDataIndex(0);

        if (!empty($existing)) {
            return ['status' => 'OK', 'created' => false, 'invoice' => $existing];
        }

        $response = $this->pjActionGenerateInvoice($order_id);

        if (is_array($response) && ($response['status'] ?? '') === 'OK') {
            $invoice = pjInvoiceModel::factory()
                ->where('t1.order_id', $order['uuid'])
                ->limit(1)
                ->findAll()
                ->getDataIndex(0);

            if (class_exists('pjOrderLogModel')) {
                $user_id = isset($_SESSION['auth']['admin']['id']) ? (int) $_SESSION['auth']['admin']['id'] : null;
                pjOrderLogModel::factory()->setAttributes([
                    'order_id'  => $order_id,
                    'log_type'  => 'invoice',
                    'action'    => 'Invoice generated',
                    'details'   => json_encode([
                        'invoice_uuid' => !empty($invoice['uuid']) ? $invoice['uuid'] : null,
                        'reason'       => 'fallback_at_paid_status',
                    ]),
                    'user_id'   => $user_id,
                    'created'   => ':NOW()',
                ])->insert();
            }

            return ['status' => 'OK', 'created' => true, 'invoice' => $invoice];
        }

        return [
            'status'  => 'ERR',
            'created' => false,
            'invoice' => null,
            'text'    => is_array($response) && isset($response['text']) ? $response['text'] : 'Invoice generation failed',
        ];
    }

    /**
     * Static wrapper voor ensureInvoiceExists() — bedoeld voor cron jobs en andere
     * contexten waar geen pjAppController-instance via dispatching beschikbaar is.
     *
     * Bouwt zelf een minimale instance op met option_arr, zodat
     * pjActionGenerateInvoice() de currency en private_key kan lezen.
     *
     * @param  int $order_id  Numerieke order ID.
     * @return array          ['status' => 'OK'|'ERR', 'created' => bool, 'invoice' => array|null]
     */
    public static function ensureInvoiceForOrder($order_id)
    {
        static $instance = null;
        if ($instance === null) {
            $instance = new self();
            // Build option_arr manually because pjBaseAppController::beforeFilter()
            // does not run for direct instantiation from a cron context.
            $base   = pjBaseOptionModel::factory()->getPairs(1);
            $script = pjOptionModel::factory()->getPairs(1);
            $instance->option_arr = array_merge((array) $base, (array) $script);
        }
        return $instance->ensureInvoiceExists($order_id);
    }

    public function isInvoiceReady()
    {
        return $this->isAdmin();
    }

    /**
     * Load order row for invoice PDF (shopping-cart schema — no Deepak-only columns).
     */
    public static function fetchOrderForInvoicePdf($order_uuid, $locale_id = null)
    {
        if (empty($order_uuid)) {
            return null;
        }

        $order_row = pjOrderModel::factory()
            ->select("t1.id, t1.uuid, t1.locale_id, t1.discount, t1.shipping, t1.tax, t1.total, t1.price,
				t1.b_name, t1.b_address_1, t1.b_address_2, t1.b_country_id, t1.b_city, t1.b_state, t1.b_zip,
				t1.s_name, t1.s_address_1, t1.s_address_2, t1.s_country_id, t1.s_city, t1.s_state, t1.s_zip,
				t1.status, t1.payment_method, t1.txn_id, t1.notes, t1.created,
				t2.content AS b_country_name, t3.content AS s_country_name")
            ->join('pjMultiLang', "t2.model='pjBaseCountry' AND t2.foreign_id=t1.b_country_id AND t2.field='name' AND t2.locale=t1.locale_id", 'left outer')
            ->join('pjMultiLang', "t3.model='pjBaseCountry' AND t3.foreign_id=t1.s_country_id AND t3.field='name' AND t3.locale=t1.locale_id", 'left outer')
            ->where('t1.uuid', $order_uuid)
            ->limit(1)
            ->findAll()
            ->getDataIndex(0);

        if (empty($order_row)) {
            return null;
        }

        if (method_exists('pjAppController', 'enrichOrderForFrontend')) {
            self::enrichOrderForFrontend($order_row);
        }

        $order_row['shippinglabel'] = '';
        $order_row['carrier_name'] = '';
        $order_row['b_house_number'] = '';
        $order_row['s_house_number'] = '';

        return $order_row;
    }

    public static function mergeOrderIntoInvoicePdfData(&$invoice_arr, $order_row)
    {
        if (empty($order_row)) {
            return;
        }

        $invoice_arr['shippinglabel'] = isset($order_row['shippinglabel']) ? $order_row['shippinglabel'] : '';
        $invoice_arr['carrier_name'] = isset($order_row['carrier_name']) ? $order_row['carrier_name'] : '';
        $invoice_arr['b_house_number'] = isset($order_row['b_house_number']) ? $order_row['b_house_number'] : '';
        $invoice_arr['b_address_2'] = isset($order_row['b_address_2']) ? $order_row['b_address_2'] : '';
        $invoice_arr['s_house_number'] = isset($order_row['s_house_number']) ? $order_row['s_house_number'] : '';
        $invoice_arr['s_address_2'] = isset($order_row['s_address_2']) ? $order_row['s_address_2'] : '';

        if (empty($invoice_arr['shipping']) && isset($order_row['shipping'])) {
            $invoice_arr['shipping'] = $order_row['shipping'];
        }
        if (empty($invoice_arr['discount']) && isset($order_row['discount'])) {
            $invoice_arr['discount'] = $order_row['discount'];
        }
        if (empty($invoice_arr['tax']) && isset($order_row['tax'])) {
            $invoice_arr['tax'] = $order_row['tax'];
        }
        if (empty($invoice_arr['total']) && isset($order_row['total'])) {
            $invoice_arr['total'] = $order_row['total'];
        }
        if (empty($invoice_arr['b_country_title']) && !empty($order_row['b_country_name'])) {
            $invoice_arr['b_country_title'] = $order_row['b_country_name'];
        }
        if (empty($invoice_arr['s_country_title']) && !empty($order_row['s_country_name'])) {
            $invoice_arr['s_country_title'] = $order_row['s_country_name'];
        }
    }

    public static function getDefaultInvoiceYTemplate()
    {
        return '<table style="width:100%;" border="0"><tbody>'
            . '<tr><td style="width:50%;">{CompanyLogo}</td><td>&nbsp;</td></tr>'
            . '<tr><td><p><strong>{CompanyName}</strong></p>'
            . '<p>{CompanyAddress}<br />{CompanyZip} {CompanyCity}<br />{CompanyCountry}<br /><br />'
            . 'Invoice number: {InvoiceNumber}<br />Invoice date: {IssueDate}<br />'
            . 'Order number: {OrderNumber}<br />Payment status: {PaymentStatus}</p></td><td>&nbsp;</td></tr>'
            . '</tbody></table><p>&nbsp;</p>'
            . '<table style="width:100%;"><tbody>'
            . '<tr><td style="width:50%;"><strong>Billing address</strong></td>'
            . '<td style="width:50%;"><strong>Shipping address</strong></td></tr>'
            . '<tr><td><strong>{BillingName}</strong><br />{BillingAddress1}<br />{BillingZip} {BillingCity}<br />{BillingCountry}</td>'
            . '<td><strong>{ShippingName}</strong><br />{ShippingAddress1}<br />{ShippingZip} {ShippingCity}<br />{ShippingCountry}</td></tr>'
            . '</tbody></table><p>&nbsp;</p>{Items}<p>&nbsp;</p>'
            . '<table style="width:100%;"><tbody>'
            . '<tr><td style="text-align:right;">Discount:</td><td style="text-align:right;">{Discount}</td></tr>'
            . '<tr><td style="text-align:right;">Shipping:</td><td style="text-align:right;">{Shipping}</td></tr>'
            . '<tr><td style="text-align:right;">Tax:</td><td style="text-align:right;">{Tax}</td></tr>'
            . '<tr><td style="text-align:right;">Insurance:</td><td style="text-align:right;">{Insurance}</td></tr>'
            . '<tr><td style="text-align:right;"><strong>Total:</strong></td>'
            . '<td style="text-align:right;"><strong>{Total}</strong></td></tr>'
            . '</tbody></table>'
            . '<p>{Notes}</p>';
    }

    public static function getInvoicePdfFilename($order_id, $invoice_uuid = null)
    {
        $base = !empty($order_id) ? (string) $order_id : (string) $invoice_uuid;
        $safe = preg_replace('/[^A-Za-z0-9_-]/', '', $base);
        if ($safe === '') {
            $safe = 'invoice';
        }
        return 'invoice-' . $safe . '.pdf';
    }

    public static function isInvoicePluginAvailable()
    {
        $modelFile = PJ_PLUGINS_PATH . 'pjInvoice/models/pjInvoice.model.php';
        if (!is_file($modelFile)) {
            return false;
        }
        if (!class_exists('pjInvoiceModel', false)) {
            $appModelFile = PJ_PLUGINS_PATH . 'pjInvoice/models/pjInvoiceApp.model.php';
            if (is_file($appModelFile)) {
                require_once $appModelFile;
            }
            require_once $modelFile;
        }
        return class_exists('pjInvoiceModel', false);
    }

    protected function pjActionEnsureInvoice($order_id)
    {
        if (!self::isInvoicePluginAvailable() || (int) $order_id <= 0) {
            return null;
        }
        $existing = $this->ensureInvoiceExists($order_id);
        if (!empty($existing['invoice'])) {
            return $existing['invoice'];
        }
        return null;
    }

    public static function processConditionals($content, $order_arr)
    {
        $hasDigital = !empty($order_arr['has_digital']);
        $isPaid = (isset($order_arr['status']) && $order_arr['status'] === 'completed');

        $conditions = array(
            'IsDigital'  => $hasDigital,
            'IsPaid'     => $isPaid,
            'IsUnpaid'   => !$isPaid,
            'HasNotes'   => !empty($order_arr['notes']),
            'HasVoucher' => !empty($order_arr['voucher']),
        );

        $content = html_entity_decode($content, ENT_QUOTES, 'UTF-8');
        $maxIterations = 10;
        $iteration = 0;
        $pattern = '/\{IF:(\w+)\}((?:(?!\{IF:|\{ENDIF\}).)*?)\{ENDIF\}/s';

        while ($iteration < $maxIterations && preg_match($pattern, $content)) {
            $content = preg_replace_callback($pattern, function ($matches) use ($conditions) {
                $conditionName = $matches[1];
                $blockContent = $matches[2];
                return (isset($conditions[$conditionName]) && $conditions[$conditionName]) ? $blockContent : '';
            }, $content);
            $iteration++;
        }
        return preg_replace('/\{ENDIF\}/', '', $content);
    }
}
