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

import java.io.IOException;
import java.net.SocketAddress;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.KeyPair;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;

import org.apache.sshd.common.FactoryManager;
import org.apache.sshd.common.NamedResource;
import org.apache.sshd.common.RuntimeSshException;
import org.apache.sshd.common.Service;
import org.apache.sshd.common.ServiceFactory;
import org.apache.sshd.common.SshConstants;
import org.apache.sshd.common.SshException;
import org.apache.sshd.common.auth.AbstractUserAuthServiceFactory;
import org.apache.sshd.common.config.keys.KeyUtils;
import org.apache.sshd.common.config.keys.OpenSshCertificate;
import org.apache.sshd.common.io.IoService;
import org.apache.sshd.common.io.IoSession;
import org.apache.sshd.common.io.IoWriteFuture;
import org.apache.sshd.common.kex.KexFactoryManager;
import org.apache.sshd.common.kex.KexProposalOption;
import org.apache.sshd.common.kex.KexState;
import org.apache.sshd.common.kex.extension.KexExtensionHandler;
import org.apache.sshd.common.kex.extension.KexExtensionHandler.AvailabilityPhase;
import org.apache.sshd.common.kex.extension.KexExtensionHandler.KexPhase;
import org.apache.sshd.common.keyprovider.HostKeyCertificateProvider;
import org.apache.sshd.common.keyprovider.KeyPairProvider;
import org.apache.sshd.common.session.ConnectionService;
import org.apache.sshd.common.session.SessionContext;
import org.apache.sshd.common.session.SessionDisconnectHandler;
import org.apache.sshd.common.session.helpers.AbstractSession;
import org.apache.sshd.common.signature.SignatureFactory;
import org.apache.sshd.common.util.GenericUtils;
import org.apache.sshd.common.util.MapEntryUtils;
import org.apache.sshd.common.util.ValidateUtils;
import org.apache.sshd.common.util.buffer.Buffer;
import org.apache.sshd.common.util.buffer.ByteArrayBuffer;
import org.apache.sshd.core.CoreModuleProperties;
import org.apache.sshd.server.ServerAuthenticationManager;
import org.apache.sshd.server.ServerFactoryManager;
import org.apache.sshd.server.auth.UserAuthFactory;
import org.apache.sshd.server.auth.WelcomeBannerPhase;
import org.apache.sshd.server.auth.gss.GSSAuthenticator;
import org.apache.sshd.server.auth.hostbased.HostBasedAuthenticator;
import org.apache.sshd.server.auth.keyboard.KeyboardInteractiveAuthenticator;
import org.apache.sshd.server.auth.password.PasswordAuthenticator;
import org.apache.sshd.server.auth.pubkey.PublickeyAuthenticator;

/**
 * Provides default implementations for {@link ServerSession} related methods
 *
 * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
 */
public abstract class AbstractServerSession extends AbstractSession implements ServerSession {
    private ServerProxyAcceptor proxyAcceptor;
    private SocketAddress clientAddress;
    private PasswordAuthenticator passwordAuthenticator;
    private PublickeyAuthenticator publickeyAuthenticator;
    private KeyboardInteractiveAuthenticator interactiveAuthenticator;
    private GSSAuthenticator gssAuthenticator;
    private HostBasedAuthenticator hostBasedAuthenticator;
    private List<UserAuthFactory> userAuthFactories;
    private KeyPairProvider keyPairProvider;
    private HostKeyCertificateProvider hostKeyCertificateProvider;

    protected AbstractServerSession(ServerFactoryManager factoryManager, IoSession ioSession) {
        super(true, factoryManager, ioSession);
    }

    @Override
    public ServerFactoryManager getFactoryManager() {
        return (ServerFactoryManager) super.getFactoryManager();
    }

    @Override
    public ServerProxyAcceptor getServerProxyAcceptor() {
        return resolveEffectiveProvider(
                ServerProxyAcceptor.class, proxyAcceptor, getFactoryManager().getServerProxyAcceptor());
    }

    @Override
    public void setServerProxyAcceptor(ServerProxyAcceptor proxyAcceptor) {
        this.proxyAcceptor = proxyAcceptor;
    }

    @Override
    public SocketAddress getClientAddress() {
        return resolvePeerAddress(clientAddress);
    }

    public void setClientAddress(SocketAddress clientAddress) {
        this.clientAddress = clientAddress;
    }

    @Override
    public PasswordAuthenticator getPasswordAuthenticator() {
        ServerFactoryManager manager = getFactoryManager();
        return resolveEffectiveProvider(
                PasswordAuthenticator.class, passwordAuthenticator, manager.getPasswordAuthenticator());
    }

