/*
 * Copyright (C) 2009-2017 Alistair Neil <info@dazzleships.net>
 *
 * This program 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 2
 * of the License, or (at your option) any later version.
 *
 * This program 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, write to the Free Software
 * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
 */
package client;

import java.text.Collator;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.regex.Pattern;
import lib.Localisation;
import lib.SimpleFile;

/**
 *
 * @author Alistair Neil <info@dazzleships.net>
 */
public class NodeList {

    public static final int NODELIST_IDLE = 0;
    public static final int NODELIST_BUILDING = 1;
    public static final int NODELIST_BUILT = 2;
    public static final int NODELIST_FAILED = 3;
    public static final int NODELIST_TERMINATED = 4;
    private static final String EMPTYSTRING = "";
    private static final Localisation LOCAL = new Localisation("resources/MessagesBundle");
    private ExitNodeTableModel entm;
    private GuardNodeTableModel gntm;
    private int intStatus = NODELIST_IDLE;
    private HashMap<String, NodeItem> hmNode;
    private final HashMap<String, NodeItem> hmBridges;
    private final ArrayList<String> alCountries;
    private TorController tc;
    private final String exitFavouritesFile;
    private final String guardFavouritesFile;
    private final String filepath;
    private volatile boolean boolAbortActions;
    private final Pattern pattspace = Pattern.compile(" ");
    private int nooffavs;

    public NodeList(String filepath, String exitfavourites, String guardfavouritesfile) {
        this.hmNode = new HashMap<>();
        this.hmBridges = new HashMap<>();
        this.filepath = filepath;
        this.alCountries = new ArrayList<>();
        this.exitFavouritesFile = exitfavourites;
        this.guardFavouritesFile = guardfavouritesfile;
    }

    /**
     * Get node item from its fingerprint
     *
     * @param key or fingerprint
     * @return NodeItem
     */
    public final NodeItem getNode(String key) {
        String temp;
        NodeItem ni = hmNode.get(key);
        if (ni == null) {
            // No bridge found so must be a new node not currently in nodelist
            ArrayList<String> alRouterInfo = getRouterDesc(key);
            if (alRouterInfo != null) {
                alRouterInfo.addAll(getRouterStatus(key));
                ni = new NodeItem();
                temp = filterRouterInfo(alRouterInfo, "router ");
                if (temp == null) {
                    return null;
                }
                String[] data = pattspace.split(temp);
                ni.setFingerprint(key);
                ni.setNickName(data[0]);
                ni.setIPAddress(data[1]);
                String isocode = tc.getCountryFromIP(ni.getIPAddress());
                if (isocode == null || isocode.contentEquals("??")) {
                    isocode = "U1";
                }
                ni.setCountryCode(isocode);
                ni.setCountryName(LOCAL.getDisplayCountry(isocode));
                temp = filterRouterInfo(alRouterInfo, "bandwidth ");
                data = pattspace.split(temp);
                ni.setBandwidth(getLowestBandwidth(data));
                temp = filterRouterInfo(alRouterInfo, "s ");
                if (temp == null) {
                    // We probably have a bridge
                    ni.setType(NodeItem.TYPE_GUARD);
                    ni.setStable(LOCAL.getString("text_yes"));
                    return ni;
                }
                if (temp.contains("Guard")) {
                    ni.setType(NodeItem.TYPE_GUARD);
                }
                if (temp.contains("Stable")) {
                    ni.setStable(LOCAL.getString("text_yes"));
                } else {
                    ni.setStable(LOCAL.getString("text_no"));
                }
                if (temp.contains("Exit") && !temp.contains("BadExit")) {
                    ni.setType(NodeItem.TYPE_EXIT);
                    temp = filterRouterInfo(alRouterInfo, "p ");
                    if (temp.startsWith("accept")) {
                        temp = temp.replace("accept ", "");
                        if (!containsPort(temp, 80) && !containsPort(temp, 443)) {
                            return ni;
                        }
                    } else {
                        temp = temp.replace("reject ", "");
                        if (containsPort(temp, 80) || containsPort(temp, 443)) {
                            return ni;
                        }
                    }
                    ni.setHttpSupported(true);
                }
            }
        }
        return ni;
    }

