/*
 * Copyright (C) 2004-2009 Jive Software. All rights reserved.
 *
 * Licensed 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.jivesoftware.openfire.mediaproxy;

import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.Timer;
import java.util.TimerTask;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * A media proxy session enables two clients to exchange UDP traffic. Each client connects to
 * a UDP port and then the proxy is responsible for exchanging traffic. Each session uses
 * a total of four ports: two for traffic exchange, and two control ports.
 *
 * @author Thiago Camargo
 */
public abstract class MediaProxySession extends Thread implements ProxyCandidate, DatagramListener {

    private static final Logger Log = LoggerFactory.getLogger(MediaProxySession.class);

    private List<SessionListener> sessionListeners = new ArrayList<>();

    private String id;
    private String pass;
    private String creator = "";
    private long timestamp = 0;

    protected InetAddress localAddress;
    protected InetAddress hostA;
    protected InetAddress hostB;

    protected int portA;
    protected int portB;

    protected int localPortA;
    protected int localPortB;

    protected DatagramSocket socketA;
    protected DatagramSocket socketAControl;
    protected DatagramSocket socketB;
    protected DatagramSocket socketBControl;

    protected Channel channelAtoB;
    protected Channel channelAtoBControl;
    protected Channel channelBtoA;
    protected Channel channelBtoAControl;

    protected Thread threadAtoB;
    protected Thread threadAtoBControl;
    protected Thread threadBtoA;
    protected Thread threadBtoAControl;

    private Timer idleTimer = null;
    private Timer lifeTimer = null;

    private int minPort = 10000;
    private int maxPort = 20000;

    /**
     * Creates a new static UDP channel between Host A and Host B.
     *
     * @param id           of the Session (Could be a Jingle session ID)
     * @param creator      the session creator name or description
     * @param localAddress the localhost IP that will listen for UDP packets
     * @param hostA        the hostname or IP of the point A of the Channel
     * @param portA        the port number point A of the Channel
     * @param hostB        the hostname or IP of the point B of the Channel
     * @param portB        the port number point B of the Channel
     * @param minPort      the minimal port value to be used by the server
     * @param maxPort      the maximun port value to be used by the server
     */
    public MediaProxySession(String id, String creator, String localAddress, String hostA, int portA, String hostB,
                             int portB, int minPort, int maxPort) {
        this.id = id;
        this.creator = creator;
        this.minPort = minPort;
        this.maxPort = maxPort;
        this.pass = String.valueOf(Math.abs(new Random().nextLong()));
        try {
            this.hostA = InetAddress.getByName(hostA);
            this.hostB = InetAddress.getByName(hostB);

            this.portA = portA;
            this.portB = portB;

            this.localAddress = InetAddress.getByName(localAddress);
            this.localPortA = getFreePort();
            this.socketA = new DatagramSocket(localPortA, this.localAddress);
            this.socketAControl = new DatagramSocket(localPortA + 1, this.localAddress);
            this.localPortB = getFreePort();
            this.socketB = new DatagramSocket(localPortB, this.localAddress);
            this.socketBControl = new DatagramSocket(localPortB + 1, this.localAddress);
            if (Log.isDebugEnabled()) {
                Log.debug("MediaProxySession: Session Created at: A " + localPortA + " : B " + localPortB);
            }
        }
        catch (Exception e) {
            Log.error(e.getMessage(), e);
        }
    }

    /**
     * Obtain a free port with a nested control port we can use.
     *
     * @return A free port number.
     */
    protected int getFreePort() {
        ServerSocket ss;
        int freePort = 0;
        int controlPort;

        for (int i = 0; i < 10; i++) {
            freePort = (int) (minPort + Math.round(Math.random() * (maxPort - minPort)));
            freePort = freePort % 2 == 0 ? freePort : freePort + 1;
            try {
                ss = new ServerSocket(freePort);
                freePort = ss.getLocalPort();
                ss.close();
                ss = new ServerSocket(freePort + 1);
                controlPort = ss.getLocalPort();
                ss.close();
                if (controlPort == (freePort + 1))
                    return freePort;
            }
            catch (IOException e) {
                Log.error(e.getMessage(), e);
            }
        }
        try {
            ss = new ServerSocket(0);
            freePort = ss.getLocalPort();
            ss.close();
        }
        catch (IOException e) {
            Log.error(e.getMessage(), e);
        } finally {
            ss = null;
        }
        return freePort;
    }

