<?php
/**
 * Plugin Name: Axagen Bulk Invoice Generator
 * Description: Generate and download bulk PDF invoices for WooCommerce orders — filter by date, status, or order number, then get a single ZIP. Supports Latin, Extended Latin (ä, ö, ü, æ, ø, å) and Cyrillic scripts.
 * Version: 0.4.5
 * Author: Axagen
 * Author URI: https://www.youtube.com/channel/UCY9nRy0lLXiFEb8N0q-9csw
 * License: GPLv2 or later
 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain: axagen-bulk-invoice-generator
 * Requires at least: 6.0
 * Requires PHP: 8.0
 * Requires Plugins: woocommerce
 */

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

define('AXAGBUIN_VERSION', '0.4.5');
define('AXAGBUIN_PLUGIN_FILE', __FILE__);

final class Axagbuin_Bulk_Invoice_Generator {
    const NONCE_ACTION          = 'axagbuin_bulk_invoice_generate';
    const SETTINGS_NONCE_ACTION = 'axagbuin_bulk_invoice_settings';
    const OPTION_PREFIX         = 'axagbuin_invoice_';
    const TRANSIENT_PREFIX      = 'axagbuin_job_';

    public static function init() {
        add_action('admin_menu',                                     [__CLASS__, 'register_menu']);
        add_action('admin_enqueue_scripts',                          [__CLASS__, 'enqueue_admin_assets']);
        add_action('admin_post_axagbuin_bulk_invoice_download',      [__CLASS__, 'handle_download']);
        add_action('admin_post_axagbuin_bulk_invoice_settings_save', [__CLASS__, 'handle_settings_save']);
        add_action('wp_ajax_axagbuin_bulk_invoice_settings_save',    [__CLASS__, 'handle_settings_save']);
        add_action('wp_ajax_axagbuin_bulk_invoice_stream',           [__CLASS__, 'handle_stream']);
        add_action('wp_ajax_axagbuin_bulk_invoice_progress',         [__CLASS__, 'handle_progress']);
        add_action('wp_ajax_axagbuin_bulk_invoice_fetch_zip',        [__CLASS__, 'handle_fetch_zip']);
        add_action('admin_post_axagbuin_bulk_invoice_fetch_zip',     [__CLASS__, 'handle_fetch_zip']);
        add_action('init',                                           [__CLASS__, 'load_dompdf']);
    }

    public static function deactivate() {
        $upload_dir = wp_upload_dir();
        $tmp_dir = trailingslashit($upload_dir['basedir']) . 'axagbuin-invoices-tmp';
        self::cleanup_tmp_dir($tmp_dir);
    }

    public static function register_menu() {
        add_submenu_page(
            'woocommerce', 'Bulk Invoices', 'Bulk Invoices',
            'manage_woocommerce', 'axagbuin-bulk-invoices', [__CLASS__, 'render_page']
        );
    }

    public static function enqueue_admin_assets($hook) {
        if ($hook !== 'woocommerce_page_axagbuin-bulk-invoices') {
            return;
        }

        wp_enqueue_style(
            'axagbuin-admin',
            plugins_url('assets/css/admin.css', AXAGBUIN_PLUGIN_FILE),
            [],
            AXAGBUIN_VERSION
        );

        wp_enqueue_script(
            'axagbuin-admin',
            plugins_url('assets/js/admin.js', AXAGBUIN_PLUGIN_FILE),
            [],
            AXAGBUIN_VERSION,
            ['in_footer' => true]
        );

        wp_localize_script('axagbuin-admin', 'axagbuinAdmin', [
            'ajaxUrl'        => admin_url('admin-ajax.php'),
            'progressNonce'  => wp_create_nonce('axagbuin_bulk_invoice_progress'),
        ]);
    }