    @Override
    public void setPasswordAuthenticator(PasswordAuthenticator passwordAuthenticator) {
        this.passwordAuthenticator = passwordAuthenticator; // OK if null - inherit from parent
    }

    @Override
    public PublickeyAuthenticator getPublickeyAuthenticator() {
        ServerFactoryManager manager = getFactoryManager();
        return resolveEffectiveProvider(
                PublickeyAuthenticator.class, publickeyAuthenticator, manager.getPublickeyAuthenticator());
    }

    @Override
    public void setPublickeyAuthenticator(PublickeyAuthenticator publickeyAuthenticator) {
        this.publickeyAuthenticator = publickeyAuthenticator; // OK if null - inherit from parent
    }

    @Override
    public KeyboardInteractiveAuthenticator getKeyboardInteractiveAuthenticator() {
        ServerFactoryManager manager = getFactoryManager();
        return resolveEffectiveProvider(
                KeyboardInteractiveAuthenticator.class, interactiveAuthenticator,
                manager.getKeyboardInteractiveAuthenticator());
    }

    @Override
    public void setKeyboardInteractiveAuthenticator(KeyboardInteractiveAuthenticator interactiveAuthenticator) {
        this.interactiveAuthenticator = interactiveAuthenticator; // OK if null - inherit from parent
    }

    @Override
    public GSSAuthenticator getGSSAuthenticator() {
        ServerFactoryManager manager = getFactoryManager();
        return resolveEffectiveProvider(
                GSSAuthenticator.class, gssAuthenticator, manager.getGSSAuthenticator());
    }

    @Override
    public void setGSSAuthenticator(GSSAuthenticator gssAuthenticator) {
        this.gssAuthenticator = gssAuthenticator; // OK if null - inherit from parent
    }

    @Override
    public HostBasedAuthenticator getHostBasedAuthenticator() {
        ServerFactoryManager manager = getFactoryManager();
        return resolveEffectiveProvider(
                HostBasedAuthenticator.class, hostBasedAuthenticator, manager.getHostBasedAuthenticator());
    }

    @Override
    public void setHostBasedAuthenticator(HostBasedAuthenticator hostBasedAuthenticator) {
        this.hostBasedAuthenticator = hostBasedAuthenticator;
    }

    @Override
    public List<UserAuthFactory> getUserAuthFactories() {
        ServerFactoryManager manager = getFactoryManager();
        return resolveEffectiveFactories(userAuthFactories, manager.getUserAuthFactories());
    }

    @Override
    public void setUserAuthFactories(List<UserAuthFactory> userAuthFactories) {
        this.userAuthFactories = userAuthFactories; // OK if null/empty - inherit from parent
    }

    @Override
    public KeyPairProvider getKeyPairProvider() {
        KexFactoryManager parent = getDelegate();
        return resolveEffectiveProvider(KeyPairProvider.class, keyPairProvider,
                (parent == null) ? null : ((ServerAuthenticationManager) parent).getKeyPairProvider());
    }

    @Override
    public HostKeyCertificateProvider getHostKeyCertificateProvider() {
        ServerFactoryManager manager = getFactoryManager();
        return resolveEffectiveProvider(HostKeyCertificateProvider.class,
                hostKeyCertificateProvider, manager.getHostKeyCertificateProvider());
    }

    @Override
    public void setHostKeyCertificateProvider(HostKeyCertificateProvider hostKeyCertificateProvider) {
        this.hostKeyCertificateProvider = hostKeyCertificateProvider;
    }

    @Override
    public void setKeyPairProvider(KeyPairProvider keyPairProvider) {
        this.keyPairProvider = keyPairProvider;
    }

    /**
     * Sends the server identification + any extra header lines
     *
     * @param  headerLines Extra header lines to be prepended to the actual identification string - ignored if
     *                     {@code null}/empty
     * @return             An {@link IoWriteFuture} that can be used to be notified of identification data being written
     *                     successfully or failing
     * @throws Exception   If failed to send identification
     * @see                <A HREF="https://tools.ietf.org/html/rfc4253#section-4.2">RFC 4253 - section 4.2</A>
     */
    protected IoWriteFuture sendServerIdentification(List<String> headerLines) throws Exception {
        serverVersion = resolveIdentificationString(CoreModuleProperties.SERVER_IDENTIFICATION.getName());
        signalSendIdentification(serverVersion, headerLines);
        return sendIdentification(serverVersion, headerLines);
    }

    @Override
    protected void checkKeys() {
        // nothing
    }

