<?php
/**
 * Invoice CPT and data model.
 *
 * @package FacturX_For_WooCommerce
 */

defined( 'ABSPATH' ) || exit;

class GOFACTOX_Invoice {

    public const POST_TYPE = 'gofactox_invoice';

    public static function init(): void {
        add_action( 'init', [ __CLASS__, 'register_post_type' ] );
    }

    /**
     * Register the gofactox_invoice custom post type.
     */
    public static function register_post_type(): void {
        register_post_type( self::POST_TYPE, [
            'labels'              => [
                'name'               => __( 'Invoices', 'gofactox-e-invoicing-for-woocommerce' ),
                'singular_name'      => __( 'Invoice', 'gofactox-e-invoicing-for-woocommerce' ),
                'menu_name'          => __( 'Factur-X', 'gofactox-e-invoicing-for-woocommerce' ),
                'all_items'          => __( 'All Invoices', 'gofactox-e-invoicing-for-woocommerce' ),
                'search_items'       => __( 'Search Invoices', 'gofactox-e-invoicing-for-woocommerce' ),
                'not_found'          => __( 'No invoices found.', 'gofactox-e-invoicing-for-woocommerce' ),
                'not_found_in_trash' => __( 'No invoices found in Trash.', 'gofactox-e-invoicing-for-woocommerce' ),
            ],
            'public'              => false,
            'show_ui'             => false, // We show via WooCommerce admin, not WP admin menu.
            'show_in_rest'        => false,
            'exclude_from_search' => true,
            'supports'            => [ 'title' ],
            'capability_type'     => 'shop_order',
            'map_meta_cap'        => true,
        ] );
    }

    /**
     * Create an invoice record for a WooCommerce order.
     *
     * @param \WC_Order $order The WooCommerce order.
     * @return int|false The invoice post ID, or false on failure.
     */
    public static function create_for_order( \WC_Order $order ): int|false {
        $order_id = $order->get_id();

        // Don't create duplicate invoices.
        $existing = $order->get_meta( '_gofactox_invoice_id' );
        if ( $existing ) {
            return (int) $existing;
        }

        // Generate invoice number.
        $invoice_number = GOFACTOX_Numbering::next_number();

        $post_id = wp_insert_post( [
            'post_type'   => self::POST_TYPE,
            'post_status' => 'publish',
            'post_title'  => $invoice_number,
            'meta_input'  => [
                '_gofactox_order_id'       => $order_id,
                '_gofactox_invoice_number' => $invoice_number,
                '_gofactox_invoice_date'   => current_time( 'Y-m-d' ),
                '_gofactox_is_credit_note' => 0,
                '_gofactox_total_ht'       => $order->get_total() - $order->get_total_tax(),
                '_gofactox_total_tax'      => $order->get_total_tax(),
                '_gofactox_total_ttc'      => $order->get_total(),
                '_gofactox_currency'       => $order->get_currency(),
                '_gofactox_pdf_path'       => '',
            ],
        ] );

        if ( is_wp_error( $post_id ) ) {
            return false;
        }

        // Store reference on the order.
        $order->update_meta_data( '_gofactox_invoice_id', $post_id );
        $order->update_meta_data( '_gofactox_invoice_number', $invoice_number );
        $order->save();

        return $post_id;
    }

    /**
     * Create a credit note record for a WooCommerce refund.
     *
     * Requires the parent order to already have an invoice (a credit note
     * legally cannot exist without referencing an issued invoice).
     *
     * @param \WC_Order_Refund $refund The WooCommerce refund.
     * @return int|false The credit note post ID, or false on failure.
     */
    public static function create_for_refund( \WC_Order_Refund $refund ): int|false {
        $parent_order = $refund->get_parent_id() ? wc_get_order( $refund->get_parent_id() ) : null;
        if ( ! $parent_order instanceof \WC_Order ) {
            return false;
        }

        // Original invoice must exist.
        $original_invoice_id = (int) $parent_order->get_meta( '_gofactox_invoice_id' );
        if ( ! $original_invoice_id ) {
            return false;
        }

        // Avoid duplicate credit notes for the same refund.
        $existing = $refund->get_meta( '_gofactox_credit_note_id' );
        if ( $existing ) {
            return (int) $existing;
        }

        $cn_number = GOFACTOX_Numbering::next_number( 'credit_note' );

        // Refund totals are stored as negative in WC; we keep positive absolute values
        // in our records and the type code (381) marks them as a credit.
        $refund_total = abs( (float) $refund->get_total() );
        $refund_tax   = abs( (float) $refund->get_total_tax() );

        $post_id = wp_insert_post( [
            'post_type'   => self::POST_TYPE,
            'post_status' => 'publish',
            'post_title'  => $cn_number,
            'meta_input'  => [
                '_gofactox_order_id'            => $parent_order->get_id(),
                '_gofactox_refund_id'           => $refund->get_id(),
                '_gofactox_original_invoice_id' => $original_invoice_id,
                '_gofactox_is_credit_note'      => 1,
                '_gofactox_invoice_number'      => $cn_number,
                '_gofactox_invoice_date'        => current_time( 'Y-m-d' ),
                '_gofactox_total_ht'            => $refund_total - $refund_tax,
                '_gofactox_total_tax'           => $refund_tax,
                '_gofactox_total_ttc'           => $refund_total,
                '_gofactox_currency'            => $parent_order->get_currency(),
                '_gofactox_pdf_path'            => '',
            ],
        ] );

        if ( is_wp_error( $post_id ) ) {
            return false;
        }

        $refund->update_meta_data( '_gofactox_credit_note_id', $post_id );
        $refund->update_meta_data( '_gofactox_credit_note_number', $cn_number );
        $refund->save();

        return $post_id;
    }

