/*
* Copyright (C) 2018-2023 Andrew Gegg
 *
 *	This file is part of the Gardeners Notebook application
 *
  * The Gardeners Notebook application is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/gpl.html>.
 */

/*
	Change log
	2.1.0   Remove Detail_2 - it's not used
    2.3.0   Pick up tab-out from DatePicker
    2.4.0   Display products as a tree by ProductCategory
            Add constructor to specify Retailer at creation
            Support tab-out on comments
    2.6.1   Code tidy-up
    2.7.0   Support D&D from ShoppingList to create PurchaseItems
    2.8.0   Use improved ShoppingListBean properties
    2.9.1   Make total price field show entered value properly formatted
    2.9.5   Modifications for Maven
            Add code to set product name column to a sensible initial size
    2.9.6	When a Diary entry is added/changed, make sure updated comments are shown
    3.0.1	CommentAdder handling moved to separate class
    		Allow delayed WatchFor to be added to an existing PI
    		Do not allow name and detail to be changed for a PI in a StoryLine
    3.0.2	Check before Cancel if changes have been made
    3.0.4	Implement delete() in the inner class PIentry as now required by INotebookBean q.v.
			Use new convenience class NotebookBeanDeleter for deletion.
   			Use new convenience class EditorCommentTableHandler to handle Comment table construction.
    		Set focus on first field.
	3.0.5	Use factory pattern DiaryBean
	3.0.6	Bodge fix for JavaFX bug JDK-8269867 which can't handle short form years
    3.1.0	Use SafeDatePicker.
    		Use new constructors for new descendant entries (to get the date)
	3.1.2	Improve handling of 'watch for' on purchase items
	3.2.1	Bug fixes around deleting part-formed purchase item lines
 */

package uk.co.gardennotebook;

import java.io.IOException;
import java.text.MessageFormat;
import java.time.LocalDate;
import java.util.ResourceBundle;
import java.util.function.BiConsumer;
import java.util.function.Consumer;

import javafx.application.Platform;
import javafx.beans.property.ReadOnlyBooleanProperty;
import javafx.beans.property.ReadOnlyStringProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.event.ActionEvent;
import javafx.event.Event;
import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.TableRow;
import javafx.scene.control.TextField;
import uk.co.gardennotebook.fxbean.*;
import javafx.scene.layout.GridPane;
//import javafx.scene.control.DatePicker;
import java.math.BigDecimal;
import java.time.format.DateTimeFormatter;
//import java.time.format.DateTimeParseException;
import java.time.format.FormatStyle;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;

import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.control.Alert;
import javafx.scene.control.ButtonType;
import javafx.scene.control.MenuItem;
import javafx.scene.control.SelectionMode;
import javafx.scene.control.TextFormatter;
import javafx.scene.control.TreeItem;
import javafx.scene.control.TreeTableColumn;
import javafx.scene.control.TreeTableRow;
import javafx.scene.control.TreeTableView;
import javafx.scene.control.cell.TextFieldTreeTableCell;
import javafx.scene.input.ClipboardContent;
import javafx.scene.input.DragEvent;
import javafx.scene.input.Dragboard;
import javafx.scene.input.TransferMode;
import javafx.scene.layout.VBox;
import javafx.scene.text.Text;
import javafx.stage.WindowEvent;
import javafx.util.converter.BigDecimalStringConverter;
import uk.co.gardennotebook.util.SimpleMoney;
import uk.co.gardennotebook.util.SimpleMoneyStringConverter;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.message.EntryMessage;
import uk.co.gardennotebook.spi.GNDBException;
import uk.co.gardennotebook.spi.NotebookEntryType;
import uk.co.gardennotebook.util.StoryLineTree;

/**
	*	Controller class for create/update of Purchases
	*
	*	@author Andy Gegg
	*	@version	3.2.1
	*	@since	1.0
 */
final class PurchaseEditor extends GridPane implements INotebookLoadable
{
	private static final Logger LOGGER = LogManager.getLogger();

	private final PurchaseBean thisValueBean;

	private Consumer<Node> loadSplit;
	private Consumer<Node> clearSplit;
	private BiConsumer<String, Node> loadTab;
	private Consumer<Node> clearTab;

	//	an observable property that the caller can monitor when a new item is being created
	private SimpleObjectProperty<PurchaseBean> newBean;
	private final boolean addingItem;

	//	an observable property that the caller can monitor when an item is deleted
	private SimpleObjectProperty<Object> deletedBean;
    
    ProductBrandBean ownBrand = null;
    boolean useOwnBrand = false;

	@FXML
	private ResourceBundle resources;

	@FXML
	private Button btnSave;
	@FXML
	private Button btnDelete;
	@FXML
	private SafeDatePicker dtpDate;
	@FXML
	private RetailerCombo parentRetailer;
	@FXML
	private TextField numTotalPrice;
	@FXML
	private TextField fldOrderNo;
	@FXML
	private TextField fldInvoiceNo;
	@FXML
	private SafeDatePicker dtpDeliveryDate;

	@FXML
	private TableView<CommentBean> tbvComment;
	@FXML
	private TableColumn<CommentBean, CommentBean> tbvCommentDate;
	@FXML
	private TableColumn<CommentBean, String> tbvCommentText;

	// purchase items
	@FXML
	private TableView<PIentry> tbvItems;
	@FXML
	private TableColumn<PIentry, ProductBrandBean> tbvPIProductBrand;
	@FXML
	private TableColumn<PIentry, ProductCategoryBean> tbvPIProductCategory;
	@FXML
	private TableColumn<PIentry, String> tbvPIProductName;
	@FXML
	private TableColumn<PIentry, String> tbvPIProductDetail;
	@FXML
	private TableColumn<PIentry, BigDecimal> tbvPIQuantity;
	@FXML
	private TableColumn<PIentry, String> tbvPIUnit;
	@FXML
	private TableColumn<PIentry, SimpleMoney> tbvPIItemCost;
	@FXML
	private TableColumn<PIentry, HusbandryClassBean> tbvPIWatchForHusbandry;
	@FXML
	private TableColumn<PIentry, GroundworkActivityBean> tbvPIWatchForGroundwork;
	@FXML
	private TableColumn<PIentry, LocalDate> tbvPIWatchForAfter;
	@FXML
	private TableColumn<PIentry, PIentry> tbvPIComment;
	@FXML
	private MenuItem ctxmnuDelete;
	@FXML
	private MenuItem ctxmnuTopHistory;
	@FXML
	private MenuItem ctxmnuDescendants;

	// products to drag&drop on the PurchaseItems table
	@FXML
	private TreeTableView<ProductTreeBean> tbvProducts;
	@FXML
	private TreeTableColumn<ProductTreeBean, String> tbvProdProductBrand;
	@FXML
	private TreeTableColumn<ProductTreeBean, String> tbvProdProductName;
	@FXML
	private TreeTableColumn<ProductTreeBean, String> tbvProdProductDetail;
//	@FXML
//	private TableColumn<ProductBean, String> tbvProdProductDetail_2;
	@FXML
	private TreeTableColumn<ProductTreeBean, String> tbvProdProductDescription;
	@FXML
	private TreeTableColumn<ProductTreeBean, ProductTreeBean> tbvProdComment;

    //  2.7.0
    @FXML
    private MenuItem ctxmnuProdCopyToPU;

	// 2.7.0    shopping list items
	@FXML
	private TableView<ShoppingListBean> tblShoppingList;
	@FXML
	private TableColumn<ShoppingListBean, ProductCategoryBean> tbvShopProductCategory;
	@FXML
	private TableColumn<ShoppingListBean, ProductBrandBean> tbvShopProductBrand;
	@FXML
	private TableColumn<ShoppingListBean, String> tbvShopProductName;
	@FXML
	private TableColumn<ShoppingListBean, String> tbvShopProductDetail;
	@FXML
	private TableColumn<ShoppingListBean, String> tbvShopNonSpecific;
	@FXML
	private TableColumn<ShoppingListBean, ShoppingListBean> tbvShopComment;
    
    @FXML
    private MenuItem ctxmnuShopMarkDelete;
    @FXML
    private MenuItem ctxmnuShopNoDelete;
    @FXML
    private MenuItem ctxmnuShopCopyToPU;
    
