package de.assecutor.votianlt.pages.view;

import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.dialog.Dialog;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.IFrame;
import com.vaadin.flow.component.html.Main;
import com.vaadin.flow.component.html.Paragraph;
import com.vaadin.flow.component.html.Span;
import com.vaadin.flow.component.icon.Icon;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.notification.NotificationVariant;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.select.Select;
import com.vaadin.flow.component.textfield.NumberField;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.router.HasDynamicTitle;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.theme.lumo.LumoUtility;
import de.assecutor.votianlt.model.PriceTable;
import de.assecutor.votianlt.model.User;
import de.assecutor.votianlt.model.invoices.SystemInvoiceData;
import de.assecutor.votianlt.model.invoices.SystemInvoiceDispatch;
import de.assecutor.votianlt.pages.base.ui.component.DialogStylingHelper;
import de.assecutor.votianlt.pages.base.ui.component.ViewToolbar;
import de.assecutor.votianlt.pages.base.ui.view.AdminLayout;
import de.assecutor.votianlt.repository.AppUserRepository;
import de.assecutor.votianlt.repository.PriceTableRepository;
import de.assecutor.votianlt.repository.SystemInvoiceDispatchRepository;
import de.assecutor.votianlt.repository.UserRepository;
import de.assecutor.votianlt.service.CustomerInvoiceService;
import de.assecutor.votianlt.service.EmailService;
import de.assecutor.votianlt.service.InvoiceTemplateService;
import de.assecutor.votianlt.service.PdfToolService;
import de.assecutor.votianlt.service.SystemCompanyProfileService;
import de.assecutor.votianlt.util.DateTimeFormatUtil;
import jakarta.annotation.security.RolesAllowed;
import lombok.extern.slf4j.Slf4j;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.YearMonth;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Admin view to create the monthly system invoices for all current customers
 * (the users of the system). Invoices are always due at the end of the month;
 * customers starting mid-month are only billed pro rata for the remaining days
 * until the last day of the month.
 */
@Route(value = "admin-create-invoices", layout = AdminLayout.class)
@RolesAllowed("ADMIN")
@Slf4j
public class AdminCreateInvoicesView extends Main implements HasDynamicTitle {

    private static final BigDecimal SYSTEM_VAT_RATE = new BigDecimal("0.19");

    private final UserRepository userRepository;
    private final PriceTableRepository priceTableRepository;
    private final AppUserRepository appUserRepository;
    private final InvoiceTemplateService invoiceTemplateService;
    private final CustomerInvoiceService customerInvoiceService;
    private final SystemCompanyProfileService systemCompanyProfileService;
    private final EmailService emailService;
    private final SystemInvoiceDispatchRepository dispatchRepository;
    private final PdfToolService pdfToolService;

    private final Grid<BillingInfo> grid = new Grid<>();

    /** Billing month, selectable by the user; defaults to the current month. */
    private YearMonth billingMonth = YearMonth.now();

    /** Filter for the invoice list: all, only open or only sent invoices. */
    private enum InvoiceFilter {
        ALL, OPEN, SENT
    }

    private InvoiceFilter invoiceFilter = InvoiceFilter.ALL;

    /** Discount with reason per customer (user id), entered via the dialog. */
    private final Map<org.bson.types.ObjectId, DiscountEntry> discounts = new HashMap<>();

    /** A discount entered for one customer: percent value plus reason. */
    private record DiscountEntry(Double percent, String reason) {
    }

