content: /*
   *  Licensed to the Apache Software Foundation (ASF) under one
   *  or more contributor license agreements.  See the NOTICE file
   *  distributed with this work for additional information
   *  regarding copyright ownership.  The ASF licenses this file
   *  to you under the Apache License, Version 2.0 (the
   *  "License"); you may not use this file except in compliance
   *  with the License.  You may obtain a copy of the License at
   *
   *        http://www.apache.org/licenses/LICENSE-2.0
   *
   *  Unless required by applicable law or agreed to in writing,
   *  software distributed under the License is distributed on an
   *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
   *  KIND, either express or implied.  See the License for the
   *  specific language governing permissions and limitations
   *  under the License.
   */
  package org.apache.causeway.core.metamodel.tabular.internal;
  
  import java.util.Comparator;
  import java.util.Optional;
  import java.util.Set;
  import java.util.concurrent.atomic.AtomicBoolean;
  import java.util.function.Predicate;
  import java.util.stream.Collectors;
  import java.util.stream.IntStream;
  
  import org.jspecify.annotations.NonNull;
  import org.jspecify.annotations.Nullable;
  
  import org.apache.causeway.applib.Identifier;
  import org.apache.causeway.applib.annotation.TableDecorator;
  import org.apache.causeway.applib.annotation.Where;
  import org.apache.causeway.applib.services.filter.CollectionFilterService;
  import org.apache.causeway.commons.binding.Bindable;
  import org.apache.causeway.commons.collections.Can;
  import org.apache.causeway.commons.functional.IndexedFunction;
  import org.apache.causeway.commons.internal.binding._BindableAbstract;
  import org.apache.causeway.commons.internal.binding._Bindables;
  import org.apache.causeway.commons.internal.binding._Observables;
  import org.apache.causeway.commons.internal.binding._Observables.LazyObservable;
  import org.apache.causeway.commons.internal.collections._Streams;
  import org.apache.causeway.commons.internal.exceptions._Exceptions;
  import org.apache.causeway.core.metamodel.interactions.managed.ActionInteraction;
  import org.apache.causeway.core.metamodel.interactions.managed.ManagedAction;
  import org.apache.causeway.core.metamodel.interactions.managed.ManagedCollection;
  import org.apache.causeway.core.metamodel.interactions.managed.ManagedMember;
  import org.apache.causeway.core.metamodel.object.ManagedObject;
  import org.apache.causeway.core.metamodel.object.ManagedObjects;
  import org.apache.causeway.core.metamodel.object.PackedManagedObject;
  import org.apache.causeway.core.metamodel.spec.ObjectSpecification;
  import org.apache.causeway.core.metamodel.spec.feature.ObjectMember;
  import org.apache.causeway.core.metamodel.tabular.DataColumn;
  import org.apache.causeway.core.metamodel.tabular.DataRow;
  import org.apache.causeway.core.metamodel.tabular.DataTableInteractive;
  import org.apache.causeway.core.metamodel.tabular.DataTableMemento;
  import org.apache.causeway.core.metamodel.tabular.internal.FilterUtils.FilterHandler;
  import org.apache.causeway.core.metamodel.tabular.simple.DataTable;
  
  import lombok.AccessLevel;
  import lombok.AllArgsConstructor;
  import lombok.Getter;
  import lombok.experimental.Accessors;
  
  public class DataTableInternal
  implements DataTableInteractive {
  
      // -- FACTORIES
  
      public static DataTableInternal empty(final ManagedMember managedMember, final Where where) {
          return new DataTableInternal(managedMember, where, Can.empty());
      }
  
      public static DataTableInternal forCollection(
              final ManagedCollection managedCollection) {
          return new DataTableInternal(managedCollection, managedCollection.getWhere(),
              managedCollection
              .streamElements()
              .collect(Can.toCan()));
      }
  
      public static DataTableInternal forAction(
              final ManagedAction managedAction,
              final ManagedObject actionResult) {
  
          if(actionResult==null) {
              new DataTableInternal(managedAction, managedAction.getWhere(), Can.empty());
          }
          if(!(actionResult instanceof PackedManagedObject)) {
              throw _Exceptions.unexpectedCodeReach();
          }
  
          var elements = ((PackedManagedObject)actionResult).unpack();
          elements.forEach(ManagedObject::getBookmark);
  
          return new DataTableInternal(managedAction, managedAction.getWhere(), elements);
      }
  
      // -- CONSTRUCTION
  
      // as this is a layer of abstraction, don't expose via getter
      final @NonNull ManagedMember managedMember;
      final @NonNull Where where;
  
      @Accessors(fluent=true)
      @Getter private final @NonNull LazyObservable<Can<ManagedObject>> dataElementsObservable;
  
      @Accessors(fluent=true)
      @Getter private final @NonNull _BindableAbstract<String> searchArgumentBindable; // filter the data rows
  
      @Accessors(fluent=true)
      @Getter private final @NonNull LazyObservable<Can<DataRow>> dataRowsObservable;
  
      @Accessors(fluent=true)
      @Getter private final @NonNull LazyObservable<Can<DataRow>> dataRowsFilteredAndSortedObservable;
  
      @Accessors(fluent=true)
      @Getter private final @NonNull LazyObservable<Can<DataRow>> dataRowsSelectedObservable;
  
      @Accessors(fluent=true)
      @Getter private final _BindableAbstract<ColumnSort> columnSortBindable;
  
      @Accessors(fluent=true)
      @Getter private final @NonNull LazyObservable<Can<DataColumn>> dataColumnsObservable;
  
      @Accessors(fluent=true)
      @Getter private final @NonNull LazyObservable<String> titleObservable;
  
      private final Optional<FilterHandler> filterHandler;
  
      /**
       * On data row selection changes (originating from UI),
       * the framework updates this {@link Bindable},
       * such that any listeners do get notified.
       */
      private final _BindableAbstract<Boolean> selectionChanges;
  
      private DataTableInternal(
              // we need access to the owner in support of imperative title and referenced column detection
              final ManagedMember managedMember,
              final Where where,
              final Can<ManagedObject> elements) {
  
          this.managedMember = managedMember;
          this.where = where;
          this.filterHandler = FilterUtils.createFilterHandler(managedMember.getElementType());
  
          this.searchArgumentBindable = _Bindables.forValue("");
          this.columnSortBindable = _Bindables.forValue(null);
  
          this.dataElementsObservable = _Observables.lazy(()->elements);
  
          this.dataRowsObservable = _Observables.lazy(()->
              dataElementsObservable.getValue().stream()
                  .map(IndexedFunction.zeroBased((rowIndex, element)->new DataRowInternal(rowIndex, this, element, tokens(element))))
                  .collect(Can.toCan()));
  
          this.dataRowsFilteredAndSortedObservable = _Observables.lazy(()->
              _Streams.sortConditionally(
                  dataRowsObservable.getValue().stream().filter(adaptSearchPredicate()),
                      sortingComparator().orElse(null))
                  .collect(Can.toCan()));
  
          this.dataRowsSelectedObservable = _Observables.lazy(()->
              dataRowsObservable.getValue().stream()
                  .filter(dataRow->dataRow.selectToggleBindable().getValue().booleanValue())
                  .collect(Can.toCan()));
  
          this.selectionChanges = _Bindables.forValue(Boolean.FALSE);
  
          this.searchArgumentBindable.addListener((e,o,n)->{
              dataRowsFilteredAndSortedObservable.invalidate();
          });
  
          this.columnSortBindable.addListener((e,o,n)->{
              dataRowsFilteredAndSortedObservable.invalidate();
          });
  
          this.dataColumnsObservable = _Observables.lazy(()->
              managedMember.getElementType()
                  .streamAssociationsForColumnRendering(managedMember.getIdentifier(), managedMember.getOwner())
                  .map(assoc->new DataColumnInternal(this, assoc))
                  .collect(Can.toCan()));
  
          //XXX future extension: the title could dynamically reflect the number of elements selected
          //eg... 5 Orders selected
          this.titleObservable = _Observables.lazy(()->
              managedMember
              .getFriendlyName());
      }
  
      @Override
      public boolean isSearchSupported() {
          return filterHandler.isPresent();
      }
  
      @Override
      public int getPageSize(final int pageSizeDefault) {
          return getMetaModel().getPageSize().orElse(pageSizeDefault);
      }
  
      @Override
      public Optional<TableDecorator> getTableDecoratorIfAny() {
          return getMetaModel().getTableDecorator();
      }
  
      /**
       * Count all data rows (the user is allowed to see).
       */
      public int getVisibleElementCount() {
          return dataElementsObservable.getValue().size();
      }
  
      /**
       * Count filtered data rows.
       */
      @Override
      public int getFilteredElementCount() {
          return dataRowsFilteredAndSortedObservable.getValue().size();
      }
  
      @Override
      public ObjectMember getMetaModel() {
          return managedMember.getMetaModel();
      }
  
      public ObjectSpecification getElementType() {
          return getMetaModel().getElementType();
      }
  
      @Override
      public Optional<DataRow> lookupDataRow(final int rowIndex) {
          return dataRowsObservable().getValue().get(rowIndex)
                  .map(DataRow.class::cast);
      }
  
      // -- FILTER
  
      @Override
      public String getSearchPromptPlaceholderText() {
          return filterHandler.map(FilterHandler::searchPromptPlaceholderText)
                  .orElse("");
      }
  
      private Predicate<DataRow> adaptSearchPredicate() {
          return filterHandler.isEmpty()
                  ? dataRow->true
                  : dataRow->filterHandler.get().getDataRowFilter()
                      .test(dataRow, searchArgumentBindable.getValue());
      }
  
      private CollectionFilterService.@Nullable Tokens tokens(final ManagedObject element){
          return filterHandler.isEmpty()
                  ? null
                  : filterHandler.get().tokenizer().apply(element.getPojo());
      }
  
      // -- SORTING
  
      private Optional<Comparator<DataRow>> sortingComparator() {
          return Optional.ofNullable(columnSortBindable.getValue())
                  .flatMap(sort->sort.asComparator(dataColumnsObservable.getValue()))
                  .or(()->managedMember.getMetaModel().getElementComparator())
                  .map(elementComparator->(rowA, rowB)->elementComparator.compare(rowA.rowElement(), rowB.rowElement()));
      }
  
      // -- TOGGLE ALL
  
      private final AtomicBoolean isProgrammaticToggle = new AtomicBoolean();
  
      @Override
      public void doProgrammaticToggle(final @NonNull Runnable runnable) {
          try {
              isProgrammaticToggle.set(true);
              runnable.run();
          } finally {
              isProgrammaticToggle.set(false);
              invalidateSelectionThenNotifyListeners();
          }
      }
  
      // -- DATA ROW TOGGLE
  
      void handleRowSelectToggle() {
          if(isProgrammaticToggle.get()) return;
          invalidateSelectionThenNotifyListeners();
      }
  
      private void invalidateSelectionThenNotifyListeners() {
          dataRowsSelectedObservable.invalidate();
          // simply toggles the boolean value, to trigger any listeners
          selectionChanges.setValue(!selectionChanges.getValue());
      }
  
      @Override
      public void selectAll(final boolean select) {
          doProgrammaticToggle(()->{
              dataRowsObservable.getValue()
                  .forEach(dataRow->{
                      dataRow.selectToggleBindable().setValue(select);
                  });
          });
      }
  
      @Override
      public void selectAllFiltered(final boolean select) {
          doProgrammaticToggle(()->{
              dataRowsFilteredAndSortedObservable.getValue()
                  .forEach(dataRow->{
                      dataRow.selectToggleBindable().setValue(select);
                  });
          });
      }
  
      @Override
      public void selectRangeOfRowsByIndex(final IntStream range, final boolean select) {
          doProgrammaticToggle(()->{
              dataRowsFilteredAndSortedObservable.getValue()
                  .pickByIndex(range)
                  .forEach(dataRow->{
                      dataRow.selectToggleBindable().setValue(select);
                  });
          });
      }
  
  //    // -- DATA ROW VISIBILITY
  //
  //    private boolean ignoreHidden(final ManagedObject adapter) {
  //        final InteractionResult visibleResult =
  //                InteractionUtils.isVisibleResult(
  //                        adapter.getSpecification(),
  //                        createVisibleInteractionContext(adapter));
  //        return visibleResult.isNotVetoing();
  //    }
  //
  //    private VisibilityContext createVisibleInteractionContext(final ManagedObject objectAdapter) {
  //        return ObjectVisibilityContext
  //                .createForRegular(objectAdapter, InteractionInitiatedBy.USER, Where.ALL_TABLES);
  //    }
  
      // -- ASSOCIATED ACTION WITH MULTI SELECT
  
      @Override
      public Can<ManagedObject> getSelected() {
          return dataRowsSelectedObservable.getValue()
              .map(DataRow::rowElement);
      }
  
      @Override
      public Set<Integer> getSelectedRowIndexes() {
          return dataRowsSelectedObservable.getValue()
              .stream()
              .map(DataRow::rowIndex)
              .collect(Collectors.toSet());
      }
  
      @Override
      public ActionInteraction startAssociatedActionInteraction(final String actionId, final Where where) {
  
          if(managedMember.getOwner().specialization().isEmpty()
              || managedMember.getOwner().getEntityState().isTransientOrRemoved()) {
              throw _Exceptions.illegalArgument("cannot start action interaction on missing or deleted action owner");
          }
  
          var featureId = managedMember.getIdentifier();
          if(!featureId.type().isPropertyOrCollection()) {
              return ActionInteraction.empty(String.format("[no such collection %s; instead got %s;"
                      + "(while searching for an associated action %s)]",
                      featureId,
                      featureId.type(),
                      actionId));
          }
          return ActionInteraction.startWithMultiselect(managedMember.getOwner(), actionId, where, this);
      }
  
      // -- EXPORT
  
      @Override
      public DataTable export() {
          return new DataTable(
                  getElementType(),
                  titleObservable().getValue(),
                  dataColumnsObservable().getValue()
                      .map(DataColumn::associationMetaModel),
                  dataRowsFilteredAndSortedObservable().getValue()
                      .stream()
                      .map(dr->dr.rowElement())
                      .collect(Can.toCan()));
      }
  
      // used internally for serialization
      private DataTable exportAll() {
          return new DataTable(
                  getElementType(),
                  titleObservable().getValue(),
                  dataColumnsObservable().getValue()
                      .map(DataColumn::associationMetaModel),
                  dataElementsObservable().getValue());
      }
  
      // -- MEMENTO
  
      @Override
      public Memento createMemento() {
          return Memento.create(this);
      }
  
      /**
       * Recreation from given 'bookmarkable' {@link ManagedObject} (owner),
       * without triggering domain events.
       * Either originates from a <i>Collection</i> or an <i>Action</i>'s
       * non-scalar result.
       * <p>
       * Corner case of owner (if entity) having been deleted, must be handled successfully,
       * that is an owner's action result is still semantically valid, even if it was deleted in the process.
       * (e.g. SecMan's {@code ApplicationUser_delete} returning a list of all remaining users)
       * <p>
       * Responsibility for recreation of the owner is with the caller
       * to allow for simpler object graph reconstruction (shared owner).
       */
      @AllArgsConstructor(access = AccessLevel.PRIVATE)
      public static class Memento implements DataTableMemento {
          private static final long serialVersionUID = 1L;
  
          static Memento create(
                  final @NonNull DataTableInternal tableInteractive) {
  
              return new Memento(
                      tableInteractive.managedMember.getIdentifier(),
                      tableInteractive.where,
                      tableInteractive.exportAll(),
                      tableInteractive.searchArgumentBindable.getValue(),
                      tableInteractive.getSelectedRowIndexes(),
                      tableInteractive.columnSortBindable().getValue());
          }
  
          private final @NonNull Identifier featureId;
          private final @NonNull Where where;
          private final @NonNull DataTable dataTable;
  
          private @Nullable String searchArgument;
          private @NonNull Set<Integer> selectedRowIndexes;
          private DataTableInteractive.@Nullable ColumnSort columnSort;
  
          @Override
          public DataTableInternal recreateDataTableModel(final ManagedObject owner) {
              var memberId = featureId.memberLogicalName();
  
              final ManagedMember managedMember = featureId.type().isPropertyOrCollection()
                      ? ManagedCollection.lookupCollection(owner, memberId, where)
                          .orElseThrow()
                      : ManagedAction.lookupAction(owner, memberId, where)
                          .orElseThrow();
  
              var dataTableInteractive = new DataTableInternal(managedMember, where,
                      dataTable.streamDataElements()
                      .peek(obj->{
                          if(obj.specialization().isViewmodel()) {
                              // make sure any referenced entities are made live if currently hollow
                              ManagedObjects.refreshViewmodel(obj, /*bookmark supplier*/ null);
                          }
                      })
                      .collect(Can.toCan()));
  
              if(columnSort!=null)  {
                  dataTableInteractive.columnSortBindable.setValue(columnSort);
              }
              dataTableInteractive.searchArgumentBindable.setValue(searchArgument);
              dataTableInteractive.doProgrammaticToggle(()->{
                  dataTableInteractive.dataRowsObservable.getValue().stream()
                      .filter(dataRow->selectedRowIndexes.contains(dataRow.rowIndex()))
                      .forEach(dataRow->dataRow.selectToggleBindable().setValue(true));
              });
              return dataTableInteractive;
          }
  
          @Override
          public void setupBindings(final DataTableInteractive tableInteractive) {
              tableInteractive.searchArgumentBindable().addListener((e, o, searchArg)->{
                  this.searchArgument = searchArg;
              });
              ((DataTableInternal)tableInteractive).selectionChanges.addListener((e, o, n)->{
                  this.selectedRowIndexes = tableInteractive.getSelectedRowIndexes();
              });
          }
  
      }
  
  }
  