package net.becvert.cordova;

import android.content.Context;
import android.net.wifi.WifiManager;
import android.os.Build;
import android.provider.Settings;
import android.text.TextUtils;
import android.util.Log;

import org.apache.cordova.CallbackContext;
import org.apache.cordova.CordovaInterface;
import org.apache.cordova.CordovaPlugin;
import org.apache.cordova.CordovaWebView;
import org.apache.cordova.PluginResult;
import org.apache.cordova.PluginResult.Status;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.net.Inet4Address;
import java.net.Inet6Address;
import java.net.InetAddress;
import java.net.NetworkInterface;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;

import javax.jmdns.JmDNS;
import javax.jmdns.ServiceEvent;
import javax.jmdns.ServiceInfo;
import javax.jmdns.ServiceListener;

import static android.content.Context.WIFI_SERVICE;

public class ZeroConf extends CordovaPlugin {

    private static final String TAG = "ZeroConf";

    WifiManager.MulticastLock lock;

    private RegistrationManager registrationManager;
    private BrowserManager browserManager;
    private List<InetAddress> addresses;
    private List<InetAddress> ipv6Addresses;
    private List<InetAddress> ipv4Addresses;
    private String hostname;

    public static final String ACTION_GET_HOSTNAME = "getHostname";
    // publisher
    public static final String ACTION_REGISTER = "register";
    public static final String ACTION_UNREGISTER = "unregister";
    public static final String ACTION_STOP = "stop";
    // browser
    public static final String ACTION_WATCH = "watch";
    public static final String ACTION_UNWATCH = "unwatch";
    public static final String ACTION_CLOSE = "close";
    // Re-initialize
    public static final String ACTION_REINIT = "reInit";

    @Override
    public void initialize(CordovaInterface cordova, CordovaWebView webView) {
        super.initialize(cordova, webView);

        Context context = this.cordova.getActivity().getApplicationContext();
        WifiManager wifi = (WifiManager) context.getSystemService(WIFI_SERVICE);
        lock = wifi.createMulticastLock("ZeroConfPluginLock");
        lock.setReferenceCounted(false);

        try {
            addresses = new CopyOnWriteArrayList<InetAddress>();
            ipv6Addresses = new CopyOnWriteArrayList<InetAddress>();
            ipv4Addresses = new CopyOnWriteArrayList<InetAddress>();
            List<NetworkInterface> intfs = Collections.list(NetworkInterface.getNetworkInterfaces());
            for (NetworkInterface intf : intfs) {
                if (intf.supportsMulticast()) {
                    List<InetAddress> addrs = Collections.list(intf.getInetAddresses());
                    for (InetAddress addr : addrs) {
                        if (!addr.isLoopbackAddress()) {
                            if (addr instanceof Inet6Address) {
                                addresses.add(addr);
                                ipv6Addresses.add(addr);
                            } else if (addr instanceof Inet4Address) {
                                addresses.add(addr);
                                ipv4Addresses.add(addr);
                            }
                        }
                    }
                }
            }
        } catch (Exception e) {
            Log.e(TAG, e.getMessage(), e);
        }

        Log.d(TAG, "Addresses " + addresses);

        try {
            hostname = getHostName(cordova);
        } catch (Exception e) {
            Log.e(TAG, e.getMessage(), e);
        }

        Log.d(TAG, "Hostname " + hostname);

        Log.v(TAG, "Initialized");
    }

    @Override
    public void onDestroy() {
        super.onDestroy();
        if (registrationManager != null) {
            try {
                registrationManager.stop();
            } catch (IOException e) {
                Log.e(TAG, e.getMessage(), e);
            } finally {
                registrationManager = null;
            }
        }
        if (browserManager != null) {
            try {
                browserManager.close();
            } catch (IOException e) {
                Log.e(TAG, e.getMessage(), e);
            } finally {
                browserManager = null;
            }
        }
        if (lock != null) {
            lock.release();
            lock = null;
        }

        Log.v(TAG, "Destroyed");
    }