    // the key is the key for a ShoppingList item, the value is true if the SL item has been added to the purchase by D&D
    // and is to be deleted.  If the user has requested that the SHoppingList item NOT be deleted, the value will be false
    private final Map<Integer, Boolean> shopForDelete = new HashMap<>();
    
    // the key is a Product id, the value is a ShoppingList item id
    // this is used to unset shopForDelete if the PI is deleted
    // (if the SAME product is added from the ShoppingList AND the Product list, this will be a false value - live with it!)
    private final Map<Integer, Integer> prodFromShop = new HashMap<>();
    
    //	handles addition of multiple purchase items
	private final ChangeListener<Boolean> purchaseItemAdder = new ChangeListener<>() {
		@Override
		public void changed(ObservableValue<? extends Boolean> obj, Boolean oldVal, Boolean newVal) {
            LOGGER.debug("purchaseItemAdder: newVal: {}", newVal);
			if (newVal != null) 
			{
				PurchaseItemBean pib_new = new PurchaseItemBean();
				if (!addingItem)
				{
					pib_new.setPurchase(thisValueBean);
				}
                if (useOwnBrand)
                {
                    pib_new.setDefaultProductBrand(ownBrand);
                }
				pib_new.isReadyProperty().addListener(this);
				pib_new.setSaveRequired(true);
				tbvItems.getItems().add(new PIentry(pib_new));
				obj.removeListener(purchaseItemAdder);
			}
		}
	};
    
    private final ChangeListener<RetailerBean> retailerChanged = new ChangeListener<>() {
        @Override
        public void changed(ObservableValue<? extends RetailerBean> obj, RetailerBean oldVal, RetailerBean newVal) {
            LOGGER.debug("retailerChanged: newVal: {}", newVal);
			if (newVal == null) 
			{
                useOwnBrand = false;
                ownBrand = null;
            }
            else
            {
                try {
                    List<ProductBrandBean> brands = newVal.getProductBrand();
                    LOGGER.debug("retailerChanged: brands: {}", brands);
                    if (!brands.isEmpty())
                    {
                        useOwnBrand = true;
                        ownBrand = brands.get(0);
                    }
                } catch (GNDBException ex) {
					PanicHandler.panic(ex);
                }
                if (useOwnBrand)
                {
                    for (var pi : tbvItems.getItems())
                    {
                        pi.pib.setDefaultProductBrand(ownBrand);
                    }
                }
            }
        }
        
    };

	PurchaseEditor()
	{
		this((PurchaseBean)null);
	}

    /**
     * @since 2.4.0
     * @param retailer  the initial Retailer 
     */
    PurchaseEditor(RetailerBean retailer)
    {
        this((PurchaseBean)null);
        this.thisValueBean.setRetailer(retailer);
    }

	PurchaseEditor(PurchaseBean initialVal)
	{
		this.thisValueBean = (initialVal != null ? initialVal : new PurchaseBean());
		this.addingItem = (initialVal == null);
		FXMLLoader fxmlLoader = new FXMLLoader(getClass().getResource("/fxml/PurchaseEditor.fxml"),
			ResourceBundle.getBundle("notebook") );
		fxmlLoader.setRoot(this);
		fxmlLoader.setController(this);
		try {
			fxmlLoader.load();	// NB initialize is called from in here
		} catch (IOException exception) {
			throw new RuntimeException(exception);
		}
	}