    public static function render_page() {
        if (!current_user_can('manage_woocommerce')) {
            wp_die('You do not have permission to access this page.');
        }
        if (!self::woocommerce_active()) {
            echo '<div class="notice notice-error"><p>WooCommerce is not active.</p></div>';
            return;
        }
        $statuses = wc_get_order_statuses();
        $settings = self::get_settings();
        ?>
        <div class="wrap">
            <h1>Bulk Invoice Generator by <a href="https://www.youtube.com/channel/UCY9nRy0lLXiFEb8N0q-9csw" target="_blank" rel="noopener noreferrer">@Axagen</a> <small style="font-size:13px;color:#888;">v<?php echo esc_html(AXAGBUIN_VERSION); ?></small></h1>
            <h2>Invoice Settings</h2>
            <form method="post" action="<?php echo esc_url(admin_url('admin-ajax.php')); ?>">
                <?php wp_nonce_field(self::SETTINGS_NONCE_ACTION, '_wpnonce'); ?>
                <input type="hidden" name="action" value="axagbuin_bulk_invoice_settings_save">
                <table class="form-table" role="presentation">
                    <tr>
                        <th scope="row"><label for="invoice_language">Invoice language</label></th>
                        <td>
                            <select id="invoice_language" name="invoice_language">
                                <option value="en" <?php selected($settings['invoice_language'], 'en'); ?>>English</option>
                                <option value="et" <?php selected($settings['invoice_language'], 'et'); ?>>Eesti (Estonian)</option>
                                <option value="ru" <?php selected($settings['invoice_language'], 'ru'); ?>>Русский (Russian)</option>
                            </select>
                        </td>
                    </tr>
                    <tr>
                        <th scope="row"><label id="lbl-seller-name" for="seller_name">Seller name</label></th>
                        <td><input type="text" id="seller_name" name="seller_name" class="regular-text" value="<?php echo esc_attr($settings['seller_name']); ?>" /></td>
                    </tr>
                    <tr>
                        <th scope="row"><label id="lbl-seller-reg" for="seller_reg">Registration number</label></th>
                        <td><input type="text" id="seller_reg" name="seller_reg" class="regular-text" value="<?php echo esc_attr($settings['seller_reg']); ?>" /></td>
                    </tr>
                    <tr>
                        <th scope="row"><label id="lbl-seller-vat" for="seller_vat">VAT number</label></th>
                        <td><input type="text" id="seller_vat" name="seller_vat" class="regular-text" value="<?php echo esc_attr($settings['seller_vat']); ?>" /></td>
                    </tr>
                    <tr>
                        <th scope="row"><label id="lbl-seller-address" for="seller_address">Seller address</label></th>
                        <td><textarea id="seller_address" name="seller_address" class="large-text" rows="3"><?php echo esc_textarea($settings['seller_address']); ?></textarea></td>
                    </tr>
                    <tr>
                        <th scope="row"><label id="lbl-seller-bank" for="seller_bank">Bank name</label></th>
                        <td><input type="text" id="seller_bank" name="seller_bank" class="regular-text" value="<?php echo esc_attr($settings['seller_bank']); ?>" /></td>
                    </tr>
                    <tr>
                        <th scope="row"><label id="lbl-seller-iban" for="seller_iban">IBAN</label></th>
                        <td><input type="text" id="seller_iban" name="seller_iban" class="regular-text" value="<?php echo esc_attr($settings['seller_iban']); ?>" /></td>
                    </tr>
                    <tr>
                        <th scope="row"><label id="lbl-seller-email" for="seller_email">Seller email</label></th>
                        <td><input type="email" id="seller_email" name="seller_email" class="regular-text" value="<?php echo esc_attr($settings['seller_email']); ?>" /></td>
                    </tr>
                    <tr>
                        <th scope="row"><label id="lbl-seller-phone" for="seller_phone">Seller phone</label></th>
                        <td><input type="text" id="seller_phone" name="seller_phone" class="regular-text" value="<?php echo esc_attr($settings['seller_phone']); ?>" /></td>
                    </tr>
                    <tr>
                        <th scope="row"><label id="lbl-seller-website" for="seller_website">Seller website</label></th>
                        <td><input type="text" id="seller_website" name="seller_website" class="regular-text" value="<?php echo esc_attr($settings['seller_website']); ?>" placeholder="e.g. example.ee or https://example.ee" /></td>
                    </tr>
                    <tr>
                        <th scope="row"><label id="lbl-logo-url" for="seller_logo_url">Logo URL</label></th>
                        <td>
                            <input type="url" id="seller_logo_url" name="seller_logo_url" class="regular-text" value="<?php echo esc_attr($settings['seller_logo_url']); ?>" />
                            <p class="description">Upload a logo in Media Library and paste the URL.</p>
                        </td>
                    </tr>
                    <tr>
                        <th scope="row"><label id="lbl-product-images" for="show_product_images">Product images in invoice</label></th>
                        <td>
                            <label><input type="checkbox" id="show_product_images" name="show_product_images" value="1" <?php checked($settings['show_product_images'], true); ?> /> Show a small thumbnail next to each line item</label>
                        </td>
                    </tr>
                    <tr>
                        <th scope="row"><label id="lbl-invoice-title" for="invoice_title">Invoice title</label></th>
                        <td><input type="text" id="invoice_title" name="invoice_title" class="regular-text" value="<?php echo esc_attr($settings['invoice_title']); ?>" /></td>
                    </tr>
                    <tr>
                        <th scope="row"><label id="lbl-header-color" for="header_color">Table header color</label></th>
                        <td>
                            <input type="color" id="header_color" name="header_color" value="<?php echo esc_attr($settings['header_color']); ?>" />
                            <p class="description">Background color of the Product / Qty / Price header row.</p>
                        </td>
                    </tr>
                    <tr>
                        <th scope="row"><label id="lbl-invoice-footer" for="invoice_footer">Footer note</label></th>
                        <td><textarea id="invoice_footer" name="invoice_footer" class="large-text" rows="2"><?php echo esc_textarea($settings['invoice_footer']); ?></textarea></td>
                    </tr>
                </table>
                <?php submit_button('Save Invoice Settings'); ?>
            </form>
            <p>Generate a ZIP with PDF invoices starting from a date or order number.</p>
            <form id="axagen-download-form" method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
                <?php wp_nonce_field(self::NONCE_ACTION, '_wpnonce'); ?>
                <input type="hidden" name="action" value="axagbuin_bulk_invoice_download">
                <table class="form-table" role="presentation">
                    <tr>
                        <th scope="row"><label id="lbl-date-from" for="date_from">Start date</label></th>
                        <td><input type="date" id="date_from" name="date_from" class="regular-text" /></td>
                    </tr>
                    <tr>
                        <th scope="row"><label id="lbl-date-to" for="date_to">End date</label></th>
                        <td><input type="date" id="date_to" name="date_to" class="regular-text" /></td>
                    </tr>
                    <tr>
                        <th scope="row"><label id="lbl-order-id-from" for="order_id_from">Start order number / ID</label></th>
                        <td>
                            <input type="number" id="order_id_from" name="order_id_from" class="regular-text" min="1" />
                            <p class="description">Matches internal order ID and common custom order number meta keys.</p>
                        </td>
                    </tr>
                    <tr>
                        <th scope="row"><label id="lbl-order-number-meta" for="order_number_meta">Order number meta key</label></th>
                        <td>
                            <input type="text" id="order_number_meta" name="order_number_meta" class="regular-text" value="_order_number" />
                            <p class="description">Optional: override if your store uses a different meta key.</p>
                        </td>
                    </tr>
                    <tr>
                        <th scope="row"><label id="lbl-status" for="status">Order status</label></th>
                        <td>
                            <select id="status" name="status">
                                <option value="any">Any</option>
                                <?php foreach ($statuses as $key => $label) : ?>
                                    <option value="<?php echo esc_attr($key); ?>"><?php echo esc_html($label); ?></option>
                                <?php endforeach; ?>
                            </select>
                        </td>
                    </tr>
                    <tr>
                        <th scope="row"><label id="lbl-limit" for="limit">Max orders</label></th>
                        <td><input type="number" id="limit" name="limit" class="regular-text" min="1" placeholder="All (no limit)" /></td>
                    </tr>
                </table>
                <div id="axagen-progress"></div>
                <?php submit_button('Generate ZIP'); ?>
            </form>
        </div>
        <?php
    }

    public static function handle_download() {
        try {
            if (!current_user_can('manage_woocommerce')) {
                wp_die('You do not have permission to access this action.');
            }
            if (!self::woocommerce_active()) { wp_die('WooCommerce is not active.'); }
            if (!class_exists('ZipArchive'))  { wp_die('PHP ZipArchive extension is not available on this server.'); }
            check_admin_referer(self::NONCE_ACTION, '_wpnonce');

            $date_from = isset($_POST['date_from']) ? sanitize_text_field(wp_unslash($_POST['date_from'])) : '';
            $date_to   = isset($_POST['date_to'])   ? sanitize_text_field(wp_unslash($_POST['date_to']))   : '';
            if (!empty($date_from) && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date_from)) { $date_from = ''; }
            if (!empty($date_to)   && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date_to))   { $date_to   = ''; }
            $order_id_from     = isset($_POST['order_id_from'])     ? absint($_POST['order_id_from']) : 0;
            $status            = isset($_POST['status'])            ? sanitize_text_field(wp_unslash($_POST['status'])) : 'any';
            $order_number_meta = isset($_POST['order_number_meta']) ? sanitize_key(wp_unslash($_POST['order_number_meta'])) : '_order_number';
            $limit             = (isset($_POST['limit']) && $_POST['limit'] !== '') ? absint($_POST['limit']) : -1;
            if ($limit === 0) { $limit = -1; }

            $order_ids = self::query_order_ids($date_from, $date_to, $order_id_from, $status, $limit, $order_number_meta);
            if (empty($order_ids)) {
                wp_safe_redirect(add_query_arg([
                    'axagbuin_bulk_invoice_empty'  => '1',
                    'axagbuin_bulk_invoice_notice' => wp_create_nonce('axagbuin_bulk_invoice_notice'),
                ], wp_get_referer() ?: admin_url('admin.php?page=axagbuin-bulk-invoices')));
                exit;
            }

            $orders = [];
            foreach ($order_ids as $oid) {
                $o = wc_get_order($oid);
                if ($o instanceof WC_Order) { $orders[] = $o; }
            }
            if (empty($orders)) {
                wp_safe_redirect(add_query_arg([
                    'axagbuin_bulk_invoice_empty'  => '1',
                    'axagbuin_bulk_invoice_notice' => wp_create_nonce('axagbuin_bulk_invoice_notice'),
                ], wp_get_referer() ?: admin_url('admin.php?page=axagbuin-bulk-invoices')));
                exit;
            }