    public AdminCreateInvoicesView(UserRepository userRepository, PriceTableRepository priceTableRepository,
            AppUserRepository appUserRepository, InvoiceTemplateService invoiceTemplateService,
            CustomerInvoiceService customerInvoiceService, SystemCompanyProfileService systemCompanyProfileService,
            EmailService emailService, SystemInvoiceDispatchRepository dispatchRepository,
            PdfToolService pdfToolService) {
        this.userRepository = userRepository;
        this.priceTableRepository = priceTableRepository;
        this.appUserRepository = appUserRepository;
        this.invoiceTemplateService = invoiceTemplateService;
        this.customerInvoiceService = customerInvoiceService;
        this.systemCompanyProfileService = systemCompanyProfileService;
        this.emailService = emailService;
        this.dispatchRepository = dispatchRepository;
        this.pdfToolService = pdfToolService;

        setSizeFull();
        addClassNames(LumoUtility.BoxSizing.BORDER, LumoUtility.Display.FLEX, LumoUtility.FlexDirection.COLUMN,
                LumoUtility.Padding.MEDIUM, LumoUtility.Gap.SMALL);
        addClassName("data-view");

        Button createSelectedButton = new Button(getTranslation("admincreateinvoices.button.createselected", 0));
        createSelectedButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
        createSelectedButton.setEnabled(false);

        // Billing month selection: current month and up to 12 months back
        Select<YearMonth> monthSelect = new Select<>();
        monthSelect.setLabel(getTranslation("admincreateinvoices.field.month"));
        List<YearMonth> months = new ArrayList<>();
        for (int offset = 0; offset >= -12; offset--) {
            months.add(YearMonth.now().plusMonths(offset));
        }
        monthSelect.setItems(months);
        monthSelect.setItemLabelGenerator(
                month -> month.format(DateTimeFormatter.ofPattern("LLLL yyyy", getLocale())));
        monthSelect.setValue(billingMonth);

        // Filter: all invoices of the month, only open or only sent ones
        Select<InvoiceFilter> filterSelect = new Select<>();
        filterSelect.setLabel(getTranslation("admincreateinvoices.filter.label"));
        filterSelect.setItems(InvoiceFilter.values());
        filterSelect.setItemLabelGenerator(
                filter -> getTranslation("admincreateinvoices.filter." + filter.name().toLowerCase()));
        filterSelect.setValue(invoiceFilter);
        filterSelect.addValueChangeListener(event -> {
            if (event.getValue() != null) {
                invoiceFilter = event.getValue();
                grid.deselectAll();
                grid.setItems(loadBillingInfos());
            }
        });

        add(new ViewToolbar(getTranslation("admincreateinvoices.title")));

        // Month and filter selection left-aligned below the heading
        com.vaadin.flow.component.orderedlayout.HorizontalLayout selectionLayout = new com.vaadin.flow.component.orderedlayout.HorizontalLayout(
                monthSelect, filterSelect);
        selectionLayout.getStyle().set("align-self", "flex-start");
        add(selectionLayout);

        Paragraph hint = new Paragraph(getTranslation("admincreateinvoices.hint"));
        hint.addClassNames(LumoUtility.TextColor.SECONDARY, LumoUtility.FontSize.SMALL, LumoUtility.Margin.NONE);
        add(hint);

        grid.setItems(loadBillingInfos());
        grid.addColumn(info -> customerDisplayName(info.user()))
                .setHeader(getTranslation("admincreateinvoices.column.customer")).setFlexGrow(2).setSortable(true);
        grid.addColumn(info -> info.startDate() != null ? DateTimeFormatUtil.formatDate(info.startDate()) : "")
                .setHeader(getTranslation("admincreateinvoices.column.start")).setFlexGrow(1).setSortable(true);
        grid.addColumn(info -> info.billedDays() < info.daysInMonth()
                ? getTranslation("admincreateinvoices.days.partial", info.billedDays(), info.daysInMonth())
                : getTranslation("admincreateinvoices.days.full"))
                .setHeader(getTranslation("admincreateinvoices.column.days")).setFlexGrow(1);
        grid.addColumn(info -> DateTimeFormatUtil.formatDate(info.dueDate()))
                .setHeader(getTranslation("admincreateinvoices.column.due")).setFlexGrow(1);
        grid.addColumn(info -> formatAmount(applyDiscount(info).netTotal()) + " €")
                .setHeader(getTranslation("admincreateinvoices.column.net")).setFlexGrow(1)
                .setTextAlign(com.vaadin.flow.component.grid.ColumnTextAlign.END);
        grid.addComponentColumn(info -> {
            DiscountEntry entry = discounts.get(info.user().getId());
            Button discountButton = new Button(new Icon(VaadinIcon.TAG));
            discountButton.addThemeVariants(ButtonVariant.LUMO_SMALL, ButtonVariant.LUMO_ICON,
                    entry != null ? ButtonVariant.LUMO_PRIMARY : ButtonVariant.LUMO_TERTIARY);
            String tooltip = entry != null
                    ? getTranslation("admincreateinvoices.discount.position", formatPercent(entry.percent()))
                            + (entry.reason().isBlank() ? "" : " – " + entry.reason())
                    : getTranslation("admincreateinvoices.discount.dialog.title");
            discountButton.setTooltipText(tooltip);
            discountButton.setAriaLabel(getTranslation("admincreateinvoices.discount.dialog.title"));
            discountButton.addClickListener(e -> openDiscountDialog(info, grid));
            return discountButton;
        }).setHeader(getTranslation("admincreateinvoices.column.discount")).setAutoWidth(true).setFlexGrow(0);
        grid.addComponentColumn(info -> {
            Button createButton = new Button(new Icon(VaadinIcon.INVOICE));
            createButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY, ButtonVariant.LUMO_SMALL,
                    ButtonVariant.LUMO_ICON);
            createButton.setTooltipText(getTranslation("admincreateinvoices.button.create"));
            createButton.setAriaLabel(getTranslation("admincreateinvoices.button.create"));
            createButton.addClickListener(e -> createInvoicePdf(info));
            return createButton;
        }).setAutoWidth(true).setFlexGrow(0).setFrozenToEnd(true);
        grid.setSelectionMode(Grid.SelectionMode.MULTI);
        grid.addSelectionListener(event -> {
            int count = event.getAllSelectedItems().size();
            createSelectedButton.setText(getTranslation("admincreateinvoices.button.createselected", count));
            createSelectedButton.setEnabled(count > 0);
        });
        createSelectedButton.addClickListener(e -> openInvoiceReviewDialog(new ArrayList<>(grid.getSelectedItems())));
        monthSelect.addValueChangeListener(event -> {
            if (event.getValue() != null) {
                billingMonth = event.getValue();
                discounts.clear();
                grid.deselectAll();
                grid.setItems(loadBillingInfos());
            }
        });
        grid.setSizeFull();
        grid.addClassName("data-grid");

        Div gridPanel = new Div(grid);
        gridPanel.addClassNames("surface-panel", "data-grid-panel");
        gridPanel.setSizeFull();
        gridPanel.getStyle().set("min-width", "0");
        add(gridPanel);

