/*
 *  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();
            });
        }

    }

}