            $upload_dir = wp_upload_dir();
            $tmp_dir    = trailingslashit($upload_dir['basedir']) . 'axagbuin-invoices-tmp';
            wp_mkdir_p($tmp_dir);
            self::cleanup_tmp_dir($tmp_dir);

            $zip_path = trailingslashit($tmp_dir) . 'bulk-invoices-' . uniqid('', true) . '.zip';

            set_time_limit(0); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- bulk PDF generation for many orders can legitimately exceed the default execution time; scoped to this request only.
            wp_raise_memory_limit('admin'); // WordPress-native helper: only ever raises (never lowers) the limit, respects the site's own configured ceiling, and applies only to this request.

            $zip_open_failed = false;
            $zip = new ZipArchive();
            if ($zip->open($zip_path, ZipArchive::CREATE) !== true) {
                $zip_open_failed = true;
            } else {
                $errors = [];
                $count  = 0;
                foreach ($orders as $order) {
                    if (!$order instanceof WC_Order) { continue; }
                    try {
                        $pdf      = self::build_invoice_pdf($order);
                        $zip->addFromString('invoices/invoice-' . $order->get_id() . '.pdf', $pdf);
                        unset($pdf);
                    } catch (Throwable $e) {
                        $errors[] = 'Order #' . $order->get_id() . ': ' . $e->getMessage();
                        self::log_error('order #' . $order->get_id() . ': ' . $e->getMessage());
                    }
                    $count++;
                    if ($count % 10 === 0) { gc_collect_cycles(); }
                }
                gc_collect_cycles();

                if (!empty($errors)) {
                    $zip->addFromString('_errors.txt', 'Bulk Invoice Generator — Error Report' . "\n" . 'Generated: ' . wp_date('Y-m-d H:i:s') . "\n" . 'Failed: ' . count($errors) . '/' . $count . "\n\n" . implode("\n", $errors));
                }
                $zip->close();
            }

            if ($zip_open_failed) { wp_die('Failed to create ZIP file.'); }
            if (!file_exists($zip_path)) { wp_die('ZIP file not found.'); }

            $safe_from = preg_replace('/[^0-9\-]/', '', $date_from);
            $safe_to   = preg_replace('/[^0-9\-]/', '', $date_to);
            $parts     = ['bulk-invoices'];
            if ($safe_from) { $parts[] = 'from-' . $safe_from; }
            if ($safe_to)   { $parts[] = 'to-'   . $safe_to;   }
            $zip_filename = implode('_', $parts) . '.zip';

