package plotter;

import java.io.IOException;
import java.net.*;
import java.nio.ByteBuffer;
import java.nio.channels.DatagramChannel;
import java.util.Arrays;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Function;

/**
 * Copy right (c) Zhou Yuding since 14-4-29
 */
public class Plotter {
    private Client client;
    private InetAddress localAddress;
    private boolean started;
    private ServerProWrapper serverDaemon;
    private Application application;

    public void start() {
        Helper.prompt("Application is starting.");
        connectServer();
    }

    /**
     * Assumption made, subnetwork mask is 255.255.255.0
     */
    private void connectServer() {
        InetAddress localAddress;
        if ((localAddress = findLocalAddress()) == null) {
            Helper.prompt("Can't resolve LOCALHOST. Aborting application.");
            stop();
        }
        if (!(started = connectAvailableServer(localAddress))) {
            Helper.prompt("No available server in LAN. Starting server daemon.");
            startServerAndConnectToSelf();
        }
        listenToRemote();
    }

    private void listenToRemote() {
        if (client.isConnected()) {
            ThreadPoolExecutorWrapper.instance().execute(() -> {
                Helper.prompt("Client is listening to remote.");
                while (client.isConnected()) {
                    try {
                        ByteBuffer buffer = Helper.newDataBuffer();
                        int receivedSize = client.receive(buffer);// Non-blocking?
                        Helper.trace(String.format("Receive %s %s from remote", receivedSize, receivedSize > 1 ? "bytes" : "byte"));
                        application.onDataReceive(buffer);
                    } catch (IOException e) {
                        Helper.logEx(e);
                        Helper.prompt("Client failed to read from remote.");
                        handleRemoteConnectionLost();
                        break;
                    }
                }
            });
        }
    }

    private void handleRemoteConnectionLost() {
        Helper.prompt("remote connection lost.");
        if (serverDaemon != null && serverDaemon.isStarted()) {
            serviceHandover();
        } else {
            stop();
        }
    }

    private void serviceHandover() {
        Helper.prompt("Service handover doesn't implemented");
        stop();
    }

    private void startServerAndConnectToSelf() {
        startServer();
        if (started = serverDaemon.isStarted()) {
            client = Client.open();
            if (client.connect(new InetSocketAddress("127.0.0.1", ServerProWrapper.SERVER_PORT))) {
                Helper.prompt(String.format("Connect to self successfully."));
            } else {
                Helper.prompt("Can't connect to self.");
            }
        } else {
            stop();
            Helper.prompt("Server is not started. Stop application.");
        }
    }

    private boolean connectAvailableServer(InetAddress localAddress) {
        AtomicBoolean serverFound = new AtomicBoolean(false);
        final int CLIENT_ECHO_PORT = 3005;
        final int FINDING_SERVER_TIMEOUT = 30;
        final CountDownLatch latch = new CountDownLatch(1);
        try {
            final DatagramChannel echoChannel = DatagramChannel.open();
            echoChannel.bind(new InetSocketAddress(CLIENT_ECHO_PORT));
            listenToEchoResponse(echoChannel, (InetSocketAddress remote) -> {
                client = Client.open();
                serverFound.set(client.connect(remote));
                latch.countDown();
                try {
                    echoChannel.close();
                } catch (IOException e) {
                    //ignore
                }
                return null;
            });
            sendEchoRequest(localAddress, echoChannel, () -> {
                try {
                    if (!serverFound.get()) {
                        Thread.sleep(1000);//Latch will be count down 1 second after all echo request were sent.
                    }
                } catch (InterruptedException e) {
                    //ignore
                }
                latch.countDown();
            });
            latch.await(FINDING_SERVER_TIMEOUT, TimeUnit.SECONDS);
            return serverFound.get();
        } catch (BindException e) {
            Helper.prompt(String.format("Can't bind echo channel to port %s.", CLIENT_ECHO_PORT));
            stop();
            return false;
        } catch (IOException e) {
            Helper.prompt("Can't start echo channel.");
            return false;
        } catch (InterruptedException e) {
            Helper.prompt("Finding server interrupted.");
            return false;
        }
    }

    private void listenToEchoResponse(final DatagramChannel echoChannel, final Function<InetSocketAddress, Void> callback) {
        ThreadPoolExecutorWrapper.instance().execute(() -> {
            int attempt = 0;
            while (attempt++ < 255) {
                ByteBuffer echoResponse = Helper.newEmptyEcho();
                try {
                    InetSocketAddress remote = (InetSocketAddress) echoChannel.receive(echoResponse);
                    Helper.prompt(String.format("Received echo response to %s:%s.", remote.getHostString(), remote.getPort()));
                    if (Helper.acceptEchoResponse(echoResponse)) {
                        Helper.prompt(String.format("Server found :  %s:%s.", remote.getHostString(), remote.getPort()));
                        callback.apply(new InetSocketAddress(remote.getAddress(), ServerProWrapper.SERVER_PORT));
                        break;
                    }
                } catch (IOException e) {
                    Helper.logEx(e);
                }
            }
        });
    }

    private void sendEchoRequest(InetAddress localAddress, DatagramChannel echoChannel, Runnable callback) {
        byte[] localIP = localAddress.getAddress();
        byte[] gatewayIP = Arrays.copyOf(localIP, 3);
        for (int i = 1; i <= 255; i++) {
            int[] potentialIP = new int[4];
            potentialIP[0] = gatewayIP[0];
            potentialIP[1] = gatewayIP[1];
            potentialIP[2] = gatewayIP[2];
            potentialIP[3] = i;
            InetSocketAddress remote = new InetSocketAddress(Helper.toIPString(potentialIP), ServerProWrapper.ECHO_PORT);
            try {
                echoChannel.send(Helper.newEchoRequest(), remote);
                //Helper.prompt(String.format("Sent echo request to %s:%s.", remote.getHostString(), remote.getPort()));
            } catch (IOException e) {
                Helper.logEx(e);
            }
        }
        callback.run();
    }

    private InetAddress findLocalAddress() {
        localAddress = null;
        try {
            localAddress = InetAddress.getLocalHost();
        } catch (UnknownHostException e) {
            Helper.logEx(e);
        }
        try {
            if (!Helper.isPublicIPAddress(localAddress)) {
                localAddress = Helper.findAddressFromNetworkInterface();
            }
        } catch (SocketException e) {
            Helper.logEx(e);
        }
        return localAddress;
    }

    private void startServer() {
        CountDownLatch latch = new CountDownLatch(1);
        serverDaemon = new ServerProWrapper();
        serverDaemon.setStartHook(() -> latch.countDown());
        serverDaemon.start();
        try {
            if (!latch.await(ServerProWrapper.SERVER_START_TIMEOUT, TimeUnit.SECONDS)) {
                Helper.prompt("Server starting timeout.");
                stop();
            }
        } catch (InterruptedException e) {
            Helper.prompt("Server doesn't start. Aborting application.");
            stop();
        }
    }

    public boolean send(ByteBuffer buffer) {
        return client.send(buffer);
    }

    public void stop() {
        started = false;
        Helper.prompt("Stop application.");
        System.exit(255);
    }

    public boolean isStarted() {
        return started;
    }

    public void setApplication(Application application) {
        this.application = application;
        Processor.instance().setTransactionHandler(application.getTransactionHandler());
    }
}
