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

import java.io.IOException;
import java.net.InetSocketAddress;
import java.util.Collections;
import java.util.EnumSet;
import java.util.Objects;
import java.util.Set;

import org.apache.sshd.client.channel.AbstractClientChannel;
import org.apache.sshd.client.future.DefaultOpenFuture;
import org.apache.sshd.client.future.OpenFuture;
import org.apache.sshd.common.Closeable;
import org.apache.sshd.common.NamedResource;
import org.apache.sshd.common.SshConstants;
import org.apache.sshd.common.SshException;
import org.apache.sshd.common.channel.ChannelAsyncInputStream;
import org.apache.sshd.common.channel.ChannelAsyncOutputStream;
import org.apache.sshd.common.channel.ChannelOutputStream;
import org.apache.sshd.common.channel.LocalWindow;
import org.apache.sshd.common.future.CloseFuture;
import org.apache.sshd.common.future.DefaultCloseFuture;
import org.apache.sshd.common.io.IoSession;
import org.apache.sshd.common.io.IoWriteFuture;
import org.apache.sshd.common.session.Session;
import org.apache.sshd.common.util.buffer.Buffer;
import org.apache.sshd.common.util.net.SshdSocketAddress;

/**
 * TODO Add javadoc
 *
 * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
 */
public class TcpipClientChannel extends AbstractClientChannel implements ForwardingTunnelEndpointsProvider {
    /**
     * Type of channel being created. The type's {@link #getName()} method returns the SSH request type
     */
    public enum Type implements NamedResource {
        Direct("direct-tcpip"),
        Forwarded("forwarded-tcpip");

        public static final Set<Type> VALUES = Collections.unmodifiableSet(EnumSet.allOf(Type.class));

        private final String channelType;

        Type(String channelType) {
            this.channelType = channelType;
        }

        @Override
        public String getName() {
            return channelType;
        }
    }

    protected final SshdSocketAddress remote;
    protected final ChannelToPortHandler port;
    protected SshdSocketAddress localEntry;

    private final Type typeEnum;
    private SshdSocketAddress tunnelEntrance;
    private SshdSocketAddress tunnelExit;

    public TcpipClientChannel(Type type, IoSession serverSession, SshdSocketAddress remote) {
        super(Objects.requireNonNull(type, "No type specified").getName());
        this.typeEnum = type;
        this.port = createChannelToPortHandler(Objects.requireNonNull(serverSession, "No server session provided"));
        this.localEntry = new SshdSocketAddress((InetSocketAddress) serverSession.getLocalAddress());
        this.remote = remote;
    }

    protected ChannelToPortHandler createChannelToPortHandler(IoSession session) {
        return new ChannelToPortHandler(session, this);
    }

    public Type getTcpipChannelType() {
        return typeEnum;
    }

    public void updateLocalForwardingEntry(LocalForwardingEntry entry) {
        Objects.requireNonNull(entry, "No local forwarding entry provided");
        // OpenSSH requires the host string it passed in the tcpip-forward global request: it compares both host and
        // port and refuses the forwarding request when it doesn't match in the forwarded-tcpip request.
        //
        // Note: Apache MINA sshd is currently more lenient in that respect and compares only the port. RFC 4254
        // states that "Implementations MUST reject these messages unless they have previously requested a remote TCP/IP
        // port forwarding with the given port number"; it does not require the host name to be checked.
        localEntry = new SshdSocketAddress(entry.getLocalAddress().getHostName(), entry.getBoundAddress().getPort());
    }

    @Override
    public synchronized OpenFuture open() throws IOException {
        InetSocketAddress src;
        SshdSocketAddress dst;
        IoSession portSession = port.getPortSession();
        InetSocketAddress loc = (InetSocketAddress) portSession.getLocalAddress();
        Type openType = getTcpipChannelType();
        switch (openType) {
            case Direct:
                src = (InetSocketAddress) portSession.getRemoteAddress();
                dst = this.remote;
                tunnelEntrance = new SshdSocketAddress(loc.getHostString(), loc.getPort());
                tunnelExit = new SshdSocketAddress(dst.getHostName(), dst.getPort());
                break;
            case Forwarded:
                src = (InetSocketAddress) portSession.getRemoteAddress();
                dst = localEntry;
                tunnelEntrance = new SshdSocketAddress(src.getHostString(), src.getPort());
                tunnelExit = new SshdSocketAddress(loc.getHostString(), loc.getPort());
                break;
            default:
                throw new SshException("Unknown client channel type: " + openType);
        }

        if (closeFuture.isClosed()) {
            throw new SshException("Session has been closed");
        }

        openFuture = new DefaultOpenFuture(src, futureLock);
        if (log.isDebugEnabled()) {
            log.debug("open({}) send SSH_MSG_CHANNEL_OPEN", this);
        }

        Session session = getSession();
        String srcHost = src.getHostString();
        String dstHost = dst.getHostName();
        LocalWindow wLocal = getLocalWindow();
        String type = getChannelType();
        Buffer buffer = session.createBuffer(SshConstants.SSH_MSG_CHANNEL_OPEN,
                type.length() + srcHost.length() + dstHost.length() + Long.SIZE);
        buffer.putString(type);
        buffer.putUInt(getChannelId());
        buffer.putUInt(wLocal.getSize());
        buffer.putUInt(wLocal.getPacketSize());
        buffer.putString(dstHost);
        buffer.putUInt(dst.getPort());
        buffer.putString(srcHost);
        buffer.putUInt(src.getPort());
        writePacket(buffer);
        return openFuture;
    }

    @Override
    protected synchronized void doOpen() throws IOException {
        if (streaming == Streaming.Async) {
            asyncIn = new ChannelAsyncOutputStream(this, SshConstants.SSH_MSG_CHANNEL_DATA) {
                @SuppressWarnings("synthetic-access")
                @Override
                protected CloseFuture doCloseGracefully() {
                    DefaultCloseFuture result = new DefaultCloseFuture(getChannelId(), futureLock);
                    CloseFuture packetsWritten = super.doCloseGracefully();
                    packetsWritten.addListener(p -> {
                        try {
                            // The channel writes EOF directly through the SSH session
                            IoWriteFuture eofSent = sendEof();
                            if (eofSent != null) {
                                eofSent.addListener(f -> result.setClosed());
                                return;
                            }
                        } catch (Exception e) {
                            getSession().exceptionCaught(e);
                        }
                        result.setClosed();
                    });
                    return result;
                }
            };
            asyncOut = new ChannelAsyncInputStream(this);
        } else {
            out = new ChannelOutputStream(
                    this, getRemoteWindow(), log, SshConstants.SSH_MSG_CHANNEL_DATA, true);
            invertedIn = out;
        }
    }

    @Override
    protected Closeable getInnerCloseable() {
        return builder()
                .sequential(port.getPortSession(), super.getInnerCloseable())
                .build();
    }

    @Override
    protected void doWriteData(byte[] data, int off, long len) throws IOException {
        port.sendToPort(SshConstants.SSH_MSG_CHANNEL_DATA, data, off, len);
    }

    @Override
    protected void doWriteExtendedData(byte[] data, int off, long len) throws IOException {
        throw new UnsupportedOperationException(getChannelType() + " Tcpip channel does not support extended data");
    }

    @Override
    public void handleEof() throws IOException {
        super.handleEof();
        port.handleEof();
    }

    @Override
    public SshdSocketAddress getTunnelEntrance() {
        return tunnelEntrance;
    }

    @Override
    public SshdSocketAddress getTunnelExit() {
        return tunnelExit;
    }
}
