/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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.netbeans.core.network.proxy;

import java.io.BufferedInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.*;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.*;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.prefs.PreferenceChangeEvent;
import java.util.prefs.PreferenceChangeListener;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
import org.netbeans.core.ProxySettings;
import org.openide.util.RequestProcessor;
import org.openide.util.lookup.ServiceProvider;

/**
 *
 * @author Jiri Rechtacek
 */
@ServiceProvider(service = ProxySelector.class, position = 1000)
public final class NbProxySelector extends ProxySelector {
    
    private final ProxySelector original;
    private static final Logger LOG = Logger.getLogger (NbProxySelector.class.getName ());
    private static Boolean useSystemProxies;
    private static final String DEFAULT_PROXY_SELECTOR_CLASS_NAME = "sun.net.spi.DefaultProxySelector";
    private static final RequestProcessor RP = new RequestProcessor(NbProxySelector.class.getName(), 5);
    private static final int DNS_TIMEOUT = 10000;
        
    /** Creates a new instance of NbProxySelector */
    public NbProxySelector() {
        original = ProxySelector.getDefault();
        LOG.log(Level.FINE, "java.net.useSystemProxies has been set to {0}", useSystemProxies());
        if (original == null || original.getClass().getName().equals(DEFAULT_PROXY_SELECTOR_CLASS_NAME)) {
            NetworkProxyReloader.reloadNetworkProxy();
        }
        ProxySettings.addPreferenceChangeListener(new ProxySettingsListener());
        copySettingsToSystem();
    } 
    