        // Action bar below the table
        com.vaadin.flow.component.orderedlayout.HorizontalLayout actionLayout = new com.vaadin.flow.component.orderedlayout.HorizontalLayout(
                createSelectedButton);
        actionLayout.setWidthFull();
        actionLayout.setJustifyContentMode(
                com.vaadin.flow.component.orderedlayout.FlexComponent.JustifyContentMode.START);
        actionLayout.getStyle().set("flex-shrink", "0");
        add(actionLayout);
    }

    // ==========================================
    // Billing calculation
    // ==========================================

    /**
     * Billing data of one customer for the current month.
     */
    public record BillingInfo(User user, LocalDate startDate, int billedDays, int daysInMonth, LocalDate dueDate,
            List<Map<String, String>> positions, BigDecimal netTotal, BigDecimal vatTotal, BigDecimal grossTotal) {
    }

    private List<BillingInfo> loadBillingInfos() {
        PriceTable priceTable = priceTableRepository.findAll().stream().findFirst().orElse(null);
        List<BillingInfo> infos = userRepository.findAll().stream().filter(this::isCurrentCustomer)
                .filter(this::existedInBillingMonth).map(user -> computeBilling(user, priceTable)).toList();
        if (invoiceFilter == InvoiceFilter.ALL) {
            return infos;
        }
        java.util.Set<String> sentInvoiceNumbers = dispatchRepository.findByBillingMonth(billingMonth.toString())
                .stream().map(SystemInvoiceDispatch::getInvoiceNumber).collect(java.util.stream.Collectors.toSet());
        boolean wantSent = invoiceFilter == InvoiceFilter.SENT;
        return infos.stream()
                .filter(info -> sentInvoiceNumbers.contains(buildInvoiceNumber(info.user())) == wantSent).toList();
    }

    /**
     * Customers who only started after the billing month are not billed for it.
     */
    private boolean existedInBillingMonth(User user) {
        if (user.getCreatedAt() == null) {
            return true;
        }
        return !user.getCreatedAt().toLocalDate().isAfter(billingMonth.atEndOfMonth());
    }

    /**
     * Current customers are all activated users without the ADMIN role.
     */
    private boolean isCurrentCustomer(User user) {
        boolean isAdmin = user.getRoles() != null && user.getRoles().contains("ADMIN");
        return !isAdmin && user.getIsActivated() == 1;
    }

    private BillingInfo computeBilling(User user, PriceTable priceTable) {
        YearMonth month = billingMonth;
        int daysInMonth = month.lengthOfMonth();
        LocalDate dueDate = month.atEndOfMonth();

        LocalDate startDate = user.getCreatedAt() != null ? user.getCreatedAt().toLocalDate() : null;
        int billedDays = daysInMonth;
        if (startDate != null && YearMonth.from(startDate).equals(month)) {
            // Customer started mid-month: bill only the remaining days until
            // the last day of the month (start day included)
            billedDays = daysInMonth - startDate.getDayOfMonth() + 1;
        }

        BigDecimal factor = new BigDecimal(billedDays).divide(new BigDecimal(daysInMonth), 10, RoundingMode.HALF_UP);
        boolean proRata = billedDays < daysInMonth;

        // Number of app users belonging to this customer in app_user: the
        // per-app-user fee is billed once per configured app user
        long appUserCount = user.getId() != null ? appUserRepository.countByErstelltVon(user.getId()) : 0;

        List<Map<String, String>> positions = new ArrayList<>();
        BigDecimal netTotal = BigDecimal.ZERO;
        // Position entries: label, configured value, quantity
        Object[][] entries = {
                { getTranslation("adminpricetable.field.monthly"),
                        priceTable != null ? priceTable.getMonthlyBasePackage() : null, 1L },
                { getTranslation("adminpricetable.field.appuserfee"),
                        priceTable != null ? priceTable.getAppUserFee() : null, appUserCount } };

        for (Object[] entry : entries) {
            String label = (String) entry[0];
            String rawValue = entry[1] != null ? ((String) entry[1]).trim() : "";
            long quantity = (Long) entry[2];
            BigDecimal amount = parseAmount(rawValue);
            Map<String, String> position = new HashMap<>();
            if (amount != null) {
                BigDecimal billed = amount.multiply(new BigDecimal(quantity)).multiply(factor).setScale(2,
                        RoundingMode.HALF_UP);
                String name = label;
                if (quantity > 1) {
                    name += " " + getTranslation("admincreateinvoices.appusers.suffix", quantity);
                }
                if (proRata) {
                    name += " " + getTranslation("admincreateinvoices.prorata.suffix", billedDays, daysInMonth);
                }
                position.put("name", name);
                position.put("netAmount", formatAmount(billed));
                netTotal = netTotal.add(billed);
            } else {
                // Non-monetary values are shown as-is and are not part of the
                // totals
                position.put("name", label);
                position.put("netAmount", rawValue.isEmpty() ? "-" : rawValue);
            }
            positions.add(position);
        }

        BigDecimal vatTotal = netTotal.multiply(SYSTEM_VAT_RATE).setScale(2, RoundingMode.HALF_UP);
        BigDecimal grossTotal = netTotal.add(vatTotal);

        return new BillingInfo(user, startDate, billedDays, daysInMonth, dueDate, positions, netTotal, vatTotal,
                grossTotal);
    }

    /**
     * Opens the dialog to enter (or change) the discount and its reason for
     * one customer. An empty or zero discount removes the entry.
     */
    private void openDiscountDialog(BillingInfo info, Grid<BillingInfo> grid) {
        Dialog dialog = DialogStylingHelper
                .createStyledDialog(getTranslation("admincreateinvoices.discount.dialog.title"), "420px");

        DiscountEntry entry = discounts.get(info.user().getId());

        NumberField percentField = new NumberField(getTranslation("admincreateinvoices.discount.dialog.percent"));
        percentField.setMin(0);
        percentField.setMax(100);
        percentField.setSuffixComponent(new Span("%"));
        percentField.setWidthFull();
        if (entry != null) {
            percentField.setValue(entry.percent());
        }

        TextField reasonField = new TextField(getTranslation("admincreateinvoices.discount.dialog.reason"));
        reasonField.setWidthFull();
        if (entry != null) {
            reasonField.setValue(entry.reason());
        }

        VerticalLayout content = DialogStylingHelper.createContentLayout("380px");
        content.add(percentField, reasonField);

        Button cancelButton = new Button(getTranslation("button.cancel"), e -> dialog.close());
        cancelButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
        Button applyButton = new Button(getTranslation("admincreateinvoices.discount.dialog.apply"), e -> {
            Double percent = percentField.getValue();
            // Clamp manual input to the valid range 0-100
            if (percent != null && percent > 100) {
                percent = 100d;
            }
            if (percent != null && percent > 0) {
                discounts.put(info.user().getId(), new DiscountEntry(percent,
                        reasonField.getValue() != null ? reasonField.getValue().trim() : ""));
            } else {
                discounts.remove(info.user().getId());
            }
            dialog.close();
            grid.getDataProvider().refreshItem(info);
        });
        applyButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);

        dialog.add(DialogStylingHelper.wrapContent(content));
        dialog.getFooter().add(cancelButton, applyButton);
        dialog.open();
    }

    /**
     * Applies the discount entered for this customer: adds a negative
     * discount position (including the reason) and recalculates the totals.
     * Returns the info unchanged if no discount is set.
     */
    private BillingInfo applyDiscount(BillingInfo info) {
        DiscountEntry entry = discounts.get(info.user().getId());
        if (entry == null || entry.percent() == null || entry.percent() <= 0) {
            return info;
        }
        BigDecimal discountAmount = info.netTotal().multiply(BigDecimal.valueOf(entry.percent()))
                .divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP);
        List<Map<String, String>> positions = new ArrayList<>(info.positions());
        Map<String, String> position = new HashMap<>();
        String name = getTranslation("admincreateinvoices.discount.position", formatPercent(entry.percent()));
        if (!entry.reason().isBlank()) {
            name += " – " + entry.reason();
        }
        position.put("name", name);
        position.put("netAmount", "-" + formatAmount(discountAmount));
        positions.add(position);
        BigDecimal netTotal = info.netTotal().subtract(discountAmount);
        BigDecimal vatTotal = netTotal.multiply(SYSTEM_VAT_RATE).setScale(2, RoundingMode.HALF_UP);
        return new BillingInfo(info.user(), info.startDate(), info.billedDays(), info.daysInMonth(), info.dueDate(),
                positions, netTotal, vatTotal, netTotal.add(vatTotal));
    }

    // ==========================================
    // PDF generation
    // ==========================================

    private void createInvoicePdf(BillingInfo info) {
        String templateData = loadSystemTemplateData();
        if (templateData == null) {
            return;
        }
        info = applyDiscount(info);
        try {
            byte[] pdfBytes = customerInvoiceService.generatePdfFromCanvasTemplateWithData(templateData,
                    buildInvoiceVariables(info), null);
            showPdfInDialog(new InvoicePreview(info, buildInvoiceNumber(info.user()), pdfBytes));
        } catch (Exception ex) {
            log.error("Error creating system invoice for user {}: {}", info.user().getEmail(), ex.getMessage(), ex);
            Notification
                    .show(getTranslation("admincreateinvoices.notification.error", ex.getMessage()), 5000,
                            Notification.Position.BOTTOM_CENTER)
                    .addThemeVariants(NotificationVariant.LUMO_ERROR);
        }
    }

    /** One generated invoice held in the review dialog. */
    private record InvoicePreview(BillingInfo info, String invoiceNumber, byte[] pdf) {
    }

    /**
     * Opens the review dialog for the selected customers: one invoice per
     * page, navigable via previous/next, removable from the list and sendable
     * to the customers by email.
     */
    private void openInvoiceReviewDialog(List<BillingInfo> infos) {
        if (infos.isEmpty()) {
            return;
        }
        String templateData = loadSystemTemplateData();
        if (templateData == null) {
            return;
        }
        List<InvoicePreview> previews = new ArrayList<>();
        try {
            for (BillingInfo info : infos) {
                BillingInfo billed = applyDiscount(info);
                previews.add(new InvoicePreview(billed, buildInvoiceNumber(billed.user()), customerInvoiceService
                        .generatePdfFromCanvasTemplateWithData(templateData, buildInvoiceVariables(billed), null)));
            }
        } catch (Exception ex) {
            log.error("Error creating system invoices for {} users: {}", infos.size(), ex.getMessage(), ex);
            Notification
                    .show(getTranslation("admincreateinvoices.notification.error", ex.getMessage()), 5000,
                            Notification.Position.BOTTOM_CENTER)
                    .addThemeVariants(NotificationVariant.LUMO_ERROR);
            return;
        }

        Dialog dialog = DialogStylingHelper.createStyledDialog(getTranslation("admincreateinvoices.dialog.title"),
                "90vw");
        dialog.setHeight("90vh");

        Paragraph positionLabel = new Paragraph();
        positionLabel.addClassNames(LumoUtility.Margin.NONE, LumoUtility.FontWeight.SEMIBOLD,
                LumoUtility.Padding.SMALL);
        positionLabel.getStyle().set("flex-shrink", "0");

        IFrame pdfFrame = new IFrame();
        pdfFrame.setWidth("100%");
        pdfFrame.getStyle().set("flex", "1").set("min-height", "0").set("border", "none");

        Div content = new Div(positionLabel, pdfFrame);
        content.getStyle().set("display", "flex").set("flex-direction", "column");
        content.setWidth("100%");
        content.setHeight("100%");

        Button previousButton = new Button(getTranslation("admincreateinvoices.dialog.button.previous"));
        previousButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
        Button nextButton = new Button(getTranslation("admincreateinvoices.dialog.button.next"));
        nextButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
        Button deleteButton = new Button(getTranslation("admincreateinvoices.dialog.button.delete"));
        deleteButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY, ButtonVariant.LUMO_ERROR);
        Button zugferdButton = new Button(getTranslation("admincreateinvoices.button.zugferd"));
        zugferdButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);
        Button sendButton = new Button(getTranslation("admincreateinvoices.dialog.button.send"));
        sendButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);
        Button closeButton = new Button(getTranslation("button.close"), e -> dialog.close());
        closeButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);

        int[] index = { 0 };
        Runnable updateView = () -> {
            InvoicePreview current = previews.get(index[0]);
            positionLabel.setText(getTranslation("admincreateinvoices.dialog.position", index[0] + 1, previews.size())
                    + " – " + customerDisplayName(current.info().user()) + " (" + current.invoiceNumber() + ")");
            pdfFrame.getElement().setAttribute("src",
                    "data:application/pdf;base64," + Base64.getEncoder().encodeToString(current.pdf()));
            previousButton.setEnabled(index[0] > 0);
            nextButton.setEnabled(index[0] < previews.size() - 1);
        };
        previousButton.addClickListener(e -> {
            if (index[0] > 0) {
                index[0]--;
                updateView.run();
            }
        });
        nextButton.addClickListener(e -> {
            if (index[0] < previews.size() - 1) {
                index[0]++;
                updateView.run();
            }
        });
        deleteButton.addClickListener(e -> {
            previews.remove(index[0]);
            Notification.show(getTranslation("admincreateinvoices.dialog.notification.removed"), 3000,
                    Notification.Position.BOTTOM_CENTER);
            if (previews.isEmpty()) {
                dialog.close();
                return;
            }
            index[0] = Math.min(index[0], previews.size() - 1);
            updateView.run();
        });
        zugferdButton.addClickListener(e -> downloadZugferdZip(previews.get(index[0])));
        sendButton.addClickListener(e -> {
            sendButton.setEnabled(false);
            sendInvoices(previews);
            dialog.close();
        });
        updateView.run();

        dialog.add(DialogStylingHelper.wrapContent(content, true));
        dialog.getFooter().add(previousButton, nextButton, deleteButton, zugferdButton, sendButton, closeButton);
        dialog.open();
    }

    /**
     * Sends the invoices as signed ZUGFeRD e-invoice (ZIP) to the respective
     * customers. First all signed ZIPs are created and cached; only when every
     * ZIP could be created and passed the validation the mails are sent.
     * Afterwards a combined ZIP containing all individual invoice ZIPs is
     * offered as browser download.
     */
    private void sendInvoices(List<InvoicePreview> previews) {
        // Phase 1: create and cache the signed ZIP of every invoice; abort
        // without sending anything if a single one fails
        Map<InvoicePreview, PdfToolService.SignedInvoice> signedZips = new LinkedHashMap<>();
        for (InvoicePreview preview : previews) {
            try {
                signedZips.put(preview, pdfToolService.signInvoice(preview.pdf(), buildZugferdMetadata(preview)));
            } catch (Exception ex) {
                log.error("Error creating ZUGFeRD e-invoice {} for batch send: {}", preview.invoiceNumber(),
                        ex.getMessage(), ex);
                Notification
                        .show(getTranslation("admincreateinvoices.notification.zugferd.batcherror",
                                preview.invoiceNumber(), ex.getMessage()), 7000, Notification.Position.BOTTOM_CENTER)
                        .addThemeVariants(NotificationVariant.LUMO_ERROR);
                return;
            }
        }
        // Invoices failing the Mustang validation stop the whole batch: no
        // mail is sent as long as a single invoice is invalid
        String invalidNumbers = signedZips.entrySet().stream().filter(entry -> !entry.getValue().valid())
                .map(entry -> entry.getKey().invoiceNumber()).collect(java.util.stream.Collectors.joining(", "));
        if (!invalidNumbers.isEmpty()) {
            log.warn("Invoice batch send aborted, validation failed for: {}", invalidNumbers);
            Notification
                    .show(getTranslation("admincreateinvoices.notification.zugferd.invalidsend", invalidNumbers),
                            7000, Notification.Position.BOTTOM_CENTER)
                    .addThemeVariants(NotificationVariant.LUMO_ERROR);
            return;
        }

        String issuerName = "";
        try {
            issuerName = safe(systemCompanyProfileService.createSystemInvoiceData().getCompanyName());
        } catch (Exception ex) {
            log.warn("Could not load system company profile for invoice mail: {}", ex.getMessage());
        }
        String monthLabel = billingMonth.format(DateTimeFormatter.ofPattern("LLLL yyyy", getLocale()));

        // Phase 2: all ZIPs exist, send one mail per invoice with its ZIP
        int sent = 0;
        int failed = 0;
        for (Map.Entry<InvoicePreview, PdfToolService.SignedInvoice> entry : signedZips.entrySet()) {
            InvoicePreview preview = entry.getKey();
            PdfToolService.SignedInvoice signed = entry.getValue();
            String email = preview.info().user().getEmail();
            if (email == null || email.isBlank()) {
                log.warn("Customer {} has no email address, invoice {} not sent",
                        customerDisplayName(preview.info().user()), preview.invoiceNumber());
                failed++;
                continue;
            }
            try {
                emailService.sendEmailWithAttachment(email,
                        getTranslation("admincreateinvoices.mail.subject", preview.invoiceNumber()),
                        getTranslation("admincreateinvoices.mail.body", preview.invoiceNumber(), monthLabel,
                                issuerName),
                        signed.zip(), signed.fileName());
                recordDispatch(preview, email);
                sent++;
            } catch (Exception ex) {
                log.error("Error sending invoice {} to {}: {}", preview.invoiceNumber(), email, ex.getMessage(), ex);
                failed++;
            }
        }

        // Phase 3: bundle all individual invoice ZIPs and offer the download
        if (signedZips.size() > 1) {
            try {
                String archiveName = "rechnungen-" + billingMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))
                        + "-zugferd.zip";
                triggerZipDownload(buildCombinedZip(signedZips.values()), archiveName);
            } catch (Exception ex) {
                log.error("Error creating combined invoice ZIP: {}", ex.getMessage(), ex);
                Notification
                        .show(getTranslation("admincreateinvoices.notification.zip.combined.error", ex.getMessage()),
                                7000, Notification.Position.BOTTOM_CENTER)
                        .addThemeVariants(NotificationVariant.LUMO_ERROR);
            }
        }

        if (sent > 0) {
            grid.deselectAll();
            grid.setItems(loadBillingInfos());
            Notification
                    .show(getTranslation("admincreateinvoices.notification.sent", sent), 5000,
                            Notification.Position.BOTTOM_CENTER)
                    .addThemeVariants(NotificationVariant.LUMO_SUCCESS);
        }
        if (failed > 0) {
            Notification
                    .show(getTranslation("admincreateinvoices.notification.sendfailed", failed), 5000,
                            Notification.Position.BOTTOM_CENTER)
                    .addThemeVariants(NotificationVariant.LUMO_ERROR);
        }
    }

    /**
     * Persists (or refreshes) the dispatch record of a sent invoice so the
     * list can be filtered into open and sent invoices.
     */
    private void recordDispatch(InvoicePreview preview, String email) {
        try {
            SystemInvoiceDispatch dispatch = dispatchRepository.findByInvoiceNumber(preview.invoiceNumber())
                    .orElseGet(SystemInvoiceDispatch::new);
            dispatch.setInvoiceNumber(preview.invoiceNumber());
            dispatch.setUserId(preview.info().user().getId());
            dispatch.setBillingMonth(billingMonth.toString());
            dispatch.setSentAt(LocalDateTime.now());
            dispatch.setSentTo(email);
            dispatchRepository.save(dispatch);
        } catch (Exception ex) {
            log.error("Error recording dispatch of invoice {}: {}", preview.invoiceNumber(), ex.getMessage(), ex);
        }
    }

    /**
     * Returns the stored system template data, or null (with a notification)
     * if no template has been saved yet.
     */
    private String loadSystemTemplateData() {
        Optional<de.assecutor.votianlt.model.InvoiceTemplate> template = invoiceTemplateService
                .getTemplateByUserId(InvoiceGeneratorView.SYSTEM_TEMPLATE_USER_ID);
        if (template.isEmpty() || template.get().getTemplateData() == null
                || template.get().getTemplateData().isEmpty()) {
            Notification
                    .show(getTranslation("admincreateinvoices.notification.notemplate"), 5000,
                            Notification.Position.BOTTOM_CENTER)
                    .addThemeVariants(NotificationVariant.LUMO_ERROR);
            return null;
        }
        return template.get().getTemplateData();
    }

    private Map<String, String> buildInvoiceVariables(BillingInfo info) throws Exception {
        SystemInvoiceData issuer = systemCompanyProfileService.createSystemInvoiceData();
        User user = info.user();
        Map<String, String> variables = new LinkedHashMap<>();

        String companyFull = (safe(issuer.getCompanyName()) + " " + safe(issuer.getCompanySubtitle())).trim();
        variables.put("masterdata.company_name", companyFull);
        variables.put("masterdata.street", safe(issuer.getCompanyStreet()));
        variables.put("masterdata.city", safe(issuer.getCompanyCity()));
        variables.put("masterdata.phone", safe(issuer.getCompanyPhone()));
        variables.put("masterdata.email", safe(issuer.getCompanyEmail()));
        variables.put("masterdata.website", safe(issuer.getCompanyWebsite()));
        variables.put("masterdata.sender_line", safe(issuer.getSenderLine()));
        variables.put("masterdata.payment_terms", safe(issuer.getPaymentTerms()));
        variables.put("masterdata.footer", safe(issuer.getFooterText()).replace("<br>", "\n"));
        variables.put("masterdata.invoice_number", buildInvoiceNumber(user));
        variables.put("masterdata.invoice_date",
                LocalDate.now().format(DateTimeFormatter.ofPattern("dd.MM.yyyy")));

        // Recipient: the user's invoice address if a different one is set
        if (user.isDiffInvoiceAddress()) {
            variables.put("customer.company_name", firstNonBlank(user.getInvCompany(), user.getCompany()));
            variables.put("customer.contact_name",
                    (safe(user.getInvFirstname()) + " " + safe(user.getInvLastname())).trim());
            variables.put("customer.street",
                    (safe(user.getInvStreet()) + " " + safe(user.getInvHouseNumber())).trim());
            variables.put("customer.city", (safe(user.getInvZip()) + " " + safe(user.getInvCity())).trim());
        } else {
            variables.put("customer.company_name", safe(user.getCompany()));
            variables.put("customer.contact_name", (safe(user.getFirstname()) + " " + safe(user.getName())).trim());
            variables.put("customer.street", (safe(user.getStreet()) + " " + safe(user.getHouseNumber())).trim());
            variables.put("customer.city", (safe(user.getZip()) + " " + safe(user.getCity())).trim());
        }
        variables.put("customer.email", safe(user.getEmail()));
        variables.put("customer.phone", safe(user.getPhone()));

        // Positions and totals
        variables.put("services.json",
                new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(info.positions()));
        variables.put("services.count", String.valueOf(info.positions().size()));
        variables.put("invoice.net_total", formatAmount(info.netTotal()) + " €");
        variables.put("invoice.vat_total", formatAmount(info.vatTotal()) + " €");
        variables.put("invoice.gross_total", formatAmount(info.grossTotal()) + " €");
        variables.put("invoice.vat_rate",
                SYSTEM_VAT_RATE.multiply(new BigDecimal("100")).setScale(0, RoundingMode.HALF_UP) + "%");
        variables.put("invoice.due_date", DateTimeFormatUtil.formatDate(info.dueDate()));
        variables.put("services.net_total", formatAmount(info.netTotal()) + " €");
        variables.put("services.gross_total", formatAmount(info.grossTotal()) + " €");

        return variables;
    }

    private String buildInvoiceNumber(User user) {
        String monthPart = billingMonth.format(DateTimeFormatter.ofPattern("yyyyMM"));
        String userPart = user.getUsrId() > 0 ? String.valueOf(user.getUsrId())
                : (user.getId() != null ? user.getId().toHexString().substring(20) : "0");
        return "SYS-" + monthPart + "-" + userPart;
    }

    private void showPdfInDialog(InvoicePreview preview) {
        Dialog pdfDialog = DialogStylingHelper.createStyledDialog(getTranslation("invoicegenerator.pdf.preview.title"),
                "90vw");
        pdfDialog.setHeight("90vh");

        Div pdfContainer = new Div();
        pdfContainer.setWidth("100%");
        pdfContainer.setHeight("100%");

        String base64Pdf = Base64.getEncoder().encodeToString(preview.pdf());

        IFrame pdfFrame = new IFrame();
        pdfFrame.setWidth("100%");
        pdfFrame.setHeight("100%");
        pdfFrame.getElement().setAttribute("src", "data:application/pdf;base64," + base64Pdf);
        pdfContainer.add(pdfFrame);

        Button closeButton = new Button(getTranslation("button.close"), e -> pdfDialog.close());
        closeButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);

        Button zugferdButton = new Button(getTranslation("admincreateinvoices.button.zugferd"),
                e -> downloadZugferdZip(preview));
        zugferdButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY);

        Button sendButton = new Button(getTranslation("admincreateinvoices.pdf.button.send"), e -> {
            e.getSource().setEnabled(false);
            sendInvoices(List.of(preview));
            pdfDialog.close();
        });
        sendButton.addThemeVariants(ButtonVariant.LUMO_PRIMARY);

        pdfDialog.add(DialogStylingHelper.wrapContent(pdfContainer, true));
        pdfDialog.getFooter().add(zugferdButton, sendButton, closeButton);
        pdfDialog.open();
    }

    // ==========================================
    // ZUGFeRD e-invoice (external PDF Tool)
    // ==========================================

    /**
     * Converts the invoice via the external PDF Tool into a ZUGFeRD e-invoice
     * and offers the resulting ZIP (signed PDF/A-3, factur-x.xml, validation
     * report) as browser download.
     */
    private void downloadZugferdZip(InvoicePreview preview) {
        try {
            PdfToolService.SignedInvoice signed = pdfToolService.signInvoice(preview.pdf(),
                    buildZugferdMetadata(preview));
            triggerZipDownload(signed.zip(), signed.fileName());
            if (signed.valid()) {
                Notification
                        .show(getTranslation("admincreateinvoices.notification.zugferd.created",
                                preview.invoiceNumber()), 5000, Notification.Position.BOTTOM_CENTER)
                        .addThemeVariants(NotificationVariant.LUMO_SUCCESS);
            } else {
                Notification
                        .show(getTranslation("admincreateinvoices.notification.zugferd.invalid"), 7000,
                                Notification.Position.BOTTOM_CENTER)
                        .addThemeVariants(NotificationVariant.LUMO_WARNING);
            }
        } catch (Exception ex) {
            log.error("Error creating ZUGFeRD e-invoice {}: {}", preview.invoiceNumber(), ex.getMessage(), ex);
            Notification
                    .show(getTranslation("admincreateinvoices.notification.zugferd.error", ex.getMessage()), 7000,
                            Notification.Position.BOTTOM_CENTER)
                    .addThemeVariants(NotificationVariant.LUMO_ERROR);
        }
    }

    /**
     * Packs the individual invoice ZIPs into one combined ZIP so all sent
     * e-invoices can be archived with a single download.
     */
    private byte[] buildCombinedZip(java.util.Collection<PdfToolService.SignedInvoice> signedInvoices)
            throws java.io.IOException {
        java.io.ByteArrayOutputStream buffer = new java.io.ByteArrayOutputStream();
        try (java.util.zip.ZipOutputStream zipStream = new java.util.zip.ZipOutputStream(buffer)) {
            for (PdfToolService.SignedInvoice signed : signedInvoices) {
                zipStream.putNextEntry(new java.util.zip.ZipEntry(signed.fileName()));
                zipStream.write(signed.zip());
                zipStream.closeEntry();
            }
        }
        return buffer.toByteArray();
    }

    /** Offers the given ZIP bytes as browser download. */
    private void triggerZipDownload(byte[] zip, String fileName) {
        String base64Zip = Base64.getEncoder().encodeToString(zip);
        getElement().executeJs("const link = document.createElement('a');"
                + "link.href = 'data:application/zip;base64," + base64Zip + "';" + "link.download = '"
                + fileName.replace("'", "") + "';"
                + "document.body.appendChild(link); link.click(); document.body.removeChild(link);");
    }

    /**
     * Builds the EN16931 metadata for the PDF Tool from the billing data: the
     * system operator as sender, the customer as recipient and the billed
     * positions (including a possible negative discount position) as lines.
     */
    private PdfToolService.InvoiceMetadata buildZugferdMetadata(InvoicePreview preview) throws Exception {
        SystemInvoiceData issuer = systemCompanyProfileService.createSystemInvoiceData();
        BillingInfo info = preview.info();
        User user = info.user();

        String[] senderZipCity = splitZipCity(issuer.getCompanyCity());
        String senderName = (safe(issuer.getCompanyName()) + " " + safe(issuer.getCompanySubtitle())).trim();
        String footer = safe(issuer.getFooterText());
        PdfToolService.Party sender = new PdfToolService.Party(senderName, safe(issuer.getCompanyStreet()),
                senderZipCity[0], senderZipCity[1], "DE", extractByPattern(footer, "USt-IdNr\\.?:?\\s*([A-Z]{2}[0-9A-Za-z]{2,12})"),
                safe(issuer.getCompanyEmail()), safe(issuer.getCompanyPhone()));

        PdfToolService.Party recipient;
        if (user.isDiffInvoiceAddress()) {
            recipient = new PdfToolService.Party(
                    firstNonBlank(user.getInvCompany(), user.getCompany(),
                            (safe(user.getInvFirstname()) + " " + safe(user.getInvLastname())).trim()),
                    (safe(user.getInvStreet()) + " " + safe(user.getInvHouseNumber())).trim(), safe(user.getInvZip()),
                    safe(user.getInvCity()), "DE", null, safe(user.getEmail()), safe(user.getPhone()));
        } else {
            recipient = new PdfToolService.Party(
                    firstNonBlank(user.getCompany(), (safe(user.getFirstname()) + " " + safe(user.getName())).trim()),
                    (safe(user.getStreet()) + " " + safe(user.getHouseNumber())).trim(), safe(user.getZip()),
                    safe(user.getCity()), "DE", null, safe(user.getEmail()), safe(user.getPhone()));
        }

        BigDecimal vatPercent = SYSTEM_VAT_RATE.multiply(new BigDecimal("100")).setScale(0, RoundingMode.HALF_UP);
        List<PdfToolService.LineItem> items = new ArrayList<>();
        for (Map<String, String> position : info.positions()) {
            BigDecimal amount = parseSignedAmount(position.get("netAmount"));
            if (amount == null) {
                // Non-monetary positions cannot be part of an EN16931 invoice
                continue;
            }
            items.add(new PdfToolService.LineItem(position.get("name"), BigDecimal.ONE, amount, vatPercent));
        }
        if (items.isEmpty()) {
            throw new IllegalStateException(getTranslation("admincreateinvoices.notification.zugferd.nopositions"));
        }

        return new PdfToolService.InvoiceMetadata(preview.invoiceNumber(), LocalDate.now(),
                billingMonth.atEndOfMonth(), info.dueDate(), "EUR", safe(issuer.getPaymentTerms()), null,
                extractByPattern(footer, "IBAN\\s*:?\\s*([A-Z]{2}[0-9A-Za-z]{13,32})"),
                extractByPattern(footer, "BIC\\s*:?\\s*([A-Za-z0-9]{8,11})"), sender, recipient, items);
    }

    /**
     * Parses a formatted position amount like "99,00" or "-49,50" including
     * its sign. Returns null for non-monetary values such as "-" or "15 %".
     */
    private BigDecimal parseSignedAmount(String value) {
        if (value == null || value.isBlank() || value.contains("%")) {
            return null;
        }
        boolean negative = value.trim().startsWith("-");
        BigDecimal amount = parseAmount(value.trim().replaceFirst("^-", ""));
        if (amount == null) {
            return null;
        }
        return negative ? amount.negate() : amount;
    }

    /** Splits a combined "zip city" value like "21502 Geesthacht". */
    private String[] splitZipCity(String zipCity) {
        String trimmed = safe(zipCity).trim();
        int space = trimmed.indexOf(' ');
        if (space < 0) {
            return new String[] { trimmed, trimmed };
        }
        return new String[] { trimmed.substring(0, space), trimmed.substring(space + 1).trim() };
    }

    /** Returns the first regex group found in the text, or null. */
    private String extractByPattern(String text, String pattern) {
        Matcher matcher = Pattern.compile(pattern).matcher(text);
        return matcher.find() ? matcher.group(1) : null;
    }

    // ==========================================
    // Helpers
    // ==========================================

    private String customerDisplayName(User user) {
        String company = safe(user.getCompany()).trim();
        if (!company.isEmpty()) {
            return company;
        }
        return (safe(user.getFirstname()) + " " + safe(user.getName())).trim();
    }

    /**
     * Parse a price table value like "99,00 €" or "1.234,56" into an amount.
     * Returns null for non-monetary values such as "15 %".
     */
    private BigDecimal parseAmount(String value) {
        if (value == null || value.isBlank() || value.contains("%")) {
            return null;
        }
        Matcher matcher = Pattern.compile("([0-9]{1,3}(?:\\.[0-9]{3})*(?:,[0-9]+)?|[0-9]+(?:[.,][0-9]+)?)")
                .matcher(value);
        if (!matcher.find()) {
            return null;
        }
        String number = matcher.group(1);
        if (number.contains(",")) {
            number = number.replace(".", "").replace(',', '.');
        }
        try {
            return new BigDecimal(number);
        } catch (NumberFormatException ex) {
            return null;
        }
    }

    private String formatAmount(BigDecimal amount) {
        return amount.setScale(2, RoundingMode.HALF_UP).toString().replace(".", ",");
    }

    /** Formats a percent value without trailing zeros, e.g. 10 or 12,5. */
    private String formatPercent(Double percent) {
        return new BigDecimal(percent.toString()).stripTrailingZeros().toPlainString().replace('.', ',');
    }

    private String safe(String value) {
        return value != null ? value : "";
    }

    private String firstNonBlank(String... values) {
        for (String value : values) {
            if (value != null && !value.isBlank()) {
                return value;
            }
        }
        return "";
    }

    @Override
    public String getPageTitle() {
        return getTranslation("page.title.admin.createinvoices");
    }
}
