package com.sparrowwallet.sparrow.wallet;

import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.address.InvalidAddressException;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.CurrencyRate;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.control.*;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.net.ExchangeSource;
import com.sparrowwallet.sparrow.net.ElectrumServer;
import com.sparrowwallet.sparrow.net.FeeRatesSource;
import com.sparrowwallet.sparrow.net.MempoolRateSize;
import javafx.application.Platform;
import javafx.beans.property.*;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.ListChangeListener;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader;
import javafx.fxml.Initializable;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.layout.StackPane;
import javafx.util.StringConverter;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.validation.ValidationResult;
import org.controlsfx.validation.ValidationSupport;
import org.controlsfx.validation.Validator;
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import tornadofx.control.Field;

import java.io.IOException;
import java.net.URL;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;

public class SendController extends WalletFormController implements Initializable {
    private static final Logger log = LoggerFactory.getLogger(SendController.class);

    public static final List<Integer> TARGET_BLOCKS_RANGE = List.of(1, 2, 3, 4, 5, 10, 25, 50);
    public static final List<Long> FEE_RATES_RANGE = List.of(1L, 2L, 4L, 8L, 16L, 32L, 64L, 128L, 256L, 512L, 1024L);

    public static final double FALLBACK_FEE_RATE = 20000d / 1000;

    @FXML
    private TabPane paymentTabs;

    @FXML
    private ToggleGroup feeSelectionToggleGroup;

    @FXML
    private ToggleButton targetBlocksToggle;

    @FXML
    private ToggleButton mempoolSizeToggle;

    @FXML
    private Field targetBlocksField;

    @FXML
    private Slider targetBlocks;

    @FXML
    private Field feeRangeField;

    @FXML
    private Slider feeRange;

    @FXML
    private CopyableLabel feeRate;

    @FXML
    private Label feeRatePriority;

    @FXML
    private Glyph feeRatePriorityGlyph;

    @FXML
    private TextField fee;

    @FXML
    private ComboBox<BitcoinUnit> feeAmountUnit;

    @FXML
    private FiatLabel fiatFeeAmount;

    @FXML
    private BlockTargetFeeRatesChart blockTargetFeeRatesChart;

    @FXML
    private MempoolSizeFeeRatesChart mempoolSizeFeeRatesChart;

    @FXML
    private TransactionDiagram transactionDiagram;

    @FXML
    private Button clearButton;

    @FXML
    private Button createButton;

    private StackPane tabHeader;

    private final BooleanProperty userFeeSet = new SimpleBooleanProperty(false);

    private final ObjectProperty<UtxoSelector> utxoSelectorProperty = new SimpleObjectProperty<>(null);

    private final ObjectProperty<UtxoFilter> utxoFilterProperty = new SimpleObjectProperty<>(null);

    private final ObjectProperty<WalletTransaction> walletTransactionProperty = new SimpleObjectProperty<>(null);

    private final ObjectProperty<WalletTransaction> createdWalletTransactionProperty = new SimpleObjectProperty<>(null);

    private final BooleanProperty insufficientInputsProperty = new SimpleBooleanProperty(false);

    private final StringProperty utxoLabelSelectionProperty = new SimpleStringProperty("");

    private final BooleanProperty includeMempoolInputsProperty = new SimpleBooleanProperty(false);

    private final ChangeListener<String> feeListener = new ChangeListener<>() {
        @Override
        public void changed(ObservableValue<? extends String> observable, String oldValue, String newValue) {
            userFeeSet.set(true);
            if(newValue.isEmpty()) {
                fiatFeeAmount.setText("");
            } else {
                setFiatFeeAmount(AppServices.getFiatCurrencyExchangeRate(), getFeeValueSats());
            }

            setTargetBlocks(getTargetBlocks());
            updateTransaction();
        }
    };

    private final ChangeListener<Number> targetBlocksListener = new ChangeListener<>() {
        @Override
        public void changed(ObservableValue<? extends Number> observable, Number oldValue, Number newValue) {
            Map<Integer, Double> targetBlocksFeeRates = getTargetBlocksFeeRates();
            Integer target = getTargetBlocks();

            if(targetBlocksFeeRates != null) {
                setFeeRate(targetBlocksFeeRates.get(target));
                blockTargetFeeRatesChart.select(target);
            } else {
                feeRate.setText("Unknown");
            }

            Tooltip tooltip = new Tooltip("Target inclusion within " + target + " blocks");
            targetBlocks.setTooltip(tooltip);

            userFeeSet.set(false);
            for(Tab tab : paymentTabs.getTabs()) {
                PaymentController controller = (PaymentController)tab.getUserData();
                controller.revalidate();
            }
            updateTransaction();
        }
    };

    private final ChangeListener<Number> feeRangeListener = new ChangeListener<>() {
        @Override
        public void changed(ObservableValue<? extends Number> observable, Number oldValue, Number newValue) {
            setFeeRate(getFeeRangeRate());
            userFeeSet.set(false);
            for(Tab tab : paymentTabs.getTabs()) {
                PaymentController controller = (PaymentController)tab.getUserData();
                controller.revalidate();
            }
            updateTransaction();
        }
    };

    private ValidationSupport validationSupport;

    @Override
    public void initialize(URL location, ResourceBundle resources) {
        EventManager.get().register(this);
    }

