/*
 This file is part of GNUnet.
 (C) 2011, 2012 Christian Grothoff (and other contributing authors)

 GNUnet is free software; you can redistribute it and/or modify
 it under the terms of the GNU General Public License as published
 by the Free Software Foundation; either version 3, or (at your
 option) any later version.

 GNUnet is distributed in the hope that it will be useful, but
 WITHOUT ANY WARRANTY; without even the implied warranty of
 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 General Public License for more details.

 You should have received a copy of the GNU General Public License
 along with GNUnet; see the file COPYING.  If not, write to the
 Free Software Foundation, Inc., 59 Temple Place - Suite 330,
 Boston, MA 02111-1307, USA.
 */

package org.gnunet.util;

import com.google.common.net.InetAddresses;
import org.gnunet.construct.*;
import org.gnunet.util.getopt.Argument;
import org.gnunet.util.getopt.ArgumentAction;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.LinkedList;

/**
 * Resolve hostnames asynchronously, using the gnunet resolver service if necessary.
 * <p/>
 * TODO: implement reverse lookup (already done in the C-API)
 */
public class Resolver {
    private static final Logger logger = LoggerFactory
            .getLogger(Resolver.class);

    private static Resolver singletonInstance;

    private Configuration cfg;

    private Client client;

    public static InetAddress getInetAddressFromString(String ipString) {
        try {
            return InetAddresses.forString(ipString);
        } catch (IllegalArgumentException e) {
            return null;
        }
    }

    @UnionCase(4)
    public static class GetMessage implements GnunetMessage.Body {
        static final int DIRECTION_GET_IP = 0;
        static final int DIRECTION_GET_NAME = 1;
        static final int AF_UNSPEC = 0;
        static final int AF_INET = 2;
        static final int AF_INET6 = 10;

        @UInt32
        public int direction;
        @UInt32
        public int domain;

        @Union(tag = "direction", optional = true)
        public Address addr;
    }

    public interface Address extends MessageUnion {
    }

    @UnionCase(GetMessage.DIRECTION_GET_IP)
    public static class TextualAddress implements Address {
        @ZeroTerminatedString
        public String addr;
    }

    @UnionCase(GetMessage.DIRECTION_GET_NAME)
    public static class NumericAddress implements Address {
        @FillWith @UInt8
        public byte[] addr;
    }


    @UnionCase(5)
    public static class ResolverResponse implements GnunetMessage.Body {
        @NestedMessage(optional = true)
        public ResponseBody responseBody;
    }


    public static class ResponseBody implements Message {
        @FillWith @UInt8
        public byte[] addr;
    }

    /**
     * Callback object for hostname resolution.
     */
    public interface AddressCallback {
        /**
         * Called for every address the requested hostname resolves to.
         *
         * @param addr address for the resolved name
         */
        public void onAddress(InetAddress addr);

        /**
         * Called after every result (if any) has been passed to onAddress.
         */
        public void onFinished();

        /**
         * Called when the resolve operation times out before returning every result.
         */
        void onTimeout();
    }


    /**
     * Configuration to use with the Resolver.
     * <p/>
     * Usually called by the entry points Program/Service.
     *
     * @param cfg configuration to use
     */
    public void setConfiguration(Configuration cfg) {
        this.cfg = cfg;
    }

    private void lazyConnect() {
        if (client == null) {
            if (cfg == null) {
                throw new AssertionError("Resolver has no Configuration");
            }
            client = new Client("resolver", cfg);
        }
    }


    private InetAddress getInet4Localhost() {
        try {
            return InetAddress.getByAddress(new byte[]{127, 0, 0, 1});
        } catch (UnknownHostException e) {
            throw new RuntimeException();
        }
    }

    private InetAddress getInet6Localhost() {
        try {
            return InetAddress.getByAddress(new byte[]{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1});
        } catch (UnknownHostException e) {
            throw new RuntimeException();
        }
    }

    public class ResolveHandle implements Cancelable {
        private String hostname;
        private AbsoluteTime deadline;
        private AddressCallback cb;
        private boolean finished = false;
        private boolean canceled = false;
        private Cancelable transmitTask = null;

        public void cancel() {
            if (finished) {
                throw new AssertionError("Resolve already finished");
            }
            if (canceled) {
                throw new AssertionError("ResolveHandle canceled twice");
            }
            if (queuedRequests.contains(this)) {
                queuedRequests.remove(this);
            } else {
                if (transmitTask != null) {
                    transmitTask.cancel();
                }
            }
            canceled = true;
        }
    }

    private LinkedList<ResolveHandle> queuedRequests = new LinkedList<ResolveHandle>();

    private boolean resolveActive = false;

    /**
     * Resolve the hostname 'hostname'.
     *
     * @param hostname hostname to resolve
     * @param timeout  timeout, calls cb.onTimeout on expiratoin
     * @param cb       callback
     * @return a handle to onCancel the getRequestIdentifier, null if getRequestIdentifier could be satisfied immediately
     */
    public Cancelable resolveHostname(String hostname, RelativeTime timeout, final AddressCallback cb) {
        // try if hostname is numeric IP or loopback
        if (hostname.equalsIgnoreCase("localhost")) {
            logger.debug("resolving address locally");
            cb.onAddress(getInet6Localhost());
            cb.onAddress(getInet4Localhost());
            cb.onFinished();
            return null;
        }
        if (hostname.equalsIgnoreCase("ip6-localhost")) {
            cb.onAddress(getInet6Localhost());
            cb.onFinished();
            return null;
        }
        InetAddress inetAddr = getInetAddressFromString(hostname);

        if (inetAddr != null) {
            cb.onAddress(inetAddr);
            cb.onFinished();
            return null;
        }

        final ResolveHandle rh = new ResolveHandle();
        rh.hostname = hostname;
        rh.deadline = timeout.toAbsolute();
        rh.cb = cb;

        queuedRequests.addLast(rh);
        handleNextRequest();
        return rh;
    }