    public ArrayList<String> getRouterDesc(String finger) {
        ArrayList<String> alResult = tc.getInfo("desc/id/" + finger);
        if (alResult == null) {
            return null;
        }
        alResult.remove("250 OK");
        if (alResult.isEmpty()) {
            return null;
        }
        return alResult;
    }

    public ArrayList<String> getRouterStatus(String finger) {
        ArrayList<String> alResult = tc.getInfo("ns/id/" + finger);
        if (alResult == null) {
            return null;
        }
        alResult.remove("250 OK");
        if (alResult.isEmpty()) {
            return null;
        }
        return alResult;
    }

    public String filterRouterInfo(ArrayList<String> alInfo, String field) {
        for (String s : alInfo) {
            if (s.startsWith(field)) {
                return s.replace(field, "");
            }
        }
        return null;
    }

    /**
     * Get the process status
     *
     * @return status
     */
    public final int getCurrentStatus() {
        return intStatus;
    }

    /**
     * Generate the nodelist
     *
     * @param tc
     */
    public final void refreshNodelist(TorController tc) {
        boolAbortActions = false;
        intStatus = NODELIST_BUILDING;
        this.tc = tc;
        boolean boolSuccess = populateNodeMap();
        if (boolSuccess) {
            boolSuccess = fetchFingerData();
        }
        if (boolSuccess) {
            intStatus = NODELIST_BUILT;
        } else if (boolAbortActions) {
            intStatus = NODELIST_TERMINATED;
        } else {
            intStatus = NODELIST_FAILED;
        }
    }

    /**
     * Update internal node mappings
     *
     */
    private boolean populateNodeMap() {
        String strFlags;
        String data[];
        String temp;
        String strNick;
        String strIP;
        String strCountryName;
        String strCountryCode;
        String strPerms;
        String strDirPort;
        String strOrPort;

        NodeItem ni;

        SimpleFile sfConsensus = new SimpleFile(tc.getDataFolder() + SimpleFile.getSeparator() + "cached-consensus");

        hmNode.clear();
        alCountries.clear();
        sfConsensus.openBufferedRead();
        while (!boolAbortActions) {
            temp = sfConsensus.readLine();
            // Test for end of node info file
            if (temp.startsWith("directory-footer")) {
                break;
            }

            // Test for node info line
            if (temp.startsWith("r ")) {
                data = pattspace.split(temp);
                strNick = data[1];
                strIP = data[6];
                strOrPort = data[7];
                strDirPort = data[8];

                // Read flags line
                while (true) {
                    strFlags = sfConsensus.readLine();
                    if (strFlags.startsWith("s ")) {
                        break;
                    }
                }
                // Ignore certain types of node
                if (!strFlags.contains("Running")
                        || !strFlags.contains("Valid")) {
                    continue;
                }

                // Read port permissions line
                while (true) {
                    strPerms = sfConsensus.readLine();
                    if (strPerms.startsWith("p ")) {
                        break;
                    }
                }

                // Check if Tor is fully active
                if (tc.getStatus() < TorController.STATUS_IDLE) {
                    boolAbortActions = false;
                    alCountries.clear();
                    break;
                }

                // Get country code from ip address
                strCountryCode = tc.getCountryFromIP(strIP);
                if (strCountryCode == null || strCountryCode.contains("??")) {
                    strCountryCode = "U1";
                }

                // If we get GEOIP errors then abort
                if (strCountryCode.startsWith("551 GEOIP")) {
                    alCountries.clear();
                    break;
                }

                // Get country name
                strCountryName = LOCAL.getDisplayCountry(strCountryCode);

                ni = new NodeItem();
                // Update our node item fields now that we have validated our node
                ni.setCountryCode(strCountryCode);
                ni.setCountryName(strCountryName);
                ni.setNickName(strNick);
                ni.setIPAddress(strIP);
                // Get stableflag
                if (strFlags.contains("Stable")) {
                    ni.setStable(LOCAL.getString("text_yes"));
                } else {
                    ni.setStable(LOCAL.getString("text_no"));
                }

                hmNode.put(strOrPort + strDirPort + ":" + strIP, ni);

                // Check if a guard node
                if (strFlags.contains("Guard")) {
                    ni.setType(NodeItem.TYPE_GUARD);
                }

                // Check if an exit node also exclude nodes flagged as bad exits
                if (strFlags.contains("Exit") && !strFlags.contains("BadExit")) {
                    ni.setType(NodeItem.TYPE_EXIT);
                    // Simple test on port permissions, we will only allow node
                    // if it accepts connections on port 80 and 443 for web browsing
                    if (strPerms.startsWith("p accept")) {
                        strPerms = strPerms.replace("p accept ", "");
                        if (!containsPort(strPerms, 80)) {
                            continue;
                        }
                        if (!containsPort(strPerms, 443)) {
                            continue;
                        }
                    } else {
                        strPerms = strPerms.replace("p reject ", "");
                        if (containsPort(strPerms, 80)) {
                            continue;
                        }
                        if (containsPort(strPerms, 443)) {
                            continue;
                        }
                    }
                    ni.setHttpSupported(true);

                    // Ensure we only add exit country info in once to validated countries list
                    if (!alCountries.contains(strCountryCode + "," + strCountryName)) {
                        alCountries.add(strCountryCode + "," + strCountryName);
                    }
                }
            }
        }
        sfConsensus.closeFile();

        // Sort our country validation list
        if (alCountries.isEmpty()) {
            return false;
        } else {
            Collections.sort(alCountries, Collator.getInstance());
        }
        return !boolAbortActions;
    }