    @Override
    public List<Proxy> select(URI uri) {
        List<Proxy> res = new ArrayList<Proxy> ();
        int proxyType = ProxySettings.getProxyType ();
        switch (proxyType) {
            case ProxySettings.DIRECT_CONNECTION:
                res = Collections.singletonList (Proxy.NO_PROXY);
                break;
            case ProxySettings.AUTO_DETECT_PROXY:
                if (useSystemProxies ()) {
                    if (original != null) {
                        res = original.select(uri);                   
                    }
                } else {                    
                    String protocol = uri.getScheme ();
                    assert protocol != null : "Invalid scheme of uri " + uri + ". Scheme cannot be null!";
                    if (dontUseProxy (ProxySettings.getSystemNonProxyHosts(), uri.getHost ())) {
                        res.add (Proxy.NO_PROXY);
                        break;
                    }
                    if (protocol.toLowerCase (Locale.US).startsWith("http")) {
                        String ports = ProxySettings.getSystemHttpPort();
                        if (ports != null && ports.length () > 0 && ProxySettings.getSystemHttpHost().length () > 0) {
                            int porti = Integer.parseInt(ports);
                            Proxy p = new Proxy (Proxy.Type.HTTP,  new InetSocketAddress (ProxySettings.getSystemHttpHost(), porti));
                            res.add (p);
                        }
                    } else { // supposed SOCKS
                        String ports = ProxySettings.getSystemSocksPort();
                        String hosts = ProxySettings.getSystemSocksHost();
                        if (ports != null && ports.length () > 0 && hosts.length () > 0) {
                            int porti = Integer.parseInt(ports);
                            Proxy p = new Proxy (Proxy.Type.SOCKS,  new InetSocketAddress (hosts, porti));
                            res.add (p);
                        }
                    }
                    if (original != null) {
                        res.addAll (original.select (uri));
                    }
                }
                break;
            case ProxySettings.MANUAL_SET_PROXY:
                String protocol = uri.getScheme ();
                assert protocol != null : "Invalid scheme of uri " + uri + ". Scheme cannot be null!";

                // handling nonProxyHosts first
                if (dontUseProxy (ProxySettings.getNonProxyHosts (), uri.getHost ())) {
                    res.add (Proxy.NO_PROXY);
                    break;
                }
                if (protocol.toLowerCase (Locale.US).startsWith("http")) {
                    String hosts = ProxySettings.getHttpHost ();
                    String ports = ProxySettings.getHttpPort ();
                    if (ports != null && ports.length () > 0 && hosts.length () > 0) {
                        int porti = Integer.parseInt(ports);
                        Proxy p = new Proxy (Proxy.Type.HTTP,  new InetSocketAddress (hosts, porti));
                        res.add (p);
                    } else {
                        LOG.log(Level.FINE, "Incomplete HTTP Proxy [{0}/{1}] found in ProxySelector[Type: {2}] for uri {3}. ", new Object[]{hosts, ports, ProxySettings.getProxyType (), uri});
                        if (original != null) {
                            LOG.log(Level.FINEST, "Fallback to the default ProxySelector which returns {0}", original.select (uri));
                            res.addAll (original.select (uri));
                        }
                    }
                } else { // supposed SOCKS
                    String ports = ProxySettings.getSocksPort ();
                    String hosts = ProxySettings.getSocksHost ();
                    if (ports != null && ports.length () > 0 && hosts.length () > 0) {
                        int porti = Integer.parseInt(ports);
                        Proxy p = new Proxy (Proxy.Type.SOCKS,  new InetSocketAddress (hosts, porti));
                        res.add (p);
                    } else {
                        LOG.log(Level.FINE, "Incomplete SOCKS Server [{0}/{1}] found in ProxySelector[Type: {2}] for uri {3}. ", new Object[]{hosts, ports, ProxySettings.getProxyType (), uri});
                        if (original != null) {
                            LOG.log(Level.FINEST, "Fallback to the default ProxySelector which returns {0}", original.select (uri));
                            res.addAll (original.select (uri));
                        }
                    }
                }
                res.add (Proxy.NO_PROXY);
                break;
            case ProxySettings.AUTO_DETECT_PAC:
                if (useSystemProxies ()) {
                    if (original != null) {
                        res = original.select(uri);                   
                    }
                } else {
                    ProxyAutoConfig pac = ProxyAutoConfig.get(getPacFile());
                    assert pac != null : "Instance of ProxyAutoConfig found for " + getPacFile();
                    if (pac == null) {
                        LOG.log(Level.FINEST, "No instance of ProxyAutoConfig({0}) for URI {1}", new Object[]{getPacFile(), uri});
                        res.add(Proxy.NO_PROXY);
                    }
                    if (pac.getPacURI().getHost() == null) {
                        LOG.log(Level.FINEST, "Identifying proxy for URI {0}---{1}, PAC LOCAL URI: {2}", //NOI18N
                                new Object[] { uri.toString(), uri.getHost(), pac.getPacURI().toString() });
                        res.addAll(pac.findProxyForURL(uri));
                    } else if (pac.getPacURI().getHost().equals(uri.getHost())) {
                        // don't proxy PAC files
                        res.add(Proxy.NO_PROXY);
                    } else {
                        LOG.log(Level.FINEST, "Identifying proxy for URI {0}---{1}, PAC URI: {2}---{3}", //NOI18N
                                new Object[] { uri.toString(), uri.getHost(), pac.getPacURI().toString(), pac.getPacURI().getHost() });
                        res.addAll(pac.findProxyForURL(uri));
                    }                    
                }
                
                if (original != null) {
                    res.addAll (original.select (uri));
                }
                
                res.add (Proxy.NO_PROXY);
                break;
            case ProxySettings.MANUAL_SET_PAC:
                // unused branch - never can setup PAC file from NetBeans
                
                ProxyAutoConfig pac = ProxyAutoConfig.get(getPacFile());
                assert pac != null : "Instance of ProxyAutoConfig found for " + getPacFile();
                if (pac == null) {
                    LOG.log(Level.FINEST, "No instance of ProxyAutoConfig({0}) for URI {1}", new Object[]{getPacFile(), uri});
                    res.add(Proxy.NO_PROXY);
                }
                if (pac.getPacURI().getHost() == null) {
                        LOG.log(Level.FINEST, "Identifying proxy for URI {0}---{1}, PAC LOCAL URI: {2}", //NOI18N
                                new Object[] { uri.toString(), uri.getHost(), pac.getPacURI().toString() });
                        res.addAll(pac.findProxyForURL(uri));
                } else if (pac.getPacURI().getHost().equals(uri.getHost())) {
                    // don't proxy PAC files
                    res.add(Proxy.NO_PROXY);
                } else {
                    LOG.log(Level.FINEST, "Identifying proxy for URI {0}---{1}, PAC URI: {2}---{3}", //NOI18N
                            new Object[] { uri.toString(), uri.getHost(), pac.getPacURI().toString(), pac.getPacURI().getHost() });
                    res.addAll(pac.findProxyForURL(uri)); // NOI18N
                }
                res.add (Proxy.NO_PROXY);
                break;
            default:
                assert false : "Invalid proxy type: " + proxyType;
        }
        LOG.log(Level.FINEST, "NbProxySelector[Type: {0}, Use HTTP for all protocols: {1}] returns {2} for URI {3}", 
                new Object[]{ProxySettings.getProxyType (), ProxySettings.useProxyAllProtocols (), res, uri});
        return res;
    }
    
    @Override
    public void connectFailed (URI arg0, SocketAddress arg1, IOException arg2) {
        LOG.log  (Level.INFO, "connectionFailed(" + arg0 + ", " + arg1 +")", arg2);
    }