    private void handleNextRequest() {
        if (!resolveActive && !queuedRequests.isEmpty()) {
            ResolveHandle rh = queuedRequests.pollFirst();
            handleRequest(rh);
        }
    }

    private void handleRequest(final ResolveHandle rh) {
        if (resolveActive) {
            throw new AssertionError("resolveActive but new resolve started");
        }

        resolveActive = true;

        lazyConnect();

        final GetMessage req = new GetMessage();
        req.direction = GetMessage.DIRECTION_GET_IP;
        req.domain = GetMessage.AF_UNSPEC;

        TextualAddress textAddr = new TextualAddress();
        textAddr.addr = rh.hostname;

        req.addr = textAddr;

        final AbsoluteTime deadline = rh.deadline;

        logger.debug("deadline is " + deadline + " | now is " + AbsoluteTime.now());

        logger.debug("remaining is " + deadline.getRemaining());

        rh.transmitTask = client.notifyTransmitReady(
                deadline.getRemaining(), true,
                0, new MessageTransmitter() {
            @Override
            public void transmit(Connection.MessageSink sink) {
                if (sink == null) {
                    onTimeout(rh);
                    return;
                }
                sink.send(req);
                rh.transmitTask = null;

                logger.debug("recv in notifyTransmitReady cb");
                client.receiveOne(deadline.getRemaining(), new MessageReceiver() {
                    @Override
                    public void process(GnunetMessage.Body msg) {
                        ResolverResponse gmsg = (ResolverResponse) msg;
                        if (gmsg.responseBody != null) {
                            try {
                                InetAddress in_addr;
                                int len = gmsg.responseBody.addr.length;
                                if (len == 4 || len == 16) {
                                    in_addr = InetAddress.getByAddress(gmsg.responseBody.addr);
                                } else {
                                    throw new ProtocolViolationException("malformed address message");
                                }

                                rh.cb.onAddress(in_addr);
                                client.receiveOne(deadline.getRemaining(), this);
                            } catch (UnknownHostException e) {
                                throw new ProtocolViolationException("malformed address");
                            }
                        } else {
                            resolveActive = false;
                            rh.cb.onFinished();
                            handleNextRequest();
                        }
                    }

                    @Override
                    public void handleError() {
                        onTimeout(rh);
                    }
                });

            }

            @Override
            public void handleError() {
                rh.cb.onTimeout();
            }
        });
    }


    private void onTimeout(ResolveHandle h) {
        resolveActive = false;
        h.cb.onTimeout();
        handleNextRequest();
    }


    public static Resolver getInstance() {
        if (singletonInstance == null) {
            singletonInstance = new Resolver();
        }
        return singletonInstance;
    }


    /**
     * Return a textual representation of an InetAddress. Shortens IPv6 addresses.
     *
     * @param addr the address to convert
     * @return textual representation of the address
     */
    public static String ipToString(InetAddress addr) {
        byte[] a = addr.getAddress();
        if (a.length == 4) {
            return addr.getHostAddress();
        } else if (a.length == 16) {
            String s = addr.getHostAddress();
            // replace the first group of zeroes (not the longest) with ::
            return s.replaceFirst("[:]?0[:](0[:])+0?", "::");
        } else {
            throw new RuntimeException("unknown InetAddress format");
        }
    }


    public static void main(final String[] argv) {
        new Program() {
            @Argument(shortname = "r", longname = "reverse",
                    description = "do reverse dns lookup",
                    action = ArgumentAction.SET)
            boolean isReverse;

            @Override
            public void run() {
                if (isReverse) {
                    System.out.println("reverse lookup not supported");
                } else {
                    resolve();
                }
            }

            public void resolve() {
                final RelativeTime timeout = RelativeTime.SECOND;

                if (unprocessedArgs.length == 0) {
                    logger.warn("no hostname(s) given");
                } else {
                    logger.info("resolving hostname '" + unprocessedArgs[0] + "'");
                    Resolver.getInstance().resolveHostname(unprocessedArgs[0], timeout, new AddressCallback() {
                        int next = 1;

                        @Override
                        public void onAddress(InetAddress addr) {
                            System.out.println(ipToString(addr));
                        }

                        @Override
                        public void onFinished() {
                            logger.info("resolve finished");
                            next();
                        }

                        @Override
                        public void onTimeout() {
                            logger.warn("resolve timed out");
                            next();

                        }

                        public void next() {
                            if (unprocessedArgs.length > next) {
                                logger.info("resolving hostname '" + unprocessedArgs[next] + "'");
                                Resolver.getInstance().resolveHostname(unprocessedArgs[next], timeout, this);
                                next++;
                            }
                        }
                    });
                }

            }

            @Override
            protected String makeHelpText() {
                return "tool for forward and reverse DNS lookup";
            }
        }.start(argv);
    }
}
