/*
 * 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.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.URI;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Pattern;
import javax.swing.JTextArea;
import javax.swing.text.BadLocationException;
import lib.ClientProcess;
import lib.SimpleFile;

/**
 *
 * @author Alistair Neil <info@dazzleships.net>
 */
public class TorProcess extends ClientProcess {

    public static final int LOG_DEBUG = 0;
    public static final int LOG_INFO = 1;
    public static final int LOG_NOTICE = 2;
    private static final String TORCONFIGFILE = "torrc";
    public static final String EMPTYSTRING = "";

    // Event constants
    public static final int TOR_MESSAGE = 0;
    public static final int TOR_BOOT_TIMEOUT = 1;
    public static final int TOR_BOOT_FATAL = 2;
    public static final int TOR_BOOT_ERROR = 3;
    public static final int TOR_CLOCK_ERROR = 4;
    public static final int TOR_NOROUTE = 5;
    public static final int TOR_BOOTED = 6;
    public static final int TOR_RESTARTED = 7;
    public static final int TOR_NOEXITS = 8;
    public static final int TOR_STOPPED = 9;
    public static final int TOR_BRIDGE = 10;
    public static final int TOR_NEWCIRC = 11;
    public static final int TOR_DIRINFO_STALE = 12;
    public static final int TOR_NOHOP0 = 13;
    public static final int TOR_NONET_ACTIVITY = 14;
    private static final String[] EVENTMESSAGES = new String[]{
        "TOR_MESSAGE", "TOR_BOOT_TIMEOUT", "TOR_BOOT_FATAL", "TOR_BOOT_ERROR",
        "TOR_CLOCK_ERROR", "TOR_NOROUTE", "TOR_BOOTED", "TOR_RESTARTED",
        "TOR_NOEXITS", "TOR_STOPPED", "TOR_BRIDGE", "TOR_NEWCIRC",
        "TOR_DIRINFO_STALE", "TOR_NOHOP0", "TOR_NONET_ACTIVITY"
    };

    private final LinkedHashMap<String, String> lhmCLIOptions;
    private final LinkedHashMap<String, String> lhmTorrcOptions;
    private final String strClientLocation;
    private final String strConfigFolder;
    private String strSecret;
    private String strCachedDataFolder;
    private String invCommas = "\"";
    private String strExternalArgs = "";
    private String strBridges = "";
    private int intListenPort;
    private int intInitialBootEvent;
    private int loglev = LOG_NOTICE;
    private float version = 9999;
    private int maxlines = 50;
    private int nolines;
    private JTextArea jtxtstdout;
    private boolean boolSilentBoot;

    /**
     * Constructor
     *
     * @param clientpath Path to Tor client
     * @param configfolder Filepath to torrc
     */
    public TorProcess(String clientpath, String configfolder) {
        setSilentBootEnabled(false);
        strClientLocation = clientpath;
        this.intInitialBootEvent = TOR_BOOTED;
        this.strConfigFolder = configfolder;
        this.lhmCLIOptions = new LinkedHashMap<>();
        this.lhmTorrcOptions = new LinkedHashMap<>();
        // All our initialisation
        if (SimpleFile.getSeparator().compareTo("/") == 0) {
            this.invCommas = "";
        }
    }

    /**
     * Starts Tor process, and issues booted event on completion
     *
     */
    public final void startProcess() {
        setStartupTimeout(45);
        // Cache age check
        float age = getCacheAge();
        Logger.getGlobal().logp(Level.INFO, TorProcess.class.getName(),
                "start() on Port=" + getListenPort(), "Cache age = " + (int) age + " minutes.");
        if (age > 180) {
            deleteCacheData();
            Logger.getGlobal().logp(Level.INFO, TorProcess.class.getName(),
                    "start() on Port=" + getListenPort(), "Cache stale, deleting old cache data.");
        }
        // Some essential initialisation
        String strConfig = strClientLocation
                + " -f " + invCommas
                + getConfigFilePath()
                + invCommas
                + " " + getCLIOptionsAsString()
                + " " + strExternalArgs;
        super.start(strConfig);
    }

    /**
     * Set the Tor stdout log level
     *
     * @param lev
     */
    public final void setLogLevel(int lev) {
        loglev = lev;
    }

    /**
     * Set external user provided startup arguments
     *
     * @param torargs
     */
    public final void setExternalArgs(String torargs) {
        strExternalArgs = torargs;
    }