    @Override
    protected boolean handleServiceRequest(String serviceName, Buffer buffer) throws Exception {
        boolean started = super.handleServiceRequest(serviceName, buffer);
        if (!started) {
            return false;
        }

        if (AbstractUserAuthServiceFactory.DEFAULT_NAME.equals(serviceName)) {
            Service service = currentService.getService();
            if (service instanceof ServerUserAuthService) {
                ServerUserAuthService authService = (ServerUserAuthService) service;
                if (WelcomeBannerPhase.IMMEDIATE.equals(authService.getWelcomePhase())) {
                    authService.sendWelcomeBanner(this);
                }
            }
        }

        return true;
    }

    @Override
    public void startService(String name, Buffer buffer) throws Exception {
        ValidateUtils.checkNotNullAndNotEmpty(name, "No service name specified");
        FactoryManager factoryManager = getFactoryManager();
        ServiceFactory factory = NamedResource.findByName(name, String.CASE_INSENSITIVE_ORDER,
                factoryManager.getServiceFactories());
        Service service = factory == null ? null : factory.create(this);

        /*
         * According to RFC4253:
         *
         * If the server rejects the service request, it SHOULD send an appropriate SSH_MSG_DISCONNECT message and MUST
         * disconnect.
         */
        if (service == null) {
            try {
                SessionDisconnectHandler handler = getSessionDisconnectHandler();
                if ((handler != null)
                        && handler.handleUnsupportedServiceDisconnectReason(
                                this, SshConstants.SSH_MSG_SERVICE_REQUEST, name, buffer)) {
                    if (log.isDebugEnabled()) {
                        log.debug("startService({}) ignore unknown service={} by handler", this, name);
                    }
                    return;
                }
            } catch (IOException | RuntimeException e) {
                warn("startService({})[{}] failed ({}) to invoke disconnect handler: {}",
                        this, name, e.getClass().getSimpleName(), e.getMessage(), e);
            }

            throw new SshException(SshConstants.SSH2_DISCONNECT_SERVICE_NOT_AVAILABLE, "Unknown service: " + name);
        }
        currentService.set(service, factory.getName(), true);
    }

    @Override
    public IoWriteFuture signalAuthenticationSuccess(
            String username, String authService, Buffer buffer)
            throws Exception {
        KexState curState = kexState.get();
        if (!KexState.DONE.equals(curState)) {
            throw new SshException(SshConstants.SSH2_DISCONNECT_PROTOCOL_ERROR,
                    "Authentication success signalled though KEX state=" + curState);
        }

        /*
         * According to https://tools.ietf.org/html/rfc8308#section-2.4
         *
         * If a server sends SSH_MSG_EXT_INFO, it MAY send it at zero, one, or both of the following opportunities:
         *
         * ...
         *
         * + Immediately preceding the server's SSH_MSG_USERAUTH_SUCCESS
         */
        KexExtensionHandler extHandler = getKexExtensionHandler();
        if ((extHandler != null) && extHandler.isKexExtensionsAvailable(this, AvailabilityPhase.AUTHOK)) {
            extHandler.sendKexExtensions(this, KexPhase.AUTHOK);
        }

        Buffer response = createBuffer(SshConstants.SSH_MSG_USERAUTH_SUCCESS, Byte.SIZE);
        IoWriteFuture future;
        IoSession networkSession = getIoSession();
        encodeLock.lock();
        try {
            Buffer packet = resolveOutputPacket(response);

            setUsername(username);
            // must be AFTER the USERAUTH-SUCCESS packet created in case delayed compression is used
            setAuthenticated();
            startService(authService, buffer);

            // Now we can inform the peer that authentication is successful
            future = networkSession.writeBuffer(packet);
        } finally {
            encodeLock.unlock();
        }

        resetIdleTimeout();
        log.info("Session {}@{} authenticated", username, networkSession.getRemoteAddress());
        return future;
    }

    @Override
    protected void handleServiceAccept(String serviceName, Buffer buffer) throws Exception {
        super.handleServiceAccept(serviceName, buffer);

        try {
            SessionDisconnectHandler handler = getSessionDisconnectHandler();
            if ((handler != null)
                    && handler.handleUnsupportedServiceDisconnectReason(
                            this, SshConstants.SSH_MSG_SERVICE_ACCEPT, serviceName, buffer)) {
                if (log.isDebugEnabled()) {
                    log.debug("handleServiceAccept({}) ignore unknown service={} by handler", this, serviceName);
                }
                return;
            }
        } catch (IOException | RuntimeException e) {
            warn("handleServiceAccept({}) failed ({}) to invoke disconnect handler of unknown service={}: {}",
                    this, e.getClass().getSimpleName(), serviceName, e.getMessage(), e);
        }

        // TODO: can services be initiated by the server-side ?
        disconnect(SshConstants.SSH2_DISCONNECT_SERVICE_NOT_AVAILABLE,
                "Unsupported packet: SSH_MSG_SERVICE_ACCEPT for " + serviceName);
    }

