/*
 * 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.sshd.common.keyprovider;

import java.io.IOException;
import java.io.InputStream;
import java.io.StreamCorruptedException;
import java.security.GeneralSecurityException;
import java.security.KeyPair;
import java.util.Collection;
import java.util.Collections;
import java.util.Iterator;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.TreeMap;
import java.util.TreeSet;

import org.apache.sshd.common.NamedResource;
import org.apache.sshd.common.config.keys.FilePasswordProvider;
import org.apache.sshd.common.session.SessionContext;
import org.apache.sshd.common.util.GenericUtils;
import org.apache.sshd.common.util.ValidateUtils;
import org.apache.sshd.common.util.io.resource.IoResource;
import org.apache.sshd.common.util.io.resource.ResourceStreamProvider;
import org.apache.sshd.common.util.security.SecurityUtils;

/**
 * @param  <R> Type of resource from which the {@link KeyPair} is generated
 * @author     <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
 */
public abstract class AbstractResourceKeyPairProvider<R> extends AbstractKeyPairProvider {
    private FilePasswordProvider passwordFinder;
    /*
     * NOTE: the map is case insensitive even for Linux, as it is (very) bad practice to have 2 key files that differ
     * from one another only in their case...
     */
    private final Map<String, Iterable<KeyPair>> cacheMap = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);

    protected AbstractResourceKeyPairProvider() {
        super();
    }

    public FilePasswordProvider getPasswordFinder() {
        return passwordFinder;
    }

    public void setPasswordFinder(FilePasswordProvider passwordFinder) {
        this.passwordFinder = passwordFinder;
    }

    /**
     * Checks which of the new resources we already loaded and can keep the associated key pair
     *
     * @param resources The collection of new resources - can be {@code null}/empty in which case the cache is cleared
     */
    protected void resetCacheMap(Collection<?> resources) {
        // if have any cached pairs then see what we can keep from previous load
        Collection<String> toDelete = Collections.emptySet();
        synchronized (cacheMap) {
            if (cacheMap.size() <= 0) {
                return; // already empty - nothing to keep
            }

            if (GenericUtils.isEmpty(resources)) {
                cacheMap.clear();
                return;
            }

            for (Object r : resources) {
                String resourceKey = ValidateUtils.checkNotNullAndNotEmpty(Objects.toString(r, null), "No resource key value");
                if (cacheMap.containsKey(resourceKey)) {
                    continue;
                }

                if (toDelete.isEmpty()) {
                    toDelete = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
                }

                if (!toDelete.add(resourceKey)) {
                    continue; // debug breakpoint
                }
            }

            if (GenericUtils.size(toDelete) > 0) {
                toDelete.forEach(cacheMap::remove);
            }
        }

        if (log.isDebugEnabled()) {
            log.debug("resetCacheMap({}) removed previous cached keys for {}", resources, toDelete);
        }
    }

    protected Iterable<KeyPair> loadKeys(SessionContext session, Collection<? extends R> resources) {
        if (GenericUtils.isEmpty(resources)) {
            return Collections.emptyList();
        } else {
            return () -> new KeyPairIterator(session, resources);
        }
    }

    protected IoResource<?> getIoResource(SessionContext session, R resource) {
        return IoResource.forResource(resource);
    }

    protected Iterable<KeyPair> doLoadKeys(SessionContext session, R resource)
            throws IOException, GeneralSecurityException {
        IoResource<?> ioResource
                = ValidateUtils.checkNotNull(getIoResource(session, resource), "No I/O resource available for %s", resource);
        String resourceKey
                = ValidateUtils.checkNotNullAndNotEmpty(ioResource.getName(), "No resource string value for %s", resource);
        Iterable<KeyPair> ids;
        synchronized (cacheMap) {
            // check if lucky enough to have already loaded this file
            ids = cacheMap.get(resourceKey);
        }

        if (ids != null) {
            if (log.isTraceEnabled()) {
                log.trace("doLoadKeys({}) using cached identifiers", resourceKey);
            }
            return ids;
        }

        ids = doLoadKeys(session, ioResource, resource, getPasswordFinder());
        if (ids != null) {
            boolean reusedKey;
            synchronized (cacheMap) {
                // if somebody else beat us to it, use the cached key - just in case file contents changed
                reusedKey = cacheMap.containsKey(resourceKey);
                if (reusedKey) {
                    ids = cacheMap.get(resourceKey);
                } else {
                    cacheMap.put(resourceKey, ids);
                }
            }

            if (log.isDebugEnabled()) {
                log.debug("doLoadKeys({}) {}", resourceKey, reusedKey ? "re-loaded" : "loaded");
            }
        } else {
            if (log.isDebugEnabled()) {
                log.debug("doLoadKeys({}) no key loaded", resourceKey);
            }
        }

        return ids;
    }

    protected Iterable<KeyPair> doLoadKeys(
            SessionContext session, NamedResource resourceKey, R resource, FilePasswordProvider provider)
            throws IOException, GeneralSecurityException {
        try (InputStream inputStream = openKeyPairResource(session, resourceKey, resource)) {
            return doLoadKeys(session, resourceKey, inputStream, provider);
        }
    }

    protected InputStream openKeyPairResource(
            SessionContext session, NamedResource resourceKey, R resource)
            throws IOException {
        if (resourceKey instanceof ResourceStreamProvider) {
            return ((ResourceStreamProvider) resourceKey).openInputStream();
        }

        throw new StreamCorruptedException("Cannot open resource data for " + resource);
    }

    protected Iterable<KeyPair> doLoadKeys(
            SessionContext session, NamedResource resourceKey, InputStream inputStream, FilePasswordProvider provider)
            throws IOException, GeneralSecurityException {
        return SecurityUtils.loadKeyPairIdentities(session, resourceKey, inputStream, provider);
    }

    protected class KeyPairIterator implements Iterator<KeyPair> {
        protected final SessionContext session;
        private final Iterator<? extends R> iterator;
        private Iterator<KeyPair> currentIdentities;
        private KeyPair nextKeyPair;
        private boolean nextKeyPairSet;

        protected KeyPairIterator(SessionContext session, Collection<? extends R> resources) {
            this.session = session;
            this.iterator = resources.iterator();
        }

        @Override
        public boolean hasNext() {
            return nextKeyPairSet || setNextObject();
        }

        @Override
        public KeyPair next() {
            if (!nextKeyPairSet && !setNextObject()) {
                throw new NoSuchElementException("Out of files to try");
            }
            nextKeyPairSet = false;
            return nextKeyPair;
        }

        @Override
        public void remove() {
            throw new UnsupportedOperationException("loadKeys(files) Iterator#remove() N/A");
        }

        @SuppressWarnings("synthetic-access")
        private boolean setNextObject() {
            nextKeyPair = KeyIdentityProvider.exhaustCurrentIdentities(currentIdentities);
            if (nextKeyPair != null) {
                nextKeyPairSet = true;
                return true;
            }

            while (iterator.hasNext()) {
                R r = iterator.next();
                try {
                    Iterable<KeyPair> ids = doLoadKeys(session, r);
                    currentIdentities = (ids == null) ? null : ids.iterator();
                    nextKeyPair = KeyIdentityProvider.exhaustCurrentIdentities(currentIdentities);
                } catch (Throwable e) {
                    warn("Failed ({}) to load key resource={}: {}", e.getClass().getSimpleName(), r, e.getMessage(), e);
                    nextKeyPair = null;
                    continue;
                }

                if (nextKeyPair != null) {
                    nextKeyPairSet = true;
                    return true;
                }
            }

            return false;
        }
    }
}