    @Override
    public boolean execute(String action, JSONArray args, final CallbackContext callbackContext) {

        if (ACTION_GET_HOSTNAME.equals(action)) {

            if (hostname != null) {

                Log.d(TAG, "Hostname: " + hostname);

                callbackContext.success(hostname);

            } else {
                callbackContext.error("Error: undefined hostname");
                return false;
            }

        } else if (ACTION_REGISTER.equals(action)) {

            final String type = args.optString(0);
            final String domain = args.optString(1);
            final String name = args.optString(2);
            final int port = args.optInt(3);
            final JSONObject props = args.optJSONObject(4);
            final String addressFamily = args.optString(5);

            Log.d(TAG, "Register " + type + domain);

            cordova.getThreadPool().execute(new Runnable() {
                @Override
                public void run() {
                    try {
                        if (registrationManager == null) {
                            List<InetAddress> selectedAddresses = addresses;
                            if ("ipv6".equalsIgnoreCase(addressFamily)) {
                                selectedAddresses = ipv6Addresses;
                            } else if ("ipv4".equalsIgnoreCase(addressFamily)) {
                                selectedAddresses = ipv4Addresses;
                            }
                            registrationManager = new RegistrationManager(selectedAddresses, hostname);
                        }

                        ServiceInfo service = registrationManager.register(type, domain, name, port, props);
                        if (service == null) {
                            callbackContext.error("Failed to register");
                            return;
                        }

                        JSONObject status = new JSONObject();
                        status.put("action", "registered");
                        status.put("service", jsonifyService(service));

                        Log.d(TAG, "Sending result: " + status.toString());

                        PluginResult result = new PluginResult(PluginResult.Status.OK, status);
                        callbackContext.sendPluginResult(result);

                    } catch (JSONException e) {
                        Log.e(TAG, e.getMessage(), e);
                        callbackContext.error("Error: " + e.getMessage());
                    } catch (IOException e) {
                        Log.e(TAG, e.getMessage(), e);
                        callbackContext.error("Error: " + e.getMessage());
                    } catch (RuntimeException e) {
                        Log.e(TAG, e.getMessage(), e);
                        callbackContext.error("Error: " + e.getMessage());
                    }
                }
            });

        } else if (ACTION_UNREGISTER.equals(action)) {

            final String type = args.optString(0);
            final String domain = args.optString(1);
            final String name = args.optString(2);

            Log.d(TAG, "Unregister " + type + domain);

            if (registrationManager != null) {
                final RegistrationManager rm = registrationManager;
                cordova.getThreadPool().execute(new Runnable() {

                    @Override
                    public void run() {
                        rm.unregister(type, domain, name);
                        callbackContext.success();
                    }
                });
            } else {
                callbackContext.success();
            }

        } else if (ACTION_STOP.equals(action)) {

            Log.d(TAG, "Stop");

            if (registrationManager != null) {
                final RegistrationManager rm = registrationManager;
                registrationManager = null;
                cordova.getThreadPool().execute(new Runnable() {
                    @Override
                    public void run() {
                        try {
                            rm.stop();
                            callbackContext.success();

                        } catch (IOException e) {
                            Log.e(TAG, e.getMessage(), e);
                            callbackContext.error("Error: " + e.getMessage());
                        }
                    }
                });
            } else {
                callbackContext.success();
            }

        } else if (ACTION_WATCH.equals(action)) {

            final String type = args.optString(0);
            final String domain = args.optString(1);
            final String addressFamily = args.optString(2);

            Log.d(TAG, "Watch " + type + domain);

            cordova.getThreadPool().execute(new Runnable() {

                @Override
                public void run() {
                    try {
                        if (browserManager == null) {
                            List<InetAddress> selectedAddresses = addresses;
                            if ("ipv6".equalsIgnoreCase(addressFamily)) {
                                selectedAddresses = ipv6Addresses;
                            } else if ("ipv4".equalsIgnoreCase(addressFamily)) {
                                selectedAddresses = ipv4Addresses;
                            }
                            browserManager = new BrowserManager(selectedAddresses, hostname);
                        }

                        browserManager.watch(type, domain, callbackContext);

                    } catch (IOException e) {
                        Log.e(TAG, e.getMessage(), e);
                        callbackContext.error("Error: " + e.getMessage());
                    } catch (RuntimeException e) {
                        Log.e(TAG, e.getMessage(), e);
                        callbackContext.error("Error: " + e.getMessage());
                    }
                }
            });

            PluginResult result = new PluginResult(Status.NO_RESULT);
            result.setKeepCallback(true);
            callbackContext.sendPluginResult(result);

        } else if (ACTION_UNWATCH.equals(action)) {

            final String type = args.optString(0);
            final String domain = args.optString(1);

            Log.d(TAG, "Unwatch " + type + domain);

            if (browserManager != null) {
                final BrowserManager bm = browserManager;
                cordova.getThreadPool().execute(new Runnable() {
                    @Override
                    public void run() {
                        bm.unwatch(type, domain);
                        callbackContext.success();
                    }
                });
            } else {
                callbackContext.success();
            }

        } else if (ACTION_CLOSE.equals(action)) {

            Log.d(TAG, "Close");

            if (browserManager != null) {
                final BrowserManager bm = browserManager;
                browserManager = null;
                cordova.getThreadPool().execute(new Runnable() {

                    @Override
                    public void run() {
                        try {
                            bm.close();
                            callbackContext.success();

                        } catch (IOException e) {
                            Log.e(TAG, e.getMessage(), e);
                            callbackContext.error("Error: " + e.getMessage());
                        }
                    }
                });
            } else {
                callbackContext.success();
            }

        } else if (ACTION_REINIT.equals(action)) {
            Log.e(TAG, "Re-Initializing");

            cordova.getThreadPool().execute(new Runnable() {
                @Override
                public void run() {
                    onDestroy();
                    initialize(cordova, webView);
                    callbackContext.success();

                    Log.e(TAG, "Re-Initialization complete");
                }
            });

        } else {
            Log.e(TAG, "Invalid action: " + action);
            callbackContext.error("Invalid action: " + action);
            return false;
        }

        return true;
    }