    @Override
    protected byte[] sendKexInit(Map<KexProposalOption, String> proposal) throws Exception {
        mergeProposals(serverProposal, proposal);
        return super.sendKexInit(proposal);
    }

    @Override
    protected void setKexSeed(byte... seed) {
        setServerKexData(seed);
    }

    @Override
    protected String resolveAvailableSignaturesProposal(FactoryManager proposedManager)
            throws IOException, GeneralSecurityException {
        /*
         * Make sure we can provide key(s) for the available signatures
         */
        ValidateUtils.checkTrue(proposedManager == getFactoryManager(),
                "Mismatched signatures proposed factory manager");

        KeyPairProvider kpp = getKeyPairProvider();
        Collection<String> provided = null;
        try {
            if (kpp != null) {
                provided = GenericUtils.stream(kpp.getKeyTypes(this)).collect(Collectors.toSet());

                HostKeyCertificateProvider hostKeyCertificateProvider = getHostKeyCertificateProvider();
                if (hostKeyCertificateProvider != null) {
                    Iterable<OpenSshCertificate> certificates = hostKeyCertificateProvider.loadCertificates(this);
                    for (OpenSshCertificate certificate : certificates) {
                        // Actually the HostKeyCertificateProvider is supposed to check for
                        // SSH_CERT_TYPE_HOST. We check again because a server may be configured to use
                        // a third-party implementation.
                        if (OpenSshCertificate.Type.HOST.equals(certificate.getType())) {
                            // Add the certificate alg only if the corresponding keyPair type is available
                            String rawKeyType = certificate.getRawKeyType();
                            if (provided.contains(rawKeyType)) {
                                provided.add(certificate.getKeyType());
                            } else {
                                log.info(
                                        "resolveAvailableSignaturesProposal({}) No private key of type={} available in provided certificate",
                                        this, rawKeyType);
                            }
                        } else {
                            log.error("resolveAvailableSignaturesProposal({}) certificate {} is not a host certificate",
                                    this, KeyUtils.getFingerPrint(certificate));
                        }
                    }
                }
            }
        } catch (Error e) {
            warn("resolveAvailableSignaturesProposal({}) failed ({}) to get key types: {}",
                    this, e.getClass().getSimpleName(), e.getMessage(), e);

            throw new RuntimeSshException(e);
        }

        Collection<String> available = NamedResource.getNameList(getSignatureFactories());
        if ((provided == null) || GenericUtils.isEmpty(available)) {
            return resolveEmptySignaturesProposal(available, provided);
        }

        Collection<String> supported = SignatureFactory.resolveSignatureFactoryNamesProposal(provided, available);
        if (GenericUtils.isEmpty(supported)) {
            return resolveEmptySignaturesProposal(available, provided);
        } else {
            return GenericUtils.join(supported, ',');
        }
    }

    /**
     * Called by {@link #resolveAvailableSignaturesProposal(FactoryManager)} if none of the provided keys is supported -
     * last chance for the derived implementation to do something
     *
     * @param  supported The supported key types - may be {@code null}/empty
     * @param  provided  The available signature types - may be {@code null}/empty
     * @return           The resolved proposal - {@code null} by default
     */
    protected String resolveEmptySignaturesProposal(
            Iterable<String> supported, Iterable<String> provided) {
        if (log.isDebugEnabled()) {
            log.debug("resolveEmptySignaturesProposal({})[{}] none of the keys appears in supported list: {}",
                    this, provided, supported);
        }
        return null;
    }