    // several modules listenes on these properties and propagates it futher
    private class ProxySettingsListener implements PreferenceChangeListener {
        @Override
        public void preferenceChange(PreferenceChangeEvent evt) {
            if (evt.getKey ().startsWith ("proxy") || evt.getKey ().startsWith ("useProxy")) {
                copySettingsToSystem ();
            }
        }
    }
    
    private void copySettingsToSystem () {
        String host = null, port = null, nonProxyHosts = null;
        String socksHost = null, socksPort = null;
        String httpsHost = null, httpsPort = null;
        int proxyType = ProxySettings.getProxyType ();
        switch (proxyType) {
            case ProxySettings.DIRECT_CONNECTION:
                host = null;
                port = null;
                httpsHost = null;
                httpsPort = null;
                nonProxyHosts = null;
                socksHost = null;
                socksPort = null;
                break;
            case ProxySettings.AUTO_DETECT_PROXY:
                host = ProxySettings.getSystemHttpHost();
                port = ProxySettings.getSystemHttpPort();
                httpsHost = ProxySettings.getSystemHttpsHost();
                httpsPort = ProxySettings.getSystemHttpsPort();
                socksHost = ProxySettings.getSystemSocksHost();
                socksPort = ProxySettings.getSystemSocksPort();
                nonProxyHosts = ProxySettings.getSystemNonProxyHosts();
                break;
            case ProxySettings.MANUAL_SET_PROXY:
                host = ProxySettings.getHttpHost ();
                port = ProxySettings.getHttpPort ();
                httpsHost = ProxySettings.getHttpsHost ();
                httpsPort = ProxySettings.getHttpsPort ();
                nonProxyHosts = ProxySettings.getNonProxyHosts ();
                socksHost = ProxySettings.getSocksHost ();
                socksPort = ProxySettings.getSocksPort ();
                break;
            case ProxySettings.AUTO_DETECT_PAC:
                host = null;
                port = null;
                httpsHost = null;
                httpsPort = null;
                nonProxyHosts = null;
                socksHost = null;
                socksPort = null;
                break;
            case ProxySettings.MANUAL_SET_PAC:
                host = null;
                port = null;
                httpsHost = null;
                httpsPort = null;
                nonProxyHosts = ProxySettings.getNonProxyHosts();
                socksHost = null;
                socksPort = null;
                break;
            default:
                assert false : "Invalid proxy type: " + proxyType;
        }
        setOrClearProperty ("http.proxyHost", host, false);
        setOrClearProperty ("http.proxyPort", port, true);
        setOrClearProperty ("http.nonProxyHosts", nonProxyHosts, false);
        setOrClearProperty ("https.proxyHost", httpsHost, false);
        setOrClearProperty ("https.proxyPort", httpsPort, true);
        setOrClearProperty ("https.nonProxyHosts", nonProxyHosts, false);
        setOrClearProperty ("socksProxyHost", socksHost, false);
        setOrClearProperty ("socksProxyPort", socksPort, true);
        LOG.log (Level.FINE, "Set System''s http.proxyHost/Port/NonProxyHost to {0}/{1}/{2}", new Object[]{host, port, nonProxyHosts});
        LOG.log (Level.FINE, "Set System''s https.proxyHost/Port to {0}/{1}", new Object[]{httpsHost, httpsPort});
        LOG.log (Level.FINE, "Set System''s socksProxyHost/Port to {0}/{1}", new Object[]{socksHost, socksPort});
    }
    
    private void setOrClearProperty (String key, String value, boolean isInteger) {
        assert key != null;
        if (value == null || value.length () == 0) {
            System.clearProperty (key);
        } else {
            if (isInteger) {
                try {
                    Integer.parseInt (value);
                } catch (NumberFormatException nfe) {
                    LOG.log (Level.INFO, nfe.getMessage(), nfe);
                }
            }
            System.setProperty (key, value);
        }
    }

    // package-private for unit-testing
    static boolean dontUseProxy (String nonProxyHosts, String host) {
        if (host == null) {
            return false;
        }
        
        // try IP adress first
        if (dontUseIp (nonProxyHosts, host)) {
            return true;
        } else {
            return dontUseHostName (nonProxyHosts, host);
        }

    }
    