	/*
	* Initializes the controller class.
	*/
	@FXML
	private void initialize()
	{
		EntryMessage log4jEntryMsg = LOGGER.traceEntry("initialize()");

		final EditorCommentTableHandler<PurchaseBean> cth = new EditorCommentTableHandler<>(resources);
		cth.constructCommentTable(tbvComment, tbvCommentDate, tbvCommentText, thisValueBean);

		thisValueBean.setSaveRequired(true);
		
		tbvPIProductCategory.setCellValueFactory(cdf -> cdf.getValue().pib.productCategoryProperty());
		tbvPIProductCategory.setCellFactory(_ -> new NotebookDropDownCellType<>((_, ci) -> new ProductCategoryCombo(ci), ProductCategoryBean::getName) {
					@Override
					protected boolean mayEdit(PIentry rowItem, ProductCategoryBean cellItem)
					{
                        return ( (rowItem != null) && (rowItem.pib.isNew()) );
					}
		});

		tbvPIProductBrand.setCellValueFactory(cdf -> cdf.getValue().pib.productBrandProperty());
		tbvPIProductBrand.setCellFactory(_ -> new NotebookDropDownCellType<>((_, ci) -> new ProductBrandCombo(ci),
                                                                                hc -> hc!=null?hc.getName():"") {
					@Override
					protected boolean mayEdit(PIentry rowItem, ProductBrandBean cellItem)
					{
						if (rowItem == null) return false;
						// must have an actual category in place first
						if (!rowItem.pib.hasProductCategory()) return false;
						return true;
					}
		});

        // this needs care.  The column is overloaded - it shows EITHER the PlantSpecies OR the Product.name value
        // For the PS, use the PlantSpeciesCombo - which can happily create a new PS.
        // For the name, show a combo of extant names but do NOT create a new Product at this point
		tbvPIProductName.setCellValueFactory(cdf -> cdf.getValue().pib.nameProperty());
		tbvPIProductName.setCellFactory(_ -> new NotebookEditCellType<>()
		{
			@Override
			protected boolean mayEdit(PIentry rowItem, String cellItem)
			{// NB rowItem is NULL unless we are in edit mode
				if (rowItem == null) return false;
				if (!rowItem.pib.hasProductCategory()) return false;
				try
				{
					if (rowItem.pib.hasDescendant()) return false;
				}
				catch (GNDBException ex)
				{
					PanicHandler.panic(ex);
				}
				return true;
			}

			@Override
			void updateViewMode(PIentry rowItem, String cellItem)
			{// NB rowItem is NULL unless we are in edit mode
				LOGGER.debug("tbvPIProductName: updateViewMode(): rowItem: {}, cellItem: {}", rowItem, cellItem);
//					LOGGER.debug("name: updateViewMode(): item.isNew: {}", cellItem.isNew());
//					LOGGER.debug("name: updateViewMode(): item.productCategory: {}", cellItem.getProductCategory());
//					LOGGER.debug("name: updateViewMode(): item.productCategory.isNew: {}", cellItem.getProductCategory().isNew());
				setGraphic(null);
				setText(null);
				if (cellItem == null)
					return;

				// cannot change the name of an existing product
				if (isEditing() && mayEdit(rowItem, cellItem))
				{
					VBox vBox = new VBox();
					if (rowItem.pib.getProductCategory().isPlantLike())
					{
						PlantSpeciesCombo cb = new PlantSpeciesCombo(rowItem.pib.getPlantSpecies());
						cb.setEditable(true);
						cb.setMandatory(true);
						cb.setOnAction(_ -> {
							LOGGER.debug("plant species: setOnAction: selection: {}", cb.getSelectionModel().getSelectedItem());
							if (cb.getValue() == null) return;
							rowItem.pib.setPlantSpecies(cb.getValue());
							commitEdit(rowItem.pib.getName());
						});
						vBox.getChildren().add(cb);
					}
					else
					{
						ProductNameCombo cb = new ProductNameCombo(rowItem.pib.getProductCategory());
						cb.setEditable(true);
						cb.setMandatory(true);
						cb.setOnAction(_ -> {
							LOGGER.debug("NOT plant species: setOnAction: selection: {}", cb.getSelectionModel().getSelectedItem());
							rowItem.pib.setName(cb.getValue());
							commitEdit(rowItem.pib.getName());
						});
						vBox.getChildren().add(cb);
					}
					setGraphic(vBox);
				}
				else
				{
					setText(cellItem);
				}

			}
		});

		tbvPIProductDetail.setCellValueFactory(cdf -> cdf.getValue().pib.nameDetailProperty());
		tbvPIProductDetail.setCellFactory(_ -> new NotebookEditCellType<>(){
				@Override
				protected boolean mayEdit(PIentry rowItem, String cellItem)
				{// NB rowItem is NULL unless we are in edit mode
                    if (rowItem == null) return false;
                    if (!rowItem.pib.hasProductCategory()) return false;
                    if (rowItem.pib.nameProperty().getValue() == null || rowItem.pib.nameProperty().getValue().isBlank()) return false;
					try
					{
						if (rowItem.pib.hasDescendant()) return false;
					}
					catch (GNDBException ex)
					{
						PanicHandler.panic(ex);
					}
                    return true;
				}
				
				@Override
				void updateViewMode(PIentry rowItem, String cellItem)
				{// NB rowItem is NULL unless we are in edit mode
                    LOGGER.traceEntry("tbvPIProductDetail: updateViewMode(): rowItem: {}, cellItem: {}", rowItem, cellItem);
					setGraphic(null);
					setText(null);
					if (cellItem == null)
						return;

					// cannot change the secondary name of an existing product
					if (isEditing() && mayEdit(rowItem, cellItem))
					{
						VBox vBox = new VBox();
						if (rowItem.pib.productCategoryProperty().get().isPlantLike())
						{							
							PlantVarietyCombo cb = new PlantVarietyCombo(rowItem.pib.getPlantSpecies(), rowItem.pib.getPlantVariety());
							cb.setEditable(true);
							cb.setOnAction(_ -> {
								LOGGER.debug("plant variety: setOnAction: selection: {}", cb.getSelectionModel().getSelectedItem());
                                rowItem.pib.setPlantVariety(cb.getValue());
                                commitEdit(rowItem.pib.getNameDetail()); //  ProductBean sets the value correctly
								});
							vBox.getChildren().add(cb);
						}
						else
						{
                            //  2.0.1
							ProductDetailCombo cb = new ProductDetailCombo(rowItem.pib.getProductCategory(), rowItem.pib.getProduct());
							cb.setEditable(true);
							cb.setOnAction(_ -> {
								LOGGER.debug("NOT plant species: setOnAction: selection: {}", cb.getSelectionModel().getSelectedItem());
								
								LOGGER.debug("NOT plant species: setOnAction: just BEFORE settting value in rowItem.productBean");
                                rowItem.pib.setNameDetail(cb.getValue());
								LOGGER.debug("NOT plant species: setOnAction: just AFTER settting value in rowItem.productBean");
                                commitEdit(rowItem.pib.getNameDetail());
								LOGGER.debug("NOT plant species: setOnAction: just AFTER commitEdit");
								});
							vBox.getChildren().add(cb);
						}
						setGraphic(vBox);
					}
					else
					{
                        setText(cellItem);
					}

				}
		});

		tbvPIQuantity.setCellValueFactory(cdf -> cdf.getValue().pib.quantityProperty());
		tbvPIQuantity.setCellFactory(_ -> new TextFieldTableCellTabOut<>(new BigDecimalStringConverter()) );   //  2.4.0
		tbvPIUnit.setCellValueFactory(cdf -> cdf.getValue().pib.unitProperty());
		tbvPIUnit.setCellFactory(_ -> new TextFieldTableCellTabOut<>() );   //  2.4.0
		tbvPIItemCost.setCellValueFactory(cdf -> cdf.getValue().pib.itemPriceProperty());
		tbvPIItemCost.setCellFactory(_ -> new TextFieldTableCellTabOut<>(new SimpleMoneyStringConverter()) );   //  2.4.0

		tbvPIWatchForHusbandry.setCellValueFactory(cdf -> cdf.getValue().watchActionHusbandry);
		tbvPIWatchForHusbandry.setCellFactory(_ -> new NotebookDropDownCellType<>((_, ci) -> new HusbandryClassCombo(ci), HusbandryClassBean::getName) {
			@Override
			protected boolean mayEdit(PIentry rowItem, HusbandryClassBean cellItem)
			{// cannot change a 'watch for' action that's already set up
				return ( (rowItem != null) && ((rowItem.watchActionHusbandry.get() == null) || rowItem.pib.isNew()) && (rowItem.pib.getProductCategory().isPlantLike()) );
			}

			@Override
			protected void onActionPostCommit(PIentry rowItem, HusbandryClassBean cellItem)
			{
				LOGGER.debug("onActionExtra(): rowItem: {}, cellItem: {}", rowItem, cellItem);
				if (rowItem == null)
					return;
				if (rowItem.watchActionHusbandry.get() != null)
				{
					rowItem.watchDate.set(LocalDate.now());
				}
				else
				{
					rowItem.watchDate.set(null);
				}
			}
		});

		tbvPIWatchForGroundwork.setCellValueFactory(cdf -> cdf.getValue().watchActionGroundwork);
		tbvPIWatchForGroundwork.setCellFactory(_ -> new NotebookDropDownCellType<>((_, ci) -> new GroundworkActivityCombo(ci), GroundworkActivityBean::getName) {
			@Override
			protected boolean mayEdit(PIentry rowItem, GroundworkActivityBean cellItem)
			{// cannot change a 'watch for' action that's already set up
				return ( (rowItem != null) && ((rowItem.watchActionGroundwork.get() == null) || rowItem.pib.isNew()) && (!rowItem.pib.getProductCategory().isPlantLike()) );
			}

			@Override
			protected void onActionPostCommit(PIentry rowItem, GroundworkActivityBean cellItem)
			{
				LOGGER.debug("onActionExtra(): rowItem: {}, cellItem: {}", rowItem, cellItem);
				if (rowItem == null)
					return;
				if (rowItem.watchActionGroundwork.get() != null)
				{
					rowItem.watchDate.set(LocalDate.now());
				}
				else
				{
					rowItem.watchDate.set(null);
				}
			}
		});

		tbvPIWatchForAfter.setCellValueFactory(cdf -> cdf.getValue().watchDate);
		tbvPIWatchForAfter.setCellFactory(_ -> new NotebookDateCellType<>(false) {
			@Override
			protected boolean mayEdit(PIentry rowItem, LocalDate cellItem)
			{// cannot change a 'watch for' action that's already set up
				//	3.0.1
				return ( (rowItem != null) && (rowItem.pib.isNew() || rowItem.watchAllowed) && ((rowItem.watchActionGroundwork.get() != null) || (rowItem.watchActionHusbandry != null)) );
			}
		} );

		tbvPIComment.setCellValueFactory(cdf -> new SimpleObjectProperty<>(cdf.getValue()));
		tbvPIComment.setCellFactory(_ -> new EditorCommentTableCell<>(resources));	//	2.9.6
		try {
			tbvItems.getItems().clear();
			for (final PurchaseItemBean pib : thisValueBean.getPurchaseItem())
			{
				LOGGER.debug("building PI list: pib: {}", pib);
				HusbandryClassBean hcb = null;
				GroundworkActivityBean gab = null;
				LocalDate watchAfter = null;
				ObservableList<ToDoListBean> watchFor = pib.getToDoList();
				LOGGER.debug("watchfor todo: {}", watchFor);
				if (!watchFor.isEmpty())
				{
					hcb = watchFor.get(0).getHusbandryClass();
					gab = watchFor.get(0).getGroundworkActivity();
				}
				ObservableList<ReminderBean> reminders = pib.getReminder();
				LOGGER.debug("watchfor reminder: {}", reminders);
				if (!reminders.isEmpty())
				{
					hcb = reminders.get(0).getHusbandryClass();
					gab = reminders.get(0).getGroundworkActivity();
					watchAfter = reminders.get(0).getShowFrom();
				}
				pib.setSaveRequired(true);
				PIentry pie = new PIentry(pib, hcb, gab, watchAfter);
				LOGGER.debug("After PIentry creation: pie: {}", pie.pib);
				tbvItems.getItems().add(pie);
			}
		} catch (GNDBException ex) {
			PanicHandler.panic(ex);
		}

		PurchaseItemBean pib_new = new PurchaseItemBean();
		if (!addingItem)
		{
			pib_new.setPurchase(thisValueBean);
		}
        if (useOwnBrand)
        {
            pib_new.setDefaultProductBrand(ownBrand);
        }
		pib_new.setSaveRequired(true);
        pib_new.isReadyProperty().addListener(purchaseItemAdder);
		tbvItems.getItems().add(new PIentry(pib_new));
		tbvItems.setColumnResizePolicy(NotebookResizer.using(tbvItems));
		
		tbvProdProductBrand.setCellValueFactory(cdf -> cdf.getValue().getValue().productBrandProperty());
        
		tbvProdProductName.setCellValueFactory(cdf -> cdf.getValue().getValue().nameProperty());
        
		tbvProdProductDetail.setCellValueFactory(cdf -> cdf.getValue().getValue().nameDetail_1Property());
        
//		tbvProdProductDetail_2.setCellValueFactory(cdf -> cdf.getValue().nameDetail_2Property());

		tbvProdProductDescription.setCellValueFactory(cdf -> cdf.getValue().getValue().descriptionProperty());
		tbvProdProductDescription.setCellFactory(TextFieldTreeTableCell.forTreeTableColumn());
        
		tbvProdComment.setCellValueFactory(cdf -> cdf.getValue().valueProperty());
		tbvProdComment.setCellFactory(_ -> new ProductTreeCommentCell(resources));
        
		tbvProducts.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
        tbvProducts.setRowFactory((_ -> new ProdListTableRow()));
		
        loadProductTree();

		//	2.9.5
		final ObservableList<TreeItem<ProductTreeBean>> items = tbvProducts.getRoot().getChildren();
		if (!items.isEmpty() && items.size() < 50)
		{
			double nameWidth = new Text(tbvProdProductName.getText()).getLayoutBounds().getWidth();
			for (TreeItem<ProductTreeBean> b : items)
			{
				final Text t2 = new Text(b.
						getValue().
						nameProperty().
						getValue());
				final double wid2 = t2.getLayoutBounds().getWidth();
				if (wid2 > nameWidth) nameWidth = wid2;
			}
			tbvProdProductName.setPrefWidth(nameWidth+30);
		}

		tbvProducts.setColumnResizePolicy(NotebookResizer.using(tbvProducts));
		tbvProducts.getSortOrder().addAll(//tbvProdProductCategory,
											tbvProdProductName,
											tbvProdProductDetail, 
//											tbvProdProductDetail_2,
											tbvProdProductBrand);
		
        //  2.7.0
		tbvShopProductCategory.setCellValueFactory(cdf -> cdf.getValue().productCategoryProperty());
        tbvShopProductCategory.setCellFactory(_ -> new NotebookDropDownCellType<ShoppingListBean, ProductCategoryBean, ProductCategoryCombo>(null, ProductCategoryBean::getName));
        tbvShopProductBrand.setCellValueFactory(cdf -> cdf.getValue().productBrandProperty());
        tbvShopProductBrand.setCellFactory(_ -> new NotebookDropDownCellType<ShoppingListBean, ProductBrandBean, ProductBrandCombo>(null, ProductBrandBean::getName));

        // this needs care.  The column is overloaded - it shows EITHER the PlantSpecies OR the Product.name value
        // For the PS, use the PlantSpeciesCombo - which can happily create a new PS.
        // For the name, show a combo of extant names but do NOT create a new Product at this point
		tbvShopProductName.setCellValueFactory(cdf -> cdf.getValue().nameProperty());
        
		tbvShopProductDetail.setCellValueFactory(cdf -> cdf.getValue().nameDetailProperty());

		tbvShopNonSpecific.setCellValueFactory(cdf -> cdf.getValue().nonspecificItemProperty());

		tbvShopComment.setCellValueFactory(cdf -> new SimpleObjectProperty<>(cdf.getValue()));
		tbvShopComment.setCellFactory(_ -> new EditorCommentTableCell<>(resources));	//	2.9.6

		tblShoppingList.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);