    /**
     * Set the listening port
     *
     * @param port
     */
    public final void setListenPort(int port) {
        // Setup listening port and control port
        if (intListenPort == port) {
            return;
        }
        intListenPort = port;
        setCLIOption("SocksPort", String.valueOf(port));
        setCLIOption("ControlPort", String.valueOf(port + 1));
        // Finish of by creating the default config file and data folders
        createDataFolder();
    }

    /**
     * Set the event that is issued on process boot completion
     *
     * @param event
     */
    public final void setInitialBootEvent(int event) {
        intInitialBootEvent = event;
    }

    /**
     * Client event handler, can be overriden by parent class
     *
     * @param data line data from standard output of Tor client
     */
    @Override
    public final void clientProcessEventFired(String data) {

        Logger.getGlobal().logp(Level.FINER, TorProcess.class.getName(),
                "clientProcessEventFired() on Port=" + getListenPort(), data);

        if (!data.isEmpty()) {
            appendStdout(data);
        }

        // Check for process stopped
        if (getClientStatus() == CLIENT_STOPPED) {
            torProcessEventFired(TOR_STOPPED, null);
            return;
        }

        // Check for timeout
        if (getClientStatus() == CLIENT_TIMEDOUT) {
            torProcessEventFired(TOR_BOOT_TIMEOUT, null);
            return;
        }

        if (getClientStatus() == CLIENT_RUNNING) {
            // Check to see if we need to copy cache
            if (strCachedDataFolder != null) {
                SimpleFile.copyFolderContents(strCachedDataFolder, getDataFolder() + SimpleFile.getSeparator(), "torrc");
                strCachedDataFolder = null;
            }
            // Check for fatal tor process startup errors
            if (data.contains("[warn] Our clock")) {
                torProcessEventFired(TOR_CLOCK_ERROR, data);
                return;
            }
            // Check for errors
            if (data.contains("exception") || data.contains("[err]")) {
                torProcessEventFired(TOR_BOOT_FATAL, data);
                return;
            }
            // Check for network unreachable
            if (data.contains("NOROUTE")) {
                torProcessEventFired(TOR_NOROUTE, data);
                return;
            }
            // Check new tor bridge
            if (data.contains("[notice] new bridge")) {
                torProcessEventFired(TOR_BRIDGE, data);
                return;
            }
            // Check for conditions that may prevent circuit building
            if (data.contains("directory information is no longer up-to-date")) {
                torProcessEventFired(TOR_DIRINFO_STALE, data);
                return;
            }
            // Check for conditions that will prevent circuit building
            if (data.contains("All routers are down")) {
                torProcessEventFired(TOR_NOEXITS, data);
                return;
            }
            // Check for Tor retrying on new circuit
            if (data.contains("Retrying on a new circuit")) {
                torProcessEventFired(TOR_NEWCIRC, data);
                return;
            }
            // Check for Tor failed to find hop zero
            if (data.contains("Failed to find node for hop 0")) {
                torProcessEventFired(TOR_NOHOP0, data);
                return;
            }
            // Check for no net activity
            if (data.contains("Tor has not observed any network activity")) {
                torProcessEventFired(TOR_NONET_ACTIVITY, data);
                return;
            }
            // Check for a bootstrap message
            if (data.contains("Bootstrapped")) {
                // If silent then dont fire bootstrapping messages
                if (!boolSilentBoot) {
                    torProcessEventFired(TOR_MESSAGE, data.substring(data.indexOf(']') + 2));
                }
                int percent = getPercentage(data);
                // Set startup timeout based on percentage of Tor progression
                if (percent >= 15) {
                    setStartupTimeout(60);
                }
                if (percent >= 40) {
                    setStartupTimeout(120);
                }
                if (percent >= 80) {
                    setStartupTimeout(30);
                }
                // Check for initial bootup completion
                if (percent >= 100) {
                    setStartupTimeout(-1);
                    torProcessEventFired(intInitialBootEvent, null);
                    intInitialBootEvent = TOR_BOOTED;
                    setSilentBootEnabled(false);
                }
            }
        }
    }

    /**
     * Get percentage value from bootstrap message
     *
     * @param data
     * @return percentage as and int
     */
    private int getPercentage(String data) {
        int result = -1;
        int idx1 = data.indexOf("Bootstrapped ");
        if (idx1 > -1) {
            idx1 += 13;
            int idx2 = data.indexOf('%', idx1);
            if (idx2 > -1) {
                String temp = data.substring(idx1, idx2);
                result = Integer.parseInt(temp);
            }
        }
        return result;
    }

