/*
 * This file is part of muCommander, http://www.mucommander.com
 *
 * muCommander 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 of the License, or
 * (at your option) any later version.
 *
 * muCommander 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 this program.  If not, see <http://www.gnu.org/licenses/>.
 */

package com.mucommander.bonjour;

import java.io.IOException;
import java.net.Inet6Address;
import java.net.MalformedURLException;
import java.util.List;
import java.util.Vector;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;

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

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

import com.mucommander.commons.conf.ConfigurationEvent;
import com.mucommander.commons.conf.ConfigurationListener;
import com.mucommander.commons.file.FileURL;
import com.mucommander.commons.file.protocol.FileProtocols;
import com.mucommander.conf.MuConfigurations;
import com.mucommander.conf.MuPreferences;

/**
 * Collects and maintains a list of available Bonjour/Zeroconf services using the JmDNS library.
 * Newly discovered services are added to the list and removed as they become unavailable.
 *
 * <p>Use {@link #getServices()} to get a list of currently available Bonjour services.
 *
 * @author Maxence Bernard
 * @see BonjourMenu
 */
public final class BonjourDirectory implements ServiceListener, ConfigurationListener {
    private static final Logger LOGGER = LoggerFactory.getLogger(BonjourDirectory.class);

    private static final Object LOCK = new Object();

    /** Does all the hard work */
    private static JmDNS jmDNS;

    /**
     * To track whether the service is now starting.
     */
    private static boolean starting = false;

    /** List of discovered and currently active Bonjour services */
    private static List<BonjourService> services = new Vector<>();

    /** Known Bonjour/Zeroconf service types and their corresponding protocol */
    private final static String KNOWN_SERVICE_TYPES[][] = {
        {"_http._tcp.local.", FileProtocols.HTTP},
        {"_ftp._tcp.local.", FileProtocols.FTP},
        {"_ssh._tcp.local.", FileProtocols.SFTP},
        {"_smb._tcp.local.", FileProtocols.SMB}
    };

    /** Number of milliseconds to wait for service info resolution before giving up */
    private final static int SERVICE_RESOLUTION_TIMEOUT = 10000;

    private static class BonjourDirectoryHolder {
        private static final BonjourDirectory INSTANCE = new BonjourDirectory();
    }

    /**
     * No-arg constructor made private so that only one instance can exist.
     */
    private BonjourDirectory() {
        MuConfigurations.addPreferencesListener(this);
    }

    private static BonjourDirectory getInstance() {
        return BonjourDirectoryHolder.INSTANCE;
    }

    /**
     * Enables/disables Bonjour services discovery. If currently active and false is specified, current services
     * will be lost and {@link #getServices()} will return an empty array. If currently inactive and true is specified,
     * services discovery will be immediately started, but it may take a while (a few seconds at least) to
     * collect services. The activation (aka initialization) is being done asynchronously.
     * @param enabled whether Bonjour services discovery should be enabled.
     */
    public static void setActive(boolean enabled) {
        if (starting) {     // not so thread-safe, but I don't see risk here.
            return;
        }
        if (enabled && jmDNS == null) {
            // let's defer init of Bonjour (that does a lot of network witchery)
            // so muC can have more resources to start up quicker
            starting = true;
            var executor = CompletableFuture.delayedExecutor(5L, TimeUnit.SECONDS);
            CompletableFuture.runAsync(BonjourDirectory::initiateBonjour, executor);
        } else if(!enabled && jmDNS!=null) {
            // Shutdown JmDNS
            try {
                jmDNS.close();
            } catch (IOException e) {
                LOGGER.trace("Could not close jmDNS", e);
            }
            services.clear();
            jmDNS = null;
        }
    }

    /**
     * Returns <code>true</code> if Bonjour services discovery is currently running.
     * @return <code>true</code> if Bonjour services discovery is currently running, <code>false</code> otherwise.
     */
    public static boolean isActive() {
        return jmDNS != null;
    }

    /**
     * Returns true if Bonjour services discovery is still starting, false if either disabled
     * or failed to finish starting.
     * @return true if starting, otherwise either disabled or failed to start
     */
    public static boolean isStarting() {
        return starting;
    }

    /**
     * Returns all currently available Bonjour services. The returned array may be empty but never null.
     * If BonjourDirectory is not currently active ({@link #isActive()}, an empty array will be returned.
     * @return all currently available Bonjour services
     */
    public static BonjourService[] getServices() {
        BonjourService servicesArray[] = new BonjourService[services.size()];
        services.toArray(servicesArray);
        return servicesArray;
    }


