/*
 *  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.services.tablecol;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

import jakarta.annotation.Priority;
import jakarta.inject.Named;

import org.springframework.beans.factory.annotation.Qualifier;
import org.jspecify.annotations.Nullable;
import org.springframework.stereotype.Service;

import org.apache.causeway.applib.annotation.CollectionLayout;
import org.apache.causeway.applib.annotation.PriorityPrecedence;
import org.apache.causeway.applib.annotation.PropertyLayout;
import org.apache.causeway.applib.services.tablecol.TableColumnOrderService;
import org.apache.causeway.commons.internal.resources._Resources;
import org.apache.causeway.commons.io.TextUtils;
import org.apache.causeway.core.metamodel.CausewayModuleCoreMetamodel;

import org.jspecify.annotations.NonNull;
import lombok.extern.log4j.Log4j2;

/**
 * Provides a simple mechanism to order the columns of both parented and standalone collections by reading a flat
 * file containing the list of the associations (usually properties, but collections are also supported), in the
 * desired order, one associationId per line.
 *
 * <p>
 * The files are located relative to the class itself.  A number of conventions are supported:
 *
 * <ul>
 *     <li>
 *         for parented collections:
 *         <ul>
 *             <li> <code>ParentClassName#collectionId.columnOrder.txt</code> </li>
 *             <li> <code>ParentClassName#collectionId.columnOrder.fallback.txt</code> </li>
 *             <li> <code>ParentClassName#_.ElementTypeClassName.columnOrder.txt</code>
 *             <p>
 *                 (where '_' is a wildcard for any collection of the element type
 *             </p>
 *             </li>
 *             <li> <code>ParentClassName#_.ElementTypeClassName.columnOrder.fallback.txt</code></li>
 *             <li> <code>ElementTypeClassName.columnOrder.txt</code></li>
 *             <li> <code>ElementTypeClassName.columnOrder.fallback.txt</code></li>
 *         </ul>
 *     </li>
 *     <li>
 *         for standalone collections:
 *         <ul>
 *             <li> <code>DomainTypeClassName.columnOrder.txt</code></li>
 *             <li> <code>DomainTypeClassName.columnOrder.fallback.txt</code></li>
 *         </ul>
 *     </li>
 * </ul>
 *
 * <p>
 * Any associations omitted from the file will not be shown as columns of the table.  The associationId must also
 * be an exact match, so can be ignored by commenting out, eg with &quot;#&quot;.
 * </p>
 *
 * <p>
 *     Also note that association that have been explicitly hidden from tables using
 *     {@link PropertyLayout#hidden() @PropertyLayout#hidden} or {@link CollectionLayout#hidden()} are never shown,
 *     irrespective of whether they are listed in the files.  You may therefore prefer to <i>not</i> hide properties
 *     with annotations, and then rely solely on these external <i>columnOrder.txt</i> files.  This has the further
 *     benefit that files can be modified at runtime and will be automatically picked up without requiring a restart
 *     of the application.
 * </p>
 *
 * @see TableColumnOrderServiceDefault
 *
 * @since 2.x {@index}
 */
@Service
@Named(CausewayModuleCoreMetamodel.NAMESPACE + ".TableColumnOrderServiceUsingTxtFile")
@Priority(PriorityPrecedence.LATE - 100) // before Default
@Qualifier("UsingFiles")
@Log4j2
public class TableColumnOrderServiceUsingTxtFile implements TableColumnOrderService {

    /**
     * Reads association Ids of the parented collection from a file.
     *
     * <p>
     * The search algorithm is:
     * <ul>
     *     <li> <code>ParentClassName#collectionId.columnOrder.txt</code> </li>
     *     <li> <code>ParentClassName#collectionId.columnOrder.fallback.txt</code> </li>
     *     <li> <code>ParentClassName#_.ElementTypeClassName.columnOrder.txt</code>
     *     <p>
     *         (where '_' is a wildcard for any collection of the element type
     *     </p>
     *     </li>
     *     <li> <code>ParentClassName#_.ElementTypeClassName.columnOrder.fallback.txt</code></li>
     *     <li> <code>ElementTypeClassName.columnOrder.txt</code></li>
     *     <li> <code>ElementTypeClassName.columnOrder.fallback.txt</code></li>
     * </ul>
     * </p>
     *
     * <p>
     * Additional files can be provided by overriding {@link #addResourceNames(Class, String, Class, List)}
     * </p>
     * 
     * @Returns {@code null}, if no matching resource was found
     */
    @Nullable
    @Override
    public List<String> orderParented(
            final Object domainObject,
            final String collectionId,
            final Class<?> elementType,
            final List<String> associationIds) {

        var domainClass = domainObject.getClass();
        var resourceNames = buildResourceNames(domainClass, collectionId, elementType);
        addResourceNames(elementType, resourceNames);   // fallback to reading the element type's own .txt file.
        var contents = tryLoad(domainClass, resourceNames)
                .orElse(null);
        return contentsMatching(contents, associationIds);
    }