    private class RegistrationManager {

        private List<JmDNS> publishers = new ArrayList<JmDNS>();

        public RegistrationManager(List<InetAddress> addresses, String hostname) throws IOException {

            if (addresses == null || addresses.size() == 0) {
                publishers.add(JmDNS.create(null, hostname));
            } else {
                for (InetAddress addr : addresses) {
                    publishers.add(JmDNS.create(addr, hostname));
                }
            }

        }

        public ServiceInfo register(String type, String domain, String name, int port, JSONObject props) throws JSONException, IOException {

            HashMap<String, String> txtRecord = new HashMap<String, String>();
            if (props != null) {
                Iterator<String> iter = props.keys();
                while (iter.hasNext()) {
                    String key = iter.next();
                    txtRecord.put(key, props.getString(key));
                }
            }

            ServiceInfo aService = null;
            for (JmDNS publisher : publishers) {
                ServiceInfo service = ServiceInfo.create(type + domain, name, port, 0, 0, txtRecord);
                try {
                    publisher.registerService(service);
                    aService = service;
                } catch (IOException e) {
                    Log.e(TAG, e.getMessage(), e);
                }
            }
            // returns only one of the ServiceInfo instances!
            return aService;
        }

        public void unregister(String type, String domain, String name) {

            for (JmDNS publisher : publishers) {
                ServiceInfo serviceInfo = publisher.getServiceInfo(type + domain, name, 5000);
                if (serviceInfo != null) {
                    publisher.unregisterService(serviceInfo);
                }
            }

        }

        public void stop() throws IOException {

            for (JmDNS publisher : publishers) {
                publisher.close();
            }

        }

    }

    private class BrowserManager implements ServiceListener {

        private List<JmDNS> browsers = new ArrayList<JmDNS>();

        private Map<String, CallbackContext> callbacks = new HashMap<String, CallbackContext>();

        public BrowserManager(List<InetAddress> addresses, String hostname) throws IOException {

            lock.acquire();

            if (addresses == null || addresses.size() == 0) {
                browsers.add(JmDNS.create(null, hostname));
            } else {
                for (InetAddress addr : addresses) {
                    browsers.add(JmDNS.create(addr, hostname));
                }
            }
        }