    /**
     * Get the ID of the Session
     *
     * @return the ID of the session
     */
    @Override
    public String getSID() {
        return id;
    }

    /**
     * Get the pass of this Session
     * A pass can be used to authorize an Session modification
     */
    @Override
    public String getPass() {
        return pass;
    }

    /**
     * Get the agent creator.
     * This field is open to MediaProxy users and just can be set in constructor.
     *
     * @return the session creator name or description
     */
    public String getCreator() {
        return creator;
    }

    /**
     * Get last packet arrived timestamp
     *
     * @return TimeStamp in Millis
     */
    public long getTimestamp() {
        return timestamp;
    }

    /**
     * Thread override method
     */
    @Override
    public void run() {
        // Create channels for parties
        createChannels();

        // Start a thread for each channel
        threadAtoB = new Thread(channelAtoB);
        threadAtoBControl = new Thread(channelAtoBControl);
        threadBtoA = new Thread(channelBtoA);
        threadBtoAControl = new Thread(channelBtoAControl);

        threadAtoB.start();
        threadAtoBControl.start();
        threadBtoA.start();
        threadBtoAControl.start();

        // Listen to channel events
        addChannelListeners();
    }

    /**
     * Creates 4 new channels for the two entities. We will create a channel between A and B and vice versa
     * and also a control channel betwwen A and B and vice versa.
     */
    abstract void createChannels();

    /**
     * Adds listener to channel events like receiving data.
     */
    void addChannelListeners() {
        channelAtoB.addListener(this);
        channelAtoBControl.addListener(this);
        channelBtoA.addListener(this);
        channelBtoAControl.addListener(this);
    }

    /**
     * Stop the Session
     */
    @Override
    public void stopAgent() {

        try {
            if (idleTimer != null) {
                idleTimer.cancel();
                idleTimer.purge();
                idleTimer = null;
            }
        } catch (Exception e) {
            Log.error(e.getMessage(), e);
        }

        try {
            if (lifeTimer != null) {
                lifeTimer.cancel();
                lifeTimer.purge();
                lifeTimer = null;
            }
        } catch (Exception e) {
            Log.error(e.getMessage(), e);
        }

        channelAtoB.removeListeners();
        channelAtoBControl.removeListeners();
        channelBtoA.removeListeners();
        channelBtoAControl.removeListeners();

        try {
            channelAtoB.cancel();
            channelAtoBControl.cancel();
            channelBtoA.cancel();
            channelBtoAControl.cancel();
        } catch (Exception e) {
            Log.error(e.getMessage(), e);
        }

        socketA.close();
        socketAControl.close();
        socketB.close();
        socketBControl.close();

        dispatchAgentStopped();

        Log.debug("MediaProxySession: Session Stopped");
    }

    /**
     * Get localhost of the Session
     *
     * @return the localhost of the session
     */
    @Override
    public InetAddress getLocalhost() {
        return localAddress;
    }

    /**
     * Get the Host A IP
     *
     * @return the host A ip
     */
    @Override
    public InetAddress getHostA() {
        return hostA;
    }

    /**
     * Get the Host B IP
     *
     * @return the host B ip
     */
    @Override
    public InetAddress getHostB() {
        return hostB;
    }

    /**
     * Set port A value
     *
     * @param portA the port number for A
     */
    @Override
    public void setPortA(int portA) {
        if (Log.isDebugEnabled()) {
            Log.debug("MediaProxySession: PORT CHANGED(A):" + portA);
        }
        this.portA = portA;
    }

    /**
     * Set port B value
     *
     * @param portB the port number for B
     */
    @Override
    public void setPortB(int portB) {
        if (Log.isDebugEnabled()) {
            Log.debug("MediaProxySession: PORT CHANGED(B):" + portB);
        }
        this.portB = portB;
    }