    /**
     * Wraps a Bonjour service into a {@link BonjourService} object and returns it. Returns <code>null</code> if
     * the service type doesn't correspond to any of the supported protocols, or if the service URL is malformed.
     *
     * @param serviceInfo the ServiceInfo to wrap into a BonjourService
     * @return a BonjourService instance corresponding to the given ServiceInfo
     */
    private static BonjourService createBonjourService(ServiceInfo serviceInfo) {
        try {
            String type = serviceInfo.getType();
            int nbServices = KNOWN_SERVICE_TYPES.length;
            // Looks for the file protocol corresponding to the service type
            for (int i = 0; i < nbServices; i++) {
                if(KNOWN_SERVICE_TYPES[i][0].equals(type)) {
                    return new BonjourService(serviceInfo.getName(), FileURL.getFileURL(serviceInfo.getURL(KNOWN_SERVICE_TYPES[i][1])), serviceInfo.getQualifiedName());
                }
            }
        } catch(MalformedURLException e) {
            // Null will be returned
        }

        return null;
    }


    ////////////////////////////////////
    // ServiceListener implementation //
    ////////////////////////////////////

    public void serviceAdded(final ServiceEvent serviceEvent) {
        LOGGER.trace("name="+serviceEvent.getName()+" type="+serviceEvent.getType());
        
        // Ignore if Bonjour has been disabled
        if (!isActive()) {
            return;
        }

        // Resolve service info in a separate thread, serviceResolved() will be called once service info has been resolved.
        // Not spawning a thread often leads to service info loss (serviceResolved() not called).
        new Thread() {
            @Override
            public void run() {
                jmDNS.requestServiceInfo(serviceEvent.getType(), serviceEvent.getName(), SERVICE_RESOLUTION_TIMEOUT);
            }
        }.start();
    }

    public void serviceResolved(ServiceEvent serviceEvent) {
        LOGGER.trace("name="+serviceEvent.getName()+" type="+serviceEvent.getType()+" info="+serviceEvent.getInfo());

        // Ignore if Bonjour has been disabled
        if (!isActive()) {
            return;
        }

        // Creates a new BonjourService corresponding to the new service and add it to the list of current Bonjour services
        ServiceInfo serviceInfo = serviceEvent.getInfo();
        if (serviceInfo!=null) {
            if (serviceInfo.getInetAddress() instanceof Inet6Address) {
                // IPv6 addresses not supported at this time + they seem not to be correctly handled by ServiceInfo
                LOGGER.debug("ignoring IPv6 service");
                return;
            }

            BonjourService bs = createBonjourService(serviceInfo);
            // Synchronized to properly handle duplicate calls
            synchronized(LOCK) {
                if (bs != null && !services.contains(bs)) {
                    LOGGER.debug("BonjourService "+bs+" added");
                    services.add(bs);
                }
            }
        }
    }

    public void serviceRemoved(ServiceEvent serviceEvent) {
        LOGGER.trace("name="+serviceEvent.getName()+" type="+serviceEvent.getType());

        // Ignore if Bonjour has been disabled
        if (!isActive()) {
            return;
        }

        // Looks for an existing BonjourService instance corresponding to the service being removed and removes it from
        // the list of current Bonjour services.
        // ServiceInfo should be available in JmDNS's cache.
        ServiceInfo serviceInfo = jmDNS.getServiceInfo(serviceEvent.getType(), serviceEvent.getName()); 
        if(serviceInfo!=null) {
            if(serviceInfo.getInetAddress() instanceof Inet6Address) {
                // IPv6 addresses not supported at this time + they seem not to be correctly handled by ServiceInfo
                LOGGER.debug("ignoring IPv6 service");
                return;
            }

            BonjourService bs = createBonjourService(serviceInfo);
            // Synchronized to properly handle duplicate calls
            synchronized(LOCK) {
                // Note: BonjourService#equals() uses the service's fully qualified name as the discriminator.
                if(bs!=null && services.contains(bs)) {
                    LOGGER.debug("BonjourService "+bs+" removed");
                    services.remove(bs);
                }
            }
        }
    }

    private static void initiateBonjour() {
        // Start JmDNS
        try {
            jmDNS = JmDNS.create();

            // Listens to service events for known service types
            int nbServices = KNOWN_SERVICE_TYPES.length;
            for (int i=0; i<nbServices; i++) {
                jmDNS.addServiceListener(KNOWN_SERVICE_TYPES[i][0], getInstance());
            }
        } catch(IOException e) {
            LOGGER.warn("Could not instantiate jmDNS, Bonjour not enabled", e);
        } finally {
            starting = false;
        }
    }


    @Override
    public void configurationChanged(ConfigurationEvent event) {
        if (event.getVariable().equals(MuPreferences.ENABLE_BONJOUR_DISCOVERY)) {
            setActive(event.getBooleanValue());
        }
    }
}