    /**
     * Get the invoice data for a WooCommerce order.
     *
     * @param \WC_Order $order The WooCommerce order.
     * @return array Invoice data array.
     */
    public static function get_invoice_data( \WC_Order $order ): array {
        $settings = self::get_seller_data();

        $period = self::resolve_billing_period( $order );
        $data = [
            'invoice_number'   => $order->get_meta( '_gofactox_invoice_number' ),
            'invoice_date'     => get_post_meta( $order->get_meta( '_gofactox_invoice_id' ), '_gofactox_invoice_date', true ) ?: current_time( 'Y-m-d' ),
            'delivery_date'    => $order->get_date_completed() ? $order->get_date_completed()->format( 'Y-m-d' ) : current_time( 'Y-m-d' ),
            'billing_period'   => $period,
            'seller'           => $settings,
            'buyer'            => self::get_buyer_data( $order ),
            'buyer_reference'  => (string) $order->get_meta( '_gofactox_buyer_po_reference' ),
            'cadre_de_facturation' => (string) get_option( 'gofactox_cadre_facturation', 'A1' ),
            'operation_category'   => self::resolve_operation_category( $order ),
            'delivery_address'     => self::resolve_delivery_address( $order ),
            'lines'            => self::get_line_items( $order ),
            'tax_totals'       => self::get_tax_totals( $order ),
            'allowances'       => self::get_order_allowances( $order ),
            'total_ht'         => (float) ( $order->get_total() - $order->get_total_tax() ),
            'total_tax'        => (float) $order->get_total_tax(),
            'total_ttc'        => (float) $order->get_total(),
            'currency'         => $order->get_currency(),
            'payment_method'   => $order->get_payment_method_title(),
            'order_id'         => $order->get_id(),
            'order_number'     => $order->get_order_number(),
            'is_credit_note'   => false,
            'original_invoice' => null,
            'refund_reason'    => '',
        ];

        /**
         * Filter the invoice data before it is consumed by the XML/PDF generators.
         *
         * @param array     $data  Invoice data structure (see method body for keys).
         * @param \WC_Order $order The source order.
         */
        return apply_filters( 'gofactox_invoice_data', $data, $order );
    }

    /**
     * Map WooCommerce coupons applied on an order to document-level allowances (BG-20).
     *
     * WooCommerce stores per-coupon discount + tax on order items; we aggregate per
     * coupon code so the resulting XML carries one `<AllowanceCharge>` per coupon.
     *
     * @return array<int, array{amount:float, tax_rate:float, reason:string}>
     */
    private static function get_order_allowances( \WC_Order $order ): array {
        $allowances = [];
        $items      = $order->get_items( 'coupon' );

        if ( empty( $items ) ) {
            return $allowances;
        }

        foreach ( $items as $item ) {
            /** @var \WC_Order_Item_Coupon $item */
            $discount     = (float) $item->get_discount();
            $discount_tax = (float) $item->get_discount_tax();
            if ( $discount <= 0 ) {
                continue;
            }

            // Effective rate of the tax that was deducted along with the discount.
            $tax_rate = ( $discount > 0 && $discount_tax > 0 )
                ? round( ( $discount_tax / $discount ) * 100, 1 )
                : 0.0;

            $allowances[] = [
                'amount'   => round( $discount, 2 ),
                'tax_rate' => $tax_rate,
                'reason'   => sprintf(
                    /* translators: %s: coupon code */
                    __( 'Coupon %s', 'gofactox-e-invoicing-for-woocommerce' ),
                    $item->get_code()
                ),
            ];
        }

        return $allowances;
    }