    /**
     * Set the Host A IP
     *
     * @param hostA the host for A
     */
    @Override
    public void setHostA(InetAddress hostA) {
        this.hostA = hostA;
    }

    /**
     * Set the Host B IP
     *
     * @param hostB the host for B
     */
    @Override
    public void setHostB(InetAddress hostB) {
        this.hostB = hostB;
    }

    /**
     * Get the Port A IP
     *
     * @return the port for A
     */
    @Override
    public int getPortA() {
        return portA;
    }

    /**
     * Get the Port B IP
     *
     * @return the port for B
     */
    @Override
    public int getPortB() {
        return portB;
    }

    /**
     * Get the localport that listen for Host A Packets
     *
     * @return the local port for A
     */
    @Override
    public int getLocalPortA() {
        return localPortA;
    }

    /**
     * Get the localport that listen for Host B Packets
     *
     * @return the local port for B
     */
    @Override
    public int getLocalPortB() {
        return localPortB;
    }

    @Override
    public void sendFromPortA(String host, int port) {
        try {
            InetAddress address = InetAddress.getByName(host);
            channelAtoB.setHost(address);
            channelAtoB.setPort(port);
            channelAtoBControl.setHost(address);
            channelAtoBControl.setPort(port + 1);
        }
        catch (Exception e) {
            Log.error(e.getMessage(), e);
        }
    }

    @Override
    public void sendFromPortB(String host, int port) {
        try {
            InetAddress address = InetAddress.getByName(host);
            channelBtoA.setHost(address);
            channelBtoA.setPort(port);
            channelBtoAControl.setHost(address);
            channelBtoAControl.setPort(port + 1);
        }
        catch (Exception e) {
            Log.error(e.getMessage(), e);
        }
    }

    /**
     * Implement DatagramListener to timestamp last packet arrived
     *
     * @param datagramPacket
     */
    @Override
    public boolean datagramReceived(DatagramPacket datagramPacket) {
        timestamp = System.currentTimeMillis();
        return true;
    }

    /**
     * Add a keep alive detector.
     * If the packet still more than the keep alive delay without receiving any packets. The Session is
     * stoped and remove from agents List.
     *
     * @param delay delay time in millis to check if the channel is inactive
     */
    void addKeepAlive(long delay) {
        if (idleTimer != null) return;
        idleTimer = new Timer();
        idleTimer.scheduleAtFixedRate(new TimerTask() {
            long lastTimeStamp = getTimestamp();

            @Override
            public void run() {
                if (lastTimeStamp == getTimestamp()) {
                    stopAgent();
                    return;
                }
                lastTimeStamp = getTimestamp();
            }
        }, delay, delay);
    }

    /**
     * Add a limited life time to the Session.
     * The Session is stoped and remove from agents List after a certain time.
     * Prevents that network cycles, refreshes a Session forever.
     *
     * @param lifetime time in Seconds to kill the Session
     */
    void addLifeTime(long lifetime) {
        lifetime *= 1000;
        if (lifeTimer != null) return;
        lifeTimer = new Timer();
        lifeTimer.scheduleAtFixedRate(new TimerTask() {
            @Override
            public void run() {
                stopAgent();
            }
        }, lifetime, lifetime);
    }

    /**
     * Adds a listener for Session events
     *
     * @param sessionListener
     */
    public void addAgentListener(SessionListener sessionListener) {
        sessionListeners.add(sessionListener);
    }

    /**
     * Removes an Session events listener
     *
     * @param sessionListener
     */
    public void removeAgentListener(SessionListener sessionListener) {
        sessionListeners.remove(sessionListener);
    }

    /**
     * Removes every Session events listeners
     */
    public void clearAgentListeners() {
        sessionListeners.clear();
    }

    /**
     * Dispatch Stop Event
     */
    public void dispatchAgentStopped() {
        for (SessionListener sessionListener : sessionListeners) {
            try {
                sessionListener.sessionClosed(this);
            } catch (Exception e) {
                Log.error(e.getMessage(), e);
            }
        }
    }
}