    @Override
    public void initializeView() {
        addValidation();

        addPaymentTab();
        initializeTabHeader(0);

        paymentTabs.getTabs().addListener((ListChangeListener<Tab>) c -> {
            if(tabHeader != null) {
                tabHeader.setVisible(c.getList().size() > 1);
            }

            if(c.getList().size() > 1) {
                if(!paymentTabs.getStyleClass().contains("multiple-tabs")) {
                    paymentTabs.getStyleClass().add("multiple-tabs");
                }
                paymentTabs.getTabs().forEach(tab -> tab.setClosable(true));
            } else {
                paymentTabs.getStyleClass().remove("multiple-tabs");
                Tab remainingTab = paymentTabs.getTabs().get(0);
                remainingTab.setClosable(false);
                remainingTab.setText("1");
            }

            updateTransaction();
        });

        insufficientInputsProperty.addListener((observable, oldValue, newValue) -> {
            for(Tab tab : paymentTabs.getTabs()) {
                PaymentController controller = (PaymentController)tab.getUserData();
                controller.revalidate();
            }
            revalidate(fee, feeListener);
        });

        targetBlocksField.managedProperty().bind(targetBlocksField.visibleProperty());
        targetBlocks.setMin(0);
        targetBlocks.setMax(TARGET_BLOCKS_RANGE.size() - 1);
        targetBlocks.setMajorTickUnit(1);
        targetBlocks.setMinorTickCount(0);
        targetBlocks.setLabelFormatter(new StringConverter<Double>() {
            @Override
            public String toString(Double object) {
                String blocks = Integer.toString(TARGET_BLOCKS_RANGE.get(object.intValue()));
                return (object.intValue() == TARGET_BLOCKS_RANGE.size() - 1) ? blocks + "+" : blocks;
            }

            @Override
            public Double fromString(String string) {
                return (double)TARGET_BLOCKS_RANGE.indexOf(Integer.valueOf(string.replace("+", "")));
            }
        });
        targetBlocks.valueProperty().addListener(targetBlocksListener);

        feeRangeField.managedProperty().bind(feeRangeField.visibleProperty());
        feeRangeField.visibleProperty().bind(targetBlocksField.visibleProperty().not());
        feeRange.setMin(0);
        feeRange.setMax(FEE_RATES_RANGE.size() - 1);
        feeRange.setMajorTickUnit(1);
        feeRange.setMinorTickCount(0);
        feeRange.setLabelFormatter(new StringConverter<Double>() {
            @Override
            public String toString(Double object) {
                return Long.toString(FEE_RATES_RANGE.get(object.intValue()));
            }

            @Override
            public Double fromString(String string) {
                return null;
            }
        });
        feeRange.valueProperty().addListener(feeRangeListener);

        blockTargetFeeRatesChart.managedProperty().bind(blockTargetFeeRatesChart.visibleProperty());
        blockTargetFeeRatesChart.initialize();
        Map<Integer, Double> targetBlocksFeeRates = getTargetBlocksFeeRates();
        if(targetBlocksFeeRates != null) {
            blockTargetFeeRatesChart.update(targetBlocksFeeRates);
        } else {
            feeRate.setText("Unknown");
        }

        mempoolSizeFeeRatesChart.managedProperty().bind(mempoolSizeFeeRatesChart.visibleProperty());
        mempoolSizeFeeRatesChart.visibleProperty().bind(blockTargetFeeRatesChart.visibleProperty().not());
        mempoolSizeFeeRatesChart.initialize();
        Map<Date, Set<MempoolRateSize>> mempoolHistogram = getMempoolHistogram();
        if(mempoolHistogram != null) {
            mempoolSizeFeeRatesChart.update(mempoolHistogram);
        }

        FeeRatesSelection feeRatesSelection = Config.get().getFeeRatesSelection();
        feeRatesSelection = (feeRatesSelection == null ? FeeRatesSelection.MEMPOOL_SIZE : feeRatesSelection);
        setDefaultFeeRate();
        updateFeeRateSelection(feeRatesSelection);
        feeSelectionToggleGroup.selectToggle(feeRatesSelection == FeeRatesSelection.BLOCK_TARGET ? targetBlocksToggle : mempoolSizeToggle);
        feeSelectionToggleGroup.selectedToggleProperty().addListener((observable, oldValue, newValue) -> {
            FeeRatesSelection newFeeRatesSelection = (FeeRatesSelection)newValue.getUserData();
            Config.get().setFeeRatesSelection(newFeeRatesSelection);
            EventManager.get().post(new FeeRatesSelectionChangedEvent(getWalletForm().getWallet(), newFeeRatesSelection));
        });

        fee.setTextFormatter(new CoinTextFormatter());
        fee.textProperty().addListener(feeListener);

        BitcoinUnit unit = getBitcoinUnit(Config.get().getBitcoinUnit());
        feeAmountUnit.getSelectionModel().select(BitcoinUnit.BTC.equals(unit) ? 0 : 1);
        feeAmountUnit.valueProperty().addListener((observable, oldValue, newValue) -> {
            Long value = getFeeValueSats(oldValue);
            if(value != null) {
                setFeeValueSats(value);
            }
        });

        userFeeSet.addListener((observable, oldValue, newValue) -> {
            blockTargetFeeRatesChart.select(0);

            Node thumb = getSliderThumb();
            if(thumb != null) {
                if(newValue) {
                    thumb.getStyleClass().add("inactive");
                } else {
                    thumb.getStyleClass().remove("inactive");
                }
            }
        });

        utxoLabelSelectionProperty.addListener((observable, oldValue, newValue) -> {
            clearButton.setText("Clear" + newValue);
        });

        utxoSelectorProperty.addListener((observable, oldValue, utxoSelector) -> {
            updateMaxClearButtons(utxoSelector, utxoFilterProperty.get());
        });

        utxoFilterProperty.addListener((observable, oldValue, utxoFilter) -> {
            updateMaxClearButtons(utxoSelectorProperty.get(), utxoFilter);
        });

        walletTransactionProperty.addListener((observable, oldValue, walletTransaction) -> {
            if(walletTransaction != null) {
                setPayments(walletTransaction.getPayments());

                double feeRate = walletTransaction.getFeeRate();
                if(userFeeSet.get()) {
                    setTargetBlocks(getTargetBlocks(feeRate));
                    setFeeRangeRate(feeRate);

                    if(walletTransaction.getFee() != getFeeValueSats()) {
                        setFeeValueSats(walletTransaction.getFee());
                    }
                } else {
                    setFeeValueSats(walletTransaction.getFee());
                }

                setFeeRate(feeRate);
            }

            transactionDiagram.update(walletTransaction);
            createButton.setDisable(walletTransaction == null || isInsufficientFeeRate());
        });

        transactionDiagram.sceneProperty().addListener((observable, oldScene, newScene) -> {
            if(oldScene == null && newScene != null) {
                transactionDiagram.update(null);
                newScene.getWindow().heightProperty().addListener((observable1, oldValue, newValue) -> {
                    transactionDiagram.update(walletTransactionProperty.get());
                });
            }
        });

        addFeeRangeTrackHighlight(0);
    }