        try {
			tblShoppingList.getItems().setAll(ShoppingListBean.fetchAll());
		} catch (GNDBException ex) {
			PanicHandler.panic(ex);
		}

        tblShoppingList.setColumnResizePolicy(NotebookResizer.using(tblShoppingList));
        
        tblShoppingList.setRowFactory((_ -> new ShopListTableRow()));

		dtpDate.setConverter(null);	// resets to default converter
		dtpDate.valueProperty().bindBidirectional(thisValueBean.dateProperty());
		dtpDate.setMandatory();

		parentRetailer.valueProperty().bindBidirectional(thisValueBean.retailerProperty());
        parentRetailer.valueProperty().addListener(retailerChanged);
		numTotalPrice.textProperty().bindBidirectional(thisValueBean.totalPriceProperty(), new SimpleMoneyStringConverter());
        // a default value must be given or it tries to convert null
        // the obvious default of new SimpleMoney() results in £0.00 being shown when you Change an extant Purchase - 
        // and it sets that value into the bean.  Hence the rather weird looking bodge.
        numTotalPrice.setTextFormatter(new TextFormatter<>(new SimpleMoneyStringConverter(), thisValueBean.getTotalPrice()));
		fldOrderNo.textProperty().bindBidirectional(thisValueBean.orderNoProperty());
		fldInvoiceNo.textProperty().bindBidirectional(thisValueBean.invoiceNoProperty());
		dtpDeliveryDate.valueProperty().bindBidirectional(thisValueBean.deliveryDateProperty());
		dtpDeliveryDate.setConverter(null);	// resets to default converter
		if (addingItem)
		{
			dtpDeliveryDate.setMinValidDate(LocalDate.now());
		}
		else
		{
			dtpDeliveryDate.setMinValidDate(dtpDate.getValue());
		}

		btnSave.disableProperty().bind(dtpDate.valueProperty().isNull()
			.or(parentRetailer.valueProperty().isNull()));
		try {
			btnDelete.setDisable(addingItem || !(this.thisValueBean.canDelete()));
		} catch (GNDBException ex) {
			PanicHandler.panic(ex);
		}

		Platform.runLater(() -> parentRetailer.requestFocus());

		LOGGER.traceExit(log4jEntryMsg);
	}	//	initialize()

    private void loadProductTree()
    {
		EntryMessage log4jEntryMsg = LOGGER.traceEntry("loadProductTree()");
    
        TreeItem<ProductTreeBean> root = new TreeItem<>();
        ObservableList<ProductCategoryBean> cats = FXCollections.emptyObservableList();
        try {
            cats = ProductCategoryBean.fetchAll();
        } catch (GNDBException ex) {
            PanicHandler.panic(ex);
        }
        for (var cat : cats)
        {
            TreeItem<ProductTreeBean> branch = new TreeItem<>(new ProductTreeBean(cat));
            root.getChildren().add(branch);
            ObservableList<ProductBean> prods = FXCollections.emptyObservableList();
            try {
                prods = ProductBean.fetchAll(cat);
            } catch (GNDBException ex) {
                PanicHandler.panic(ex);
            }
            for (var prod : prods)
            {
                TreeItem<ProductTreeBean> leaf = new TreeItem<>(new ProductTreeBean(prod));
                branch.getChildren().add(leaf);
            }
        }
		tbvProducts.setRoot(root);
		LOGGER.traceExit(log4jEntryMsg);
    }

	@Override
	public void setLoadSplit(Consumer<Node> code)
	{
		loadSplit = code;
	}

	@Override
	public void setClearSplit(Consumer<Node> code)
	{
		clearSplit = code;
	}

	@Override
	public void setLoadTab(BiConsumer<String, Node> code)
	{
		loadTab = code;
	}

	@Override
	public void setClearTab(Consumer<Node> code)
	{
		clearTab = code;
	}

	@Override
	public void clearUpOnClose(Event e)
	{
		if (isCancelDenied())
		{
			e.consume();
			return;
		}
		thisValueBean.cancelEdit();
	}