    /**
     * Enable/Disable bootstrap message events on startup
     *
     * @param enabled
     */
    public final void setSilentBootEnabled(boolean enabled) {
        boolSilentBoot = enabled;
    }

    /**
     * Get textual representation on an event
     *
     * @param event
     * @return Event as text
     */
    public String getEventMessage(int event) {
        return EVENTMESSAGES[event];
    }

    /**
     * Called if an event was fired, will be overidden by sub class
     *
     * @param event
     * @param data
     */
    public void torProcessEventFired(int event, String data) {
    }

    public final void setControlPassword(String secret, String hashpass) {
        strSecret = secret;
        setCLIOption("hashedcontrolpassword", hashpass);
    }

    /**
     * Get the currently set authentification secret
     *
     * @return String
     */
    public final String getSecret() {
        return strSecret;
    }

    /**
     * Set Tor bridges, supports multiple addresses
     *
     * @param bridges
     */
    public final void setBridges(String bridges) {

        clearCLIOption("UseBridges");
        clearCLIOption("Bridge");
        clearCLIOption("UpdateBridgesFromAuthority");
        strBridges = "";
        if (bridges == null || bridges.isEmpty()) {
            return;
        }
        StringBuilder sbBridgesOption = new StringBuilder();
        String sep = "";
        String[] arrBridges = Pattern.compile(",").split(bridges);
        for (String s : arrBridges) {
            sbBridgesOption.append(sep);
            sbBridgesOption.append(s);
            if (sep.isEmpty()) {
                sep = " --Bridge ";
            }
        }
        strBridges = bridges;
        setCLIOption("UseBridges", "1");
        setCLIOption("UpdateBridgesFromAuthority", "1");
        setCLIOption("Bridge", sbBridgesOption.toString());
    }

    /**
     * Validate bridge addresses
     *
     * @param bridges
     * @return true if valid
     */
    public boolean validateBridges(String bridges) {
        if (bridges.isEmpty()) {
            return true;
        }
        String[] arrBridges = Pattern.compile(",").split(bridges);
        for (String s : arrBridges) {
            if (!validateHostPort(s)) {
                return false;
            }
        }
        return true;
    }

    /**
     * Validate a host:port ipv4 address
     *
     * @param hostport
     * @return true if valid
     */
    public final boolean validateHostPort(String hostport) {

        try {
            URI uri = new URI("my://" + hostport); // may throw URISyntaxException
            if (uri.getHost() == null || uri.getPort() == -1) {
                return false;
            }
        } catch (Exception ex) {
            return false;
        }
        return true;
    }

    /**
     * Get bridges
     *
     * @return bridges as csv string
     */
    public final String getBridges() {
        return strBridges;
    }

    /**
     * Set Ownership process id, useful for proper process termination in event
     * of a crash
     *
     * @param processid
     */
    public void setOwnershipID(String processid) {
        this.setCLIOption("__OwningControllerProcess", processid);
    }

    /**
     * Return the currently set process ownership ID
     *
     * @return String process id
     */
    public String getOwnershipID() {
        return getCLIOptions("__OwningControllerProcess");
    }

    /**
     * Set the path to the geoip file
     *
     * @param filepath File location
     */
    public final void setGeoIP4(String filepath) {
        if (filepath != null) {
            if (SimpleFile.exists(filepath)) {
                setTorrcOption("GeoIPFile", invCommas + filepath + invCommas);
            } else {
                clearTorrcOption("GeoIPFile");
            }
        } else {
            clearTorrcOption("GeoIPFile");
        }
    }

    /**
     * Set the path to the geoip file
     *
     * @param filepath File location
     */
    public final void setGeoIP6(String filepath) {
        if (filepath != null) {
            if (SimpleFile.exists(filepath)) {
                setTorrcOption("GeoIPv6File", invCommas + filepath + invCommas);
            } else {
                clearTorrcOption("GeoIPv6File");
            }
        } else {
            clearTorrcOption("GeoIPv6File");
        }
    }

    /**
     * Get Tor client location as a filepath
     *
     * @return filepath as string
     */
    public final String getClientLocation() {
        return strClientLocation;
    }

    /**
     * Get the path to the configuration file
     *
     * @return String Path to configuration file
     */
    public final String getConfigFilePath() {
        return getDataFolder() + File.separator + TORCONFIGFILE;
    }

    /**
     * Get a previously added tor option string
     *
     * @param option Tor option key
     * @return String Tor option value
     */
    public final String getCLIOptions(String option) {
        return lhmCLIOptions.get(option);
    }

