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

import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import jakarta.annotation.Priority;
import jakarta.inject.Inject;
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.PriorityPrecedence;
import org.apache.causeway.applib.layout.grid.Grid;
import org.apache.causeway.applib.services.grid.GridLoaderService;
import org.apache.causeway.applib.services.grid.GridMarshallerService;
import org.apache.causeway.applib.services.message.MessageService;
import org.apache.causeway.applib.value.NamedWithMimeType.CommonMimeType;
import org.apache.causeway.commons.collections.Can;
import org.apache.causeway.commons.internal.base._Strings;
import org.apache.causeway.commons.internal.collections._Maps;
import org.apache.causeway.commons.internal.reflection._Reflect;
import org.apache.causeway.commons.internal.reflection._Reflect.InterfacePolicy;
import org.apache.causeway.core.config.environment.CausewaySystemEnvironment;
import org.apache.causeway.core.metamodel.CausewayModuleCoreMetamodel;
import org.apache.causeway.core.metamodel.services.grid.spi.LayoutResource;
import org.apache.causeway.core.metamodel.services.grid.spi.LayoutResourceLoader;

import lombok.Getter;
import org.jspecify.annotations.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.experimental.Accessors;
import lombok.extern.log4j.Log4j2;

/**
 * Default implementation of {@link GridLoaderService}.
 *
 * @since 1.x revised for 2.0 {@index}
 */
@Service
@Named(CausewayModuleCoreMetamodel.NAMESPACE + ".GridLoaderServiceDefault")
@Priority(PriorityPrecedence.MIDPOINT)
@Qualifier("Default")
@RequiredArgsConstructor //JUnit Support
@Log4j2
public class GridLoaderServiceDefault implements GridLoaderService {

    private final MessageService messageService;
    final Can<LayoutResourceLoader> layoutResourceLoaders;

    @Getter(onMethod_={@Override}) @Accessors(fluent = true)
    private final boolean supportsReloading;

    @Inject
    public GridLoaderServiceDefault(
            final MessageService messageService,
            final CausewaySystemEnvironment causewaySystemEnvironment,
            final List<LayoutResourceLoader> layoutResourceLoaders) {
        this.messageService = messageService;
        this.supportsReloading = causewaySystemEnvironment.isPrototyping();
        this.layoutResourceLoaders = Can.ofCollection(layoutResourceLoaders);
    }

    record LayoutKey(
            @NonNull Class<?> domainClass,
            /** layout suffix */
            @Nullable String layoutIfAny) {
    }

    // for better logging messages (used only in prototyping mode)
    private final Map<LayoutKey, String> badContentByKey = _Maps.newHashMap();
    // cache (used only in prototyping mode)
    private final Map<LayoutKey, Grid> gridCache = _Maps.newHashMap();

    @Override
    public void remove(final Class<?> domainClass) {
        if(!supportsReloading()) {
            return;
        }
        final String layoutIfAny = null;
        var layoutKey = new LayoutKey(domainClass, layoutIfAny);
        badContentByKey.remove(layoutKey);
        gridCache.remove(layoutKey);
    }

    @Override
    public boolean existsFor(final Class<?> domainClass, final EnumSet<CommonMimeType> supportedFormats) {
        return loadLayoutResource(new LayoutKey(domainClass, null), supportedFormats).isPresent();
    }