    private List<String> buildResourceNames(
            final Class<?> domainClass,
            final String collectionId,
            final Class<?> elementType) {
        var resourceNames = new ArrayList<String>();
        addResourceNames(domainClass, collectionId, elementType, resourceNames);
        return resourceNames;
    }

    /**
     * Builds the list of file names to be read from.
     *
     * <p>
     * The default implementation provides only a single file name, <i>ClassName#collectionId.columnOrder.txt</i>.
     * </p>
     *
     * @param domainClass  - the class with the parent collection
     * @param collectionId - the id of the collection
     * @param addTo        - to be added to
     */
    protected void addResourceNames(
            final Class<?> domainClass,
            final String collectionId,
            final Class<?> elementType,
            final List<String> addTo) {
        addTo.add(String.format("%s#%s.columnOrder.txt", domainClass.getSimpleName(), collectionId));
        addTo.add(String.format("%s#%s.columnOrder.fallback.txt", domainClass.getSimpleName(), collectionId));
        addTo.add(String.format("%s#_.%s.columnOrder.txt", domainClass.getSimpleName(), elementType.getSimpleName()));
        addTo.add(String.format("%s#_.%s.columnOrder.fallback.txt", domainClass.getSimpleName(), elementType.getSimpleName()));
    }

    private static Optional<String> tryLoad(final Class<?> domainClass, final List<String> resourceNames) {
        for (String resourceName : resourceNames) {
            try {
                final String contents = _Resources.loadAsStringUtf8(domainClass, resourceName);
                if (contents != null) {
                    return Optional.of(contents);
                }
            } catch (Exception ignore) {
                // in most cases there won't be a file to load, so we just continue.
                // not an error condition, but we'll log it at lowest (trace) level.
                if (log.isTraceEnabled()) {
                    log.trace("No resource file '{}' relative to {}", resourceName, domainClass.getName());
                }
            }
        }
        return Optional.empty();
    }

    /**
     * Reads associationIds of a standalone collection from a file.
     *
     * <p>
     * The search algorithm is:
     * <ul>
     *     <li> <code>DomainTypeClassName.columnOrder.txt</code></li>
     *     <li> <code>DomainTypeClassName.columnOrder.fallback.txt</code></li>
     * </ul>
     * </p>
     *
     * <p>
     * Additional files can be provided by overriding {@link #addResourceNames(Class, List)}.
     * </p>
     * 
     * @Returns {@code null}, if no matching resource was found
     */
    @Nullable
    @Override
    public List<String> orderStandalone(
            final Class<?> domainType,
            final List<String> associationIds) {
        var resourceNames = buildResourceNames(domainType);
        var contents = tryLoad(domainType, resourceNames)
                .orElse(null);
        return contentsMatching(contents, associationIds);
    }

    private List<String> buildResourceNames(final Class<?> domainClass) {
        var resourceNames = new ArrayList<String>();
        addResourceNames(domainClass, resourceNames);
        return resourceNames;
    }

    /**
     * Builds the list of file names to be read from.
     *
     * <p>
     * The default implementation provides only a single file name, <i>ClassName#collectionId.columnOrder.txt</i>.
     * </p>
     *
     * @param domainClass - the class in the standalone collection
     * @param addTo       - to be added to
     */
    protected void addResourceNames(
            final Class<?> domainClass,
            final List<String> addTo) {
        addTo.add(String.format("%s.columnOrder.txt", domainClass.getSimpleName()));
        addTo.add(String.format("%s.columnOrder.fallback.txt", domainClass.getSimpleName()));
    }

    /**
     * if contents is {@code null} returns {@code null}
     */
    @Nullable
    private static List<String> contentsMatching(
            final @Nullable String contents,
            final @NonNull List<String> associationIds) {
        return contents==null
                ? null
                : TextUtils.readLines(contents).stream()
                    .map(String::trim) // ignore any leading or trailing whitespace
                    .filter(line->!line.startsWith("#")) // speed up (not strictly required)
                    .filter(associationIds::contains)
                    .collect(Collectors.toList());
    }

}