    /**
     * Get the credit note data for a WooCommerce refund.
     *
     * Refund line items, taxes and totals are stored as negative in WC; we expose them
     * as positive absolute values here. The downstream XML and PDF generators infer
     * the credit semantics from `is_credit_note` + the type code (381 vs 380).
     *
     * @param \WC_Order_Refund $refund The WooCommerce refund.
     * @return array Credit note data array (same shape as get_invoice_data() + extras).
     */
    public static function get_credit_note_data( \WC_Order_Refund $refund ): array {
        $parent_order = $refund->get_parent_id() ? wc_get_order( $refund->get_parent_id() ) : null;

        $original_invoice_id = $parent_order ? (int) $parent_order->get_meta( '_gofactox_invoice_id' ) : 0;
        $original_invoice    = [
            'number' => $parent_order ? (string) $parent_order->get_meta( '_gofactox_invoice_number' ) : '',
            'date'   => $original_invoice_id ? (string) get_post_meta( $original_invoice_id, '_gofactox_invoice_date', true ) : '',
        ];

        $cn_post_id = (int) $refund->get_meta( '_gofactox_credit_note_id' );

        $data = [
            'invoice_number'   => (string) $refund->get_meta( '_gofactox_credit_note_number' ),
            'invoice_date'     => $cn_post_id
                ? ( get_post_meta( $cn_post_id, '_gofactox_invoice_date', true ) ?: current_time( 'Y-m-d' ) )
                : current_time( 'Y-m-d' ),
            'delivery_date'    => $original_invoice['date'] ?: current_time( 'Y-m-d' ),
            'seller'           => self::get_seller_data(),
            'buyer'            => $parent_order instanceof \WC_Order ? self::get_buyer_data( $parent_order ) : [],
            'buyer_reference'  => $parent_order instanceof \WC_Order ? (string) $parent_order->get_meta( '_gofactox_buyer_po_reference' ) : '',
            'cadre_de_facturation' => (string) get_option( 'gofactox_cadre_facturation', 'A1' ),
            'operation_category'   => self::resolve_operation_category( $parent_order ),
            'delivery_address'     => $parent_order instanceof \WC_Order ? self::resolve_delivery_address( $parent_order ) : null,
            'lines'            => self::get_refund_line_items( $refund ),
            'tax_totals'       => self::get_refund_tax_totals( $refund ),
            'allowances'       => [], // Credit notes mirror the refund lines themselves; no document-level allowance.
            'total_ht'         => abs( (float) ( $refund->get_total() - $refund->get_total_tax() ) ),
            'total_tax'        => abs( (float) $refund->get_total_tax() ),
            'total_ttc'        => abs( (float) $refund->get_total() ),
            'currency'         => $parent_order instanceof \WC_Order ? $parent_order->get_currency() : 'EUR',
            'payment_method'   => $parent_order instanceof \WC_Order ? $parent_order->get_payment_method_title() : '',
            'order_id'         => $refund->get_id(),
            'order_number'     => $parent_order instanceof \WC_Order ? $parent_order->get_order_number() : '',
            'is_credit_note'   => true,
            'original_invoice' => $original_invoice,
            'refund_reason'    => (string) $refund->get_reason(),
        ];

        /**
         * Filter the credit note data before it is consumed by the XML/PDF generators.
         *
         * @param array            $data   Credit note data structure.
         * @param \WC_Order_Refund $refund The source refund.
         */
        return apply_filters( 'gofactox_credit_note_data', $data, $refund );
    }

    /**
     * Return a map of missing required seller fields keyed by option slug.
     *
     * Required fields stem from French invoicing law (Code de commerce art. L441-9 + CGI art. 242 nonies A).
     * SIREN/SIRET is mandatory for any registered French business; VAT number is mandatory
     * unless the seller is on the franchise en base regime (article 293 B).
     *
     * @return array<string, string> Map of option_slug => human-readable label.
     */
    public static function get_validation_errors(): array {
        $seller = self::get_seller_data();
        $errors = [];

        $required = [
            'company_name'  => __( 'Company Name', 'gofactox-e-invoicing-for-woocommerce' ),
            'address_line1' => __( 'Address Line 1', 'gofactox-e-invoicing-for-woocommerce' ),
            'postal_code'   => __( 'Postal Code', 'gofactox-e-invoicing-for-woocommerce' ),
            'city'          => __( 'City', 'gofactox-e-invoicing-for-woocommerce' ),
            'siret'         => __( 'SIRET', 'gofactox-e-invoicing-for-woocommerce' ),
        ];

        if ( empty( $seller['vat_exempt'] ) ) {
            $required['vat_number'] = __( 'VAT Number', 'gofactox-e-invoicing-for-woocommerce' );
        }

        foreach ( $required as $key => $label ) {
            if ( empty( trim( (string) ( $seller[ $key ] ?? '' ) ) ) ) {
                $errors[ $key ] = $label;
            }
        }

        return $errors;
    }