    private void initializeTabHeader(int count) {
        final int lookupCount = count;
        Platform.runLater(() -> {
            StackPane stackPane = (StackPane)paymentTabs.lookup(".tab-header-area");
            if(stackPane != null) {
                tabHeader = stackPane;
                tabHeader.managedProperty().bind(tabHeader.visibleProperty());
                tabHeader.setVisible(false);
                paymentTabs.getStyleClass().remove("initial");
            } else if(lookupCount < 20) {
                initializeTabHeader(lookupCount+1);
            }
        });
    }

    public BitcoinUnit getBitcoinUnit(BitcoinUnit bitcoinUnit) {
        BitcoinUnit unit = bitcoinUnit;
        if(unit == null || unit.equals(BitcoinUnit.AUTO)) {
            unit = getWalletForm().getWallet().getAutoUnit();
        }
        return unit;
    }

    public ValidationSupport getValidationSupport() {
        return validationSupport;
    }

    private void addValidation() {
        validationSupport = new ValidationSupport();
        validationSupport.registerValidator(fee, Validator.combine(
                (Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Insufficient Inputs", userFeeSet.get() && insufficientInputsProperty.get()),
                (Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Insufficient Fee", getFeeValueSats() != null && getFeeValueSats() == 0),
                (Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Insufficient Fee Rate", isInsufficientFeeRate())
        ));

        validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
        validationSupport.setErrorDecorationEnabled(false);
    }

    public Tab addPaymentTab() {
        Tab tab = getPaymentTab();
        paymentTabs.getTabs().add(tab);
        paymentTabs.getSelectionModel().select(tab);
        return tab;
    }

    public Tab getPaymentTab() {
        OptionalInt highestTabNo = paymentTabs.getTabs().stream().mapToInt(tab -> Integer.parseInt(tab.getText())).max();
        Tab tab = new Tab(Integer.toString(highestTabNo.isPresent() ? highestTabNo.getAsInt() + 1 : 1));

        try {
            FXMLLoader paymentLoader = new FXMLLoader(AppServices.class.getResource("wallet/payment.fxml"));
            tab.setContent(paymentLoader.load());
            PaymentController controller = paymentLoader.getController();
            controller.setSendController(this);
            controller.initializeView();
            tab.setUserData(controller);
            return tab;
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    public List<Payment> getPayments() {
        List<Payment> payments = new ArrayList<>();
        for(Tab tab : paymentTabs.getTabs()) {
            PaymentController controller = (PaymentController)tab.getUserData();
            payments.add(controller.getPayment());
        }

        return payments;
    }

    public void setPayments(List<Payment> payments) {
        while(paymentTabs.getTabs().size() < payments.size()) {
            addPaymentTab();
        }

        while(paymentTabs.getTabs().size() > payments.size()) {
            paymentTabs.getTabs().remove(paymentTabs.getTabs().size() - 1);
        }

        for(int i = 0; i < paymentTabs.getTabs().size(); i++) {
            Payment payment = payments.get(i);
            PaymentController controller = (PaymentController)paymentTabs.getTabs().get(i).getUserData();
            controller.setPayment(payment);
        }
    }

    public void updateTransaction() {
        updateTransaction(null);
    }

    public void updateTransaction(boolean sendAll) {
        try {
            if(paymentTabs.getTabs().size() == 1) {
                PaymentController controller = (PaymentController)paymentTabs.getTabs().get(0).getUserData();
                controller.setSendMax(sendAll);
                updateTransaction(List.of(controller.getPayment()));
            } else {
                updateTransaction(null);
            }
        } catch(IllegalStateException e) {
            //ignore
        }
    }

    public void updateTransaction(List<Payment> transactionPayments) {
        try {
            List<Payment> payments = transactionPayments != null ? transactionPayments : getPayments();
            if(!userFeeSet.get() || (getFeeValueSats() != null && getFeeValueSats() > 0)) {
                Wallet wallet = getWalletForm().getWallet();
                Long userFee = userFeeSet.get() ? getFeeValueSats() : null;
                Integer currentBlockHeight = AppServices.getCurrentBlockHeight();
                boolean groupByAddress = Config.get().isGroupByAddress();
                boolean includeMempoolChange = Config.get().isIncludeMempoolChange();
                boolean includeMempoolInputs = includeMempoolInputsProperty.get();
                WalletTransaction walletTransaction = wallet.createWalletTransaction(getUtxoSelectors(), getUtxoFilters(), payments, getFeeRate(), getMinimumFeeRate(), userFee, currentBlockHeight, groupByAddress, includeMempoolChange, includeMempoolInputs);
                walletTransactionProperty.setValue(walletTransaction);
                insufficientInputsProperty.set(false);

                return;
            }
        } catch(InvalidAddressException | IllegalStateException e) {
            //ignore
        } catch(InsufficientFundsException e) {
            insufficientInputsProperty.set(true);
        }

        walletTransactionProperty.setValue(null);
    }

    private List<UtxoSelector> getUtxoSelectors() throws InvalidAddressException {
        if(utxoSelectorProperty.get() != null) {
            return List.of(utxoSelectorProperty.get());
        }

        Wallet wallet = getWalletForm().getWallet();
        long noInputsFee = wallet.getNoInputsFee(getPayments(), getFeeRate());
        long costOfChange = wallet.getCostOfChange(getFeeRate(), getMinimumFeeRate());

        return List.of(new BnBUtxoSelector(noInputsFee, costOfChange), new KnapsackUtxoSelector(noInputsFee));
    }

    private List<UtxoFilter> getUtxoFilters() {
        UtxoFilter utxoFilter = utxoFilterProperty.get();
        if(utxoFilter != null) {
            return List.of(utxoFilter, new FrozenUtxoFilter());
        }

        return List.of(new FrozenUtxoFilter());
    }

    private void updateFeeRateSelection(FeeRatesSelection feeRatesSelection) {
        boolean blockTargetSelection = (feeRatesSelection == FeeRatesSelection.BLOCK_TARGET);
        targetBlocksField.setVisible(blockTargetSelection);
        blockTargetFeeRatesChart.setVisible(blockTargetSelection);
        if(blockTargetSelection) {
            setTargetBlocks(getTargetBlocks(getFeeRangeRate()));
        } else {
            setFeeRangeRate(getTargetBlocksFeeRates().get(getTargetBlocks()));
        }
        updateTransaction();
    }

    private void setDefaultFeeRate() {
        int defaultTarget = TARGET_BLOCKS_RANGE.get((TARGET_BLOCKS_RANGE.size() / 2) - 1);
        int index = TARGET_BLOCKS_RANGE.indexOf(defaultTarget);
        Double defaultRate = getTargetBlocksFeeRates().get(defaultTarget);
        targetBlocks.setValue(index);
        blockTargetFeeRatesChart.select(defaultTarget);
        setFeeRangeRate(defaultRate);
        setFeeRate(getFeeRangeRate());
    }

    private Long getFeeValueSats() {
        return getFeeValueSats(feeAmountUnit.getSelectionModel().getSelectedItem());
    }

    private Long getFeeValueSats(BitcoinUnit bitcoinUnit) {
        if(fee.getText() != null && !fee.getText().isEmpty()) {
            double fieldValue = Double.parseDouble(fee.getText().replaceAll(",", ""));
            return bitcoinUnit.getSatsValue(fieldValue);
        }

        return null;
    }

    private void setFeeValueSats(long feeValue) {
        fee.textProperty().removeListener(feeListener);
        DecimalFormat df = new DecimalFormat("#.#", DecimalFormatSymbols.getInstance(Locale.ENGLISH));
        df.setMaximumFractionDigits(8);
        fee.setText(df.format(feeAmountUnit.getValue().getValue(feeValue)));
        fee.textProperty().addListener(feeListener);
        setFiatFeeAmount(AppServices.getFiatCurrencyExchangeRate(), feeValue);
    }

    private Integer getTargetBlocks() {
        int index = (int)targetBlocks.getValue();
        return TARGET_BLOCKS_RANGE.get(index);
    }

    private Integer getTargetBlocks(double feeRate) {
        Map<Integer, Double> targetBlocksFeeRates = getTargetBlocksFeeRates();
        int maxTargetBlocks = 1;
        for(Integer targetBlocks : targetBlocksFeeRates.keySet()) {
            if(TARGET_BLOCKS_RANGE.contains(targetBlocks)) {
                maxTargetBlocks = Math.max(maxTargetBlocks, targetBlocks);
                Double candidate = targetBlocksFeeRates.get(targetBlocks);
                if(Math.round(feeRate) >= Math.round(candidate)) {
                    return targetBlocks;
                }
            }
        }

        return maxTargetBlocks;
    }

    private void setTargetBlocks(Integer target) {
        targetBlocks.valueProperty().removeListener(targetBlocksListener);
        int index = TARGET_BLOCKS_RANGE.indexOf(target);
        targetBlocks.setValue(index);
        blockTargetFeeRatesChart.select(target);
        Tooltip tooltip = new Tooltip("Target inclusion within " + target + " blocks");
        targetBlocks.setTooltip(tooltip);
        targetBlocks.valueProperty().addListener(targetBlocksListener);
    }

    private Map<Integer, Double> getTargetBlocksFeeRates() {
        Map<Integer, Double> retrievedFeeRates = AppServices.getTargetBlockFeeRates();
        if(retrievedFeeRates == null) {
            retrievedFeeRates = TARGET_BLOCKS_RANGE.stream().collect(Collectors.toMap(java.util.function.Function.identity(), v -> FALLBACK_FEE_RATE,
                    (u, v) -> { throw new IllegalStateException("Duplicate target blocks"); },
                    LinkedHashMap::new));
        }

        return retrievedFeeRates;
    }

    private Double getFeeRangeRate() {
        return Math.pow(2.0, feeRange.getValue());
    }

    private void setFeeRangeRate(Double feeRate) {
        feeRange.valueProperty().removeListener(feeRangeListener);
        feeRange.setValue(Math.log(feeRate) / Math.log(2));
        feeRange.valueProperty().addListener(feeRangeListener);
    }

    public Double getFeeRate() {
        if(targetBlocksField.isVisible()) {
            return getTargetBlocksFeeRates().get(getTargetBlocks());
        } else {
            return getFeeRangeRate();
        }
    }

    private Double getMinimumFeeRate() {
        Optional<Double> optMinFeeRate = getTargetBlocksFeeRates().values().stream().min(Double::compareTo);
        Double minRate = optMinFeeRate.orElse(FALLBACK_FEE_RATE);
        return Math.max(minRate, Transaction.DUST_RELAY_TX_FEE);
    }

    private Map<Date, Set<MempoolRateSize>> getMempoolHistogram() {
        return AppServices.getMempoolHistogram();
    }

    public boolean isInsufficientFeeRate() {
        return walletTransactionProperty.get() != null && walletTransactionProperty.get().getFeeRate() < AppServices.getMinimumRelayFeeRate();
    }

    private void setFeeRate(Double feeRateAmt) {
        feeRate.setText(String.format("%.2f", feeRateAmt) + " sats/vByte");
        setFeeRatePriority(feeRateAmt);
    }

    private void setFeeRatePriority(Double feeRateAmt) {
        Map<Integer, Double> targetBlocksFeeRates = getTargetBlocksFeeRates();
        Integer targetBlocks = getTargetBlocks(feeRateAmt);
        if(targetBlocksFeeRates.get(Integer.MAX_VALUE) != null) {
            Double minFeeRate = targetBlocksFeeRates.get(Integer.MAX_VALUE);
            if(minFeeRate > 1.0 && feeRateAmt < minFeeRate) {
                feeRatePriority.setText("Below Minimum");
                feeRatePriority.setTooltip(new Tooltip("Transactions at this fee rate are currently being purged from the default sized mempool"));
                feeRatePriorityGlyph.setStyle("-fx-text-fill: #a0a1a7cc");
                feeRatePriorityGlyph.setIcon(FontAwesome5.Glyph.EXCLAMATION_CIRCLE);
                return;
            }

            Double lowestBlocksRate = targetBlocksFeeRates.get(TARGET_BLOCKS_RANGE.get(TARGET_BLOCKS_RANGE.size() - 1));
            if(lowestBlocksRate >= minFeeRate && feeRateAmt < (minFeeRate + ((lowestBlocksRate - minFeeRate) / 2)) && !isPayjoinTx()) {
                feeRatePriority.setText("Try Then Replace");
                feeRatePriority.setTooltip(new Tooltip("Send a transaction, verify it appears in the destination wallet, then RBF to get it confirmed or sent to another address"));
                feeRatePriorityGlyph.setStyle("-fx-text-fill: #7eb7c9cc");
                feeRatePriorityGlyph.setIcon(FontAwesome5.Glyph.PLUS_CIRCLE);
                return;
            }
        }

        if(targetBlocks != null) {
            if(targetBlocks < FeeRatesSource.BLOCKS_IN_HALF_HOUR) {
                Double maxFeeRate = FEE_RATES_RANGE.get(FEE_RATES_RANGE.size() - 1).doubleValue();
                Double highestBlocksRate = targetBlocksFeeRates.get(TARGET_BLOCKS_RANGE.get(0));
                if(highestBlocksRate < maxFeeRate && feeRateAmt > (highestBlocksRate + ((maxFeeRate - highestBlocksRate) / 10))) {
                    feeRatePriority.setText("Overpaid");
                    feeRatePriority.setTooltip(new Tooltip("Transaction fees at this rate are likely higher than necessary"));
                    feeRatePriorityGlyph.setStyle("-fx-text-fill: #c8416499");
                    feeRatePriorityGlyph.setIcon(FontAwesome5.Glyph.EXCLAMATION_CIRCLE);
                } else {
                    feeRatePriority.setText("High Priority");
                    feeRatePriority.setTooltip(new Tooltip("Typically confirms within minutes"));
                    feeRatePriorityGlyph.setStyle("-fx-text-fill: #c8416499");
                    feeRatePriorityGlyph.setIcon(FontAwesome5.Glyph.CIRCLE);
                }
            } else if(targetBlocks < FeeRatesSource.BLOCKS_IN_HOUR) {
                feeRatePriority.setText("Medium Priority");
                feeRatePriority.setTooltip(new Tooltip("Typically confirms within an hour or two"));
                feeRatePriorityGlyph.setStyle("-fx-text-fill: #fba71b99");
                feeRatePriorityGlyph.setIcon(FontAwesome5.Glyph.CIRCLE);
            } else {
                feeRatePriority.setText("Low Priority");
                feeRatePriority.setTooltip(new Tooltip("Typically confirms in a day or longer"));
                feeRatePriorityGlyph.setStyle("-fx-text-fill: #41a9c999");
                feeRatePriorityGlyph.setIcon(FontAwesome5.Glyph.CIRCLE);
            }
        }
    }

    private boolean isPayjoinTx() {
        if(walletTransactionProperty.get() != null) {
            return walletTransactionProperty.get().getPayments().stream().anyMatch(payment -> AppServices.getPayjoinURI(payment.getAddress()) != null);
        }

        return false;
    }

    private Node getSliderThumb() {
        return targetBlocks.lookup(".thumb");
    }

    private void setFiatFeeAmount(CurrencyRate currencyRate, Long amount) {
        if(amount != null && currencyRate != null && currencyRate.isAvailable()) {
            fiatFeeAmount.set(currencyRate, amount);
        }
    }

    private void addFeeRangeTrackHighlight(int count) {
        Platform.runLater(() -> {
            Node track = feeRange.lookup(".track");
            if(track != null) {
                Map<Integer, Double> targetBlocksFeeRates = getTargetBlocksFeeRates();
                String highlight = "";
                if(targetBlocksFeeRates.get(Integer.MAX_VALUE) != null) {
                    highlight += "#a0a1a766 " + getPercentageOfFeeRange(targetBlocksFeeRates.get(Integer.MAX_VALUE)) + "%, ";
                }
                highlight += "#41a9c966 " + getPercentageOfFeeRange(targetBlocksFeeRates, FeeRatesSource.BLOCKS_IN_TWO_HOURS - 1) + "%, ";
                highlight += "#fba71b66 " + getPercentageOfFeeRange(targetBlocksFeeRates, FeeRatesSource.BLOCKS_IN_HOUR - 1) + "%, ";
                highlight += "#c8416466 " + getPercentageOfFeeRange(targetBlocksFeeRates, FeeRatesSource.BLOCKS_IN_HALF_HOUR - 1) + "%";

                track.setStyle("-fx-background-color: " +
                        "-fx-shadow-highlight-color, " +
                        "linear-gradient(to bottom, derive(-fx-text-box-border, -10%), -fx-text-box-border), " +
                        "linear-gradient(to bottom, derive(-fx-control-inner-background, -9%), derive(-fx-control-inner-background, 0%), derive(-fx-control-inner-background, -5%), derive(-fx-control-inner-background, -12%)), " +
                        "linear-gradient(to right, " + highlight + ")");
            } else if(count < 20) {
                addFeeRangeTrackHighlight(count+1);
            }
        });
    }

    private int getPercentageOfFeeRange(Map<Integer, Double> targetBlocksFeeRates, Integer minTargetBlocks) {
        List<Integer> rates = new ArrayList<>(targetBlocksFeeRates.keySet());
        Collections.reverse(rates);
        for(Integer targetBlocks : rates) {
            if(targetBlocks < minTargetBlocks) {
                return getPercentageOfFeeRange(targetBlocksFeeRates.get(targetBlocks));
            }
        }

        return 100;
    }

    private int getPercentageOfFeeRange(Double feeRate) {
        double index = Math.log(feeRate) / Math.log(2);
        return (int)Math.round(index * 10.0);
    }

    private void updateMaxClearButtons(UtxoSelector utxoSelector, UtxoFilter utxoFilter) {
        if(utxoSelector instanceof PresetUtxoSelector) {
            PresetUtxoSelector presetUtxoSelector = (PresetUtxoSelector)utxoSelector;
            int num = presetUtxoSelector.getPresetUtxos().size();
            String selection = " (" + num + " UTXO" + (num != 1 ? "s" : "") + " selected)";
            utxoLabelSelectionProperty.set(selection);
        } else if(utxoFilter instanceof ExcludeUtxoFilter) {
            ExcludeUtxoFilter excludeUtxoFilter = (ExcludeUtxoFilter)utxoFilter;
            int num = excludeUtxoFilter.getExcludedUtxos().size();
            String exclusion = " (" + num + " UTXO" + (num != 1 ? "s" : "") + " excluded)";
            utxoLabelSelectionProperty.set(exclusion);
        } else {
            utxoLabelSelectionProperty.set("");
        }
    }

    public void clear(ActionEvent event) {
        boolean firstTab = true;
        for(Iterator<Tab> iterator = paymentTabs.getTabs().iterator(); iterator.hasNext(); ) {
            PaymentController controller = (PaymentController)iterator.next().getUserData();
            if(firstTab) {
                controller.clear();
                firstTab = false;
            } else {
                EventManager.get().unregister(controller);
                iterator.remove();
            }
        }

        fee.textProperty().removeListener(feeListener);
        fee.setText("");
        fee.textProperty().addListener(feeListener);

        fiatFeeAmount.setText("");

        userFeeSet.set(false);
        setDefaultFeeRate();
        utxoSelectorProperty.setValue(null);
        utxoFilterProperty.setValue(null);
        includeMempoolInputsProperty.set(false);
        walletTransactionProperty.setValue(null);
        createdWalletTransactionProperty.set(null);

        validationSupport.setErrorDecorationEnabled(false);
    }

    public UtxoSelector getUtxoSelector() {
        return utxoSelectorProperty.get();
    }

    public ObjectProperty<UtxoSelector> utxoSelectorProperty() {
        return utxoSelectorProperty;
    }

    public boolean isInsufficientInputs() {
        return insufficientInputsProperty.get();
    }

    public BooleanProperty insufficientInputsProperty() {
        return insufficientInputsProperty;
    }

    public WalletTransaction getWalletTransaction() {
        return walletTransactionProperty.get();
    }

    public ObjectProperty<WalletTransaction> walletTransactionProperty() {
        return walletTransactionProperty;
    }

    public String getUtxoLabelSelection() {
        return utxoLabelSelectionProperty.get();
    }

    public StringProperty utxoLabelSelectionProperty() {
        return utxoLabelSelectionProperty;
    }

    public TabPane getPaymentTabs() {
        return paymentTabs;
    }

    public Button getCreateButton() {
        return createButton;
    }

    private void revalidate(TextField field, ChangeListener<String> listener) {
        field.textProperty().removeListener(listener);
        String amt = field.getText();
        field.setText(amt + "0");
        field.setText(amt);
        field.textProperty().addListener(listener);
    }

    public void createTransaction(ActionEvent event) {
        if(log.isDebugEnabled()) {
            Map<WalletNode, List<String>> inputHashes = new LinkedHashMap<>();
            for(WalletNode node : walletTransactionProperty.get().getSelectedUtxos().values()) {
                List<String> nodeHashes = inputHashes.computeIfAbsent(node, k -> new ArrayList<>());
                nodeHashes.add(ElectrumServer.getScriptHash(walletForm.getWallet(), node));
            }
            Map<WalletNode, List<String>> changeHash = Collections.emptyMap();
            if(walletTransactionProperty.get().getChangeNode() != null) {
                changeHash = Map.of(walletTransactionProperty.get().getChangeNode(), List.of(ElectrumServer.getScriptHash(walletForm.getWallet(), walletTransactionProperty.get().getChangeNode())));
            }
            log.debug("Creating tx " + walletTransactionProperty.get().getTransaction().getTxId() + ", expecting notifications for \ninputs \n" + inputHashes + " and \nchange \n" + changeHash);
        }

        addWalletTransactionNodes();
        createdWalletTransactionProperty.set(walletTransactionProperty.get());
        PSBT psbt = walletTransactionProperty.get().createPSBT();
        EventManager.get().post(new ViewPSBTEvent(createButton.getScene().getWindow(), walletTransactionProperty.get().getPayments().get(0).getLabel(), null, psbt));
    }

    private void addWalletTransactionNodes() {
        WalletTransaction walletTransaction = walletTransactionProperty.get();
        Set<WalletNode> nodes = new LinkedHashSet<>(walletTransaction.getSelectedUtxos().values());
        if(walletTransaction.getChangeNode() != null) {
            nodes.add(walletTransaction.getChangeNode());
        }
        List<WalletNode> consolidationNodes = walletTransaction.getConsolidationSendNodes();
        nodes.addAll(consolidationNodes);

        //All wallet nodes applicable to this transaction are stored so when the subscription status for one is updated, the history for all can be fetched in one atomic update
        walletForm.addWalletTransactionNodes(nodes);
    }

    @Subscribe
    public void walletNodesChanged(WalletNodesChangedEvent event) {
        if(event.getWallet().equals(walletForm.getWallet())) {
            clear(null);
        }
    }

    @Subscribe
    public void walletHistoryChanged(WalletHistoryChangedEvent event) {
        if(event.getWallet().equals(walletForm.getWallet()) && createdWalletTransactionProperty.get() != null) {
            if(createdWalletTransactionProperty.get().getSelectedUtxos() != null && allSelectedUtxosSpent(event.getHistoryChangedNodes())) {
                clear(null);
            } else {
                updateTransaction();
            }
        }
    }

    private boolean allSelectedUtxosSpent(List<WalletNode> historyChangedNodes) {
        Set<BlockTransactionHashIndex> unspentUtxos = new HashSet<>(createdWalletTransactionProperty.get().getSelectedUtxos().keySet());

        for(Map.Entry<BlockTransactionHashIndex, WalletNode> selectedUtxoEntry : createdWalletTransactionProperty.get().getSelectedUtxos().entrySet()) {
            BlockTransactionHashIndex utxo = selectedUtxoEntry.getKey();
            WalletNode utxoWalletNode = selectedUtxoEntry.getValue();

            for(WalletNode changedNode : historyChangedNodes) {
                if(utxoWalletNode.equals(changedNode)) {
                    Optional<BlockTransactionHashIndex> spentTxo = changedNode.getTransactionOutputs().stream().filter(txo -> txo.getHash().equals(utxo.getHash()) && txo.getIndex() == utxo.getIndex() && txo.isSpent()).findAny();
                    if(spentTxo.isPresent()) {
                        unspentUtxos.remove(utxo);
                    }
                }
            }
        }

        return unspentUtxos.isEmpty();
    }

    @Subscribe
    public void walletEntryLabelChanged(WalletEntryLabelChangedEvent event) {
        if(event.getWallet().equals(walletForm.getWallet())) {
            updateTransaction();
        }
    }

    @Subscribe
    public void feeRatesUpdated(FeeRatesUpdatedEvent event) {
        blockTargetFeeRatesChart.update(event.getTargetBlockFeeRates());
        blockTargetFeeRatesChart.select(getTargetBlocks());
        mempoolSizeFeeRatesChart.update(getMempoolHistogram());
        if(targetBlocksField.isVisible()) {
            setFeeRate(event.getTargetBlockFeeRates().get(getTargetBlocks()));
        } else {
            setFeeRatePriority(getFeeRangeRate());
        }
        addFeeRangeTrackHighlight(0);
    }

    @Subscribe
    public void feeRateSelectionChanged(FeeRatesSelectionChangedEvent event) {
        if(event.getWallet() == getWalletForm().getWallet()) {
            updateFeeRateSelection(event.getFeeRateSelection());
        }
    }

    @Subscribe
    public void spendUtxos(SpendUtxoEvent event) {
        if(!event.getUtxos().isEmpty() && event.getWallet().equals(getWalletForm().getWallet())) {
            if(event.getPayments() != null) {
                clear(null);
                setPayments(event.getPayments());
            }

            if(event.getFee() != null) {
                setFeeValueSats(event.getFee());
                userFeeSet.set(true);
            }

            includeMempoolInputsProperty.set(event.isIncludeMempoolInputs());

            List<BlockTransactionHashIndex> utxos = event.getUtxos();
            utxoSelectorProperty.set(new PresetUtxoSelector(utxos));
            utxoFilterProperty.set(null);
            updateTransaction(event.getPayments() == null);
        }
    }

    @Subscribe
    public void bitcoinUnitChanged(BitcoinUnitChangedEvent event) {
        BitcoinUnit unit = getBitcoinUnit(event.getBitcoinUnit());
        feeAmountUnit.getSelectionModel().select(BitcoinUnit.BTC.equals(unit) ? 0 : 1);
    }

    @Subscribe
    public void fiatCurrencySelected(FiatCurrencySelectedEvent event) {
        if(event.getExchangeSource() == ExchangeSource.NONE) {
            fiatFeeAmount.setCurrency(null);
            fiatFeeAmount.setBtcRate(0.0);
        }
    }

    @Subscribe
    public void exchangeRatesUpdated(ExchangeRatesUpdatedEvent event) {
        setFiatFeeAmount(event.getCurrencyRate(), getFeeValueSats());
    }

    @Subscribe
    public void excludeUtxo(ExcludeUtxoEvent event) {
        if(event.getWalletTransaction() == walletTransactionProperty.get()) {
            UtxoSelector utxoSelector = utxoSelectorProperty.get();
            if(utxoSelector instanceof MaxUtxoSelector) {
                Collection<BlockTransactionHashIndex> utxos = walletForm.getWallet().getWalletUtxos().keySet();
                utxos.remove(event.getUtxo());
                if(utxoFilterProperty.get() instanceof ExcludeUtxoFilter) {
                    ExcludeUtxoFilter existingUtxoFilter = (ExcludeUtxoFilter)utxoFilterProperty.get();
                    utxos.removeAll(existingUtxoFilter.getExcludedUtxos());
                }
                PresetUtxoSelector presetUtxoSelector = new PresetUtxoSelector(utxos);
                utxoSelectorProperty.set(presetUtxoSelector);
                updateTransaction(true);
            } else if(utxoSelector instanceof PresetUtxoSelector) {
                PresetUtxoSelector presetUtxoSelector = new PresetUtxoSelector(((PresetUtxoSelector)utxoSelector).getPresetUtxos());
                presetUtxoSelector.getPresetUtxos().remove(event.getUtxo());
                utxoSelectorProperty.set(presetUtxoSelector);
                updateTransaction(true);
            } else {
                ExcludeUtxoFilter utxoFilter = new ExcludeUtxoFilter();
                if(utxoFilterProperty.get() instanceof ExcludeUtxoFilter) {
                    ExcludeUtxoFilter existingUtxoFilter = (ExcludeUtxoFilter)utxoFilterProperty.get();
                    utxoFilter.getExcludedUtxos().addAll(existingUtxoFilter.getExcludedUtxos());
                }

                utxoFilter.getExcludedUtxos().add(event.getUtxo());
                utxoFilterProperty.set(utxoFilter);
                updateTransaction();
            }
        }
    }

    @Subscribe
    public void walletUtxoStatusChanged(WalletUtxoStatusChangedEvent event) {
        if(event.getWallet().equals(getWalletForm().getWallet())) {
            UtxoSelector utxoSelector = utxoSelectorProperty.get();
            if(utxoSelector instanceof MaxUtxoSelector) {
                updateTransaction(true);
            } else if(utxoSelectorProperty().get() instanceof PresetUtxoSelector) {
                PresetUtxoSelector presetUtxoSelector = new PresetUtxoSelector(((PresetUtxoSelector)utxoSelector).getPresetUtxos());
                presetUtxoSelector.getPresetUtxos().remove(event.getUtxo());
                utxoSelectorProperty.set(presetUtxoSelector);
                updateTransaction(true);
            } else {
                updateTransaction();
            }
        }
    }
}
