/*
 * 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.eclipse.aether.internal.impl.filter;

import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicBoolean;

import org.eclipse.aether.MultiRuntimeException;
import org.eclipse.aether.RepositorySystemSession;
import org.eclipse.aether.artifact.Artifact;
import org.eclipse.aether.impl.RepositorySystemLifecycle;
import org.eclipse.aether.metadata.Metadata;
import org.eclipse.aether.repository.RemoteRepository;
import org.eclipse.aether.resolution.ArtifactResult;
import org.eclipse.aether.spi.connector.filter.RemoteRepositoryFilter;
import org.eclipse.aether.spi.resolution.ArtifactResolverPostProcessor;
import org.eclipse.aether.util.ConfigUtils;
import org.eclipse.aether.util.FileUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import static java.util.Objects.requireNonNull;

/**
 * Remote repository filter source filtering on G coordinate. It is backed by a file that lists all allowed groupIds
 * and groupId not present in this file are filtered out.
 * <p>
 * The file can be authored manually: format is one groupId per line, comments starting with "#" (hash) amd empty lines
 * for structuring are supported. The file can also be pre-populated by "record" functionality of this filter.
 * When "recording", this filter will not filter out anything, but will instead populate the file with all encountered
 * groupIds.
 * <p>
 * The groupId file is expected on path "${basedir}/groupId-${repository.id}.txt".
 * <p>
 * The groupId file once loaded are cached in component, so in-flight groupId file change during component existence
 * are NOT noticed.
 *
 * @since 1.9.0
 */