    /**
     * URL of the Factur-X settings tab (used to deep-link from error messages).
     */
    public static function get_settings_url(): string {
        return admin_url( 'admin.php?page=wc-settings&tab=facturx' );
    }

    /**
     * Resolve the operation category (catégorie de l'opération), one of the new
     * mandatory mentions of the French e-invoicing reform: livraison de biens,
     * prestation de services, or mixte.
     *
     * Driven by the gofactox_operation_category setting. When set to "auto" (the
     * default) the category is detected from the order's product lines; otherwise
     * the merchant's explicit choice is used.
     *
     * @param \WC_Order|null $order The source order (null falls back to "goods").
     * @return array{code:string, label:string}
     */
    private static function resolve_operation_category( ?\WC_Order $order ): array {
        $labels = [
            'goods'    => __( 'Supply of goods', 'gofactox-e-invoicing-for-woocommerce' ),
            'services' => __( 'Supply of services', 'gofactox-e-invoicing-for-woocommerce' ),
            'mixed'    => __( 'Mixed (goods and services)', 'gofactox-e-invoicing-for-woocommerce' ),
        ];

        $mode = get_option( 'gofactox_operation_category', 'auto' );
        $code = in_array( $mode, [ 'goods', 'services', 'mixed' ], true )
            ? $mode
            : self::detect_operation_category( $order );

        return [ 'code' => $code, 'label' => $labels[ $code ] ];
    }

    /**
     * Detect the operation category from an order's product lines.
     *
     * Virtual or downloadable products are treated as services (prestation de
     * services), shippable physical products as goods (livraison de biens). An order
     * mixing both yields "mixed". Orders with no resolvable products fall back to
     * "goods", the common case for a WooCommerce store.
     */
    private static function detect_operation_category( ?\WC_Order $order ): string {
        if ( ! $order instanceof \WC_Order ) {
            return 'goods';
        }

        $has_goods    = false;
        $has_services = false;

        foreach ( $order->get_items() as $item ) {
            $product = $item->get_product();
            if ( ! $product ) {
                $has_goods = true;
                continue;
            }
            if ( $product->is_virtual() || $product->is_downloadable() ) {
                $has_services = true;
            } else {
                $has_goods = true;
            }
        }

        if ( $has_goods && $has_services ) {
            return 'mixed';
        }
        if ( $has_services ) {
            return 'services';
        }
        return 'goods';
    }

    /**
     * Resolve the deliver-to address (EN 16931 BG-15) when it differs from the buyer's
     * billing address. The French e-invoicing reform makes the delivery address a
     * mandatory mention whenever the goods are delivered somewhere other than the
     * customer's billing address.
     *
     * Returns null when there is no usable shipping address (e.g. a virtual-only order)
     * or when it matches the billing address, in which case nothing is emitted.
     *
     * @return array{name:string, address_line1:string, address_line2:string, postal_code:string, city:string, country_code:string}|null
     */
    private static function resolve_delivery_address( \WC_Order $order ): ?array {
        $shipping = $order->get_address( 'shipping' );
        $billing  = $order->get_address( 'billing' );

        // No usable shipping address (virtual-only order, or shipping not collected).
        if ( empty( $shipping['address_1'] ) && empty( $shipping['city'] ) ) {
            return null;
        }

        // Only required when delivery differs from the billing address.
        $norm = static fn ( $v ) => strtolower( trim( (string) ( $v ?? '' ) ) );
        $same = $norm( $shipping['address_1'] ?? '' ) === $norm( $billing['address_1'] ?? '' )
            && $norm( $shipping['postcode'] ?? '' ) === $norm( $billing['postcode'] ?? '' )
            && $norm( $shipping['city'] ?? '' ) === $norm( $billing['city'] ?? '' )
            && $norm( $shipping['country'] ?? '' ) === $norm( $billing['country'] ?? '' );
        if ( $same ) {
            return null;
        }

        $name = ! empty( $shipping['company'] )
            ? $shipping['company']
            : trim( ( $shipping['first_name'] ?? '' ) . ' ' . ( $shipping['last_name'] ?? '' ) );

        return [
            'name'          => $name,
            'address_line1' => $shipping['address_1'] ?? '',
            'address_line2' => $shipping['address_2'] ?? '',
            'postal_code'   => $shipping['postcode'] ?? '',
            'city'          => $shipping['city'] ?? '',
            'country_code'  => $shipping['country'] ?? '',
        ];
    }