//	@FXML
//	/**
//	 * JavaFX bug JDK-8269867 does not recognise short form dates (outside the USA) so 4/7/22 is taken as AD 22 not AD 2022.
//	 * This is a nasty little bodge to make it usable - remove when/if the bug is fixed.
//	 *
//	 * @param event	not used
//	 * @since 3.0.1
//	 */
//	private void dtpDeliveryDateOnAction(ActionEvent event)
//	{
//		EntryMessage log4jEntryMsg = LOGGER.traceEntry("dtpDeliveryDateOnAction(): value: {}", dtpDeliveryDate.getValue());
//		if (dtpDeliveryDate.getValue() == null) return;
//		if (dtpDeliveryDate.getValue().getYear() < 100)
//			dtpDeliveryDate.setValue((dtpDeliveryDate.getValue().plusYears(2000)));
//	}

	@FXML
	/**
	 * If the Purchase date is changed, don't allow delivery dates before that date
	 *
	 * @param event	not used
	 * @since 3.1.0
	 */
	private void dtpDateOnAction(ActionEvent event)
	{
		EntryMessage log4jEntryMsg = LOGGER.traceEntry("dtpDateOnAction(): value: {}", dtpDate.getValue());
		if (dtpDate.getValue() == null) return;
		dtpDeliveryDate.setMinValidDate(dtpDate.getValue());
	}

	@FXML
	private void ctxmnuTopOnAction(WindowEvent event)
	{
		EntryMessage log4jEntryMsg = LOGGER.traceEntry("ctxmnuTopOnAction()");
		final PurchaseItemBean piBean = tbvItems.getSelectionModel().getSelectedItem().pib;
		if (piBean == null)
		{
			LOGGER.debug("no item selected in Table");
			return;
		}

        //  2.7.0
        //  if this is a new Purchase the PI doesn't 'exist' so fails canDelete()
        //  if this is a new PI, it fails canDelete()
        //  but it's perfectly sensible to scrap a new line in a (new) Purchase
        boolean canDelete = true;
        if (!addingItem && !piBean.isNew())
        {
            try
            {
                canDelete = piBean.canDelete();
            } catch (GNDBException ex) {
                PanicHandler.panic(ex);
            }
        }
        ctxmnuDelete.setDisable(!canDelete);
		
        if (addingItem)
        {
            ctxmnuTopHistory.setDisable(true);
        }
        
		try {
			ctxmnuDescendants.setDisable(!(piBean.hasDescendant()));
		} catch (GNDBException ex) {
			PanicHandler.panic(ex);
		}

		LOGGER.traceExit(log4jEntryMsg);
	}

    /*
    *   Used to delete a PurchaseItem
    */
	@FXML
	private void ctxmnuDeleteOnAction(ActionEvent event)
	{
		EntryMessage log4jEntryMsg = LOGGER.traceEntry("ctxmnuDeleteOnAction()");
		final PIentry selectedPIEntry = tbvItems.getSelectionModel().getSelectedItem();
		final PurchaseItemBean piBean = selectedPIEntry.pib;
		if (piBean == null)
		{
			return;
		}

        //  2.7.0
        //  if this is a new Purchase the PI doesn't 'exist' so fails canDelete()
        //  if this is a new PI, it fails canDelete()
        //  but it's perfectly sensible to scrap a new line in a (new) Purchase
        if (!addingItem && !piBean.isNew())
        {
            boolean canDelete = false;
            try {
                canDelete = piBean.canDelete();
            } catch (GNDBException ex) {
                PanicHandler.panic(ex);
            }
            if (!canDelete)
            {
                return;
            }
        }
		
		Alert checkDelete = new Alert(Alert.AlertType.CONFIRMATION, resources.getString("alert.confirmdelete"), ButtonType.NO, ButtonType.YES);
		Optional<ButtonType> result = checkDelete.showAndWait();
		LOGGER.debug("after delete dialog: result:{}, result.get:{}",result, result.get());
		if (result.isPresent() && result.get() == ButtonType.YES)
		{
			LOGGER.debug("after delete confirmed");
            if (!addingItem && !piBean.isNew())
            {
                try {
                    piBean.delete();
                } catch (GNDBException ex) {
                    PanicHandler.panic(ex);
                }
            }
//			tbvItems.getItems().remove(selectedPIEntry);

            //  2.7.0   if the deleted item was from the ShoppingList it should no longer be marked for deletion
			// 3.2.1 - deleting a half-formed line crashes
			if (piBean.getProduct() == null)
			{
				piBean.setProductCategory(null);
			}
			else
			{
				final Integer ix = piBean.getProduct().getKey();

				if (prodFromShop.containsKey(ix) && shopForDelete.getOrDefault(prodFromShop.get(ix), false))
				{
					shopForDelete.remove(prodFromShop.get(ix));
					prodFromShop.remove(ix);
					tblShoppingList.refresh();
				}
				tbvItems.getItems().remove(selectedPIEntry);
			}
		}
        
		LOGGER.traceExit(log4jEntryMsg);
	}	//	ctxmnuDeleteOnAction()

	@FXML
	private void ctxmnuDescendantsOnAction(ActionEvent event)
	{
		EntryMessage log4jEntryMsg = LOGGER.traceEntry("ctxmnuDescendantsOnAction()");
		PurchaseItemBean ixBean = tbvItems.getSelectionModel().getSelectedItem().pib;
		if (ixBean == null)
		{
			return;
		}
		
		boolean hasDescendant = false;
		try {
			hasDescendant = ixBean.hasDescendant();
		} catch (GNDBException ex) {
				PanicHandler.panic(ex);
		}
		if (!hasDescendant)
		{
			return;
		}
		
		StoryLineTree<? extends INotebookBean> history = StoryLineTree.emptyTree();
		try {
			history = ixBean.getDescendants();
		} catch (GNDBException ex) {
			PanicHandler.panic(ex);
		}
		StoryLineTree<DiaryBean> beanTree = history.copyTree(bean -> {
			LOGGER.debug("copying: {}", bean);
			return switch (bean.getType())
					{
						case PURCHASEITEM -> DiaryBean.from((PurchaseItemBean) bean);
						case HUSBANDRY -> DiaryBean.from((HusbandryBean) bean);
						case AFFLICTIONEVENT -> DiaryBean.from((AfflictionEventBean) bean);
						case GROUNDWORK -> DiaryBean.from((GroundworkBean) bean);
						default -> null;
					};
			});
		StoryLineTab tabCon = new StoryLineTab();
		loadTab.accept(resources.getString("tab.descendants"), tabCon);
		tabCon.setHistory(beanTree);
		LOGGER.traceExit(log4jEntryMsg);
	}	//	ctxmnuDescendantsOnAction()


	@FXML
	private void ctxmnuDescendantHusbandryOnAction(ActionEvent event)
	{
		EntryMessage log4jEntryMsg = LOGGER.traceEntry("ctxmnuNewHusbandryOnAction()");
		final PurchaseItemBean piBean = tbvItems.getSelectionModel().getSelectedItem().pib;
		if (piBean == null)
		{
			return;
		}
        //  2.6.1   the PI doesn't actually exist, so cannot sensibly give it a dependant
        //          (the menu option shouldn't actually be available, but belt and braces!)
        if (addingItem || piBean.isNew())
        {
            return;
        }
		
		final HusbandryEditor tabCon = new HusbandryEditor(piBean, true);
		loadTab.accept(resources.getString("tab.husbandry"), tabCon);
		LOGGER.debug("ctxmnuDescendantHusbandryOnAction(): after pop-up");
		tabCon.newBeanProperty().addListener((_, _, newVal) -> {
			LOGGER.debug("ctxmnuDescendantHusbandryOnAction(): newBean listener: newVal: {}", newVal);
			try {
				newVal.setAncestor(piBean);
			} catch (GNDBException ex) {
				PanicHandler.panic(ex);
			}
		});
		LOGGER.traceExit(log4jEntryMsg);
	}	//	ctxmnuDescendantHusbandryOnAction()

	@FXML
	private void ctxmnuDescendantGroundworkOnAction(ActionEvent event)
	{
		EntryMessage log4jEntryMsg = LOGGER.traceEntry("ctxmnuDescendantGroundworkOnAction()");
		final PurchaseItemBean piBean = tbvItems.getSelectionModel().getSelectedItem().pib;
		if (piBean == null)
		{
			return;
		}
        //  2.6.1   the PI doesn't actually exist, so cannot sensibly give it a dependant
        //          (the menu option shouldn't actually be available, but belt and braces!)
        if (addingItem || piBean.isNew())
        {
            return;
        }
		
		final GroundworkEditor tabCon = new GroundworkEditor(piBean, true);
		loadTab.accept(resources.getString("tab.groundwork"), tabCon);
		LOGGER.debug("ctxmnuDescendantGroundworkOnAction(): after pop-up");
		tabCon.newBeanProperty().addListener((_, _, newVal) -> {
			LOGGER.debug("ctxmnuDescendantGroundworkOnAction(): newBean listener: newVal: {}", newVal);
			try {
				newVal.setAncestor(piBean);
			} catch (GNDBException ex) {
				PanicHandler.panic(ex);
			}
		});
		LOGGER.traceExit(log4jEntryMsg);
	}	//	ctxmnuDescendantGroundworkOnAction()

	@FXML
	private void ctxmnuDescendantAfflictionOnAction(ActionEvent event)
	{
		EntryMessage log4jEntryMsg = LOGGER.traceEntry("ctxmnuDescendantAfflictionOnAction()");
		final PurchaseItemBean piBean = tbvItems.getSelectionModel().getSelectedItem().pib;
		if (piBean == null)
		{
			return;
		}
        //  2.6.1   the PI doesn't actually exist, so cannot sensibly give it a dependant
        //          (the menu option shouldn't actually be available, but belt and braces!)
        if (addingItem || piBean.isNew())
        {
            return;
        }
		
		final AfflictionEventEditor tabCon = new AfflictionEventEditor(piBean, true);
		loadTab.accept(resources.getString("tab.affliction"), tabCon);
		LOGGER.debug("ctxmnuDescendantAfflictionOnAction(): after pop-up");
		tabCon.newBeanProperty().addListener((_, _, newVal) -> {
			LOGGER.debug("ctxmnuDescendantAfflictionOnAction(): newBean listener: newVal: {}", newVal);
			try {
				newVal.setAncestor(piBean);
			} catch (GNDBException ex) {
				PanicHandler.panic(ex);
			}
		});
		LOGGER.traceExit(log4jEntryMsg);
	}	//	ctxmnuDescendantAfflictionOnAction()

    //  2.7.0
	@FXML
	private void ctxmnuShopOnShowing(WindowEvent event)
	{
		EntryMessage log4jEntryMsg = LOGGER.traceEntry("ctxmnuShopOnShowing()");

        List<ShoppingListBean> splis = tblShoppingList.getSelectionModel().getSelectedItems();
        ctxmnuShopCopyToPU.setDisable(true);
        for (var sli : splis)
        {
            if (!sli.hasValidProduct())
                continue;
            if (shopForDelete.getOrDefault(sli.getKey(), false))
                continue;
            ctxmnuShopCopyToPU.setDisable(false);
            return;
        }
    }

    //  2.7.0
	@FXML
	private void ctxmnuShopMarkDeleteOnAction(ActionEvent event)
	{
		EntryMessage log4jEntryMsg = LOGGER.traceEntry("ctxmnuShopMarkDeleteOnAction()");
        List<ShoppingListBean> splis = tblShoppingList.getSelectionModel().getSelectedItems();
        for (var sli : splis)
        {
            shopForDelete.put(sli.getKey(), true);
            
        }
        tblShoppingList.refresh();
    }

    /**
     * Mark the ShoppingList entry as NOT to be deleted even though it's been added to the current Purchase.
     * 
     * @param event unused
     */
	@FXML
	private void ctxmnuShopNoDeleteOnAction(ActionEvent event)
	{
		EntryMessage log4jEntryMsg = LOGGER.traceEntry("ctxmnuShopNoDeleteOnAction()");
        List<ShoppingListBean> splis = tblShoppingList.getSelectionModel().getSelectedItems();
        for (var sli : splis)
        {
            shopForDelete.put(sli.getKey(), false);
            
        }
        tblShoppingList.refresh();
    }
    
    @FXML
    private void ctxmnuShopCopyToPUOnAction(ActionEvent event)
    {
		EntryMessage log4jEntryMsg = LOGGER.traceEntry("ctxmnuShopCopyToPU()");
        
        for (var sli : tblShoppingList.getSelectionModel().getSelectedItems())
        {
            if (sli.get().isPresent() && sli.hasValidProduct() && 
                !shopForDelete.getOrDefault(sli.getValue().get().getKey(), false))
            {
                addSLItoPU(sli);
            }
        }
        tblShoppingList.refresh();
    }

    /**
     * If no actual product is selected, disable the Copy action.
     * 
     * @param event unused
     */
    @FXML
    private void ctxmnuProdOnShowing(WindowEvent event)
    {
		EntryMessage log4jEntryMsg = LOGGER.traceEntry("ctxmnuProdOnShowing");

        ctxmnuProdCopyToPU.setDisable(true);
        if (tbvProducts.getSelectionModel().isEmpty())
        {
            return;
        }
        //  can only D&D Products, not Categories
        for (var pr : tbvProducts.getSelectionModel().getSelectedItems())
        {
            if (pr.getValue().getNodeType() == NotebookEntryType.PRODUCT)
            {
                ctxmnuProdCopyToPU.setDisable(false);
                return;
            }
        }
    }
    
    @FXML
    private void ctxmnuProdCopyToPUOnAction(ActionEvent event)
    {
		EntryMessage log4jEntryMsg = LOGGER.traceEntry("ctxmnuProdCopyToPUOnAction()");
        
        dropProdOnPIs();
        tblShoppingList.refresh();
    }

	@FXML
	private void btnCancelOnAction(ActionEvent event)
	{
		EntryMessage log4jEntryMsg = LOGGER.traceEntry("btnCancelOnAction()");
		if (thisValueBean == null)
		{
			LOGGER.debug("PurchaseEditorController: Cancel: Bean is null!");
			throw new IllegalStateException("PurchaseEditorController: Cancel: Bean is null!");
		}

		if (isCancelDenied())
		{
			return;
		}

		thisValueBean.cancelEdit();
		for (PIentry item : tbvItems.getItems())
		{
			item.pib.cancelEdit();
		}
		clearTab.accept(this);
		LOGGER.traceExit(log4jEntryMsg);
	}

	/**
	 * Check if user really wants to quit if changes have been made
	 *
	 * @return	true	user does NOT want to quit
	 */
	private boolean isCancelDenied()
	{
		//	check if PIs need saving
		boolean savePI = checkPINeedSave();

		if (thisValueBean.needSave() || savePI)
		{
			Alert checkQuit = new Alert(Alert.AlertType.CONFIRMATION, resources.getString("alert.confirmquit"), ButtonType.NO, ButtonType.YES);
			Optional<ButtonType> result = checkQuit.showAndWait();
			LOGGER.debug("after delete dialog: result:{}, result.get:{}",result, result.orElse(null));
			if (result.isPresent() && result.get() == ButtonType.NO)
			{
				LOGGER.debug("after quit denied");
				return true;
			}
		}
		return false;
	}

	private boolean checkPINeedSave()
	{
		//	3.2.1	if sub-list is empty it crashes
		if (tbvItems.getItems().isEmpty())
		{
			return false;
		}
		for (PIentry pie : tbvItems.getItems().subList(0,tbvItems.getItems().size()-1))
		{
			if (pie.pib.needSave())
			{
				return true;
			}
		}
		return false;
	}

	@FXML
	private void btnDeleteOnAction(ActionEvent event)
	{
		EntryMessage log4jEntryMsg = LOGGER.traceEntry("btnDeleteOnAction()");
		if (thisValueBean == null)
		{
			LOGGER.debug("PurchaseEditorController: Delete: Bean is null!");
			throw new IllegalStateException("PurchaseEditorController: Delete: Bean is null!");
		}
        
		final NotebookBeanDeleter<PurchaseBean> deleterImpl = new NotebookBeanDeleter<>(resources);
		if (deleterImpl.deleteItemImpl(thisValueBean))
		{
			deletedBeanProperty().setValue(new Object());
			clearTab.accept(this);
		}

		LOGGER.traceExit(log4jEntryMsg);
	}

	@FXML
	private void btnSaveOnAction(ActionEvent event)
	{
		EntryMessage log4jEntryMsg = LOGGER.traceEntry("btnSaveOnAction(): addingItem: {}", addingItem);
		if (thisValueBean == null)
		{
			LOGGER.debug("PurchaseEditorController(): Save: Bean is null!");
			return;
		}

        // check everything CAN be saved!
		if (!thisValueBean.canSave())
			return;
		try {
			thisValueBean.save();
		} catch (GNDBException ex) {
			PanicHandler.panic(ex);
		}

        LOGGER.debug("Save(): after save purchase, thisValueBean: {}", thisValueBean);
        LOGGER.debug("Save(): tbvItems.getItems().size(): {}", tbvItems.getItems().size());
		
		if (tbvItems.getItems().size() <2)
		{// only the dummy item
			newBeanProperty().setValue(thisValueBean);	//	2.9.6
			clearTab.accept(this);
			return;
		}
		final List<PurchaseItemBean> pibs = tbvItems.getItems().subList(0, tbvItems.getItems().size()-1).stream().map(p -> p.pib).collect(Collectors.toList());
        LOGGER.debug("Save(): pibs: {}", pibs);

		if (addingItem)
		{// a new purchase, so need to set the Purchase parent of all the PurchaseItems - EXCEPT the last, dummy item
			LOGGER.debug("PurchaseEditorController(): Save: setting parent of PIs for new Purchase");
			for (PurchaseItemBean pib : pibs)
			{
				LOGGER.debug("Save(): setting Purchase for PI: {}", pib);
				pib.setPurchase(thisValueBean);
				LOGGER.debug("Save(): after setting purchase, purchase: {}", pib.getPurchase());
			}
		}
		
		for (PurchaseItemBean pib : pibs)
		{
			LOGGER.debug("PurchaseEditorController(): Save: checking can save Product: {}", pib.getProduct());
			if (!pib.getProduct().canSave())
			{
				LOGGER.debug("PurchaseEditorController(): Save: cannot save Product: {}", pib.getProduct());
				return;
			}
			try {
				pib.getProduct().save();
			} catch (GNDBException ex) {
				PanicHandler.panic(ex);
			}
		}
		
		for (PurchaseItemBean pib : pibs)
		{
			LOGGER.debug("PurchaseEditorController(): Save: checking can save PI: {}", pib);
			if (!pib.canSave())
			{
				LOGGER.debug("PurchaseEditorController(): Save: cannot save PI: {}", pib);
				return;
			}
		}
		for (PurchaseItemBean pib : pibs)
		{
			LOGGER.debug("PurchaseEditorController(): Save: save PI {}", pib);
			try {
				pib.save();
			} catch (GNDBException ex) {
				PanicHandler.panic(ex);
			}
		}
        
        //  2.7.0   clear out any ShoppingList items now fulfilled
        for (var slb : tblShoppingList.getItems())
        {
            if (shopForDelete.getOrDefault(slb.getKey(), false)  /*&&
                !shopDoNotDelete.getOrDefault(slb.getKey(), false)*/ )
            {
                try
                {
                    if (slb.canDelete())
                    {
                        slb.delete();
                    }
                } catch (GNDBException ex) {
					PanicHandler.panic(ex);
                }
            }
        }

        //	set any 'watch for' actions
		for (final PIentry pie : tbvItems.getItems().subList(0, tbvItems.getItems().size()-1))
		{
			LOGGER.debug("watch for loop: pie: {}", pie);
			if ( (pie.watchActionHusbandry.get() == null) && (pie.watchActionGroundwork.get() == null) ) continue;
			if (!pie.watchAllowed) continue;

			LocalDate chosen = pie.watchDate.get();
			if ( (chosen != null) && chosen.isAfter(LocalDate.now().plusDays(1)))
			{
				ReminderBean rb = new ReminderBean();
				rb.setPurchaseItem(pie.pib);
				rb.setHusbandryClass(pie.watchActionHusbandry.get());
				rb.setGroundworkActivity(pie.watchActionGroundwork.get());
				rb.setPlantSpecies(pie.pib.getPlantSpecies());
				rb.setPlantVariety(pie.pib.getPlantVariety());
				rb.setShowFrom(pie.watchDate.get());
				rb.setDescription(MessageFormat.format(resources.getString("text.purchaseitem.watchfor"),
						pie.pib.getProduct().getName(),
						pie.pib.getProduct().getNameDetail_1(),
						thisValueBean.getDate().format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT))));
				try
				{
					rb.save();
				} catch (GNDBException ex)
				{
					PanicHandler.panic(ex);
				}
			}
			else
			{
				ToDoListBean tdlb = new ToDoListBean();
				tdlb.setPurchaseItem(pie.pib);
				tdlb.setHusbandryClass(pie.watchActionHusbandry.get());
				tdlb.setGroundworkActivity(pie.watchActionGroundwork.get());
				tdlb.setPlantSpecies(pie.pib.getPlantSpecies());
				tdlb.setPlantVariety(pie.pib.getPlantVariety());
				tdlb.setDescription(MessageFormat.format(resources.getString("text.purchaseitem.watchfor"),
						pie.pib.getProduct().getName(),
						pie.pib.getProduct().getNameDetail_1(),
						thisValueBean.getDate().format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT))));
				try
				{
					tdlb.save();
				} catch (GNDBException ex)
				{
					PanicHandler.panic(ex);
				}
			}
		}
        
		newBeanProperty().setValue(thisValueBean);
		clearTab.accept(this);
		LOGGER.traceExit(log4jEntryMsg);
	}	//	btnSaveOnAction()
	
	@FXML
	private void tbvItemsOnDragOver(DragEvent ev)
	{
		Dragboard db = ev.getDragboard();
		if ( db.hasString() && 
			(db.getString() != null) &&
			(!db.getString().isEmpty()) &&
			( (db.getString().startsWith("tbvProducts:") || (db.getString().startsWith("tblShoppingList:")) )))
		{
			ev.acceptTransferModes(TransferMode.COPY_OR_MOVE);
		}
		ev.consume();
		
	}

	@FXML
	private void tbvItemsOnDragDropped(DragEvent ev)
	{
		Dragboard db = ev.getDragboard();
		boolean success = false;
		if ( db.hasString() && 
			(db.getString() != null) &&
			(!db.getString().isEmpty()) )
		{
            if (db.getString().startsWith("tbvProducts:"))
            {
                success = true;
                dropProdOnPIs();
            }
            else if (db.getString().startsWith("tblShoppingList:"))
            {
                success = true;
                dropShopOnPIs();
            }
		}
		ev.setDropCompleted(success);
		ev.consume();
	}
	
	/*
	 * Create PurchaseItems in the Purchase using the selected Products
	 */
	private void dropProdOnPIs()
	{
		final var prods = tbvProducts.getSelectionModel().getSelectedItems();
		for (var pr : prods)
		{
            if (pr.getValue().getNodeType() == NotebookEntryType.PRODUCT)
            {
                PurchaseItemBean pib_new = new PurchaseItemBean();
                pib_new.setProduct(pr.getValue().getProductBean());
                if (!addingItem)
                {
                    pib_new.setPurchase(thisValueBean);
                }
                pib_new.setQuantity(BigDecimal.ONE);
                setOwnBrand(pib_new);
                tbvItems.getItems().add(tbvItems.getItems().size()-1, new PIentry(pib_new));
            }
		}
	}

	/*
	 * Create PurchaseItems in the Purchase using the selected Products in the ShoppingList
	 */
	private void dropShopOnPIs()
	{
		final var slis = tblShoppingList.getSelectionModel().getSelectedItems();
		for (var sli : slis)
		{
            if (sli.hasValidProduct() && 
                !shopForDelete.getOrDefault(sli.getValue().get().getKey(), false) )
            {
                addSLItoPU(sli);
            }
		}
	}
    
    private void addSLItoPU(ShoppingListBean sli)
    {
        PurchaseItemBean pib_new = new PurchaseItemBean();
        pib_new.setProduct(sli.getProduct());
        if (!addingItem)
        {
            pib_new.setPurchase(thisValueBean);
        }
        pib_new.setQuantity(BigDecimal.ONE);
        setOwnBrand(pib_new);
        tbvItems.getItems().add(tbvItems.getItems().size()-1, new PIentry(pib_new));

        shopForDelete.putIfAbsent(sli.getKey(), true);
        prodFromShop.put(sli.getValue().get().getProduct().get().getKey(), sli.getKey());
    }
    
    private void setOwnBrand(PurchaseItemBean pib_new)
    {
        if (!pib_new.hasProductBrand())
        {
            if (useOwnBrand)
            {
                pib_new.setProductBrand(ownBrand);
            }
        }
    }

	SimpleObjectProperty<PurchaseBean> newBeanProperty()
	{
		if (newBean == null)
		{
			newBean = new SimpleObjectProperty<>();
		}
		return newBean;
	}

	SimpleObjectProperty<Object> deletedBeanProperty()
	{
		if (deletedBean == null)
		{
			deletedBean = new SimpleObjectProperty<>();
		}
		return deletedBean;
	}

    
    /**
     * Implement Drag&Drop to copy ShoppingList items to the PurchaseItems list.
     * Main benefit is a decent row image when copying.
     * 
     * @since 2.7.0
     */
    private class ShopListTableRow extends TableRow<ShoppingListBean>
    {
        ShopListTableRow()
        {
            setOnDragDetected(ev -> {
            		if (tblShoppingList.getSelectionModel().isEmpty())
                    {
                        ev.consume();
                        return;
                    }

                    int size = 0;
                    for (var sli : tblShoppingList.getSelectionModel().getSelectedItems())
                    {
                    //  can only D&D Products, not non-specific items (PurchaseItems need a Product)
                        // if the item is marked 'for delete' it must already have been added
                        if (sli.hasValidProduct() &&
                            !shopForDelete.getOrDefault(sli.getValue().get().getKey(), false))
                        {
                            size++;
                        }
                    }
                    if (size <= 0)
                    {
                        ev.consume();
                        return;
                    }
                    Dragboard db = startDragAndDrop(TransferMode.COPY_OR_MOVE);
                    db.setDragView(snapshot(null, null));
                    ClipboardContent content = new ClipboardContent();
                    content.putString("tblShoppingList:"+size);
                    db.setContent(content);
                    ev.consume();
            });
            
            // when the drag is complete, force a redisplay so that TextFill changes happen
            // The isDropCompleted() method ALWAYS returns FALSE
            setOnDragDone(eh -> {
                if (eh.isAccepted())
                {
                    getTableView().refresh();
                }
                eh.consume();
            });
            
        }
        @Override
        protected void updateItem(ShoppingListBean item, boolean empty)
        {
            super.updateItem(item, empty);
            this.setGraphic(null);
            if (empty || this.getItem() == null)
            {
                this.setText(null);
                return;
            }
            if (item == null) return;
            final int ix = item.getKey();
            if (shopForDelete.getOrDefault(ix, false))
            {
                this.setStyle(resources.getString("row.purchase.shop.deletestyle"));
            }
        }
    }   //  ShopListTableRow

    /**
     * Implement Drag&Drop to copy Product items to the PurchaseItems list.
     * Main benefit is a decent row image when copying.
     * 
     * @since 2.7.0
     */
    private class ProdListTableRow extends TreeTableRow<ProductTreeBean>
    {
        ProdListTableRow()
        {
            setOnDragDetected(ev -> {
                if (tbvProducts.getSelectionModel().isEmpty())
                {
                    ev.consume();
                    return;
                }
                //  can only D&D Products, not Categories
                int size = 0;
                for (var pr : tbvProducts.getSelectionModel().getSelectedItems())
                {
                    if (pr.getValue().getNodeType() == NotebookEntryType.PRODUCT)
                    {
                        size++;
                    }
                }
                if (size <= 0)
                {
                    ev.consume();
                    return;
                }
                Dragboard db = startDragAndDrop(TransferMode.COPY);
                db.setDragView(snapshot(null, null));
                ClipboardContent content = new ClipboardContent();
                content.putString("tbvProducts:"+size);
                db.setContent(content);
                ev.consume();
            });
            
        }
    }


    private static class PIentry implements INotebookBean
	{
		final PurchaseItemBean pib;
		final boolean watchAllowed;
		SimpleObjectProperty<HusbandryClassBean> watchActionHusbandry = new SimpleObjectProperty<>(this, "watchActionHusbandry", null);
		SimpleObjectProperty<GroundworkActivityBean> watchActionGroundwork = new SimpleObjectProperty<>(this, "watchActionGroundwork", null);
		SimpleObjectProperty<LocalDate> watchDate = new SimpleObjectProperty<>(this, "watchDate", null);

		PIentry(PurchaseItemBean pib)
		{
			this(pib, null, null, null);
		}

		PIentry(PurchaseItemBean pib, HusbandryClassBean watchActionHusbandry, GroundworkActivityBean watchActionGroundwork, LocalDate watchDate)
		{
			LOGGER.debug("PIentry: constructor: pib: {}", pib);
			this.pib = pib;
			watchAllowed = pib.isNew() || ( (watchActionHusbandry == null) && (watchActionGroundwork == null) );
			this.watchActionHusbandry.setValue(watchActionHusbandry);
			this.watchActionGroundwork.setValue(watchActionGroundwork);
			this.watchDate.setValue(watchDate);
//			LOGGER.debug("PIentry: constructor: hash: {}", this.);
		}

		@Override
		public Integer getKey()
		{
			return pib.getKey();
		}

		@Override
		public NotebookEntryType getType()
		{
			return pib.getType();
		}

		@Override
		public boolean sameAs(INotebookBean other)
		{
			return pib.sameAs(other);
		}

		@Override
		public boolean isNew()
		{
			return pib.isNew();
		}

		@Override
		public ReadOnlyBooleanProperty isNewProperty()
		{
			return pib.isNewProperty();
		}

		@Override
		public boolean canDelete() throws GNDBException
		{
			return pib.canDelete();
		}

		@Override
		public ReadOnlyBooleanProperty canDeleteProperty() throws GNDBException
		{
			return pib.canDeleteProperty();
		}

		@Override
		public void delete()  throws GNDBException
		{}

		@Override
		public boolean hasAncestor() throws GNDBException
		{
			return pib.hasAncestor();
		}

		@Override
		public ReadOnlyBooleanProperty hasAncestorProperty() throws GNDBException
		{
			return pib.hasAncestorProperty();
		}

		@Override
		public StoryLineTree<? extends INotebookBean> getAncestors() throws GNDBException
		{
			return pib.getAncestors();
		}

		@Override
		public boolean hasDescendant() throws GNDBException
		{
			return pib.hasDescendant();
		}

		@Override
		public ReadOnlyBooleanProperty hasDescendantProperty() throws GNDBException
		{
			return pib.hasDescendantProperty();
		}

		@Override
		public StoryLineTree<? extends INotebookBean> getDescendants() throws GNDBException
		{
			return pib.getDescendants();
		}

		@Override
		public ObservableList<CommentBean> getComments()
		{
			return pib.getComments();
		}

		@Override
		public ReadOnlyStringProperty commentTextProperty()
		{
			return pib.commentTextProperty();
		}

		@Override
		public void addComment(String text) throws GNDBException
		{
			pib.addComment(text);
		}

		@Override
		public void addComment(CommentBean comment) throws GNDBException
		{
			LOGGER.debug("PIentry: addComment: {}", comment);
			pib.addComment(comment);
		}

		@Override
		public void changeCommentText(CommentBean comment, String text) throws GNDBException
		{
			pib.changeCommentText(comment, text);
		}

		@Override
		public void changeCommentDate(CommentBean comment, LocalDate date) throws GNDBException
		{
			pib.changeCommentDate(comment, date);
		}

		@Override
		public void deleteComment(CommentBean comment) throws GNDBException
		{
			pib.deleteComment(comment);
		}

		@Override
		public void save() throws GNDBException
		{
			// 3.0.1	does nothing - added to meet changed INotebookBean
		}

		@Override
		public String toString()
		{
			return "PIentry wrapping: pib: " + pib +
					", hcb: " + watchActionHusbandry.get() +
					", gwa: " + watchActionGroundwork.get() +
					", after: " + watchDate.get();
		}
	}

}