    /**
     * Update internal finger mappings
     *
     */
    private boolean fetchFingerData() {
        int idx;
        String line;
        String data[];
        String ipaddress;
        String finger;
        String strOrPort;
        String strDirPort;
        String key;
        boolean result;

        String guardwhitelist = getGuardFavouritesAsCSV();
        NodeItem ni;
        HashMap<String, NodeItem> hmNodeReplacement = new HashMap<>();

        SimpleFile sfDescriptors = new SimpleFile(tc.getDataFolder() + SimpleFile.getSeparator() + "cached-descriptors");
        SimpleFile sfDescriptorsNew = new SimpleFile(tc.getDataFolder() + SimpleFile.getSeparator() + "cached-descriptors.new");
        if (!sfDescriptorsNew.exists() || sfDescriptorsNew.getFile().length() == 0) {
            sfDescriptorsNew = null;
        }

        sfDescriptors.openBufferedRead();
        try {
            while (!boolAbortActions) {
                line = sfDescriptors.readLine();
                // If line is null we have then processed the main descriptors file
                if (line == null) {
                    // Check if their is a descriptors new file
                    if (sfDescriptorsNew == null) {
                        break;
                    } else {
                        sfDescriptors.closeFile();
                        sfDescriptors = sfDescriptorsNew;
                        sfDescriptors.openBufferedRead();
                        sfDescriptorsNew = null;
                        continue;
                    }
                }

                if (line.startsWith("router ")) {
                    data = pattspace.split(line);
                    ipaddress = data[2];
                    strOrPort = data[3];
                    strDirPort = data[5];
                    key = strOrPort + strDirPort + ":";
                    if (!hmNode.containsKey(key + ipaddress)) {
                        continue;
                    }

                    // Get fingerprint
                    while (true) {
                        line = sfDescriptors.readLine();
                        idx = line.indexOf("fingerprint");
                        if (idx > -1) {
                            line = line.substring(idx + 12);
                            break;
                        }
                    }
                    finger = "$" + line.replace(" ", EMPTYSTRING);

                    // Get bandwidth
                    while (true) {
                        line = sfDescriptors.readLine();
                        idx = line.indexOf("bandwidth");
                        if (idx > -1) {
                            line = line.substring(idx + 10);
                            break;
                        }
                    }
                    data = pattspace.split(line);
                    ni = hmNode.get(key + ipaddress);
                    ni.setFingerprint(finger);
                    ni.setGuardEnabled(guardwhitelist.contains(finger));
                    ni.setBandwidth(getLowestBandwidth(data));
                    hmNodeReplacement.put(finger, ni);
                }
            }
            result = !boolAbortActions;
        } catch (Exception ex) {
            result = false;
        }
        sfDescriptors.closeFile();
        // Replace our original ipaddress keyed nodemap with our fingerprint keyed nodemap
        hmNode = hmNodeReplacement;
        return result;
    }