    /**
     * Get seller data from plugin settings.
     */
    public static function get_seller_data(): array {
        return [
            'company_name'  => get_option( 'gofactox_company_name', '' ),
            'legal_form'    => get_option( 'gofactox_legal_form', '' ),
            'share_capital' => get_option( 'gofactox_share_capital', '' ),
            'address_line1' => get_option( 'gofactox_address_line1', '' ),
            'address_line2' => get_option( 'gofactox_address_line2', '' ),
            'postal_code'   => get_option( 'gofactox_postal_code', '' ),
            'city'          => get_option( 'gofactox_city', '' ),
            'country_code'  => get_option( 'gofactox_country_code', 'FR' ),
            'siret'         => get_option( 'gofactox_siret', '' ),
            'vat_number'    => get_option( 'gofactox_vat_number', '' ),
            'rcs_city'      => get_option( 'gofactox_rcs_city', '' ),
            'vat_exempt'    => get_option( 'gofactox_vat_exempt', 'no' ) === 'yes',
            'vat_on_debits' => get_option( 'gofactox_vat_on_debits', 'no' ) === 'yes',
            'iban'          => trim( (string) get_option( 'gofactox_iban', '' ) ),
            'bic'           => trim( (string) get_option( 'gofactox_bic', '' ) ),
            'bank_name'     => trim( (string) get_option( 'gofactox_bank_name', '' ) ),
            'email'         => trim( (string) get_option( 'gofactox_seller_email', '' ) ),
            'logo_path'     => self::resolve_logo_path(),
        ];
    }

    /**
     * Detect the billing period for an order, when the order originates from
     * WooCommerce Subscriptions (or a compatible plugin). The period is exposed
     * on the invoice as EN 16931 BT-73/BT-74 (Invoice billing period start/end).
     *
     * @return array{start:string, end:string}|null Both Y-m-d strings, or null if not applicable.
     */
    private static function resolve_billing_period( \WC_Order $order ): ?array {
        if ( ! function_exists( 'wcs_order_contains_subscription' ) ) {
            return null;
        }
        if ( ! wcs_order_contains_subscription( $order, [ 'parent', 'renewal' ] ) ) {
            return null;
        }

        $subs = function_exists( 'wcs_get_subscriptions_for_order' )
            ? wcs_get_subscriptions_for_order( $order, [ 'order_type' => 'any' ] )
            : [];
        if ( empty( $subs ) ) {
            return null;
        }

        $start = null;
        $end   = null;
        foreach ( $subs as $sub ) {
            $period_start = (int) ( $sub->get_date( 'last_payment' ) ? strtotime( $sub->get_date( 'last_payment' ) ) : strtotime( $sub->get_date( 'start' ) ) );
            $period_end   = (int) ( $sub->get_date( 'next_payment' ) ? strtotime( $sub->get_date( 'next_payment' ) ) : strtotime( $sub->get_date( 'end' ) ) );

            // Fallback for subscriptions without next_payment (final period or one-time).
            if ( ! $period_end && $period_start ) {
                $period_end = strtotime( '+1 month', $period_start );
            }
            if ( ! $period_start ) {
                continue;
            }

            $start = $start ? min( $start, $period_start ) : $period_start;
            $end   = $end ? max( $end, $period_end ) : $period_end;
        }

        if ( ! $start || ! $end ) {
            return null;
        }
        return [
            'start' => gmdate( 'Y-m-d', $start ),
            'end'   => gmdate( 'Y-m-d', $end ),
        ];
    }

    /**
     * Resolve the absolute filesystem path of the company logo, if configured.
     *
     * DOMPDF embeds images via local paths when isRemoteEnabled is false (which is the
     * default for security reasons), so we resolve the attachment to its real file path.
     */
    private static function resolve_logo_path(): string {
        $attachment_id = (int) get_option( 'gofactox_company_logo', 0 );
        if ( ! $attachment_id ) {
            return '';
        }
        $path = get_attached_file( $attachment_id );
        return ( $path && file_exists( $path ) ) ? $path : '';
    }

    /**
     * Get buyer data from a WooCommerce order.
     */
    private static function get_buyer_data( \WC_Order $order ): array {
        $billing = $order->get_address( 'billing' );
        $shipping = $order->get_address( 'shipping' );

        return [
            'name'           => trim( $billing['first_name'] . ' ' . $billing['last_name'] ),
            'company'        => $billing['company'] ?? '',
            'address_line1'  => $billing['address_1'] ?? '',
            'address_line2'  => $billing['address_2'] ?? '',
            'postal_code'    => $billing['postcode'] ?? '',
            'city'           => $billing['city'] ?? '',
            'country_code'   => $billing['country'] ?? '',
            'email'          => $billing['email'] ?? '',
            'phone'          => $billing['phone'] ?? '',
            'siret'          => $order->get_meta( '_gofactox_buyer_siret' ),
            'siren'          => $order->get_meta( '_gofactox_buyer_siren' ),
            'vat_number'     => $order->get_meta( '_gofactox_buyer_vat_number' ),
            'shipping'       => $shipping,
        ];
    }