    /**
     * Add a tor option string which is passed to the tor client on startup. See
     * Tor documentation for valid options.
     *
     * @param option
     * @param value
     */
    public final void setCLIOption(String option, String value) {
        lhmCLIOptions.put(option, value);
    }

    /**
     * Add a tor option boolean value
     *
     * @param option
     * @param value
     */
    public final void setBoolTorOption(String option, boolean value) {
        lhmCLIOptions.remove(option);
        if (value) {
            lhmCLIOptions.put(option, "1");
        } else {
            lhmCLIOptions.put(option, "0");
        }
    }

    /**
     * Get a previously added tor option string
     *
     * @param option Tor option key
     * @return String Tor option value
     */
    public final String getTorrcOption(String option) {
        return lhmTorrcOptions.get(option);
    }

    /**
     * Add a torrc option string See Tor documentation for valid options.
     *
     * @param option
     * @param value
     */
    public final void setTorrcOption(String option, String value) {
        if (value.startsWith("\"")) {
            value = value.replace('\\', '/');
        }
        lhmTorrcOptions.put(option, value);
    }

    /**
     * Gets all the currently set torrc options as single String
     *
     * @return String Tor client formatted cli arguments
     */
    public final String getTorrcOptionsAsString() {
        Iterator<String> iterator = lhmTorrcOptions.keySet().iterator();
        String key;
        StringBuilder sbResult = new StringBuilder();
        while (iterator.hasNext()) {
            key = iterator.next();
            sbResult.append(key);
            sbResult.append(" ");
            sbResult.append(lhmTorrcOptions.get(key));
            sbResult.append("\r\n");
        }
        return sbResult.toString();
    }

    /**
     * Remove previously add torrc option
     *
     * @param option
     */
    public final void clearTorrcOption(String option) {
        lhmTorrcOptions.remove(option);
    }

    /**
     * Get a previously added tor option boolean value
     *
     * @param option Tor option key
     * @return Boolean value
     */
    public final boolean getCLIOptionBool(String option) {
        return lhmCLIOptions.get(option).contentEquals("1");
    }

    /**
     * Remove previously add tor option
     *
     * @param option
     */
    public final void clearCLIOption(String option) {
        lhmCLIOptions.remove(option);
    }

    /**
     * Gets all the currently set tor options as single String for use as
     * arguments passed to tor client
     *
     * @return String Tor cleint formatted cli arguments
     */
    public final String getCLIOptionsAsString() {
        Iterator<String> iterator = lhmCLIOptions.keySet().iterator();
        String key;
        String value;
        StringBuilder sbResult = new StringBuilder();
        while (iterator.hasNext()) {
            key = iterator.next();
            sbResult.append("--");
            sbResult.append(key);
            sbResult.append(" ");
            value = lhmCLIOptions.get(key);
            if (!value.isEmpty()) {
                sbResult.append(value);
                sbResult.append(" ");
            }
        }
        return sbResult.toString().trim();
    }

    /**
     * Get the listening port
     *
     * @return port
     */
    public final int getListenPort() {
        return intListenPort;
    }

    /**
     * Get the control port
     *
     * @return port
     */
    public final int getControlPort() {
        return intListenPort + 1;
    }

    /**
     * Creates the default Tor config file
     */
    public final void createDefaultConfig() {
        SimpleFile sfTorrc = new SimpleFile(getConfigFilePath());
        sfTorrc.openBufferedWrite();
        sfTorrc.writeFile(getTorrcOptionsAsString(), 0);
        switch (loglev) {
            case LOG_DEBUG:
                sfTorrc.writeFile("log debug stdout", 1);
                break;
            case LOG_INFO:
                sfTorrc.writeFile("log info stdout", 1);
                break;
            case LOG_NOTICE:
                sfTorrc.writeFile("log notice stdout", 1);
                break;
        }
        sfTorrc.writeFile(EMPTYSTRING, 1);
        sfTorrc.closeFile();
    }

    /**
     * Delete the configuration file
     */
    public final void deleteConfigFile() {
        SimpleFile.delete(getConfigFilePath());
    }

    /**
     * Creates a data folder for the Tor client to put its cache data
     */
    public final void createDataFolder() {
        String folder = getDataFolder();
        if (folder != null) {
            setTorrcOption("datadirectory", invCommas + getDataFolder() + invCommas);
            SimpleFile.createFolder(getDataFolder());
        }
    }