    /**
     * Return the lowest bandwidth
     *
     * @param data
     * @return bandwidth as float
     */
    private float getLowestBandwidth(String[] data) {
        float bw = 1000000;
        float tmp;
        for (String s : data) {
            tmp = Float.parseFloat(s) / 1000000;
            if (tmp < bw) {
                bw = tmp;
            }
        }
        return bw;
    }

    /**
     * Add newly learned bridge to nodelist
     *
     * @param bridgedata
     */
    public void addBridge(String bridgedata) {
        Pattern pat = Pattern.compile(" ");
        bridgedata = bridgedata.substring(bridgedata.indexOf('$'));
        String[] data = pat.split(bridgedata);
        int index = data[0].indexOf('~');
        if (index < 0) {
            index = data[0].indexOf('=');
        }
        NodeItem ni = new NodeItem();
        ni.setFingerprint(data[0].substring(0, index));
        ni.setIPAddress(data[2]);
        ni.setNickName(data[0].substring(index + 1));
        hmBridges.put(ni.getFingerprint(), ni);
    }

    /**
     * Clear any learned bridges
     */
    public void clearBridges() {
        hmBridges.clear();
    }

    /**
     * Test to check supplied data contains the specified port
     *
     * @param data
     * @param port
     * @return True if data contains specified port
     */
    private boolean containsPort(String data, int port) {
        String strRanges[];
        String[] strPorts;
        int rangemin;
        int rangemax;
        Pattern pattcomma = Pattern.compile(",");
        Pattern pattminus = Pattern.compile("-");
        strRanges = pattcomma.split(data);
        for (String s : strRanges) {
            // Test to see if its a range of ports
            if (s.contains("-")) {
                strPorts = pattminus.split(s);
                rangemin = Integer.parseInt(strPorts[0]);
                rangemax = Integer.parseInt(strPorts[1]);
                if (port >= rangemin && port <= rangemax) {
                    return true;
                }
            } else {
                // We have a single port
                rangemin = Integer.parseInt(s);
                if (port == rangemin) {
                    return true;
                }
            }
        }
        return false;
    }

    /**
     * Gets validated countries, a valid country is any country with active exit
     * nodes
     *
     * @return String array of countries
     */
    public final String[] getValidatedCountries() {
        return alCountries.toArray(new String[alCountries.size()]);
    }

    /**
     * Gets validated country codes, a valid country is any country with active
     * exit nodes
     *
     * @return String array of country abbreviations
     */
    public final String[] getValidatedCountryCodes() {
        String[] result = new String[alCountries.size()];
        int idx = 0;
        for (String s : alCountries) {
            result[idx++] = s.substring(0, 2);
        }
        return result;
    }

    /**
     * Set the guard node view table model
     *
     * @param gntm
     */
    public final void setGuardNodeTableModel(GuardNodeTableModel gntm) {
        this.gntm = gntm;
    }

    /**
     * Set the exit node view table model
     *
     * @param entm
     */
    public final void setExitNodeTableModel(ExitNodeTableModel entm) {
        this.entm = entm;
    }

    /**
     * Get a list of exitnodes, if all is false then it returns only favourited
     * nodes, if omitfailednodes is true then don't include nodes that failed
     * testing
     *
     * @param all
     * @param omitfailednodes
     * @return ArrayList of exit nodes
     */
    public final ArrayList<String> getExitNodes(boolean all, boolean omitfailednodes) {
        ArrayList<String> al = new ArrayList<>();
        for (int i = 0; i < entm.getRowCount(); i++) {
            int teststatus = (Integer) entm.getValueAt(i, 7);
            if (omitfailednodes && teststatus == NodeItem.TESTSTATUS_FAILED) {
                continue;
            }
            boolean fave = (Boolean) entm.getValueAt(i, 4) | all;
            if (fave) {
                al.add((String) entm.getValueAt(i, 5));
            }
        }
        return al;
    }

    /**
     * Get a string of comma separated exitnodes, if all is false then it
     * returns only favourited nodes, if omitfailednodes is true then don't
     * include nodes that failed testing
     *
     * @param all
     * @param omitfailednodes
     * @return String of exitnodes in csv format
     */
    public final String getExitNodesAsString(boolean all, boolean omitfailednodes) {
        String result = getExitNodes(all, omitfailednodes).toString();
        result = result.replace("[", "").replace(" ", "").replace("]", "");
        return result;
    }