    /**
     * Get line items from a WooCommerce refund (values flipped to positive).
     *
     * Refund line items in WC have negative qty/total; this is the canonical way to
     * represent partial reversals. We convert to positive values: the document type
     * code (381) is what indicates a credit on the receiving side.
     */
    private static function get_refund_line_items( \WC_Order_Refund $refund ): array {
        $lines = [];

        foreach ( $refund->get_items() as $item ) {
            /** @var \WC_Order_Item_Product $item */
            $qty        = abs( (float) $item->get_quantity() );
            $line_total = abs( (float) $item->get_total() );
            $line_tax   = abs( (float) $item->get_total_tax() );
            $unit_price = $qty > 0 ? round( $line_total / $qty, 2 ) : 0.0;

            $tax_rate = 0.0;
            $taxes    = $item->get_taxes();
            if ( ! empty( $taxes['total'] ) ) {
                foreach ( $taxes['total'] as $rate_id => $tax_amount ) {
                    if ( abs( (float) $tax_amount ) > 0 ) {
                        $rate_obj = \WC_Tax::_get_tax_rate( $rate_id );
                        if ( $rate_obj ) {
                            $tax_rate = (float) $rate_obj['tax_rate'];
                        }
                        break;
                    }
                }
            }

            $lines[] = [
                'name'       => $item->get_name(),
                'quantity'   => $qty,
                'unit_price' => $unit_price,
                'total_ht'   => round( $line_total, 2 ),
                'tax_rate'   => $tax_rate,
                'tax_amount' => round( $line_tax, 2 ),
                'total_ttc'  => round( $line_total + $line_tax, 2 ),
                'sku'        => $item->get_product() ? $item->get_product()->get_sku() : '',
            ];
        }

        // Refund shipping (if any).
        $shipping_total = abs( (float) $refund->get_shipping_total() );
        if ( $shipping_total > 0 ) {
            $shipping_tax  = abs( (float) $refund->get_shipping_tax() );
            $shipping_rate = $shipping_total > 0 && $shipping_tax > 0
                ? round( ( $shipping_tax / $shipping_total ) * 100, 1 )
                : 0.0;
            $shipping_label = '';
            foreach ( $refund->get_shipping_methods() as $method ) {
                $shipping_label = $method->get_method_title();
                break;
            }
            if ( ! $shipping_label ) {
                $shipping_label = __( 'Shipping refund', 'gofactox-e-invoicing-for-woocommerce' );
            }

            $lines[] = [
                'name'       => $shipping_label,
                'quantity'   => 1,
                'unit_price' => $shipping_total,
                'total_ht'   => $shipping_total,
                'tax_rate'   => $shipping_rate,
                'tax_amount' => $shipping_tax,
                'total_ttc'  => $shipping_total + $shipping_tax,
                'sku'        => '',
            ];
        }

        // Refund fees (if any).
        foreach ( $refund->get_fees() as $fee ) {
            $fee_total = abs( (float) $fee->get_total() );
            $fee_tax   = abs( (float) $fee->get_total_tax() );
            $fee_rate  = $fee_total > 0 && $fee_tax > 0
                ? round( ( $fee_tax / $fee_total ) * 100, 1 )
                : 0.0;

            $lines[] = [
                'name'       => $fee->get_name(),
                'quantity'   => 1,
                'unit_price' => $fee_total,
                'total_ht'   => $fee_total,
                'tax_rate'   => $fee_rate,
                'tax_amount' => $fee_tax,
                'total_ttc'  => $fee_total + $fee_tax,
                'sku'        => '',
            ];
        }

        return $lines;
    }