    @Override
    protected boolean readIdentification(Buffer buffer) throws Exception {
        ServerProxyAcceptor acceptor = getServerProxyAcceptor();
        int rpos = buffer.rpos();
        boolean debugEnabled = log.isDebugEnabled();
        if (acceptor != null) {
            try {
                boolean completed = acceptor.acceptServerProxyMetadata(this, buffer);
                if (!completed) {
                    buffer.rpos(rpos); // restore original buffer position
                    return false; // more data required
                }
            } catch (Throwable t) {
                warn("readIdentification({}) failed ({}) to accept proxy metadata: {}",
                        this, t.getClass().getSimpleName(), t.getMessage(), t);

                if (t instanceof IOException) {
                    throw (IOException) t;
                } else {
                    throw new SshException(t);
                }
            }
        }

        List<String> ident = doReadIdentification(buffer, true);
        int numLines = GenericUtils.size(ident);
        clientVersion = (numLines <= 0) ? null : ident.remove(numLines - 1);
        if (GenericUtils.isEmpty(clientVersion)) {
            buffer.rpos(rpos); // restore original buffer position
            return false; // more data required
        }

        if (debugEnabled) {
            log.debug("readIdentification({}) client version string: {}", this, clientVersion);
        }

        IOException err;
        if (SessionContext.isValidVersionPrefix(clientVersion)) {
            /*
             * NOTE: because of the way that "doReadIdentification" works we are assured that there are no extra lines
             * beyond the version one, but we check this nevertheless
             */
            err = (numLines > 1)
                    ? new SshException(SshConstants.SSH2_DISCONNECT_PROTOCOL_ERROR,
                            "Unexpected extra " + (numLines - 1) + " lines from client=" + clientVersion)
                    : null;
        } else {
            err = new SshException(SshConstants.SSH2_DISCONNECT_PROTOCOL_VERSION_NOT_SUPPORTED,
                    "Unsupported protocol version: " + clientVersion);
        }

        if (err != null) {
            IoSession networkSession = getIoSession();
            networkSession.writeBuffer(
                    new ByteArrayBuffer((err.getMessage() + "\n").getBytes(StandardCharsets.UTF_8)))
                    .addListener(future -> close(true));
            throw err;
        }

        signalPeerIdentificationReceived(clientVersion, ident);

        kexState.set(KexState.INIT);
        sendKexInit();
        return true;
    }

    @Override
    protected void receiveKexInit(Map<KexProposalOption, String> proposal, byte[] seed)
            throws IOException {
        mergeProposals(clientProposal, proposal);
        setClientKexData(seed);
    }

    @Override
    public KeyPair getHostKey() {
        String proposedKey = getNegotiatedKexParameter(KexProposalOption.SERVERKEYS);
        String keyType = KeyUtils.getCanonicalKeyType(proposedKey);
        if (GenericUtils.isEmpty(keyType)) {
            return null;    // OK if not negotiated yet
        }

        KeyPairProvider provider = Objects.requireNonNull(getKeyPairProvider(), "No host keys provider");
        try {
            HostKeyCertificateProvider hostKeyCertificateProvider = getHostKeyCertificateProvider();
            if (hostKeyCertificateProvider != null) {
                OpenSshCertificate publicKey = hostKeyCertificateProvider.loadCertificate(this, keyType);
                if (publicKey != null) {
                    String rawKeyType = publicKey.getRawKeyType();

                    if (log.isDebugEnabled()) {
                        log.debug("getHostKey({}) using certified key {}/{} with ID={}",
                                this, keyType, rawKeyType, publicKey.getId());
                    }

                    KeyPair keyPair = provider.loadKey(this, rawKeyType);
                    ValidateUtils.checkNotNull(keyPair, "No certified private key of type=%s available", rawKeyType);
                    return new KeyPair(publicKey, keyPair.getPrivate());
                }
            }

            return provider.loadKey(this, keyType);
        } catch (IOException | GeneralSecurityException | Error e) {
            warn("getHostKey({}) failed ({}) to load key of type={}[{}]: {}",
                    this, e.getClass().getSimpleName(), proposedKey, keyType, e.getMessage(), e);

            throw new RuntimeSshException(e);
        }
    }

    @Override
    public int getActiveSessionCountForUser(String userName) {
        if (GenericUtils.isEmpty(userName)) {
            return 0;
        }

        IoSession networkSession = getIoSession();
        IoService service = networkSession.getService();
        Map<?, IoSession> sessionsMap = service.getManagedSessions();
        if (MapEntryUtils.isEmpty(sessionsMap)) {
            return 0;
        }

        int totalCount = 0;
        for (IoSession is : sessionsMap.values()) {
            ServerSession session = (ServerSession) getSession(is, true);
            if (session == null) {
                continue;
            }

            String sessionUser = session.getUsername();
            if ((!GenericUtils.isEmpty(sessionUser))
                    && Objects.equals(sessionUser, userName)) {
                totalCount++;
            }
        }

        return totalCount;
    }

    /**
     * @return The underlying {@link IoSession} id.
     */
    public long getId() {
        IoSession networkSession = getIoSession();
        return networkSession.getId();
    }

    @Override
    protected ConnectionService getConnectionService() {
        return (this.currentService instanceof ConnectionService)
                ? (ConnectionService) this.currentService
                : null;
    }
}