    /**
     * Get a list of all guard nodes guard nodes
     *
     * @return ArrayList of guard nodes
     */
    public final ArrayList<String> getGuardNodes() {
        ArrayList<String> al = new ArrayList<>();
        if (gntm == null) {
            return al;
        }
        for (int i = 0; i < gntm.getRowCount(); i++) {
            al.add((String) gntm.getValueAt(i, 3));
        }
        return al;
    }

    /**
     * Get guard favourites
     *
     * @return CSV string of guard entry fingerprints
     */
    public final String getGuardFavouritesAsCSV() {
        StringBuilder sbResult = new StringBuilder();
        String line;
        String sep = "";
        if (guardFavouritesFile == null) {
            return sbResult.toString();
        }
        SimpleFile sf = new SimpleFile(filepath + guardFavouritesFile);
        if (!sf.exists()) {
            return sbResult.toString();
        }
        sf.openBufferedRead();
        while ((line = sf.readLine()) != null) {
            sbResult.append(sep);
            sbResult.append(line);
            if (sep.isEmpty()) {
                sep = ",";
            }
        }
        sf.closeFile();
        return sbResult.toString();
    }

    /**
     * Update the guard node table model
     */
    public final void refreshGuardTableModel() {
        if (gntm == null) {
            return;
        }
        NodeItem ni;
        gntm.clear();
        // Populate model
        for (String s : hmNode.keySet()) {
            ni = hmNode.get(s);
            if (ni.isGuard()) {
                Object[] rowData = new Object[]{ni};
                gntm.addRow(rowData);
            }
        }
    }

    /**
     * Update the table model based on supplied country
     *
     *
     * @param isocountry in the format "GB,Great Britain"
     */
    public final void refreshExitTableModel(String isocountry) {
        if (entm == null) {
            return;
        }
        NodeItem ni;
        entm.clear();
        String abbrv = isocountry;
        String favourites = "";
        if (exitFavouritesFile != null) {
            SimpleFile sf = new SimpleFile(filepath + exitFavouritesFile);
            if (sf.exists()) {
                sf.openBufferedRead();
                favourites = sf.readEntireFile().trim();
                sf.closeFile();
            }
        }

        // Populate model
        nooffavs = 0;
        for (String s : hmNode.keySet()) {
            ni = hmNode.get(s);
            if (ni.isExit() && ni.isHttpSupported() && ni.getCountryCode().contentEquals(abbrv)) {
                ni.setLatency(9999);
                ni.setTestingMessage(LOCAL.getString("textfield_unknown"));
                // Whitelisting here
                ni.setFavouriteEnabled(favourites.contains(ni.getIPAddress()));
                if (ni.isFavourite()) {
                    nooffavs++;
                }
                Object[] rowData = new Object[]{ni};
                entm.addRow(rowData);
            }
        }
    }

    /**
     * Get the number of active favourites
     *
     * @return number of favs as int
     */
    public int getNumberOfFavs() {
        return nooffavs;
    }

    /**
     * Ensures any threaded actions will terminate themselves
     */
    public final void terminate() {
        boolAbortActions = true;
    }

    /**
     * Save exit node whitelist
     */
    public void saveExitFavourites() {
        if (exitFavouritesFile == null) {
            return;
        }
        SimpleFile sf = new SimpleFile(filepath + exitFavouritesFile);
        sf.openBufferedWrite();
        NodeItem ni;
        nooffavs = 0;
        for (String s : hmNode.keySet()) {
            ni = hmNode.get(s);
            if (ni.isExit() && ni.isFavourite()) {
                nooffavs++;
                sf.writeFile(ni.getIPAddress(), 1);
            }
        }
        sf.closeFile();
    }

    /**
     * Save exit node blacklist
     *
     * @return number of active guards
     */
    public int saveGuardWhitelist() {
        int activeguards = 0;
        if (guardFavouritesFile == null) {
            return activeguards;
        }
        SimpleFile sf = new SimpleFile(filepath + guardFavouritesFile);
        sf.openBufferedWrite();
        NodeItem ni;
        for (String s : hmNode.keySet()) {
            ni = hmNode.get(s);
            if (ni.isGuard() && ni.isGuardEnabled()) {
                sf.writeFile(ni.getFingerprint(), 1);
                activeguards++;
            }
        }
        sf.closeFile();
        return activeguards;
    }

}