    /**
     * Get tax totals from a refund, grouped by rate (positive values).
     */
    private static function get_refund_tax_totals( \WC_Order_Refund $refund ): array {
        $bases_by_rate = [];

        foreach ( $refund->get_items() as $item ) {
            $line_total = abs( (float) $item->get_total() );
            $taxes      = $item->get_taxes();
            $tax_rate   = 0.0;

            if ( ! empty( $taxes['total'] ) ) {
                foreach ( $taxes['total'] as $rate_id => $tax_amount ) {
                    if ( abs( (float) $tax_amount ) > 0 ) {
                        $rate_obj = \WC_Tax::_get_tax_rate( $rate_id );
                        if ( $rate_obj ) {
                            $tax_rate = (float) $rate_obj['tax_rate'];
                        }
                        break;
                    }
                }
            }

            $key = (string) $tax_rate;
            $bases_by_rate[ $key ] = ( $bases_by_rate[ $key ] ?? 0.0 ) + $line_total;
        }

        $shipping_total = abs( (float) $refund->get_shipping_total() );
        if ( $shipping_total > 0 ) {
            $shipping_tax  = abs( (float) $refund->get_shipping_tax() );
            $shipping_rate = $shipping_total > 0 && $shipping_tax > 0
                ? round( ( $shipping_tax / $shipping_total ) * 100, 1 )
                : 0.0;
            $key = (string) $shipping_rate;
            $bases_by_rate[ $key ] = ( $bases_by_rate[ $key ] ?? 0.0 ) + $shipping_total;
        }

        foreach ( $refund->get_fees() as $fee ) {
            $fee_total = abs( (float) $fee->get_total() );
            $fee_tax   = abs( (float) $fee->get_total_tax() );
            $fee_rate  = $fee_total > 0 && $fee_tax > 0
                ? round( ( $fee_tax / $fee_total ) * 100, 1 )
                : 0.0;
            $key = (string) $fee_rate;
            $bases_by_rate[ $key ] = ( $bases_by_rate[ $key ] ?? 0.0 ) + $fee_total;
        }

        $totals = [];
        foreach ( $refund->get_tax_totals() as $tax ) {
            $rate_id  = $tax->rate_id;
            $rate_obj = \WC_Tax::_get_tax_rate( $rate_id );
            $rate     = $rate_obj ? (float) $rate_obj['tax_rate'] : 0.0;
            $base     = round( $bases_by_rate[ (string) $rate ] ?? 0.0, 2 );

            $totals[] = [
                'rate'   => $rate,
                'base'   => $base,
                'amount' => abs( (float) $tax->amount ),
                'label'  => $tax->label,
            ];
        }

        return $totals;
    }

    /**
     * Get line items from a WooCommerce order.
     */
    private static function get_line_items( \WC_Order $order ): array {
        $lines = [];

        foreach ( $order->get_items() as $item ) {
            /** @var \WC_Order_Item_Product $item */
            $qty        = $item->get_quantity();
            $line_total = (float) $item->get_total();
            $line_tax   = (float) $item->get_total_tax();
            $unit_price = $qty > 0 ? round( $line_total / $qty, 2 ) : 0.0;

            // Get tax rate for this line.
            $tax_rate = 0.0;
            $taxes    = $item->get_taxes();
            if ( ! empty( $taxes['total'] ) ) {
                foreach ( $taxes['total'] as $rate_id => $tax_amount ) {
                    if ( (float) $tax_amount > 0 ) {
                        $rate_obj = \WC_Tax::_get_tax_rate( $rate_id );
                        if ( $rate_obj ) {
                            $tax_rate = (float) $rate_obj['tax_rate'];
                        }
                        break;
                    }
                }
            }

            $lines[] = [
                'name'       => $item->get_name(),
                'quantity'   => $qty,
                'unit_price' => $unit_price,
                'total_ht'   => round( $line_total, 2 ),
                'tax_rate'   => $tax_rate,
                'tax_amount' => round( $line_tax, 2 ),
                'total_ttc'  => round( $line_total + $line_tax, 2 ),
                'sku'        => $item->get_product() ? $item->get_product()->get_sku() : '',
            ];
        }

        // Add shipping as a line item if applicable.
        $shipping_total = (float) $order->get_shipping_total();
        if ( $shipping_total > 0 ) {
            $shipping_tax = (float) $order->get_shipping_tax();
            $shipping_rate = $shipping_total > 0 && $shipping_tax > 0
                ? round( ( $shipping_tax / $shipping_total ) * 100, 1 )
                : 0.0;

            $lines[] = [
                'name'       => $order->get_shipping_method() ?: __( 'Shipping', 'gofactox-e-invoicing-for-woocommerce' ),
                'quantity'   => 1,
                'unit_price' => $shipping_total,
                'total_ht'   => $shipping_total,
                'tax_rate'   => $shipping_rate,
                'tax_amount' => $shipping_tax,
                'total_ttc'  => $shipping_total + $shipping_tax,
                'sku'        => '',
            ];
        }

        // Add fees if applicable.
        foreach ( $order->get_fees() as $fee ) {
            $fee_total = (float) $fee->get_total();
            $fee_tax   = (float) $fee->get_total_tax();
            $fee_rate  = $fee_total > 0 && $fee_tax > 0
                ? round( ( $fee_tax / abs( $fee_total ) ) * 100, 1 )
                : 0.0;

            $lines[] = [
                'name'       => $fee->get_name(),
                'quantity'   => 1,
                'unit_price' => $fee_total,
                'total_ht'   => $fee_total,
                'tax_rate'   => $fee_rate,
                'tax_amount' => $fee_tax,
                'total_ttc'  => $fee_total + $fee_tax,
                'sku'        => '',
            ];
        }

        return $lines;
    }