            while (ob_get_level() > 0) { ob_end_clean(); }
            nocache_headers();
            header('Content-Type: application/zip');
            header('Content-Disposition: attachment; filename="' . $zip_filename . '"');
            header('Content-Length: ' . (int) filesize($zip_path));
            readfile($zip_path); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- Streaming large ZIP file; WP_Filesystem::get_contents() would exhaust memory for large exports.
            wp_delete_file($zip_path);
            exit;
        } catch (Throwable $e) {
            self::log_error($e->getMessage());
            wp_die('Bulk invoice generation failed: ' . esc_html($e->getMessage()));
        }
    }

    public static function handle_stream() {
        while (ob_get_level() > 0) { ob_end_clean(); }
        ini_set('output_buffering', 'off'); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- required for server-sent streaming of this admin-only AJAX endpoint; not a global default.
        ini_set('zlib.output_compression', '0'); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- required for server-sent streaming of this admin-only AJAX endpoint; not a global default.
        ini_set('implicit_flush', '1'); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- required for server-sent streaming of this admin-only AJAX endpoint; not a global default.
        if (function_exists('apache_setenv')) { apache_setenv('no-gzip', '1'); }
        ob_implicit_flush(true);
        header('Content-Type: text/plain; charset=utf-8');
        header('X-Accel-Buffering: no');
        header('Cache-Control: no-cache, no-store');
        header('Pragma: no-cache');

        $stream_sent  = false;
        $transient_key_ref = '';

        // Sends the JSON payload (if any) and terminates the request. Called explicitly at every
        // exit point below instead of just calling exit directly, so there's one place that
        // guarantees the response is flushed before the script ends.
        $finish = function ($payload) {
            if ($payload !== null) {
                echo wp_json_encode($payload) . "\n";
                flush();
            }
            exit;
        };

        register_shutdown_function(function () use (&$stream_sent, &$transient_key_ref) {
            $err = error_get_last();
            if (!$err) { return; }
            $fatal_types = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR];
            if (!in_array($err['type'], $fatal_types, true)) { return; }
            $msg = 'Fatal PHP error: ' . $err['message'] . ' in ' . basename($err['file']) . ':' . $err['line'];
            if ($transient_key_ref) { set_transient($transient_key_ref, ['error' => $msg], 3600); }
            if (!$stream_sent) {
                while (ob_get_level() > 0) { ob_end_clean(); }
                echo wp_json_encode(['error' => $msg]) . "\n";
                flush();
            }
        });

        try {
            if (!current_user_can('manage_woocommerce')) {
                $finish(['error' => 'No permission.']);
            }
            if (!self::woocommerce_active()) {
                $finish(['error' => 'WooCommerce not active.']);
            }
            if (!class_exists('ZipArchive')) {
                $finish(['error' => 'ZipArchive not available on this server.']);
            }

            $nonce = isset($_POST['_wpnonce']) ? sanitize_text_field(wp_unslash($_POST['_wpnonce'])) : '';
            if (!wp_verify_nonce($nonce, self::NONCE_ACTION)) {
                $finish(['error' => 'Security check failed. Refresh the page and try again.']);
            }

            set_time_limit(0); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- bulk PDF generation for many orders can legitimately exceed the default execution time; scoped to this request only.
            wp_raise_memory_limit('admin'); // WordPress-native helper: only ever raises (never lowers) the limit, respects the site's own configured ceiling, and applies only to this request.

            $raw_job_key = isset($_POST['axagbuin_job_key']) ? sanitize_key(wp_unslash($_POST['axagbuin_job_key'])) : '';
            // Namespace the client-supplied job key under our own transient prefix so a request can
            // only ever read/write transients within this plugin's own key space, never arbitrary ones.
            $transient_key = $raw_job_key !== '' ? self::TRANSIENT_PREFIX . $raw_job_key : '';
            $transient_key_ref = $transient_key;

            $date_from         = isset($_POST['date_from'])         ? sanitize_text_field(wp_unslash($_POST['date_from']))         : '';
            $date_to           = isset($_POST['date_to'])           ? sanitize_text_field(wp_unslash($_POST['date_to']))           : '';
            if (!empty($date_from) && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date_from)) { $date_from = ''; }
            if (!empty($date_to)   && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date_to))   { $date_to   = ''; }
            $order_id_from     = isset($_POST['order_id_from'])     ? absint($_POST['order_id_from'])                              : 0;
            $status            = isset($_POST['status'])            ? sanitize_text_field(wp_unslash($_POST['status']))            : 'any';
            $order_number_meta = isset($_POST['order_number_meta']) ? sanitize_key(wp_unslash($_POST['order_number_meta']))        : '_order_number';
            $limit             = (isset($_POST['limit']) && $_POST['limit'] !== '') ? absint($_POST['limit']) : -1;
            if ($limit === 0) { $limit = -1; }

            $order_ids = self::query_order_ids($date_from, $date_to, $order_id_from, $status, $limit, $order_number_meta);
            if (empty($order_ids)) {
                $finish(['error' => 'No orders found for the selected filters.']);
            }

            $orders = [];
            foreach ($order_ids as $oid) {
                $o = wc_get_order($oid);
                if ($o instanceof WC_Order) { $orders[] = $o; }
            }
            if (empty($orders)) {
                $finish(['error' => 'No orders found.']);
            }

            $total = count($orders);
            echo wp_json_encode(['total' => $total]) . "\n"; flush();
            $stream_sent = true;
            if ($transient_key) { set_transient($transient_key, ['count' => 0, 'total' => $total], 3600); }

            $upload_dir = wp_upload_dir();
            $tmp_dir    = trailingslashit($upload_dir['basedir']) . 'axagbuin-invoices-tmp';
            wp_mkdir_p($tmp_dir);
            self::cleanup_tmp_dir($tmp_dir);

            $token    = 'axagbuin_' . bin2hex(random_bytes(16));
            $zip_path = trailingslashit($tmp_dir) . $token . '.zip';
            $zip      = new ZipArchive();
            if ($zip->open($zip_path, ZipArchive::CREATE) !== true) {
                $finish(['error' => 'Failed to create ZIP file.']);
            }

            $errors = [];
            $count  = 0;
            foreach ($orders as $order) {
                if (!$order instanceof WC_Order) { continue; }
                try {
                    $pdf      = self::build_invoice_pdf($order);
                    $zip->addFromString('invoices/invoice-' . $order->get_id() . '.pdf', $pdf);
                    unset($pdf);
                } catch (Throwable $e) {
                    $errors[] = 'Order #' . $order->get_id() . ': ' . $e->getMessage();
                    self::log_error('order #' . $order->get_id() . ': ' . $e->getMessage());
                }
                $count++;
                echo wp_json_encode(['progress' => $count]) . "\n"; flush();
                if ($transient_key) { set_transient($transient_key, ['count' => $count, 'total' => $total], 3600); }
                if ($count % 10 === 0) { gc_collect_cycles(); }
            }
            gc_collect_cycles();

            if (!empty($errors)) {
                $zip->addFromString('_errors.txt', 'Bulk Invoice Generator — Error Report' . "\n" . 'Generated: ' . wp_date('Y-m-d H:i:s') . "\n" . 'Failed: ' . count($errors) . '/' . $count . "\n\n" . implode("\n", $errors));
            }

            $zip->close();

            if (!file_exists($zip_path)) {
                if ($transient_key) { set_transient($transient_key, ['error' => 'ZIP file not found after generation.'], 3600); }
                $finish(['error' => 'ZIP file not found after generation.']);
            }

            $safe_from    = preg_replace('/[^0-9\-]/', '', $date_from);
            $safe_to      = preg_replace('/[^0-9\-]/', '', $date_to);
            $parts        = ['bulk-invoices'];
            if ($safe_from) { $parts[] = 'from-' . $safe_from; }
            if ($safe_to)   { $parts[] = 'to-'   . $safe_to;   }
            $zip_filename = implode('_', $parts) . '.zip';

            $fetch_nonce = wp_create_nonce('axagbuin_fetch_zip_' . $token);
            if ($transient_key) { set_transient($transient_key, ['done' => true, 'token' => $token, 'fetch_nonce' => $fetch_nonce, 'filename' => $zip_filename], 3600); }
            $finish(['done' => true, 'token' => $token, 'fetch_nonce' => $fetch_nonce, 'filename' => $zip_filename]);
        } catch (Throwable $e) {
            $loc = basename($e->getFile()) . ':' . $e->getLine();
            if ($transient_key_ref) { set_transient($transient_key_ref, ['error' => 'Generation failed: ' . $e->getMessage() . ' (' . $loc . ')'], 3600); }
            self::log_error('stream error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
            $finish(['error' => 'Generation failed: ' . $e->getMessage() . ' (' . $loc . ')']);
        }
    }

    public static function handle_progress() {
        if (!current_user_can('manage_woocommerce')) {
            wp_send_json(['error' => 'No permission.']);
        }
        $nonce = isset($_POST['_progress_nonce']) ? sanitize_text_field(wp_unslash($_POST['_progress_nonce'])) : '';
        if (!wp_verify_nonce($nonce, 'axagbuin_bulk_invoice_progress')) {
            wp_send_json(['error' => 'Security check failed.']);
        }
        $raw_job_key = isset($_POST['axagbuin_job_key']) ? sanitize_key(wp_unslash($_POST['axagbuin_job_key'])) : '';
        if (empty($raw_job_key)) {
            wp_send_json(['error' => 'Missing job key.']);
        }
        $transient_key = self::TRANSIENT_PREFIX . $raw_job_key;
        $data = get_transient($transient_key);
        wp_send_json($data !== false ? $data : ['waiting' => true]);
    }

    public static function handle_fetch_zip() {
        if (!current_user_can('manage_woocommerce')) { wp_die('No permission.'); }
        $token = isset($_GET['token']) ? sanitize_text_field(wp_unslash($_GET['token'])) : '';
        if (empty($token) || !preg_match('/^axagbuin_[0-9a-f]+$/', $token)) { wp_die('Invalid token.'); }
        if (!isset($_GET['_wpnonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_GET['_wpnonce'])), 'axagbuin_fetch_zip_' . $token)) {
            wp_die('Security check failed.');
        }
        $upload_dir = wp_upload_dir();
        $zip_path   = trailingslashit($upload_dir['basedir']) . 'axagbuin-invoices-tmp/' . $token . '.zip';
        if (!file_exists($zip_path)) { wp_die('File not found or already downloaded.'); }

        $raw_filename = isset($_GET['filename']) ? sanitize_text_field(wp_unslash($_GET['filename'])) : 'bulk-invoices.zip';
        $filename     = preg_replace('/[^a-zA-Z0-9_\-\.]/', '', basename($raw_filename));
        if (empty($filename)) { $filename = 'bulk-invoices.zip'; }

        while (ob_get_level() > 0) { ob_end_clean(); }
        nocache_headers();
        header('Content-Type: application/zip');
        header('Content-Disposition: attachment; filename="' . $filename . '"');
        header('Content-Length: ' . (int) filesize($zip_path));
        readfile($zip_path); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- Streaming large ZIP file; WP_Filesystem::get_contents() would exhaust memory for large exports.
        wp_delete_file($zip_path);
        exit;
    }

    private static function log_error($message) {
        if (defined('WP_DEBUG') && WP_DEBUG) {
            error_log('Axagen Bulk Invoice Generator — ' . $message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- only logs when WP_DEBUG is enabled by the site owner.
        }
    }

    private static function build_invoice_pdf(WC_Order $order) {
        return self::render_pdf($order, self::build_invoice_lines($order));
    }

    private static function build_invoice_lines(WC_Order $order) {
        $lines = [];
        $lines[] = 'Invoice for Order #' . $order->get_order_number();
        $lines[] = 'Date: ' . wp_date('Y-m-d H:i', $order->get_date_created() ? $order->get_date_created()->getTimestamp() : time());
        $lines[] = 'Status: ' . wc_get_order_status_name($order->get_status());
        $lines[] = 'Payment: ' . $order->get_payment_method_title();
        $lines[] = 'Total: ' . self::clean_price(wc_price($order->get_total(), ['currency' => $order->get_currency()]));
        $lines[] = '';
        $lines[] = 'Billing:';
        $lines[] = $order->get_formatted_billing_full_name();
        $lines[] = $order->get_billing_address_1();
        if ($order->get_billing_address_2()) { $lines[] = $order->get_billing_address_2(); }
        $lines[] = $order->get_billing_city() . ' ' . $order->get_billing_postcode();
        $lines[] = $order->get_billing_country();
        if ($order->get_billing_email()) { $lines[] = 'Email: ' . $order->get_billing_email(); }
        if ($order->get_billing_phone()) { $lines[] = 'Phone: ' . $order->get_billing_phone(); }
        $lines[] = '';
        $lines[] = 'Items:';
        foreach ($order->get_items() as $item) {
            $lines[] = $item->get_name() . ' x ' . $item->get_quantity() . ' - ' . self::clean_price(wc_price($item->get_total(), ['currency' => $order->get_currency()]));
        }
        $lines[] = '';
        $lines[] = 'Order total: ' . self::clean_price(wc_price($order->get_total(), ['currency' => $order->get_currency()]));
        return array_filter($lines, static function ($line) { return $line !== null; });
    }

    private static function render_pdf(WC_Order $order, array $lines) {
        if (!class_exists('\\Dompdf\\Dompdf')) { return self::render_simple_pdf_fallback($lines); }
        $html    = self::build_invoice_html($order);
        $options = new \Dompdf\Options();
        $options->set('isRemoteEnabled', true);
        $options->set('isHtml5ParserEnabled', true);
        $options->set('defaultFont', 'DejaVu Sans');
        $options->setChroot([wp_upload_dir()['basedir'], plugin_dir_path(AXAGBUIN_PLUGIN_FILE)]);
        $options->set('httpContext', stream_context_create(['http' => ['timeout' => 5], 'https' => ['timeout' => 5]]));
        $dompdf = new \Dompdf\Dompdf($options);
        $dompdf->loadHtml($html, 'UTF-8');
        $dompdf->setPaper('A4', 'portrait');
        $dompdf->render();
        $output = $dompdf->output();
        unset($dompdf, $options, $html);
        return $output;
    }

    private static function detect_language($setting) {
        return in_array($setting, ['en', 'et', 'ru'], true) ? $setting : 'en';
    }

    private static function get_labels($lang) {
        $all = [
            'en' => ['reg_no'=>'Reg. no','vat'=>'VAT','order_number'=>'Order number','order_date'=>'Order date','payment'=>'Payment','product'=>'Product','qty'=>'Qty','price'=>'Price','subtotal'=>'Subtotal','discount'=>'Discount','tax'=>'Tax','shipping'=>'Shipping','total'=>'Total','via_template'=>'via %s','customer_notes'=>'Customer Notes'],
            'et' => ['reg_no'=>'Registrikood','vat'=>'KMKR','order_number'=>'Tellimuse number','order_date'=>'Tellimuse kuupäev','payment'=>'Makseviis','product'=>'Toode','qty'=>'Kogus','price'=>'Hind','subtotal'=>'Vahesumma','discount'=>'Soodustus','tax'=>'Maksud','shipping'=>'Transport','total'=>'Kokku','via_template'=>'%s kaudu','customer_notes'=>'Kliendi märkused'],
            'ru' => ['reg_no'=>'Рег. номер','vat'=>'НДС','order_number'=>'Номер заказа','order_date'=>'Дата заказа','payment'=>'Оплата','product'=>'Товар','qty'=>'Кол-во','price'=>'Цена','subtotal'=>'Подытог','discount'=>'Скидка','tax'=>'Налог','shipping'=>'Доставка','total'=>'Итого','via_template'=>'через %s','customer_notes'=>'Примечания'],
        ];
        return $all[$lang] ?? $all['en'];
    }

    private static function build_invoice_html(WC_Order $order) {
        $settings = self::get_settings();
        $lang     = self::detect_language($settings['invoice_language']);
        $labels   = self::get_labels($lang);

        $logo_html = '';
        $logo_url  = self::sanitize_logo_url($settings['seller_logo_url']);
        if (!empty($logo_url)) {
            $logo_html = '<img src="' . esc_url($logo_url) . '" class="logo" alt="Logo">';
        }

        $seller_name     = trim($settings['seller_name']);
        $seller_name_reg = $seller_name;
        if (!empty($settings['seller_reg'])) {
            $seller_name_reg = $seller_name !== ''
                ? $seller_name . ', ' . $labels['reg_no'] . ': ' . $settings['seller_reg']
                : $labels['reg_no'] . ': ' . $settings['seller_reg'];
        }
        $seller_parts = [];
        if (!empty(trim($settings['seller_website']))) {
            $website_raw    = trim($settings['seller_website']);
            $website_href   = preg_match('#^https?://#i', $website_raw) ? $website_raw : 'https://' . $website_raw;
            $seller_parts[] = '<a href="' . esc_url($website_href) . '">' . esc_html($website_raw) . '</a>';
        }
        if (!empty(trim($settings['seller_email'])))   { $seller_parts[] = esc_html(trim($settings['seller_email'])); }
        if (!empty(trim($settings['seller_phone'])))   { $seller_parts[] = esc_html(trim($settings['seller_phone'])); }
        $bank = trim($settings['seller_bank']);
        $iban = trim($settings['seller_iban']);
        if (!empty($bank) && !empty($iban))      { $seller_parts[] = esc_html($bank . ': ' . $iban); }
        elseif (!empty($bank))                   { $seller_parts[] = esc_html($bank); }
        elseif (!empty($iban))                   { $seller_parts[] = esc_html($iban); }
        if (!empty($seller_name_reg))            { $seller_parts[] = esc_html($seller_name_reg); }
        if (!empty($settings['seller_vat']))     { $seller_parts[] = esc_html($labels['vat'] . ': ' . $settings['seller_vat']); }
        if (!empty($settings['seller_address'])) { $seller_parts[] = nl2br(esc_html($settings['seller_address'])); }
        $seller_block = implode('<br>', $seller_parts);

        $country_code = $order->get_billing_country();
        $countries    = (function_exists('WC') && WC()->countries) ? WC()->countries->countries : (class_exists('WC_Countries') ? (new WC_Countries())->countries : []);
        $country_name = isset($countries[$country_code]) ? $countries[$country_code] : $country_code;
        $address_street = trim($order->get_billing_address_1() . ' ' . $order->get_billing_address_2());
        $buyer_block  = self::format_block([
            $order->get_formatted_billing_full_name(),
            $address_street,
            trim($order->get_billing_postcode() . ' ' . $order->get_billing_city()),
            $country_name,
            $order->get_billing_phone(),
            $order->get_billing_email(),
        ]);

        $items_html = '';
        foreach ($order->get_items() as $item) {
            $name    = wp_strip_all_tags($item->get_name());
            $qty     = $item->get_quantity();
            $total   = self::clean_price(wc_price($item->get_total(), ['currency' => $order->get_currency()]));
            $img_tag = '';
            if (!empty($settings['show_product_images'])) {
                $product = $item->get_product();
                if ($product) {
                    $thumb_id = $product->get_image_id();
                    if ($thumb_id) {
                        $img_path = get_attached_file($thumb_id);
                        if ($img_path && file_exists($img_path)) {
                            $mime = mime_content_type($img_path);
                            $allowed_mime = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
                            if (in_array($mime, $allowed_mime, true)) {
                                $b64     = base64_encode(file_get_contents($img_path));
                                $img_tag = '<img src="data:' . esc_attr($mime) . ';base64,' . $b64 . '" style="width:36px;height:36px;object-fit:cover;vertical-align:middle;margin-right:6px;border-radius:3px">';
                            }
                        }
                    }
                }
            }
            $item_meta_html = '';
            foreach ($item->get_formatted_meta_data('_', true) as $meta) {
                $mk = wp_strip_all_tags((string)$meta->display_key);
                $mv = wp_strip_all_tags(wp_kses_post((string)$meta->display_value));
                if ($mk && $mv) {
                    $item_meta_html .= '<div class="item-meta"><strong>' . esc_html($mk) . ':</strong> ' . esc_html($mv) . '</div>';
                }
            }
            $items_html .= '<tr><td class="item-name">' . $img_tag . esc_html($name) . $item_meta_html . '</td><td class="item-qty">' . esc_html($qty) . '</td><td class="item-total">' . esc_html($total) . '</td></tr>';
        }

        $subtotal            = self::clean_price(wc_price($order->get_subtotal(),       ['currency' => $order->get_currency()]));
        $raw_discount        = (float) $order->get_discount_total();
        $raw_tax             = (float) $order->get_total_tax();
        $discount_total      = $raw_discount > 0 ? self::clean_price(wc_price($raw_discount, ['currency' => $order->get_currency()])) : '';
        $tax_total           = $raw_tax > 0 ? self::clean_price(wc_price($raw_tax,      ['currency' => $order->get_currency()])) : '';
        $shipping_val        = self::clean_price(wc_price($order->get_shipping_total(), ['currency' => $order->get_currency()]));
        $shipping_method = $order->get_shipping_method();
        if (!empty($shipping_method)) { $shipping_val .= ' ' . sprintf($labels['via_template'], $shipping_method); }
        $total = self::clean_price(wc_price($order->get_total(), ['currency' => $order->get_currency()]));

        $footer_html = '';
        if (!empty($settings['invoice_footer'])) {
            $footer_html = '<div class="footer">' . nl2br(esc_html($settings['invoice_footer'])) . '</div>';
        }

        $order_date     = $order->get_date_created() ? wp_date('F j, Y', $order->get_date_created()->getTimestamp()) : '';
        $payment_method = $order->get_payment_method_title();
        $header_color   = esc_attr($settings['header_color']);
        $font_css       = self::get_font_css();
        $font_stack     = self::get_font_stack();

        return '<!doctype html><html><head><meta charset="utf-8"><style>
        ' . $font_css . '
        body { font-family: ' . $font_stack . '; font-size: 12px; color: #111; }
        .top-bar { display: table; width: 100%; margin-bottom: 30px; }
        .top-bar .t-left, .top-bar .t-right { display: table-cell; vertical-align: top; }
        .top-bar .t-right { text-align: right; font-size: 11px; line-height: 1.8; }
        .logo { max-height: 70px; max-width: 220px; display: block; }
        h1 { font-size: 26px; font-weight: 700; margin: 0 0 20px 0; }
        .info-row { display: table; width: 100%; margin-bottom: 24px; }
        .info-row .i-left, .info-row .i-right { display: table-cell; vertical-align: top; }
        .info-row .i-right { text-align: right; font-size: 12px; line-height: 2; }
        .block { white-space: pre-line; line-height: 1.8; }
        table { width: 100%; border-collapse: collapse; margin-top: 10px; }
        th { background: ' . $header_color . '; color: #fff; font-weight: 600; text-align: left; padding: 8px 6px; }
        td { text-align: left; padding: 7px 6px; border-bottom: 1px solid #eee; }
        .item-meta { font-size: 11px; color: #333; margin-top: 3px; }
        .totals { margin-top: 12px; text-align: right; }
        .totals div { margin-bottom: 4px; }
        .footer { margin-top: 20px; font-size: 11px; color: #444; }
        .item-qty, .item-total { width: 90px; }
        </style></head><body>
        <div class="top-bar">
            <div class="t-left">' . $logo_html . '</div>
            <div class="t-right">' . $seller_block . '</div>
        </div>
        <h1>' . esc_html($settings['invoice_title']) . '</h1>
        <div class="info-row">
            <div class="i-left">
                <div class="block">' . $buyer_block . '</div>
            </div>
            <div class="i-right">
                <div>' . esc_html($labels['order_number']) . ': ' . esc_html($order->get_order_number()) . '</div>
                <div>' . esc_html($labels['order_date']) . ': ' . esc_html($order_date) . '</div>
                <div>' . esc_html($labels['payment']) . ': ' . esc_html($payment_method) . '</div>
            </div>
        </div>
        <table>
            <thead><tr>
                <th>' . esc_html($labels['product']) . '</th>
                <th class="item-qty">' . esc_html($labels['qty']) . '</th>
                <th class="item-total">' . esc_html($labels['price']) . '</th>
            </tr></thead>
            <tbody>' . $items_html . '</tbody>
        </table>
        <div class="totals">
            <div>' . esc_html($labels['subtotal']) . ': ' . esc_html($subtotal) . '</div>
            ' . ($discount_total !== '' ? '<div>' . esc_html($labels['discount']) . ': ' . esc_html($discount_total) . '</div>' : '') . '
            ' . ($tax_total !== ''      ? '<div>' . esc_html($labels['tax'])      . ': ' . esc_html($tax_total)      . '</div>' : '') . '
            <div>' . esc_html($labels['shipping']) . ': ' . esc_html($shipping_val) . '</div>
            <div><strong>' . esc_html($labels['total']) . ': ' . esc_html($total) . '</strong></div>
        </div>
        ' . $footer_html . '
        </body></html>';
    }

    private static function render_simple_pdf_fallback(array $lines) {
        $content_lines = self::wrap_lines($lines, 90);
        $text = "BT\n/F1 11 Tf\n14 TL\n50 800 Td\n";
        $first = true;
        foreach ($content_lines as $line) {
            $escaped = self::pdf_escape($line);
            if ($first) { $text .= "(" . $escaped . ") Tj\n"; $first = false; }
            else { $text .= "T*\n(" . $escaped . ") Tj\n"; }
        }
        $text .= "ET\n";
        $objects = [];
        $objects[] = "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n";
        $objects[] = "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n";
        $objects[] = "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Contents 5 0 R /Resources << /Font << /F1 4 0 R >> >> >>\nendobj\n";
        $objects[] = "4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n";
        $objects[] = "5 0 obj\n<< /Length " . strlen($text) . " >>\nstream\n" . $text . "endstream\nendobj\n";
        $pdf = "%PDF-1.4\n";
        $xref_positions = [0];
        foreach ($objects as $obj) { $xref_positions[] = strlen($pdf); $pdf .= $obj; }
        $xref_offset = strlen($pdf);
        $pdf .= "xref\n0 " . (count($objects) + 1) . "\n0000000000 65535 f \n";
        for ($i = 1; $i <= count($objects); $i++) { $pdf .= sprintf("%010d 00000 n \n", $xref_positions[$i]); }
        $pdf .= "trailer\n<< /Size " . (count($objects) + 1) . " /Root 1 0 R >>\nstartxref\n" . $xref_offset . "\n%%EOF";
        return $pdf;
    }

    private static function wrap_lines(array $lines, $max_chars) {
        $wrapped = [];
        foreach ($lines as $line) {
            $line = trim((string)$line);
            if ($line === '') { $wrapped[] = ''; continue; }
            $parts = preg_split('/\s+/', $line);
            $current = '';
            foreach ($parts as $part) {
                if ($current === '') { $current = $part; continue; }
                if (strlen($current) + strlen($part) + 1 > $max_chars) { $wrapped[] = $current; $current = $part; }
                else { $current .= ' ' . $part; }
            }
            if ($current !== '') { $wrapped[] = $current; }
        }
        return $wrapped;
    }

    private static function pdf_escape($text) {
        $text = (string)$text;
        if (function_exists('iconv')) {
            $converted = iconv('UTF-8', 'ISO-8859-1//TRANSLIT//IGNORE', $text);
            if ($converted !== false) { $text = $converted; }
        }
        $text = str_replace(['\\', '(', ')'], ['\\\\', '\\(', '\\)'], $text);
        $text = preg_replace("/[\r\n]+/", ' ', $text);
        return $text;
    }

    private static function clean_price($html) {
        $text    = wp_strip_all_tags((string)$html);
        $charset = get_bloginfo('charset') ?: 'UTF-8';
        return html_entity_decode($text, ENT_QUOTES, $charset);
    }

    private static function get_font_css() {
        $fonts_dir = plugin_dir_path(AXAGBUIN_PLUGIN_FILE) . 'fonts' . DIRECTORY_SEPARATOR;
        $fonts = [
            ['family' => 'Noto Sans', 'weight' => 400, 'style' => 'normal', 'path' => $fonts_dir . 'NotoSans-Regular.ttf'],
            ['family' => 'Noto Sans', 'weight' => 700, 'style' => 'normal', 'path' => $fonts_dir . 'NotoSans-Bold.ttf'],
        ];
        $css = '';
        foreach ($fonts as $font) {
            if (!file_exists($font['path'])) { continue; }
            $url  = self::path_to_file_url($font['path']);
            $css .= "@font-face { font-family: '" . $font['family'] . "'; font-style: " . $font['style'] . "; font-weight: " . $font['weight'] . "; src: url('" . $url . "'); }\n";
        }
        return $css;
    }

    private static function get_font_stack() {
        return "'Noto Sans','DejaVu Sans',sans-serif";
    }

    private static function path_to_file_url($path) {
        $path = wp_normalize_path($path);
        $path = str_replace(' ', '%20', $path);
        return 'file:///' . ltrim($path, '/');
    }

    private static function sanitize_logo_url($url) {
        $url = trim((string)$url);
        if ($url === '') { return ''; }
        if (stripos($url, 'data:image/') === 0) { return $url; }
        $parsed = wp_parse_url($url);
        if (empty($parsed['host'])) { return ''; }
        $site_host = wp_parse_url(site_url(), PHP_URL_HOST);
        if ($site_host && strcasecmp($parsed['host'], $site_host) === 0) { return $url; }
        $uploads = wp_upload_dir();
        if (!empty($uploads['baseurl']) && strpos($url, $uploads['baseurl']) === 0) { return $url; }
        return '';
    }

    private static function get_settings() {
        return [
            'seller_name'         => (string) get_option(self::OPTION_PREFIX . 'seller_name', ''),
            'seller_reg'          => (string) get_option(self::OPTION_PREFIX . 'seller_reg', ''),
            'seller_vat'          => (string) get_option(self::OPTION_PREFIX . 'seller_vat', ''),
            'seller_address'      => (string) get_option(self::OPTION_PREFIX . 'seller_address', ''),
            'seller_bank'         => (string) get_option(self::OPTION_PREFIX . 'seller_bank', ''),
            'seller_iban'         => (string) get_option(self::OPTION_PREFIX . 'seller_iban', ''),
            'seller_email'        => (string) get_option(self::OPTION_PREFIX . 'seller_email', ''),
            'seller_phone'        => (string) get_option(self::OPTION_PREFIX . 'seller_phone', ''),
            'seller_website'      => (string) get_option(self::OPTION_PREFIX . 'seller_website', ''),
            'seller_logo_url'     => (string) get_option(self::OPTION_PREFIX . 'seller_logo_url', ''),
            'invoice_title'       => (string) get_option(self::OPTION_PREFIX . 'invoice_title', 'INVOICE'),
            'invoice_footer'      => (string) get_option(self::OPTION_PREFIX . 'invoice_footer', ''),
            'invoice_language'    => (string) get_option(self::OPTION_PREFIX . 'invoice_language', 'en'),
            'header_color'        => (string)(get_option(self::OPTION_PREFIX . 'header_color', '#333333') ?: '#333333'),
            'show_product_images' => (bool)   get_option(self::OPTION_PREFIX . 'show_product_images', false),
        ];
    }

    public static function handle_settings_save() {
        if (!current_user_can('manage_woocommerce')) { wp_die('You do not have permission to access this action.'); }
        check_admin_referer(self::SETTINGS_NONCE_ACTION, '_wpnonce');
        $fields = [
            'seller_name'         => 'sanitize_text_field',
            'seller_reg'          => 'sanitize_text_field',
            'seller_vat'          => 'sanitize_text_field',
            'seller_address'      => 'sanitize_textarea_field',
            'seller_bank'         => 'sanitize_text_field',
            'seller_iban'         => 'sanitize_text_field',
            'seller_email'        => 'sanitize_email',
            'seller_phone'        => 'sanitize_text_field',
            'seller_website'      => 'sanitize_text_field',
            'seller_logo_url'     => 'esc_url_raw',
            'invoice_title'       => 'sanitize_text_field',
            'invoice_footer'      => 'sanitize_textarea_field',
            'invoice_language'    => 'sanitize_text_field',
            'header_color'        => 'sanitize_hex_color',
            'show_product_images' => static function( $v ) { return (bool)(int)$v; },
        ];
        foreach ($fields as $key => $sanitizer) {
            $value = isset($_POST[$key]) ? wp_unslash($_POST[$key]) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized via call_user_func($sanitizer) on the next line
            update_option(self::OPTION_PREFIX . $key, call_user_func($sanitizer, $value));
        }
        wp_safe_redirect(add_query_arg([
            'axagbuin_bulk_invoice_saved'       => '1',
            'axagbuin_bulk_invoice_saved_nonce' => wp_create_nonce('axagbuin_bulk_invoice_saved'),
        ], wp_get_referer() ?: admin_url('admin.php?page=axagbuin-bulk-invoices')));
        exit;
    }

    private static function format_block(array $lines) {
        $clean = array_filter(array_map('trim', $lines), static function ($line) { return $line !== ''; });
        return esc_html(implode("\n", $clean));
    }

    private static function woocommerce_active() {
        return function_exists('wc_get_orders');
    }

    private static function query_order_ids($date_from, $date_to, $order_id_from, $status, $limit, $order_number_meta) {
        if (self::is_hpos_enabled()) {
            return self::query_order_ids_hpos($date_from, $date_to, $order_id_from, $status, $limit, $order_number_meta);
        }

        global $wpdb;
        $limit = (int) $limit;
        if ($limit === 0) { return []; }

        $post_statuses = $status !== 'any' ? [sanitize_text_field($status)] : array_keys(wc_get_order_statuses());

        $params = [];
        $sql = "SELECT DISTINCT p.ID FROM {$wpdb->posts} p";

        if ($order_id_from > 0) {
            $meta_keys = array_unique(array_filter(['_order_number','_order_number_formatted','_alg_wc_full_order_number','_alg_wc_custom_order_number','_wc_seq_order_number', $order_number_meta]));
            $meta_ph   = implode(',', array_fill(0, count($meta_keys), '%s'));
            $sql      .= " LEFT JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID AND pm.meta_key IN ($meta_ph)";
            $params    = array_merge($params, $meta_keys);
        }

        $sql .= " WHERE p.post_type = 'shop_order'";

        if (!empty($post_statuses)) {
            $st_ph   = implode(',', array_fill(0, count($post_statuses), '%s'));
            $sql    .= " AND p.post_status IN ($st_ph)";
            $params  = array_merge($params, $post_statuses);
        }

        if (!empty($date_from)) { $sql .= " AND p.post_date >= %s"; $params[] = $date_from . ' 00:00:00'; }
        if (!empty($date_to))   { $sql .= " AND p.post_date <= %s"; $params[] = $date_to   . ' 23:59:59'; }

        if ($order_id_from > 0) {
            $sql    .= " AND (p.ID >= %d OR CAST(pm.meta_value AS UNSIGNED) >= %d)";
            $params[] = $order_id_from;
            $params[] = $order_id_from;
        }

        $sql .= " ORDER BY p.ID ASC";
        if ($limit > 0) { $sql .= " LIMIT %d"; $params[] = $limit; }

        return array_map('absint', $wpdb->get_col($wpdb->prepare($sql, $params))); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is fully prepared; table names use $wpdb properties (safe); caching bulk order queries would cause stale results.
    }

    private static function query_order_ids_hpos($date_from, $date_to, $order_id_from, $status, $limit, $order_number_meta) {
        global $wpdb;
        $limit = (int) $limit;
        if ($limit === 0) { return []; }

        $orders_table  = $wpdb->prefix . 'wc_orders';
        $meta_table    = $wpdb->prefix . 'wc_orders_meta';
        $strip = static function($s) { return str_replace('wc-', '', $s); };
        $post_statuses = $status !== 'any'
            ? [$strip(sanitize_text_field($status))]
            : array_map($strip, array_keys(wc_get_order_statuses()));

        $params = [];
        $sql = "SELECT DISTINCT o.id FROM {$orders_table} o";

        if ($order_id_from > 0) {
            $meta_keys = array_unique(array_filter(['_order_number','_order_number_formatted','_alg_wc_full_order_number','_alg_wc_custom_order_number','_wc_seq_order_number', $order_number_meta]));
            $meta_ph   = implode(',', array_fill(0, count($meta_keys), '%s'));
            $sql      .= " LEFT JOIN {$meta_table} om ON om.order_id = o.id AND om.meta_key IN ($meta_ph)";
            $params    = array_merge($params, $meta_keys);
        }

        $sql .= " WHERE o.type = 'shop_order'";

        if (!empty($post_statuses)) {
            $st_ph   = implode(',', array_fill(0, count($post_statuses), '%s'));
            $sql    .= " AND o.status IN ($st_ph)";
            $params  = array_merge($params, $post_statuses);
        }

        if (!empty($date_from)) { $sql .= " AND o.date_created_gmt >= %s"; $params[] = $date_from . ' 00:00:00'; }
        if (!empty($date_to))   { $sql .= " AND o.date_created_gmt <= %s"; $params[] = $date_to   . ' 23:59:59'; }

        if ($order_id_from > 0) {
            $sql    .= " AND (o.id >= %d OR CAST(om.meta_value AS UNSIGNED) >= %d)";
            $params[] = $order_id_from;
            $params[] = $order_id_from;
        }

        $sql .= " ORDER BY o.id ASC";
        if ($limit > 0) { $sql .= " LIMIT %d"; $params[] = $limit; }

        return array_map('absint', $wpdb->get_col($wpdb->prepare($sql, $params))); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL is fully prepared; table names use $wpdb properties (safe); caching bulk order queries would cause stale results.
    }

    private static function is_hpos_enabled() {
        if (!class_exists('\\Automattic\\WooCommerce\\Utilities\\OrderUtil')) { return false; }
        return \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled();
    }

    public static function load_dompdf() {
        $paths = [
            plugin_dir_path(AXAGBUIN_PLUGIN_FILE) . 'dompdf/vendor/autoload.php',
            plugin_dir_path(AXAGBUIN_PLUGIN_FILE) . 'dompdf/autoload.inc.php',
            plugin_dir_path(AXAGBUIN_PLUGIN_FILE) . 'vendor/dompdf/autoload.inc.php',
        ];
        foreach ($paths as $path) {
            if (file_exists($path)) { require_once $path; return; }
        }
    }

    private static function cleanup_tmp_dir($dir) {
        if (!is_dir($dir)) { return; }
        $files = glob(trailingslashit($dir) . '*.zip');
        if (!$files) { return; }
        $now = time();
        foreach ($files as $file) {
            if (is_file($file) && $now - filemtime($file) > 3600) { wp_delete_file($file); }
        }
    }
}

Axagbuin_Bulk_Invoice_Generator::init();
register_deactivation_hook(__FILE__, ['Axagbuin_Bulk_Invoice_Generator', 'deactivate']);

add_action('admin_notices', function () {
    if (!current_user_can('manage_woocommerce')) { return; }
    if (isset($_GET['axagbuin_bulk_invoice_saved'], $_GET['axagbuin_bulk_invoice_saved_nonce'])
        && wp_verify_nonce(sanitize_text_field(wp_unslash($_GET['axagbuin_bulk_invoice_saved_nonce'])), 'axagbuin_bulk_invoice_saved')
    ) {
        echo '<div class="notice notice-success is-dismissible"><p>Invoice settings saved.</p></div>';
    }
    if (!isset($_GET['axagbuin_bulk_invoice_empty'], $_GET['axagbuin_bulk_invoice_notice'])) { return; }
    if (!wp_verify_nonce(sanitize_text_field(wp_unslash($_GET['axagbuin_bulk_invoice_notice'])), 'axagbuin_bulk_invoice_notice')) { return; }
    echo '<div class="notice notice-warning"><p>No orders found for the selected filters.</p></div>';
});