        private void watch(String type, String domain, CallbackContext callbackContext) {

            callbacks.put(type + domain, callbackContext);

            for (JmDNS browser : browsers) {
                browser.addServiceListener(type + domain, this);
            }

        }

        private void unwatch(String type, String domain) {

            callbacks.remove(type + domain);

            for (JmDNS browser : browsers) {
                browser.removeServiceListener(type + domain, this);
            }

        }

        private void close() throws IOException {

            lock.release();

            callbacks.clear();

            for (JmDNS browser : browsers) {
                browser.close();
            }

        }

        @Override
        public void serviceResolved(ServiceEvent ev) {
            Log.d(TAG, "Resolved");

            sendCallback("resolved", ev.getInfo());
        }

        @Override
        public void serviceRemoved(ServiceEvent ev) {
            Log.d(TAG, "Removed");

            sendCallback("removed", ev.getInfo());
        }

        @Override
        public void serviceAdded(ServiceEvent ev) {
            Log.d(TAG, "Added");

            sendCallback("added", ev.getInfo());
        }

        public void sendCallback(String action, ServiceInfo service) {
            CallbackContext callbackContext = callbacks.get(service.getType());
            if (callbackContext == null) {
                return;
            }

            JSONObject status = new JSONObject();
            try {
                status.put("action", action);
                status.put("service", jsonifyService(service));

                Log.d(TAG, "Sending result: " + status.toString());

                PluginResult result = new PluginResult(PluginResult.Status.OK, status);
                result.setKeepCallback(true);
                callbackContext.sendPluginResult(result);

            } catch (JSONException e) {
                Log.e(TAG, e.getMessage(), e);
                callbackContext.error("Error: " + e.getMessage());
            }
        }

    }

    private static JSONObject jsonifyService(ServiceInfo service) throws JSONException {
        JSONObject obj = new JSONObject();

        String domain = service.getDomain() + ".";
        obj.put("domain", domain);
        obj.put("type", service.getType().replace(domain, ""));
        obj.put("name", service.getName());
        obj.put("port", service.getPort());
        obj.put("hostname", service.getServer());

        JSONArray ipv4Addresses = new JSONArray();
        InetAddress[] inet4Addresses = service.getInet4Addresses();
        for (int i = 0; i < inet4Addresses.length; i++) {
            if (inet4Addresses[i] != null) {
                ipv4Addresses.put(inet4Addresses[i].getHostAddress());
            }
        }
        obj.put("ipv4Addresses", ipv4Addresses);

        JSONArray ipv6Addresses = new JSONArray();
        InetAddress[] inet6Addresses = service.getInet6Addresses();
        for (int i = 0; i < inet6Addresses.length; i++) {
            if (inet6Addresses[i] != null) {
                ipv6Addresses.put(inet6Addresses[i].getHostAddress());
            }
        }
        obj.put("ipv6Addresses", ipv6Addresses);

        JSONObject props = new JSONObject();
        Enumeration<String> names = service.getPropertyNames();
        while (names.hasMoreElements()) {
            String name = names.nextElement();
            props.put(name, service.getPropertyString(name));
        }
        obj.put("txtRecord", props);

        return obj;

    }

    // http://stackoverflow.com/questions/21898456/get-android-wifi-net-hostname-from-code
    public static String getHostName(CordovaInterface cordova) throws NoSuchMethodException, SecurityException, IllegalAccessException, IllegalArgumentException, InvocationTargetException {
        Method getString = Build.class.getDeclaredMethod("getString", String.class);
        getString.setAccessible(true);
        String hostName = getString.invoke(null, "net.hostname").toString();
        if (TextUtils.isEmpty(hostName)) {
            // API 26+ :
            // Querying the net.hostname system property produces a null result
            String id = Settings.Secure.getString(cordova.getActivity().getContentResolver(), Settings.Secure.ANDROID_ID);
            hostName = "android-" + id;
        }
        return hostName;
    }

}