    @Override
    public <T extends Grid> Optional<T> load(
            final Class<?> domainClass,
            final String layoutIfAny,
            final @NonNull GridMarshallerService<T> marshaller) {

        var supportedFormats = marshaller.supportedFormats();

        var layoutKey = new LayoutKey(domainClass, layoutIfAny);
        var layoutResource = loadLayoutResource(layoutKey, supportedFormats).orElse(null);
        if(layoutResource == null) {
            log.debug(
                    "Failed to locate or load layout resource for class {}, "
                    + "with layout-suffix (if any) {}, "
                    + "using layout-resource-loaders {}.",
                    domainClass.getName(), layoutIfAny,
                    layoutResourceLoaders.stream().map(Object::getClass).map(Class::getName).collect(Collectors.joining(", ")));
            return Optional.empty();
        }

        if(supportsReloading()) {
            final String badContent = badContentByKey.get(layoutKey);
            if(badContent != null) {
                if(Objects.equals(layoutResource.content(), badContent)) {
                    // seen this before and already logged; just quit
                    return Optional.empty();
                } else {
                    // this different content might be good
                    badContentByKey.remove(layoutKey);
                }
            }
        } else {
            // if cached, serve from cache - otherwise fall through
            @SuppressWarnings("unchecked")
            final T grid = (T)gridCache.get(layoutKey);
            if(grid != null) {
                return Optional.of(grid);
            }
        }

        try {
            final T grid = marshaller
                    .unmarshal(layoutResource.content(), layoutResource.format())
                    .getValue().orElseThrow();
            grid.setDomainClass(domainClass);
            if(supportsReloading()) {
                gridCache.put(layoutKey, grid);
            }
            return Optional.of(grid);
        } catch(Exception ex) {

            if(supportsReloading()) {
                // save fact that this was bad content, so that we don't log again if called next time
                badContentByKey.put(layoutKey, layoutResource.content());
            }

            // note that we don't blacklist if the file exists but couldn't be parsed;
            // the developer might fix so we will want to retry.
            final String resourceName = layoutResource.resourceName();
            final String message = "Failed to parse " + resourceName + " file (" + ex.getMessage() + ")";
            if(supportsReloading()) {
                messageService.warnUser(message);
            }
            log.warn(message);

            return Optional.empty();
        }
    }

    // -- HELPER

    Optional<LayoutResource> loadLayoutResource(
            final LayoutKey layoutKey,
            final EnumSet<CommonMimeType> supportedFormats) {
        return _Reflect.streamTypeHierarchy(layoutKey.domainClass(), InterfacePolicy.EXCLUDE)
            .flatMap(type->loadContent(type, layoutKey.layoutIfAny(), supportedFormats).stream())
            .findFirst();
    }

    private Optional<LayoutResource> loadContent(
            final @NonNull Class<?> domainClass,
            final @Nullable String layoutIfAny,
            final EnumSet<CommonMimeType> supportedFormats) {
        return streamResourceNameCandidatesFor(domainClass, layoutIfAny, supportedFormats)
            .flatMap(candidateResourceName->lookupLayoutResourceUsingLoaders(domainClass, candidateResourceName).stream())
            .findFirst();
    }

    private Stream<String> streamResourceNameCandidatesFor(
            final @NonNull Class<?> domainClass,
            final @Nullable String layoutIfAny,
            final @NonNull  EnumSet<CommonMimeType> supportedFormats) {
        return supportedFormats.stream()
                .flatMap(format->streamResourceNameCandidatesFor(domainClass, layoutIfAny, format));
    }

    private Stream<String> streamResourceNameCandidatesFor(
            final @NonNull Class<?> domainClass,
            final @Nullable String layoutIfAny,
            final @NonNull CommonMimeType format) {
        return format.getProposedFileExtensions().stream()
                .flatMap(fileExtension->streamResourceNameCandidatesFor(domainClass, layoutIfAny, fileExtension));
    }

    private Stream<String> streamResourceNameCandidatesFor(
            final @NonNull Class<?> domainClass,
            final @Nullable String layoutIfAny,
            final @NonNull String fileExtension) {

        var typeSimpleName = domainClass.getSimpleName();

        return _Strings.isNotEmpty(layoutIfAny)
                ? Stream.of(
                        String.format("%s-%s.layout.%s", typeSimpleName, layoutIfAny, fileExtension),
                        String.format("%s.layout.%s", typeSimpleName, fileExtension),
                        String.format("%s.layout.fallback.%s", typeSimpleName, fileExtension))
                : Stream.of(
                        String.format("%s.layout.%s", typeSimpleName, fileExtension),
                        String.format("%s.layout.fallback.%s", typeSimpleName,fileExtension));
    }

    private Optional<LayoutResource> lookupLayoutResourceUsingLoaders(
            final @NonNull Class<?> type,
            final @NonNull String candidateResourceName) {

        return layoutResourceLoaders.stream()
            .flatMap(loader->loader.lookupLayoutResource(type, candidateResourceName).stream())
            .findFirst();
    }

}