    private static boolean dontUseHostName (String nonProxyHosts, String host) {
        if (host == null) {
            return false;
        }
        
        boolean dontUseProxy = false;
        StringTokenizer st = new StringTokenizer (nonProxyHosts, "|", false);
        while (st.hasMoreTokens () && !dontUseProxy) {
            String token = st.nextToken ().trim();
            int star = token.indexOf ("*");
            if (star == -1) {
                dontUseProxy = token.equals (host);
                if (dontUseProxy) {
                    LOG.log(Level.FINEST, "NbProxySelector[Type: {0}]. Host {1} found in nonProxyHosts: {2}", new Object[]{ProxySettings.getProxyType (), host, nonProxyHosts});
                }
            } else {
                String start = token.substring (0, star - 1 < 0 ? 0 : star - 1);
                String end = token.substring (star + 1 > token.length () ? token.length () : star + 1);

                //Compare left of * if and only if * is not first character in token
                boolean compareStart = star > 0; // not first character
                //Compare right of * if and only if * is not the last character in token
                boolean compareEnd = star < (token.length() - 1); // not last character
                dontUseProxy = (compareStart && host.startsWith(start)) || (compareEnd && host.endsWith(end));

                if (dontUseProxy) {
                    LOG.log(Level.FINEST, "NbProxySelector[Type: {0}]. Host {1} found in nonProxyHosts: {2}", new Object[]{ProxySettings.getProxyType (), host, nonProxyHosts});
                }
            }
        }
        return dontUseProxy;
    }
    
    private static boolean dontUseIp (String nonProxyHosts, String host) {
        if (host == null) {
            return false;
        }
                   
        String ip;        
        DnsTimeoutTask dns = new DnsTimeoutTask(host);
        // fix #189195 - timeout when waiting for DNS response
        RequestProcessor.Task create = RP.post(dns);
        try {
            create.waitFinished(DNS_TIMEOUT);
        } catch (InterruptedException ex) {
            LOG.log(Level.INFO, "Timeout when waiting for DNS response. ({0})", host);
        }
        
        ip = dns.getIp();
        
        if (ip == null) {
            return false;
        }

        boolean dontUseProxy = false;
        StringTokenizer st = new StringTokenizer (nonProxyHosts, "|", false);
        while (st.hasMoreTokens () && !dontUseProxy) {
            String nonProxyHost = st.nextToken ().trim();
            int star = nonProxyHost.indexOf ("*");
            if (star == -1) {
                dontUseProxy = nonProxyHost.equals (ip);
                if (dontUseProxy) {
                    LOG.log(Level.FINEST, "NbProxySelector[Type: {0}]. Host''s IP {1} found in nonProxyHosts: {2}", new Object[]{ProxySettings.getProxyType (), ip, nonProxyHosts});
                }
            } else {
                // match with given dotted-quad IP
                try {
                    dontUseProxy = Pattern.matches (nonProxyHost, ip);
                    if (dontUseProxy) {
                        LOG.log(Level.FINEST, "NbProxySelector[Type: {0}]. Host''s IP{1} found in nonProxyHosts: {2}", new Object[]{ProxySettings.getProxyType (), ip, nonProxyHosts});
                    }
                } catch (PatternSyntaxException pse) {
                    // may ignore it here
                }
            }
        }
        return dontUseProxy;
    }
    
    static boolean useSystemProxies() {
        if (useSystemProxies == null) {
            final String netPropertiesFN = "net.properties"; // NOL10N
            final String propertyKey = "java.net.useSystemProxies"; // NOL10N

            Properties props = new Properties();
            String fname = System.getProperty("java.home");

            if (fname == null) {
                return false;
            }
            
            try {
                // JDK 8 and older
                File folder = new File(fname, "lib");
                File netProperties = new File(folder, netPropertiesFN);

                if (!netProperties.exists()) {
                    // JDK 9 and newer
                    folder = new File(fname, "conf");
                    netProperties = new File(folder, netPropertiesFN);
                }

                fname = netProperties.getCanonicalPath();
                try (InputStream bin = new BufferedInputStream(Files.newInputStream(Paths.get(fname)))) {
                    props.load(bin);
                }

                String val = props.getProperty(propertyKey);
                val = System.getProperty(propertyKey, val);
                
                useSystemProxies = Boolean.valueOf(val);
            } catch (Exception e) {
                // set default value
                useSystemProxies = false;
            }           
        }
        
        return useSystemProxies;
    }
    
    static boolean usePAC() {
        String pacFile = ProxySettings.getSystemPac();
        return pacFile != null;
    }
    
    private static String getPacFile() {
        return ProxySettings.getSystemPac();
    }
    
    private static class DnsTimeoutTask implements Runnable {

        private final String host;      
        private String ip = null;
        
        public DnsTimeoutTask(String host) {
            this.host = host;
        }
        
        @Override
        public void run() {
            try {
                ip = InetAddress.getByName(host).getHostAddress();
            } catch (UnknownHostException ex) {
                LOG.log(Level.FINE, ex.getLocalizedMessage(), ex);
            }
        }
        
        public String getIp() {
            return ip;
        }
    }
}