@Singleton
@Named(GroupIdRemoteRepositoryFilterSource.NAME)
public final class GroupIdRemoteRepositoryFilterSource extends RemoteRepositoryFilterSourceSupport
        implements ArtifactResolverPostProcessor {
    public static final String NAME = "groupId";

    private static final String CONF_NAME_RECORD = "record";

    static final String GROUP_ID_FILE_PREFIX = "groupId-";

    static final String GROUP_ID_FILE_SUFFIX = ".txt";

    private static final Logger LOGGER = LoggerFactory.getLogger(GroupIdRemoteRepositoryFilterSource.class);

    private final RepositorySystemLifecycle repositorySystemLifecycle;

    @Inject
    public GroupIdRemoteRepositoryFilterSource(RepositorySystemLifecycle repositorySystemLifecycle) {
        super(NAME);
        this.repositorySystemLifecycle = requireNonNull(repositorySystemLifecycle);
    }

    @SuppressWarnings("unchecked")
    private ConcurrentMap<Path, Set<String>> rules(RepositorySystemSession session) {
        return (ConcurrentMap<Path, Set<String>>)
                session.getData().computeIfAbsent(getClass().getName() + ".rules", ConcurrentHashMap::new);
    }

    @SuppressWarnings("unchecked")
    private ConcurrentMap<Path, Boolean> changedRules(RepositorySystemSession session) {
        return (ConcurrentMap<Path, Boolean>)
                session.getData().computeIfAbsent(getClass().getName() + ".changedRules", ConcurrentHashMap::new);
    }

    private AtomicBoolean onShutdownHandlerRegistered(RepositorySystemSession session) {
        return (AtomicBoolean) session.getData()
                .computeIfAbsent(getClass().getName() + ".onShutdownHandlerRegistered", AtomicBoolean::new);
    }

    @Override
    public RemoteRepositoryFilter getRemoteRepositoryFilter(RepositorySystemSession session) {
        if (isEnabled(session) && !isRecord(session)) {
            return new GroupIdFilter(session);
        }
        return null;
    }

    @Override
    public void postProcess(RepositorySystemSession session, List<ArtifactResult> artifactResults) {
        if (isEnabled(session) && isRecord(session)) {
            if (onShutdownHandlerRegistered(session).compareAndSet(false, true)) {
                repositorySystemLifecycle.addOnSystemEndedHandler(() -> saveRecordedLines(session));
            }
            for (ArtifactResult artifactResult : artifactResults) {
                if (artifactResult.isResolved() && artifactResult.getRepository() instanceof RemoteRepository) {
                    Path filePath = filePath(
                            getBasedir(session, false),
                            artifactResult.getRepository().getId());
                    boolean newGroupId = rules(session)
                            .computeIfAbsent(filePath, f -> Collections.synchronizedSet(new TreeSet<>()))
                            .add(artifactResult.getArtifact().getGroupId());
                    if (newGroupId) {
                        changedRules(session).put(filePath, Boolean.TRUE);
                    }
                }
            }
        }
    }

    /**
     * Returns the groupId path. The file and parents may not exist, this method merely calculate the path.
     */
    private Path filePath(Path basedir, String remoteRepositoryId) {
        return basedir.resolve(GROUP_ID_FILE_PREFIX + remoteRepositoryId + GROUP_ID_FILE_SUFFIX);
    }

    private Set<String> cacheRules(RepositorySystemSession session, RemoteRepository remoteRepository) {
        Path filePath = filePath(getBasedir(session, false), remoteRepository.getId());
        return rules(session).computeIfAbsent(filePath, r -> {
            Set<String> rules = loadRepositoryRules(filePath);
            if (rules != NOT_PRESENT) {
                LOGGER.info("Loaded {} groupId for remote repository {}", rules.size(), remoteRepository.getId());
            }
            return rules;
        });
    }

    private Set<String> loadRepositoryRules(Path filePath) {
        if (Files.isReadable(filePath)) {
            try (BufferedReader reader = Files.newBufferedReader(filePath, StandardCharsets.UTF_8)) {
                TreeSet<String> result = new TreeSet<>();
                String groupId;
                while ((groupId = reader.readLine()) != null) {
                    if (!groupId.startsWith("#") && !groupId.trim().isEmpty()) {
                        result.add(groupId);
                    }
                }
                return Collections.unmodifiableSet(result);
            } catch (IOException e) {
                throw new UncheckedIOException(e);
            }
        }
        return NOT_PRESENT;
    }

    private static final TreeSet<String> NOT_PRESENT = new TreeSet<>();

    private class GroupIdFilter implements RemoteRepositoryFilter {
        private final RepositorySystemSession session;

        private GroupIdFilter(RepositorySystemSession session) {
            this.session = session;
        }

        @Override
        public Result acceptArtifact(RemoteRepository remoteRepository, Artifact artifact) {
            return acceptGroupId(remoteRepository, artifact.getGroupId());
        }

        @Override
        public Result acceptMetadata(RemoteRepository remoteRepository, Metadata metadata) {
            return acceptGroupId(remoteRepository, metadata.getGroupId());
        }

        private Result acceptGroupId(RemoteRepository remoteRepository, String groupId) {
            Set<String> groupIds = cacheRules(session, remoteRepository);
            if (NOT_PRESENT == groupIds) {
                return NOT_PRESENT_RESULT;
            }

            if (groupIds.contains(groupId)) {
                return new SimpleResult(true, "G:" + groupId + " allowed from " + remoteRepository);
            } else {
                return new SimpleResult(false, "G:" + groupId + " NOT allowed from " + remoteRepository);
            }
        }
    }

    private static final RemoteRepositoryFilter.Result NOT_PRESENT_RESULT =
            new SimpleResult(true, "GroupId file not present");

    /**
     * Returns {@code true} if given session is recording.
     */
    private boolean isRecord(RepositorySystemSession session) {
        return ConfigUtils.getBoolean(session, false, configPropKey(CONF_NAME_RECORD));
    }

    /**
     * On-close handler that saves recorded rules, if any.
     */
    private void saveRecordedLines(RepositorySystemSession session) {
        if (changedRules(session).isEmpty()) {
            return;
        }

        ArrayList<Exception> exceptions = new ArrayList<>();
        for (Map.Entry<Path, Set<String>> entry : rules(session).entrySet()) {
            Path filePath = entry.getKey();
            if (changedRules(session).get(filePath) != Boolean.TRUE) {
                continue;
            }
            Set<String> recordedLines = entry.getValue();
            if (!recordedLines.isEmpty()) {
                try {
                    TreeSet<String> result = new TreeSet<>();
                    result.addAll(loadRepositoryRules(filePath));
                    result.addAll(recordedLines);

                    LOGGER.info("Saving {} groupIds to '{}'", result.size(), filePath);
                    FileUtils.writeFileWithBackup(filePath, p -> Files.write(p, result));
                } catch (IOException e) {
                    exceptions.add(e);
                }
            }
        }
        MultiRuntimeException.mayThrow("session save groupIds failure", exceptions);
    }
}