    /**
     * Get tax totals grouped by rate.
     *
     * Calculates the tax base by summing line item totals per rate,
     * NOT by reverse-calculating from tax amount (which causes rounding errors).
     */
    private static function get_tax_totals( \WC_Order $order ): array {
        // Build base amounts by summing line totals per tax rate.
        $bases_by_rate = [];

        foreach ( $order->get_items() as $item ) {
            $line_total = (float) $item->get_total();
            $taxes      = $item->get_taxes();
            $tax_rate   = 0.0;

            if ( ! empty( $taxes['total'] ) ) {
                foreach ( $taxes['total'] as $rate_id => $tax_amount ) {
                    if ( (float) $tax_amount > 0 ) {
                        $rate_obj = \WC_Tax::_get_tax_rate( $rate_id );
                        if ( $rate_obj ) {
                            $tax_rate = (float) $rate_obj['tax_rate'];
                        }
                        break;
                    }
                }
            }

            $key = (string) $tax_rate;
            if ( ! isset( $bases_by_rate[ $key ] ) ) {
                $bases_by_rate[ $key ] = 0.0;
            }
            $bases_by_rate[ $key ] += $line_total;
        }

        // Include shipping and fees in their respective tax rate buckets.
        $shipping_total = (float) $order->get_shipping_total();
        if ( $shipping_total > 0 ) {
            $shipping_tax  = (float) $order->get_shipping_tax();
            $shipping_rate = $shipping_total > 0 && $shipping_tax > 0
                ? round( ( $shipping_tax / $shipping_total ) * 100, 1 )
                : 0.0;
            $key = (string) $shipping_rate;
            $bases_by_rate[ $key ] = ( $bases_by_rate[ $key ] ?? 0.0 ) + $shipping_total;
        }

        foreach ( $order->get_fees() as $fee ) {
            $fee_total = (float) $fee->get_total();
            $fee_tax   = (float) $fee->get_total_tax();
            $fee_rate  = abs( $fee_total ) > 0 && $fee_tax > 0
                ? round( ( $fee_tax / abs( $fee_total ) ) * 100, 1 )
                : 0.0;
            $key = (string) $fee_rate;
            $bases_by_rate[ $key ] = ( $bases_by_rate[ $key ] ?? 0.0 ) + $fee_total;
        }

        // Build totals from WC tax totals, using our calculated bases.
        $totals = [];

        foreach ( $order->get_tax_totals() as $code => $tax ) {
            $rate_id  = $tax->rate_id;
            $rate_obj = \WC_Tax::_get_tax_rate( $rate_id );
            $rate     = $rate_obj ? (float) $rate_obj['tax_rate'] : 0.0;

            $tax_amount = (float) $tax->amount;
            $base       = round( $bases_by_rate[ (string) $rate ] ?? 0.0, 2 );

            $totals[] = [
                'rate'   => $rate,
                'base'   => $base,
                'amount' => $tax_amount,
                'label'  => $tax->label,
            ];
        }

        return $totals;
    }

    /**
     * Get the PDF file path for an invoice.
     */
    public static function get_pdf_path( int $invoice_id ): string {
        return get_post_meta( $invoice_id, '_gofactox_pdf_path', true ) ?: '';
    }

    /**
     * Update the PDF file path for an invoice.
     */
    public static function set_pdf_path( int $invoice_id, string $path ): void {
        update_post_meta( $invoice_id, '_gofactox_pdf_path', $path );
    }

    /**
     * Get invoice post by order.
     */
    public static function get_by_order( \WC_Order $order ): ?\WP_Post {
        $invoice_id = $order->get_meta( '_gofactox_invoice_id' );
        if ( ! $invoice_id ) {
            return null;
        }

        $post = get_post( $invoice_id );
        return $post && $post->post_type === self::POST_TYPE ? $post : null;
    }

    /**
     * Get credit note post by refund.
     */
    public static function get_by_refund( \WC_Order_Refund $refund ): ?\WP_Post {
        $cn_id = $refund->get_meta( '_gofactox_credit_note_id' );
        if ( ! $cn_id ) {
            return null;
        }

        $post = get_post( $cn_id );
        return $post && $post->post_type === self::POST_TYPE ? $post : null;
    }

    /**
     * Is the given invoice post a credit note?
     */
    public static function is_credit_note( int $invoice_id ): bool {
        return (bool) get_post_meta( $invoice_id, '_gofactox_is_credit_note', true );
    }
}