    /**
     * Get the datafolder being used by tor client
     *
     * @return Path to datafolder
     */
    public final String getDataFolder() {
        if (strConfigFolder == null) {
            return null;
        }
        return strConfigFolder + String.valueOf(intListenPort);
    }

    /**
     * Get the age of the file cache in minutes
     *
     * @return age of cache in minutes
     */
    public float getCacheAge() {

        String path = getDataFolder() + SimpleFile.getSeparator()
                + "cached-consensus";
        if (SimpleFile.exists(path)) {
            return SimpleFile.getAgeOfFile(path, SimpleFile.PERIOD_MINUTES);
        } else {
            return -1;
        }
    }

    /**
     * Delete Tor cache data
     */
    public final void deleteCacheData() {
        SimpleFile.secureWipe(getDataFolder() + SimpleFile.getSeparator() + "cached-consensus");
        SimpleFile.secureWipe(getDataFolder() + SimpleFile.getSeparator() + "cached-certs");
        SimpleFile.secureWipe(getDataFolder() + SimpleFile.getSeparator() + "cached-descriptors");
        SimpleFile.secureWipe(getDataFolder() + SimpleFile.getSeparator() + "cached-descriptors.new");
        SimpleFile.secureWipe(getDataFolder() + SimpleFile.getSeparator() + "lock");
        SimpleFile.secureWipe(getDataFolder() + SimpleFile.getSeparator() + "state");
    }

    /**
     * This populates the the current folder whose name is derived from the
     * listening port with data from the given source folder derived by the
     * given port number. This effectively allows each Tor client spawned to
     * have its Tor cache data copied from the first Tor client launched instead
     * of having to go to the net and fetch it and thus start up is a lot
     * faster. I was actually mildly suprised that this actually works.
     *
     * @param port
     */
    public final void setCachedDataFolder(int port) {
        if (port < 0) {
            strCachedDataFolder = null;
            return;
        }
        strCachedDataFolder = strConfigFolder + String.valueOf(port);
    }

    /**
     * Get the currently set cached data folder
     *
     * @return path to cached data folder as String
     */
    public final String getCachedDataFolder() {
        return strCachedDataFolder;
    }

    /**
     * Set the text area that will receive Stdout output
     *
     * @param jta
     */
    public void setStdoutTextArea(JTextArea jta) {
        jtxtstdout = jta;
    }

    /**
     * Set the maximum no of lines to display in the Stdout output
     *
     * @param lines
     */
    public void setMaxHistory(int lines) {
        maxlines = lines;
    }

    /**
     * Clear the Stdout text area
     */
    public void clearStdout() {
        if (jtxtstdout != null) {
            jtxtstdout.setText("");
        }
        nolines = 0;
    }

    /**
     * Append text to the StdOut text area
     *
     * @param text
     */
    private void appendStdout(String text) {
        if (jtxtstdout == null) {
            return;
        }
        jtxtstdout.append(text + "\n");
        if (++nolines > maxlines) {
            try {
                int end = jtxtstdout.getLineEndOffset(0);
                jtxtstdout.replaceRange("", 0, end);
            } catch (BadLocationException ex) {
            }
        }
        jtxtstdout.setCaretPosition(jtxtstdout.getText().length());
    }

    /**
     * Get Tor version as float
     *
     * @return Tor version as String
     */
    public final float getVersion() {
        if (version == 9999) {
            BufferedReader br;
            String strVer = "";
            Process proc;
            try {
                proc = Runtime.getRuntime().exec(strClientLocation + " --version");
                br = new BufferedReader(new InputStreamReader(proc.getInputStream()), 256);
                String line;
                while (true) {
                    line = br.readLine();
                    if (line == null) {
                        break;
                    }
                    strVer = line;
                }
                br.close();
                proc.destroy();
                proc.waitFor();
            } catch (IOException | InterruptedException ex) {
                Logger.getLogger(TorProcess.class.getName()).log(Level.SEVERE, null, ex);
            }
            int idx = strVer.indexOf("ion");
            if (idx > -1) {
                strVer = strVer.substring(idx + 4).replace(".", "");
                idx = strVer.indexOf(' ');
                if (idx > -1) {
                    strVer = strVer.substring(0, idx);
                }
                try {
                    version = Float.parseFloat("0." + strVer) * 10;
                } catch (Exception ex) {
                    Logger.getGlobal().logp(Level.SEVERE, this.getClass().getName(), "getVersion() Port=" + intListenPort, "", ex);
                }
            }
        }
        return version;
    }

}
